653 lines
19 KiB
Dart
653 lines
19 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/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<ServerSetup>
|
|
implements ConnectServerSetup {
|
|
static const _labels = ['Connect', 'Choose', 'Install', 'Done'];
|
|
|
|
ViewServerSetup? _model;
|
|
|
|
StreamSubscription<LogLine>? _logSub;
|
|
StreamSubscription<List<DeployStep>>? _stepSub;
|
|
|
|
final _lines = <LogLine>[];
|
|
List<DeployStep> _steps = [];
|
|
final Map<String, String> _versions = {};
|
|
final Set<String> _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<EnvTool> 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<ViewServerSetup>.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<void> _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<DeployStep> 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<String, String> 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<String, String> 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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|