mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 13:18:34 +08:00
feat: 重构状态管理&底部菜单
This commit is contained in:
+83
-109
@@ -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<EasyNodeApp> bootstrap() async {
|
||||
class EasyNodeApp extends StatelessWidget {
|
||||
const EasyNodeApp._({required _Bootstrap bootstrap}) : _b = bootstrap;
|
||||
|
||||
final _Bootstrap _b;
|
||||
|
||||
static Future<Widget> 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<EasyNodeApp> 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<EasyNodeApp> {
|
||||
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<EasyNodeApp> {
|
||||
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<EasyNodeApp> {
|
||||
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<void> _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: (_) {},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+120
-198
@@ -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<ServerListPage> createState() => _ServerListPageState();
|
||||
ConsumerState<ServersTab> createState() => _ServersTabState();
|
||||
}
|
||||
|
||||
class _ServerListPageState extends State<ServerListPage> {
|
||||
class _ServersTabState extends ConsumerState<ServersTab> {
|
||||
final TextEditingController _searchCtrl = TextEditingController();
|
||||
final Set<String> _connectingIds = {};
|
||||
List<ServerModel> _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<ServerListPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _refresh() => ref.read(hostListProvider.notifier).refresh();
|
||||
|
||||
Future<void> _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<ServerListPage> {
|
||||
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<ServerListPage> {
|
||||
}
|
||||
|
||||
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<void> _confirmLogout() async {
|
||||
Navigator.of(context).pop();
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<ServerListPage> {
|
||||
}
|
||||
|
||||
Future<void> _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<bool>(
|
||||
context: context,
|
||||
@@ -174,21 +109,20 @@ class _ServerListPageState extends State<ServerListPage> {
|
||||
),
|
||||
);
|
||||
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<ServerListPage> {
|
||||
),
|
||||
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<List<ServerModel>> 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<ServerModel> _filteredServers() {
|
||||
if (_query.isEmpty) return _servers;
|
||||
return _servers
|
||||
List<ServerModel> _filteredServers(List<ServerModel> 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) {
|
||||
@@ -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<MainShellPage> createState() => _MainShellPageState();
|
||||
}
|
||||
|
||||
class _MainShellPageState extends ConsumerState<MainShellPage> {
|
||||
int _index = 0;
|
||||
|
||||
static const _tabs = <Widget>[
|
||||
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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<TerminalPage> createState() => _TerminalPageState();
|
||||
}
|
||||
|
||||
class _TerminalPageState extends State<TerminalPage> {
|
||||
late final SshTerminalController _controller;
|
||||
String? _connectError;
|
||||
bool _connecting = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = SshTerminalController(config: widget.config);
|
||||
_connect();
|
||||
}
|
||||
|
||||
Future<void> _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);
|
||||
}
|
||||
}
|
||||
@@ -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<ServerModel> initialServers;
|
||||
final VoidCallback onSessionExpired;
|
||||
class TerminalShellPage extends ConsumerStatefulWidget {
|
||||
const TerminalShellPage({super.key});
|
||||
|
||||
@override
|
||||
State<TerminalShellPage> createState() => _TerminalShellPageState();
|
||||
ConsumerState<TerminalShellPage> createState() => _TerminalShellPageState();
|
||||
}
|
||||
|
||||
class _TerminalShellPageState extends State<TerminalShellPage> {
|
||||
class _TerminalShellPageState extends ConsumerState<TerminalShellPage> {
|
||||
bool _openingServer = false;
|
||||
late List<ServerModel> _cachedServers;
|
||||
List<ServerModel>? _cachedServers;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cachedServers = widget.initialServers;
|
||||
}
|
||||
TerminalSessionManager get _manager =>
|
||||
ref.read(terminalSessionManagerProvider);
|
||||
|
||||
Future<void> _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<ServerModel> 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<TerminalShellPage> {
|
||||
);
|
||||
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<TerminalShellPage> {
|
||||
}
|
||||
|
||||
Future<void> _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<TerminalShellPage> {
|
||||
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<TerminalShellPage> {
|
||||
),
|
||||
TerminalToolbar(
|
||||
controller: active?.controller,
|
||||
onInput: (value) => widget.manager.activeSession?.controller
|
||||
.writeInput(value),
|
||||
onInput: (value) =>
|
||||
manager.activeSession?.controller.writeInput(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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<ApiClient>((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<ServerRepository>((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);
|
||||
});
|
||||
@@ -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<AuthState> {
|
||||
AuthNotifier(this._ref, AuthState initial) : super(initial);
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
Future<void> 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<void> 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<AuthNotifier, AuthState>((ref) {
|
||||
throw UnimplementedError('authProvider must be overridden in bootstrap');
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<List<ServerModel>> {
|
||||
@override
|
||||
Future<List<ServerModel>> build() {
|
||||
final repo = ref.watch(serverRepositoryProvider);
|
||||
return repo.fetchHosts();
|
||||
}
|
||||
|
||||
Future<void> 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, List<ServerModel>>(
|
||||
HostListNotifier.new,
|
||||
);
|
||||
@@ -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<AppStorage>((ref) {
|
||||
throw UnimplementedError('appStorageProvider must be overridden');
|
||||
});
|
||||
|
||||
final secureStorageProvider = Provider<SecureAppStorage>((ref) {
|
||||
throw UnimplementedError('secureStorageProvider must be overridden');
|
||||
});
|
||||
|
||||
final cookieStoreProvider = Provider<SessionCookieStore>((ref) {
|
||||
throw UnimplementedError('cookieStoreProvider must be overridden');
|
||||
});
|
||||
@@ -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<TerminalSessionManager>((ref) {
|
||||
final manager = TerminalSessionManager();
|
||||
ref.onDispose(manager.dispose);
|
||||
return manager;
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+37
-51
@@ -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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user