import 'dart:io'; import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/data/model/app/path_with_prefix.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/sftp/worker.dart'; import 'package:server_box/data/provider/server/all.dart'; import 'package:server_box/data/provider/sftp.dart'; import 'package:server_box/data/res/misc.dart'; import 'package:server_box/data/store/setting.dart'; import 'package:server_box/view/page/storage/sftp.dart'; import 'package:server_box/view/page/storage/sftp_mission.dart'; final class LocalFilePageArgs { final bool? isPickFile; final String? initDir; const LocalFilePageArgs({this.isPickFile, this.initDir}); } class LocalFilePage extends ConsumerStatefulWidget { final LocalFilePageArgs? args; const LocalFilePage({super.key, this.args}); static const route = AppRoute(page: LocalFilePage.new, path: '/files/local'); @override ConsumerState createState() => _LocalFilePageState(); } class _LocalFilePageState extends ConsumerState with AutomaticKeepAliveClientMixin { late final _path = LocalPath(widget.args?.initDir ?? Paths.file); final _sortType = _SortType.name.vn; bool get isPickFile => widget.args?.isPickFile ?? false; @override void dispose() { super.dispose(); _sortType.dispose(); } @override Widget build(BuildContext context) { super.build(context); final title = _path.path.fileNameGetter ?? libL10n.file; return Scaffold( appBar: CustomAppBar( title: AnimatedSwitcher( duration: Durations.short3, child: Text(title, key: ValueKey(title)), ), actions: [ if (!isPickFile) IconButton( onPressed: () async { final path = await Pfs.pickFilePath(); if (path == null) return; final name = path.getFileName() ?? 'imported'; final destinationDir = Directory(_path.path); if (!await destinationDir.exists()) { await destinationDir.create(recursive: true); } await File(path).copy(_path.path.joinPath(name)); setState(() {}); }, icon: const Icon(Icons.add), ), if (!isMobile) IconButton( icon: const Icon(Icons.refresh), tooltip: MaterialLocalizations.of(context).refreshIndicatorSemanticLabel, onPressed: () => setState(() {}), ), if (!isPickFile) _buildMissionBtn(), _buildSortBtn(), ], ), body: isMobile ? RefreshIndicator( onRefresh: () async { setState(() {}); }, child: _sortType.listen(_buildBody), ) : _sortType.listen(_buildBody), ); } Widget _buildBody() { Future> getEntities() async { final files = await Directory(_path.path).list().toList(); final sorted = _sortType.value.sort(files); final stats = await Future.wait(sorted.map((e) async => (e, await e.stat()))); return stats; } return FutureWidget( future: getEntities(), loading: UIs.placeholder, success: (items) { items ??= []; final len = _path.canBack ? items.length + 1 : items.length; return ListView.builder( itemCount: len, padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 13), itemBuilder: (context, index) { if (index == 0 && _path.canBack) { return ListTile( leading: const Icon(Icons.arrow_back), title: const Text('..'), onTap: () { _path.update('..'); setState(() {}); }, ).cardx; } if (_path.canBack) index--; final item = items![index]; final file = item.$1; final fileName = file.path.split('/').last; final stat = item.$2; final isDir = stat.type == FileSystemEntityType.directory; return _buildItem(file: file, fileName: fileName, stat: stat, isDir: isDir); }, ); }, ); } Widget _buildItem({ required FileSystemEntity file, required String fileName, required FileStat stat, required bool isDir, }) { return CardX( child: ListTile( leading: isDir ? const Icon(Icons.folder_open) : const Icon(Icons.insert_drive_file), title: Text(fileName), subtitle: isDir ? null : Text(stat.size.bytes2Str, style: UIs.textGrey), trailing: Text(stat.modified.ymdhms(), style: UIs.textGrey), onLongPress: () { if (isDir) { _showDirActionDialog(file); return; } _showFileActionDialog(file); }, onTap: () { if (!isDir) { _showFileActionDialog(file); return; } _path.update(fileName); setState(() {}); }, ), ); } Widget _buildMissionBtn() { return IconButton( icon: const Icon(Icons.downloading), onPressed: () => SftpMissionPage.route.go(context), ); } Widget _buildSortBtn() { return _sortType.listenVal((value) { return PopupMenuButton<_SortType>( icon: const Icon(Icons.sort), itemBuilder: (_) => _SortType.values.map((e) => e.menuItem).toList(), onSelected: (value) { _sortType.value = value; }, ); }); } @override bool get wantKeepAlive => true; } extension _Actions on _LocalFilePageState { Future _showDirActionDialog(FileSystemEntity file) async { context.showRoundDialog( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( onTap: () { context.pop(); _showRenameDialog(file); }, title: Text(libL10n.rename), leading: const Icon(Icons.abc), ), ListTile( onTap: () { context.pop(); _showDeleteDialog(file); }, title: Text(libL10n.delete), leading: const Icon(Icons.delete), ), ], ), ); } Future _showFileActionDialog(FileSystemEntity file) async { final fileName = file.path.split('/').lastOrNull ?? ''; if (isPickFile) { context.showRoundDialog( title: libL10n.file, child: Text(fileName), actions: [ Btn.ok( onTap: () { context.pop(); context.pop(file.path); }, ), ], ); return; } context.showRoundDialog( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (isMobile) Btn.tile( icon: const Icon(Icons.edit), text: libL10n.edit, onTap: () => _onTapEdit(file, fileName), ), Btn.tile( icon: const Icon(Icons.abc), text: libL10n.rename, onTap: () { context.pop(); _showRenameDialog(file); }, ), Btn.tile( icon: const Icon(Icons.delete), text: libL10n.delete, onTap: () { context.pop(); _showDeleteDialog(file); }, ), Btn.tile( icon: const Icon(Icons.upload), text: l10n.upload, onTap: () => _onTapUpload(file, fileName), ), Btn.tile( icon: const Icon(Icons.open_in_new), text: libL10n.open, onTap: () { Pfs.sharePaths(paths: [file.absolute.path]); }, ), ], ), ); } void _showRenameDialog(FileSystemEntity file) { final fileName = file.path.split(Pfs.seperator).last; final ctrl = TextEditingController(text: fileName); void onSubmit() async { final newName = ctrl.text; if (newName.isEmpty) { context.showSnackBar(libL10n.empty); return; } context.pop(); final newPath = '${file.parent.path}${Pfs.seperator}$newName'; await context.showLoadingDialog(fn: () => file.rename(newPath)); setStateSafe(() {}); } context.showRoundDialog( title: libL10n.rename, child: Input( autoFocus: true, icon: Icons.abc, label: libL10n.name, controller: ctrl, suggestion: true, maxLines: 3, onSubmitted: (p0) => onSubmit(), ), actions: Btn.ok(onTap: onSubmit).toList, ); } void _showDeleteDialog(FileSystemEntity file) { final fileName = file.path.split('/').last; context.showRoundDialog( title: libL10n.delete, child: Text(libL10n.askContinue('${libL10n.delete} $fileName')), actions: Btn.ok( onTap: () async { context.pop(); try { await file.delete(recursive: true); } catch (e) { context.showSnackBar('${libL10n.fail}:\n$e'); return; } setStateSafe(() {}); }, ).toList, ); } } extension _OnTapFile on _LocalFilePageState { void _onTapEdit(FileSystemEntity file, String fileName) async { context.pop(); final stat = await file.stat(); if (stat.size > Miscs.editorMaxSize) { context.showRoundDialog( title: libL10n.attention, child: Text(l10n.fileTooLarge(fileName, stat.size, '1m')), ); return; } await EditorPage.route.go( context, args: EditorPageArgs( path: file.absolute.path, onSave: (_) { context.showSnackBar(l10n.saved); setStateSafe(() {}); }, closeAfterSave: SettingStore.instance.closeAfterSave.fetch(), softWrap: SettingStore.instance.editorSoftWrap.fetch(), enableHighlight: SettingStore.instance.editorHighlight.fetch(), ), ); } void _onTapUpload(FileSystemEntity file, String fileName) async { context.pop(); final spi = await context.showPickSingleDialog( title: libL10n.select, items: ref.read(serversNotifierProvider).servers.values.toList(), display: (e) => e.name, ); if (spi == null) return; final args = SftpPageArgs(spi: spi, isSelect: true); final remotePath = await SftpPage.route.go(context, args); if (remotePath == null) { return; } ref.read(sftpNotifierProvider.notifier).add(SftpReq(spi, '$remotePath/$fileName', file.absolute.path, SftpReqType.upload)); context.showSnackBar(l10n.added2List); } } enum _SortType { name, size, time; List sort(List files) { switch (this) { case _SortType.name: files.sort((a, b) => a.path.compareTo(b.path)); break; case _SortType.size: files.sort((a, b) => a.statSync().size.compareTo(b.statSync().size)); break; case _SortType.time: files.sort((a, b) => a.statSync().modified.compareTo(b.statSync().modified)); break; } return files; } String get i18n => switch (this) { name => libL10n.name, size => l10n.size, time => l10n.time, }; IconData get icon => switch (this) { name => Icons.sort_by_alpha, size => Icons.sort, time => Icons.access_time, }; PopupMenuItem<_SortType> get menuItem { return PopupMenuItem( value: this, child: Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [Icon(icon), Text(i18n)]), ); } }