import 'dart:math' as math; import 'package:flutter/material.dart'; import '../core/app_controller.dart'; import '../l10n/strings.dart'; import 'adaptive.dart'; import 'diagnostics_page.dart'; import 'editor/viewers.dart'; import '../services/editor/sources.dart'; import 'files_page.dart'; import 'monitor_page.dart'; import 'servers_page.dart'; import 'settings_page.dart'; import 'terminal_page.dart'; import 'theme.dart'; class Workspace extends StatefulWidget { final AppController controller; const Workspace(this.controller, {super.key}); @override State createState() => _WorkspaceState(); } class _WorkspaceState extends State { int index = 0; // The tool shown next to the server list in two panes. int lastTool = 1; // Stable slots: a page keeps its state (SFTP directory, search, terminal // focus) when a resize, rotation or fold moves it to another place. final serversSlot = GlobalKey(debugLabel: 'servers'); final toolSlot = GlobalKey(debugLabel: 'tool'); final serverSearch = TextEditingController(); @override void dispose() { serverSearch.dispose(); super.dispose(); } static const names = [ 'servers', 'terminal', 'files', 'monitor', 'diagnostics', 'settings', ]; static const icons = [ Icons.dns_outlined, Icons.terminal, Icons.folder_outlined, Icons.monitor_heart_outlined, Icons.network_check, Icons.settings_outlined, ]; bool _openingIncoming = false; /// Opens files other apps handed to SM Mobile, one at a time. void _openIncoming() { final c = widget.controller; if (_openingIncoming || c.pendingIncoming.isEmpty) return; _openingIncoming = true; WidgetsBinding.instance.addPostFrameCallback((_) async { try { while (mounted && c.vault.unlocked && c.pendingIncoming.isNotEmpty) { final doc = c.pendingIncoming.removeAt(0); await openSource(context, c, DeviceSource(c.documents, doc)); } } finally { _openingIncoming = false; } }); } void select(int v) => setState(() { index = v; if (v != 0) lastTool = v; }); @override Widget build(BuildContext context) => ListenableBuilder( listenable: Listenable.merge([widget.controller.ssh, widget.controller]), builder: (context, _) { final c = widget.controller; _openIncoming(); final layout = AdaptiveLayout.of(context); final twoPane = layout.twoPane; // In two panes the server list is always visible, so it is not a // destination of its own. // The tool beside the list becomes the current page, so that folding // back to one pane keeps showing it, with its state, instead of // replacing it with the list. if (twoPane && index == 0) index = lastTool; final shown = index; final destinations = [for (var i = twoPane ? 1 : 0; i < 6; i++) i]; final servers = KeyedSubtree( key: serversSlot, child: ServersPage( c, search: serverSearch, onConnected: () { if (mounted) select(1); }, ), ); final page = shown == 0 ? servers : KeyedSubtree( key: toolSlot, child: switch (shown) { 1 => TerminalPage( c, key: ValueKey(c.ssh.connected ? c.ssh.client : null), ), 2 => FilesPage( c, key: ValueKey(c.ssh.connected ? c.ssh.client : null), ), 3 => MonitorPage( c, key: ValueKey(c.ssh.connected ? c.ssh.client : null), ), 4 => DiagnosticsPage(c), _ => SettingsPage(c), }, ); final squeezed = keyboardSqueezed(context); return Scaffold( appBar: squeezed ? null : AppBar( titleSpacing: 12, leading: const Padding( padding: EdgeInsets.only(left: 12), child: Center(child: BrandMark()), ), leadingWidth: 48, title: PageTitle('SM Mobile', context.tr(names[shown])), actions: [ if (c.ssh.connecting) IconButton( onPressed: c.ssh.disconnect, tooltip: context.tr('cancel'), icon: const Icon(Icons.close), ), if (c.ssh.connected) IconButton( onPressed: c.ssh.disconnect, tooltip: context.tr('disconnect'), icon: const Icon(Icons.link_off), ), if (!c.ssh.connected && !c.ssh.connecting && c.ssh.profile != null) IconButton( onPressed: () => connectProfile(context, c, c.ssh.profile!), tooltip: context.tr('reconnect'), icon: const Icon(Icons.refresh), ), IconButton( onPressed: c.lock, tooltip: context.tr('lock'), icon: const Icon(Icons.lock_outline), ), ], ), body: MinHeight( minHeight: 120, child: Column( children: [ if (squeezed) SizedBox(height: MediaQuery.paddingOf(context).top), if (c.ssh.connecting) const LinearProgressIndicator(), if (c.ssh.error != null) SafeArea( top: false, bottom: false, child: MaterialBanner( content: Text(context.tr(c.ssh.error!)), actions: [ TextButton( onPressed: () { c.ssh.error = null; c.changed(); }, child: Text(context.tr('done')), ), ], ), ), if (c.ssh.connected && !squeezed) SafeArea( top: false, bottom: false, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 4, ), child: Row( children: [ const Icon( Icons.circle, color: Color(0xff43d6a0), size: 8, ), const SizedBox(width: 8), Expanded( child: Text( '${c.ssh.profile!.name} ยท ${context.tr('connected')}', maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), ), ), Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (layout.useRail) SafeArea( right: false, child: _Rail( selected: destinations.indexOf(shown), onSelected: (i) => select(destinations[i]), destinations: [ for (final i in destinations) NavigationRailDestination( icon: Icon(icons[i]), label: Text(context.tr(names[i])), ), ], ), ), Expanded( child: SafeArea( left: !layout.useRail, bottom: layout.useRail, child: FoldAwareSplit( secondaryWidth: layout.windowClass >= WindowClass.large ? 400 : 340, secondary: twoPane ? servers : null, primary: page, ), ), ), ], ), ), ], ), ), bottomNavigationBar: layout.useRail ? null : clampNavigationText( NavigationBar( selectedIndex: math.min(destinations.indexOf(shown), 3), onDestinationSelected: (v) async { if (v < 3 || destinations.length == 4) { select(destinations[v]); return; } final chosen = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (ctx) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ for (final i in destinations.skip(3)) ListTile( leading: Icon(icons[i]), title: Text(ctx.tr(names[i])), selected: i == shown, onTap: () => Navigator.pop(ctx, i), ), ], ), ), ), ); if (chosen != null && mounted) select(chosen); }, destinations: [ for (final i in destinations.take(3)) NavigationDestination( icon: Icon(icons[i]), label: context.tr(names[i]), ), if (destinations.length == 4) NavigationDestination( icon: Icon(icons[destinations[3]]), label: context.tr(names[destinations[3]]), ) else NavigationDestination( icon: Icon( destinations.indexOf(shown) >= 3 ? icons[shown] : Icons.more_horiz, ), label: context.tr( destinations.indexOf(shown) >= 3 ? names[shown] : 'settings', ), ), ], ), ), ); }, ); } /// Navigation rail that keeps every destination reachable in short windows: /// labels are hidden when they do not fit, and the rail scrolls as a last /// resort. class _Rail extends StatelessWidget { final int selected; final ValueChanged onSelected; final List destinations; const _Rail({ required this.selected, required this.onSelected, required this.destinations, }); @override Widget build(BuildContext context) => clampNavigationText( LayoutBuilder( builder: (context, constraints) { final scale = MediaQuery.textScalerOf(context).scale(1); final withLabels = destinations.length * 72 * scale + 16; final labels = constraints.maxHeight >= withLabels; final needed = labels ? withLabels : destinations.length * 56.0 + 16; return SingleChildScrollView( child: SizedBox( height: math.max(constraints.maxHeight, needed), child: NavigationRail( selectedIndex: selected < 0 ? null : selected, onDestinationSelected: onSelected, labelType: labels ? NavigationRailLabelType.all : NavigationRailLabelType.none, destinations: destinations, ), ), ); }, ), ); }