569 lines
17 KiB
Dart
569 lines
17 KiB
Dart
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("В ответе нет данных");
|
||
}
|
||
|
||
_assetsList.clear();
|
||
_assetsList.addAll(respData);
|
||
} on DioError catch (e) {
|
||
final respData = e.response?.data;
|
||
if (respData != null) {
|
||
Get.snackbar(
|
||
"Ошибка получения ресурсов",
|
||
"${respData['error']}",
|
||
);
|
||
} else {
|
||
Get.snackbar(
|
||
"Ошибка получения ресурсов",
|
||
"$e",
|
||
);
|
||
}
|
||
} catch (e) {
|
||
Get.snackbar(
|
||
"Ошибка получения ресурсов",
|
||
"$e",
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<void> refreshTag() async {
|
||
try {
|
||
final resp = await ApiController.to.apiClient.getAssetsTags();
|
||
|
||
final respData = resp.data;
|
||
if (respData == null) {
|
||
throw Exception("В ответе нет данных");
|
||
}
|
||
|
||
_tagsList.clear();
|
||
_tagsList.addAll(respData);
|
||
} on DioError catch (e) {
|
||
final respData = e.response?.data;
|
||
if (respData != null) {
|
||
Get.snackbar(
|
||
"Ошибка получения тегов",
|
||
"${respData['error']}",
|
||
);
|
||
} else {
|
||
Get.snackbar(
|
||
"Ошибка получения тегов",
|
||
"$e",
|
||
);
|
||
}
|
||
} catch (e) {
|
||
Get.snackbar(
|
||
"Ошибка получения тегов",
|
||
"$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("Ошибка", value.toString());
|
||
},
|
||
);
|
||
},
|
||
child: const Text("Перенесите файл сюда")
|
||
.paddingAll(8)
|
||
.fittedBox()
|
||
.constrained(height: 200, width: 200),
|
||
).border(all: 2, color: Colors.lightBlueAccent),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Get.back(result: null),
|
||
child: const Text("Отменить"),
|
||
)
|
||
],
|
||
),
|
||
);
|
||
|
||
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("В ответе нет данных");
|
||
}
|
||
|
||
refreshData();
|
||
} on DioError catch (e) {
|
||
final respData = e.response?.data;
|
||
if (respData != null) {
|
||
Get.snackbar(
|
||
"Ошибка загрузки ресурса",
|
||
"${respData['error']}",
|
||
);
|
||
} else {
|
||
Get.snackbar(
|
||
"Ошибка загрузки ресурса",
|
||
"$e",
|
||
);
|
||
}
|
||
} catch (e) {
|
||
Get.snackbar(
|
||
"Ошибка загрузки ресурса",
|
||
"$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("Изменение ресурса"),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
TextField(
|
||
controller: TextEditingController(text: description.value),
|
||
onChanged: (value) => description.value = value,
|
||
decoration: const InputDecoration(
|
||
labelText: "Описание",
|
||
),
|
||
),
|
||
TextField(
|
||
controller: TextEditingController(text: tags.join(", ")),
|
||
onChanged: (value) => tags.value =
|
||
value.split(",").map((e) => e.trim()).toList(growable: false),
|
||
decoration: const InputDecoration(
|
||
labelText: "Теги",
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Obx(
|
||
() => Wrap(
|
||
children: tags
|
||
.where((p0) => p0.isNotEmpty)
|
||
.map((tag) => Chip(label: Text(tag)).paddingAll(2))
|
||
.toList(growable: false),
|
||
).paddingAll(8).card(color: Colors.blueGrey.shade200).expanded(),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () => previewAsset(e),
|
||
child: const Text("Предпросмотр")),
|
||
],
|
||
).constrained(width: Get.width * 0.5, height: Get.width * 0.5),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Get.back(result: false),
|
||
child: const Text("Отменить"),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Get.back(result: true),
|
||
child: const Text("Подтвердить"),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
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("В ответе нет данных");
|
||
}
|
||
|
||
refreshData();
|
||
} on DioError catch (e) {
|
||
final respData = e.response?.data;
|
||
if (respData != null) {
|
||
Get.snackbar(
|
||
"Ошибка изменения ресурса",
|
||
"${respData['error']}",
|
||
);
|
||
} else {
|
||
Get.snackbar(
|
||
"Ошибка изменения ресурса",
|
||
"$e",
|
||
);
|
||
}
|
||
} catch (e) {
|
||
Get.snackbar(
|
||
"Ошибка изменения ресурса",
|
||
"$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("Удаление ресурса"),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Text(
|
||
"Вы планируете удалить ресурс. Это действие нельзя отменить."),
|
||
Obx(
|
||
() => CheckboxListTile(
|
||
value: checkReferences.value,
|
||
onChanged: (value) => checkReferences.value = value ?? false,
|
||
title: const Text("Проверить ссылки"),
|
||
),
|
||
),
|
||
Obx(
|
||
() => CheckboxListTile(
|
||
value: deleteReferencing.value,
|
||
onChanged: (value) => deleteReferencing.value = value ?? false,
|
||
title: const Text("Удалить ссылающиеся сущности"),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Get.back(result: false),
|
||
child: const Text("Отменить"),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Get.back(result: true),
|
||
child: const Text("Удалить"),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
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("В ответе нет данных");
|
||
}
|
||
|
||
refreshData();
|
||
} on DioError catch (e) {
|
||
final respData = e.response?.data;
|
||
if (respData != null) {
|
||
Get.snackbar(
|
||
"Ошибка удаления ресурса",
|
||
"${respData['error']}",
|
||
);
|
||
} else {
|
||
Get.snackbar(
|
||
"Ошибка удаления ресурса",
|
||
"$e",
|
||
);
|
||
}
|
||
} catch (e) {
|
||
Get.snackbar(
|
||
"Ошибка удаления ресурса",
|
||
"$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("На данный момент этот тип не поддерживает предпросмотр")
|
||
.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("Теги:"),
|
||
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("Имя файла"), size: ColumnSize.M),
|
||
DataColumn2(label: Text("Описание"), size: ColumnSize.L),
|
||
DataColumn2(label: Text("ID файла"), size: ColumnSize.M),
|
||
DataColumn2(label: Text("Теги"), size: ColumnSize.L),
|
||
DataColumn2(label: Text("Действия")),
|
||
],
|
||
empty: const Text("Ресурсы не найдены"),
|
||
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),
|
||
),
|
||
);
|
||
}
|