Add app source and tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
alvocool
2026-10-11 10:15:30 +03:00
parent 7e246255c2
commit 38881d1fe1
135 changed files with 21057 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
import 'package:flutter/cupertino.dart';
import '../../about/internal/application/ServerProfile.dart';
import 'ConfigurationState.dart';
/// Probes the server's environment (Java, MariaDB, Postgres, nginx, Docker,
/// Node, Git), shows what's present, and guides installing what's missing
/// using the auto-detected package manager — preview, then confirm.
class Configuration extends StatefulWidget {
final ServerProfile server;
const Configuration({super.key, required this.server});
@override
State<Configuration> createState() => ConfigurationState();
}

View File

@@ -0,0 +1,406 @@
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),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,16 @@
import '../../about/external/data/EnvCheck.dart';
abstract class ConnectConfiguration {
/// The environment scan finished: the detected package manager and the
/// version string found for each tool (empty when absent).
void onEnvironmentProbed(String packageManager, Map<String, String> versions);
/// The scan failed.
void onProbeFailed(String message);
/// The exact command that would install [tool], for the user to confirm.
void onInstallCommandReady(EnvTool tool, String command);
/// [tool] finished installing; [version] is its freshly re-probed version.
void onToolInstalled(EnvTool tool, String version);
}

View File

@@ -0,0 +1,79 @@
import '../../about/external/data/EnvCheck.dart';
import '../../about/internal/application/ServerProfile.dart';
import '../../about/internal/application/StepTracker.dart';
import '../../comms/Comms.dart' show LogLine;
import '../parent/ParentViewModel.dart';
import 'ConnectConfiguration.dart';
class ViewConfiguration extends ParentViewModel {
ConnectConfiguration connection;
ViewConfiguration(super.context, this.connection);
Stream<LogLine> get logs => getDataManager().logs;
Stream<List<DeployStep>> get steps => getDataManager().stepStream;
/// Detects the package manager, then probes for every tool at once.
void probeEnvironment(ServerProfile server) async {
if (!await canReach(server, () => probeEnvironment(server))) return;
try {
final packageManager = await getDataManager().detectPackageManager(
server,
);
final versions = await getDataManager().probeEnvironment(
server,
kEnvTools.map((t) => '${t.key} ${t.detect}').toList(),
);
connection.onEnvironmentProbed(packageManager, versions);
} catch (error) {
connection.onProbeFailed(error.toString());
}
}
/// Builds the install command for [tool] under [packageManager] and hands it
/// back for confirmation — nothing runs on the server until the user agrees.
void prepareInstall(EnvTool tool, String packageManager) {
if (packageManager == 'unknown') {
connection.onProbeFailed(
'Unknown package manager — cannot auto-install.',
);
return;
}
connection.onInstallCommandReady(
tool,
installCommandFor(packageManager, tool.packagesFor(packageManager)),
);
}
/// Installs [tool], enables its unit where it has one, then re-probes just
/// that tool so the card updates without a full re-scan.
void installTool(ServerProfile server, EnvTool tool, String command) async {
try {
await getDataManager().installPackages(
server: server,
command: command,
enableUnit: tool.unit,
);
var version = '';
try {
final probed = await getDataManager().probeEnvironment(server, [
'${tool.key} ${tool.detect}',
]);
version = probed[tool.key] ?? '';
} catch (_) {
// The install itself succeeded; a failed re-probe just leaves the
// version blank until the next full scan.
}
connection.onToolInstalled(tool, version);
} catch (error) {
connection.onToolInstalled(tool, '');
handleError(
error,
() => installTool(server, tool, command),
() {},
"Retry",
);
}
}
}