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/NotificationType.dart'; import '../../about/internal/application/ServerProfile.dart'; import '../../about/internal/application/StepTracker.dart'; import '../../about/internal/application/TextType.dart'; import '../../comms/Comms.dart' show LogLine; import '../../designs/LogConsole.dart'; import '../../designs/StepList.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import 'Configuration.dart'; import 'ConnectConfiguration.dart'; import 'ViewConfiguration.dart'; class ConfigurationState extends State implements ConnectConfiguration { ViewConfiguration? _model; StreamSubscription? _logSub; StreamSubscription>? _stepSub; final _lines = []; List _steps = []; final Map _versions = {}; String _packageManager = 'unknown'; bool _loading = true; String? _error; String? _busyKey; ServerProfile get _server => widget.server; bool _installed(String key) => (_versions[key] ?? '').trim().isNotEmpty; @override void dispose() { _logSub?.cancel(); _stepSub?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewConfiguration(context, this), onViewModelReady: _onReady, builder: (context, viewModel, child) => Scaffold( appBar: AppBar( title: text( '${_server.name} · configuration', 20, TextType.Bold, color: context.jp.text, ), actions: [ IconButton( tooltip: 'Re-scan', onPressed: _loading || _busyKey != null ? null : _onRescan, icon: const Icon(Icons.refresh), ), const SizedBox(width: 4), ], ), body: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), children: [ _summaryCard(), if (_error != null) ...[const SizedBox(height: 12), _errorCard()], const SizedBox(height: 16), text( 'ENVIRONMENT', 12, TextType.Bold, color: context.jp.textDim, letterSpacing: 1.2, ), const SizedBox(height: 10), 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 tool in kEnvTools) SizedBox(width: w, child: _toolCard(tool)), ], ); }, ), if (_steps.isNotEmpty) ...[ const SizedBox(height: 22), StepList(steps: _steps), ], if (_lines.isNotEmpty) ...[ const SizedBox(height: 14), LogConsole( logs: _lines, height: 260, onClear: _onClearLogs, onCopy: _onCopyLogs, ), ], ], ), ), ); } void _onReady(ViewConfiguration 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?.probeEnvironment(_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 _onRescan() { setState(() { _loading = true; _error = null; }); _model?.probeEnvironment(_server); } void _onInstall(EnvTool tool) => _model?.prepareInstall(tool, _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))); } // ── ConnectConfiguration ─────────────────────────────────────────────────── @override void onEnvironmentProbed( String packageManager, Map versions, ) { if (!mounted) return; setState(() { _packageManager = packageManager; _versions ..clear() ..addAll(versions); _loading = false; _error = null; }); } @override void onProbeFailed(String message) { if (!mounted) return; setState(() { _loading = false; _error = message; }); } @override void onInstallCommandReady(EnvTool tool, String command) { final sudoNote = _server.useSudo ? ' (via sudo)' : ''; final unitNote = tool.unit == null ? '' : '\n\nThen: systemctl enable --now ${tool.unit}'; _model?.showApplicationNotification( NotificationType.info, 'Install ${tool.label}?', 'Peach will run$sudoNote:\n\n$command$unitNote', true, true, null, action: 'Install', positiveAction: () => _onConfirmInstall(tool, command), ); } void _onConfirmInstall(EnvTool tool, String command) { Navigator.pop(context); setState(() { _busyKey = tool.key; _lines.clear(); }); _model?.installTool(_server, tool, command); } @override void onToolInstalled(EnvTool tool, String version) { if (!mounted) return; setState(() { _versions[tool.key] = version; _busyKey = null; }); } // ── screen-only widgets ──────────────────────────────────────────────────── Widget _summaryCard() { final jp = context.jp; final present = kEnvTools.where((t) => _installed(t.key)).length; return Container( padding: const EdgeInsets.all(16), decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed), child: Row( children: [ Container( width: 46, height: 46, decoration: BoxDecoration( color: PeachTheme.seed.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(13), border: Border.all( color: PeachTheme.seed.withValues(alpha: 0.35), ), ), child: const Icon(Icons.fact_check, color: PeachTheme.seed), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ text( _loading ? 'Scanning environment…' : '$present / ${kEnvTools.length} tools ready', 16, TextType.Bold, color: jp.text, ), const SizedBox(height: 2), text( '${_server.username}@${_server.host}', 12, TextType.Regular, color: jp.textDim, ), ], ), ), _packageManagerChip(), ], ), ); } Widget _packageManagerChip() { final jp = context.jp; final known = _packageManager != 'unknown'; final color = known ? PeachTheme.accent : jp.textFaint; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: color.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(20), border: Border.all(color: color.withValues(alpha: 0.4)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.inventory_2, size: 13, color: color), const SizedBox(width: 5), text( _loading ? '…' : _packageManager, 11.5, TextType.Bold, color: color, ), ], ), ); } Widget _errorCard() => Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: PeachTheme.danger.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(12), border: Border.all(color: PeachTheme.danger.withValues(alpha: 0.5)), ), child: Row( children: [ const Icon(Icons.error_outline, color: PeachTheme.danger), const SizedBox(width: 10), Expanded( child: text(_error!, 14, TextType.Regular, color: PeachTheme.danger), ), ], ), ); Widget _toolCard(EnvTool tool) { final jp = context.jp; final installed = _installed(tool.key); final version = _versions[tool.key]?.trim() ?? ''; final busy = _busyKey == tool.key; final color = installed ? PeachTheme.seed : jp.textFaint; return Container( padding: const EdgeInsets.all(14), decoration: PeachTheme.panel(context, radius: 16), child: Row( children: [ Container( width: 42, height: 42, decoration: BoxDecoration( color: color.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(11), border: Border.all(color: color.withValues(alpha: 0.3)), ), child: Icon(tool.icon, color: color, size: 20), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ text(tool.label, 15, TextType.Bold, color: jp.text), const SizedBox(height: 2), text( _loading ? 'checking…' : (installed ? (version.isEmpty ? 'installed' : version) : 'Not installed'), 12, TextType.Regular, color: installed ? jp.textDim : jp.textFaint, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), const SizedBox(width: 10), if (_loading) const SizedBox() else if (installed) const Icon(Icons.check_circle, color: PeachTheme.seed) else if (busy) const SizedBox( width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2), ) else OutlinedButton.icon( onPressed: _busyKey != null ? null : () => _onInstall(tool), icon: const Icon(Icons.download, size: 18), label: text('Install', 13, TextType.Bold, color: PeachTheme.seed), style: OutlinedButton.styleFrom( foregroundColor: PeachTheme.seed, side: BorderSide(color: PeachTheme.seed.withValues(alpha: 0.5)), padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 10, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), ), ], ), ); } }