From dfbea7cb6c52fb95c0d8673c644d7326a174dac1 Mon Sep 17 00:00:00 2001 From: Andrew nuark G Date: Sun, 30 Apr 2023 17:14:42 +0700 Subject: [PATCH] Implemented insertion of new items into table --- lib/pages/dialogs/open_table_dialog.dart | 942 +++++++++++++++++++ lib/pages/home_panels/tables_list_panel.dart | 5 +- lib/utils.dart | 35 + lib/widgets/data_input_dialog.dart | 126 +++ 4 files changed, 1107 insertions(+), 1 deletion(-) create mode 100644 lib/pages/dialogs/open_table_dialog.dart create mode 100644 lib/widgets/data_input_dialog.dart diff --git a/lib/pages/dialogs/open_table_dialog.dart b/lib/pages/dialogs/open_table_dialog.dart new file mode 100644 index 0000000..39c8b49 --- /dev/null +++ b/lib/pages/dialogs/open_table_dialog.dart @@ -0,0 +1,942 @@ +import 'package:data_table_2/data_table_2.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_fast_forms/flutter_fast_forms.dart'; +import 'package:get/get.dart'; +import 'package:omni_datetime_picker/omni_datetime_picker.dart'; +import 'package:recase/recase.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:tuuli_api/tuuli_api.dart'; +import 'package:tuuli_app/api_controller.dart'; +import 'package:tuuli_app/models/db_column_definition.dart'; +import 'package:tuuli_app/models/user_definition.dart'; +import 'package:tuuli_app/utils.dart'; +import 'package:tuuli_app/widgets/data_input_dialog.dart'; + +class OpenTableController extends GetxController { + final TableDefinition table; + + OpenTableController({required this.table}); + + @override + void onInit() { + super.onInit(); + + refreshTableData(); + } + + final _userCache = {}.obs; + UserDefinition? getUserFromCache(int id) { + return _userCache[id]; + } + + void putUserInCache(UserDefinition user) { + _userCache[user.id] = user; + } + + final _assetsCache = {}.obs; + Asset? getAssetFromCache(int id) { + return _assetsCache[id]; + } + + void putAssetInCache(Asset asset) { + _assetsCache[asset.id] = asset; + } + + final _tableData = >[].obs; + List> get tableData => _tableData; + + final _newRowData = {}.obs; + Map get newRowData => _newRowData; + void setNewRowData(String key, dynamic value) { + _newRowData[key] = value; + } + + void clearNewRowData() { + _newRowData.clear(); + } + + Future refreshTableData() async { + try { + final resp = await ApiController.to.apiClient.getItemsFromTable( + tableName: table.tableName, + itemsSelector: const ItemsSelector( + fields: [ + "*", + ], + where: [], + ), + ); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + _tableData.clear(); + _tableData.addAll(respData); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to get table data", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to get table data", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to get table data", + "$e", + ); + } + } + + Future showUserPicker() async { + final username = "".obs; + + final user = await Get.dialog( + AlertDialog( + title: const Text("Select user"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + onChanged: (value) { + username.value = value; + }, + decoration: const InputDecoration( + labelText: "Username", + ), + ), + Obx( + () => FutureBuilder>( + future: () async { + if (username.value.isEmpty) { + return []; + } + + final resp = + await ApiController.to.apiClient.getItemsFromTable( + tableName: "users", + itemsSelector: ItemsSelector( + fields: [ + "id", + "username", + ], + where: [ + ColumnConditionCompat( + column: "username", + operator_: + ColumnConditionCompatOperator.contains, + value: username.value, + ) + ], + )); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + return respData + .map((e) => UserDefinition( + id: e["id"], + username: e["username"], + password: "", + accessToken: "", + )) + .toList(growable: false); + }(), + initialData: const [], + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text("${snapshot.error}"); + } + + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } + + final users = snapshot.data!; + + return SingleChildScrollView( + child: Column( + children: [ + for (final user in users) + ListTile( + title: Text(user.username), + onTap: () { + Get.back(result: user); + }, + ), + ], + ), + ); + }, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Get.back(result: false); + }, + child: const Text("Cancel"), + ), + ], + ), + ); + + return user; + } + + Future showAssetPicker() async { + final name = "".obs; + + final asset = await Get.dialog( + AlertDialog( + title: const Text("Select asset"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + onChanged: (value) { + name.value = value; + }, + decoration: const InputDecoration( + labelText: "Filename", + ), + ), + Obx( + () => FutureBuilder>( + future: () async { + if (name.value.isEmpty) { + return []; + } + + final resp = + await ApiController.to.apiClient.getItemsFromTable( + tableName: "assets", + itemsSelector: ItemsSelector( + fields: [ + "id", + "name", + "description", + ], + where: [ + ColumnConditionCompat( + column: "name", + operator_: + ColumnConditionCompatOperator.contains, + value: name.value, + ) + ], + )); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + return respData + .map((e) => Asset( + id: e["id"], + name: e["name"], + description: e["description"], + fid: "", + tags: "", + )) + .toList(growable: false); + }(), + initialData: const [], + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text("${snapshot.error}"); + } + + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } + + final assets = snapshot.data!; + + return SingleChildScrollView( + child: Column( + children: [ + for (final asset in assets) + ListTile( + title: Text(asset.name), + subtitle: Text(asset.description), + onTap: () { + Get.back(result: asset); + }, + ), + ], + ), + ); + }, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Get.back(result: null); + }, + child: const Text("Cancel"), + ), + ], + ), + ); + + return asset; + } + + Future addNewRow() async { + try { + final resp = await ApiController.to.apiClient.createItem( + tableName: table.tableName, + itemDefinition: convertToPayload(newRowData), + ); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + clearNewRowData(); + refreshTableData(); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to get table data", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to get table data", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to get table data", + "$e", + ); + } + } + + Future updateItem( + Map originalItem, + String columnName, + dynamic data, + ) async { + final idCol = table.parsedColumns + .firstWhereOrNull((e) => e is PrimarySerialColumnDefinition); + try { + final resp = await ApiController.to.apiClient.updateItemInTable( + tableName: table.tableName, + itemUpdate: ItemUpdate( + oldItem: idCol == null + ? convertToPayload(originalItem) + : { + idCol.name: originalItem[idCol.name], + }, + item: convertToPayload({columnName: data}), + ), + ); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + refreshTableData(); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to update table data", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to update table data", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to update table data", + "$e", + ); + } + } + + Future deleteItem(Map e) async { + final idCol = table.parsedColumns + .firstWhereOrNull((e) => e is PrimarySerialColumnDefinition); + try { + final resp = await ApiController.to.apiClient.deleteItemFromTable( + tableName: table.tableName, + columnConditionCompat: [ + if (idCol != null) + ColumnConditionCompat( + column: idCol.name, + operator_: ColumnConditionCompatOperator.eq, + value: e[idCol.name], + ) + else + ...convertToPayload(e).entries.map( + (e) => ColumnConditionCompat( + column: e.key, + operator_: ColumnConditionCompatOperator.eq, + value: e.value, + ), + ), + ], + ); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + refreshTableData(); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to update table data", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to update table data", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to update table data", + "$e", + ); + } + } +} + +class OpenTableDialog extends GetView { + const OpenTableDialog({super.key}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Row( + children: [ + Text(controller.table.tableName.pascalCase), + const Spacer(), + IconButton( + onPressed: () => controller.refreshTableData(), + icon: const Icon(Icons.refresh), + ), + IconButton( + onPressed: () { + Get.back(); + }, + icon: const Icon(Icons.close), + ), + ], + ), + content: Obx( + () => DataTable2( + columns: [ + for (final col in controller.table.parsedColumns) + DataColumn( + label: Text( + col.name.pascalCase, + ), + ), + const DataColumn(label: Text("Actions")), + ], + empty: const Text("No data"), + rows: [ + DataRow( + cells: [ + for (final col in controller.table.parsedColumns) + if (col is PrimarySerialColumnDefinition) + const DataCell( + Text( + "AUTO", + ), + ) + else if (col is TextColumnDefinition) + DataCell( + Obx( + () => TextField( + controller: TextEditingController( + text: controller.newRowData[col.name] ?? ""), + decoration: InputDecoration( + label: Text(col.name.pascalCase), + ), + onChanged: (value) => controller.setNewRowData( + col.name, + value, + ), + ), + ), + ) + else if (col is BooleanColumnDefinition) + DataCell( + Obx( + () => Row( + children: [ + Checkbox( + value: controller.newRowData[col.name] ?? false, + onChanged: (value) => controller.setNewRowData( + col.name, + value, + ), + ), + ], + ), + ), + ) + else if (col is TimestampColumnDefinition) + DataCell( + Obx( + () => TextField( + controller: TextEditingController( + text: () { + final dt = controller.newRowData[col.name]; + if (dt == null || dt is! DateTime) { + return ""; + } + + return postgresDateFormat(dt); + }(), + ), + decoration: InputDecoration( + label: Text(col.name.pascalCase), + ), + readOnly: true, + onTap: () async { + final dt = await showOmniDateTimePicker( + context: context, + is24HourMode: true, + isForce2Digits: true, + ); + if (dt != null) { + controller.setNewRowData( + col.name, + dt, + ); + } + }, + ), + ), + ) + else if (col is DoubleColumnDefinition) + DataCell( + Obx( + () => TextField( + controller: TextEditingController( + text: (controller.newRowData[col.name] as double?) + ?.toString() ?? + ""), + decoration: InputDecoration( + label: Text(col.name.pascalCase), + ), + onChanged: (value) => controller.setNewRowData( + col.name, + double.tryParse(value), + ), + ), + ), + ) + else if (col is IntegerColumnDefinition) + DataCell( + Obx( + () => TextField( + controller: TextEditingController( + text: (controller.newRowData[col.name] as int?) + ?.toString() ?? + ""), + decoration: InputDecoration( + label: Text(col.name.pascalCase), + ), + onChanged: (value) => controller.setNewRowData( + col.name, + int.tryParse(value), + ), + ), + ), + ) + else if (col is UserRefColumnDefinition) + DataCell(Obx( + () => TextField( + controller: TextEditingController( + text: (controller.newRowData[col.name] + as UserDefinition?) + ?.username ?? + ""), + decoration: InputDecoration( + label: Text(col.name.pascalCase), + ), + readOnly: true, + onTap: () async { + final user = await controller.showUserPicker(); + if (user == null) return; + + controller.setNewRowData( + col.name, + user, + ); + }, + ), + )) + else if (col is AssetRefColumnDefinition) + DataCell(Obx( + () => TextField( + controller: TextEditingController( + text: (controller.newRowData[col.name] as Asset?) + ?.name ?? + ""), + decoration: InputDecoration( + label: Text(col.name.pascalCase), + ), + readOnly: true, + onTap: () async { + final asset = await controller.showAssetPicker(); + if (asset == null) return; + + controller.setNewRowData( + col.name, + asset, + ); + }, + ), + )) + else + DataCell.empty, + DataCell(Row( + children: [ + IconButton( + onPressed: () { + controller.addNewRow(); + }, + icon: const Icon(Icons.add), + ), + IconButton( + onPressed: () => controller.clearNewRowData(), + icon: const Icon(Icons.clear), + ), + ], + )), + ], + ), + ...controller.tableData.map((e) { + return DataRow( + cells: [ + for (final col in controller.table.parsedColumns) + if (col is PrimarySerialColumnDefinition) + DataCell( + Tooltip( + message: e[col.name].toString(), + child: Text( + e[col.name].toString(), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ) + else if (col is TextColumnDefinition) + DataCell( + Tooltip( + message: e[col.name].toString(), + child: Text( + e[col.name].toString(), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + onDoubleTap: () async { + final text = await showStringInputDialog( + originalValue: e[col.name].toString()); + if (text != null) { + await controller.updateItem( + e, + col.name, + text, + ); + } + }, + ) + else if (col is BooleanColumnDefinition) + DataCell( + Checkbox( + value: e[col.name], + onChanged: (v) async { + await controller.updateItem( + e, + col.name, + v ?? false, + ); + }, + ), + ) + else if (col is TimestampColumnDefinition) + DataCell( + () { + final msg = () { + final dt = e[col.name]; + if (dt == null) { + return "#error#"; + } + if (dt is String) { + final rdt = DateTime.parse(dt); + return postgresDateFormat(rdt); + } + + return postgresDateFormat(dt); + }(); + return Tooltip( + message: msg, + child: Text( + msg, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ); + }(), + onDoubleTap: () async { + final dt = await showOmniDateTimePicker( + context: context, + is24HourMode: true, + isForce2Digits: true, + ); + if (dt != null) { + await controller.updateItem( + e, + col.name, + dt, + ); + } + }, + ) + else if (col is DoubleColumnDefinition) + DataCell( + Tooltip( + message: e[col.name].toString(), + child: Text( + e[col.name].toString(), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + onDoubleTap: () async { + final dblVal = await showDoubleInputDialog( + originalValue: e[col.name]); + if (dblVal != null) { + await controller.updateItem( + e, + col.name, + dblVal, + ); + } + }, + ) + else if (col is IntegerColumnDefinition) + DataCell( + Tooltip( + message: e[col.name].toString(), + child: Text( + e[col.name].toString(), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + onDoubleTap: () async { + final intVal = await showIntInputDialog( + originalValue: e[col.name]); + if (intVal != null) { + await controller.updateItem( + e, + col.name, + intVal, + ); + } + }, + ) + else if (col is UserRefColumnDefinition) + DataCell( + FutureBuilder( + future: () async { + final cachedUser = + controller.getUserFromCache(e[col.name]); + if (cachedUser != null) { + return cachedUser.username; + } + + final user = await ApiController.to.apiClient + .getItemsFromTable( + tableName: "users", + itemsSelector: ItemsSelector( + fields: ["username"], + where: [ + ColumnConditionCompat( + column: "id", + operator_: ColumnConditionCompatOperator.eq, + value: e[col.name], + ), + ], + ), + ); + + final ud = user.data; + if (ud == null || + ud.isEmpty || + ud.first["username"] == null) { + return "#error#"; + } + + controller.putUserInCache(UserDefinition( + id: e[col.name], + username: ud.first["username"], + password: "", + accessToken: "", + )); + + return ud.first["username"].toString(); + }(), + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return const CircularProgressIndicator(); + } + + return Tooltip( + message: snapshot.data ?? "#error#", + child: Text( + snapshot.data ?? "#error#", + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ); + }, + ), + onDoubleTap: () async { + final user = await controller.showUserPicker(); + if (user != null) { + await controller.updateItem( + e, + col.name, + user.id, + ); + } + }, + ) + else if (col is AssetRefColumnDefinition) + DataCell( + FutureBuilder( + future: () async { + final cachedAsset = + controller.getAssetFromCache(e[col.name]); + if (cachedAsset != null) { + return cachedAsset.name; + } + + final asset = await ApiController.to.apiClient + .getItemsFromTable( + tableName: "assets", + itemsSelector: ItemsSelector( + fields: ["name"], + where: [ + ColumnConditionCompat( + column: "id", + operator_: ColumnConditionCompatOperator.eq, + value: e[col.name], + ), + ], + ), + ); + + final ad = asset.data; + if (ad == null || + ad.isEmpty || + ad.first["name"] == null) { + return "#error#"; + } + + controller.putAssetInCache(Asset( + id: e[col.name], + name: ad.first["name"], + description: "", + fid: "", + tags: "", + )); + + return ad.first["name"].toString(); + }(), + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return const CircularProgressIndicator(); + } + + return Tooltip( + message: snapshot.data ?? "#error#", + child: Text( + snapshot.data ?? "#error#", + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ); + }, + ), + onDoubleTap: () async { + final asset = await controller.showAssetPicker(); + if (asset != null) { + await controller.updateItem( + e, + col.name, + asset.id, + ); + } + }, + ) + else + DataCell.empty, + DataCell(Row( + children: [ + IconButton( + onPressed: () => controller.deleteItem(e), + icon: const Icon(Icons.delete), + ), + ], + )), + ], + ); + }), + ], + ), + ).constrained(width: Get.width * 0.9, height: Get.height * 0.9), + ); + } + + static Future show(TableDefinition table) async { + Get.lazyPut(() => OpenTableController(table: table)); + + await Get.dialog( + const OpenTableDialog(), + barrierDismissible: false, + ); + } +} diff --git a/lib/pages/home_panels/tables_list_panel.dart b/lib/pages/home_panels/tables_list_panel.dart index ddc6770..109c376 100644 --- a/lib/pages/home_panels/tables_list_panel.dart +++ b/lib/pages/home_panels/tables_list_panel.dart @@ -6,6 +6,7 @@ import 'package:tuuli_app/api_controller.dart'; import 'package:recase/recase.dart'; import 'package:tuuli_app/models/db_column_definition.dart'; import 'package:tuuli_app/pages/dialogs/create_table_dialog.dart'; +import 'package:tuuli_app/pages/dialogs/open_table_dialog.dart'; class TablesListPanelController extends GetxController { @override @@ -62,7 +63,9 @@ class TablesListPanelController extends GetxController { } } - Future openTable(TableDefinition table) async {} + Future openTable(TableDefinition table) async { + await OpenTableDialog.show(table); + } } class TablesListPanel extends GetView { diff --git a/lib/utils.dart b/lib/utils.dart index 8aff8fb..19f90bb 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -1,5 +1,8 @@ import 'dart:math'; +import 'package:tuuli_api/tuuli_api.dart'; +import 'package:tuuli_app/models/user_definition.dart'; + Random _random = Random(); String randomHexString(int length) { @@ -9,3 +12,35 @@ String randomHexString(int length) { } return sb.toString(); } + +String postgresDateFormat(DateTime dt) { + int yearSign = dt.year.sign; + int absYear = dt.year.abs(); + String y = absYear + .toString() + .padLeft((dt.year >= -9999 && dt.year <= 9999) ? 4 : 6, "0"); + if (yearSign == -1) { + y = "-$y"; + } + String m = dt.month.toString().padLeft(2, "0"); + String d = dt.day.toString().padLeft(2, "0"); + String h = dt.hour.toString().padLeft(2, "0"); + String min = dt.minute.toString().padLeft(2, "0"); + String sec = dt.second.toString().padLeft(2, "0"); + + return "$y-$m-$d $h:$min:$sec"; +} + +Map convertToPayload(Map data) { + return data.map((key, value) { + if (value is UserDefinition) { + return MapEntry(key, value.id); + } else if (value is Asset) { + return MapEntry(key, value.id); + } else if (value is DateTime) { + return MapEntry(key, postgresDateFormat(value)); + } + + return MapEntry(key, value); + }); +} diff --git a/lib/widgets/data_input_dialog.dart b/lib/widgets/data_input_dialog.dart new file mode 100644 index 0000000..c406d9c --- /dev/null +++ b/lib/widgets/data_input_dialog.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_fast_forms/flutter_fast_forms.dart'; +import 'package:get/get.dart'; + +Future showStringInputDialog({String? originalValue}) async { + final strVal = (originalValue ?? "").obs; + return await Get.dialog( + AlertDialog( + title: const Text("Enter a string"), + content: TextField( + controller: TextEditingController(text: originalValue), + onChanged: (value) { + strVal.value = value; + }, + ), + actions: [ + TextButton( + onPressed: () { + Get.back(result: null); + }, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () { + Get.back(result: strVal.value); + }, + child: const Text("OK"), + ), + ], + ), + ); +} + +Future showDoubleInputDialog({double? originalValue}) async { + final strVal = (originalValue?.toString() ?? "").obs; + return await Get.dialog( + AlertDialog( + title: const Text("Enter a number"), + content: FastTextField( + name: "Number", + initialValue: originalValue?.toString(), + keyboardType: TextInputType.number, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) { + if (value == null || value.isEmpty) { + return "Please enter a number"; + } + final parsed = double.tryParse(value); + if (parsed == null) { + return "Please enter a valid number"; + } + return null; + }, + onChanged: (value) { + strVal.value = value ?? ""; + }, + ), + actions: [ + TextButton( + onPressed: () { + Get.back(result: null); + }, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () { + final d = double.tryParse(strVal.value); + if (d != null) { + Get.back(result: d); + } else { + Get.back(result: null); + } + }, + child: const Text("OK"), + ), + ], + ), + ); +} + +Future showIntInputDialog({int? originalValue}) async { + final strVal = (originalValue?.toString() ?? "").obs; + return await Get.dialog( + AlertDialog( + title: const Text("Enter a number"), + content: FastTextField( + name: "Number", + initialValue: originalValue?.toString(), + keyboardType: TextInputType.number, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) { + if (value == null || value.isEmpty) { + return "Please enter a number"; + } + final parsed = int.tryParse(value); + if (parsed == null) { + return "Please enter a valid number"; + } + return null; + }, + onChanged: (value) { + strVal.value = value ?? ""; + }, + ), + actions: [ + TextButton( + onPressed: () { + Get.back(result: null); + }, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () { + final i = int.tryParse(strVal.value); + if (i != null) { + Get.back(result: i); + } else { + Get.back(result: null); + } + }, + child: const Text("OK"), + ), + ], + ), + ); +}