From 6ba57612fac725153aa37d6e8ec65eae24e6638d Mon Sep 17 00:00:00 2001 From: Andrew nuark G Date: Wed, 12 Apr 2023 01:22:50 +0700 Subject: [PATCH] Items workflow done --- lib/api/api_client.dart | 152 ++++++++++++++ .../create_table_item_bottomsheet.dart | 198 ++++++++++++++++++ .../bottomsheets/open_table_bottomsheet.dart | 192 ++++++++++++++++- pubspec.lock | 26 ++- pubspec.yaml | 2 + 5 files changed, 565 insertions(+), 5 deletions(-) create mode 100644 lib/pages/bottomsheets/create_table_item_bottomsheet.dart diff --git a/lib/api/api_client.dart b/lib/api/api_client.dart index 80e4747..80a8abf 100644 --- a/lib/api/api_client.dart +++ b/lib/api/api_client.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:get/get.dart'; import 'package:tuuli_app/api/model/access_token_model.dart'; import 'package:http/browser_client.dart'; import 'package:http/http.dart'; @@ -23,6 +24,8 @@ class ErrorOrData { } typedef FutureErrorOrData = Future>; +typedef TableItemsData = Map; +typedef TableItemsDataList = List; class ApiClient { final BrowserClient _client = BrowserClient(); @@ -142,6 +145,155 @@ class ApiClient { return ErrorOrData(ignored, error); } + FutureErrorOrData getTableItems(TableModel table) async { + TableItemsDataList? data; + Exception? error; + + final response = await post( + '/items/${Uri.encodeComponent(table.tableName)}', + body: { + "fields": ["*"] + }, + headers: { + 'Content-Type': 'application/json', + }, + ); + if (response.statusCode == 200) { + final body = json.decode(await response.stream.bytesToString()) + as Map; + if (body['error'] != null) { + error = Exception(body['error']); + } else if (body['items'] == null) { + error = Exception('Server error'); + } else { + data = (body['items'] as List) + .map((e) => e as TableItemsData) + .toList(growable: false); + } + } else if (response.statusCode == 422) { + error = Exception('Invalid request parameters'); + } else { + error = Exception('HTTP ${response.statusCode}'); + } + + return ErrorOrData(data, error); + } + + FutureErrorOrData insertItem( + TableModel table, TableItemsData newItem) async { + bool? ignored; + Exception? error; + + final response = await post( + '/items/${Uri.encodeComponent(table.tableName)}/+', + body: newItem.map((key, value) => + MapEntry(key, value is DateTime ? value.toIso8601String() : value)), + headers: { + 'Content-Type': 'application/json', + }, + ); + if (response.statusCode == 200) { + final body = json.decode(await response.stream.bytesToString()); + if (body['error'] != null) { + error = Exception(body['error']); + } else { + ignored = true; + } + } else if (response.statusCode == 422) { + error = Exception('Invalid request parameters'); + } else { + error = Exception('HTTP ${response.statusCode}'); + } + + return ErrorOrData(ignored, error); + } + + FutureErrorOrData updateItem( + TableModel table, TableItemsData newItem, TableItemsData oldItem) async { + bool? ignored; + Exception? error; + + final response = await post( + '/items/${Uri.encodeComponent(table.tableName)}/*', + body: { + "item": newItem.map((key, value) => + MapEntry(key, value is DateTime ? value.toIso8601String() : value)), + "oldItem": oldItem.map((key, value) => + MapEntry(key, value is DateTime ? value.toIso8601String() : value)), + }, + headers: { + 'Content-Type': 'application/json', + }, + ); + if (response.statusCode == 200) { + final body = json.decode(await response.stream.bytesToString()); + if (body['error'] != null) { + error = Exception(body['error']); + } else { + ignored = true; + } + } else if (response.statusCode == 422) { + error = Exception('Invalid request parameters'); + } else { + error = Exception('HTTP ${response.statusCode}'); + } + + return ErrorOrData(ignored, error); + } + + FutureErrorOrData deleteItem(TableModel table, TableItemsData e) async { + bool? ignored; + Exception? error; + + TableField? primaryField = + table.columns.firstWhereOrNull((el) => el.isPrimary); + TableField? uniqueField = + table.columns.firstWhereOrNull((el) => el.isUnique); + + final response = await post( + '/items/${Uri.encodeComponent(table.tableName)}/-', + body: { + "defs": [ + if (primaryField != null) + { + "name": primaryField.fieldName, + "value": e[primaryField.fieldName], + } + else if (uniqueField != null) + { + "name": uniqueField.fieldName, + "value": e[uniqueField.fieldName], + } + else + for (final field in table.columns) + { + "name": field.fieldName, + "value": e[field.fieldName], + } + ], + }, + headers: { + 'Content-Type': 'application/json', + }, + ); + if (response.statusCode == 200) { + final body = json.decode(await response.stream.bytesToString()); + if (body['error'] != null) { + error = Exception(body['error']); + } else { + ignored = true; + } + } else if (response.statusCode == 422) { + error = Exception('Invalid request parameters'); + } else { + error = Exception('HTTP ${response.statusCode}'); + } + + return ErrorOrData(ignored, error); + } + + // REGION: HTTP Methods implementation + Future get( String path, { Map? headers, diff --git a/lib/pages/bottomsheets/create_table_item_bottomsheet.dart b/lib/pages/bottomsheets/create_table_item_bottomsheet.dart new file mode 100644 index 0000000..c979b13 --- /dev/null +++ b/lib/pages/bottomsheets/create_table_item_bottomsheet.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_fast_forms/flutter_fast_forms.dart'; +import 'package:get/get.dart'; +import 'package:tuuli_app/api/api_client.dart'; +import 'package:tuuli_app/api/model/table_field_model.dart'; +import 'package:tuuli_app/api/model/tables_list_model.dart'; +import 'package:uuid/uuid.dart'; +import 'package:uuid/uuid_util.dart'; + +class CreateTableItemBottomSheet extends StatefulWidget { + final TableModel table; + final TableItemsData? existingItem; + + const CreateTableItemBottomSheet({ + super.key, + required this.table, + this.existingItem, + }); + + @override + State createState() => _CreateTableItemBottomSheetState(); +} + +class _CreateTableItemBottomSheetState + extends State { + final _formKey = GlobalKey(); + + final _values = {}; + + @override + void initState() { + super.initState(); + for (final field in widget.table.columns.where((e) => !e.isPrimary)) { + _values[field.fieldName] = null; + } + widget.existingItem?.forEach((key, value) { + _values[key] = value; + }); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: FastForm( + formKey: _formKey, + children: [ + Text( + "Create new item", + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + for (final field in widget.table.columns.where((e) => !e.isPrimary)) + Card( + margin: const EdgeInsets.all(8), + child: Container( + padding: const EdgeInsets.all(8), + child: _createFormField(field), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text("Cancel"), + ), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + Navigator.of(context).pop(_values); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Please fill in all fields"), + ), + ); + }, + child: + Text(widget.existingItem == null ? "Create" : "Update"), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _createFormField(TableField field) { + switch (field.fieldType) { + case "serial": + case "bigint": + // ignore: no_duplicate_case_values + case "int": + return FastTextField( + name: field.fieldName, + labelText: field.fieldName, + validator: (value) { + if (value == null || + value.isEmpty || + double.tryParse(value) is! double) { + return "Please enter a value"; + } + return null; + }, + initialValue: (_values[field.fieldName] ?? "").toString(), + onChanged: (value) { + _values[field.fieldName] = int.tryParse(value ?? ""); + }, + ); + case "uuid": + return FastTextField( + name: field.fieldName, + labelText: field.fieldName, + validator: (value) { + if (value == null || + value.isEmpty || + !Uuid.isValidUUID(fromString: value)) { + return "Please enter a value"; + } + return null; + }, + initialValue: (_values[field.fieldName] ?? "").toString(), + onChanged: (value) { + _values[field.fieldName] = + Uuid.isValidUUID(fromString: value ?? "") ? value : null; + }, + ); + case "str": + return FastTextField( + name: field.fieldName, + labelText: field.fieldName, + validator: (value) { + if (value == null || value.isEmpty) { + return "Please enter a value"; + } + return null; + }, + initialValue: _values[field.fieldName], + onChanged: (value) { + _values[field.fieldName] = value; + }, + ); + case "bool": + return FastCheckbox( + name: field.fieldName, + labelText: field.fieldName, + titleText: field.fieldName, + initialValue: _values[field.fieldName], + onChanged: (value) { + _values[field.fieldName] = value; + }, + ); + case "date": + // ignore: no_duplicate_case_values + case "datetime": + return FastCalendar( + name: field.fieldName, + firstDate: DateTime.now().subtract(const Duration(days: 365 * 200)), + lastDate: DateTime.now().add(const Duration(days: 365 * 200)), + initialValue: DateTime.tryParse(_values[field.fieldName] ?? ""), + validator: (value) { + if (value == null) { + return "Please enter a value"; + } + return null; + }, + onChanged: (value) { + _values[field.fieldName] = value; + }, + ); + case "float": + return FastTextField( + name: field.fieldName, + labelText: field.fieldName, + validator: (value) { + if (value == null || + value.isEmpty || + double.tryParse(value) is! double) { + return "Please enter a value"; + } + return null; + }, + initialValue: (_values[field.fieldName] ?? "").toString(), + onChanged: (value) { + _values[field.fieldName] = double.tryParse(value ?? ""); + }, + ); + default: + return const Text("Unknown field type"); + } + } +} diff --git a/lib/pages/bottomsheets/open_table_bottomsheet.dart b/lib/pages/bottomsheets/open_table_bottomsheet.dart index cce3d23..bccfae2 100644 --- a/lib/pages/bottomsheets/open_table_bottomsheet.dart +++ b/lib/pages/bottomsheets/open_table_bottomsheet.dart @@ -1,8 +1,11 @@ +import 'package:bottom_sheet/bottom_sheet.dart'; +import 'package:data_table_2/data_table_2.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:recase/recase.dart'; import 'package:tuuli_app/api/api_client.dart'; import 'package:tuuli_app/api/model/tables_list_model.dart'; +import 'package:tuuli_app/pages/bottomsheets/create_table_item_bottomsheet.dart'; class OpenTableBottomSheet extends StatefulWidget { final TableModel table; @@ -15,14 +18,23 @@ class OpenTableBottomSheet extends StatefulWidget { class _OpenTableBottomSheetState extends State { final apiClient = Get.find(); + final tableItems = TableItemsDataList.empty(growable: true); + + @override + void initState() { + super.initState(); + + _refreshTableData(); + } @override Widget build(BuildContext context) { - return SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16), + return Padding( + padding: const EdgeInsets.all(16), + child: Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ Row( children: [ @@ -31,6 +43,14 @@ class _OpenTableBottomSheetState extends State { style: Theme.of(context).textTheme.headlineSmall, ), const Spacer(), + IconButton( + onPressed: _addNewItem, + icon: const Icon(Icons.add), + ), + IconButton( + onPressed: _refreshTableData, + icon: const Icon(Icons.refresh), + ), IconButton( onPressed: _dropTable, icon: const Icon(Icons.delete), @@ -42,6 +62,45 @@ class _OpenTableBottomSheetState extends State { ], ), const Divider(), + Expanded( + child: DataTable2( + columnSpacing: 12, + horizontalMargin: 12, + headingRowColor: + MaterialStateColor.resolveWith((states) => Colors.black), + columns: [ + ...widget.table.columns.map((e) => DataColumn( + label: Text(e.fieldName), + )), + const DataColumn(label: Text("Actions")), + ], + rows: tableItems + .map((e) => DataRow(cells: [ + for (int i = 0; i < widget.table.columns.length; i++) + DataCell( + Text(e[widget.table.columns[i].fieldName] + ?.toString() ?? + "null"), + ), + DataCell( + Row( + children: [ + IconButton( + onPressed: () => _deleteItem(e), + icon: const Icon(Icons.delete), + ), + IconButton( + onPressed: () => _updateExistingItem(e), + icon: const Icon(Icons.edit), + ), + ], + ), + ), + ])) + .toList(growable: false), + empty: const Center(child: Text("No data")), + ), + ), ], ), ), @@ -55,7 +114,7 @@ class _OpenTableBottomSheetState extends State { "Are you sure you want to drop this table \"${widget.table.tableName}\"?", textConfirm: "Drop", onConfirm: () => Get.back(result: true), - onCancel: () => Get.back(result: false), + onCancel: () {}, barrierDismissible: false, ); @@ -70,4 +129,129 @@ class _OpenTableBottomSheetState extends State { Get.snackbar("Error", error.toString()); }); } + + Future _refreshTableData() async { + final result = await apiClient.getTableItems(widget.table); + result.unfold((data) { + setState(() { + tableItems.clear(); + tableItems.addAll(data); + }); + }, (error) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Error: $error"), + ), + ); + }); + } + + Future _addNewItem() async { + final newItem = await showFlexibleBottomSheet>( + minHeight: 1, + initHeight: 1, + maxHeight: 1, + context: context, + builder: (_, __, ___) => CreateTableItemBottomSheet(table: widget.table), + anchors: [0, 0.5, 1], + isSafeArea: true, + isDismissible: false, + ); + + if (newItem == null) { + return; + } + + final result = await apiClient.insertItem(widget.table, newItem); + result.unfold((data) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Item added"), + ), + ); + _refreshTableData(); + }, (error) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Error: $error"), + ), + ); + }); + } + + Future _updateExistingItem(TableItemsData oldItem) async { + final newItem = await showFlexibleBottomSheet>( + minHeight: 1, + initHeight: 1, + maxHeight: 1, + context: context, + builder: (_, __, ___) => CreateTableItemBottomSheet( + table: widget.table, + existingItem: Map.fromEntries(widget.table.columns + .where((el) => !el.isPrimary) + .map((el) => MapEntry(el.fieldName, oldItem[el.fieldName]))), + ), + anchors: [0, 0.5, 1], + isSafeArea: true, + isDismissible: false, + ); + + if (newItem == null) { + return; + } + + final result = await apiClient.updateItem(widget.table, newItem, oldItem); + result.unfold((data) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Item added"), + ), + ); + _refreshTableData(); + }, (error) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Error: $error"), + ), + ); + }); + } + + Future _deleteItem(TableItemsData e) async { + final really = await Get.defaultDialog( + title: "Delete item", + middleText: "Are you sure you want to delete this item?", + textConfirm: "Delete", + onConfirm: () => Get.back(result: true), + onCancel: () {}, + barrierDismissible: false, + ); + + if (really != true) { + return; + } + + final result = await apiClient.deleteItem(widget.table, e); + result.unfold((data) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Item deleted"), + ), + ); + _refreshTableData(); + }, (error) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Error: $error"), + ), + ); + }); + } } diff --git a/pubspec.lock b/pubspec.lock index 91add29..3de30a5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + data_table_2: + dependency: "direct main" + description: + name: data_table_2 + sha256: daea1a67128e88b6b5537ee0752c67ecdc2fba9cd9303b3554361c648549618d + url: "https://pub.dev" + source: hosted + version: "2.4.2" fake_async: dependency: transitive description: @@ -102,6 +110,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_fast_forms: + dependency: "direct main" + description: + name: flutter_fast_forms + sha256: fd2cb1208fd8360942ac69dc206b6a0ea049f3161dfc2f86548b6b04bcbb2abd + url: "https://pub.dev" + source: hosted + version: "10.0.0" flutter_lints: dependency: "direct dev" description: @@ -147,6 +163,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + intl: + dependency: transitive + description: + name: intl + sha256: a3715e3bc90294e971cb7dc063fbf3cd9ee0ebf8604ffeafabd9e6f16abbdbe6 + url: "https://pub.dev" + source: hosted + version: "0.18.0" js: dependency: transitive description: @@ -370,4 +394,4 @@ packages: version: "1.0.0" sdks: dart: ">=3.0.0-322.0.dev <4.0.0" - flutter: ">=3.0.0" + flutter: ">=3.7.0" diff --git a/pubspec.yaml b/pubspec.yaml index 0e98bd9..3441758 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,8 @@ dependencies: animated_background: ^2.0.0 bottom_sheet: ^3.1.2 + data_table_2: ^2.4.2 + flutter_fast_forms: ^10.0.0 get: ^4.6.5 get_storage: ^2.1.1 http: ^0.13.5