diff --git a/mobile/lib/features/servers/servers_tab.dart b/mobile/lib/features/servers/servers_tab.dart index 2599b7c..1ad708e 100644 --- a/mobile/lib/features/servers/servers_tab.dart +++ b/mobile/lib/features/servers/servers_tab.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../../core/api/api_result.dart'; import '../../core/ui/palette.dart'; @@ -721,7 +722,10 @@ class _ServerCard extends StatelessWidget { children: [ Row( children: [ - _ServerOsIcon(enabled: server.canConnect), + _ServerOsIcon( + enabled: server.canConnect, + isWindows: server.isWindows, + ), const SizedBox(width: 12), Expanded( child: InkWell( @@ -939,12 +943,14 @@ class _IconPillButton extends StatelessWidget { } class _ServerOsIcon extends StatelessWidget { - const _ServerOsIcon({required this.enabled}); + const _ServerOsIcon({required this.enabled, required this.isWindows}); final bool enabled; + final bool isWindows; @override Widget build(BuildContext context) { + final asset = isWindows ? 'assets/windows.svg' : 'assets/linux.svg'; return Container( width: 40, height: 40, @@ -953,10 +959,14 @@ class _ServerOsIcon extends StatelessWidget { color: enabled ? AppPalette.banner : AppPalette.chip, borderRadius: BorderRadius.circular(10), ), - child: Icon( - Icons.monitor_outlined, - size: 22, - color: enabled ? AppPalette.primary : AppPalette.muted, + child: Opacity( + opacity: enabled ? 1 : 0.45, + child: SvgPicture.asset( + asset, + width: 22, + height: 22, + fit: BoxFit.contain, + ), ), ); } diff --git a/mobile/lib/features/shell/main_shell_page.dart b/mobile/lib/features/shell/main_shell_page.dart index 25d50ff..ef1074c 100644 --- a/mobile/lib/features/shell/main_shell_page.dart +++ b/mobile/lib/features/shell/main_shell_page.dart @@ -1,14 +1,17 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/ui/palette.dart'; import '../../l10n/app_localizations.dart'; import '../../state/auth_notifier.dart'; +import '../../state/terminal_providers.dart'; import '../servers/servers_tab.dart'; import 'scripts_tab.dart'; import 'settings_tab.dart'; +import 'sftp_session_manager.dart'; import 'sftp_tab.dart'; /// Top-level shell shown after login. Hosts the four bottom-nav tabs the @@ -39,34 +42,85 @@ class _MainShellPageState extends ConsumerState { ref.listen(authProvider, (_, _) {}); final l = AppLocalizations.of(context); - return Scaffold( - backgroundColor: AppPalette.canvas, - body: SafeArea( - child: IndexedStack(index: _index, children: _tabs), + final sftpManager = ref.watch(sftpSessionManagerProvider); + return AnimatedBuilder( + animation: sftpManager, + builder: (context, child) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + if (_canGoUpInSftp(sftpManager)) { + sftpManager.goParent(); + } else { + _confirmExit(context); + } + }, + child: child!, + ); + }, + child: Scaffold( + backgroundColor: AppPalette.canvas, + body: SafeArea( + child: IndexedStack(index: _index, children: _tabs), + ), + bottomNavigationBar: _WarmBottomBar( + selectedIndex: _index, + onSelected: (i) => setState(() => _index = i), + items: [ + _WarmBottomBarItem( + icon: Icons.monitor_outlined, + label: l.tr('tabs.servers'), + ), + _WarmBottomBarItem( + icon: Icons.folder_outlined, + label: l.tr('tabs.sftp'), + ), + _WarmBottomBarItem( + icon: Icons.article_outlined, + label: l.tr('tabs.scripts'), + ), + _WarmBottomBarItem( + icon: Icons.settings_outlined, + label: l.tr('tabs.settings'), + ), + ], + ), ), - bottomNavigationBar: _WarmBottomBar( - selectedIndex: _index, - onSelected: (i) => setState(() => _index = i), - items: [ - _WarmBottomBarItem( - icon: Icons.monitor_outlined, - label: l.tr('tabs.servers'), + ); + } + + bool _canGoUpInSftp(SftpSessionManager manager) { + if (_index != 1) return false; + final session = manager.activeSession; + if (session == null) return false; + if (session.status != SftpConnectionStatus.connected) return false; + final path = session.currentPath; + return path.isNotEmpty && path != '/' && path != '~'; + } + + Future _confirmExit(BuildContext context) async { + final l = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l.tr('common.exitAppTitle')), + content: Text(l.tr('common.exitAppBody')), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l.tr('common.cancel')), ), - _WarmBottomBarItem( - icon: Icons.folder_outlined, - label: l.tr('tabs.sftp'), - ), - _WarmBottomBarItem( - icon: Icons.article_outlined, - label: l.tr('tabs.scripts'), - ), - _WarmBottomBarItem( - icon: Icons.settings_outlined, - label: l.tr('tabs.settings'), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l.tr('common.exitApp')), ), ], ), ); + if (confirmed == true) { + await SystemNavigator.pop(); + } } } diff --git a/mobile/lib/l10n/strings_en.dart b/mobile/lib/l10n/strings_en.dart index b5a9ac8..790a9e9 100644 --- a/mobile/lib/l10n/strings_en.dart +++ b/mobile/lib/l10n/strings_en.dart @@ -21,6 +21,9 @@ const Map stringsEn = { 'common.system': 'System default', 'common.delete': 'Delete', 'common.optional': 'Optional', + 'common.exitApp': 'Exit', + 'common.exitAppTitle': 'Exit app?', + 'common.exitAppBody': 'Are you sure you want to exit the app?', // Login page 'login.serverAddress': 'Server address', diff --git a/mobile/lib/l10n/strings_zh.dart b/mobile/lib/l10n/strings_zh.dart index 4c52d38..df1a932 100644 --- a/mobile/lib/l10n/strings_zh.dart +++ b/mobile/lib/l10n/strings_zh.dart @@ -20,6 +20,9 @@ const Map stringsZh = { 'common.system': '跟随系统', 'common.delete': '删除', 'common.optional': '可选', + 'common.exitApp': '退出', + 'common.exitAppTitle': '退出 App?', + 'common.exitAppBody': '确认退出 App?', // Login 'login.serverAddress': '服务端地址',