Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
16
lib/Peach/see/configuration/Configuration.dart
Normal file
16
lib/Peach/see/configuration/Configuration.dart
Normal 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();
|
||||
}
|
||||
406
lib/Peach/see/configuration/ConfigurationState.dart
Normal file
406
lib/Peach/see/configuration/ConfigurationState.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
lib/Peach/see/configuration/ConnectConfiguration.dart
Normal file
16
lib/Peach/see/configuration/ConnectConfiguration.dart
Normal 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);
|
||||
}
|
||||
79
lib/Peach/see/configuration/ViewConfiguration.dart
Normal file
79
lib/Peach/see/configuration/ViewConfiguration.dart
Normal 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user