mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 15:08:33 +08:00
feat: native菜单自定义排序
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user