From 4a5039cb19f9886d53e4229d405dc9f48fd2eb8a Mon Sep 17 00:00:00 2001 From: Andrew nuark G Date: Tue, 11 Apr 2023 02:42:20 +0700 Subject: [PATCH] Add table creation --- lib/api/api_client.dart | 52 ++++ lib/api/model/table_field_model.dart | 127 +++++++++ lib/api/model/tables_list_model.dart | 42 +++ lib/c.dart | 3 + lib/extensions/try_cast_extension.dart | 3 + lib/interfaces/appbar_provider_interface.dart | 6 + lib/main.dart | 51 +--- .../bottomsheers/edit_table_bottomsheet.dart | 242 ++++++++++++++++++ lib/pages/checkup_page.dart | 36 +-- lib/pages/home_page.dart | 165 +++++++++++- lib/pages/home_panels/none_panel.dart | 15 ++ lib/pages/home_panels/settings_panel.dart | 36 +++ lib/pages/home_panels/tables_list_panel.dart | 179 +++++++++++++ lib/pages/home_panels/users_list_panel.dart | 36 +++ lib/pages/login_page.dart | 137 +++++----- lib/pages/not_found_page.dart | 49 +++- lib/widgets/table_field_widget.dart | 51 ++++ pubspec.lock | 40 +++ pubspec.yaml | 3 + 19 files changed, 1132 insertions(+), 141 deletions(-) create mode 100644 lib/api/model/table_field_model.dart create mode 100644 lib/api/model/tables_list_model.dart create mode 100644 lib/c.dart create mode 100644 lib/extensions/try_cast_extension.dart create mode 100644 lib/interfaces/appbar_provider_interface.dart create mode 100644 lib/pages/bottomsheers/edit_table_bottomsheet.dart create mode 100644 lib/pages/home_panels/none_panel.dart create mode 100644 lib/pages/home_panels/settings_panel.dart create mode 100644 lib/pages/home_panels/tables_list_panel.dart create mode 100644 lib/pages/home_panels/users_list_panel.dart create mode 100644 lib/widgets/table_field_widget.dart diff --git a/lib/api/api_client.dart b/lib/api/api_client.dart index d1f9b19..22d2621 100644 --- a/lib/api/api_client.dart +++ b/lib/api/api_client.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'package:tuuli_app/api/model/access_token_model.dart'; import 'package:http/browser_client.dart'; import 'package:http/http.dart'; +import 'package:tuuli_app/api/model/table_field_model.dart'; +import 'package:tuuli_app/api/model/tables_list_model.dart'; class ErrorOrData { final T? data; @@ -58,6 +60,8 @@ class ApiClient { } else { data = AccessTokenModel(accessToken: body['access_token']); } + } else if (response.statusCode == 422) { + error = Exception('Invalid request parameters'); } else { error = Exception('HTTP ${response.statusCode}'); } @@ -65,6 +69,54 @@ class ApiClient { return ErrorOrData(data, error); } + FutureErrorOrData tablesList() async { + TablesListModel? data; + Exception? error; + + final response = await get('/api/listTables'); + if (response.statusCode == 200) { + final body = json.decode(await response.stream.bytesToString()); + if (body['error'] != null) { + error = Exception(body['error']); + } else if (body['tables'] == null) { + error = Exception('Server error'); + } else { + data = TablesListModel.fromJson(body); + } + } else if (response.statusCode == 422) { + error = Exception('Invalid request parameters'); + } else { + error = Exception('HTTP ${response.statusCode}'); + } + + return ErrorOrData(data, error); + } + + FutureErrorOrData createTable( + String tableName, List columns) async { + Exception? error; + + final response = + await post('/api/createTable/${Uri.encodeComponent(tableName)}', body: { + 'columns': + columns.map((e) => e.toColumnDefinition()).toList(growable: false), + }, 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 if (response.statusCode == 422) { + error = Exception('Invalid request parameters'); + } else { + error = Exception('HTTP ${response.statusCode}'); + } + + return ErrorOrData(null, error); + } + Future get( String path, { Map? headers, diff --git a/lib/api/model/table_field_model.dart b/lib/api/model/table_field_model.dart new file mode 100644 index 0000000..03b51bf --- /dev/null +++ b/lib/api/model/table_field_model.dart @@ -0,0 +1,127 @@ +import 'package:uuid/uuid.dart'; + +typedef SerialTableField = TableField; +typedef UUIDTableField = TableField; +typedef StringTableField = TableField; +typedef BigIntTableField = TableField; +typedef BoolTableField = TableField; +typedef DateTableField = TableField; +typedef DateTimeTableField = TableField; +typedef FloatTableField = TableField; +typedef IntTableField = TableField; + +final possibleFieldTypes = { + "serial": SerialTableField, + "uuid": UUIDTableField, + "str": StringTableField, + "bigint": BigIntTableField, + "bool": BoolTableField, + "date": DateTableField, + "datetime": DateTimeTableField, + "float": FloatTableField, + "int": IntTableField, +}; + +class TableField { + final String fieldName; + final String fieldType; + final bool isUnique; + final bool isPrimary; + final Type type = T; + + TableField({ + required this.fieldName, + required this.fieldType, + required this.isUnique, + required this.isPrimary, + }); + + bool canBePrimary() { + return fieldType == "serial" || fieldType == "uuid"; + } + + @override + String toString() { + return "TableField<$T>(fieldName: $fieldName, fieldType: $fieldType, isUnique: $isUnique, isPrimary: $isPrimary)"; + } + + String toColumnDefinition() { + return "$fieldName:$fieldType${isPrimary ? ":primary" : ""}${isUnique ? ":unique" : ""}"; + } + + static TableField parseTableField(String definition) { + final parts = definition.split(":"); + final fieldName = parts[0]; + final fieldType = parts[1]; + final isUnique = parts.contains("unique"); + final isPrimary = parts.contains("primary"); + + switch (fieldType) { + case "serial": + return SerialTableField( + fieldName: fieldName, + fieldType: fieldType, + isUnique: isUnique, + isPrimary: isPrimary, + ); + case "uuid": + return UUIDTableField( + fieldName: fieldName, + fieldType: fieldType, + isUnique: isUnique, + isPrimary: isPrimary, + ); + case "str": + return StringTableField( + fieldName: fieldName, + fieldType: fieldType, + isUnique: isUnique, + isPrimary: isPrimary, + ); + case "bigint": + return BigIntTableField( + fieldName: fieldName, + fieldType: fieldType, + isUnique: isUnique, + isPrimary: isPrimary, + ); + case "bool": + return BoolTableField( + fieldName: fieldName, + fieldType: fieldType, + isUnique: isUnique, + isPrimary: isPrimary, + ); + case "date": + return DateTableField( + fieldName: fieldName, + fieldType: fieldType, + isUnique: isUnique, + isPrimary: isPrimary, + ); + case "datetime": + return DateTimeTableField( + fieldName: fieldName, + fieldType: fieldType, + isUnique: isUnique, + isPrimary: isPrimary, + ); + case "float": + return FloatTableField( + fieldName: fieldName, + fieldType: fieldType, + isUnique: isUnique, + isPrimary: isPrimary, + ); + case "int": + return IntTableField( + fieldName: fieldName, + fieldType: fieldType, + isUnique: isUnique, + isPrimary: isPrimary, + ); + default: + throw Exception("Unknown field type: $fieldType"); + } + } +} diff --git a/lib/api/model/tables_list_model.dart b/lib/api/model/tables_list_model.dart new file mode 100644 index 0000000..a9dc1bf --- /dev/null +++ b/lib/api/model/tables_list_model.dart @@ -0,0 +1,42 @@ +import 'package:tuuli_app/api/model/table_field_model.dart'; + +class TablesListModel { + final List tables; + + TablesListModel(this.tables); + + factory TablesListModel.fromJson(Map json) => + TablesListModel( + List.from( + json["tables"].map((x) => TableModel.fromJson(x)), + ), + ); +} + +class TableModel { + final String tableId; + final String tableName; + final String columnsDefinition; + final List columns; + final bool system; + final bool hidden; + + TableModel({ + required this.tableId, + required this.tableName, + required this.columnsDefinition, + required this.system, + required this.hidden, + }) : columns = columnsDefinition + .split(",") + .map(TableField.parseTableField) + .toList(growable: false); + + factory TableModel.fromJson(Map json) => TableModel( + tableId: json["table_id"], + tableName: json["table_name"], + columnsDefinition: json["columns"], + system: json["system"], + hidden: json["hidden"], + ); +} diff --git a/lib/c.dart b/lib/c.dart new file mode 100644 index 0000000..273b47a --- /dev/null +++ b/lib/c.dart @@ -0,0 +1,3 @@ +class C { + static const double materialDrawerWidth = 304.0; +} diff --git a/lib/extensions/try_cast_extension.dart b/lib/extensions/try_cast_extension.dart new file mode 100644 index 0000000..9deb228 --- /dev/null +++ b/lib/extensions/try_cast_extension.dart @@ -0,0 +1,3 @@ +extension TryCastExtension on Object { + T? tryCast() => this is T ? this as T : null; +} diff --git a/lib/interfaces/appbar_provider_interface.dart b/lib/interfaces/appbar_provider_interface.dart new file mode 100644 index 0000000..eaf4431 --- /dev/null +++ b/lib/interfaces/appbar_provider_interface.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; + +class AppbarProviderInterface { + AppBar get appBar => + throw UnimplementedError("appBar getter not implemented"); +} diff --git a/lib/main.dart b/lib/main.dart index 344a9eb..ac54169 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,7 +10,18 @@ import 'package:tuuli_app/pages/not_found_page.dart'; void main() async { await GetStorage.init(); - Get.put(ApiClient.fromString("http://127.0.0.1:8000"), permanent: true); + Get.put( + ApiClient.fromString("http://127.0.0.1:8000"), + permanent: true, + builder: () { + final client = ApiClient.fromString("http://127.0.0.1:8000"); + final accessToken = GetStorage().read("accessToken"); + if (accessToken != null) { + client.setAccessToken(accessToken); + } + return client; + }, + ); runApp(const MainApp()); } @@ -33,15 +44,12 @@ class MainApp extends StatelessWidget { } Route _onGenerateRoute(RouteSettings settings) { - Widget? pageBody; - bool appBarNeeded = true; + Widget pageBody; switch (settings.name) { case "/": - appBarNeeded = false; pageBody = const CheckupPage(); break; case "/login": - appBarNeeded = false; pageBody = const LoginPage(); break; case "/home": @@ -53,38 +61,7 @@ class MainApp extends StatelessWidget { } return MaterialPageRoute( - builder: (context) => Scaffold( - appBar: appBarNeeded - ? AppBar( - title: const Text('GWS Playground'), - actions: [ - if (Navigator.of(context).canPop()) - IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - Get.back(canPop: false); - }, - ), - IconButton( - icon: const Icon(Icons.home), - onPressed: () { - Get.offAllNamed("/"); - }, - ), - IconButton( - icon: const Icon(Icons.logout), - onPressed: () { - GetStorage().erase().then((value) { - GetStorage().save(); - Get.offAllNamed("/"); - }); - }, - ), - ], - ) - : null, - body: pageBody, - ), + builder: (context) => pageBody, ); } } diff --git a/lib/pages/bottomsheers/edit_table_bottomsheet.dart b/lib/pages/bottomsheers/edit_table_bottomsheet.dart new file mode 100644 index 0000000..0bf8b4a --- /dev/null +++ b/lib/pages/bottomsheers/edit_table_bottomsheet.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:recase/recase.dart'; +import 'package:tuuli_app/api/model/table_field_model.dart'; +import 'package:tuuli_app/api/model/tables_list_model.dart'; +import 'package:tuuli_app/widgets/table_field_widget.dart'; + +class EditTableBottomSheetResult { + final String tableName; + final List fields; + + EditTableBottomSheetResult(this.tableName, this.fields); +} + +class EditTableBottomSheet extends StatefulWidget { + final TableModel? table; + + const EditTableBottomSheet({super.key, this.table}); + + @override + State createState() => _EditTableBottomSheetState(); +} + +class _EditTableBottomSheetState extends State { + var tableName = "".obs; + + late final List fields; + + final newFieldName = TextEditingController(); + String? newFieldType; + var newFieldPrimary = false; + var newFieldUnique = false; + + @override + void initState() { + super.initState(); + fields = widget.table?.columns ?? []; + if (widget.table != null) { + tableName.value = widget.table!.tableName; + } + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Obx( + () => Text( + tableName.isEmpty + ? "Edit table" + : "Edit table \"${tableName.value.pascalCase}\"", + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + const Spacer(), + IconButton( + onPressed: Get.back, + icon: const Icon(Icons.cancel), + ) + ], + ), + if (widget.table == null) const Divider(), + if (widget.table == null) + TextFormField( + decoration: const InputDecoration( + labelText: 'Table name', + hintText: 'Enter table name', + ), + readOnly: widget.table != null, + maxLength: 15, + onChanged: (value) => tableName.value = value, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter table name'; + } + return null; + }, + ), + const Divider(), + Text( + "Fields", + style: Theme.of(context).textTheme.titleLarge, + ), + if (fields.isEmpty) const Text("No fields"), + ...fields + .map((e) => TableFieldWidget( + field: e, + onRemove: () => _removeColumn(e.fieldName), + )) + .toList(growable: false), + if (widget.table == null) const SizedBox(width: 16), + if (widget.table == null) + Card( + child: Container( + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.all(8), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("Add new field"), + const SizedBox(height: 8), + Row( + children: [ + Flexible( + child: TextFormField( + controller: newFieldName, + decoration: const InputDecoration( + labelText: 'Column name', + hintText: 'Enter column name', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + Flexible( + child: DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Column type', + hintText: 'Choose column type', + border: OutlineInputBorder(), + ), + items: possibleFieldTypes.keys + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.pascalCase), + )) + .toList(growable: false), + value: newFieldType, + onChanged: (value) => newFieldType = value, + ), + ), + const SizedBox(width: 16), + ToggleButtons( + isSelected: [ + newFieldPrimary, + newFieldUnique, + !newFieldPrimary && !newFieldUnique + ], + onPressed: (index) { + setState(() { + newFieldPrimary = index == 0; + newFieldUnique = index == 1; + }); + }, + children: const [ + Text("Primary"), + Text("Unique"), + Text("Normal"), + ], + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: _addNewField, + child: const Text("Add field"), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: _saveTable, + child: const Text("Save table"), + ), + ], + ), + ), + ); + } + + void _addNewField() { + if (newFieldType == null) { + return; + } + + final fieldName = newFieldName.text; + if (fieldName.isEmpty || + fields.any((element) => element.fieldName == fieldName)) { + Get.defaultDialog( + title: "Error", + middleText: "Field name is empty or already exists", + ); + return; + } + + final field = TableField.parseTableField( + "$fieldName:$newFieldType${newFieldUnique ? ":unique" : ""}${newFieldPrimary ? ":primary" : ""}", + ); + + if (field.isPrimary && !field.canBePrimary()) { + Get.defaultDialog( + title: "Error", + middleText: "Field type \"${field.fieldType}\" can't be primary", + ); + return; + } + + setState(() { + newFieldName.clear(); + newFieldType = null; + newFieldPrimary = false; + newFieldUnique = false; + fields.add(field); + }); + } + + void _saveTable() { + if (tableName.isEmpty) { + Get.defaultDialog( + title: "Error", + middleText: "Table name is empty", + ); + return; + } + + if (fields.isEmpty) { + Get.defaultDialog( + title: "Error", + middleText: "Table must have at least one field", + ); + return; + } + + Get.back(result: EditTableBottomSheetResult(tableName.value, fields)); + } + + void _removeColumn(String name) { + setState(() { + fields.removeWhere((element) => element.fieldName == name); + }); + } +} diff --git a/lib/pages/checkup_page.dart b/lib/pages/checkup_page.dart index e3599a8..2963dda 100644 --- a/lib/pages/checkup_page.dart +++ b/lib/pages/checkup_page.dart @@ -28,23 +28,25 @@ class _CheckupPageState extends State @override Widget build(BuildContext context) { - return AnimatedBackground( - behaviour: RandomParticleBehaviour(), - vsync: this, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Checking credentials...', - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 16), - const SizedBox.square( - dimension: 32, - child: CircularProgressIndicator(), - ), - ], + return Scaffold( + body: AnimatedBackground( + behaviour: RandomParticleBehaviour(), + vsync: this, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Checking credentials...', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 16), + const SizedBox.square( + dimension: 32, + child: CircularProgressIndicator(), + ), + ], + ), ), ), ); diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 9861e65..f1c2074 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,5 +1,20 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:tuuli_app/api/api_client.dart'; +import 'package:tuuli_app/api/model/tables_list_model.dart'; +import 'package:tuuli_app/c.dart'; +import 'package:tuuli_app/pages/home_panels/none_panel.dart'; +import 'package:tuuli_app/pages/home_panels/settings_panel.dart'; +import 'package:tuuli_app/pages/home_panels/tables_list_panel.dart'; +import 'package:tuuli_app/pages/home_panels/users_list_panel.dart'; + +enum PageType { + none, + tables, + users, + settings, +} class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -9,25 +24,151 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { + final apiClient = Get.find(); + TablesListModel tables = TablesListModel([]); + + bool get isNarrow => + (MediaQuery.of(context).size.width - C.materialDrawerWidth) <= 600; + + @override + void initState() { + super.initState(); + + _refreshData(); + } + + var currentPage = PageType.none; + final pageNames = { + PageType.none: "Home", + PageType.tables: "Tables", + PageType.users: "Users", + PageType.settings: "Settings", + }; + + AppBar get appBar => AppBar( + title: Text(pageNames[currentPage]!), + actions: [ + IconButton( + icon: const Icon(Icons.home), + onPressed: () { + Get.offAllNamed("/"); + }, + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _refreshData, + ), + ], + ); + + ListView get drawerOptions => ListView( + children: [ + ListTile( + leading: const Icon(Icons.table_chart), + title: const Text("Tables"), + onTap: () { + setState(() { + currentPage = PageType.tables; + }); + }, + ), + ListTile( + leading: const Icon(Icons.person), + title: const Text("Users"), + onTap: () { + setState(() { + currentPage = PageType.users; + }); + }, + ), + ListTile( + leading: const Icon(Icons.settings), + title: const Text("Settings"), + onTap: () { + setState(() { + currentPage = PageType.settings; + }); + }, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.logout), + title: const Text("Logout"), + onTap: () { + GetStorage().erase().then((value) { + GetStorage().save(); + Get.offAllNamed("/"); + }); + }, + ), + ], + ); + @override Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + return Scaffold( + appBar: appBar, + drawer: isNarrow + ? Drawer( + width: C.materialDrawerWidth, + child: drawerOptions, + ) + : null, + body: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Home Page', - style: Theme.of(context).textTheme.headlineMedium, + Container( + width: C.materialDrawerWidth, + decoration: BoxDecoration( + color: Colors.black.withAlpha(100), + border: Border( + right: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + ), + child: drawerOptions, ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () { - Get.offNamed("/login"); - }, - child: const Text('Login'), + LimitedBox( + maxWidth: MediaQuery.of(context).size.width - C.materialDrawerWidth, + child: Builder(builder: (context) { + switch (currentPage) { + case PageType.tables: + return TablesListPanel(tables: tables); + case PageType.users: + return const UsersListPanel(); + case PageType.settings: + return const SettingsPanel(); + case PageType.none: + default: + return const NonePanel(); + } + }), ), ], ), ); } + + void _refreshData() { + apiClient.tablesList().then( + (value) => value.unfold( + (tables) { + setState(() { + this.tables = tables; + }); + }, + (error) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error.toString()), + ), + ); + }, + ), + ); + } } diff --git a/lib/pages/home_panels/none_panel.dart b/lib/pages/home_panels/none_panel.dart new file mode 100644 index 0000000..8a2738c --- /dev/null +++ b/lib/pages/home_panels/none_panel.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +class NonePanel extends StatelessWidget { + const NonePanel({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Text( + 'Use the menu for navigation', + style: Theme.of(context).textTheme.headlineSmall, + ), + ); + } +} diff --git a/lib/pages/home_panels/settings_panel.dart b/lib/pages/home_panels/settings_panel.dart new file mode 100644 index 0000000..687b730 --- /dev/null +++ b/lib/pages/home_panels/settings_panel.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class SettingsPanel extends StatefulWidget { + const SettingsPanel({super.key}); + + @override + State createState() => _SettingsPanelState(); +} + +class _SettingsPanelState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: IntrinsicHeight( + child: _buildBody(), + ), + ); + } + + Widget _buildBody() { + return Column( + children: [ + Text( + 'Settings', + style: Theme.of(context).textTheme.headlineSmall, + ), + ], + ); + } +} diff --git a/lib/pages/home_panels/tables_list_panel.dart b/lib/pages/home_panels/tables_list_panel.dart new file mode 100644 index 0000000..fb7d47c --- /dev/null +++ b/lib/pages/home_panels/tables_list_panel.dart @@ -0,0 +1,179 @@ +import 'package:bottom_sheet/bottom_sheet.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/c.dart'; +import 'package:tuuli_app/pages/bottomsheers/edit_table_bottomsheet.dart'; + +class TablesListPanel extends StatefulWidget { + final TablesListModel tables; + + const TablesListPanel({super.key, required this.tables}); + + @override + State createState() => _TablesListPanelState(); +} + +class _TablesListPanelState extends State { + final apiClient = Get.find(); + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return _buildTableList(); + } + + Widget _buildTableCard(BuildContext ctx, TableModel table) { + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _openTable(table), + child: Container( + margin: const EdgeInsets.all(5), + padding: const EdgeInsets.all(5), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + table.tableName.pascalCase, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Row( + children: [ + Text( + table.system ? "System" : "Userland", + style: const TextStyle( + fontSize: 12, + ), + ), + ], + ), + Row( + children: [ + Text( + "${table.columns.length} column(s)", + style: const TextStyle( + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + ], + ) + ], + ), + ], + ), + ), + ), + ); + } + + Widget get newTableCard => Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: _createNewTable, + child: Container( + margin: const EdgeInsets.all(5), + padding: const EdgeInsets.all(5), + child: const Icon(Icons.add), + ), + ), + ); + + Widget _buildTableList() { + var tableItems = widget.tables.tables + .where((table) => !table.hidden && !table.system) + .toList(growable: false); + + if (tableItems.isEmpty) { + return Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "No tables found", + style: TextStyle( + color: Theme.of(context).disabledColor, + fontSize: 24, + ), + ), + const SizedBox(height: 4), + Text( + "Maybe create one?", + style: TextStyle( + color: Theme.of(context).disabledColor, + fontSize: 14, + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _createNewTable, + icon: const Icon(Icons.add), + label: const Text("Create table"), + ) + ], + ), + ); + } + + return GridView.builder( + shrinkWrap: true, + itemCount: tableItems.length + 1, + itemBuilder: (ctx, i) => + i == 0 ? newTableCard : _buildTableCard(ctx, tableItems[i - 1]), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + childAspectRatio: 5 / 2, + ), + ); + } + + void _createNewTable() async { + final result = await showFlexibleBottomSheet( + minHeight: 0, + initHeight: 0.75, + maxHeight: 1, + context: context, + builder: (_, __, ___) => const EditTableBottomSheet(), + anchors: [0, 0.5, 1], + isSafeArea: true, + isDismissible: false, + ); + + if (result == null) return; + + await apiClient + .createTable(result.tableName, result.fields) + .then((e) => e.unfold((_) { + Get.snackbar( + "Success", + "Table created", + colorText: Colors.white, + ); + }, (error) { + Get.defaultDialog( + title: "Error", + middleText: error.toString(), + textConfirm: "OK", + onConfirm: () => Get.back(), + ); + })); + } + + void _openTable(TableModel table) async { + // TODO: Open table + } +} diff --git a/lib/pages/home_panels/users_list_panel.dart b/lib/pages/home_panels/users_list_panel.dart new file mode 100644 index 0000000..1d3ad2d --- /dev/null +++ b/lib/pages/home_panels/users_list_panel.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class UsersListPanel extends StatefulWidget { + const UsersListPanel({super.key}); + + @override + State createState() => _UsersListPanelState(); +} + +class _UsersListPanelState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: IntrinsicHeight( + child: _buildBody(), + ), + ); + } + + Widget _buildBody() { + return Column( + children: [ + Text( + 'Users', + style: Theme.of(context).textTheme.headlineSmall, + ), + ], + ); + } +} diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index af8c7b7..98a0f67 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -26,73 +26,75 @@ class _LoginPageState extends State with TickerProviderStateMixin { Widget build(BuildContext context) { final screenSize = MediaQuery.of(context).size; final formWidth = screenSize.width <= 600 ? screenSize.width : 300.0; - return Stack( - children: [ - AnimatedBackground( - behaviour: RandomParticleBehaviour(), - vsync: this, - child: const SizedBox.square( - dimension: 0, + return Scaffold( + body: Stack( + children: [ + AnimatedBackground( + behaviour: RandomParticleBehaviour(), + vsync: this, + child: const SizedBox.square( + dimension: 0, + ), ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - LimitedBox( - maxWidth: formWidth, - child: Container( - color: Colors.black.withAlpha(100), - padding: const EdgeInsets.all(16), - child: Form( - key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextFormField( - controller: loginController, - enabled: !submitted, - decoration: const InputDecoration( - labelText: 'Login', - hintText: 'Enter your login', + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LimitedBox( + maxWidth: formWidth, + child: Container( + color: Colors.black.withAlpha(100), + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: loginController, + enabled: !submitted, + decoration: const InputDecoration( + labelText: 'Login', + hintText: 'Enter your login', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your Login'; + } + return null; + }, ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter your Login'; - } - return null; - }, - ), - TextFormField( - controller: passwordController, - obscureText: true, - enabled: !submitted, - decoration: const InputDecoration( - labelText: 'Password', - hintText: 'Enter your password', + TextFormField( + controller: passwordController, + obscureText: true, + enabled: !submitted, + decoration: const InputDecoration( + labelText: 'Password', + hintText: 'Enter your password', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your password'; + } + return null; + }, ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter your password'; - } - return null; - }, - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: submitted ? null : _submit, - child: const Text('Login'), - ), - ], + const SizedBox(height: 16), + ElevatedButton( + onPressed: submitted ? null : _submit, + child: const Text('Login'), + ), + ], + ), ), ), ), - ), - ], - ), - ], + ], + ), + ], + ), ); } @@ -121,13 +123,16 @@ class _LoginPageState extends State with TickerProviderStateMixin { content: Text('Login successful'), ), ); + apiClient.setAccessToken(data.accessToken); GetStorage() .write("accessToken", data.accessToken) - .then((value) => GetStorage().save()); - Timer(1.seconds, () { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - WidgetsBinding.instance.addPostFrameCallback((_) { - Get.offAllNamed("/home"); + .then((value) => GetStorage().save()) + .then((value) { + Timer(1.seconds, () { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + WidgetsBinding.instance.addPostFrameCallback((_) { + Get.offAllNamed("/home"); + }); }); }); }, (error) { diff --git a/lib/pages/not_found_page.dart b/lib/pages/not_found_page.dart index b22bdd7..3f46a93 100644 --- a/lib/pages/not_found_page.dart +++ b/lib/pages/not_found_page.dart @@ -1,19 +1,50 @@ import 'package:animated_background/animated_background.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/src/scheduler/ticker.dart'; +import 'package:get/get.dart'; -class NotFoundPage extends StatelessWidget { +class NotFoundPage extends StatefulWidget { const NotFoundPage({super.key}); + @override + State createState() => _NotFoundPageState(); +} + +class _NotFoundPageState extends State + with TickerProviderStateMixin { @override Widget build(BuildContext context) { - return AnimatedBackground( - behaviour: RandomParticleBehaviour(), - vsync: Scaffold.of(context), - child: Center( - child: Text( - 'Page not found', - style: Theme.of(context).textTheme.headlineMedium, + return Scaffold( + body: AnimatedBackground( + behaviour: RandomParticleBehaviour(), + vsync: this, + child: Center( + child: Text( + 'Page not found', + style: Theme.of(context).textTheme.headlineMedium, + ), + ), + ), + bottomSheet: Container( + color: Colors.black.withAlpha(100), + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (Navigator.of(context).canPop()) + ElevatedButton( + onPressed: () { + Get.back(canPop: false); + }, + child: const Text('Go back'), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: () { + Get.offAllNamed("/"); + }, + child: const Text('Go home'), + ), + ], ), ), ); diff --git a/lib/widgets/table_field_widget.dart b/lib/widgets/table_field_widget.dart new file mode 100644 index 0000000..040aee6 --- /dev/null +++ b/lib/widgets/table_field_widget.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:tuuli_app/api/model/table_field_model.dart'; + +class TableFieldWidget extends StatelessWidget { + final TableField field; + final VoidCallback? onRemove; + + const TableFieldWidget({ + super.key, + required this.field, + this.onRemove, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Container( + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.all(8), + child: Row( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (field.isPrimary) const Icon(Icons.star), + if (field.isUnique) const Icon(Icons.lock), + Text( + "Field \"${field.fieldName}\"", + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + Text("Field type: ${field.fieldType} (baked by ${field.type})"), + ], + ), + if (onRemove != null) const Spacer(), + if (onRemove != null) + IconButton( + icon: const Icon(Icons.delete), + onPressed: onRemove, + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 99d4e7d..91add29 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + bottom_inset_observer: + dependency: transitive + description: + name: bottom_inset_observer + sha256: cbfb01e0e07cc4922052701786d5e607765a6f54e1844f41061abf8744519a7d + url: "https://pub.dev" + source: hosted + version: "3.1.0" + bottom_sheet: + dependency: "direct main" + description: + name: bottom_sheet + sha256: "7a3d4a1515eba91a7d9e1359e49416147de339889170fc879a8b905d27958c94" + url: "https://pub.dev" + source: hosted + version: "3.1.2" characters: dependency: transitive description: @@ -49,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67 + url: "https://pub.dev" + source: hosted + version: "3.0.2" fake_async: dependency: transitive description: @@ -243,6 +267,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.4" + recase: + dependency: "direct main" + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" sky_engine: dependency: transitive description: flutter @@ -304,6 +336,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + url: "https://pub.dev" + source: hosted + version: "3.0.7" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3a69500..0e98bd9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,9 +11,12 @@ dependencies: sdk: flutter animated_background: ^2.0.0 + bottom_sheet: ^3.1.2 get: ^4.6.5 get_storage: ^2.1.1 http: ^0.13.5 + recase: ^4.1.0 + uuid: ^3.0.7 dev_dependencies: flutter_test: