feat: native菜单自定义排序

This commit is contained in:
chaoszhu
2026-06-28 09:25:39 +08:00
parent 00be584c82
commit 55a13ecdbe
6 changed files with 350 additions and 44 deletions
+28
View File
@@ -114,4 +114,32 @@ class AppStorage {
_prefs.getStringList(_keySftpFolderCache) ?? const [];
Future<void> setSftpFolderCache(List<String> v) =>
_prefs.setStringList(_keySftpFolderCache, v);
// ── Tab order ──
static const _keyTabOrder = 'app.tabOrder';
static const defaultTabOrder = ['servers', 'sftp', 'docker', 'scripts', 'settings'];
List<String> get tabOrder {
final stored = _prefs.getStringList(_keyTabOrder);
if (stored == null || stored.length != defaultTabOrder.length) {
return defaultTabOrder;
}
if (!defaultTabOrder.every(stored.contains)) return defaultTabOrder;
return stored;
}
Future<void> setTabOrder(List<String> v) =>
_prefs.setStringList(_keyTabOrder, v);
static const _keyDefaultTab = 'app.defaultTab';
static const defaultTab = 'servers';
String get homeTab {
final stored = _prefs.getString(_keyDefaultTab);
if (stored == null || !defaultTabOrder.contains(stored)) return defaultTab;
return stored;
}
Future<void> setHomeTab(String v) => _prefs.setString(_keyDefaultTab, v);
}
+50 -38
View File
@@ -10,6 +10,7 @@ import '../../l10n/app_localizations.dart';
import '../../state/app_update_notifier.dart';
import '../../state/auth_notifier.dart';
import '../../state/plus_info_notifier.dart';
import '../../state/tab_order_notifier.dart';
import '../../state/terminal_providers.dart';
import '../docker/docker_icon.dart';
import '../docker/docker_tab.dart';
@@ -31,19 +32,51 @@ class MainShellPage extends ConsumerStatefulWidget {
}
class _MainShellPageState extends ConsumerState<MainShellPage> {
int _index = 0;
late int _index;
static const _tabs = <Widget>[
ServersTab(),
SftpTab(),
DockerTab(),
ScriptsTab(),
SettingsTab(),
];
static const _allTabs = <String, Widget>{
'settings': SettingsTab(),
'scripts': ScriptsTab(),
'servers': ServersTab(),
'sftp': SftpTab(),
'docker': DockerTab(),
};
static _WarmBottomBarItem _barItem(String key, AppLocalizations l) {
return switch (key) {
'settings' => _WarmBottomBarItem(
icon: const Icon(Icons.settings_outlined),
label: l.tr('tabs.settings'),
),
'scripts' => _WarmBottomBarItem(
icon: const Icon(Icons.article_outlined),
label: l.tr('tabs.scripts'),
),
'servers' => _WarmBottomBarItem(
icon: const Icon(Icons.monitor_outlined),
label: l.tr('tabs.servers'),
),
'sftp' => _WarmBottomBarItem(
icon: const Icon(Icons.folder_outlined),
label: l.tr('tabs.sftp'),
),
'docker' => _WarmBottomBarItem(
icon: const DockerIcon(),
label: l.tr('tabs.docker'),
),
_ => _WarmBottomBarItem(
icon: const Icon(Icons.help_outline),
label: key,
),
};
}
@override
void initState() {
super.initState();
final order = ref.read(tabOrderProvider);
final homeTab = ref.read(homeTabProvider);
_index = order.indexOf(homeTab).clamp(0, order.length - 1);
WidgetsBinding.instance.addPostFrameCallback(
(_) => _checkUpdatesSilently(),
);
@@ -57,15 +90,15 @@ class _MainShellPageState extends ConsumerState<MainShellPage> {
@override
Widget build(BuildContext context) {
// signOut wipes auth state; AppRoot re-renders to LoginPage automatically.
// We don't need to handle that here.
ref.listen(authProvider, (_, _) {});
// Eager-load Plus status so gating UI in Scripts / Server form can read
// it synchronously without each page firing its own request.
ref.watch(plusInfoProvider);
final l = AppLocalizations.of(context);
final sftpManager = ref.watch(sftpSessionManagerProvider);
final tabOrder = ref.watch(tabOrderProvider);
final tabs = tabOrder.map((k) => _allTabs[k]!).toList();
final items = tabOrder.map((k) => _barItem(k, l)).toList();
return AnimatedBuilder(
animation: sftpManager,
builder: (context, child) {
@@ -73,7 +106,7 @@ class _MainShellPageState extends ConsumerState<MainShellPage> {
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
if (_canGoUpInSftp(sftpManager)) {
if (_canGoUpInSftp(sftpManager, tabOrder)) {
sftpManager.goParent();
} else {
_confirmExit(context);
@@ -87,40 +120,19 @@ class _MainShellPageState extends ConsumerState<MainShellPage> {
backgroundColor: context.colors.canvas,
body: SafeArea(
bottom: false,
child: IndexedStack(index: _index, children: _tabs),
child: IndexedStack(index: _index, children: tabs),
),
bottomNavigationBar: _WarmBottomBar(
selectedIndex: _index,
onSelected: (i) => setState(() => _index = i),
items: [
_WarmBottomBarItem(
icon: const Icon(Icons.monitor_outlined),
label: l.tr('tabs.servers'),
),
_WarmBottomBarItem(
icon: const Icon(Icons.folder_outlined),
label: l.tr('tabs.sftp'),
),
_WarmBottomBarItem(
icon: const DockerIcon(),
label: l.tr('tabs.docker'),
),
_WarmBottomBarItem(
icon: const Icon(Icons.article_outlined),
label: l.tr('tabs.scripts'),
),
_WarmBottomBarItem(
icon: const Icon(Icons.settings_outlined),
label: l.tr('tabs.settings'),
),
],
items: items,
),
),
);
}
bool _canGoUpInSftp(SftpSessionManager manager) {
if (_index != 1) return false;
bool _canGoUpInSftp(SftpSessionManager manager, List<String> order) {
if (order[_index] != 'sftp') return false;
final session = manager.activeSession;
if (session == null) return false;
if (session.status != SftpConnectionStatus.connected) return false;
+216 -6
View File
@@ -7,6 +7,7 @@ import '../../core/utils/app_store_compliance.dart';
import '../../l10n/app_localizations.dart';
import '../../state/auth_notifier.dart';
import '../../state/app_update_notifier.dart';
import '../../state/tab_order_notifier.dart';
import '../../state/credential_list_notifier.dart';
import '../../state/host_list_notifier.dart';
import '../../state/locale_notifier.dart';
@@ -217,6 +218,24 @@ class SettingsTab extends ConsumerWidget {
};
}
void _showTabOrderDialog(BuildContext context, WidgetRef ref) {
showDialog<void>(
context: context,
builder: (_) => _TabOrderDialog(
initialOrder: ref.read(tabOrderProvider),
initialHomeTab: ref.read(homeTabProvider),
onSave: (order, homeTab) {
ref.read(tabOrderProvider.notifier).setOrder(order);
ref.read(homeTabProvider.notifier).setHomeTab(homeTab);
},
onReset: () {
ref.read(tabOrderProvider.notifier).resetToDefault();
ref.read(homeTabProvider.notifier).resetToDefault();
},
),
);
}
Future<void> _checkForUpdates(BuildContext context, WidgetRef ref) async {
final l = AppLocalizations.of(context);
final result = await ref.read(appUpdateProvider.notifier).check();
@@ -381,6 +400,18 @@ class SettingsTab extends ConsumerWidget {
subtitle: _languageSubtitle(context, locale),
onTap: () => _pickLanguage(context, ref),
),
SettingsRow(
icon: Icons.swap_vert_outlined,
title: l.tr('settings.tabOrder.title'),
subtitle: l.tr('settings.tabOrder.subtitle'),
onTap: () => _showTabOrderDialog(context, ref),
),
SettingsRow(
icon: Icons.palette_outlined,
title: l.tr('settings.theme.title'),
subtitle: _themeModeLabel(l, ref.watch(themeModeProvider)),
onTap: () => _showThemePicker(context, ref),
),
SettingsRow(
key: const Key('settings-check-update'),
icon: Icons.system_update_alt_outlined,
@@ -401,12 +432,6 @@ class SettingsTab extends ConsumerWidget {
? null
: () => _checkForUpdates(context, ref),
),
SettingsRow(
icon: Icons.palette_outlined,
title: l.tr('settings.theme.title'),
subtitle: _themeModeLabel(l, ref.watch(themeModeProvider)),
onTap: () => _showThemePicker(context, ref),
),
],
),
),
@@ -828,3 +853,188 @@ class _DiscountNotificationCard extends StatelessWidget {
);
}
}
class _TabOrderDialog extends StatefulWidget {
const _TabOrderDialog({
required this.initialOrder,
required this.initialHomeTab,
required this.onSave,
required this.onReset,
});
final List<String> initialOrder;
final String initialHomeTab;
final void Function(List<String> order, String homeTab) onSave;
final VoidCallback onReset;
static const _tabMeta = <String, (IconData, String)>{
'settings': (Icons.settings_outlined, 'tabs.settings'),
'scripts': (Icons.article_outlined, 'tabs.scripts'),
'servers': (Icons.monitor_outlined, 'tabs.servers'),
'sftp': (Icons.folder_outlined, 'tabs.sftp'),
'docker': (Icons.view_in_ar_outlined, 'tabs.docker'),
};
@override
State<_TabOrderDialog> createState() => _TabOrderDialogState();
}
class _TabOrderDialogState extends State<_TabOrderDialog> {
late List<String> _order;
late String _homeTab;
@override
void initState() {
super.initState();
_order = List.of(widget.initialOrder);
_homeTab = widget.initialHomeTab;
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final c = context.colors;
return Dialog(
backgroundColor: c.card,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: c.border),
),
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 18, 0, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Row(
children: [
Icon(Icons.swap_vert_outlined, color: c.primary, size: 20),
const SizedBox(width: 8),
Text(
l.tr('settings.tabOrder.title'),
style: TextStyle(
color: c.text,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
TextButton(
onPressed: () {
widget.onReset();
Navigator.of(context).pop();
},
style: TextButton.styleFrom(foregroundColor: c.muted),
child: Text(
l.tr('settings.tabOrder.reset'),
style: const TextStyle(fontSize: 12),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 0, 18, 8),
child: Text(
l.tr('settings.tabOrder.homeHint'),
style: TextStyle(color: c.softMuted, fontSize: 12),
),
),
ReorderableListView.builder(
shrinkWrap: true,
buildDefaultDragHandles: false,
proxyDecorator: (child, index, animation) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) => Material(
elevation: 4,
color: c.card,
borderRadius: BorderRadius.circular(10),
child: child,
),
child: child,
);
},
itemCount: _order.length,
onReorderItem: (oldIndex, newIndex) {
setState(() {
final item = _order.removeAt(oldIndex);
_order.insert(newIndex, item);
});
},
itemBuilder: (context, index) {
final key = _order[index];
final meta = _TabOrderDialog._tabMeta[key]!;
final isHome = key == _homeTab;
return ListTile(
key: ValueKey(key),
onTap: () => setState(() => _homeTab = key),
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: c.chip,
borderRadius: BorderRadius.circular(10),
),
child: Icon(meta.$1, size: 18, color: c.primary),
),
title: Row(
children: [
Text(
l.tr(meta.$2),
style: TextStyle(
color: c.text,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
if (isHome) ...[
const SizedBox(width: 6),
Icon(Icons.home_rounded, size: 15, color: c.primary),
],
],
),
trailing: ReorderableDragStartListener(
index: index,
child: Icon(
Icons.drag_handle,
color: c.softMuted,
),
),
);
},
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: c.primary,
foregroundColor: c.fontOnPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
minimumSize: const Size.fromHeight(46),
),
onPressed: () {
widget.onSave(_order, _homeTab);
Navigator.of(context).pop();
},
child: Text(
l.tr('common.save'),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
);
}
}
+5
View File
@@ -327,6 +327,11 @@ const Map<String, String> stringsEn = {
'settings.theme.light': 'Light',
'settings.theme.dark': 'Dark',
'settings.tabOrder.title': 'Menu Order',
'settings.tabOrder.subtitle': 'Customize menu order and home page',
'settings.tabOrder.reset': 'Reset to Default',
'settings.tabOrder.homeHint': 'Drag to reorder, tap to set as home page',
'settings.update.title': 'Check for updates',
'settings.update.subtitle': 'Compare with the latest native release',
'settings.update.availableSubtitle': 'New version available: {0}',
+5
View File
@@ -310,6 +310,11 @@ const Map<String, String> stringsZh = {
'settings.theme.light': '亮色',
'settings.theme.dark': '暗黑',
'settings.tabOrder.title': '菜单排序',
'settings.tabOrder.subtitle': '自定义菜单顺序和默认页面',
'settings.tabOrder.reset': '恢复默认',
'settings.tabOrder.homeHint': '拖动排序,点击设为默认页面',
'settings.update.title': '检查更新',
'settings.update.subtitle': '对比最新原生端版本',
'settings.update.availableSubtitle': '发现新版本:{0}',
+46
View File
@@ -0,0 +1,46 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/storage/app_storage.dart';
import 'storage_providers.dart';
class TabOrderNotifier extends StateNotifier<List<String>> {
TabOrderNotifier(this._storage) : super(_storage.tabOrder);
final AppStorage _storage;
Future<void> setOrder(List<String> order) async {
await _storage.setTabOrder(order);
state = order;
}
Future<void> resetToDefault() async {
await _storage.setTabOrder(AppStorage.defaultTabOrder);
state = AppStorage.defaultTabOrder;
}
}
final tabOrderProvider =
StateNotifierProvider<TabOrderNotifier, List<String>>((ref) {
return TabOrderNotifier(ref.watch(appStorageProvider));
});
class HomeTabNotifier extends StateNotifier<String> {
HomeTabNotifier(this._storage) : super(_storage.homeTab);
final AppStorage _storage;
Future<void> setHomeTab(String tab) async {
await _storage.setHomeTab(tab);
state = tab;
}
Future<void> resetToDefault() async {
await _storage.setHomeTab(AppStorage.defaultTab);
state = AppStorage.defaultTab;
}
}
final homeTabProvider =
StateNotifierProvider<HomeTabNotifier, String>((ref) {
return HomeTabNotifier(ref.watch(appStorageProvider));
});