import 'package:flutter/material.dart'; import '../core/app_controller.dart'; import '../l10n/strings.dart'; import '../models/server_profile.dart'; import 'adaptive.dart'; import 'common.dart'; import 'profile_editor.dart'; import 'profile_io.dart'; Future connectProfile( BuildContext context, AppController c, ServerProfile profile, ) async { final password = TextEditingController(), passphrase = TextEditingController(); try { final stored = c.vault.secret( profile.id, profile.auth == AuthMethod.password ? 'password' : 'privateKey', ); final allowed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text('${ctx.tr('connect')} · ${profile.name}'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ SelectableText( '${profile.username}@${profile.host}:${profile.port}', ), const SizedBox(height: 16), if (profile.auth == AuthMethod.password) TextField( controller: password, obscureText: true, autocorrect: false, enableSuggestions: false, decoration: InputDecoration( labelText: ctx.tr('password'), hintText: stored == null ? null : '••••••••', ), ), if (profile.auth == AuthMethod.privateKey) TextField( controller: passphrase, obscureText: true, autocorrect: false, enableSuggestions: false, decoration: InputDecoration(labelText: ctx.tr('passphrase')), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(ctx.tr('cancel')), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: Text(ctx.tr('connect')), ), ], ), ); if (allowed != true || !c.vault.unlocked || !context.mounted) return; final credential = profile.auth == AuthMethod.password ? (password.text.isEmpty ? stored ?? '' : password.text) : stored ?? ''; await c.ssh.connect( profile, credential: credential, passphrase: passphrase.text.isEmpty ? null : passphrase.text, prompt: (endpoint, type, fingerprint) async { if (!context.mounted || !c.vault.unlocked) return false; return await showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( title: Text(ctx.tr('hostTrustTitle')), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(ctx.tr('hostTrustBody')), const SizedBox(height: 16), SelectableText('$endpoint\n$type\n$fingerprint'), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(ctx.tr('cancel')), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: Text(ctx.tr('trust')), ), ], ), ) ?? false; }, ); } catch (e) { if (context.mounted) showFailure(context, e); } finally { password.clear(); passphrase.clear(); Future.delayed(const Duration(seconds: 1), () { password.dispose(); passphrase.dispose(); }); } } class ServersPage extends StatefulWidget { final AppController controller; final VoidCallback onConnected; // Owned by the caller when the search must outlive this page, for example // when a fold hides the list and shows it again. final TextEditingController? search; const ServersPage( this.controller, { required this.onConnected, this.search, super.key, }); @override State createState() => _ServersPageState(); } class _ServersPageState extends State { late final search = widget.search ?? TextEditingController(); @override void dispose() { if (widget.search == null) search.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final c = widget.controller; final query = search.text; final profiles = c.vault.profiles .where( (p) => '${p.name} ${p.host} ${p.username}'.toLowerCase().contains( query.toLowerCase(), ), ) .toList(); return LayoutBuilder( builder: (context, constraints) { final pad = readablePadding(constraints, maxWidth: 900, gutter: 16); return MinHeight( minHeight: 200, child: Column( children: [ Padding( padding: pad, child: Row( children: [ Expanded( child: TextField( controller: search, decoration: InputDecoration( labelText: context.tr('search'), prefixIcon: const Icon(Icons.search), ), onChanged: (_) => setState(() {}), ), ), const SizedBox(width: 10), IconButton.filled( onPressed: () => Navigator.push( context, MaterialPageRoute(builder: (_) => ProfileEditor(c)), ), tooltip: context.tr('addServer'), icon: const Icon(Icons.add), ), // Desktop-compatible JSON, also available in Settings. PopupMenuButton( tooltip: context.tr('profileTransfer'), icon: const Icon(Icons.import_export), onSelected: (action) async { try { if (action == 'importProfiles') { await importProfiles(context, c); } else { await exportProfiles(c); } } catch (e) { if (context.mounted) showFailure(context, e); } }, itemBuilder: (ctx) => [ for (final (value, icon) in [ ('importProfiles', Icons.file_open), ('exportProfiles', Icons.ios_share), ]) PopupMenuItem( value: value, child: ListTile( leading: Icon(icon), title: Text(ctx.tr(value)), contentPadding: EdgeInsets.zero, ), ), ], ), ], ), ), Expanded( child: profiles.isEmpty ? const PageMessage('emptyServers') : ListView.builder( padding: pad.copyWith(top: 0), itemCount: profiles.length, itemBuilder: (context, i) { final p = profiles[i]; final active = c.ssh.profile?.id == p.id && (c.ssh.connected || c.ssh.connecting); return Card( child: ListTile( leading: Icon( Icons.dns_outlined, color: active ? const Color(0xff43d6a0) : Theme.of(context).colorScheme.primary, ), title: Text(p.name), subtitle: Text( '${p.username}@${p.host}:${p.port}\n${context.tr(active ? c.ssh.connecting ? 'connecting' : 'connected' : 'disconnected')}', ), isThreeLine: true, onTap: c.ssh.connecting ? null : () async { await connectProfile(context, c, p); if (c.ssh.connected) widget.onConnected(); }, trailing: PopupMenuButton( onSelected: (action) async { try { if (action == 'edit') { await Navigator.push( context, MaterialPageRoute( builder: (_) => ProfileEditor(c, profile: p), ), ); } if (action == 'duplicate') { await c.vault.save( ServerProfile( name: p.name, host: p.host, port: p.port, username: p.username, ), ); c.changed(); } if (action == 'delete' && context.mounted && await confirm( context, 'confirmDelete', detail: p.name, )) { if (c.ssh.profile?.id == p.id) { c.ssh.disconnect(clear: true); } await c.vault.deleteProfile(p.id); c.changed(); } } catch (e) { if (context.mounted) { showFailure(context, e); } } }, itemBuilder: (ctx) => ['edit', 'duplicate', 'delete'] .map( (v) => PopupMenuItem( value: v, child: Text(ctx.tr(v)), ), ) .toList(), ), ), ); }, ), ), ], ), ); }, ); } }