Files
PeachDeployments/lib/Peach/see/configuration/ConfigurationState.dart
alvocool 38881d1fe1 Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 10:15:30 +03:00

407 lines
12 KiB
Dart

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<Configuration>
implements ConnectConfiguration {
ViewConfiguration? _model;
StreamSubscription<LogLine>? _logSub;
StreamSubscription<List<DeployStep>>? _stepSub;
final _lines = <LogLine>[];
List<DeployStep> _steps = [];
final Map<String, String> _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<ViewConfiguration>.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<void> _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<DeployStep> 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<String, String> 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),
),
),
),
],
),
);
}
}