import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:stacked/stacked.dart'; import '../../../about/external/data/EnvCheck.dart'; import '../../../about/internal/application/ServerProfile.dart'; import '../../../about/internal/application/StepTracker.dart'; import '../../../about/internal/application/TextType.dart'; import '../../../comms/Comms.dart' show DeployResult, LogLine; import '../../../designs/LogConsole.dart'; import '../../../designs/StepList.dart'; import '../../../designs/text/Text.dart'; import '../../../utils/Colors.dart'; import 'ConnectServerSetup.dart'; import 'ServerSetup.dart'; import 'ViewServerSetup.dart'; class ServerSetupState extends State implements ConnectServerSetup { static const _labels = ['Connect', 'Choose', 'Install', 'Done']; ViewServerSetup? _model; StreamSubscription? _logSub; StreamSubscription>? _stepSub; final _lines = []; List _steps = []; final Map _versions = {}; final Set _selected = {}; int _step = 0; // 0 connect · 1 choose · 2 install · 3 done bool _connecting = true; bool _connected = false; String? _connectError; String _packageManager = 'unknown'; bool _installing = false; DeployResult? _result; ServerProfile get _server => widget.server; bool _installed(String key) => (_versions[key] ?? '').trim().isNotEmpty; List get _toInstall => kEnvTools.where((t) => _selected.contains(t.key)).toList(); int get _presentCount => kEnvTools.where((t) => _installed(t.key)).length; @override void dispose() { _logSub?.cancel(); _stepSub?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewServerSetup(context, this), onViewModelReady: _onReady, builder: (context, viewModel, child) => Scaffold( appBar: AppBar( title: text( 'Set up ${_server.name}', 20, TextType.Bold, color: context.jp.text, ), actions: [ TextButton( onPressed: _onSkip, child: text('Skip', 13, TextType.Bold, color: colorPrimary), ), const SizedBox(width: 8), ], ), body: Column( children: [ _progressBar(), Expanded( child: switch (_step) { 0 => _connectStep(), 1 => _chooseStep(viewModel), 2 => _installStep(viewModel), _ => _doneStep(), }, ), ], ), ), ); } void _onReady(ViewServerSetup 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); _stepSub = _model?.steps.listen(_onSteps); _model?.probeServer(_server); } void _onLogLine(LogLine line) { if (!mounted) return; setState(() => _lines.add(line)); } void _onSteps(List steps) { if (!mounted) return; setState(() => _steps = steps); } // ── named handlers ───────────────────────────────────────────────────────── void _onSkip() => Navigator.of(context).pop(); void _onFinish() => Navigator.of(context).pop(true); void _onRetryConnect() { setState(() { _connecting = true; _connectError = null; }); _model?.probeServer(_server); } void _onGoToChoose() => setState(() => _step = 1); void _onGoToConnect() => setState(() => _step = 0); void _onGoToInstall() => setState(() => _step = 2); void _onToggleTool(EnvTool tool, bool? selected) { setState(() { if (selected == true) { _selected.add(tool.key); } else { _selected.remove(tool.key); } }); } void _onInstall(ViewServerSetup model) { setState(() { _installing = true; _lines.clear(); _result = null; }); model.installEnvironment( _server, _toInstall, model.buildInstallCommand(_toInstall, _packageManager), ); } void _onClearLogs() => setState(() => _lines.clear()); void _onCopyLogs() { Clipboard.setData( ClipboardData(text: _lines.map((l) => l.text).join('\n')), ); _notify('Logs copied.'); } void _notify(String message) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } // ── ConnectServerSetup ───────────────────────────────────────────────────── @override void onServerProbed(String packageManager, Map versions) { if (!mounted) return; setState(() { _packageManager = packageManager; _versions ..clear() ..addAll(versions); _connected = true; _connecting = false; _connectError = null; }); } @override void onProbeFailed(String message) { if (!mounted) return; setState(() { _connected = false; _connecting = false; _connectError = message; }); } @override void onInstallFinished(DeployResult result, Map versions) { if (!mounted) return; setState(() { _installing = false; _result = result; _versions.addAll(versions); if (result.ok) _step = 3; }); } // ── screen-only widgets ──────────────────────────────────────────────────── Widget _progressBar() { final jp = context.jp; return Container( padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), decoration: BoxDecoration( color: jp.surface, border: Border(bottom: BorderSide(color: jp.border)), ), child: Row( children: [ for (var i = 0; i < _labels.length; i++) ...[ _dot(i, _labels[i]), if (i < _labels.length - 1) Expanded( child: Container( height: 2, margin: const EdgeInsets.symmetric(horizontal: 6), color: i < _step ? PeachTheme.seed : jp.border, ), ), ], ], ), ); } Widget _dot(int index, String label) { final jp = context.jp; final done = index < _step; final active = index == _step; final color = done || active ? PeachTheme.seed : jp.textFaint; return Row( children: [ Container( width: 26, height: 26, alignment: Alignment.center, decoration: BoxDecoration( color: color.withValues(alpha: active ? 0.18 : (done ? 0.14 : 0.0)), shape: BoxShape.circle, border: Border.all(color: color.withValues(alpha: 0.6)), ), child: done ? const Icon(Icons.check, size: 14, color: PeachTheme.seed) : text('${index + 1}', 12, TextType.Bold, color: color), ), const SizedBox(width: 6), text( label, 12.5, active ? TextType.Bold : TextType.Regular, color: active ? jp.text : jp.textFaint, ), ], ); } Widget _connectStep() { final jp = context.jp; return ListView( padding: const EdgeInsets.all(20), children: [ Container( padding: const EdgeInsets.all(20), decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed), child: Column( children: [ if (_connecting) ...[ const SizedBox( width: 40, height: 40, child: CircularProgressIndicator(strokeWidth: 3), ), const SizedBox(height: 16), text( 'Connecting to ${_server.host} …', 14, TextType.Regular, color: jp.textDim, ), ] else if (_connected) ...[ const Icon( Icons.check_circle, color: PeachTheme.seed, size: 44, ), const SizedBox(height: 12), text('Connected', 18, TextType.Bold, color: jp.text), const SizedBox(height: 6), text( 'Package manager: $_packageManager · ' '$_presentCount/${kEnvTools.length} tools present', 14, TextType.Regular, color: jp.textDim, align: TextAlign.center, ), ] else ...[ const Icon(Icons.error, color: PeachTheme.danger, size: 44), const SizedBox(height: 12), text( 'Could not connect', 14, TextType.Bold, color: PeachTheme.danger, ), const SizedBox(height: 6), text( _connectError ?? '', 12, TextType.Regular, color: jp.textDim, align: TextAlign.center, ), ], ], ), ), const SizedBox(height: 20), Row( children: [ if (!_connected && !_connecting) ...[ Expanded( child: OutlinedButton.icon( onPressed: _onRetryConnect, icon: const Icon(Icons.refresh), label: text('Retry', 14, TextType.Bold, color: jp.textDim), ), ), const SizedBox(width: 10), ], Expanded( child: FilledButton.icon( onPressed: _connected ? _onGoToChoose : null, icon: const Icon(Icons.arrow_forward), label: text( 'Choose environment', 14, TextType.Bold, color: colorWhite, ), ), ), ], ), ], ); } Widget _chooseStep(ViewServerSetup model) { final jp = context.jp; return Column( children: [ Expanded( child: ListView( padding: const EdgeInsets.all(20), children: [ text( 'What should this server run?', 18, TextType.Bold, color: jp.text, ), const SizedBox(height: 4), text( 'Tick anything missing you want Peach to install ' '(via $_packageManager). Installed tools are already ready.', 13, TextType.Regular, color: jp.textDim, ), const SizedBox(height: 16), for (final tool in kEnvTools) _toolTile(tool), ], ), ), _bottomBar( back: _onGoToConnect, nextLabel: _selected.isEmpty ? 'Nothing to install' : 'Review & install', onNext: _selected.isEmpty ? null : _onGoToInstall, ), ], ); } Widget _toolTile(EnvTool tool) { final jp = context.jp; final installed = _installed(tool.key); final selected = _selected.contains(tool.key); return Container( margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(12), decoration: PeachTheme.panel(context, radius: 14), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: (installed ? PeachTheme.seed : jp.textFaint).withValues( alpha: 0.14, ), borderRadius: BorderRadius.circular(11), ), child: Icon( tool.icon, color: installed ? PeachTheme.seed : jp.textDim, size: 20, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ text(tool.label, 14, TextType.Bold, color: jp.text), text( installed ? (_versions[tool.key] ?? 'installed') : 'not installed', 11.5, TextType.Regular, color: installed ? jp.textDim : jp.textFaint, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), if (installed) const Padding( padding: EdgeInsets.only(right: 6), child: Icon(Icons.check_circle, color: PeachTheme.seed), ) else Checkbox(value: selected, onChanged: (v) => _onToggleTool(tool, v)), ], ), ); } Widget _installStep(ViewServerSetup model) { final jp = context.jp; final command = model.buildInstallCommand(_toInstall, _packageManager); return Column( children: [ Expanded( child: ListView( padding: const EdgeInsets.all(20), children: [ text('Review', 18, TextType.Bold, color: jp.text), const SizedBox(height: 4), text( 'These will be installed${_server.useSudo ? ' via sudo' : ''}:', 13, TextType.Regular, color: jp.textDim, ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: _toInstall .map( (t) => Chip( avatar: Icon(t.icon, size: 16), label: Text(t.label), ), ) .toList(), ), const SizedBox(height: 14), Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: jp.consoleBg, borderRadius: BorderRadius.circular(12), border: Border.all(color: ConsoleColors.frame), ), child: SelectableText( command, style: const TextStyle( fontFamily: 'monospace', fontSize: 12.5, color: ConsoleColors.body, ), ), ), if (_steps.isNotEmpty) ...[ const SizedBox(height: 16), StepList(steps: _steps), ], if (_result != null && !_result!.ok) ...[ const SizedBox(height: 12), _banner(_result!), ], if (_lines.isNotEmpty) ...[ const SizedBox(height: 14), LogConsole( logs: _lines, height: 240, onClear: _onClearLogs, onCopy: _onCopyLogs, ), ], ], ), ), _bottomBar( back: _installing ? null : _onGoToChoose, nextLabel: _installing ? 'Installing…' : 'Install now', onNext: _installing ? null : () => _onInstall(model), nextIcon: Icons.download, ), ], ); } Widget _doneStep() { final jp = context.jp; return ListView( padding: const EdgeInsets.all(20), children: [ Container( padding: const EdgeInsets.all(24), decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed), child: Column( children: [ Container( width: 72, height: 72, decoration: BoxDecoration( gradient: PeachTheme.brandGradient, borderRadius: BorderRadius.circular(20), boxShadow: PeachTheme.glow( PeachTheme.seed, opacity: 0.5, blur: 28, ), ), child: Icon(Icons.check, color: colorWhite, size: 38), ), const SizedBox(height: 16), text( '${_server.name} is ready', 20, TextType.Bold, color: jp.text, ), const SizedBox(height: 6), text( '$_presentCount/${kEnvTools.length} tools installed. ' 'Add a service to start deploying.', 14, TextType.Regular, color: jp.textDim, align: TextAlign.center, ), ], ), ), const SizedBox(height: 20), FilledButton.icon( onPressed: _onFinish, icon: const Icon(Icons.check), label: text('Finish', 14, TextType.Bold, color: colorWhite), ), ], ); } Widget _bottomBar({ VoidCallback? back, required String nextLabel, VoidCallback? onNext, IconData nextIcon = Icons.arrow_forward, }) { return SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 14), child: Row( children: [ OutlinedButton( onPressed: back, child: text('Back', 13, TextType.Bold, color: context.jp.textDim), ), const SizedBox(width: 10), Expanded( child: FilledButton.icon( onPressed: onNext, icon: _installing ? SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: colorWhite, ), ) : Icon(nextIcon), label: text(nextLabel, 14, TextType.Bold, color: colorWhite), ), ), ], ), ), ); } Widget _banner(DeployResult result) { final color = result.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(result.ok ? Icons.check_circle : Icons.error, color: color), const SizedBox(width: 10), Expanded( child: text(result.message, 14, TextType.Regular, color: color), ), ], ), ); } }