Merge branch 'revolution' into 'master'

Revolution

See merge request nuark/tuuli_app!1
This commit is contained in:
Andrew 2023-05-08 12:50:15 +07:00
commit a01a66c88d
53 changed files with 5745 additions and 2187 deletions

View file

@ -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

25
.vscode/launch.json vendored Normal file
View file

@ -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"
}
]
}

View file

@ -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<T> {
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<T> = Future<ErrorOrData<T>>;
typedef TableItemsData = Map<String, dynamic>;
typedef TableItemsDataList = List<TableItemsData>;
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<AccessTokenModel> 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<TablesListModel> 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<bool> createTable(
String tableName, List<TableField> 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<bool> 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<TableItemsDataList> 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<String, dynamic>;
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<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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<StreamedResponse> get(
String path, {
Map<String, String>? headers,
}) {
return _request(path, 'GET', headers: headers);
}
Future<StreamedResponse> post(
String path, {
Map<String, String>? headers,
dynamic body,
}) {
return _request(path, 'POST', headers: headers, body: body);
}
Future<StreamedResponse> _request(
String path,
String method, {
Map<String, String>? 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);
}
}

View file

@ -1,7 +0,0 @@
class AccessTokenModel {
final String accessToken;
const AccessTokenModel({
required this.accessToken,
});
}

View file

@ -1,127 +0,0 @@
import 'package:uuid/uuid.dart';
typedef SerialTableField = TableField<int>;
typedef UUIDTableField = TableField<UuidValue>;
typedef StringTableField = TableField<String>;
typedef BigIntTableField = TableField<BigInt>;
typedef BoolTableField = TableField<bool>;
typedef DateTableField = TableField<DateTime>;
typedef DateTimeTableField = TableField<DateTime>;
typedef FloatTableField = TableField<double>;
typedef IntTableField = TableField<int>;
final possibleFieldTypes = {
"serial": SerialTableField,
"uuid": UUIDTableField,
"str": StringTableField,
"bigint": BigIntTableField,
"bool": BoolTableField,
"date": DateTableField,
"datetime": DateTimeTableField,
"float": FloatTableField,
"int": IntTableField,
};
class TableField<T> {
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");
}
}
}

View file

@ -1,42 +0,0 @@
import 'package:tuuli_app/api/model/table_field_model.dart';
class TablesListModel {
final List<TableModel> tables;
TablesListModel(this.tables);
factory TablesListModel.fromJson(Map<String, dynamic> json) =>
TablesListModel(
List<TableModel>.from(
json["tables"].map((x) => TableModel.fromJson(x)),
),
);
}
class TableModel {
final String tableId;
final String tableName;
final String columnsDefinition;
final List<TableField> 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<String, dynamic> json) => TableModel(
tableId: json["table_id"],
tableName: json["table_name"],
columnsDefinition: json["columns"],
system: json["system"],
hidden: json["hidden"],
);
}

View file

@ -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<String, dynamic> json) => UserModel(
id: json["id"],
username: json["username"],
password: json["password"],
accessToken: json["access_token"],
);
Map<String, dynamic> toJson() => {
"id": id,
"username": username,
"password": password,
"access_token": accessToken,
};
}

37
lib/api_controller.dart Normal file
View file

@ -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<String>("endPoint") ?? "";
set endPoint(String value) => apiStorageBox.write("endPoint", value);
String get token => apiStorageBox.read<String>("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();
}
}

View file

@ -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<String>("accessToken");
if (accessToken != null) {
client.setAccessToken(accessToken);
}
return client;
},
Get.put(ApiController(), permanent: true);
Get.lazyPut<CheckupPageController>(
() => CheckupPageController(),
fenix: true,
);
Get.lazyPut<LoginPageController>(
() => LoginPageController(),
fenix: true,
);
Get.lazyPut<HomePageController>(
() => 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,

View file

@ -0,0 +1,106 @@
import 'package:tuuli_api/tuuli_api.dart';
extension ColumnsParser on TableDefinition {
List<DBColumnDefinition> 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<DBColumnDefinition>()
.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);
}

View file

@ -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,
});
}

View file

@ -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;
}
}
}

View file

@ -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";
}

View file

@ -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,
});
}

View file

@ -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,
});
}

View file

@ -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<StatefulWidget> createState() => _CreateTableItemBottomSheetState();
}
class _CreateTableItemBottomSheetState
extends State<CreateTableItemBottomSheet> {
final _formKey = GlobalKey<FormState>();
final _values = <String, dynamic>{};
@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");
}
}
}

View file

@ -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<StatefulWidget> createState() => _CreateUserBottomSheetState();
}
class _CreateUserBottomSheetState extends State<CreateUserBottomSheet> {
final _formKey = GlobalKey<FormState>();
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"),
),
],
),
],
),
),
);
}
}

View file

@ -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<TableField> fields;
EditTableBottomSheetResult(this.tableName, this.fields);
}
class EditTableBottomSheet extends StatefulWidget {
final TableModel? table;
const EditTableBottomSheet({super.key, this.table});
@override
State<StatefulWidget> createState() => _EditTableBottomSheetState();
}
class _EditTableBottomSheetState extends State<EditTableBottomSheet> {
var tableName = "".obs;
late final List<TableField> 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);
});
}
}

View file

@ -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<StatefulWidget> createState() => _OpenTableBottomSheetState();
}
class _OpenTableBottomSheetState extends State<OpenTableBottomSheet> {
final apiClient = Get.find<ApiClient>();
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<void> _dropTable() async {
final really = await Get.defaultDialog<bool>(
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<void> _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<void> _addNewItem() async {
final newItem = await showFlexibleBottomSheet<Map<String, dynamic>>(
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<void> _updateExistingItem(TableItemsData oldItem) async {
final newItem = await showFlexibleBottomSheet<Map<String, dynamic>>(
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<void> _deleteItem(TableItemsData e) async {
final really = await Get.defaultDialog<bool>(
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"),
),
);
});
}
}

View file

@ -1,34 +1,41 @@
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});
@override
State<StatefulWidget> createState() => _CheckupPageState();
}
class _CheckupPageState extends State<CheckupPage>
with TickerProviderStateMixin {
@override
void initState() {
super.initState();
class CheckupPageController extends GetxController {
Future<void> 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();
WidgetsBinding.instance.addPostFrameCallback((_) {
checkCredentials();
if (resp.statusCode == 200) {
Get.offAllNamed("/home");
} else {
Get.offAllNamed("/login");
}
});
} catch (e) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Get.offAllNamed("/login");
});
}
}
}
}
class CheckupPage extends GetView<CheckupPageController> {
const CheckupPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: AnimatedBackground(
behaviour: RandomParticleBehaviour(),
vsync: this,
child: Center(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -37,29 +44,16 @@ class _CheckupPageState extends State<CheckupPage>
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
const SizedBox.square(
FutureBuilder(
future: controller.checkCredentials(),
builder: (ctx, _) => const SizedBox.square(
dimension: 32,
child: CircularProgressIndicator(),
),
),
],
),
),
),
);
}
Future<void> checkCredentials() async {
final accessToken = GetStorage().read<String?>("accessToken");
if (accessToken == null) {
Get.offAllNamed("/login");
} else {
final apiClient = Get.find<ApiClient>();
(await apiClient.tablesList()).unfold((data) {
Get.offAllNamed("/home");
}, (error) async {
await GetStorage().remove("accessToken");
Get.offAllNamed("/login");
});
}
}
}

View file

@ -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 = <TableColumnDefinition>[].obs;
List<TableColumnDefinition> get columnsDefinition => _columnsDefinition;
Future<void> 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<void> createNewColumn() async {
final columnName = "".obs;
final columnType = "".obs;
final columnIsUnique = false.obs;
final confirm = await Get.dialog<bool>(
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<CreateTableController> {
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<bool?> show() async {
Get.lazyPut<CreateTableController>(() => CreateTableController());
return Get.dialog<bool>(
const CreateTableDialog(),
barrierDismissible: false,
);
}
}

View file

@ -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 = <TableDefinition>[].obs;
List<TableDefinition> get tables => _tables.toList();
final _access = <String, TableAccess>{}.obs;
Map<String, TableAccess> get access => _access;
final _allowedColumns = <String, String?>{}.obs;
Map<String, String?> get allowedColumns => _allowedColumns;
Future<void> refreshData() async {
await refreshTables();
for (final table in tables) {
_access[table.tableId] = TableAccess.none;
}
for (final table in tables) {
await refreshTableAccess(table);
}
}
Future<void> 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<void> 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<void> 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<OkResponse> 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<void> 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 = <String, bool>{}.obs;
for (final column in tableColumns) {
selectedColumns[column] = currentlyAvailableColumns.contains(column);
}
final confirm = await Get.dialog<bool>(
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<OkResponse> 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<GroupACLController> {
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<void> show(GroupDefinition group) async {
Get.lazyPut<GroupACLController>(() => GroupACLController(group));
await Get.dialog(
const GroupACLDialog(),
barrierDismissible: false,
);
Get.delete<GroupACLController>();
}
}

View file

@ -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 = <int, UserDefinition?>{}.obs;
UserDefinition? getUserFromCache(int id) {
return _userCache[id];
}
void putUserInCache(UserDefinition user) {
_userCache[user.id] = user;
}
final _assetsCache = <int, Asset?>{}.obs;
Asset? getAssetFromCache(int id) {
return _assetsCache[id];
}
void putAssetInCache(Asset asset) {
_assetsCache[asset.id] = asset;
}
final _tableData = <Map<String, dynamic>>[].obs;
List<Map<String, dynamic>> get tableData => _tableData;
final _newRowData = <String, dynamic>{}.obs;
Map<String, dynamic> get newRowData => _newRowData;
void setNewRowData(String key, dynamic value) {
_newRowData[key] = value;
}
void clearNewRowData() {
_newRowData.clear();
}
Future<void> 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<UserDefinition?> showUserPicker() async {
final username = "".obs;
final user = await Get.dialog<UserDefinition>(
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<List<UserDefinition>>(
future: () async {
if (username.value.isEmpty) {
return <UserDefinition>[];
}
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<Asset?> showAssetPicker() async {
final name = "".obs;
final asset = await Get.dialog<Asset>(
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<List<Asset>>(
future: () async {
if (name.value.isEmpty) {
return <Asset>[];
}
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<void> 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<void> updateItem(
Map<String, dynamic> 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<void> deleteItem(Map<String, dynamic> 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<OpenTableController> {
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<String>(
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<String>(
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<void> show(TableDefinition table) async {
Get.lazyPut<OpenTableController>(() => OpenTableController(table: table));
await Get.dialog(
const OpenTableDialog(),
barrierDismissible: false,
);
}
}

View file

@ -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<StatefulWidget> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with HomePageStateRef {
final apiClient = Get.find<ApiClient>();
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>(
() => TablesListPanelController(),
fenix: true,
);
Get.lazyPut<UserListPanelController>(
() => UserListPanelController(),
fenix: true,
);
Get.lazyPut<AssetsPagePanelController>(
() => AssetsPagePanelController(),
fenix: true,
);
}
Future<void> logout() async {
ApiController.to.token = "";
await Future.wait([
Get.delete<TablesListPanelController>(),
Get.delete<UserListPanelController>(),
Get.delete<AssetsPagePanelController>(),
Get.delete<HomePageController>(),
]);
Get.offAllNamed("/");
}
}
class HomePage extends GetView<HomePageController> {
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<HomePage> with HomePageStateRef {
},
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: refreshData,
icon: const Icon(Icons.logout),
onPressed: () => controller.logout(),
),
],
);
ListView get drawerOptions => ListView(
children: [
ListTile(
Obx(
() => ListTile(
leading: const Icon(Icons.table_chart),
title: const Text("Tables"),
onTap: () {
setState(() {
currentPage = PageType.tables;
});
controller.currentPage = PageType.tables;
},
selected: controller.currentPage == PageType.tables,
),
ListTile(
),
Obx(
() => ListTile(
leading: const Icon(Icons.person),
title: const Text("Users"),
onTap: () {
setState(() {
currentPage = PageType.users;
});
controller.currentPage = PageType.users;
},
selected: controller.currentPage == PageType.users,
),
ListTile(
),
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: () {
setState(() {
currentPage = PageType.settings;
});
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<HomePage> 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<HomePage> 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()),
),
);
},
),
);
}
}

View file

@ -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 = <Asset>[].obs;
List<Asset> get assetsList => _assetsList.toList();
final _tagsList = <String>[].obs;
List<String> get tagsList => _tagsList.toList();
final _filterTags = <String>[].obs;
List<String> get filterTags => _filterTags.toList();
set filterTags(List<String> value) => _filterTags.value = value;
Future<void> refreshData() async {
_isLoading.value = true;
await Future.wait([
refreshAssets(),
refreshTag(),
]);
_isLoading.value = false;
}
Future<void> 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<void> 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<void> openUploadDialog() async {
final file = await Get.dialog<MultipartFile>(
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<void> editAsset(Asset e) async {
final description = e.description.obs;
final tags = e.tags.split(",").obs;
final confirm = await Get.dialog<bool>(
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<void> removeAsset(Asset e) async {
final checkReferences = false.obs;
final deleteReferencing = false.obs;
final confirm = await Get.dialog<bool>(
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<AssetsPagePanelController> {
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),
),
);
}

View file

@ -1,59 +1,201 @@
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<StatefulWidget> createState() => _TablesListPanelState();
void onInit() {
super.onInit();
refreshData();
}
class _TablesListPanelState extends State<TablesListPanel> {
final apiClient = Get.find<ApiClient>();
final _isLoading = false.obs;
bool get isLoading => _isLoading.value;
@override
void initState() {
super.initState();
final _tables = <TableDefinition>[].obs;
List<TableDefinition> get tables => _tables;
Future<void> 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<void> createNewTable() async {
final created = await CreateTableDialog.show();
if (created == true) {
refreshData();
}
}
Future<void> openTable(TableDefinition table) async {
await OpenTableDialog.show(table);
}
Future<void> 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 TablesListPanel extends GetView<TablesListPanelController> {
const TablesListPanel({super.key});
@override
Widget build(BuildContext context) {
return _buildTableList();
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 _buildTableCard(BuildContext ctx, TableModel table) {
Widget get whenNoTables => Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
"No tables found",
style: TextStyle(
color: Get.theme.disabledColor,
fontSize: 24,
),
),
const SizedBox(height: 4),
Text(
"Maybe create one?",
style: TextStyle(
color: Get.theme.disabledColor,
fontSize: 14,
),
),
],
),
);
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: () => _openTable(table),
onTap: () => controller.openTable(table),
child: Container(
margin: const EdgeInsets.all(5),
padding: const EdgeInsets.all(5),
child: Stack(
children: [
Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
table.tableName.pascalCase,
maxLines: 1,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
),
),
Row(
@ -69,133 +211,23 @@ class _TablesListPanelState extends State<TablesListPanel> {
Row(
children: [
Text(
"${table.columns.length} column(s)",
"${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)
],
),
),
),
);
}
Widget get newTableCard => Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: _createNewTable,
child: Container(
margin: const EdgeInsets.all(5),
padding: const EdgeInsets.all(5),
child: const Icon(Icons.add),
),
),
);
Widget _buildTableList() {
var tableItems = widget.tables.tables
.where((table) => !table.hidden && !table.system)
.toList(growable: false);
if (tableItems.isEmpty) {
return Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
"No tables found",
style: TextStyle(
color: Theme.of(context).disabledColor,
fontSize: 24,
),
),
const SizedBox(height: 4),
Text(
"Maybe create one?",
style: TextStyle(
color: Theme.of(context).disabledColor,
fontSize: 14,
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _createNewTable,
icon: const Icon(Icons.add),
label: const Text("Create table"),
)
],
),
);
}
return GridView.builder(
shrinkWrap: true,
itemCount: tableItems.length + 1,
itemBuilder: (ctx, i) =>
i == 0 ? newTableCard : _buildTableCard(ctx, tableItems[i - 1]),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
childAspectRatio: 5 / 2,
),
);
}
void _createNewTable() async {
final result = await showFlexibleBottomSheet<EditTableBottomSheetResult>(
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<void>(
minHeight: 1,
initHeight: 1,
maxHeight: 1,
context: context,
builder: (_, __, ___) => OpenTableBottomSheet(table: table),
anchors: [0, 0.5, 1],
isSafeArea: true,
isDismissible: false,
);
widget.parent.refreshData();
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,64 +1,133 @@
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<void> 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<ApiController>().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<LoginPageController> {
const LoginPage({super.key});
@override
State<StatefulWidget> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final apiClient = Get.find<ApiClient>();
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(
const SizedBox.square(
dimension: 0,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LimitedBox(
maxWidth: formWidth,
child: Container(
child: Obx(
() => Container(
color: Colors.black.withAlpha(100),
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: loginController,
enabled: !submitted,
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<LoginPage> 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<LoginPage> 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<LoginPage> with TickerProviderStateMixin {
),
);
}
Future<void> _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;
});
});
}
}

View file

@ -1,4 +1,3 @@
import 'package:animated_background/animated_background.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
@ -14,16 +13,12 @@ class _NotFoundPageState extends State<NotFoundPage>
@override
Widget build(BuildContext context) {
return Scaffold(
body: AnimatedBackground(
behaviour: RandomParticleBehaviour(),
vsync: this,
child: Center(
body: Center(
child: Text(
'Page not found',
style: Theme.of(context).textTheme.headlineMedium,
),
),
),
bottomSheet: Container(
color: Colors.black.withAlpha(100),
padding: const EdgeInsets.all(16),

View file

@ -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<String, dynamic> convertToPayload(Map<String, dynamic> 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);
});
}

View file

@ -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<AudioPlayerWidgetController> {
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>(
() => AudioPlayerWidgetController(
title: title,
url: url,
),
);
return const AudioPlayerWidget();
}
}

View file

@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:flutter_fast_forms/flutter_fast_forms.dart';
import 'package:get/get.dart';
Future<String?> showStringInputDialog({String? originalValue}) async {
final strVal = (originalValue ?? "").obs;
return await Get.dialog<String>(
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<double?> showDoubleInputDialog({double? originalValue}) async {
final strVal = (originalValue?.toString() ?? "").obs;
return await Get.dialog<double>(
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<int?> showIntInputDialog({int? originalValue}) async {
final strVal = (originalValue?.toString() ?? "").obs;
return await Get.dialog<int>(
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"),
),
],
),
);
}

View file

@ -1,51 +0,0 @@
import 'package:flutter/material.dart';
import 'package:tuuli_app/api/model/table_field_model.dart';
class TableFieldWidget<T> extends StatelessWidget {
final TableField<T> 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,
),
],
),
),
);
}
}

View file

@ -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"

View file

@ -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:

17
windows/.gitignore vendored Normal file
View file

@ -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/

102
windows/CMakeLists.txt Normal file
View file

@ -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 "$<$<CONFIG:Debug>:_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 "$<TARGET_FILE_DIR:${BINARY_NAME}>")
# 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)

View file

@ -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 $<CONFIG>
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
${CPP_WRAPPER_SOURCES_CORE}
${CPP_WRAPPER_SOURCES_PLUGIN}
${CPP_WRAPPER_SOURCES_APP}
)

View file

@ -0,0 +1,20 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <audioplayers_windows/audioplayers_windows_plugin.h>
#include <irondash_engine_context/irondash_engine_context_plugin_c_api.h>
#include <super_native_extensions/super_native_extensions_plugin_c_api.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
IrondashEngineContextPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi"));
SuperNativeExtensionsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi"));
}

View file

@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter/plugin_registry.h>
// Registers Flutter plugins.
void RegisterPlugins(flutter::PluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View file

@ -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 $<TARGET_FILE:${plugin}_plugin>)
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)

View file

@ -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)

121
windows/runner/Runner.rc Normal file
View file

@ -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

View file

@ -0,0 +1,66 @@
#include "flutter_window.h"
#include <optional>
#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<flutter::FlutterViewController>(
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<LRESULT> 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);
}

View file

@ -0,0 +1,33 @@
#ifndef RUNNER_FLUTTER_WINDOW_H_
#define RUNNER_FLUTTER_WINDOW_H_
#include <flutter/dart_project.h>
#include <flutter/flutter_view_controller.h>
#include <memory>
#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::FlutterViewController> flutter_controller_;
};
#endif // RUNNER_FLUTTER_WINDOW_H_

43
windows/runner/main.cpp Normal file
View file

@ -0,0 +1,43 @@
#include <flutter/dart_project.h>
#include <flutter/flutter_view_controller.h>
#include <windows.h>
#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<std::string> 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;
}

16
windows/runner/resource.h Normal file
View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 and Windows 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
<!-- Windows 8 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
<!-- Windows 7 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
</application>
</compatibility>
</assembly>

65
windows/runner/utils.cpp Normal file
View file

@ -0,0 +1,65 @@
#include "utils.h"
#include <flutter_windows.h>
#include <io.h>
#include <stdio.h>
#include <windows.h>
#include <iostream>
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<std::string> 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::string>();
}
std::vector<std::string> 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;
}

19
windows/runner/utils.h Normal file
View file

@ -0,0 +1,19 @@
#ifndef RUNNER_UTILS_H_
#define RUNNER_UTILS_H_
#include <string>
#include <vector>
// 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<std::string>,
// encoded in UTF-8. Returns an empty std::vector<std::string> on failure.
std::vector<std::string> GetCommandLineArguments();
#endif // RUNNER_UTILS_H_

View file

@ -0,0 +1,288 @@
#include "win32_window.h"
#include <dwmapi.h>
#include <flutter_windows.h>
#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<int>(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<EnableNonClientDpiScaling*>(
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<LONG>(origin.x),
static_cast<LONG>(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<CREATESTRUCT*>(lparam);
SetWindowLongPtr(window, GWLP_USERDATA,
reinterpret_cast<LONG_PTR>(window_struct->lpCreateParams));
auto that = static_cast<Win32Window*>(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<RECT*>(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<Win32Window*>(
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));
}
}

View file

@ -0,0 +1,102 @@
#ifndef RUNNER_WIN32_WINDOW_H_
#define RUNNER_WIN32_WINDOW_H_
#include <windows.h>
#include <functional>
#include <memory>
#include <string>
// 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_