diff --git a/.metadata b/.metadata index 1c353fa..5f0d710 100644 --- a/.metadata +++ b/.metadata @@ -15,21 +15,6 @@ migration: - platform: root create_revision: 9ba0d08ebc074bf0da6dfd1fadea39f5c5566198 base_revision: 9ba0d08ebc074bf0da6dfd1fadea39f5c5566198 - - platform: android - create_revision: 9ba0d08ebc074bf0da6dfd1fadea39f5c5566198 - base_revision: 9ba0d08ebc074bf0da6dfd1fadea39f5c5566198 - - platform: ios - create_revision: 9ba0d08ebc074bf0da6dfd1fadea39f5c5566198 - base_revision: 9ba0d08ebc074bf0da6dfd1fadea39f5c5566198 - - platform: linux - create_revision: 9ba0d08ebc074bf0da6dfd1fadea39f5c5566198 - base_revision: 9ba0d08ebc074bf0da6dfd1fadea39f5c5566198 - - platform: macos - create_revision: 9ba0d08ebc074bf0da6dfd1fadea39f5c5566198 - base_revision: 9ba0d08ebc074bf0da6dfd1fadea39f5c5566198 - - platform: web - create_revision: 9ba0d08ebc074bf0da6dfd1fadea39f5c5566198 - base_revision: 9ba0d08ebc074bf0da6dfd1fadea39f5c5566198 - platform: windows create_revision: 9ba0d08ebc074bf0da6dfd1fadea39f5c5566198 base_revision: 9ba0d08ebc074bf0da6dfd1fadea39f5c5566198 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..72f6b2e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "tuuli_app", + "request": "launch", + "type": "dart" + }, + { + "name": "tuuli_app (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "tuuli_app (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} diff --git a/lib/api/api_client.dart b/lib/api/api_client.dart deleted file mode 100644 index 33eaf80..0000000 --- a/lib/api/api_client.dart +++ /dev/null @@ -1,395 +0,0 @@ -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'; -import 'package:tuuli_app/api/model/table_field_model.dart'; -import 'package:tuuli_app/api/model/tables_list_model.dart'; -import 'package:tuuli_app/api/model/user_model.dart'; - -class ErrorOrData { - final T? data; - final Exception? error; - - ErrorOrData(this.data, this.error); - - void unfold( - void Function(T data) onData, void Function(Exception error) onError) { - if (data != null) { - onData(data as T); - } else { - onError(error!); - } - } -} - -typedef FutureErrorOrData = Future>; -typedef TableItemsData = Map; -typedef TableItemsDataList = List; - -class ApiClient { - final BrowserClient _client = BrowserClient(); - var _accessToken = ''; - - final Uri baseUrl; - - ApiClient(this.baseUrl); - - ApiClient.fromString(String baseUrl) : this(Uri.parse(baseUrl)); - - void setAccessToken(String accessToken) { - _accessToken = accessToken; - } - - FutureErrorOrData login( - String username, - String password, - ) async { - AccessTokenModel? data; - Exception? error; - - final response = await post('/api/getAccessToken', body: { - 'username': username, - 'password': password, - }, 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 (body['access_token'] == null) { - error = Exception('No access token'); - } else { - data = AccessTokenModel(accessToken: body['access_token']); - } - } else if (response.statusCode == 422) { - error = Exception('Invalid request parameters'); - } else { - error = Exception('HTTP ${response.statusCode}'); - } - - 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 { - bool? ignored; - 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 { - ignored = true; - } - } else if (response.statusCode == 422) { - error = Exception('Invalid request parameters'); - } else { - error = Exception('HTTP ${response.statusCode}'); - } - - return ErrorOrData(ignored, error); - } - - FutureErrorOrData dropTable(String tableName) async { - bool? ignored; - Exception? error; - - final response = - await post('/api/dropTable/${Uri.encodeComponent(tableName)}'); - 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 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); - } - - FutureErrorOrData createUser( - TableModel table, String username, String password) async { - bool? ignored; - Exception? error; - - final response = await post( - '/api/createUser', - body: { - "username": username, - "password": password, - }, - 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 updateUser( - TableModel table, int userId, String password, String accessToken) async { - bool? ignored; - Exception? error; - - final response = await post( - '/api/updateUser', - body: { - "user_id": userId, - "password": password, - "access_token": accessToken, - }, - 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, - }) { - return _request(path, 'GET', headers: headers); - } - - Future post( - String path, { - Map? headers, - dynamic body, - }) { - return _request(path, 'POST', headers: headers, body: body); - } - - Future _request( - String path, - String method, { - Map? headers, - dynamic body, - }) async { - final uri = baseUrl.resolve(path); - final request = Request(method, uri); - if (headers != null) { - request.headers.addAll(headers); - } - if (_accessToken.isNotEmpty) { - request.headers["Access-Token"] = _accessToken; - } - if (body != null) { - request.body = json.encode(body); - } - return _client.send(request); - } -} diff --git a/lib/api/model/access_token_model.dart b/lib/api/model/access_token_model.dart deleted file mode 100644 index 9c9c140..0000000 --- a/lib/api/model/access_token_model.dart +++ /dev/null @@ -1,7 +0,0 @@ -class AccessTokenModel { - final String accessToken; - - const AccessTokenModel({ - required this.accessToken, - }); -} diff --git a/lib/api/model/table_field_model.dart b/lib/api/model/table_field_model.dart deleted file mode 100644 index 03b51bf..0000000 --- a/lib/api/model/table_field_model.dart +++ /dev/null @@ -1,127 +0,0 @@ -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 deleted file mode 100644 index a9dc1bf..0000000 --- a/lib/api/model/tables_list_model.dart +++ /dev/null @@ -1,42 +0,0 @@ -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/api/model/user_model.dart b/lib/api/model/user_model.dart deleted file mode 100644 index 35b88a9..0000000 --- a/lib/api/model/user_model.dart +++ /dev/null @@ -1,27 +0,0 @@ -class UserModel { - final int id; - final String username; - final String? password; - final String? accessToken; - - UserModel({ - required this.id, - required this.username, - this.password, - this.accessToken, - }); - - factory UserModel.fromJson(Map json) => UserModel( - id: json["id"], - username: json["username"], - password: json["password"], - accessToken: json["access_token"], - ); - - Map toJson() => { - "id": id, - "username": username, - "password": password, - "access_token": accessToken, - }; -} diff --git a/lib/api_controller.dart b/lib/api_controller.dart new file mode 100644 index 0000000..0c3558d --- /dev/null +++ b/lib/api_controller.dart @@ -0,0 +1,37 @@ +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:tuuli_api/tuuli_api.dart'; + +class ApiController extends GetxController { + static ApiController get to => Get.find(); + + final apiStorageBox = GetStorage(); + + String get endPoint => apiStorageBox.read("endPoint") ?? ""; + set endPoint(String value) => apiStorageBox.write("endPoint", value); + + String get token => apiStorageBox.read("accessToken") ?? ""; + set token(String value) => apiStorageBox.write("accessToken", value); + + TuuliApi? _apiClientBase; + TuuliApi get apiClientBase { + _apiClientBase ??= TuuliApi( + dio: Dio(BaseOptions( + baseUrl: endPoint, + connectTimeout: 5000.milliseconds, + receiveTimeout: 3000.milliseconds, + receiveDataWhenStatusError: true, + )), + interceptors: [ + ApiKeyAuthInterceptor(), + ], + ); + _apiClientBase!.setApiKey("access-token", token); + return _apiClientBase!; + } + + DefaultApi get apiClient { + return apiClientBase.getDefaultApi(); + } +} diff --git a/lib/main.dart b/lib/main.dart index ac54169..6528de4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,26 +1,29 @@ 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_controller.dart'; import 'package:tuuli_app/pages/checkup_page.dart'; import 'package:tuuli_app/pages/home_page.dart'; +import 'package:tuuli_app/pages/home_panels/tables_list_panel.dart'; +import 'package:tuuli_app/pages/home_panels/users_list_panel.dart'; import 'package:tuuli_app/pages/login_page.dart'; 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, - builder: () { - final client = ApiClient.fromString("http://127.0.0.1:8000"); - final accessToken = GetStorage().read("accessToken"); - if (accessToken != null) { - client.setAccessToken(accessToken); - } - return client; - }, + Get.put(ApiController(), permanent: true); + Get.lazyPut( + () => CheckupPageController(), + fenix: true, + ); + Get.lazyPut( + () => LoginPageController(), + fenix: true, + ); + Get.lazyPut( + () => HomePageController(), + fenix: true, ); runApp(const MainApp()); @@ -34,7 +37,7 @@ class MainApp extends StatelessWidget { return GetMaterialApp( debugShowCheckedModeBanner: false, debugShowMaterialGrid: false, - initialRoute: "/login", + initialRoute: "/", onGenerateRoute: _onGenerateRoute, theme: ThemeData( brightness: Brightness.dark, diff --git a/lib/models/db_column_definition.dart b/lib/models/db_column_definition.dart new file mode 100644 index 0000000..3443120 --- /dev/null +++ b/lib/models/db_column_definition.dart @@ -0,0 +1,106 @@ +import 'package:tuuli_api/tuuli_api.dart'; + +extension ColumnsParser on TableDefinition { + List get parsedColumns { + return columns + .split(",") + .map((e) { + final parts = e.split(":"); + final name = parts[0]; + switch (parts[1]) { + case "serial": + return PrimarySerialColumnDefinition(name); + case "str": + return TextColumnDefinition( + name, + parts.contains("unique"), + parts.contains("default"), + ); + case "bool": + return BooleanColumnDefinition( + name, + parts.contains("unique"), + parts.contains("default"), + ); + case "datetime": + return TimestampColumnDefinition( + name, + parts.contains("unique"), + parts.contains("default"), + ); + case "float": + return DoubleColumnDefinition( + name, + parts.contains("unique"), + parts.contains("default"), + ); + case "int": + return IntegerColumnDefinition( + name, + parts.contains("unique"), + parts.contains("default"), + ); + case "int-user": + return UserRefColumnDefinition(name); + case "int-asset": + return AssetRefColumnDefinition(name); + } + + return null; + }) + .whereType() + .toList(growable: false); + } +} + +abstract class DBColumnDefinition { + final String name; + final bool unique; + final bool hasDefault; + + DBColumnDefinition({ + required this.name, + required this.unique, + required this.hasDefault, + }); +} + +class PrimarySerialColumnDefinition extends DBColumnDefinition { + PrimarySerialColumnDefinition(String name) + : super(name: name, unique: false, hasDefault: false); +} + +class TextColumnDefinition extends DBColumnDefinition { + TextColumnDefinition(String name, bool unique, bool hasDefault) + : super(name: name, unique: unique, hasDefault: hasDefault); +} + +class BooleanColumnDefinition extends DBColumnDefinition { + BooleanColumnDefinition(String name, bool unique, bool hasDefault) + : super(name: name, unique: unique, hasDefault: hasDefault); +} + +class TimestampColumnDefinition extends DBColumnDefinition { + TimestampColumnDefinition(String name, bool unique, bool hasDefault) + : super(name: name, unique: unique, hasDefault: hasDefault); +} + +class DoubleColumnDefinition extends DBColumnDefinition { + DoubleColumnDefinition(String name, bool unique, bool hasDefault) + : super(name: name, unique: unique, hasDefault: hasDefault); +} + +class IntegerColumnDefinition extends DBColumnDefinition { + IntegerColumnDefinition(String name, bool unique, bool hasDefault) + : super(name: name, unique: unique, hasDefault: hasDefault); +} + +class UserRefColumnDefinition extends DBColumnDefinition { + UserRefColumnDefinition(String name) + : super(name: name, unique: false, hasDefault: false); +} + +class AssetRefColumnDefinition extends DBColumnDefinition { + AssetRefColumnDefinition(String name) + : super(name: name, unique: false, hasDefault: false); +} diff --git a/lib/models/group_definition.dart b/lib/models/group_definition.dart new file mode 100644 index 0000000..22c4a17 --- /dev/null +++ b/lib/models/group_definition.dart @@ -0,0 +1,11 @@ +class GroupDefinition { + final int id; + final String name; + final String description; + + const GroupDefinition({ + required this.id, + required this.name, + required this.description, + }); +} diff --git a/lib/models/table_access.dart b/lib/models/table_access.dart new file mode 100644 index 0000000..5a9eea2 --- /dev/null +++ b/lib/models/table_access.dart @@ -0,0 +1,24 @@ +enum TableAccess { + read("r"), + write("w"), + readWrite("rw"), + none(""); + + final String def; + + const TableAccess(this.def); + + static TableAccess fromString(String? def) { + switch (def) { + case "r": + return TableAccess.read; + case "w": + return TableAccess.write; + case "rw": + return TableAccess.readWrite; + case "none": + default: + return TableAccess.none; + } + } +} diff --git a/lib/models/table_column_definition.dart b/lib/models/table_column_definition.dart new file mode 100644 index 0000000..c1d2975 --- /dev/null +++ b/lib/models/table_column_definition.dart @@ -0,0 +1,88 @@ +abstract class TableColumnDefinition { + final String columnName; + final bool isUnique; + + TableColumnDefinition({ + required this.columnName, + required this.isUnique, + }); + + String get def => throw UnimplementedError("def getter not implemented"); +} + +class SerialPrimaryColumnDefinition extends TableColumnDefinition { + SerialPrimaryColumnDefinition({ + required super.columnName, + }) : super(isUnique: true); + + @override + String get def => "$columnName:serial:primary"; +} + +class TextColumnDefinition extends TableColumnDefinition { + TextColumnDefinition({ + required super.columnName, + required super.isUnique, + }); + + @override + String get def => "$columnName:str${isUnique ? ":unique" : ""}"; +} + +class BooleanColumnDefinition extends TableColumnDefinition { + BooleanColumnDefinition({ + required super.columnName, + required super.isUnique, + }); + + @override + String get def => "$columnName:bool${isUnique ? ":unique" : ""}"; +} + +class TimestampColumnDefinition extends TableColumnDefinition { + TimestampColumnDefinition({ + required super.columnName, + required super.isUnique, + }); + + @override + String get def => "$columnName:datetime${isUnique ? ":unique" : ""}"; +} + +class DoubleColumnDefinition extends TableColumnDefinition { + DoubleColumnDefinition({ + required super.columnName, + required super.isUnique, + }); + + @override + String get def => "$columnName:float${isUnique ? ":unique" : ""}"; +} + +class IntegerColumnDefinition extends TableColumnDefinition { + IntegerColumnDefinition({ + required super.columnName, + required super.isUnique, + }); + + @override + String get def => "$columnName:int${isUnique ? ":unique" : ""}"; +} + +class UserRefColumnDefinition extends TableColumnDefinition { + UserRefColumnDefinition({ + required super.columnName, + }) : super(isUnique: false); + + @override + String get def => "$columnName:int-user"; +} + +class AssetRefColumnDefinition extends TableColumnDefinition { + AssetRefColumnDefinition({ + required super.columnName, + }) : super(isUnique: false); + + @override + String get def => "$columnName:int-asset"; +} diff --git a/lib/models/user_definition.dart b/lib/models/user_definition.dart new file mode 100644 index 0000000..31aa60a --- /dev/null +++ b/lib/models/user_definition.dart @@ -0,0 +1,13 @@ +class UserDefinition { + final int id; + final String username; + final String password; + final String accessToken; + + const UserDefinition({ + required this.id, + required this.username, + required this.password, + required this.accessToken, + }); +} diff --git a/lib/models/user_in_group_definition.dart b/lib/models/user_in_group_definition.dart new file mode 100644 index 0000000..360ed47 --- /dev/null +++ b/lib/models/user_in_group_definition.dart @@ -0,0 +1,11 @@ +class UserInGroupDefinition { + final int id; + final int userId; + final int groupId; + + const UserInGroupDefinition({ + required this.id, + required this.userId, + required this.groupId, + }); +} diff --git a/lib/pages/bottomsheets/create_table_item_bottomsheet.dart b/lib/pages/bottomsheets/create_table_item_bottomsheet.dart deleted file mode 100644 index 45c5621..0000000 --- a/lib/pages/bottomsheets/create_table_item_bottomsheet.dart +++ /dev/null @@ -1,198 +0,0 @@ -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( - widget.existingItem == null ? "Create new item" : "Update 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/create_user_bottomsheet.dart b/lib/pages/bottomsheets/create_user_bottomsheet.dart deleted file mode 100644 index 886ef86..0000000 --- a/lib/pages/bottomsheets/create_user_bottomsheet.dart +++ /dev/null @@ -1,217 +0,0 @@ -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:tuuli_app/api/model/user_model.dart'; -import 'package:tuuli_app/utils.dart'; -import 'package:uuid/uuid.dart'; -import 'package:uuid/uuid_util.dart'; - -class CreateUserResult { - final String username; - final String password; - - CreateUserResult(this.username, this.password); -} - -class UpdateUserResult { - final String username; - final String password; - final String accessToken; - - UpdateUserResult(this.username, this.password, this.accessToken); -} - -// TODO: Add a way to change user's group -class CreateUserBottomSheet extends StatefulWidget { - final UserModel? existingUser; - - const CreateUserBottomSheet({ - super.key, - this.existingUser, - }); - - @override - State createState() => _CreateUserBottomSheetState(); -} - -class _CreateUserBottomSheetState extends State { - final _formKey = GlobalKey(); - - String? newUsername; - String? newPassword; - String? newAccessToken; - - bool obscurePassword = true; - bool obscureToken = true; - - @override - void initState() { - super.initState(); - } - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16), - child: FastForm( - formKey: _formKey, - children: [ - Text( - widget.existingUser == null ? "Create new user" : "Update user", - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - Card( - margin: const EdgeInsets.all(8), - child: Container( - padding: const EdgeInsets.all(8), - child: FastTextField( - name: "Username", - labelText: "Username", - validator: (value) { - if (value == null || value.isEmpty) { - return "Please enter a value"; - } - return null; - }, - readOnly: widget.existingUser != null, - initialValue: widget.existingUser?.username, - onChanged: (value) { - newUsername = value; - }, - ), - ), - ), - Card( - margin: const EdgeInsets.all(8), - child: Container( - padding: const EdgeInsets.all(8), - child: Row( - children: [ - Flexible( - child: FastTextField( - name: "Password", - labelText: "Password", - validator: (value) { - if (value == null || value.isEmpty) { - return "Please enter a value"; - } - return null; - }, - obscureText: obscurePassword, - onChanged: (value) { - newPassword = value; - }, - ), - ), - IconButton( - icon: Icon( - obscurePassword - ? Icons.visibility_off - : Icons.visibility, - ), - onPressed: () { - setState(() { - obscurePassword = !obscurePassword; - }); - }, - ), - ], - ), - ), - ), - if (widget.existingUser != null) - Card( - margin: const EdgeInsets.all(8), - child: Container( - padding: const EdgeInsets.all(8), - child: Row( - children: [ - Flexible( - child: FastTextField( - name: "Access token", - labelText: "Access token", - validator: (value) { - if (value == null || value.isEmpty) { - return "Please enter a value"; - } - return null; - }, - obscureText: obscureToken, - initialValue: newAccessToken ?? - widget.existingUser?.accessToken, - readOnly: true, - onChanged: (value) { - newAccessToken = value; - }, - ), - ), - IconButton( - icon: const Icon(Icons.shuffle), - onPressed: () { - setState(() { - newAccessToken = randomHexString(64); - }); - }, - ), - IconButton( - icon: Icon( - obscurePassword - ? Icons.visibility_off - : Icons.visibility, - ), - onPressed: () { - setState(() { - obscureToken = !obscureToken; - }); - }, - ), - ], - ), - ), - ), - 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(widget.existingUser == null - ? CreateUserResult( - newUsername!, - newPassword!, - ) - : UpdateUserResult( - newUsername!, - newPassword!, - newAccessToken!, - )); - return; - } - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Please fill in all fields"), - ), - ); - }, - child: - Text(widget.existingUser == null ? "Create" : "Update"), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/pages/bottomsheets/edit_table_bottomsheet.dart b/lib/pages/bottomsheets/edit_table_bottomsheet.dart deleted file mode 100644 index 0bf8b4a..0000000 --- a/lib/pages/bottomsheets/edit_table_bottomsheet.dart +++ /dev/null @@ -1,242 +0,0 @@ -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/bottomsheets/open_table_bottomsheet.dart b/lib/pages/bottomsheets/open_table_bottomsheet.dart deleted file mode 100644 index 44b95db..0000000 --- a/lib/pages/bottomsheets/open_table_bottomsheet.dart +++ /dev/null @@ -1,257 +0,0 @@ -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; - - const OpenTableBottomSheet({super.key, required this.table}); - - @override - State createState() => _OpenTableBottomSheetState(); -} - -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 Padding( - padding: const EdgeInsets.all(16), - child: Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Text( - widget.table.tableName.pascalCase, - 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), - ), - IconButton( - onPressed: Get.back, - icon: const Icon(Icons.cancel), - ), - ], - ), - 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: () => _updateExistingItem(e), - icon: const Icon(Icons.edit), - ), - IconButton( - onPressed: () => _deleteItem(e), - icon: const Icon(Icons.delete), - ), - ], - ), - ), - ])) - .toList(growable: false), - empty: const Center(child: Text("No data")), - ), - ), - ], - ), - ), - ); - } - - Future _dropTable() async { - final really = await Get.defaultDialog( - title: "Drop table", - middleText: - "Are you sure you want to drop this table \"${widget.table.tableName}\"?", - textConfirm: "Drop", - onConfirm: () => Get.back(result: true), - onCancel: () {}, - barrierDismissible: false, - ); - - if (really != true) { - return; - } - - final result = await apiClient.dropTable(widget.table.tableName); - result.unfold((data) { - Get.back(); - }, (error) { - 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/lib/pages/checkup_page.dart b/lib/pages/checkup_page.dart index d25468a..a4fe508 100644 --- a/lib/pages/checkup_page.dart +++ b/lib/pages/checkup_page.dart @@ -1,65 +1,59 @@ -import 'package:animated_background/animated_background.dart'; 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_controller.dart'; -class CheckupPage extends StatefulWidget { - const CheckupPage({super.key}); +class CheckupPageController extends GetxController { + Future checkCredentials() async { + if (ApiController.to.token.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ApiController.to.token = ""; + Get.offAllNamed("/login"); + }); + } else { + try { + final resp = await ApiController.to.apiClient.listTables(); - @override - State createState() => _CheckupPageState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (resp.statusCode == 200) { + Get.offAllNamed("/home"); + } else { + Get.offAllNamed("/login"); + } + }); + } catch (e) { + WidgetsBinding.instance.addPostFrameCallback((_) { + Get.offAllNamed("/login"); + }); + } + } + } } -class _CheckupPageState extends State - with TickerProviderStateMixin { - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - checkCredentials(); - }); - } +class CheckupPage extends GetView { + const CheckupPage({super.key}); @override Widget build(BuildContext context) { 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( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Checking credentials...', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 16), + FutureBuilder( + future: controller.checkCredentials(), + builder: (ctx, _) => const SizedBox.square( dimension: 32, child: CircularProgressIndicator(), ), - ], - ), + ), + ], ), ), ); } - - Future checkCredentials() async { - final accessToken = GetStorage().read("accessToken"); - if (accessToken == null) { - Get.offAllNamed("/login"); - } else { - final apiClient = Get.find(); - (await apiClient.tablesList()).unfold((data) { - Get.offAllNamed("/home"); - }, (error) async { - await GetStorage().remove("accessToken"); - Get.offAllNamed("/login"); - }); - } - } } diff --git a/lib/pages/dialogs/create_table_dialog.dart b/lib/pages/dialogs/create_table_dialog.dart new file mode 100644 index 0000000..7d9e014 --- /dev/null +++ b/lib/pages/dialogs/create_table_dialog.dart @@ -0,0 +1,251 @@ +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:styled_widget/styled_widget.dart'; +import 'package:tuuli_app/api_controller.dart'; +import 'package:tuuli_app/models/table_column_definition.dart'; + +const typeToNameMatcher = { + SerialPrimaryColumnDefinition: "Serial ID", + TextColumnDefinition: "Text", + BooleanColumnDefinition: "Boolean", + TimestampColumnDefinition: "Date/Time", + DoubleColumnDefinition: "Double", + IntegerColumnDefinition: "Integer", + UserRefColumnDefinition: "User reference", + AssetRefColumnDefinition: "Asset reference", +}; + +class CreateTableController extends GetxController { + final _tableName = "".obs; + String get tableName => _tableName.value; + set tableName(String value) => _tableName.value = value; + + final _columnsDefinition = [].obs; + List get columnsDefinition => _columnsDefinition; + + Future createTable() async { + try { + final resp = await ApiController.to.apiClient.createTable( + tableName: tableName, + columnsDefinition: + _columnsDefinition.map((e) => e.def).toList(growable: false), + ); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + Get.back(result: true); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to update tables access", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to update tables access", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to update tables access", + "$e", + ); + } + } + + Future createNewColumn() async { + final columnName = "".obs; + final columnType = "".obs; + final columnIsUnique = false.obs; + + final confirm = await Get.dialog( + AlertDialog( + title: const Text("Create new column"), + content: Wrap( + runSpacing: 16, + children: [ + FastTextField( + name: "ColumnName", + decoration: const InputDecoration( + labelText: "Column name", + border: OutlineInputBorder(), + ), + initialValue: columnName.value, + onChanged: (value) => columnName.value = value ?? "", + ), + FastDropdown( + name: "ColumnTypes", + hint: const Text("Select column type"), + items: typeToNameMatcher.keys.toList(growable: false), + itemsBuilder: (items, field) => items + .map( + (e) => DropdownMenuItem( + value: e, + child: Text(typeToNameMatcher[e]!), + ), + ) + .toList(), + onChanged: (value) => + columnType.value = typeToNameMatcher[value] ?? "", + ), + FastCheckbox( + name: "ColumnIsUnique", + titleText: "Is Unique", + initialValue: false, + onChanged: (value) => columnIsUnique.value = value!, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Get.back(result: false); + }, + child: const Text('Close'), + ), + TextButton( + onPressed: () { + Get.back(result: true); + }, + child: const Text('Create'), + ), + ], + ), + ); + + if (confirm != true) return; + + TableColumnDefinition? ct; + switch (columnType.value) { + case "Serial ID": + ct = SerialPrimaryColumnDefinition( + columnName: columnName.value, + ); + break; + case "Text": + ct = TextColumnDefinition( + columnName: columnName.value, + isUnique: columnIsUnique.value, + ); + break; + case "Boolean": + ct = BooleanColumnDefinition( + columnName: columnName.value, + isUnique: columnIsUnique.value, + ); + break; + case "Date/Time": + ct = TimestampColumnDefinition( + columnName: columnName.value, + isUnique: columnIsUnique.value, + ); + break; + case "Double": + ct = DoubleColumnDefinition( + columnName: columnName.value, + isUnique: columnIsUnique.value, + ); + break; + case "Integer": + ct = IntegerColumnDefinition( + columnName: columnName.value, + isUnique: columnIsUnique.value, + ); + break; + case "User reference": + ct = UserRefColumnDefinition( + columnName: columnName.value, + ); + break; + case "Asset reference": + ct = AssetRefColumnDefinition( + columnName: columnName.value, + ); + break; + } + if (ct == null) return; + _columnsDefinition.add(ct); + } + + void removeColumn(TableColumnDefinition e) { + _columnsDefinition.remove(e); + } +} + +class CreateTableDialog extends GetView { + const CreateTableDialog({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Creating new table'), + content: Column( + children: [ + TextField( + decoration: const InputDecoration( + labelText: "Table name", + border: OutlineInputBorder(), + ), + onChanged: (value) => controller.tableName = value, + ), + const Divider(), + Obx( + () => ListView( + children: controller.columnsDefinition + .map( + (e) => ListTile( + title: Text(e.columnName), + subtitle: Text(e.def), + trailing: IconButton( + onPressed: () => controller.removeColumn(e), + icon: const Icon(Icons.delete), + ), + ), + ) + .toList(), + ).expanded(), + ), + const Divider(), + [ + ElevatedButton( + onPressed: () => controller.createNewColumn(), + child: const Text("Create column"), + ).expanded() + ].toRow(), + ], + ).constrained(width: Get.width * 0.9, height: Get.height * 0.9), + actions: [ + TextButton( + onPressed: () { + Get.back(); + }, + child: const Text('Close'), + ), + Obx( + () => TextButton( + onPressed: controller.columnsDefinition.isEmpty + ? null + : () => controller.createTable(), + child: const Text('Create'), + ), + ), + ], + ); + } + + static Future show() async { + Get.lazyPut(() => CreateTableController()); + + return Get.dialog( + const CreateTableDialog(), + barrierDismissible: false, + ); + } +} diff --git a/lib/pages/dialogs/group_acl_dialog.dart b/lib/pages/dialogs/group_acl_dialog.dart new file mode 100644 index 0000000..84a188f --- /dev/null +++ b/lib/pages/dialogs/group_acl_dialog.dart @@ -0,0 +1,409 @@ +import 'package:data_table_2/data_table_2.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart' hide Response; +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/group_definition.dart'; +import 'package:tuuli_app/models/table_access.dart'; + +class GroupACLController extends GetxController { + final GroupDefinition group; + + GroupACLController(this.group); + + @override + void onInit() { + super.onInit(); + + refreshData(); + } + + final _tables = [].obs; + List get tables => _tables.toList(); + + final _access = {}.obs; + Map get access => _access; + + final _allowedColumns = {}.obs; + Map get allowedColumns => _allowedColumns; + + Future refreshData() async { + await refreshTables(); + for (final table in tables) { + _access[table.tableId] = TableAccess.none; + } + + for (final table in tables) { + await refreshTableAccess(table); + } + } + + Future refreshTables() async { + try { + final resp = await ApiController.to.apiClient.listTables(); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + _tables.clear(); + _tables.addAll(respData.where((e) => e.hidden != true)); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to get tables", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to get tables", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to get tables", + "$e", + ); + } + } + + Future refreshTableAccess(TableDefinition table) async { + try { + final resp = await ApiController.to.apiClient.getItemsFromTable( + tableName: "table_access", + itemsSelector: ItemsSelector( + fields: ["access_type", "allowed_columns"], + where: [ + ColumnConditionCompat( + column: "user_group_id", + operator_: ColumnConditionCompatOperator.eq, + value: group.id, + ), + ColumnConditionCompat( + column: "table_name", + operator_: ColumnConditionCompatOperator.eq, + value: table.tableName, + ), + ], + ), + ); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + if (respData.isNotEmpty) { + _access[table.tableId] = + TableAccess.fromString(respData.first["access_type"]); + _allowedColumns[table.tableId] = respData.first["allowed_columns"]; + } else { + _access[table.tableId] = TableAccess.none; + _allowedColumns[table.tableId] = null; + } + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to get tables access", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to get tables access", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to get tables access", + "$e", + ); + } + } + + Future updateTableAccess( + TableDefinition table, { + required bool read, + required bool write, + }) async { + final oldTableAccess = _access[table.tableId]; + var newTableAccess = TableAccess.none; + if (read && write) { + newTableAccess = TableAccess.readWrite; + } else if (read) { + newTableAccess = TableAccess.read; + } else if (write) { + newTableAccess = TableAccess.write; + } + + try { + Response resp; + if (newTableAccess == TableAccess.none) { + resp = await ApiController.to.apiClient.deleteItemFromTable( + tableName: "table_access", + columnConditionCompat: [ + ColumnConditionCompat( + column: "user_group_id", + operator_: ColumnConditionCompatOperator.eq, + value: group.id, + ), + ColumnConditionCompat( + column: "table_name", + operator_: ColumnConditionCompatOperator.eq, + value: table.tableName, + ), + ], + ); + } else if (oldTableAccess == TableAccess.none) { + resp = await ApiController.to.apiClient.createItem( + tableName: "table_access", + itemDefinition: { + "user_group_id": group.id, + "table_name": table.tableName, + "access_type": newTableAccess.def, + }, + ); + } else { + resp = await ApiController.to.apiClient.updateItemInTable( + tableName: "table_access", + itemUpdate: ItemUpdate( + item: { + "access_type": newTableAccess.def, + }, + oldItem: { + "user_group_id": group.id, + "table_name": table.tableName, + }, + ), + ); + } + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + refreshTableAccess(table); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to update tables access", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to update tables access", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to update tables access", + "$e", + ); + } + } + + Future changeAllowedColumns(TableDefinition table) async { + final tableColumns = table.columns + .split(",") + .map((e) => e.split(":").first) + .where((e) => e.isNotEmpty) + .toList(); + final currentlyAvailableColumns = + _allowedColumns[table.tableId]!.split(","); + + if (currentlyAvailableColumns.length == 1 && + currentlyAvailableColumns.first == "*") { + currentlyAvailableColumns.clear(); + currentlyAvailableColumns.addAll(tableColumns); + } + + final selectedColumns = {}.obs; + for (final column in tableColumns) { + selectedColumns[column] = currentlyAvailableColumns.contains(column); + } + + final confirm = await Get.dialog( + AlertDialog( + title: const Text("Allowed columns"), + content: Obx( + () => Wrap( + children: [ + ElevatedButton( + onPressed: () { + for (final column in tableColumns) { + selectedColumns[column] = !selectedColumns[column]!; + } + }, + child: const Text("Swap all"), + ), + ...selectedColumns.entries.map((e) { + return CheckboxListTile( + title: Text(e.key), + value: e.value, + onChanged: (value) => selectedColumns[e.key] = value!, + ); + }), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => Get.back(result: true), + child: const Text("Ok"), + ), + ], + ), + ); + + if (confirm != true) return; + + if (selectedColumns.values.every((e) => !e)) { + await Get.dialog( + AlertDialog( + title: const Text("Error"), + content: const Text("At least one column must be selected"), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: const Text("Ok"), + ), + ], + ), + ); + return; + } + + try { + Response resp = + await ApiController.to.apiClient.updateItemInTable( + tableName: "table_access", + itemUpdate: ItemUpdate( + item: { + "allowed_columns": selectedColumns.keys + .where((k) => selectedColumns[k]!) + .join(","), + }, + oldItem: { + "user_group_id": group.id, + "table_name": table.tableName, + }, + ), + ); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + refreshTableAccess(table); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to update tables access", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to update tables access", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to update tables access", + "$e", + ); + } + } +} + +class GroupACLDialog extends GetView { + const GroupACLDialog({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Group ACL'), + content: Obx( + () => DataTable2( + columns: const [ + DataColumn2(label: Text('Table'), size: ColumnSize.L), + DataColumn2(label: Text('Read'), size: ColumnSize.S), + DataColumn2(label: Text('Write'), size: ColumnSize.S), + DataColumn2(label: Text('Allowed columns'), size: ColumnSize.S), + ], + empty: const Text("No tables"), + rows: controller.access.entries.map((e) { + final table = controller.tables.firstWhere( + (element) => element.tableId == e.key, + ); + final tableAccess = e.value; + final read = tableAccess == TableAccess.read || + tableAccess == TableAccess.readWrite; + final write = tableAccess == TableAccess.write || + tableAccess == TableAccess.readWrite; + return DataRow(cells: [ + DataCell(Text(table.tableName.pascalCase)), + DataCell(Checkbox( + value: read, + onChanged: (value) => controller.updateTableAccess( + table, + read: value ?? false, + write: write, + ), + )), + DataCell(Checkbox( + value: write, + onChanged: (value) => controller.updateTableAccess( + table, + read: read, + write: value ?? false, + ), + )), + controller.allowedColumns[table.tableId] == null + ? DataCell.empty + : DataCell( + Text(controller.allowedColumns[table.tableId]!), + onTap: () => controller.changeAllowedColumns(table), + ), + ]); + }).toList(growable: false), + ).constrained(width: Get.width * 0.9, height: Get.height * 0.9), + ), + actions: [ + TextButton( + onPressed: () { + Get.back(); + }, + child: const Text('Close'), + ), + ], + ); + } + + static Future show(GroupDefinition group) async { + Get.lazyPut(() => GroupACLController(group)); + + await Get.dialog( + const GroupACLDialog(), + barrierDismissible: false, + ); + + Get.delete(); + } +} diff --git a/lib/pages/dialogs/open_table_dialog.dart b/lib/pages/dialogs/open_table_dialog.dart new file mode 100644 index 0000000..ceb6cd0 --- /dev/null +++ b/lib/pages/dialogs/open_table_dialog.dart @@ -0,0 +1,944 @@ +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: "", + mime: "", + )) + .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: "", + mime: "", + )); + + 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_page.dart b/lib/pages/home_page.dart index 26e351c..88f3adb 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,9 +1,9 @@ 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/api_controller.dart'; import 'package:tuuli_app/c.dart'; +import 'package:tuuli_app/pages/home_panels/assets_panel.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'; @@ -13,47 +13,65 @@ enum PageType { none, tables, users, + assets, settings, } -mixin HomePageStateRef { - void refreshData(); -} +class HomePageController extends GetxController { + final _currentPage = PageType.none.obs; + PageType get currentPage => _currentPage.value; + set currentPage(PageType value) => _currentPage.value = value; -class HomePage extends StatefulWidget { - const HomePage({super.key}); + String get currentPageName => pageNames[currentPage]!; - @override - State createState() => _HomePageState(); -} - -class _HomePageState extends State with HomePageStateRef { - final apiClient = Get.find(); - TablesListModel tables = TablesListModel([]); - - TableModel get usersTable => - tables.tables.firstWhere((element) => element.tableName == "users"); - - 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.assets: "Assets", PageType.settings: "Settings", }; + @override + void onInit() { + super.onInit(); + + Get.lazyPut( + () => TablesListPanelController(), + fenix: true, + ); + Get.lazyPut( + () => UserListPanelController(), + fenix: true, + ); + Get.lazyPut( + () => AssetsPagePanelController(), + fenix: true, + ); + } + + Future logout() async { + ApiController.to.token = ""; + + await Future.wait([ + Get.delete(), + Get.delete(), + Get.delete(), + Get.delete(), + ]); + + Get.offAllNamed("/"); + } +} + +class HomePage extends GetView { + const HomePage({super.key}); + + bool get isNarrow => + (Get.mediaQuery.size.width - C.materialDrawerWidth) <= 600; + AppBar get appBar => AppBar( - title: Text(pageNames[currentPage]!), + title: Obx(() => Text(controller.currentPageName)), actions: [ IconButton( icon: const Icon(Icons.home), @@ -62,51 +80,59 @@ class _HomePageState extends State with HomePageStateRef { }, ), IconButton( - icon: const Icon(Icons.refresh), - onPressed: refreshData, + icon: const Icon(Icons.logout), + onPressed: () => controller.logout(), ), ], ); ListView get drawerOptions => ListView( children: [ - ListTile( - leading: const Icon(Icons.table_chart), - title: const Text("Tables"), - onTap: () { - setState(() { - currentPage = PageType.tables; - }); - }, + Obx( + () => ListTile( + leading: const Icon(Icons.table_chart), + title: const Text("Tables"), + onTap: () { + controller.currentPage = PageType.tables; + }, + selected: controller.currentPage == PageType.tables, + ), ), - ListTile( - leading: const Icon(Icons.person), - title: const Text("Users"), - onTap: () { - setState(() { - currentPage = PageType.users; - }); - }, + Obx( + () => ListTile( + leading: const Icon(Icons.person), + title: const Text("Users"), + onTap: () { + controller.currentPage = PageType.users; + }, + selected: controller.currentPage == PageType.users, + ), ), - ListTile( - leading: const Icon(Icons.settings), - title: const Text("Settings"), - onTap: () { - setState(() { - currentPage = PageType.settings; - }); - }, + Obx( + () => ListTile( + leading: const Icon(Icons.dataset_outlined), + title: const Text("Assets"), + onTap: () { + controller.currentPage = PageType.assets; + }, + selected: controller.currentPage == PageType.assets, + ), + ), + Obx( + () => ListTile( + leading: const Icon(Icons.settings), + title: const Text("Settings"), + onTap: () { + controller.currentPage = PageType.settings; + }, + selected: controller.currentPage == PageType.settings, + ), ), const Divider(), ListTile( leading: const Icon(Icons.logout), title: const Text("Logout"), - onTap: () { - GetStorage().erase().then((value) { - GetStorage().save(); - Get.offAllNamed("/"); - }); - }, + onTap: () => controller.logout(), ), ], ); @@ -140,12 +166,14 @@ class _HomePageState extends State with HomePageStateRef { ), LimitedBox( maxWidth: MediaQuery.of(context).size.width - C.materialDrawerWidth, - child: Builder(builder: (context) { - switch (currentPage) { + child: Obx(() { + switch (controller.currentPage) { case PageType.tables: - return TablesListPanel(parent: this, tables: tables); + return const TablesListPanel(); case PageType.users: - return UsersListPanel(usersTable: usersTable); + return const UsersListPanel(); + case PageType.assets: + return const AssetsPagePanel(); case PageType.settings: return const SettingsPanel(); case PageType.none: @@ -158,25 +186,4 @@ class _HomePageState extends State with HomePageStateRef { ), ); } - - @override - 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/assets_panel.dart b/lib/pages/home_panels/assets_panel.dart new file mode 100644 index 0000000..9b24c1a --- /dev/null +++ b/lib/pages/home_panels/assets_panel.dart @@ -0,0 +1,568 @@ +import 'package:data_table_2/data_table_2.dart'; +import 'package:dio/dio.dart'; +import 'package:file_icon/file_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_fast_forms/flutter_fast_forms.dart'; +import 'package:get/get.dart' hide MultipartFile; +import 'package:photo_view/photo_view.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:super_drag_and_drop/super_drag_and_drop.dart'; +import 'package:tuuli_api/tuuli_api.dart'; +import 'package:tuuli_app/api_controller.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:tuuli_app/widgets/audio_player_widget.dart'; + +class AssetsPagePanelController extends GetxController { + @override + void onInit() { + super.onInit(); + + refreshData(); + } + + final scrollController = ScrollController(); + + final _isLoading = false.obs; + bool get isLoading => _isLoading.value; + + final _assetsList = [].obs; + List get assetsList => _assetsList.toList(); + + final _tagsList = [].obs; + List get tagsList => _tagsList.toList(); + + final _filterTags = [].obs; + List get filterTags => _filterTags.toList(); + set filterTags(List value) => _filterTags.value = value; + + Future refreshData() async { + _isLoading.value = true; + + await Future.wait([ + refreshAssets(), + refreshTag(), + ]); + + _isLoading.value = false; + } + + Future refreshAssets() async { + try { + final resp = await ApiController.to.apiClient.getAssets(); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + _assetsList.clear(); + _assetsList.addAll(respData); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to get assets", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to get assets", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to get assets", + "$e", + ); + } + } + + Future refreshTag() async { + try { + final resp = await ApiController.to.apiClient.getAssetsTags(); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + _tagsList.clear(); + _tagsList.addAll(respData); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to get tags", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to get tags", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to get tags", + "$e", + ); + } + } + + Future openUploadDialog() async { + final file = await Get.dialog( + AlertDialog( + content: DropRegion( + formats: Formats.standardFormats, + hitTestBehavior: HitTestBehavior.opaque, + onDropOver: (event) { + if (event.session.items.length == 1 && + event.session.allowedOperations.contains(DropOperation.copy)) { + return DropOperation.copy; + } + return DropOperation.none; + }, + onPerformDrop: (event) async { + final item = event.session.items.first; + final reader = item.dataReader; + if (reader == null) return; + + reader.getFile( + null, + (dataReader) async { + final data = await dataReader.readAll(); + + final fileName = + dataReader.fileName ?? await reader.getSuggestedName(); + const mimeType = "application/octet-stream"; + + final file = MultipartFile.fromBytes( + data, + filename: fileName, + contentType: MediaType.parse(mimeType), + ); + Get.back(result: file); + }, + onError: (value) { + Get.snackbar("Error", value.toString()); + }, + ); + }, + child: const Text("Drop file here") + .paddingAll(8) + .fittedBox() + .constrained(height: 200, width: 200), + ).border(all: 2, color: Colors.lightBlueAccent), + actions: [ + TextButton( + onPressed: () => Get.back(result: null), + child: const Text("Cancel"), + ) + ], + ), + ); + + if (file == null) return; + + final sendProgress = 0.obs; + final receiveProgress = 0.obs; + final req = ApiController.to.apiClient.putAsset( + asset: file, + onSendProgress: (count, _) { + sendProgress.value = count; + }, + onReceiveProgress: (count, _) { + receiveProgress.value = count; + }, + ); + Get.dialog( + SizedBox( + width: 32, + height: 32, + child: Obx( + () => CircularProgressIndicator( + value: + sendProgress.value == 0 ? null : sendProgress.value.toDouble(), + ), + ), + ).paddingAll(32).card().center(), + barrierDismissible: false, + ); + + try { + final resp = await req; + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + refreshData(); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to put asset", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to put asset", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to put asset", + "$e", + ); + } finally { + Get.back(); + } + } + + Future editAsset(Asset e) async { + final description = e.description.obs; + final tags = e.tags.split(",").obs; + + final confirm = await Get.dialog( + AlertDialog( + title: const Text("Edit asset"), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: TextEditingController(text: description.value), + onChanged: (value) => description.value = value, + decoration: const InputDecoration( + labelText: "Description", + ), + ), + TextField( + controller: TextEditingController(text: tags.join(", ")), + onChanged: (value) => tags.value = + value.split(",").map((e) => e.trim()).toList(growable: false), + decoration: const InputDecoration( + labelText: "Tags", + ), + ), + const SizedBox(height: 16), + Obx( + () => Wrap( + children: tags + .where((p0) => p0.isNotEmpty) + .map((tag) => Chip(label: Text(tag))) + .toList(growable: false), + ).paddingAll(8).card(color: Colors.blueGrey.shade200).expanded(), + ), + ElevatedButton( + onPressed: () => previewAsset(e), + child: const Text("View asset")), + ], + ).constrained(width: Get.width * 0.5, height: Get.width * 0.5), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => Get.back(result: true), + child: const Text("Confirm"), + ), + ], + ), + ); + + if (confirm != true) return; + + try { + final resp = + await ApiController.to.apiClient.updateAssetDescriptionAndTags( + assetId: e.id, + assetDescription: description.value, + assetTags: tags, + ); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + refreshData(); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to edit asset", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to edit asset", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to edit asset", + "$e", + ); + } + } + + Future removeAsset(Asset e) async { + final checkReferences = false.obs; + final deleteReferencing = false.obs; + + final confirm = await Get.dialog( + AlertDialog( + title: const Text("Remove asset"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text("You are about to remove an asset."), + Obx( + () => CheckboxListTile( + value: checkReferences.value, + onChanged: (value) => checkReferences.value = value ?? false, + title: const Text("Check references"), + ), + ), + Obx( + () => CheckboxListTile( + value: deleteReferencing.value, + onChanged: (value) => deleteReferencing.value = value ?? false, + title: const Text("Delete referencing"), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => Get.back(result: true), + child: const Text("Remove"), + ), + ], + ), + ); + + if (confirm != true) return; + + try { + final resp = await ApiController.to.apiClient.removeAsset( + assetId: e.id, + checkReferences: checkReferences.value, + deleteReferencing: deleteReferencing.value, + ); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + refreshData(); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to remove asset", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to remove asset", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to remove asset", + "$e", + ); + } + } + + void previewAsset(Asset e) { + Get.dialog( + Stack( + children: [ + if (e.mime.split("/").first == "image") + PhotoView( + imageProvider: + NetworkImage("${ApiController.to.endPoint}/assets/${e.fid}"), + enablePanAlways: true, + ) + else if (e.mime.split("/").first == "audio") + AudioPlayerWidget.create( + title: e.name, + url: "${ApiController.to.endPoint}/assets/${e.fid}", + ) + else + const Text("Unsupported media type") + .fontSize(16) + .paddingAll(8) + .card() + .center(), + Positioned( + top: 8, + right: 8, + child: Material( + color: Colors.transparent, + child: IconButton( + onPressed: () => Get.back(), + icon: const Icon(Icons.close), + ), + ), + ), + ], + ), + ); + } +} + +class AssetsPagePanel extends GetView { + const AssetsPagePanel({super.key}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Column( + children: [ + AppBar( + elevation: 0, + title: Row( + children: [ + const Text("Tags:"), + Obx( + () => FastChipsInput( + name: "FastChipsInput", + options: controller.tagsList, + crossAxisAlignment: WrapCrossAlignment.center, + decoration: const InputDecoration( + border: InputBorder.none, + ), + chipBuilder: (chipValue, chipIndex, field) => InputChip( + label: Text(chipValue), + isEnabled: field.widget.enabled, + onDeleted: () => field + .didChange([...field.value!]..remove(chipValue)), + selected: chipIndex == field.selectedChipIndex, + showCheckmark: false, + backgroundColor: Colors.green.shade200, + ), + onChanged: (value) => controller.filterTags = value ?? [], + ), + ).expanded(), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => controller.openUploadDialog(), + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => controller.refreshData(), + ), + ], + ), + Expanded( + child: assetsPanel, + ), + ], + ), + Obx( + () => Positioned( + right: 16, + bottom: 16, + child: controller.isLoading + ? const CircularProgressIndicator() + : const SizedBox(), + ), + ), + ], + ); + } + + Widget get assetsPanel => Obx( + () => DataTable2( + horizontalScrollController: controller.scrollController, + columns: const [ + DataColumn2(label: Text(""), fixedWidth: 16), + DataColumn2(label: Text("Filename"), size: ColumnSize.M), + DataColumn2(label: Text("Description"), size: ColumnSize.L), + DataColumn2(label: Text("File ID"), size: ColumnSize.M), + DataColumn2(label: Text("Tags"), size: ColumnSize.L), + DataColumn2(label: Text("Actions")), + ], + empty: const Text("No assets found"), + rows: controller.assetsList + .where((element) { + if (controller.filterTags.isEmpty) return true; + + return element.tags + .split(",") + .any(controller.filterTags.contains); + }) + .map((e) => DataRow2( + cells: [ + DataCell(FileIcon(e.name)), + DataCell(Tooltip( + message: e.name, + child: Text( + e.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + )), + DataCell(Tooltip( + message: e.description, + child: Text( + e.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + )), + DataCell(Tooltip( + message: e.fid, + child: Text( + e.fid, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + )), + DataCell(Tooltip( + message: e.tags, + child: Text( + e.tags, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + )), + DataCell(Row(children: [ + IconButton( + icon: const Icon(Icons.preview), + onPressed: () => controller.previewAsset(e), + ), + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => controller.editAsset(e), + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () => controller.removeAsset(e), + ), + ])), + ], + )) + .toList(growable: false), + ), + ); +} diff --git a/lib/pages/home_panels/tables_list_panel.dart b/lib/pages/home_panels/tables_list_panel.dart index 569269f..90987bd 100644 --- a/lib/pages/home_panels/tables_list_panel.dart +++ b/lib/pages/home_panels/tables_list_panel.dart @@ -1,110 +1,152 @@ -import 'package:bottom_sheet/bottom_sheet.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:tuuli_api/tuuli_api.dart'; +import 'package:tuuli_app/api_controller.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/bottomsheets/edit_table_bottomsheet.dart'; -import 'package:tuuli_app/pages/bottomsheets/open_table_bottomsheet.dart'; -import 'package:tuuli_app/pages/home_page.dart'; - -class TablesListPanel extends StatefulWidget { - final TablesListModel tables; - final HomePageStateRef parent; - - const TablesListPanel({ - super.key, - required this.tables, - required this.parent, - }); +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 - State createState() => _TablesListPanelState(); + void onInit() { + super.onInit(); + + refreshData(); + } + + final _isLoading = false.obs; + bool get isLoading => _isLoading.value; + + final _tables = [].obs; + List get tables => _tables; + + Future refreshData() async { + _isLoading.value = true; + try { + final resp = await ApiController.to.apiClient.listTables(); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + _tables.clear(); + _tables.addAll(respData); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to get tables", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to get tables", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to get tables", + "$e", + ); + } + _isLoading.value = false; + } + + Future createNewTable() async { + final created = await CreateTableDialog.show(); + if (created == true) { + refreshData(); + } + } + + Future openTable(TableDefinition table) async { + await OpenTableDialog.show(table); + } + + Future deleteTable(TableDefinition table) async { + try { + final resp = await ApiController.to.apiClient.dropTable( + tableName: table.tableName, + ); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + Get.snackbar( + "Table deleted", + "${table.tableName.pascalCase} was deleted", + ); + refreshData(); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to delete table", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to delete table", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to delete table", + "$e", + ); + } + } } -class _TablesListPanelState extends State { - final apiClient = Get.find(); - - @override - void initState() { - super.initState(); - } +class TablesListPanel extends GetView { + const TablesListPanel({super.key}); @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, - ), - ), - ], - ) - ], - ), - ], + return Stack( + children: [ + Column( + children: [ + AppBar( + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => controller.createNewTable(), + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => controller.refreshData(), + ), + ], + ), + Expanded( + child: Obx( + () => controller.tables.isEmpty ? whenNoTables : cardGrid), + ), + ], + ), + Obx( + () => Positioned( + right: 16, + bottom: 16, + child: controller.isLoading + ? const CircularProgressIndicator() + : const SizedBox(), ), ), - ), + ], ); } - 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( + Widget get whenNoTables => Center( child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, @@ -113,7 +155,7 @@ class _TablesListPanelState extends State { Text( "No tables found", style: TextStyle( - color: Theme.of(context).disabledColor, + color: Get.theme.disabledColor, fontSize: 24, ), ), @@ -121,81 +163,71 @@ class _TablesListPanelState extends State { Text( "Maybe create one?", style: TextStyle( - color: Theme.of(context).disabledColor, + color: Get.theme.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, + Widget get cardGrid => GridView.count( + shrinkWrap: true, + crossAxisCount: 3, childAspectRatio: 5 / 2, + children: [...controller.tables.map((e) => _buildTableCard(e))], + ); + + Widget _buildTableCard(TableDefinition table) { + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => controller.openTable(table), + child: Container( + margin: const EdgeInsets.all(5), + padding: const EdgeInsets.all(5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + table.tableName.pascalCase, + maxLines: 1, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, + ), + ), + Row( + children: [ + Text( + table.system ? "System" : "Userland", + style: const TextStyle( + fontSize: 12, + ), + ), + ], + ), + Row( + children: [ + Text( + "${table.parsedColumns.length} column(s)", + style: const TextStyle( + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + if (!table.system) + ElevatedButton( + onPressed: () => controller.deleteTable(table), + child: const Text("Delete"), + ).paddingOnly(top: 8) + ], + ), + ), ), ); } - - 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) => WidgetsBinding.instance.addPostFrameCallback((_) { - e.unfold((_) { - Get.snackbar( - "Success", - "Table created", - colorText: Colors.white, - ); - }, (error) { - Get.defaultDialog( - title: "Error", - middleText: error.toString(), - textConfirm: "OK", - onConfirm: () => Get.back(), - ); - }); - })); - - widget.parent.refreshData(); - } - - void _openTable(TableModel table) async { - await showFlexibleBottomSheet( - minHeight: 1, - initHeight: 1, - maxHeight: 1, - context: context, - builder: (_, __, ___) => OpenTableBottomSheet(table: table), - anchors: [0, 0.5, 1], - isSafeArea: true, - isDismissible: false, - ); - - widget.parent.refreshData(); - } } diff --git a/lib/pages/home_panels/users_list_panel.dart b/lib/pages/home_panels/users_list_panel.dart index aa316f5..5624918 100644 --- a/lib/pages/home_panels/users_list_panel.dart +++ b/lib/pages/home_panels/users_list_panel.dart @@ -1,225 +1,921 @@ -import 'package:bottom_sheet/bottom_sheet.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/json_object.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:tuuli_app/api/api_client.dart'; -import 'package:tuuli_app/api/model/tables_list_model.dart'; -import 'package:tuuli_app/api/model/user_model.dart'; -import 'package:tuuli_app/pages/bottomsheets/create_user_bottomsheet.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/group_definition.dart'; +import 'package:tuuli_app/models/user_definition.dart'; +import 'package:tuuli_app/models/user_in_group_definition.dart'; +import 'package:tuuli_app/pages/dialogs/group_acl_dialog.dart'; -class UsersListPanel extends StatefulWidget { - final TableModel usersTable; - - const UsersListPanel({super.key, required this.usersTable}); - - @override - State createState() => _UsersListPanelState(); +enum UserListPanelTab { + users, + groups, } -class _UsersListPanelState extends State { - final apiClient = Get.find(); - - final users = []; - +class UserListPanelController extends GetxController { @override - void initState() { - super.initState(); + void onInit() { + super.onInit(); - _refreshUsers(); + refreshData(); } - @override - Widget build(BuildContext context) { - return _buildUserList(); + final _currentTab = UserListPanelTab.users.obs; + UserListPanelTab get currentTab => _currentTab.value; + set currentTab(UserListPanelTab value) => _currentTab.value = value; + + final _isLoading = false.obs; + bool get isLoading => _isLoading.value; + + final _users = [].obs; + List get users => _users; + + final _groups = [].obs; + List get groups => _groups; + + final _usersInGroups = >{}.obs; + Map> get usersInGroups => + _usersInGroups; + + Future refreshData() async { + _isLoading.value = true; + + await Future.wait([ + refreshUsers(), + refreshGroups(), + ]); + + await Future.wait([ + for (final group in groups) getGroupUsers(group), + ]); + + _isLoading.value = false; } - Widget _buildUserCard(BuildContext ctx, UserModel user) { - return Card( - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => _openUser(user), - child: Container( - margin: const EdgeInsets.all(5), - padding: const EdgeInsets.all(5), - child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - user.username, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - Row( - children: [ - Text( - "User id: ${user.id}", - style: const TextStyle( - fontSize: 12, - ), - ), - ], - ), - ], - ), - ], - ), - ), - ), - ); - } - - Widget get newUserCard => Card( - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: _createNewUser, - child: Container( - margin: const EdgeInsets.all(5), - padding: const EdgeInsets.all(5), - child: const Icon(Icons.add), - ), + Future refreshUsers() async { + try { + final resp = await ApiController.to.apiClient.getItemsFromTable( + tableName: "users", + itemsSelector: const ItemsSelector( + fields: ["id", "username", "password", "access_token"], + where: [], ), ); - Widget _buildUserList() { - if (users.isEmpty) { - return Center( + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + _users.clear(); + _users.addAll(respData.map((e) => UserDefinition( + id: e["id"], + username: e["username"], + password: e["password"], + accessToken: e["access_token"], + ))); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to get users", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to get users", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to get users", + "$e", + ); + } + } + + Future refreshGroups() async { + try { + final resp = await ApiController.to.apiClient.getItemsFromTable( + tableName: "user_group", + itemsSelector: const ItemsSelector( + fields: ["id", "name", "description"], + where: [], + )); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + _groups.clear(); + _groups.addAll(respData.map((e) => GroupDefinition( + id: e["id"], + name: e["name"], + description: e["description"], + ))); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to get groups", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to get groups", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to get groups", + "$e", + ); + } + } + + Future createNewUser({UserDefinition? user}) async { + final username = "".obs; + final password = "".obs; + if (user != null) { + username.value = user.username; + } + final accept = await Get.dialog( + AlertDialog( + title: user == null + ? const Text("Create new user") + : const Text("Edit user"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: user != null + ? TextEditingController(text: user.username) + : null, + decoration: const InputDecoration( + labelText: "Username", + ), + readOnly: user != null, + onChanged: (value) => username.value = value, + ), + TextField( + decoration: const InputDecoration( + labelText: "Password", + ), + obscureText: true, + onChanged: (value) => password.value = value, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => Get.back(result: true), + child: user == null ? const Text("Create") : const Text("Save"), + ), + ], + ), + ); + + if (accept != true || username.isEmpty || password.isEmpty) return; + + try { + OkResponse? respData; + if (user == null) { + final resp = await ApiController.to.apiClient.createUser( + createUserDefinition: CreateUserDefinition( + username: username.value.trim(), + password: password.value.trim(), + ), + ); + respData = resp.data; + } else { + final resp = await ApiController.to.apiClient.updateUser( + userUpdateDefinition: UserUpdateDefinition( + userId: user.id, + password: password.value.trim(), + accessToken: user.accessToken, + ), + ); + respData = resp.data; + } + + if (respData == null) { + throw Exception("No data in response"); + } + + refreshData(); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to create user", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to create user", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to create user", + "$e", + ); + } + } + + Future createNewGroup() async { + final groupName = "".obs; + final groupDescription = "".obs; + final accept = await Get.dialog( + AlertDialog( + title: const Text("Create new group"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + decoration: const InputDecoration( + labelText: "Group name", + ), + onChanged: (value) => groupName.value = value, + ), + TextField( + decoration: const InputDecoration( + labelText: "Group description (optional)", + ), + onChanged: (value) => groupDescription.value = value, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => Get.back(result: true), + child: const Text("Create"), + ), + ], + ), + ); + + if (accept != true || groupName.isEmpty) return; + + try { + final resp = await ApiController.to.apiClient.createItem( + tableName: "user_group", + itemDefinition: { + "name": groupName.trim(), + "description": groupDescription.trim() + }, + ); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + Get.snackbar( + "Group created", + "Group $groupName created", + ); + + refreshData(); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to create group", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to create group", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to create group", + "$e", + ); + } + } + + Future getGroupUsers(GroupDefinition group) async { + final resp = await ApiController.to.apiClient.getItemsFromTable( + tableName: "user_in_user_group", + itemsSelector: ItemsSelector( + fields: ["id", "user_id", "user_group_id"], + where: [ + ColumnConditionCompat( + column: "user_group_id", + operator_: ColumnConditionCompatOperator.eq, + value: group.id, + ), + ], + ), + ); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + final data = respData + .map( + (e) => UserInGroupDefinition( + id: e["id"], + userId: e["user_id"], + groupId: e["user_group_id"], + ), + ) + .map((e) => users.firstWhereOrNull((e1) => e1.id == e.userId)) + .whereType() + .toList(); + + _usersInGroups[group] = data; + } + + Future addUserToGroup(GroupDefinition group) async { + final groupUsers = _usersInGroups[group] ?? []; + final usersNotInGroup = + users.where((e) => groupUsers.contains(e) != true).toList(); + + final selectedUser = await Get.dialog( + AlertDialog( + title: const Text("Select user to add to group"), + content: SizedBox( + width: 400, + height: 400, + child: ListView.builder( + itemCount: usersNotInGroup.length, + itemBuilder: (context, index) { + final user = usersNotInGroup[index]; + return ListTile( + title: Text(user.username), + onTap: () => Get.back(result: user), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Get.back(result: null), + child: const Text("Cancel"), + ), + ], + ), + ); + + if (selectedUser == null) return; + + try { + final resp = await ApiController.to.apiClient.createItem( + tableName: "user_in_user_group", + itemDefinition: { + "user_id": selectedUser.id, + "user_group_id": group.id, + }, + ); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + Get.snackbar( + "User added to group", + "User ${selectedUser.username} added to group ${group.name}", + ); + + refreshData(); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to add user to group", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to add user to group", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to add user to group", + "$e", + ); + } + } + + Future removeUserFromGroup( + GroupDefinition group, + UserDefinition user, + ) async { + final accept = await Get.dialog( + AlertDialog( + title: const Text("Remove user from group"), + content: const Text( + "Are you sure you want to remove this user from the group?"), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => Get.back(result: true), + child: const Text("Remove"), + ), + ], + ), + ); + + if (accept != true) return; + + try { + final resp = await ApiController.to.apiClient.deleteItemFromTable( + tableName: "user_in_user_group", + columnConditionCompat: [ + ColumnConditionCompat( + column: "user_group_id", + operator_: ColumnConditionCompatOperator.eq, + value: group.id, + ), + ColumnConditionCompat( + column: "user_id", + operator_: ColumnConditionCompatOperator.eq, + value: user.id, + ), + ], + ); + + final respData = resp.data; + if (respData == null) { + throw Exception("No data in response"); + } + + Get.snackbar( + "User removed from group", + "User ${user.username} removed from group ${group.name}", + ); + + refreshData(); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to remove user from group", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to remove user from group", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to remove user from group", + "$e", + ); + } + } + + Future deleteGroup(GroupDefinition group) async { + final accept = await Get.dialog( + AlertDialog( + title: const Text("Delete group"), + content: const Text( + "Are you sure you want to delete this group? This action cannot be undone."), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => Get.back(result: true), + child: const Text("Delete"), + ), + ], + ), + ); + + if (accept != true) return; + + try { + final resp1 = await ApiController.to.apiClient.deleteItemFromTable( + tableName: "user_in_user_group", + columnConditionCompat: [ + ColumnConditionCompat( + column: "user_group_id", + operator_: ColumnConditionCompatOperator.eq, + value: group.id, + ), + ], + ); + + if (resp1.data == null) { + throw Exception("Could not delete users from group"); + } + + final resp2 = await ApiController.to.apiClient.deleteItemFromTable( + tableName: "user_group", + columnConditionCompat: [ + ColumnConditionCompat( + column: "id", + operator_: ColumnConditionCompatOperator.eq, + value: group.id, + ), + ], + ); + + if (resp2.data == null) { + throw Exception("Could not delete group"); + } + + Get.snackbar( + "Group deleted", + "Group ${group.name} deleted", + ); + + refreshData(); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to delete group", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to delete group", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to delete group", + "$e", + ); + } + } + + Future editUser(UserDefinition user) async { + createNewUser(user: user); + } + + Future deleteUser(UserDefinition user) async { + final accept = await Get.dialog( + AlertDialog( + title: const Text("Delete user"), + content: const Text( + "Are you sure you want to delete this user? This action cannot be undone.\n" + "Note: This will not remove references to this user in other tables.", + ), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => Get.back(result: true), + child: const Text("Delete"), + ), + ], + ), + ); + + if (accept != true) return; + + try { + final resp1 = await ApiController.to.apiClient.deleteItemFromTable( + tableName: "user_in_user_group", + columnConditionCompat: [ + ColumnConditionCompat( + column: "user_id", + operator_: ColumnConditionCompatOperator.eq, + value: user.id, + ), + ], + ); + + if (resp1.data == null) { + throw Exception("Could not delete users from group"); + } + + final resp2 = await ApiController.to.apiClient.removeUser( + userId: user.id, + ); + + if (resp2.data == null) { + throw Exception("Could not delete group"); + } + + Get.snackbar( + "User deleted", + "User ${user.username} deleted", + ); + + refreshData(); + } on DioError catch (e) { + final respData = e.response?.data; + if (respData != null) { + Get.snackbar( + "Error trying to delete user", + "${respData['error']}", + ); + } else { + Get.snackbar( + "Error trying to delete user", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Error trying to delete user", + "$e", + ); + } + } + + Future changeGroupSecurity(GroupDefinition group) async { + await GroupACLDialog.show(group); + } +} + +class UsersListPanel extends GetView { + const UsersListPanel({super.key}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Column( + children: [ + AppBar( + elevation: 0, + title: Obx( + () => DropdownButton( + items: const [ + DropdownMenuItem( + value: UserListPanelTab.users, + child: Text("Users"), + ), + DropdownMenuItem( + value: UserListPanelTab.groups, + child: Text("Groups"), + ), + ], + value: controller.currentTab, + onChanged: (value) { + controller.currentTab = value ?? UserListPanelTab.users; + }, + ), + ).marginAll(8), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => + controller.currentTab == UserListPanelTab.users + ? controller.createNewUser() + : controller.createNewGroup(), + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => controller.refreshData(), + ), + ], + ), + Expanded( + child: Obx(() => controller.currentTab == UserListPanelTab.users + ? usersPanel + : groupsPanel), + ), + ], + ), + Obx( + () => Positioned( + right: 16, + bottom: 16, + child: controller.isLoading + ? const CircularProgressIndicator() + : const SizedBox(), + ), + ), + ], + ); + } + + Widget get usersPanel => Obx(() => + controller.users.isEmpty ? whenNoSomething("No users found") : cardList); + + Widget get groupsPanel => Obx(() => controller.groups.isEmpty + ? whenNoSomething("No groups found") + : groupsList); + + Widget whenNoSomething(message) => Center( child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Text( - "No users found", + message, style: TextStyle( - color: Theme.of(context).disabledColor, + color: Get.theme.disabledColor, fontSize: 24, ), ), - const SizedBox(height: 4), - Text( - "Maybe create one? Or maybe you don't have permission to view them?", - style: TextStyle( - color: Theme.of(context).disabledColor, - fontSize: 14, - ), - ), - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: _createNewUser, - icon: const Icon(Icons.add), - label: const Text("Create user"), - ) ], ), ); - } - return GridView.builder( - shrinkWrap: true, - itemCount: users.length + 1, - itemBuilder: (ctx, i) => - i == 0 ? newUserCard : _buildUserCard(ctx, users[i - 1]), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 5, - childAspectRatio: 5 / 2, + Widget get cardList => ListView.builder( + shrinkWrap: true, + itemCount: controller.users.length, + itemBuilder: (context, index) => + _buildUserCard(controller.users[index]), + ); + + Widget get groupsList => ListView.builder( + shrinkWrap: true, + itemCount: controller.groups.length, + itemBuilder: (context, index) => + _buildGroupCard(controller.groups[index]), + ); + + Widget _buildUserCard(UserDefinition user) { + return Card( + clipBehavior: Clip.antiAlias, + child: ExpansionTile( + title: Text( + user.username, + maxLines: 1, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, + ), + ), + subtitle: Text( + "ID: ${user.id}", + style: const TextStyle( + fontSize: 12, + ), + ), + childrenPadding: const EdgeInsets.all(8), + children: [ + Row( + children: [ + ElevatedButton.icon( + onPressed: () => controller.editUser(user), + icon: const Icon(Icons.edit_attributes), + label: const Text("Edit user"), + ).paddingAll(8).expanded(), + Obx( + () { + final adminGroup = controller.usersInGroups.keys + .toList() + .firstWhereOrNull((element) => element.id == 2); + if (adminGroup == null) return const SizedBox(); + + final adminList = controller.usersInGroups[adminGroup]; + if (adminList == null) return const SizedBox(); + + final isAdmin = + adminList.any((element) => element.id == user.id); + + final isCurrentUser = + user.accessToken == ApiController.to.token; + if (isCurrentUser) return const SizedBox(); + + final btn = ElevatedButton.icon( + onPressed: () => controller.deleteUser(user), + icon: const Icon(Icons.delete_forever), + label: const Text("Delete user"), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + ), + ); + + if (isAdmin) { + return Tooltip( + message: "Please note that this user is an admin", + child: btn, + ).paddingAll(8).expanded(); + } + + return btn.paddingAll(8).expanded(); + }, + ), + ], + ), + ], ), ); } - Future _createNewUser() async { - var result = await showFlexibleBottomSheet( - minHeight: 1, - initHeight: 1, - maxHeight: 1, - context: context, - builder: (_, __, ___) => const CreateUserBottomSheet(), - anchors: [0, 0.5, 1], - isSafeArea: true, - isDismissible: false, + Widget _buildGroupCard(GroupDefinition group) { + return Card( + clipBehavior: Clip.antiAlias, + child: ExpansionTile( + title: Text( + group.name, + maxLines: 1, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, + ), + ), + leading: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (group.id != 1) + IconButton( + icon: const Icon(Icons.person_add), + onPressed: () => controller.addUserToGroup(group), + ), + if (group.id > 2) + IconButton( + icon: const Icon(Icons.delete_forever), + onPressed: () => controller.deleteGroup(group), + ), + if (group.id != 2) + IconButton( + icon: const Icon(Icons.security), + onPressed: () => controller.changeGroupSecurity(group), + ), + ], + ), + subtitle: Text( + group.description, + style: const TextStyle( + fontSize: 12, + ), + ), + children: [ + Obx( + () { + final groupData = controller.usersInGroups[group]; + if (group.id == 1) { + return const ListTile( + title: Text("No users can be added to this group"), + ); + } + if (groupData == null) { + return const Center( + child: CircularProgressIndicator(), + ); + } + if (groupData.isEmpty) { + return const ListTile( + title: Text("No users in group"), + ); + } + return ListView.builder( + shrinkWrap: true, + itemCount: groupData.length, + itemBuilder: (context, index) { + final user = controller.usersInGroups[group]![index]; + final isYou = user.accessToken == ApiController.to.token; + return ListTile( + title: Text(user.username), + subtitle: isYou ? const Text("You") : null, + trailing: isYou && + (group.id == 2 || + group.name.toLowerCase() == "admin") + ? null + : IconButton( + icon: const Icon(Icons.delete), + onPressed: () => controller.removeUserFromGroup( + group, + user, + ), + ), + ); + }, + ); + }, + ), + ], + ), ); - - if (result == null) { - return; - } - - final response = await apiClient.createUser( - widget.usersTable, result.username, result.password); - response.unfold((data) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("User created"), - ), - ); - _refreshUsers(); - }, (error) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text("Error: $error"), - ), - ); - }); - } - - Future _openUser(UserModel user) async { - final result = await showFlexibleBottomSheet( - minHeight: 1, - initHeight: 1, - maxHeight: 1, - context: context, - builder: (_, __, ___) => CreateUserBottomSheet(existingUser: user), - anchors: [0, 0.5, 1], - isSafeArea: true, - isDismissible: false, - ); - - if (result == null) { - return; - } - - final response = await apiClient.updateItem(widget.usersTable, { - "username": result.username, - "password": result.password, - "access_token": result.accessToken, - }, { - "id": user.id, - }); - response.unfold((data) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("User updated"), - ), - ); - _refreshUsers(); - }, (error) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text("Error: $error"), - ), - ); - }); - } - - Future _refreshUsers() async { - final result = await apiClient.getTableItems(widget.usersTable); - result.unfold((data) { - setState(() { - users.clear(); - users.addAll(data.map((e) => UserModel.fromJson(e))); - }); - }, (error) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text("Error: $error"), - ), - ); - }); } } diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index 98a0f67..d51f9e9 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -1,40 +1,95 @@ import 'dart:async'; -import 'package:animated_background/animated_background.dart'; +import 'package:dio/dio.dart'; 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_api/tuuli_api.dart'; +import 'package:tuuli_app/api_controller.dart'; -class LoginPage extends StatefulWidget { +class LoginPageController extends GetxController { + final _endpoint = ApiController.to.endPoint.obs; + String get endpoint => _endpoint.value; + set endpoint(String value) => _endpoint.value = value; + + final _username = "".obs; + String get username => _username.value; + set username(String value) => _username.value = value; + + final _password = "".obs; + String get password => _password.value; + set password(String value) => _password.value = value; + + final _submitted = false.obs; + bool get submitted => _submitted.value; + set submitted(bool value) => _submitted.value = value; + + bool get isFormValid => + endpoint.isNotEmpty && username.isNotEmpty && password.isNotEmpty; + + Future submitForm() async { + submitted = true; + if (isFormValid) { + try { + ApiController.to.endPoint = endpoint; + final resp = await ApiController.to.apiClient.getAccessToken( + authModel: AuthModel( + username: username, + password: password, + ), + ); + + final respData = resp.data; + if (resp.statusCode == 200 && respData != null) { + final accessToken = respData.accessToken; + Get.find().token = accessToken; + WidgetsBinding.instance.addPostFrameCallback((_) { + Get.offAllNamed("/home"); + }); + } else { + Get.snackbar( + "Login failed", + resp.statusMessage ?? "Unknown error", + ); + } + } on DioError catch (e) { + final errorData = e.response?.data; + if (errorData != null) { + final error = errorData["error"]; + if (error != null) { + Get.snackbar( + "Login failed", + "$error", + ); + } + } else { + Get.snackbar( + "Login failed", + "$e", + ); + } + } catch (e) { + Get.snackbar( + "Login failed", + "$e", + ); + } + } + submitted = false; + } +} + +class LoginPage extends GetView { const LoginPage({super.key}); - @override - State createState() => _LoginPageState(); -} - -class _LoginPageState extends State with TickerProviderStateMixin { - final _formKey = GlobalKey(); - - final apiClient = Get.find(); - var submitted = false; - - final loginController = TextEditingController(); - final passwordController = TextEditingController(); - @override Widget build(BuildContext context) { - final screenSize = MediaQuery.of(context).size; + final screenSize = Get.mediaQuery.size; final formWidth = screenSize.width <= 600 ? screenSize.width : 300.0; return Scaffold( body: Stack( children: [ - AnimatedBackground( - behaviour: RandomParticleBehaviour(), - vsync: this, - child: const SizedBox.square( - dimension: 0, - ), + const SizedBox.square( + dimension: 0, ), Row( mainAxisAlignment: MainAxisAlignment.start, @@ -42,23 +97,37 @@ class _LoginPageState extends State with TickerProviderStateMixin { children: [ LimitedBox( maxWidth: formWidth, - child: Container( - color: Colors.black.withAlpha(100), - padding: const EdgeInsets.all(16), - child: Form( - key: _formKey, + child: Obx( + () => Container( + color: Colors.black.withAlpha(100), + padding: const EdgeInsets.all(16), child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextFormField( - controller: loginController, - enabled: !submitted, + enabled: !controller.submitted, + decoration: const InputDecoration( + labelText: 'Endpoint', + hintText: 'Enter Tuuli Endpoint', + ), + initialValue: ApiController.to.endPoint, + onChanged: (value) => controller.endpoint = value, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter Tuuli Endpoint'; + } + return null; + }, + ), + TextFormField( + enabled: !controller.submitted, decoration: const InputDecoration( labelText: 'Login', - hintText: 'Enter your login', + hintText: 'Enter your username', ), + onChanged: (value) => controller.username = value, validator: (value) { if (value == null || value.isEmpty) { return 'Please enter your Login'; @@ -67,13 +136,13 @@ class _LoginPageState extends State with TickerProviderStateMixin { }, ), TextFormField( - controller: passwordController, obscureText: true, - enabled: !submitted, + enabled: !controller.submitted, decoration: const InputDecoration( labelText: 'Password', hintText: 'Enter your password', ), + onChanged: (value) => controller.password = value, validator: (value) { if (value == null || value.isEmpty) { return 'Please enter your password'; @@ -83,7 +152,10 @@ class _LoginPageState extends State with TickerProviderStateMixin { ), const SizedBox(height: 16), ElevatedButton( - onPressed: submitted ? null : _submit, + onPressed: + !controller.isFormValid || controller.submitted + ? null + : () => controller.submitForm(), child: const Text('Login'), ), ], @@ -97,54 +169,4 @@ class _LoginPageState extends State with TickerProviderStateMixin { ), ); } - - Future _submit() async { - if (!_formKey.currentState!.validate()) { - return; - } - - setState(() { - submitted = true; - }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Trying to login...'), - ), - ); - - final response = await apiClient.login( - loginController.text.trim(), - passwordController.text.trim(), - ); - response.unfold((data) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Login successful'), - ), - ); - apiClient.setAccessToken(data.accessToken); - GetStorage() - .write("accessToken", data.accessToken) - .then((value) => GetStorage().save()) - .then((value) { - Timer(1.seconds, () { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - WidgetsBinding.instance.addPostFrameCallback((_) { - Get.offAllNamed("/home"); - }); - }); - }); - }, (error) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(error.toString()), - ), - ); - setState(() { - submitted = false; - }); - }); - } } diff --git a/lib/pages/not_found_page.dart b/lib/pages/not_found_page.dart index 3f46a93..f92fe21 100644 --- a/lib/pages/not_found_page.dart +++ b/lib/pages/not_found_page.dart @@ -1,4 +1,3 @@ -import 'package:animated_background/animated_background.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -14,14 +13,10 @@ class _NotFoundPageState extends State @override Widget build(BuildContext context) { return Scaffold( - body: AnimatedBackground( - behaviour: RandomParticleBehaviour(), - vsync: this, - child: Center( - child: Text( - 'Page not found', - style: Theme.of(context).textTheme.headlineMedium, - ), + body: Center( + child: Text( + 'Page not found', + style: Theme.of(context).textTheme.headlineMedium, ), ), bottomSheet: Container( 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/audio_player_widget.dart b/lib/widgets/audio_player_widget.dart new file mode 100644 index 0000000..64c5751 --- /dev/null +++ b/lib/widgets/audio_player_widget.dart @@ -0,0 +1,72 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class AudioPlayerWidgetController extends GetxController { + final String title; + final String url; + + AudioPlayerWidgetController({ + required this.title, + required this.url, + }); + + @override + void onInit() { + super.onInit(); + + player = AudioPlayer(); + player.play(UrlSource(url)); + + player.onPlayerStateChanged.listen((event) { + _playerState.value = event; + }); + } + + @override + void onClose() { + player.dispose(); + super.onClose(); + } + + late AudioPlayer player; + + final _playerState = PlayerState.stopped.obs; + get playerState => _playerState.value; +} + +class AudioPlayerWidget extends GetView { + const AudioPlayerWidget({super.key}); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Obx( + () => controller.playerState == PlayerState.playing + ? IconButton( + onPressed: () => controller.player.pause(), + icon: const Icon(Icons.pause), + ) + : IconButton( + onPressed: () => controller.player.resume(), + icon: const Icon(Icons.play_arrow), + ), + ), + title: const Text("Playing"), + subtitle: Text(controller.title), + ).card().paddingAll(16).center(); + } + + static AudioPlayerWidget create( + {required String url, required String title}) { + Get.lazyPut( + () => AudioPlayerWidgetController( + title: title, + url: url, + ), + ); + + return const AudioPlayerWidget(); + } +} 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"), + ), + ], + ), + ); +} diff --git a/lib/widgets/table_field_widget.dart b/lib/widgets/table_field_widget.dart deleted file mode 100644 index 040aee6..0000000 --- a/lib/widgets/table_field_widget.dart +++ /dev/null @@ -1,51 +0,0 @@ -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 3de30a5..860577d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,14 +1,30 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - animated_background: - dependency: "direct main" + _fe_analyzer_shared: + dependency: transitive description: - name: animated_background - sha256: "24b05a6dca2cb0231b011f9e8fd2e9d8060faac08a78cf0643915bb7d6e9b03b" + name: _fe_analyzer_shared + sha256: "8880b4cfe7b5b17d57c052a5a3a8cc1d4f546261c7cc8fbd717bd53f48db0568" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "59.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: a89627f49b0e70e068130a36571409726b04dab12da7e5625941d2c8ec278b96 + url: "https://pub.dev" + source: hosted + version: "5.11.1" + args: + dependency: transitive + description: + name: args + sha256: "4cab82a83ffef80b262ddedf47a0a8e56ee6fbf7fe21e6e768b02792034dd440" + url: "https://pub.dev" + source: hosted + version: "2.4.0" async: dependency: transitive description: @@ -17,6 +33,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.10.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "6063c05f987596ba7a3dad9bb9a5d8adfa5e7c07b9bae5301b27c11d0b3a239f" + url: "https://pub.dev" + source: hosted + version: "4.0.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: fb6bca878ad175d8f6ddc0e0a2d4226d81fa7c10747c12db420e96c7a096b2cc + url: "https://pub.dev" + source: hosted + version: "3.0.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: c4a56c49347b2e85ac4e1efea218948ca0fba87f04d2a3d3de07ce2410037038 + url: "https://pub.dev" + source: hosted + version: "4.0.1" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "897e24f190232a3fbb88134b062aa83a9240f55789b5e8d17c114283284ef56b" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "3a90a46198d375fc7d47bc1d3070c8fd8863b6469b7d87ca80f953efb090f976" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "4f5dcbfec0bf98ea09e243d5f5b64ea43a4e6710a2f292724bed16cdba3c691e" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "010f575653c01ccbe9756050b18df83d89426740e04b684f6438aa26c775a965" + url: "https://pub.dev" + source: hosted + version: "2.0.1" boolean_selector: dependency: transitive description: @@ -41,6 +113,78 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + build: + dependency: transitive + description: + name: build + sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + url: "https://pub.dev" + source: hosted + version: "1.1.1" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: db49b8609ef8c81cca2b310618c3017c00f03a92af44c04d310b907b2d692d95 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "7b25ba738bc74c94187cebeb9cc29d38a32e8279ce950eabd821d3b454a5f03d" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "14febe0f5bac5ae474117a36099b4de6f1dbc52df6c5e55534b3da9591bf4292" + url: "https://pub.dev" + source: hosted + version: "7.2.7" + built_collection: + dependency: "direct main" + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: "direct main" + description: + name: built_value + sha256: "31b7c748fd4b9adf8d25d72a4c4a59ef119f12876cf414f94f8af5131d5fa2b0" + url: "https://pub.dev" + source: hosted + version: "8.4.4" + built_value_generator: + dependency: "direct dev" + description: + name: built_value_generator + sha256: "6c7d31060667a309889e45fd86fcaec6477750d767cf32a7f7ce4b1578d3440c" + url: "https://pub.dev" + source: hosted + version: "8.4.4" characters: dependency: transitive description: @@ -49,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "3d1505d91afa809d177efd4eed5bb0eb65805097a1463abdd2add076effae311" + url: "https://pub.dev" + source: hosted + version: "2.0.2" clock: dependency: transitive description: @@ -57,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0d43dd1288fd145de1ecc9a3948ad4a6d5a82f0a14c4fdd0892260787d975cbe" + url: "https://pub.dev" + source: hosted + version: "4.4.0" collection: dependency: transitive description: @@ -65,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.1" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" crypto: dependency: transitive description: @@ -73,6 +241,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "6d691edde054969f0e0f26abb1b30834b5138b963793e56f69d3a9a4435e6352" + url: "https://pub.dev" + source: hosted + version: "2.3.0" data_table_2: dependency: "direct main" description: @@ -81,6 +257,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.2" + dio: + dependency: "direct main" + description: + name: dio + sha256: "0894a098594263fe1caaba3520e3016d8a855caeb010a882273189cca10f11e9" + url: "https://pub.dev" + source: hosted + version: "5.1.1" fake_async: dependency: transitive description: @@ -105,11 +289,35 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.4" + file_icon: + dependency: "direct main" + description: + name: file_icon + sha256: c46b6c24d9595d18995758b90722865baeda407f56308eadd757e1ab913f50a1 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_chips_input: + dependency: "direct main" + description: + name: flutter_chips_input + sha256: "9828e45e75f268ff51a08e8d05848776b0a4d8327867d2b347a733030bafe64f" + url: "https://pub.dev" + source: hosted + version: "2.0.0" flutter_fast_forms: dependency: "direct main" description: @@ -131,6 +339,19 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + url: "https://pub.dev" + source: hosted + version: "3.2.0" get: dependency: "direct main" description: @@ -147,6 +368,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + glob: + dependency: transitive + description: + name: glob + sha256: "4515b5b6ddb505ebdd242a5f2cc5d22d3d6a80013789debfbda7777f47ea308c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: f9e130f3259f52d26f0cfc0e964513796dafed572fa52e45d2f8d6ca14db39b2 + url: "https://pub.dev" + source: hosted + version: "2.2.0" http: dependency: "direct main" description: @@ -155,8 +392,16 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.5" - http_parser: + http_multi_server: dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: "direct main" description: name: http_parser sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" @@ -167,10 +412,34 @@ packages: dependency: transitive description: name: intl - sha256: a3715e3bc90294e971cb7dc063fbf3cd9ee0ebf8604ffeafabd9e6f16abbdbe6 + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" url: "https://pub.dev" source: hosted - version: "0.18.0" + version: "0.18.1" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + irondash_engine_context: + dependency: transitive + description: + name: irondash_engine_context + sha256: "086fbbcaef07b821b304b0371e472c687715f326219b69b18dc5c962dab09b55" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + irondash_message_channel: + dependency: transitive + description: + name: irondash_message_channel + sha256: "081ff9631a2c6782a47ef4fdf9c97206053af1bb174e2a25851692b04f3bc126" + url: "https://pub.dev" + source: hosted + version: "0.1.1" js: dependency: transitive description: @@ -179,6 +448,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: c33da08e136c3df0190bd5bbe51ae1df4a7d96e7954d1d7249fea2968a72d317 + url: "https://pub.dev" + source: hosted + version: "4.8.0" + lint: + dependency: transitive + description: + name: lint + sha256: "4a539aa34ec5721a2c7574ae2ca0336738ea4adc2a34887d54b7596310b33c85" + url: "https://pub.dev" + source: hosted + version: "1.10.0" lints: dependency: transitive description: @@ -187,6 +472,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + logging: + dependency: transitive + description: + name: logging + sha256: "04094f2eb032cbb06c6f6e8d3607edcfcb0455e2bb6cbc010cb01171dcb64e6d" + url: "https://pub.dev" + source: hosted + version: "1.1.1" matcher: dependency: transitive description: @@ -211,6 +504,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + mime: + dependency: "direct main" + description: + name: mime + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + url: "https://pub.dev" + source: hosted + version: "1.0.4" + omni_datetime_picker: + dependency: "direct main" + description: + name: omni_datetime_picker + sha256: c70ca19eba89b7ed3663ae8eab2e7922c12b443826cf06c841a7f0a19e22870d + url: "https://pub.dev" + source: hosted + version: "1.0.7" + one_of: + dependency: transitive + description: + name: one_of + sha256: "25fe0fcf181e761c6fcd604caf9d5fdf952321be17584ba81c72c06bdaa511f0" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + one_of_serializer: + dependency: "direct main" + description: + name: one_of_serializer + sha256: "3f3dfb5c1578ba3afef1cb47fcc49e585e797af3f2b6c2cc7ed90aad0c5e7b83" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" path: dependency: transitive description: @@ -231,18 +564,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "019f18c9c10ae370b08dce1f3e3b73bc9f58e7f087bb5e921f06529438ac0ae7" + sha256: "2cec049d282c7f13c594b4a73976b0b4f2d7a1838a6dd5aaf7bd9719196bee86" url: "https://pub.dev" source: hosted - version: "2.0.24" + version: "2.0.27" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "818b2dc38b0f178e0ea3f7cf3b28146faab11375985d815942a68eee11c2d0f7" + sha256: ad4c4d011830462633f03eb34445a45345673dfd4faf1ab0b4735fbd93b19183 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_linux: dependency: transitive description: @@ -263,10 +596,18 @@ packages: dependency: transitive description: name: path_provider_windows - sha256: f53720498d5a543f9607db4b0e997c4b5438884de25b0f73098cc2671a51b130 + sha256: d3f80b32e83ec208ac95253e0cd4d298e104fbc63cb29c5c69edaed43b0c69d6 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" + photo_view: + dependency: "direct main" + description: + name: photo_view + sha256: "8036802a00bae2a78fc197af8a158e3e2f7b500561ed23b4c458107685e645bb" + url: "https://pub.dev" + source: hosted + version: "0.14.0" platform: dependency: transitive description: @@ -283,6 +624,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" process: dependency: transitive description: @@ -291,6 +640,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.4" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "307de764d305289ff24ad257ad5c5793ce56d04947599ad68b3baa124105fc17" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: ec85d7d55339d85f44ec2b682a82fea340071e8978257e5a43e69f79e98ef50c + url: "https://pub.dev" + source: hosted + version: "1.2.2" + quiver: + dependency: transitive + description: + name: quiver + sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 + url: "https://pub.dev" + source: hosted + version: "3.2.1" recase: dependency: "direct main" description: @@ -299,11 +672,91 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "858aaa72d8f61637d64e776aca82e1c67e6d9ee07979123c5d17115031c1b13b" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "6478c6bbbecfe9aced34c483171e90d7c078f5883558b30ec3163cf18402c749" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "0c1c16c56c9708aa9c361541a6f0e5cc6fc12a3232d866a687a7b7db30032b07" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "9d387433ca65717bbf1be88f4d5bb18f10508917a8fa2fb02e0fd0d7479a9afa" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: fb5cf25c0235df2d0640ac1b1174f6466bd311f621574997ac59018a6664548d + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: "74083203a8eae241e0de4a0d597dbedab3b8fef5563f33cf3c12d7e93c655ca5" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "5e588e2efef56916a3b229c3bfe81e6a525665a454519ca51dbcc4236a274173" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: c24a96135a2ccd62c64b69315a14adc5c3419df63b4d7c05832a346fdb73682c + url: "https://pub.dev" + source: hosted + version: "1.4.0" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: a988c0e8d8ffbdb8a28aa7ec8e449c260f3deb808781fe1284d22c5bba7156e8 + url: "https://pub.dev" + source: hosted + version: "1.0.3" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: c2bea18c95cfa0276a366270afaa2850b09b4a76db95d546f3d003dcc7011298 + url: "https://pub.dev" + source: hosted + version: "1.2.7" source_span: dependency: transitive description: @@ -328,6 +781,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" string_scanner: dependency: transitive description: @@ -336,6 +797,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + styled_widget: + dependency: "direct main" + description: + name: styled_widget + sha256: "4d439802919b6ccf10d1488798656da8804633b03012682dd1c8ca70a084aa84" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + super_clipboard: + dependency: transitive + description: + name: super_clipboard + sha256: "7628464b63fd18df486e87a989ecfd73565f7b01a15ee47a2448283931c3d14d" + url: "https://pub.dev" + source: hosted + version: "0.3.0+2" + super_drag_and_drop: + dependency: "direct main" + description: + name: super_drag_and_drop + sha256: "9a31045fcb264bcfe57e5001b24b68cb8d4880d8d4ecfd594dd4c11a92b1ca56" + url: "https://pub.dev" + source: hosted + version: "0.3.0+2" + super_native_extensions: + dependency: transitive + description: + name: super_native_extensions + sha256: "18ad4c367cea763654d458d9e9aec8c0c00c6b1d542c4f746c94a223e8e4bd0c" + url: "https://pub.dev" + source: hosted + version: "0.3.0+2" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" + url: "https://pub.dev" + source: hosted + version: "3.1.0" term_glyph: dependency: transitive description: @@ -352,6 +853,31 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.18" + timing: + dependency: transitive + description: + name: timing + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + tuple: + dependency: "direct main" + description: + name: tuple + sha256: "0ea99cd2f9352b2586583ab2ce6489d1f95a5f6de6fb9492faaf97ae2060f0aa" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + tuuli_api: + dependency: "direct main" + description: + path: "." + ref: master + resolved-ref: eedd7a336d42a455a9d46ca203b0d52e920c27bf + url: "https://glab.nuark.xyz/nuark/tuuli_api.git" + source: git + version: "1.0.2" typed_data: dependency: transitive description: @@ -361,7 +887,7 @@ packages: source: hosted version: "1.3.1" uuid: - dependency: "direct main" + dependency: transitive description: name: uuid sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" @@ -376,14 +902,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + watcher: + dependency: transitive + description: + name: watcher + sha256: "6a7f46926b01ce81bfc339da6a7f20afbe7733eff9846f6d6a5466aa4c6667c0" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://pub.dev" + source: hosted + version: "2.4.0" win32: dependency: transitive description: name: win32 - sha256: c9ebe7ee4ab0c2194e65d3a07d8c54c5d00bb001b76081c4a04cdb8448b59e46 + sha256: dd8f9344bc305ae2923e3d11a2a911d9a4e2c7dd6fe0ed10626d63211a69676e url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "4.1.3" xdg_directories: dependency: transitive description: @@ -392,6 +934,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "23812a9b125b48d4007117254bca50abb6c712352927eece9e155207b1db2370" + url: "https://pub.dev" + source: hosted + version: "3.1.1" sdks: dart: ">=3.0.0-322.0.dev <4.0.0" flutter: ">=3.7.0" diff --git a/pubspec.yaml b/pubspec.yaml index 3441758..79c95a6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,19 +10,39 @@ dependencies: flutter: sdk: flutter - animated_background: ^2.0.0 + audioplayers: ^4.0.1 bottom_sheet: ^3.1.2 + built_collection: ^5.1.1 + built_value: ^8.4.4 data_table_2: ^2.4.2 + dio: ^5.1.1 + file_icon: ^1.0.0 + flutter_chips_input: ^2.0.0 flutter_fast_forms: ^10.0.0 get: ^4.6.5 get_storage: ^2.1.1 http: ^0.13.5 + http_parser: ^4.0.2 + mime: ^1.0.4 + omni_datetime_picker: ^1.0.7 + one_of_serializer: ^1.5.0 + photo_view: ^0.14.0 recase: ^4.1.0 - uuid: ^3.0.7 + shared_preferences: ^2.1.0 + styled_widget: ^0.4.1 + super_drag_and_drop: ^0.3.0+2 + tuple: ^2.0.1 + tuuli_api: + git: + url: https://glab.nuark.xyz/nuark/tuuli_api.git + ref: master dev_dependencies: flutter_test: sdk: flutter + + build_runner: ^2.3.3 + built_value_generator: ^8.4.4 flutter_lints: ^2.0.0 flutter: diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..ea0f442 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,102 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(gws_playground LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "gws_playground") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..930d207 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,104 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + windows-x64 $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..842ecd9 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,20 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); + IrondashEngineContextPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi")); + SuperNativeExtensionsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..a6bba34 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows + irondash_engine_context + super_native_extensions +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..209de2f --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "gws_playground" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "gws_playground" "\0" + VALUE "LegalCopyright", "Copyright (C) 2023 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "gws_playground.exe" "\0" + VALUE "ProductName", "gws_playground" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..b25e363 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,66 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..8cb3dd0 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"gws_playground", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..a42ea76 --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..b2b0873 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length <= 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_