mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2026-02-15 20:55:25 +01:00
* refactor(Settings page): Simplify the click handling logic of the cancel button * fix(backup_service): Add a cancel button in the restore backup dialog * refactor(Settings Page): Refactor the ordered list component and optimize state management - Extract the logic for building list items into a separate method to improve maintainability - Add animation effects to enhance the dragging experience - Use PageStorageKey to maintain the scroll position - Optimize the state management logic of the checkbox - Add new contributors in github_id.dart * fix: Add SafeArea to the settings page to prevent content from being obscured Add SafeArea wrapping content in multiple settings pages to prevent content from being obscured by the navigation bar on certain devices, thereby enhancing user experience * refactor: Extract file list retrieval method and optimize asynchronous loading of iOS settings page Extract the `_getEntities` method from an inline function to a class member method to enhance code readability Preload watch context and push token in the iOS settings page to avoid repeatedly creating Futures * fix: Add a `key` attribute to the ChoiceChipX component to avoid rendering issues * refactor(Settings page): Refactor the platform-related settings logic and merge the Android settings into the main page Migrate the Android platform settings from a standalone page to the main settings page, and remove redundant Android settings page files Adjust the platform setting logic, retaining only the special setting entry for the iOS platform * build: Update fl_lib dependency to v1.0.363 * feat(Settings): Add persistent disable state for cards and virtual keys Add persistent storage functionality for server detail cards and SSH virtual key disable status Modify the logic of relevant pages to support the saving and restoration of disabled states * refactor(setting): Simplify save logic and optimize file sorting performance In the settings page, remove the unnecessary `enabledList` filtering and directly save the `_order` list Optimize the sorting logic on the local file page by first retrieving the file status before proceeding with sorting * fix: Optimize data filtering and backup service error handling on the settings page Fix the data filtering logic in the settings page to only process key-value pairs with specific prefixes Add error handling to the backup service, capture and display merge failure exceptions * fix(Settings page): Fixed the issue where disabled items were not included in the order settings and asynchronously saved preference settings Fix the issue where disabled items in the virtual keyboard and service details order settings are not included in the order list Change the preference setting saving method to an asynchronous operation, and add a mounted check to prevent updating the state after the component is unmounted * refactor: Optimize the reordering logic and remove redundant sorting methods Narrow the scope of state updates in the reordering logic to only encompass the parts where data is actually modified Remove the unused sorting methods in `_local.dart` to simplify the code * refactor(view): Optimize the refresh logic of the local file page Refactor the refresh method that directly calls setState into a unified _refresh method Use the `_entitiesFuture` to cache the list of files to obtain results and avoid redundant calculations * Update lib/view/page/storage/local.dart Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
433 lines
12 KiB
Dart
433 lines
12 KiB
Dart
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/core/utils/host_key_helper.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<String, LocalFilePageArgs>(page: LocalFilePage.new, path: '/files/local');
|
|
|
|
@override
|
|
ConsumerState<LocalFilePage> createState() => _LocalFilePageState();
|
|
}
|
|
|
|
class _LocalFilePageState extends ConsumerState<LocalFilePage> with AutomaticKeepAliveClientMixin {
|
|
late final _path = LocalPath(widget.args?.initDir ?? Paths.file);
|
|
final _sortType = _SortType.name.vn;
|
|
late Future<List<(FileSystemEntity, FileStat)>> _entitiesFuture = _getEntities();
|
|
bool get isPickFile => widget.args?.isPickFile ?? false;
|
|
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
_sortType.dispose();
|
|
}
|
|
|
|
Future<void> _refresh() async {
|
|
setStateSafe(() {
|
|
_entitiesFuture = _getEntities();
|
|
});
|
|
await _entitiesFuture;
|
|
}
|
|
|
|
@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));
|
|
_refresh();
|
|
},
|
|
icon: const Icon(Icons.add),
|
|
),
|
|
if (!isMobile)
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh),
|
|
tooltip: MaterialLocalizations.of(context).refreshIndicatorSemanticLabel,
|
|
onPressed: _refresh,
|
|
),
|
|
if (!isPickFile) _buildMissionBtn(),
|
|
_buildSortBtn(),
|
|
],
|
|
),
|
|
body: isMobile
|
|
? RefreshIndicator(
|
|
onRefresh: _refresh,
|
|
child: _sortType.listen(_buildBody),
|
|
)
|
|
: _sortType.listen(_buildBody),
|
|
);
|
|
}
|
|
|
|
Widget _buildBody() {
|
|
return FutureWidget(
|
|
future: _entitiesFuture,
|
|
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('..');
|
|
_refresh();
|
|
},
|
|
).cardx;
|
|
}
|
|
|
|
if (_path.canBack) index--;
|
|
|
|
final item = items![index];
|
|
final file = item.$1;
|
|
final fileName = file.path.split(Pfs.seperator).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,
|
|
}) {
|
|
final isServerFolder = isDir && file.parent.path == Paths.file;
|
|
String? serverName;
|
|
if (isServerFolder) {
|
|
final servers = ref.read(serversProvider).servers;
|
|
final server = servers[fileName];
|
|
if (server != null) {
|
|
serverName = server.name;
|
|
}
|
|
}
|
|
|
|
return CardX(
|
|
child: ListTile(
|
|
leading: isDir ? const Icon(Icons.folder_open) : const Icon(Icons.insert_drive_file),
|
|
title: Text(serverName ?? fileName),
|
|
subtitle: isDir
|
|
? (serverName != null ? Text(fileName, style: UIs.textGrey) : 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);
|
|
_refresh();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMissionBtn() {
|
|
return IconButton(
|
|
icon: const Icon(Icons.downloading),
|
|
onPressed: () => SftpMissionPage.route.go(context),
|
|
);
|
|
}
|
|
|
|
Future<List<(FileSystemEntity, FileStat)>> _getEntities() async {
|
|
final files = await Directory(_path.path).list().toList();
|
|
final stats = await Future.wait(files.map((e) async => (e, await e.stat())));
|
|
stats.sort(_sortType.value.compareTuple);
|
|
return stats;
|
|
}
|
|
|
|
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<void> _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<void> _showFileActionDialog(FileSystemEntity file) async {
|
|
final fileName = file.path.split(Pfs.seperator).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: libL10n.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(Pfs.seperator).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<Spi>(
|
|
title: libL10n.select,
|
|
items: ref.read(serversProvider).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;
|
|
}
|
|
|
|
if (!await ensureHostKeyAcceptedForSftp(context, spi)) {
|
|
return;
|
|
}
|
|
|
|
ref.read(sftpProvider.notifier).add(SftpReq(spi, '$remotePath/$fileName', file.absolute.path, SftpReqType.upload));
|
|
context.showSnackBar(l10n.added2List);
|
|
}
|
|
}
|
|
|
|
enum _SortType {
|
|
name,
|
|
size,
|
|
time;
|
|
|
|
int compareTuple((FileSystemEntity, FileStat) a, (FileSystemEntity, FileStat) b) {
|
|
return switch (this) {
|
|
_SortType.name => a.$1.path.compareTo(b.$1.path),
|
|
_SortType.size => a.$2.size.compareTo(b.$2.size),
|
|
_SortType.time => a.$2.modified.compareTo(b.$2.modified),
|
|
};
|
|
}
|
|
|
|
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)]),
|
|
);
|
|
}
|
|
}
|