From c0cd2d72556c097aca1a7a85d4cd8ff78bc5e7c8 Mon Sep 17 00:00:00 2001 From: Levi Date: Mon, 4 Sep 2023 13:51:30 +0800 Subject: [PATCH] feat: task file manage (#196) 1. support view task file list 2. support share file on android and iOS --- ui/flutter/assets/locales/zh_CN.json | 2 +- .../modules/setting/views/setting_view.dart | 14 +-- .../task/bindings/task_files_binding.dart | 12 +++ .../controllers/task_files_controller.dart | 86 +++++++++++++++++ .../modules/task/views/task_files_view.dart | 92 +++++++++++++++++++ ui/flutter/lib/app/routes/app_pages.dart | 11 ++- ui/flutter/lib/app/routes/app_routes.dart | 2 + ui/flutter/lib/app/views/breadcrumb_view.dart | 59 ++++++++++++ .../lib/app/views/buid_task_list_view.dart | 12 ++- .../lib/app/views/directory_selector.dart | 2 +- ui/flutter/lib/generated/locales.g.dart | 2 +- ui/flutter/lib/util/util.dart | 7 ++ ui/flutter/pubspec.lock | 24 +++++ ui/flutter/pubspec.yaml | 1 + 14 files changed, 312 insertions(+), 14 deletions(-) create mode 100644 ui/flutter/lib/app/modules/task/bindings/task_files_binding.dart create mode 100644 ui/flutter/lib/app/modules/task/controllers/task_files_controller.dart create mode 100644 ui/flutter/lib/app/modules/task/views/task_files_view.dart create mode 100644 ui/flutter/lib/app/views/breadcrumb_view.dart diff --git a/ui/flutter/assets/locales/zh_CN.json b/ui/flutter/assets/locales/zh_CN.json index 4ea7e65f..0a3dad97 100644 --- a/ui/flutter/assets/locales/zh_CN.json +++ b/ui/flutter/assets/locales/zh_CN.json @@ -29,7 +29,7 @@ "downloadDirValid": "请选择下载目录", "connections": "连接数", "maxRunning": "最大下载数", - "items": "@count 条", + "items": "@count 项", "subscribeTracker": "订阅 Tracker", "subscribeFail": "订阅失败,请检查网络或稍后重试", "update": "更新", diff --git a/ui/flutter/lib/app/modules/setting/views/setting_view.dart b/ui/flutter/lib/app/modules/setting/views/setting_view.dart index 84a06e6e..0432cb4a 100644 --- a/ui/flutter/lib/app/modules/setting/views/setting_view.dart +++ b/ui/flutter/lib/app/modules/setting/views/setting_view.dart @@ -334,13 +334,15 @@ class SettingView extends GetView { width: 150, child: DropdownButtonFormField( value: startCfg.value.network, - onChanged: (value) async { - startCfg.update((val) { - val!.network = value!; - }); + onChanged: Util.isDesktop() + ? (value) async { + startCfg.update((val) { + val!.network = value!; + }); - await debounceSave(needRestart: true); - }, + await debounceSave(needRestart: true); + } + : null, items: [ const DropdownMenuItem( value: 'tcp', diff --git a/ui/flutter/lib/app/modules/task/bindings/task_files_binding.dart b/ui/flutter/lib/app/modules/task/bindings/task_files_binding.dart new file mode 100644 index 00000000..27a954f1 --- /dev/null +++ b/ui/flutter/lib/app/modules/task/bindings/task_files_binding.dart @@ -0,0 +1,12 @@ +import 'package:get/get.dart'; + +import '../controllers/task_files_controller.dart'; + +class TaskFilesBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut( + () => TaskFilesController(), + ); + } +} diff --git a/ui/flutter/lib/app/modules/task/controllers/task_files_controller.dart b/ui/flutter/lib/app/modules/task/controllers/task_files_controller.dart new file mode 100644 index 00000000..aff4180d --- /dev/null +++ b/ui/flutter/lib/app/modules/task/controllers/task_files_controller.dart @@ -0,0 +1,86 @@ +import 'package:get/get.dart'; +import '../../../../api/api.dart'; +import '../../../../api/model/resource.dart'; +import '../../../../api/model/task.dart'; + +class FileItem { + final bool isDirectory; + final String path; + final String name; + final int size; + + FileItem(this.isDirectory, this.path, this.name, this.size); + + String get fullPath => "${path == "/" ? path : "$path/"}$name"; +} + +class TaskFilesController extends GetxController { + final Map> _dirMap = {}; + + final task = Rx(null); + final fileList = [].obs; + + @override + void onInit() async { + super.onInit(); + + final taskId = Get.rootDelegate.parameters['id']; + final tasks = await getTasks([]); + task.value = tasks.firstWhere((element) => element.id == taskId); + parseDirMap(task.value!.meta.res.files); + toDir("/"); + } + + void parseDirMap(List fileList) { + for (final file in fileList) { + String dir = file.path; + if (!dir.startsWith("/")) { + dir = "/$dir"; + } + if (!_dirMap.containsKey(dir)) { + _dirMap[dir] = []; + } + + _dirMap[dir]!.add(FileItem(false, dir, file.name, file.size)); + + void findParent(String dir) { + final parentDirIndex = dir.lastIndexOf("/"); + String parentDir = + parentDirIndex == 0 ? "/" : dir.substring(0, parentDirIndex); + if (!_dirMap.containsKey(parentDir)) { + _dirMap[parentDir] = []; + } + String dirName = dir.substring(dir.lastIndexOf("/") + 1); + if (!_dirMap[parentDir]!.any((element) => element.name == dirName)) { + _dirMap[parentDir]!.add(FileItem(true, parentDir, dirName, 0)); + } + } + + while (dir != "/" && dir != "") { + findParent(dir); + dir = dir.substring(0, dir.lastIndexOf("/")); + } + } + + // sort children, directories first then files, alphabetically + _dirMap.forEach((key, value) { + value.sort((a, b) { + if (a.isDirectory && !b.isDirectory) { + return -1; + } else if (!a.isDirectory && b.isDirectory) { + return 1; + } else { + return a.name.compareTo(b.name); + } + }); + }); + } + + void toDir(String dir) { + fileList.value = _dirMap[dir]?.toList() ?? []; + } + + int dirItemCount(String dir) { + return _dirMap[dir]?.length ?? 0; + } +} diff --git a/ui/flutter/lib/app/modules/task/views/task_files_view.dart b/ui/flutter/lib/app/modules/task/views/task_files_view.dart new file mode 100644 index 00000000..61d68b36 --- /dev/null +++ b/ui/flutter/lib/app/modules/task/views/task_files_view.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../../../util/file_icon.dart'; +import '../../../../util/icons.dart'; +import '../../../../util/util.dart'; +import '../../../views/breadcrumb_view.dart'; +import '../controllers/task_files_controller.dart'; + +class TaskFilesView extends GetView { + const TaskFilesView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Get.rootDelegate.popRoute()), + // actions: [], + title: Obx(() => Text(controller.task.value?.meta.res.name ?? "")), + ), + body: Obx(() { + final fileList = controller.fileList; + final breadcrumbItems = ["/"]; + if (fileList.isNotEmpty) { + final file = fileList.first; + final path = file.path.substring(1); + if (path.isNotEmpty) { + final pathArr = path.split("/"); + for (int i = 0; i < pathArr.length; i++) { + breadcrumbItems.add(pathArr[i]); + } + } + } + return Column( + children: [ + Breadcrumb( + items: breadcrumbItems, + onItemTap: (index) { + final targetDirArr = []; + for (int i = 0; i <= index; i++) { + targetDirArr.add(breadcrumbItems[i]); + } + controller + .toDir(targetDirArr.join("/").replaceFirst('//', '/')); + }).paddingOnly(left: 16, top: 16, bottom: 8), + Expanded( + child: ListView.builder( + itemBuilder: (context, index) { + final meta = controller.task.value!.meta; + final file = fileList[index]; + return ListTile( + leading: file.isDirectory + ? const Icon(Icons.folder) + : Icon(FaIcons.allIcons[findIcon(file.name)]), + title: Text(fileList[index].name), + subtitle: file.isDirectory + ? Text('items'.trParams({ + 'count': controller + .dirItemCount(file.fullPath) + .toString() + })) + : Text(Util.fmtByte(file.size)), + trailing: file.isDirectory + ? null + : IconButton( + icon: const Icon(Icons.share), + onPressed: () { + final xfile = XFile(Util.safePathJoin([ + meta.opts.path, + meta.res.rootDir, + file.fullPath + ])); + Share.shareXFiles([xfile]); + }), + onTap: () { + if (file.isDirectory) { + controller.toDir(file.fullPath); + } + }, + ); + }, + itemCount: controller.fileList.length, + ), + ) + ], + ); + })); + } +} diff --git a/ui/flutter/lib/app/routes/app_pages.dart b/ui/flutter/lib/app/routes/app_pages.dart index 9935592d..eefdd270 100644 --- a/ui/flutter/lib/app/routes/app_pages.dart +++ b/ui/flutter/lib/app/routes/app_pages.dart @@ -1,4 +1,5 @@ import 'package:get/get.dart'; +import 'package:gopeed/app/modules/task/views/task_files_view.dart'; import 'package:gopeed/app/modules/task/views/task_view.dart'; import '../modules/create/bindings/create_binding.dart'; @@ -10,6 +11,7 @@ import '../modules/root/views/root_view.dart'; import '../modules/setting/bindings/setting_binding.dart'; import '../modules/setting/views/setting_view.dart'; import '../modules/task/bindings/task_binding.dart'; +import '../modules/task/bindings/task_files_binding.dart'; part 'app_routes.dart'; @@ -37,7 +39,14 @@ class AppPages { name: _Paths.TASK, page: () => const TaskView(), transition: Transition.noTransition, - binding: TaskBinding()), + binding: TaskBinding(), + children: [ + GetPage( + name: _Paths.TASK_FILES, + page: () => const TaskFilesView(), + transition: Transition.noTransition, + binding: TaskFilesBinding()), + ]), GetPage( name: _Paths.SETTING, page: () => const SettingView(), diff --git a/ui/flutter/lib/app/routes/app_routes.dart b/ui/flutter/lib/app/routes/app_routes.dart index 49e66033..40ec86d4 100644 --- a/ui/flutter/lib/app/routes/app_routes.dart +++ b/ui/flutter/lib/app/routes/app_routes.dart @@ -7,6 +7,7 @@ abstract class Routes { static const HOME = _Paths.HOME; static const CREATE = _Paths.CREATE; static const TASK = _Paths.HOME + _Paths.TASK; + static const TASK_FILES = TASK + _Paths.TASK_FILES; static const SETTING = _Paths.HOME + _Paths.SETTING; } @@ -16,5 +17,6 @@ abstract class _Paths { static const HOME = '/home'; static const CREATE = '/create'; static const TASK = '/task'; + static const TASK_FILES = '/files'; static const SETTING = '/setting'; } diff --git a/ui/flutter/lib/app/views/breadcrumb_view.dart b/ui/flutter/lib/app/views/breadcrumb_view.dart new file mode 100644 index 00000000..0fe9f635 --- /dev/null +++ b/ui/flutter/lib/app/views/breadcrumb_view.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class Breadcrumb extends StatelessWidget { + final List items; + final Function(int)? onItemTap; + final TextStyle textStyle; + final TextStyle activeTextStyle; + + const Breadcrumb({ + super.key, + required this.items, + this.onItemTap, + this.textStyle = const TextStyle(fontSize: 16), + this.activeTextStyle = + const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + }); + + @override + Widget build(BuildContext context) { + List children = []; + for (int i = 0; i < items.length; i++) { + children.add( + GestureDetector( + onTap: () { + if (onItemTap != null) { + onItemTap!(i); + } + }, + child: Text( + items[i], + style: i == items.length - 1 ? activeTextStyle : textStyle, + ), + ), + ); + if (i != items.length - 1) { + children.add(const Text(" > ")); + } + } + return Row( + children: [ + ...(children.length == 1 + ? children.sublist(0, 1) + : children.sublist(0, 2)), + children.length > 2 + ? Expanded( + child: SingleChildScrollView( + reverse: true, + scrollDirection: Axis.horizontal, + child: Row( + children: children.sublist(2), + ), + ), + ) + : null, + ].where((e) => e != null).map((e) => e!).toList(), + ).paddingOnly(right: 12); + } +} diff --git a/ui/flutter/lib/app/views/buid_task_list_view.dart b/ui/flutter/lib/app/views/buid_task_list_view.dart index 03a64291..68007368 100644 --- a/ui/flutter/lib/app/views/buid_task_list_view.dart +++ b/ui/flutter/lib/app/views/buid_task_list_view.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:gopeed/api/model/meta.dart'; -import 'package:gopeed/api/model/resource.dart'; import 'package:path/path.dart' as path; import '../../api/api.dart'; @@ -109,11 +108,16 @@ class BuildTaskListView extends GetView { List buildActions() { final list = []; if (isDone()) { - if (Util.isDesktop()) { + if (Util.isDesktop() || Util.isMobile()) { list.add(IconButton( icon: const Icon(Icons.folder_open), - onPressed: () async { - await FileExplorer.openAndSelectFile(buildExplorerUrl(task)); + onPressed: () { + if (Util.isDesktop()) { + FileExplorer.openAndSelectFile(buildExplorerUrl(task)); + } else { + Get.rootDelegate + .toNamed(Routes.TASK_FILES, parameters: {'id': task.id}); + } }, )); } diff --git a/ui/flutter/lib/app/views/directory_selector.dart b/ui/flutter/lib/app/views/directory_selector.dart index 9439f5b8..d84d05d2 100644 --- a/ui/flutter/lib/app/views/directory_selector.dart +++ b/ui/flutter/lib/app/views/directory_selector.dart @@ -24,7 +24,7 @@ class _DirectorySelectorState extends State { children: [ Expanded( child: TextFormField( - readOnly: Util.isWeb() && !Util.isIOS() ? false : true, + readOnly: Util.isWeb() ? false : true, controller: widget.controller, decoration: widget.showLabel ? InputDecoration( diff --git a/ui/flutter/lib/generated/locales.g.dart b/ui/flutter/lib/generated/locales.g.dart index 109ebbd5..15f4b2a2 100644 --- a/ui/flutter/lib/generated/locales.g.dart +++ b/ui/flutter/lib/generated/locales.g.dart @@ -357,7 +357,7 @@ class Locales { 'downloadDirValid': '请选择下载目录', 'connections': '连接数', 'maxRunning': '最大下载数', - 'items': '@count 条', + 'items': '@count 项', 'subscribeTracker': '订阅 Tracker', 'subscribeFail': '订阅失败,请检查网络或稍后重试', 'update': '更新', diff --git a/ui/flutter/lib/util/util.dart b/ui/flutter/lib/util/util.dart index 29e8dbb6..c5ea578b 100644 --- a/ui/flutter/lib/util/util.dart +++ b/ui/flutter/lib/util/util.dart @@ -12,6 +12,13 @@ class Util { return path; } + static String safePathJoin(List paths) { + return paths + .map((e) => safeDir(e)) + .join("/") + .replaceAll(RegExp(r'//'), "/"); + } + static String fmtByte(int byte) { if (byte < 0) { return "0 B"; diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index f1351acf..9dec16c8 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -757,6 +757,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.4.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "6cec740fa0943a826951223e76218df002804adb588235a8910dc3d6b0654e11" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.1.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "357412af4178d8e11d14f41723f80f12caea54cf0d5cd29af9dcdab85d58aea7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.3.0" shared_preferences: dependency: "direct main" description: @@ -1018,6 +1034,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.0.7" + uuid: + dependency: transitive + description: + name: uuid + sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" vector_math: dependency: transitive description: diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index 765af765..3faef674 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -58,6 +58,7 @@ dependencies: window_manager: ^0.3.4 tray_manager: ^0.2.0 autoscale_tabbarview: ^1.0.2 + share_plus: ^7.1.0 dev_dependencies: flutter_test: sdk: flutter