import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:stacked/stacked.dart'; import '../../../about/external/data/Docker.dart'; import '../../../about/internal/application/ServerProfile.dart'; import '../../../about/internal/application/TextType.dart'; import '../../../comms/Comms.dart' show LogLine; import '../../../designs/LogConsole.dart'; import '../../../designs/text/Text.dart'; import '../../../utils/Colors.dart'; import 'ConnectDockerRun.dart'; import 'DockerRun.dart'; import 'ViewDockerRun.dart'; class DockerRunState extends State implements ConnectDockerRun { ViewDockerRun? _model; StreamSubscription? _logSub; final _lines = []; final _image = TextEditingController(); final _name = TextEditingController(); final _ports = TextEditingController(); final _env = TextEditingController(); final _volumes = TextEditingController(); final _command = TextEditingController(); String _restart = 'unless-stopped'; bool _detached = true; bool _running = false; (bool, String)? _result; ServerProfile get _server => widget.server; @override void dispose() { _logSub?.cancel(); for (final c in [_image, _name, _ports, _env, _volumes, _command]) { c.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewDockerRun(context, this), onViewModelReady: _onReady, builder: (context, viewModel, child) => Scaffold( appBar: AppBar( title: text( 'Run container', 20, TextType.Bold, color: context.jp.text, ), ), body: ListView( padding: const EdgeInsets.fromLTRB(20, 18, 20, 40), children: [ _field( 'Image', _image, hint: 'nginx:latest · redis:7 · postgres:16', ), _field('Container name (optional)', _name, hint: 'my-nginx'), _field( 'Ports (host:container, one per line)', _ports, hint: '8080:80\n443:443', lines: 2, mono: true, ), _field( 'Environment (KEY=VALUE, one per line)', _env, hint: 'POSTGRES_PASSWORD=secret', lines: 2, mono: true, ), _field( 'Volumes (host:container, one per line)', _volumes, hint: '/data/pg:/var/lib/postgresql/data', lines: 2, mono: true, ), Row( children: [ Expanded( child: DropdownButtonFormField( initialValue: _restart, decoration: const InputDecoration( labelText: 'Restart policy', ), items: const [ DropdownMenuItem(value: 'no', child: Text('no')), DropdownMenuItem( value: 'on-failure', child: Text('on-failure'), ), DropdownMenuItem(value: 'always', child: Text('always')), DropdownMenuItem( value: 'unless-stopped', child: Text('unless-stopped'), ), ], onChanged: _onRestartChanged, ), ), const SizedBox(width: 12), Expanded( child: SwitchListTile( contentPadding: EdgeInsets.zero, dense: true, value: _detached, onChanged: _onDetachedChanged, title: text( 'Detached (-d)', 14, TextType.Regular, color: context.jp.text, ), ), ), ], ), _field( 'Command / args (optional)', _command, hint: 'redis-server --appendonly yes', mono: true, ), const SizedBox(height: 8), FilledButton.icon( onPressed: _running ? null : _onRun, icon: _running ? SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: colorWhite, ), ) : const Icon(Icons.play_arrow), label: text( _running ? 'Starting…' : 'Run container', 14, TextType.Bold, color: colorWhite, ), ), if (_result != null) ...[ const SizedBox(height: 14), _banner(_result!), ], if (_lines.isNotEmpty) ...[ const SizedBox(height: 14), LogConsole( logs: _lines, height: 220, onClear: _onClearLogs, onCopy: _onCopyLogs, ), ], ], ), ), ); } void _onReady(ViewDockerRun 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 { _logSub = _model?.logs.listen(_onLogLine); } void _onLogLine(LogLine line) { if (!mounted) return; setState(() => _lines.add(line)); } DockerRunSpec _spec() => DockerRunSpec( image: _image.text, name: _name.text, ports: _ports.text, env: _env.text, volumes: _volumes.text, restart: _restart, detached: _detached, command: _command.text, ); // ── named handlers ───────────────────────────────────────────────────────── void _onRun() { if (_image.text.trim().isEmpty) { _notify('Enter an image (e.g. nginx:latest).'); return; } setState(() { _running = true; _lines.clear(); _result = null; }); _model?.runContainer(_server, _spec()); } void _onRestartChanged(String? v) => setState(() => _restart = v ?? 'no'); void _onDetachedChanged(bool v) => setState(() => _detached = v); void _onClearLogs() => setState(() => _lines.clear()); void _onCopyLogs() { Clipboard.setData( ClipboardData(text: _lines.map((l) => l.text).join('\n')), ); _notify('Logs copied.'); } void _onViewContainers() => Navigator.pop(context, true); void _notify(String message) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } // ── ConnectDockerRun ─────────────────────────────────────────────────────── @override void onRunFinished(bool started, String output) { if (!mounted) return; setState(() { _running = false; _result = (started, output); }); } // ── screen-only widgets ──────────────────────────────────────────────────── Widget _banner((bool, String) result) { final (ok, output) = result; final color = ok ? PeachTheme.seed : PeachTheme.danger; return Container( padding: const EdgeInsets.fromLTRB(14, 12, 12, 12), decoration: BoxDecoration( color: color.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(12), border: Border.all(color: color.withValues(alpha: 0.5)), ), child: Row( children: [ Icon(ok ? Icons.check_circle : Icons.error, color: color), const SizedBox(width: 10), Expanded( child: text( ok ? 'Container started ✓ (${_shortId(output)})' : output, 14, TextType.Regular, color: color, ), ), if (ok) FilledButton( onPressed: _onViewContainers, child: text('View', 13, TextType.Bold, color: colorWhite), ), ], ), ); } /// `docker run -d` echoes the full container id; the short form is what the /// CLI shows everywhere else. String _shortId(String output) { final id = output.trim().split('\n').last.trim(); return id.length <= 12 ? id : '${id.substring(0, 12)}…'; } Widget _field( String label, TextEditingController controller, { String? hint, int lines = 1, bool mono = false, }) { return Padding( padding: const EdgeInsets.only(bottom: 14), child: TextField( controller: controller, maxLines: lines, style: mono ? const TextStyle(fontFamily: 'monospace', fontSize: 13) : null, decoration: InputDecoration(labelText: label, hintText: hint), ), ); } }