diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index cd798c0..933ea02 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -9,44 +10,44 @@ import 'core/storage/secure_storage.dart'; import 'features/auth/auth_session.dart'; import 'features/auth/login_controller.dart'; import 'features/auth/login_page.dart'; -import 'features/servers/server_list_page.dart'; -import 'features/servers/server_repository.dart'; -import 'features/terminal/terminal_session_manager.dart'; +import 'features/shell/main_shell_page.dart'; +import 'state/auth_notifier.dart'; +import 'state/auth_state.dart'; +import 'state/storage_providers.dart'; -/// Top-level container that swaps between login and server-list screens -/// without a full router. Keeping it small avoids the overhead of go_router -/// or auto_route in the first release. -class EasyNodeApp extends StatefulWidget { - const EasyNodeApp({ - super.key, +/// Bootstrap result. Wraps the values [EasyNodeApp] needs to install on the +/// root [ProviderScope]. Building these synchronously up front keeps the +/// providers free of async initialization and lets storage be read in +/// `build` without futures. +class _Bootstrap { + _Bootstrap({ required this.appStorage, required this.secureStorage, required this.cookieStore, required this.flutterSecureStorage, - required this.terminalSessionManager, - this.initialPassword = '', - this.initialSession, - this.initialApiClient, - this.initialPublicKeyPem, + required this.initialPassword, + required this.initialAuthState, }); final AppStorage appStorage; final SecureAppStorage secureStorage; final SessionCookieStore cookieStore; final FlutterSecureStorage flutterSecureStorage; - final TerminalSessionManager terminalSessionManager; final String initialPassword; - final AuthSession? initialSession; - final ApiClient? initialApiClient; - final String? initialPublicKeyPem; + final AuthState initialAuthState; +} - static Future bootstrap() async { +class EasyNodeApp extends StatelessWidget { + const EasyNodeApp._({required _Bootstrap bootstrap}) : _b = bootstrap; + + final _Bootstrap _b; + + static Future bootstrap() async { final prefs = await SharedPreferences.getInstance(); final secure = const FlutterSecureStorage(); final secureWrapper = SecureAppStorage(secure); final appStorage = AppStorage(prefs); final cookieStore = SessionCookieStore(secureWrapper); - final terminalSessionManager = TerminalSessionManager(); var initialPassword = ''; if (appStorage.savePassword) { @@ -58,9 +59,7 @@ class EasyNodeApp extends StatefulWidget { ''; } - AuthSession? initialSession; - ApiClient? initialApiClient; - String? initialPublicKeyPem; + AuthState initialAuthState = AuthState.empty; final token = await secureWrapper.readToken(); final cookie = await secureWrapper.readSessionCookie(); final deviceId = await secureWrapper.readDeviceId(); @@ -81,13 +80,16 @@ class EasyNodeApp extends StatefulWidget { token: token, ); try { - initialPublicKeyPem = await api.getPublicKey(); - initialApiClient = api; - initialSession = AuthSession( - serverAddress: appStorage.serverAddress, - username: appStorage.username, - token: token, - deviceId: deviceId, + final pubKey = await api.getPublicKey(); + initialAuthState = AuthState( + session: AuthSession( + serverAddress: appStorage.serverAddress, + username: appStorage.username, + token: token, + deviceId: deviceId, + ), + apiClient: api, + publicKeyPem: pubKey, ); } catch (_) { await secureWrapper.deleteToken(); @@ -96,35 +98,49 @@ class EasyNodeApp extends StatefulWidget { } } - return EasyNodeApp( - appStorage: appStorage, - secureStorage: secureWrapper, - cookieStore: cookieStore, - flutterSecureStorage: secure, - terminalSessionManager: terminalSessionManager, - initialPassword: initialPassword, - initialSession: initialSession, - initialApiClient: initialApiClient, - initialPublicKeyPem: initialPublicKeyPem, + return EasyNodeApp._( + bootstrap: _Bootstrap( + appStorage: appStorage, + secureStorage: secureWrapper, + cookieStore: cookieStore, + flutterSecureStorage: secure, + initialPassword: initialPassword, + initialAuthState: initialAuthState, + ), ); } @override - State createState() => _EasyNodeAppState(); + Widget build(BuildContext context) { + return ProviderScope( + overrides: [ + appStorageProvider.overrideWithValue(_b.appStorage), + secureStorageProvider.overrideWithValue(_b.secureStorage), + cookieStoreProvider.overrideWithValue(_b.cookieStore), + authProvider.overrideWith( + (ref) => AuthNotifier(ref, _b.initialAuthState), + ), + ], + child: _AppRoot(initialPassword: _b.initialPassword), + ); + } } -class _EasyNodeAppState extends State { - AuthSession? _session; - String? _publicKeyPem; - ApiClient? _apiClient; +class _AppRoot extends ConsumerStatefulWidget { + const _AppRoot({required this.initialPassword}); + + final String initialPassword; + + @override + ConsumerState<_AppRoot> createState() => _AppRootState(); +} + +class _AppRootState extends ConsumerState<_AppRoot> { late final LoginController _loginController; @override void initState() { super.initState(); - _session = widget.initialSession; - _apiClient = widget.initialApiClient; - _publicKeyPem = widget.initialPublicKeyPem; _loginController = LoginController(apiClientFactory: _buildApiClient) ..onLoginSuccess(_onLoginSuccess); } @@ -132,7 +148,7 @@ class _EasyNodeAppState extends State { ApiClient _buildApiClient(String serverAddress, {String? token}) { return ApiClient( serverAddress: serverAddress, - cookieStore: widget.cookieStore, + cookieStore: ref.read(cookieStoreProvider), token: token, ); } @@ -141,76 +157,34 @@ class _EasyNodeAppState extends State { AuthSession session, String? passwordToSave, ) async { - await widget.appStorage.setServerAddress(session.serverAddress); - await widget.appStorage.setUsername(session.username); - if (passwordToSave != null) { - await widget.appStorage.setSavePassword(true); - await widget.secureStorage.writePassword( - session.serverAddress, - session.username, - passwordToSave, - ); - } else { - await widget.appStorage.setSavePassword(false); - await widget.secureStorage.deletePassword( - session.serverAddress, - session.username, - ); - } - await widget.secureStorage.writeToken(session.token); - await widget.secureStorage.writeDeviceId(session.deviceId); - final api = _buildApiClient(session.serverAddress, token: session.token); final pubKey = await api.getPublicKey(); - - if (!mounted) return; - setState(() { - _session = session; - _apiClient = api; - _publicKeyPem = pubKey; - }); - } - - Future _logout() async { - await widget.terminalSessionManager.closeAll(); - await widget.secureStorage.deleteToken(); - await widget.secureStorage.deleteDeviceId(); - await widget.cookieStore.clear(); - if (!mounted) return; - setState(() { - _session = null; - _apiClient = null; - _publicKeyPem = null; - }); + await ref + .read(authProvider.notifier) + .signIn( + session: session, + apiClient: api, + publicKeyPem: pubKey, + passwordToSave: passwordToSave, + ); } @override Widget build(BuildContext context) { - Widget home; - if (_session != null && _apiClient != null && _publicKeyPem != null) { - home = ServerListPage( - repository: ApiServerRepository( - apiClient: _apiClient!, - publicKeyPem: _publicKeyPem!, - ), - session: _session!, - terminalSessionManager: widget.terminalSessionManager, - onLogout: _logout, - ); + final auth = ref.watch(authProvider); + final appStorage = ref.watch(appStorageProvider); + + final Widget home; + if (auth.signedIn) { + home = const MainShellPage(); } else { home = LoginPage( controller: _loginController, - initialServerAddress: widget.appStorage.serverAddress, - initialUsername: widget.appStorage.username, - initialSavePassword: widget.appStorage.savePassword, + initialServerAddress: appStorage.serverAddress, + initialUsername: appStorage.username, + initialSavePassword: appStorage.savePassword, initialPassword: widget.initialPassword, - onLoginSuccess: (session) { - // Login result already triggered _onLoginSuccess via the callback - // bound on the controller; nothing else to do here. - if (_session == null) { - setState(() => _session = session); - } - }, + onLoginSuccess: (_) {}, ); } diff --git a/mobile/lib/features/servers/server_list_page.dart b/mobile/lib/features/servers/servers_tab.dart similarity index 54% rename from mobile/lib/features/servers/server_list_page.dart rename to mobile/lib/features/servers/servers_tab.dart index 0476ef9..dd05071 100644 --- a/mobile/lib/features/servers/server_list_page.dart +++ b/mobile/lib/features/servers/servers_tab.dart @@ -1,45 +1,30 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_result.dart'; -import '../auth/auth_session.dart'; +import '../../state/api_providers.dart'; +import '../../state/auth_notifier.dart'; +import '../../state/host_list_notifier.dart'; +import '../../state/terminal_providers.dart'; import '../terminal/ssh_connection_config.dart'; -import '../terminal/terminal_session_manager.dart'; import '../terminal/terminal_shell_page.dart'; import 'server_model.dart'; -import 'server_repository.dart'; -class ServerListPage extends StatefulWidget { - const ServerListPage({ - super.key, - required this.repository, - required this.session, - required this.terminalSessionManager, - required this.onLogout, - }); - - final ServerRepository repository; - final AuthSession session; - final TerminalSessionManager terminalSessionManager; - final VoidCallback onLogout; +/// First bottom-nav tab — server list + connect action. Was a top-level +/// page that built its own ServerRepository / TerminalSessionManager; now +/// it sources both from providers so state stays consistent across tabs. +class ServersTab extends ConsumerStatefulWidget { + const ServersTab({super.key}); @override - State createState() => _ServerListPageState(); + ConsumerState createState() => _ServersTabState(); } -class _ServerListPageState extends State { +class _ServersTabState extends ConsumerState { final TextEditingController _searchCtrl = TextEditingController(); final Set _connectingIds = {}; - List _servers = const []; - bool _loading = true; String _query = ''; bool _searchVisible = false; - String? _error; - - @override - void initState() { - super.initState(); - _refresh(); - } @override void dispose() { @@ -47,37 +32,15 @@ class _ServerListPageState extends State { super.dispose(); } - Future _refresh() async { - setState(() { - _loading = true; - _error = null; - }); - try { - final servers = await widget.repository.fetchHosts(); - if (!mounted) return; - setState(() { - _servers = servers; - _loading = false; - }); - } catch (error) { - if (!mounted) return; - if (error is UnauthorizedFailure) { - widget.onLogout(); - return; - } - setState(() { - _error = error.toString(); - _loading = false; - }); - } - } + Future _refresh() => ref.read(hostListProvider.notifier).refresh(); Future _connect(ServerModel server) async { if (!server.canConnect) return; + final manager = ref.read(terminalSessionManagerProvider); - final existing = widget.terminalSessionManager.firstForHost(server.id); + final existing = manager.firstForHost(server.id); if (existing != null) { - widget.terminalSessionManager.setActive(existing.id); + manager.setActive(existing.id); _openShell(); return; } @@ -85,11 +48,11 @@ class _ServerListPageState extends State { setState(() => _connectingIds.add(server.id)); final SshConnectionConfig config; try { - config = await widget.repository.fetchSshConfig(server.id); + config = await ref.read(serverRepositoryProvider).fetchSshConfig(server.id); } catch (error) { if (!mounted) return; if (error is UnauthorizedFailure) { - widget.onLogout(); + await ref.read(authProvider.notifier).signOut(); return; } ScaffoldMessenger.of(context).showSnackBar( @@ -101,46 +64,17 @@ class _ServerListPageState extends State { } if (!mounted) return; - await widget.terminalSessionManager.openSession(config); + await manager.openSession(config); if (!mounted) return; _openShell(); } void _openShell() { Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => TerminalShellPage( - manager: widget.terminalSessionManager, - repository: widget.repository, - initialServers: _servers, - onSessionExpired: widget.onLogout, - ), - ), + MaterialPageRoute(builder: (_) => const TerminalShellPage()), ); } - Future _confirmLogout() async { - Navigator.of(context).pop(); - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Log out?'), - content: const Text('This will clear the saved login session.'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Log out'), - ), - ], - ), - ); - if (confirmed == true) widget.onLogout(); - } - void _toggleSearch() { setState(() { _searchVisible = !_searchVisible; @@ -152,7 +86,8 @@ class _ServerListPageState extends State { } Future _confirmCloseAllTerminals() async { - final count = widget.terminalSessionManager.sessions.length; + final manager = ref.read(terminalSessionManagerProvider); + final count = manager.sessions.length; if (count == 0) return; final confirmed = await showDialog( context: context, @@ -174,21 +109,20 @@ class _ServerListPageState extends State { ), ); if (confirmed != true) return; - await widget.terminalSessionManager.closeAll(); + await manager.closeAll(); } @override Widget build(BuildContext context) { + // Logout from UnauthorizedFailure inside refresh is handled by the + // notifier; we just need to redraw on host-list state changes. + final hostsAsync = ref.watch(hostListProvider); + final manager = ref.watch(terminalSessionManagerProvider); + return Scaffold( appBar: AppBar( centerTitle: true, - leading: Builder( - builder: (context) => IconButton( - tooltip: 'Settings', - icon: const Icon(Icons.settings), - onPressed: () => Scaffold.of(context).openDrawer(), - ), - ), + automaticallyImplyLeading: false, title: const Text('Servers'), actions: [ IconButton( @@ -206,126 +140,114 @@ class _ServerListPageState extends State { ), onPressed: _toggleSearch, ), - IconButton( + const IconButton( tooltip: 'Add server', - icon: const Icon(Icons.add), + icon: Icon(Icons.add), onPressed: null, ), ], ), - drawer: Drawer( - child: SafeArea( - child: Column( - children: [ - const DrawerHeader( - child: Align( - alignment: Alignment.bottomLeft, - child: Text( - 'Settings', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600), - ), - ), - ), - ListTile( - leading: const Icon(Icons.logout), - title: const Text('Log out'), - onTap: _confirmLogout, - ), - ], - ), - ), - ), body: RefreshIndicator( onRefresh: _refresh, child: AnimatedBuilder( - animation: widget.terminalSessionManager, - builder: (context, _) => _buildBody(), + animation: manager, + builder: (context, _) => _buildBody(hostsAsync, manager), ), ), ); } - Widget _buildBody() { - if (_loading) { - return const Center(child: CircularProgressIndicator()); - } - if (_error != null) { - return ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(24), - children: [ - const SizedBox(height: 56), - Center(child: Text(_error!, textAlign: TextAlign.center)), - const SizedBox(height: 8), - Center( - child: TextButton(onPressed: _refresh, child: const Text('Retry')), - ), - ], - ); - } - - final filtered = _filteredServers(); - return ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.fromLTRB(12, 8, 12, 16), - children: [ - _ActiveTerminalBanner( - count: widget.terminalSessionManager.sessions.length, - onTap: _openShell, - onCloseAll: _confirmCloseAllTerminals, - ), - AnimatedSize( - duration: const Duration(milliseconds: 220), - curve: Curves.easeInOut, - alignment: Alignment.topCenter, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 220), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: child, + Widget _buildBody( + AsyncValue> hostsAsync, + Object manager, + ) { + return hostsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) { + if (error is UnauthorizedFailure) { + // signOut already triggered by the notifier; show nothing useful + // for the brief moment before AppRoot rebuilds to LoginPage. + return const SizedBox.shrink(); + } + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(24), + children: [ + const SizedBox(height: 56), + Center(child: Text(error.toString(), textAlign: TextAlign.center)), + const SizedBox(height: 8), + Center( + child: TextButton(onPressed: _refresh, child: const Text('Retry')), ), - child: _searchVisible - ? Padding( - key: const ValueKey('search-field'), - padding: const EdgeInsets.only(bottom: 8), - child: TextField( - controller: _searchCtrl, - autofocus: true, - decoration: const InputDecoration( - prefixIcon: Icon(Icons.search), - hintText: 'Search by name, host, user, tag, or group', - border: OutlineInputBorder(), + ], + ); + }, + data: (servers) { + final filtered = _filteredServers(servers); + final sessions = ref.read(terminalSessionManagerProvider).sessions.length; + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 16), + children: [ + _ActiveTerminalBanner( + count: sessions, + onTap: _openShell, + onCloseAll: _confirmCloseAllTerminals, + ), + AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: child, + ), + child: _searchVisible + ? Padding( + key: const ValueKey('search-field'), + padding: const EdgeInsets.only(bottom: 8), + child: TextField( + controller: _searchCtrl, + autofocus: true, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + hintText: 'Search by name, host, user, tag, or group', + border: OutlineInputBorder(), + ), + onChanged: (value) => setState( + () => _query = value.trim().toLowerCase(), + ), + ), + ) + : const SizedBox( + key: ValueKey('search-empty'), + width: double.infinity, ), - onChanged: (value) => setState( - () => _query = value.trim().toLowerCase(), - ), - ), - ) - : const SizedBox( - key: ValueKey('search-empty'), - width: double.infinity, - ), - ), - ), - if (_servers.isEmpty) - const _MessageState( - message: - 'No servers yet. Pull to refresh after adding hosts on web.', - ) - else if (filtered.isEmpty) - const _MessageState(message: 'No matching servers.') - else - for (final server in filtered) - _ServerCard(server: server, state: this), - ], + ), + ), + if (servers.isEmpty) + const _MessageState( + message: + 'No servers yet. Pull to refresh after adding hosts on web.', + ) + else if (filtered.isEmpty) + const _MessageState(message: 'No matching servers.') + else + for (final server in filtered) + _ServerCard(server: server, state: this), + ], + ); + }, ); } - List _filteredServers() { - if (_query.isEmpty) return _servers; - return _servers + List _filteredServers(List servers) { + if (_query.isEmpty) return servers; + return servers .where((server) { final haystack = [ server.name, @@ -404,7 +326,7 @@ class _ServerCard extends StatelessWidget { const _ServerCard({required this.server, required this.state}); final ServerModel server; - final _ServerListPageState state; + final _ServersTabState state; @override Widget build(BuildContext context) { diff --git a/mobile/lib/features/shell/main_shell_page.dart b/mobile/lib/features/shell/main_shell_page.dart new file mode 100644 index 0000000..31f231c --- /dev/null +++ b/mobile/lib/features/shell/main_shell_page.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../state/auth_notifier.dart'; +import '../servers/servers_tab.dart'; +import 'scripts_tab.dart'; +import 'settings_tab.dart'; +import 'sftp_tab.dart'; + +/// Top-level shell shown after login. Hosts the four bottom-nav tabs the +/// product spec calls for: Servers / SFTP / Scripts / Settings. +/// +/// Tabs are kept alive via [IndexedStack] so switching back doesn't refetch. +class MainShellPage extends ConsumerStatefulWidget { + const MainShellPage({super.key}); + + @override + ConsumerState createState() => _MainShellPageState(); +} + +class _MainShellPageState extends ConsumerState { + int _index = 0; + + static const _tabs = [ + ServersTab(), + SftpTab(), + ScriptsTab(), + SettingsTab(), + ]; + + @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, (_, _) {}); + + return Scaffold( + body: SafeArea(child: IndexedStack(index: _index, children: _tabs)), + bottomNavigationBar: NavigationBar( + selectedIndex: _index, + onDestinationSelected: (i) => setState(() => _index = i), + labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, + height: 56, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.dns_outlined), + selectedIcon: Icon(Icons.dns), + label: 'Servers', + tooltip: 'Servers', + ), + NavigationDestination( + icon: Icon(Icons.folder_outlined), + selectedIcon: Icon(Icons.folder), + label: 'SFTP', + tooltip: 'SFTP', + ), + NavigationDestination( + icon: Icon(Icons.library_books_outlined), + selectedIcon: Icon(Icons.library_books), + label: 'Scripts', + tooltip: 'Scripts', + ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + tooltip: 'Settings', + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/shell/scripts_tab.dart b/mobile/lib/features/shell/scripts_tab.dart new file mode 100644 index 0000000..1f71c64 --- /dev/null +++ b/mobile/lib/features/shell/scripts_tab.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class ScriptsTab extends StatelessWidget { + const ScriptsTab({super.key}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Scaffold( + appBar: AppBar(centerTitle: true, title: const Text('Scripts')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.library_books_outlined, size: 48, color: colors.primary), + const SizedBox(height: 12), + Text( + '即将上线:脚本库', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/shell/settings_tab.dart b/mobile/lib/features/shell/settings_tab.dart new file mode 100644 index 0000000..d2b880a --- /dev/null +++ b/mobile/lib/features/shell/settings_tab.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../state/auth_notifier.dart'; + +class SettingsTab extends ConsumerWidget { + const SettingsTab({super.key}); + + Future _confirmLogout(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Log out?'), + content: const Text('This will clear the saved login session.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Log out'), + ), + ], + ), + ); + if (confirmed == true) { + await ref.read(authProvider.notifier).signOut(); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(authProvider).session; + return Scaffold( + appBar: AppBar(centerTitle: true, title: const Text('Settings')), + body: ListView( + children: [ + if (session != null) + ListTile( + leading: const Icon(Icons.account_circle_outlined), + title: Text(session.username), + subtitle: Text(session.serverAddress), + ), + const Divider(height: 1), + ListTile( + key: const Key('settings-logout'), + leading: const Icon(Icons.logout), + title: const Text('Log out'), + onTap: () => _confirmLogout(context, ref), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/shell/sftp_tab.dart b/mobile/lib/features/shell/sftp_tab.dart new file mode 100644 index 0000000..cdfd8e6 --- /dev/null +++ b/mobile/lib/features/shell/sftp_tab.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +class SftpTab extends StatelessWidget { + const SftpTab({super.key}); + + @override + Widget build(BuildContext context) { + return _PlaceholderTab( + title: 'SFTP', + icon: Icons.folder_outlined, + message: '即将上线:浏览和管理远程文件', + ); + } +} + +class _PlaceholderTab extends StatelessWidget { + const _PlaceholderTab({ + required this.title, + required this.icon, + required this.message, + }); + + final String title; + final IconData icon; + final String message; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Scaffold( + appBar: AppBar(centerTitle: true, title: Text(title)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 48, color: colors.primary), + const SizedBox(height: 12), + Text(message, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/terminal/terminal_page.dart b/mobile/lib/features/terminal/terminal_page.dart deleted file mode 100644 index c1a8b12..0000000 --- a/mobile/lib/features/terminal/terminal_page.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:xterm/ui.dart'; - -import 'ssh_connection_config.dart'; -import 'ssh_terminal_controller.dart'; -import 'terminal_toolbar.dart'; - -/// SSH terminal screen. Connects on mount, displays the xterm view, and -/// disconnects in [State.dispose] so leaving the page tears down the SSH -/// session. -class TerminalPage extends StatefulWidget { - const TerminalPage({super.key, required this.config}); - - final SshConnectionConfig config; - - @override - State createState() => _TerminalPageState(); -} - -class _TerminalPageState extends State { - late final SshTerminalController _controller; - String? _connectError; - bool _connecting = true; - - @override - void initState() { - super.initState(); - _controller = SshTerminalController(config: widget.config); - _connect(); - } - - Future _connect() async { - try { - await _controller.connect(); - } catch (error) { - if (!mounted) return; - setState(() => _connectError = error.toString()); - return; - } - if (!mounted) return; - setState(() => _connecting = false); - } - - @override - void dispose() { - _controller.disconnect(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final title = widget.config.name.isEmpty - ? '${widget.config.username}@${widget.config.host}' - : widget.config.name; - return Scaffold( - appBar: AppBar(title: Text(title)), - body: SafeArea( - top: false, - child: Column( - children: [ - Expanded(child: _buildBody()), - TerminalToolbar( - controller: _controller, - onInput: _controller.writeInput, - ), - ], - ), - ), - ); - } - - Widget _buildBody() { - if (_connectError != null) { - return Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - '连接失败: $_connectError', - style: TextStyle(color: Theme.of(context).colorScheme.error), - textAlign: TextAlign.center, - ), - ), - ); - } - if (_connecting) { - return const Center(child: CircularProgressIndicator()); - } - return TerminalView(_controller.terminal); - } -} diff --git a/mobile/lib/features/terminal/terminal_shell_page.dart b/mobile/lib/features/terminal/terminal_shell_page.dart index 9e0b42b..aad6f49 100644 --- a/mobile/lib/features/terminal/terminal_shell_page.dart +++ b/mobile/lib/features/terminal/terminal_shell_page.dart @@ -1,59 +1,55 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:xterm/ui.dart'; import '../../core/api/api_result.dart'; +import '../../state/api_providers.dart'; +import '../../state/auth_notifier.dart'; +import '../../state/host_list_notifier.dart'; +import '../../state/terminal_providers.dart'; import '../servers/server_model.dart'; -import '../servers/server_repository.dart'; import 'terminal_session.dart'; import 'terminal_session_manager.dart'; import 'terminal_toolbar.dart'; -class TerminalShellPage extends StatefulWidget { - const TerminalShellPage({ - super.key, - required this.manager, - required this.repository, - this.initialServers = const [], - required this.onSessionExpired, - }); - - final TerminalSessionManager manager; - final ServerRepository repository; - final List initialServers; - final VoidCallback onSessionExpired; +class TerminalShellPage extends ConsumerStatefulWidget { + const TerminalShellPage({super.key}); @override - State createState() => _TerminalShellPageState(); + ConsumerState createState() => _TerminalShellPageState(); } -class _TerminalShellPageState extends State { +class _TerminalShellPageState extends ConsumerState { bool _openingServer = false; - late List _cachedServers; + List? _cachedServers; - @override - void initState() { - super.initState(); - _cachedServers = widget.initialServers; - } + TerminalSessionManager get _manager => + ref.read(terminalSessionManagerProvider); Future _openServerMenu(BuildContext anchorContext) async { if (_openingServer) return; - if (_cachedServers.isNotEmpty) { - await _showServerMenu(anchorContext, _cachedServers); + final cached = _cachedServers ?? + ref.read(hostListProvider).maybeWhen( + data: (data) => data, + orElse: () => null, + ); + if (cached != null && cached.isNotEmpty) { + _cachedServers = cached; + await _showServerMenu(anchorContext, cached); return; } setState(() => _openingServer = true); final List servers; try { - servers = await widget.repository.fetchHosts(); + servers = await ref.read(serverRepositoryProvider).fetchHosts(); } catch (error) { if (!mounted) return; if (error is UnauthorizedFailure) { setState(() => _openingServer = false); - widget.onSessionExpired(); + await ref.read(authProvider.notifier).signOut(); return; } ScaffoldMessenger.of( @@ -108,12 +104,13 @@ class _TerminalShellPageState extends State { ); if (selected == null || !mounted) return; try { - final config = await widget.repository.fetchSshConfig(selected.id); - await widget.manager.openSession(config); + final config = + await ref.read(serverRepositoryProvider).fetchSshConfig(selected.id); + await _manager.openSession(config); } catch (error) { if (!mounted) return; if (error is UnauthorizedFailure) { - widget.onSessionExpired(); + await ref.read(authProvider.notifier).signOut(); return; } ScaffoldMessenger.of(context).showSnackBar( @@ -123,24 +120,26 @@ class _TerminalShellPageState extends State { } Future _closeActive() async { - final active = widget.manager.activeSession; + final manager = _manager; + final active = manager.activeSession; if (active == null) { Navigator.of(context).maybePop(); return; } - await widget.manager.closeSession(active.id); - if (mounted && widget.manager.sessions.isEmpty) { + await manager.closeSession(active.id); + if (mounted && manager.sessions.isEmpty) { Navigator.of(context).maybePop(); } } @override Widget build(BuildContext context) { + final manager = ref.watch(terminalSessionManagerProvider); return AnimatedBuilder( - animation: widget.manager, + animation: manager, builder: (context, _) { - final sessions = widget.manager.sessions.toList(growable: false); - final active = widget.manager.activeSession; + final sessions = manager.sessions.toList(growable: false); + final active = manager.activeSession; final activeIndex = active == null ? 0 : math.max( @@ -156,12 +155,12 @@ class _TerminalShellPageState extends State { sessions: sessions, active: active, openingServer: _openingServer, - onSelect: widget.manager.setActive, + onSelect: manager.setActive, onNew: _openServerMenu, onClose: _closeActive, onReconnect: active == null ? null - : () => widget.manager.reconnect(active.id), + : () => manager.reconnect(active.id), ), Expanded( child: ColoredBox( @@ -190,8 +189,8 @@ class _TerminalShellPageState extends State { ), TerminalToolbar( controller: active?.controller, - onInput: (value) => widget.manager.activeSession?.controller - .writeInput(value), + onInput: (value) => + manager.activeSession?.controller.writeInput(value), ), ], ), diff --git a/mobile/lib/state/api_providers.dart b/mobile/lib/state/api_providers.dart new file mode 100644 index 0000000..02f77f8 --- /dev/null +++ b/mobile/lib/state/api_providers.dart @@ -0,0 +1,29 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/api/api_client.dart'; +import '../features/servers/server_repository.dart'; +import 'auth_notifier.dart'; + +/// Resolves the active [ApiClient] from the auth state. Pages should not +/// build their own ApiClient; they ask this provider so a re-login swap is +/// observed automatically. +final apiClientProvider = Provider((ref) { + final api = ref.watch(authProvider).apiClient; + if (api == null) { + throw StateError('apiClientProvider read while signed out'); + } + return api; +}); + +/// Repository for `/host-list` and `/mobile/ssh-connection`. Depends on the +/// active ApiClient and the public key fetched at login time, both of +/// which are derived from [authProvider]. +final serverRepositoryProvider = Provider((ref) { + final auth = ref.watch(authProvider); + final api = auth.apiClient; + final pubKey = auth.publicKeyPem; + if (api == null || pubKey == null) { + throw StateError('serverRepositoryProvider read while signed out'); + } + return ApiServerRepository(apiClient: api, publicKeyPem: pubKey); +}); diff --git a/mobile/lib/state/auth_notifier.dart b/mobile/lib/state/auth_notifier.dart new file mode 100644 index 0000000..6d0c1ae --- /dev/null +++ b/mobile/lib/state/auth_notifier.dart @@ -0,0 +1,65 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/api/api_client.dart'; +import '../features/auth/auth_session.dart'; +import 'auth_state.dart'; +import 'storage_providers.dart'; +import 'terminal_providers.dart'; + +/// Single source of truth for "are we logged in?". Login flow calls +/// [signIn] after the controller succeeds; logout calls [signOut]. UI +/// observes this provider to swap between LoginPage and MainShellPage. +class AuthNotifier extends StateNotifier { + AuthNotifier(this._ref, AuthState initial) : super(initial); + + final Ref _ref; + + Future signIn({ + required AuthSession session, + required ApiClient apiClient, + required String publicKeyPem, + String? passwordToSave, + }) async { + final appStorage = _ref.read(appStorageProvider); + final secureStorage = _ref.read(secureStorageProvider); + + await appStorage.setServerAddress(session.serverAddress); + await appStorage.setUsername(session.username); + if (passwordToSave != null) { + await appStorage.setSavePassword(true); + await secureStorage.writePassword( + session.serverAddress, + session.username, + passwordToSave, + ); + } else { + await appStorage.setSavePassword(false); + await secureStorage.deletePassword( + session.serverAddress, + session.username, + ); + } + await secureStorage.writeToken(session.token); + await secureStorage.writeDeviceId(session.deviceId); + + state = AuthState( + session: session, + apiClient: apiClient, + publicKeyPem: publicKeyPem, + ); + } + + Future signOut() async { + await _ref.read(terminalSessionManagerProvider).closeAll(); + final secureStorage = _ref.read(secureStorageProvider); + final cookieStore = _ref.read(cookieStoreProvider); + await secureStorage.deleteToken(); + await secureStorage.deleteDeviceId(); + await cookieStore.clear(); + state = AuthState.empty; + } +} + +final authProvider = StateNotifierProvider((ref) { + throw UnimplementedError('authProvider must be overridden in bootstrap'); +}); diff --git a/mobile/lib/state/auth_state.dart b/mobile/lib/state/auth_state.dart new file mode 100644 index 0000000..1872cb4 --- /dev/null +++ b/mobile/lib/state/auth_state.dart @@ -0,0 +1,30 @@ +import '../core/api/api_client.dart'; +import '../features/auth/auth_session.dart'; + +/// Snapshot of authenticated-app state. `signedIn` requires all three fields +/// to be present; the UI uses that flag to decide between login and main +/// shell without re-checking the underlying nullables in every consumer. +class AuthState { + const AuthState({this.session, this.apiClient, this.publicKeyPem}); + + final AuthSession? session; + final ApiClient? apiClient; + final String? publicKeyPem; + + bool get signedIn => + session != null && apiClient != null && publicKeyPem != null; + + static const empty = AuthState(); + + AuthState copyWith({ + AuthSession? session, + ApiClient? apiClient, + String? publicKeyPem, + }) { + return AuthState( + session: session ?? this.session, + apiClient: apiClient ?? this.apiClient, + publicKeyPem: publicKeyPem ?? this.publicKeyPem, + ); + } +} diff --git a/mobile/lib/state/host_list_notifier.dart b/mobile/lib/state/host_list_notifier.dart new file mode 100644 index 0000000..eff5ce9 --- /dev/null +++ b/mobile/lib/state/host_list_notifier.dart @@ -0,0 +1,34 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/api/api_result.dart'; +import '../features/servers/server_model.dart'; +import 'api_providers.dart'; +import 'auth_notifier.dart'; + +/// Mirrors web's `store.hostList` + `store.getHostList()`: the list is fetched +/// on first read and exposed as an [AsyncValue] so the UI can show +/// loading / error / data without manual `_loading` flags. +class HostListNotifier extends AsyncNotifier> { + @override + Future> build() { + final repo = ref.watch(serverRepositoryProvider); + return repo.fetchHosts(); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + try { + return await ref.read(serverRepositoryProvider).fetchHosts(); + } on UnauthorizedFailure { + await ref.read(authProvider.notifier).signOut(); + rethrow; + } + }); + } +} + +final hostListProvider = + AsyncNotifierProvider>( + HostListNotifier.new, +); diff --git a/mobile/lib/state/storage_providers.dart b/mobile/lib/state/storage_providers.dart new file mode 100644 index 0000000..6d5e910 --- /dev/null +++ b/mobile/lib/state/storage_providers.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/api/cookie_store.dart'; +import '../core/storage/app_storage.dart'; +import '../core/storage/secure_storage.dart'; + +/// All four storage providers are bootstrap-only — `EasyNodeApp.bootstrap` +/// constructs the concrete instances once and overrides them on the root +/// `ProviderScope`, so reads always succeed without async work. +final appStorageProvider = Provider((ref) { + throw UnimplementedError('appStorageProvider must be overridden'); +}); + +final secureStorageProvider = Provider((ref) { + throw UnimplementedError('secureStorageProvider must be overridden'); +}); + +final cookieStoreProvider = Provider((ref) { + throw UnimplementedError('cookieStoreProvider must be overridden'); +}); diff --git a/mobile/lib/state/terminal_providers.dart b/mobile/lib/state/terminal_providers.dart new file mode 100644 index 0000000..602afa0 --- /dev/null +++ b/mobile/lib/state/terminal_providers.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../features/terminal/terminal_session_manager.dart'; + +/// Single TerminalSessionManager for the whole app. Used to be passed +/// through constructors; now lives here so any page can reach it. Disposed +/// alongside the ProviderScope at app teardown. +final terminalSessionManagerProvider = Provider((ref) { + final manager = TerminalSessionManager(); + ref.onDispose(manager.dispose); + return manager; +}); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 363c53f..0461b3a 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -182,6 +182,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" flutter_secure_storage: dependency: "direct main" description: @@ -512,6 +520,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" shared_preferences: dependency: "direct main" description: @@ -589,6 +605,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 74da752..e87cb16 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: dartssh2: ^2.12.0 xterm: ^4.0.0 uuid: ^4.5.1 + flutter_riverpod: ^2.5.1 dev_dependencies: flutter_test: diff --git a/mobile/test/features/servers/server_list_page_test.dart b/mobile/test/features/servers/servers_tab_test.dart similarity index 57% rename from mobile/test/features/servers/server_list_page_test.dart rename to mobile/test/features/servers/servers_tab_test.dart index 3fcecb0..415e646 100644 --- a/mobile/test/features/servers/server_list_page_test.dart +++ b/mobile/test/features/servers/servers_tab_test.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mobile/features/auth/auth_session.dart'; -import 'package:mobile/features/servers/server_list_page.dart'; import 'package:mobile/features/servers/server_model.dart'; import 'package:mobile/features/servers/server_repository.dart'; +import 'package:mobile/features/servers/servers_tab.dart'; import 'package:mobile/features/terminal/ssh_connection_config.dart'; +import 'package:mobile/state/api_providers.dart'; +import 'package:mobile/state/host_list_notifier.dart'; class _FakeRepository implements ServerRepository { _FakeRepository({ @@ -46,13 +48,6 @@ class _FakeRepository implements ServerRepository { } } -const _session = AuthSession( - serverAddress: 'https://example.com', - username: 'root', - token: 't', - deviceId: 'd', -); - ServerModel _server({String id = 'h1', bool canConnect = true}) { return ServerModel.fromJson({ 'id': id, @@ -68,50 +63,44 @@ ServerModel _server({String id = 'h1', bool canConnect = true}) { }); } -Widget _wrap(Widget child) => MaterialApp(home: child); +Widget _wrap({required ServerRepository repo}) { + return ProviderScope( + overrides: [serverRepositoryProvider.overrideWithValue(repo)], + child: const MaterialApp(home: ServersTab()), + ); +} void main() { - testWidgets('shows empty state when host list is empty', (tester) async { + testWidgets('shows empty-state copy when host list is empty', (tester) async { final repo = _FakeRepository(hosts: const []); - await tester.pumpWidget(_wrap(ServerListPage( - repository: repo, - session: _session, - onLogout: () {}, - ))); + await tester.pumpWidget(_wrap(repo: repo)); await tester.pumpAndSettle(); - expect(find.textContaining('暂无服务器'), findsOneWidget); + expect(find.textContaining('No servers yet'), findsOneWidget); }); - testWidgets('renders one tile per host with connect button', (tester) async { + testWidgets('renders one card per host with the right action label', + (tester) async { final repo = _FakeRepository(hosts: [ _server(id: 'h1'), _server(id: 'h2', canConnect: false), ]); - await tester.pumpWidget(_wrap(ServerListPage( - repository: repo, - session: _session, - onLogout: () {}, - ))); + await tester.pumpWidget(_wrap(repo: repo)); await tester.pumpAndSettle(); expect(find.byKey(const Key('server-h1')), findsOneWidget); expect(find.byKey(const Key('server-h2')), findsOneWidget); - expect(find.text('连接'), findsOneWidget); - expect(find.text('未配置'), findsOneWidget); + expect(find.text('Connect'), findsOneWidget); + expect(find.text('Not configured'), findsOneWidget); }); - testWidgets('shows error and retry button when fetch fails', (tester) async { + testWidgets('shows error and Retry when fetch fails', (tester) async { final repo = _FakeRepository(fetchError: Exception('boom')); - await tester.pumpWidget(_wrap(ServerListPage( - repository: repo, - session: _session, - onLogout: () {}, - ))); + await tester.pumpWidget(_wrap(repo: repo)); await tester.pumpAndSettle(); expect(find.textContaining('boom'), findsOneWidget); - expect(find.text('重试'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); }); testWidgets('shows snackbar when fetchSshConfig fails', (tester) async { @@ -119,33 +108,30 @@ void main() { hosts: [_server(id: 'h1')], sshError: Exception('nope'), ); - await tester.pumpWidget(_wrap(ServerListPage( - repository: repo, - session: _session, - onLogout: () {}, - ))); + await tester.pumpWidget(_wrap(repo: repo)); await tester.pumpAndSettle(); - await tester.tap(find.text('连接')); + await tester.tap(find.text('Connect')); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(repo.connectCalls, 1); - expect(find.textContaining('获取 SSH 参数失败'), findsOneWidget); + expect(find.textContaining('Failed to get SSH config'), findsOneWidget); }); - testWidgets('logout button calls onLogout', (tester) async { - var loggedOut = 0; - final repo = _FakeRepository(hosts: const []); - await tester.pumpWidget(_wrap(ServerListPage( - repository: repo, - session: _session, - onLogout: () => loggedOut++, - ))); - await tester.pumpAndSettle(); + testWidgets('host list provider can refresh on demand', (tester) async { + final repo = _FakeRepository(hosts: [_server(id: 'h1')]); + final container = ProviderContainer( + overrides: [serverRepositoryProvider.overrideWithValue(repo)], + ); + addTearDown(container.dispose); - await tester.tap(find.byTooltip('退出登录')); - await tester.pump(); - expect(loggedOut, 1); + final initial = await container.read(hostListProvider.future); + expect(initial, hasLength(1)); + + repo.hosts = [_server(id: 'h1'), _server(id: 'h2')]; + await container.read(hostListProvider.notifier).refresh(); + final refreshed = await container.read(hostListProvider.future); + expect(refreshed, hasLength(2)); }); }