Merge branch 'main' of github.com:lollipopkit/flutter_server_box

This commit is contained in:
lollipopkit
2024-02-18 10:33:55 +08:00
23 changed files with 257 additions and 48 deletions

View File

@@ -1342,6 +1342,12 @@ abstract class S {
/// **'Use `rm -r` to delete a folder in SFTP.'** /// **'Use `rm -r` to delete a folder in SFTP.'**
String get sftpRmrDirSummary; String get sftpRmrDirSummary;
/// No description provided for @sftpShowFoldersFirst.
///
/// In en, this message translates to:
/// **'Disply folders first'**
String get sftpShowFoldersFirst;
/// No description provided for @sftpSSHConnected. /// No description provided for @sftpSSHConnected.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View File

@@ -651,6 +651,9 @@ class SDe extends S {
@override @override
String get sftpRmrDirSummary => 'Verwenden Sie \"rm -r\", um das Verzeichnis in SFTP zu löschen.'; String get sftpRmrDirSummary => 'Verwenden Sie \"rm -r\", um das Verzeichnis in SFTP zu löschen.';
@override
String get sftpShowFoldersFirst => 'Ordner zuerst anzeigen';
@override @override
String get sftpSSHConnected => 'SFTP Verbunden'; String get sftpSSHConnected => 'SFTP Verbunden';

View File

@@ -651,6 +651,9 @@ class SEn extends S {
@override @override
String get sftpRmrDirSummary => 'Use `rm -r` to delete a folder in SFTP.'; String get sftpRmrDirSummary => 'Use `rm -r` to delete a folder in SFTP.';
@override
String get sftpShowFoldersFirst => 'Disply folders first';
@override @override
String get sftpSSHConnected => 'SFTP Connected'; String get sftpSSHConnected => 'SFTP Connected';

View File

@@ -651,6 +651,9 @@ class SFr extends S {
@override @override
String get sftpRmrDirSummary => 'Utilisez `rm -r` pour supprimer un dossier dans SFTP.'; String get sftpRmrDirSummary => 'Utilisez `rm -r` pour supprimer un dossier dans SFTP.';
@override
String get sftpShowFoldersFirst => 'Dossiers d\'abord lors du tri';
@override @override
String get sftpSSHConnected => 'SFTP connecté'; String get sftpSSHConnected => 'SFTP connecté';

View File

@@ -651,6 +651,9 @@ class SId extends S {
@override @override
String get sftpRmrDirSummary => 'Gunakan `rm -r` untuk menghapus dir di SFTP'; String get sftpRmrDirSummary => 'Gunakan `rm -r` untuk menghapus dir di SFTP';
@override
String get sftpShowFoldersFirst => 'Folder ditampilkan lebih dulu';
@override @override
String get sftpSSHConnected => 'Sftp terhubung'; String get sftpSSHConnected => 'Sftp terhubung';

View File

@@ -651,6 +651,9 @@ class SZh extends S {
@override @override
String get sftpRmrDirSummary => '在 SFTP 中使用 `rm -r` 来删除文件夹'; String get sftpRmrDirSummary => '在 SFTP 中使用 `rm -r` 来删除文件夹';
@override
String get sftpShowFoldersFirst => '排序时文件夹显示在前';
@override @override
String get sftpSSHConnected => 'SFTP 已连接...'; String get sftpSSHConnected => 'SFTP 已连接...';
@@ -1502,6 +1505,9 @@ class SZhTw extends SZh {
@override @override
String get sftpRmrDirSummary => '在 SFTP 中使用 `rm -r` 來刪除文件夾'; String get sftpRmrDirSummary => '在 SFTP 中使用 `rm -r` 來刪除文件夾';
@override
String get sftpShowFoldersFirst => '排序時文件夾顯示在前';
@override @override
String get sftpSSHConnected => 'SFTP 已連接...'; String get sftpSSHConnected => 'SFTP 已連接...';

View File

@@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.VIBRATE" />
<application <application
android:label="ServerBox" android:label="ServerBox"

View File

@@ -9,26 +9,31 @@ import 'package:toolbox/core/extension/uint8list.dart';
import '../../data/res/misc.dart'; import '../../data/res/misc.dart';
typedef _OnStdout = void Function(String data, StreamSink<Uint8List> sink); typedef _OnStdout = void Function(String data, SSHSession session);
typedef _OnStdin = void Function(StreamSink<Uint8List> sink); typedef _OnStdin = void Function(SSHSession session);
typedef PwdRequestFunc = Future<String?> Function(String? user); typedef PwdRequestFunc = Future<String?> Function(String? user);
extension SSHClientX on SSHClient { extension SSHClientX on SSHClient {
Future<int?> exec( Future<SSHSession> exec(
String cmd, { String cmd, {
_OnStdout? onStderr, _OnStdout? onStderr,
_OnStdout? onStdout, _OnStdout? onStdout,
_OnStdin? stdin, _OnStdin? stdin,
bool redirectToBash = false, // not working yet. do not use
}) async { }) async {
final session = await execute(cmd); final session = await execute(redirectToBash ? "head -1 | bash" : cmd);
if (redirectToBash) {
session.stdin.add("$cmd\n".uint8List);
}
final stdoutDone = Completer<void>(); final stdoutDone = Completer<void>();
final stderrDone = Completer<void>(); final stderrDone = Completer<void>();
if (onStdout != null) { if (onStdout != null) {
session.stdout.listen( session.stdout.listen(
(e) => onStdout(e.string, session.stdin), (e) => onStdout(e.string, session),
onDone: stdoutDone.complete, onDone: stdoutDone.complete,
); );
} else { } else {
@@ -37,7 +42,7 @@ extension SSHClientX on SSHClient {
if (onStderr != null) { if (onStderr != null) {
session.stderr.listen( session.stderr.listen(
(e) => onStderr(e.string, session.stdin), (e) => onStderr(e.string, session),
onDone: stderrDone.complete, onDone: stderrDone.complete,
); );
} else { } else {
@@ -45,14 +50,14 @@ extension SSHClientX on SSHClient {
} }
if (stdin != null) { if (stdin != null) {
stdin(session.stdin); stdin(session);
} }
await stdoutDone.future; await stdoutDone.future;
await stderrDone.future; await stderrDone.future;
session.close(); session.close();
return session.exitCode; return session;
} }
Future<int?> execWithPwd( Future<int?> execWithPwd(
@@ -61,38 +66,48 @@ extension SSHClientX on SSHClient {
_OnStdout? onStdout, _OnStdout? onStdout,
_OnStdout? onStderr, _OnStdout? onStderr,
_OnStdin? stdin, _OnStdin? stdin,
bool redirectToBash = false, // not working yet. do not use
}) async { }) async {
var isRequestingPwd = false; var isRequestingPwd = false;
return await exec( final session = await exec(
cmd, cmd,
onStderr: (data, sink) async { redirectToBash: redirectToBash,
onStderr?.call(data, sink); onStderr: (data, session) async {
debugPrint(
" --- execWithPwd stderr (reqPwd = $isRequestingPwd) --- $data");
onStderr?.call(data, session);
if (isRequestingPwd) return; if (isRequestingPwd) return;
isRequestingPwd = true;
if (data.contains('[sudo] password for ')) { if (data.contains('[sudo] password for ')) {
isRequestingPwd = true;
final user = Miscs.pwdRequestWithUserReg.firstMatch(data)?.group(1); final user = Miscs.pwdRequestWithUserReg.firstMatch(data)?.group(1);
if (context == null) return; if (context == null) return;
final pwd = await context.showPwdDialog(user); final pwd = await context.showPwdDialog(user);
if (pwd == null || pwd.isEmpty) { if (pwd == null || pwd.isEmpty) {
// Add ctrl + c to exit. debugPrint("Empty pwd. Exiting");
sink.add('\x03'.uint8List); session.kill(SSHSignal.INT);
} else { } else {
sink.add('$pwd\n'.uint8List); session.stdin.add('$pwd\n'.uint8List);
} }
isRequestingPwd = false;
} }
}, },
onStdout: onStdout, onStdout: (data, sink) async {
onStdout?.call(data, sink);
debugPrint(" --- execWithPwd stdout --- $data");
},
stdin: stdin, stdin: stdin,
); );
return session.exitCode;
} }
Future<Uint8List> runWithSessionAction( Future<Uint8List> runForOutput(
String command, { String command, {
bool runInPty = false, bool runInPty = false,
bool stdout = true, bool stdout = true,
bool stderr = true, bool stderr = true,
Map<String, String>? environment, Map<String, String>? environment,
Future<void> Function(SSHSession)? action, Future<void> Function(SSHSession)? action,
}) async { }) async {
final session = await execute( final session = await execute(
command, command,

View File

@@ -0,0 +1,83 @@
class ChainComparator<T> {
final ChainComparator<T>? _parent;
final Comparator<T> _comparator;
ChainComparator._create(this._parent, this._comparator);
ChainComparator.empty() : this._create(null, (a, b) => 0);
ChainComparator.create() : this._create(null, (a, b) => 0);
static ChainComparator<T> comparing<T, F extends Comparable<F>>(
F Function(T) extractor) {
return ChainComparator._create(
null, (a, b) => extractor(a).compareTo(extractor(b)));
}
int compare(T a, T b) {
final parent = _parent;
if (parent != null) {
final int result = parent.compare(a, b);
if (result != 0) return result;
}
return _comparator(a, b);
}
int call(T a, T b) {
return compare(a, b);
}
ChainComparator<T> thenCompareBy<F extends Comparable<F>>(
F Function(T) extractor,
{bool reversed = false}) {
return ChainComparator._create(
this,
reversed
? (a, b) => extractor(b).compareTo(extractor(a))
: (a, b) => extractor(a).compareTo(extractor(b)),
);
}
ChainComparator<T> thenWithComparator(Comparator<T> comparator,
{bool reversed = false}) {
return ChainComparator._create(
this,
!reversed ? comparator : (a, b) => comparator(b, a),
);
}
ChainComparator<T> thenCompareByReversed<F extends Comparable<F>>(
F Function(T) extractor) {
return ChainComparator._create(
this, (a, b) => -extractor(a).compareTo(extractor(b)));
}
ChainComparator<T> thenTrueFirst(bool Function(T) f) {
return ChainComparator._create(this, (a, b) {
final fa = f(a), fb = f(b);
return fa == fb ? 0 : (fa ? -1 : 1);
});
}
ChainComparator<T> reversed() {
return ChainComparator._create(null, (a, b) => this.compare(b, a));
}
}
class Comparators {
static Comparator<String> compareStringCaseInsensitive(
{bool uppercaseFirst = false}) {
return (String a, String b) {
final r = a.toLowerCase().compareTo(b.toLowerCase());
if (r != 0) return r;
return uppercaseFirst ? a.compareTo(b) : b.compareTo(a);
};
}
}
/*
Comparator.comparing<Type1, Type2>(Type1::getType2)
.thenCompare<Type3>(Type1::getType3)
.thenCompare<Type4>(Type1::getType4)
.thenCompareReversed<Type5>(Type1::getType5)
*/

View File

@@ -61,3 +61,4 @@ final isWeb = OS.type == OS.web;
final isMobile = OS.type == OS.ios || OS.type == OS.android; final isMobile = OS.type == OS.ios || OS.type == OS.android;
final isDesktop = final isDesktop =
OS.type == OS.linux || OS.type == OS.macos || OS.type == OS.windows; OS.type == OS.linux || OS.type == OS.macos || OS.type == OS.windows;
const isDebuggingMobileLayoutOnDesktop = kDebugMode;

View File

@@ -272,7 +272,7 @@ class ServerProvider extends ChangeNotifier {
ensure(await client.run(ShellFunc.installerMkdirs).string); ensure(await client.run(ShellFunc.installerMkdirs).string);
ensure(await client.runWithSessionAction(ShellFunc.installerShellWriter, ensure(await client.runForOutput(ShellFunc.installerShellWriter,
action: (session) async { action: (session) async {
session.stdin.add(ShellFunc.allScript.uint8List); session.stdin.add(ShellFunc.allScript.uint8List);
}) })

View File

@@ -197,6 +197,9 @@ class SettingStore extends PersistentStore {
/// Open SFTP with last viewed path /// Open SFTP with last viewed path
late final sftpOpenLastPath = property('sftpOpenLastPath', true); late final sftpOpenLastPath = property('sftpOpenLastPath', true);
/// Show folders first in SFTP file browser
late final sftpShowFoldersFirst = property('sftpShowFoldersFirst', true);
/// Show tip of suspend /// Show tip of suspend
late final showSuspendTip = property('showSuspendTip', true); late final showSuspendTip = property('showSuspendTip', true);

View File

@@ -207,6 +207,7 @@
"setting": "Einstellungen", "setting": "Einstellungen",
"sftpDlPrepare": "Verbindung vorbereiten...", "sftpDlPrepare": "Verbindung vorbereiten...",
"sftpRmrDirSummary": "Verwenden Sie \"rm -r\", um das Verzeichnis in SFTP zu löschen.", "sftpRmrDirSummary": "Verwenden Sie \"rm -r\", um das Verzeichnis in SFTP zu löschen.",
"sftpShowFoldersFirst": "Ordner zuerst anzeigen",
"sftpSSHConnected": "SFTP Verbunden", "sftpSSHConnected": "SFTP Verbunden",
"showDistLogo": "Distributionslogo anzeigen", "showDistLogo": "Distributionslogo anzeigen",
"shutdown": "Abschaltung", "shutdown": "Abschaltung",

View File

@@ -207,6 +207,7 @@
"setting": "Settings", "setting": "Settings",
"sftpDlPrepare": "Preparing to connect...", "sftpDlPrepare": "Preparing to connect...",
"sftpRmrDirSummary": "Use `rm -r` to delete a folder in SFTP.", "sftpRmrDirSummary": "Use `rm -r` to delete a folder in SFTP.",
"sftpShowFoldersFirst": "Disply folders first",
"sftpSSHConnected": "SFTP Connected", "sftpSSHConnected": "SFTP Connected",
"showDistLogo": "Show distribution logo", "showDistLogo": "Show distribution logo",
"shutdown": "Shutdown", "shutdown": "Shutdown",

View File

@@ -207,6 +207,7 @@
"setting": "Paramètres", "setting": "Paramètres",
"sftpDlPrepare": "Préparation de la connexion...", "sftpDlPrepare": "Préparation de la connexion...",
"sftpRmrDirSummary": "Utilisez `rm -r` pour supprimer un dossier dans SFTP.", "sftpRmrDirSummary": "Utilisez `rm -r` pour supprimer un dossier dans SFTP.",
"sftpShowFoldersFirst": "Dossiers d'abord lors du tri",
"sftpSSHConnected": "SFTP connecté", "sftpSSHConnected": "SFTP connecté",
"showDistLogo": "Afficher le logo de la distribution", "showDistLogo": "Afficher le logo de la distribution",
"shutdown": "Éteindre", "shutdown": "Éteindre",

View File

@@ -207,6 +207,7 @@
"setting": "Pengaturan", "setting": "Pengaturan",
"sftpDlPrepare": "Bersiap untuk terhubung ...", "sftpDlPrepare": "Bersiap untuk terhubung ...",
"sftpRmrDirSummary": "Gunakan `rm -r` untuk menghapus dir di SFTP", "sftpRmrDirSummary": "Gunakan `rm -r` untuk menghapus dir di SFTP",
"sftpShowFoldersFirst": "Folder ditampilkan lebih dulu",
"sftpSSHConnected": "Sftp terhubung", "sftpSSHConnected": "Sftp terhubung",
"showDistLogo": "Tampilkan logo distribusi", "showDistLogo": "Tampilkan logo distribusi",
"shutdown": "Matikan", "shutdown": "Matikan",

View File

@@ -207,6 +207,7 @@
"setting": "设置", "setting": "设置",
"sftpDlPrepare": "准备连接至服务器...", "sftpDlPrepare": "准备连接至服务器...",
"sftpRmrDirSummary": "在 SFTP 中使用 `rm -r` 来删除文件夹", "sftpRmrDirSummary": "在 SFTP 中使用 `rm -r` 来删除文件夹",
"sftpShowFoldersFirst": "排序时文件夹显示在前",
"sftpSSHConnected": "SFTP 已连接...", "sftpSSHConnected": "SFTP 已连接...",
"showDistLogo": "显示发行版 Logo", "showDistLogo": "显示发行版 Logo",
"shutdown": "关机", "shutdown": "关机",

View File

@@ -207,6 +207,7 @@
"setting": "設置", "setting": "設置",
"sftpDlPrepare": "準備連接至服務器...", "sftpDlPrepare": "準備連接至服務器...",
"sftpRmrDirSummary": "在 SFTP 中使用 `rm -r` 來刪除文件夾", "sftpRmrDirSummary": "在 SFTP 中使用 `rm -r` 來刪除文件夾",
"sftpShowFoldersFirst": "排序時文件夾顯示在前",
"sftpSSHConnected": "SFTP 已連接...", "sftpSSHConnected": "SFTP 已連接...",
"showDistLogo": "顯示發行版 Logo", "showDistLogo": "顯示發行版 Logo",
"shutdown": "关机", "shutdown": "关机",

View File

@@ -206,7 +206,7 @@ class _ServerPageState extends State<ServerPage>
late final List<Widget> children; late final List<Widget> children;
if (srv.state == ServerState.finished) { if (srv.state == ServerState.finished) {
if (cardStatus.value.flip) { if (cardStatus.value.flip) {
children = [title, ..._buildFlipedCard(srv)]; children = [title, ..._buildFlippedCard(srv)];
} else { } else {
children = [title, ..._buildNormalCard(srv.status, srv.spi)]; children = [title, ..._buildNormalCard(srv.status, srv.spi)];
} }
@@ -228,7 +228,7 @@ class _ServerPageState extends State<ServerPage>
); );
} }
List<Widget> _buildFlipedCard(Server srv) { List<Widget> _buildFlippedCard(Server srv) {
return [ return [
UIs.height13, UIs.height13,
Row( Row(

View File

@@ -855,6 +855,7 @@ class _SettingPageState extends State<SettingPage> {
children: [ children: [
_buildSftpRmrDir(), _buildSftpRmrDir(),
_buildSftpOpenLastPath(), _buildSftpOpenLastPath(),
_buildSftpShowFoldersFirst(),
].map((e) => CardX(child: e)).toList(), ].map((e) => CardX(child: e)).toList(),
); );
} }
@@ -867,6 +868,13 @@ class _SettingPageState extends State<SettingPage> {
); );
} }
Widget _buildSftpShowFoldersFirst() {
return ListTile(
title: Text(l10n.sftpShowFoldersFirst),
trailing: StoreSwitch(prop: _setting.sftpShowFoldersFirst),
);
}
Widget _buildNetViewType() { Widget _buildNetViewType() {
final items = NetViewType.values final items = NetViewType.values
.map((e) => PopupMenuItem( .map((e) => PopupMenuItem(

View File

@@ -33,11 +33,14 @@ class SSHPage extends StatefulWidget {
final ServerPrivateInfo spi; final ServerPrivateInfo spi;
final String? initCmd; final String? initCmd;
final bool pop; final bool pop;
final Function()? onSessionEnd;
const SSHPage({ const SSHPage({
super.key, super.key,
required this.spi, required this.spi,
this.initCmd, this.initCmd,
this.pop = true, this.pop = true,
this.onSessionEnd,
}); });
@override @override
@@ -103,7 +106,7 @@ class _SSHPageState extends State<SSHPage> with AutomaticKeepAliveClientMixin {
_terminalTheme = _isDark ? TerminalThemes.dark : TerminalThemes.light; _terminalTheme = _isDark ? TerminalThemes.dark : TerminalThemes.light;
// Because the virtual keyboard only displayed on mobile devices // Because the virtual keyboard only displayed on mobile devices
if (isMobile) { if (isMobile || isDebuggingMobileLayoutOnDesktop) {
_virtKeyWidth = _media.size.width / 7; _virtKeyWidth = _media.size.width / 7;
_virtKeysHeight = _media.size.height * 0.043 * _virtKeysList.length; _virtKeysHeight = _media.size.height * 0.043 * _virtKeysList.length;
} }
@@ -115,7 +118,9 @@ class _SSHPageState extends State<SSHPage> with AutomaticKeepAliveClientMixin {
Widget child = Scaffold( Widget child = Scaffold(
backgroundColor: _terminalTheme.background, backgroundColor: _terminalTheme.background,
body: _buildBody(), body: _buildBody(),
bottomNavigationBar: isDesktop ? null : _buildBottom(), bottomNavigationBar: isDesktop && !isDebuggingMobileLayoutOnDesktop
? null
: _buildBottom(),
); );
if (isIOS) { if (isIOS) {
child = AnnotatedRegion( child = AnnotatedRegion(
@@ -229,10 +234,12 @@ class _SSHPageState extends State<SSHPage> with AutomaticKeepAliveClientMixin {
void _doVirtualKey(VirtKey item) { void _doVirtualKey(VirtKey item) {
if (item.func != null) { if (item.func != null) {
HapticFeedback.mediumImpact();
_doVirtualKeyFunc(item.func!); _doVirtualKeyFunc(item.func!);
return; return;
} }
if (item.key != null) { if (item.key != null) {
HapticFeedback.mediumImpact();
_doVirtualKeyInput(item.key!); _doVirtualKeyInput(item.key!);
} }
} }
@@ -381,6 +388,7 @@ class _SSHPageState extends State<SSHPage> with AutomaticKeepAliveClientMixin {
if (mounted && widget.pop) { if (mounted && widget.pop) {
context.pop(); context.pop();
} }
widget.onSessionEnd?.call();
} }
void _listen(Stream<Uint8List>? stream) { void _listen(Stream<Uint8List>? stream) {

View File

@@ -71,6 +71,7 @@ class _SSHTabPageState extends State<SSHTabPage>
if (confirm != true) { if (confirm != true) {
return; return;
} }
// debugPrint("Removing a tab whose tabId = $e");
_tabIds.remove(e); _tabIds.remove(e);
_refreshTabs(); _refreshTabs();
}, },
@@ -104,6 +105,12 @@ class _SSHTabPageState extends State<SSHTabPage>
key: key, key: key,
spi: spi, spi: spi,
pop: false, pop: false,
onSessionEnd: () {
// debugPrint("Session done received on page whose tabId = $name");
// debugPrint("key = $key");
_tabIds.remove(name);
_refreshTabs();
},
); );
_refreshTabs(); _refreshTabs();
_tabController.animateTo(_tabIds.length - 1); _tabController.animateTo(_tabIds.length - 1);

View File

@@ -8,7 +8,9 @@ import 'package:toolbox/core/extension/context/dialog.dart';
import 'package:toolbox/core/extension/context/locale.dart'; import 'package:toolbox/core/extension/context/locale.dart';
import 'package:toolbox/core/extension/context/snackbar.dart'; import 'package:toolbox/core/extension/context/snackbar.dart';
import 'package:toolbox/core/extension/sftpfile.dart'; import 'package:toolbox/core/extension/sftpfile.dart';
import 'package:toolbox/core/utils/comparator.dart';
import 'package:toolbox/core/utils/platform/base.dart'; import 'package:toolbox/core/utils/platform/base.dart';
import 'package:toolbox/data/res/color.dart';
import 'package:toolbox/data/res/logger.dart'; import 'package:toolbox/data/res/logger.dart';
import 'package:toolbox/data/res/misc.dart'; import 'package:toolbox/data/res/misc.dart';
import 'package:toolbox/data/res/provider.dart'; import 'package:toolbox/data/res/provider.dart';
@@ -50,7 +52,8 @@ class _SftpPageState extends State<SftpPage> with AfterLayoutMixin {
final _status = SftpBrowserStatus(); final _status = SftpBrowserStatus();
late final _client = widget.spi.server?.client; late final _client = widget.spi.server?.client;
final _sortType = ValueNotifier(_SortType.name); final _sortOption =
ValueNotifier(_SortOption(sortBy: _SortType.name, reversed: false));
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -69,29 +72,47 @@ class _SftpPageState extends State<SftpPage> with AfterLayoutMixin {
icon: const Icon(Icons.downloading), icon: const Icon(Icons.downloading),
onPressed: () => AppRoute.sftpMission().go(context), onPressed: () => AppRoute.sftpMission().go(context),
), ),
ValueListenableBuilder<_SortType>( ValueListenableBuilder(
valueListenable: _sortType, valueListenable: _sortOption,
builder: (context, value, child) { builder: (context, value, child) {
return PopupMenuButton<_SortType>( return PopupMenuButton<_SortType>(
icon: const Icon(Icons.sort), icon: const Icon(Icons.sort),
itemBuilder: (context) { itemBuilder: (context) {
return [ final currentSelectedOption = _sortOption.value;
PopupMenuItem( final options = [
value: _SortType.name, (_SortType.name, l10n.name),
child: Text(l10n.name), (_SortType.size, l10n.size),
), (_SortType.time, l10n.time),
PopupMenuItem(
value: _SortType.size,
child: Text(l10n.size),
),
PopupMenuItem(
value: _SortType.time,
child: Text(l10n.time),
),
]; ];
return options.map((r) {
final (type, name) = r;
return PopupMenuItem(
value: type,
child: Text(
type == currentSelectedOption.sortBy
? "$name (${currentSelectedOption.reversed ? '-' : '+'})"
: name,
style: TextStyle(
color: type == currentSelectedOption.sortBy
? primaryColor
: null,
fontWeight: type == currentSelectedOption.sortBy
? FontWeight.bold
: null,
),
),
);
}).toList();
}, },
onSelected: (value) { onSelected: (sortBy) {
_sortType.value = value; final oldValue = _sortOption.value;
if (oldValue.sortBy == sortBy) {
_sortOption.value = _SortOption(
sortBy: sortBy, reversed: !oldValue.reversed);
} else {
_sortOption.value =
_SortOption(sortBy: sortBy, reversed: false);
}
}, },
); );
}, },
@@ -274,9 +295,10 @@ class _SftpPageState extends State<SftpPage> with AfterLayoutMixin {
child: FadeIn( child: FadeIn(
key: Key(widget.spi.name + _status.path!.path), key: Key(widget.spi.name + _status.path!.path),
child: ValueListenableBuilder( child: ValueListenableBuilder(
valueListenable: _sortType, valueListenable: _sortOption,
builder: (_, sortType, __) { builder: (_, sortOption, __) {
final files = sortType.sort(_status.files!); final files = sortOption.sortBy
.sort(_status.files!, reversed: sortOption.reversed);
return ListView.builder( return ListView.builder(
itemCount: files.length, itemCount: files.length,
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
@@ -797,20 +819,51 @@ enum _SortType {
size, size,
; ;
List<SftpName> sort(List<SftpName> files) { List<SftpName> sort(List<SftpName> files, {bool reversed = false}) {
var comparator = ChainComparator<SftpName>.create();
if (Stores.setting.sftpShowFoldersFirst.fetch()) {
comparator = comparator.thenTrueFirst((x) => x.attr.isDirectory);
}
switch (this) { switch (this) {
case _SortType.name: case _SortType.name:
files.sort((a, b) => a.filename.compareTo(b.filename)); files.sort(
comparator
.thenWithComparator(
(a, b) => Comparators.compareStringCaseInsensitive()(
a.filename, b.filename),
reversed: reversed,
)
.compare,
);
break; break;
case _SortType.time: case _SortType.time:
files.sort( files.sort(
(a, b) => (a.attr.modifyTime ?? 0).compareTo(b.attr.modifyTime ?? 0), comparator
.thenCompareBy<num>(
(x) => x.attr.modifyTime ?? 0,
reversed: reversed,
)
.compare,
); );
break; break;
case _SortType.size: case _SortType.size:
files.sort((a, b) => (a.attr.size ?? 0).compareTo(b.attr.size ?? 0)); files.sort(
comparator
.thenCompareBy<num>(
(x) => x.attr.size ?? 0,
reversed: reversed,
)
.compare,
);
break; break;
} }
return files; return files;
} }
} }
class _SortOption {
final _SortType sortBy;
final bool reversed;
_SortOption({required this.sortBy, required this.reversed});
}