Assets page main functionality
This commit is contained in:
parent
61f3184f85
commit
64a435ccb2
8 changed files with 606 additions and 929 deletions
|
|
@ -1,199 +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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
@ -1,218 +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"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
@ -1,243 +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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
@ -1,258 +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"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:get/get.dart';
|
||||||
import 'package:get_storage/get_storage.dart';
|
import 'package:get_storage/get_storage.dart';
|
||||||
import 'package:tuuli_app/api_controller.dart';
|
import 'package:tuuli_app/api_controller.dart';
|
||||||
import 'package:tuuli_app/c.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/none_panel.dart';
|
||||||
import 'package:tuuli_app/pages/home_panels/settings_panel.dart';
|
import 'package:tuuli_app/pages/home_panels/settings_panel.dart';
|
||||||
import 'package:tuuli_app/pages/home_panels/tables_list_panel.dart';
|
import 'package:tuuli_app/pages/home_panels/tables_list_panel.dart';
|
||||||
|
|
@ -12,6 +13,7 @@ enum PageType {
|
||||||
none,
|
none,
|
||||||
tables,
|
tables,
|
||||||
users,
|
users,
|
||||||
|
assets,
|
||||||
settings,
|
settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,6 +28,7 @@ class HomePageController extends GetxController {
|
||||||
PageType.none: "Home",
|
PageType.none: "Home",
|
||||||
PageType.tables: "Tables",
|
PageType.tables: "Tables",
|
||||||
PageType.users: "Users",
|
PageType.users: "Users",
|
||||||
|
PageType.assets: "Assets",
|
||||||
PageType.settings: "Settings",
|
PageType.settings: "Settings",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -41,6 +44,10 @@ class HomePageController extends GetxController {
|
||||||
() => UserListPanelController(),
|
() => UserListPanelController(),
|
||||||
fenix: true,
|
fenix: true,
|
||||||
);
|
);
|
||||||
|
Get.lazyPut<AssetsPagePanelController>(
|
||||||
|
() => AssetsPagePanelController(),
|
||||||
|
fenix: true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
|
|
@ -49,6 +56,7 @@ class HomePageController extends GetxController {
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
Get.delete<TablesListPanelController>(),
|
Get.delete<TablesListPanelController>(),
|
||||||
Get.delete<UserListPanelController>(),
|
Get.delete<UserListPanelController>(),
|
||||||
|
Get.delete<AssetsPagePanelController>(),
|
||||||
Get.delete<HomePageController>(),
|
Get.delete<HomePageController>(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -100,6 +108,16 @@ class HomePage extends GetView<HomePageController> {
|
||||||
selected: controller.currentPage == PageType.users,
|
selected: controller.currentPage == PageType.users,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Obx(
|
||||||
|
() => ListTile(
|
||||||
|
leading: const Icon(Icons.dataset_outlined),
|
||||||
|
title: const Text("Assets"),
|
||||||
|
onTap: () {
|
||||||
|
controller.currentPage = PageType.assets;
|
||||||
|
},
|
||||||
|
selected: controller.currentPage == PageType.assets,
|
||||||
|
),
|
||||||
|
),
|
||||||
Obx(
|
Obx(
|
||||||
() => ListTile(
|
() => ListTile(
|
||||||
leading: const Icon(Icons.settings),
|
leading: const Icon(Icons.settings),
|
||||||
|
|
@ -154,6 +172,8 @@ class HomePage extends GetView<HomePageController> {
|
||||||
return const TablesListPanel();
|
return const TablesListPanel();
|
||||||
case PageType.users:
|
case PageType.users:
|
||||||
return const UsersListPanel();
|
return const UsersListPanel();
|
||||||
|
case PageType.assets:
|
||||||
|
return const AssetsPagePanel();
|
||||||
case PageType.settings:
|
case PageType.settings:
|
||||||
return const SettingsPanel();
|
return const SettingsPanel();
|
||||||
case PageType.none:
|
case PageType.none:
|
||||||
|
|
|
||||||
518
lib/pages/home_panels/assets_panel.dart
Normal file
518
lib/pages/home_panels/assets_panel.dart
Normal file
|
|
@ -0,0 +1,518 @@
|
||||||
|
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' hide MultipartFile;
|
||||||
|
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';
|
||||||
|
|
||||||
|
class AssetsPagePanelController extends GetxController {
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
|
||||||
|
refreshData();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 users",
|
||||||
|
"${respData['error']}",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Get.snackbar(
|
||||||
|
"Error trying to get users",
|
||||||
|
"$e",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Get.snackbar(
|
||||||
|
"Error trying to get users",
|
||||||
|
"$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 users",
|
||||||
|
"${respData['error']}",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Get.snackbar(
|
||||||
|
"Error trying to get users",
|
||||||
|
"$e",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Get.snackbar(
|
||||||
|
"Error trying to get users",
|
||||||
|
"$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 get users",
|
||||||
|
"${respData['error']}",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Get.snackbar(
|
||||||
|
"Error trying to get users",
|
||||||
|
"$e",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Get.snackbar(
|
||||||
|
"Error trying to get users",
|
||||||
|
"$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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).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 get users",
|
||||||
|
"${respData['error']}",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Get.snackbar(
|
||||||
|
"Error trying to get users",
|
||||||
|
"$e",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Get.snackbar(
|
||||||
|
"Error trying to get users",
|
||||||
|
"$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 get users",
|
||||||
|
"${respData['error']}",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Get.snackbar(
|
||||||
|
"Error trying to get users",
|
||||||
|
"$e",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Get.snackbar(
|
||||||
|
"Error trying to get users",
|
||||||
|
"$e",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
columns: const [
|
||||||
|
DataColumn2(label: Text("ID"), size: ColumnSize.S, numeric: true),
|
||||||
|
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(Text(e.id.toString())),
|
||||||
|
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.edit),
|
||||||
|
onPressed: () => controller.editAsset(e),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
onPressed: () => controller.removeAsset(e),
|
||||||
|
),
|
||||||
|
])),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.toList(growable: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
76
pubspec.lock
76
pubspec.lock
|
|
@ -254,6 +254,14 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_fast_forms:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -360,6 +368,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.4"
|
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:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -476,10 +500,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_android
|
name: path_provider_android
|
||||||
sha256: da97262be945a72270513700a92b39dd2f4a54dad55d061687e2e37a6390366a
|
sha256: "3e58242edc02624f2c712e3f8bea88e0e341c4ae1abd3a6ff661318a3aefd829"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.25"
|
version: "2.0.26"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -508,10 +532,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_windows
|
name: path_provider_windows
|
||||||
sha256: f53720498d5a543f9607db4b0e997c4b5438884de25b0f73098cc2671a51b130
|
sha256: d3f80b32e83ec208ac95253e0cd4d298e104fbc63cb29c5c69edaed43b0c69d6
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.5"
|
version: "2.1.6"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -588,10 +612,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: "7fa90471a6875d26ad78c7e4a675874b2043874586891128dc5899662c97db46"
|
sha256: "5d7b3bd0400bdd0c03e59a3d3d5314651141a145b58196cd9018b12a2adc0c1b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.3"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -709,6 +733,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.1"
|
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"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -738,10 +786,10 @@ packages:
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: master
|
ref: master
|
||||||
resolved-ref: "46d8acd4a54fd716a16f119e4296ee5583b8bc98"
|
resolved-ref: ab60426db27a2441107e529f527df0e502dae104
|
||||||
url: "https://glab.nuark.xyz/nuark/tuuli_api.git"
|
url: "https://glab.nuark.xyz/nuark/tuuli_api.git"
|
||||||
source: git
|
source: git
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -750,6 +798,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.1"
|
version: "1.3.1"
|
||||||
|
uuid:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -778,10 +834,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: a6f0236dbda0f63aa9a25ad1ff9a9d8a4eaaa5012da0dc59d21afdb1dc361ca4
|
sha256: dd8f9344bc305ae2923e3d11a2a911d9a4e2c7dd6fe0ed10626d63211a69676e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.4"
|
version: "4.1.3"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ dependencies:
|
||||||
built_value: ^8.4.4
|
built_value: ^8.4.4
|
||||||
data_table_2: ^2.4.2
|
data_table_2: ^2.4.2
|
||||||
dio: ^5.1.1
|
dio: ^5.1.1
|
||||||
|
flutter_chips_input: ^2.0.0
|
||||||
flutter_fast_forms: ^10.0.0
|
flutter_fast_forms: ^10.0.0
|
||||||
get: ^4.6.5
|
get: ^4.6.5
|
||||||
get_storage: ^2.1.1
|
get_storage: ^2.1.1
|
||||||
|
|
@ -24,7 +25,7 @@ dependencies:
|
||||||
recase: ^4.1.0
|
recase: ^4.1.0
|
||||||
shared_preferences: ^2.1.0
|
shared_preferences: ^2.1.0
|
||||||
styled_widget: ^0.4.1
|
styled_widget: ^0.4.1
|
||||||
|
super_drag_and_drop: ^0.3.0+2
|
||||||
tuuli_api:
|
tuuli_api:
|
||||||
git:
|
git:
|
||||||
url: https://glab.nuark.xyz/nuark/tuuli_api.git
|
url: https://glab.nuark.xyz/nuark/tuuli_api.git
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue