import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; import '../../../about/external/data/Docker.dart'; import '../../../about/internal/application/NotificationType.dart'; import '../../../about/internal/application/ServerProfile.dart'; import '../../../about/internal/application/TextType.dart'; import '../../../configs/Navigator.dart'; import '../../../designs/OutputSheet.dart'; import '../../../designs/text/Text.dart'; import '../../../utils/Colors.dart'; import '../run/DockerRun.dart'; import 'ConnectDockerManager.dart'; import 'DockerManager.dart'; import 'ViewDockerManager.dart'; class DockerManagerState extends State implements ConnectDockerManager { ViewDockerManager? _model; DockerStatus? _status; List _containers = []; bool _loading = true; bool _installing = false; String? _busyId; ServerProfile get _server => widget.server; bool get _ready => _status != null && _status!.installed && _status!.running; @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewDockerManager(context, this), onViewModelReady: _onReady, builder: (context, viewModel, child) => Scaffold( appBar: AppBar( title: text( 'Docker · ${_server.name}', 20, TextType.Bold, color: context.jp.text, ), actions: [ if (_ready) IconButton( tooltip: 'Test (hello-world)', onPressed: _onTest, icon: const Icon(Icons.verified), ), IconButton( tooltip: 'Refresh', onPressed: _loading ? null : _onRefresh, icon: const Icon(Icons.refresh), ), const SizedBox(width: 4), ], ), floatingActionButton: _ready ? FloatingActionButton.extended( onPressed: _onOpenRun, icon: const Icon(Icons.add), label: text( 'Run container', 14, TextType.Bold, color: colorWhite, ), ) : null, body: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), children: [ _statusCard(), if (_ready) ...[ const SizedBox(height: 16), _sectionHeader(), const SizedBox(height: 10), if (_loading) const Center( child: Padding( padding: EdgeInsets.all(24), child: CircularProgressIndicator(), ), ) else if (_containers.isEmpty) _empty() else LayoutBuilder( builder: (context, c) { const gap = 12.0; final cols = c.maxWidth >= 720 ? 2 : 1; final w = (c.maxWidth - gap * (cols - 1)) / cols; return Wrap( spacing: gap, runSpacing: gap, children: [ for (final container in _containers) SizedBox(width: w, child: _containerCard(container)), ], ); }, ), ], ], ), ), ); } void _onReady(ViewDockerManager model) { _model = model; // onViewModelReady fires mid-build; a synchronous setState from the // first load would dirty this State while it is still being built. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _initiate(); }); } Future _initiate() async { _model?.loadDocker(_server); } // ── named handlers ───────────────────────────────────────────────────────── void _onRefresh() { setState(() => _loading = true); _model?.loadDocker(_server); } void _onTest() => _model?.runTest(_server); void _onInstall() => _model?.prepareInstall(_server); Future _onOpenRun() async { await PeachNavigation().navigateToPageWithData( DockerRun(server: _server), context, ); _onRefresh(); } void _onStart(DockerContainer c) => _action(c, 'start'); void _onStop(DockerContainer c) => _action(c, 'stop'); void _onRestart(DockerContainer c) => _action(c, 'restart'); void _onRemove(DockerContainer c) { _model?.showApplicationNotification( NotificationType.warning, 'Remove "${c.name}"?', 'This force-removes the container (docker rm -f).', true, true, null, action: 'Remove', positiveAction: () => _onConfirmRemove(c), ); } void _onConfirmRemove(DockerContainer c) { Navigator.pop(context); _action(c, 'rm'); } void _action(DockerContainer c, String action) { setState(() => _busyId = c.id); _model?.runAction(_server, c.id, action); } void _onShowLogs(DockerContainer c) { setState(() => _busyId = c.id); _model?.loadContainerLogs(_server, c.id, c.name); } void _notify(String message) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } // ── ConnectDockerManager ─────────────────────────────────────────────────── @override void onDockerLoaded(DockerStatus status, List containers) { if (!mounted) return; setState(() { _status = status; _containers = containers; _loading = false; _busyId = null; }); } @override void onActionFinished(String output) { if (!mounted) return; setState(() => _busyId = null); if (output.toLowerCase().contains('error')) _notify(output); _onRefresh(); } @override void onOutputReady(String title, String output) { if (!mounted) return; setState(() => _busyId = null); showOutputSheet(context, title, output); } @override void onInstallCommandReady(String command) { _model?.showApplicationNotification( NotificationType.info, 'Install Docker?', 'Runs:\n$command\nthen: systemctl enable --now docker', true, true, null, action: 'Install', positiveAction: () => _onConfirmInstall(command), ); } void _onConfirmInstall(String command) { Navigator.pop(context); setState(() => _installing = true); _model?.installDocker(_server, command); } @override void onInstallFinished() { if (!mounted) return; setState(() => _installing = false); _onRefresh(); } @override void onDockerFailed(String message) { if (!mounted) return; setState(() { _loading = false; _busyId = null; }); _notify(message); } // ── screen-only widgets ──────────────────────────────────────────────────── Widget _sectionHeader() => Row( children: [ text( 'CONTAINERS', 12, TextType.Bold, color: context.jp.textDim, letterSpacing: 1.2, ), const SizedBox(width: 8), text('${_containers.length}', 12, TextType.Bold, color: PeachTheme.seed), ], ); Widget _statusCard() { final jp = context.jp; final installed = _status?.installed ?? false; final running = _status?.running ?? false; final (color, label) = !installed ? (jp.textFaint, 'NOT INSTALLED') : running ? (PeachTheme.seed, 'RUNNING') : (PeachTheme.warn, 'STOPPED'); return Container( padding: const EdgeInsets.all(16), decoration: PeachTheme.panel(context, glowColor: PeachTheme.accent), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 46, height: 46, decoration: BoxDecoration( color: PeachTheme.accent.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(13), ), child: const Icon(Icons.inventory_2, color: PeachTheme.accent), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ text( _loading ? 'Checking Docker…' : 'Docker', 16, TextType.Bold, color: jp.text, ), text( installed ? (_status?.version ?? '') : 'Not installed', 12, TextType.Regular, color: jp.textDim, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 5, ), decoration: BoxDecoration( color: color.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(20), border: Border.all(color: color.withValues(alpha: 0.4)), ), child: text(label, 10.5, TextType.Bold, color: color), ), ], ), if (!installed) ...[ const SizedBox(height: 14), FilledButton.icon( onPressed: _installing ? null : _onInstall, icon: _installing ? SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: colorWhite, ), ) : const Icon(Icons.download), label: text( _installing ? 'Installing…' : 'Install Docker', 14, TextType.Bold, color: colorWhite, ), ), ] else if (!running) ...[ const SizedBox(height: 12), text( 'The Docker daemon is not running. Start it with ' 'systemctl start docker (Configuration → install also enables it).', 12.5, TextType.Regular, color: jp.textDim, ), ], ], ), ); } Widget _empty() { final jp = context.jp; return Container( padding: const EdgeInsets.symmetric(vertical: 40), alignment: Alignment.center, decoration: PeachTheme.panel(context), child: Column( children: [ Icon(Icons.inbox, size: 40, color: jp.textFaint), const SizedBox(height: 10), text('No containers yet', 14, TextType.Regular, color: jp.textDim), const SizedBox(height: 4), text( 'Tap "Run container" to start one.', 12, TextType.Light, color: jp.textFaint, ), ], ), ); } Widget _containerCard(DockerContainer c) { final jp = context.jp; final busy = _busyId == c.id; final (color, label) = switch (c.state.toLowerCase()) { 'running' => (PeachTheme.seed, 'UP'), 'exited' => (jp.textFaint, 'EXITED'), 'paused' => (PeachTheme.warn, 'PAUSED'), 'created' => (PeachTheme.accent, 'CREATED'), 'restarting' => (PeachTheme.warn, 'RESTARTING'), _ => (PeachTheme.danger, c.state.toUpperCase()), }; return Container( padding: const EdgeInsets.all(14), decoration: PeachTheme.panel(context, radius: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.widgets, size: 18, color: c.up ? PeachTheme.seed : jp.textFaint, ), const SizedBox(width: 8), Expanded( child: text( c.name, 14, TextType.Bold, color: jp.text, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), decoration: BoxDecoration( color: color.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(20), ), child: text(label, 10, TextType.Bold, color: color), ), ], ), const SizedBox(height: 6), text( c.image, 12, TextType.Regular, color: jp.textDim, maxLines: 1, overflow: TextOverflow.ellipsis, ), text( c.status, 11.5, TextType.Light, color: jp.textFaint, maxLines: 1, overflow: TextOverflow.ellipsis, ), if (c.ports.trim().isNotEmpty) text( c.ports, 11.5, TextType.Light, color: jp.textFaint, maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), Row( children: [ if (busy) const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) else ...[ if (c.up) ...[ _act(Icons.restart_alt, 'Restart', () => _onRestart(c)), _act(Icons.stop, 'Stop', () => _onStop(c)), ] else _act(Icons.play_arrow, 'Start', () => _onStart(c)), _act(Icons.article_outlined, 'Logs', () => _onShowLogs(c)), _act( Icons.delete_outline, 'Remove', () => _onRemove(c), color: PeachTheme.danger, ), ], ], ), ], ), ); } Widget _act(IconData icon, String tip, VoidCallback onTap, {Color? color}) => IconButton( tooltip: tip, iconSize: 20, color: color ?? context.jp.textDim, onPressed: onTap, icon: Icon(icon), ); }