Files
PeachDeployments/lib/Peach/see/deploy/single/DeployState.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

1031 lines
31 KiB
Dart

import 'dart:async';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stacked/stacked.dart';
import '../../../about/internal/application/ServerProfile.dart';
import '../../../about/internal/application/ServiceConfig.dart';
import '../../../about/internal/application/StepTracker.dart';
import '../../../about/internal/application/TextType.dart';
import '../../../comms/Comms.dart' show DeployResult, LogLine, TransferProgress;
import '../../../designs/LogConsole.dart';
import '../../../designs/StepList.dart';
import '../../../designs/TransferCard.dart';
import '../../../designs/text/Text.dart';
import '../../../utils/Colors.dart';
import '../../../utils/CommonUtils.dart';
import 'ConnectDeploy.dart';
import 'Deploy.dart';
import 'ViewDeploy.dart';
enum DeployPhase { idle, building, packaging, deploying, reverting, done }
class DeployState extends State<Deploy> implements ConnectDeploy {
ViewDeploy? _model;
final _subscriptions = <StreamSubscription>[];
final _lines = <LogLine>[];
List<DeployStep> _steps = [];
TransferProgress? _transfer;
// Staged artifact — one of these, never both.
Uint8List? _jarBytes;
String? _jarName;
String? _webDir;
String _source = '';
late String _projectPath;
List<String>? _nginxRoots;
bool _dragging = false;
bool _following = false;
bool _succeeded = false;
DeployPhase _phase = DeployPhase.idle;
DeployResult? _lastResult;
ServerProfile get _server => widget.server;
ServiceConfig get _service => widget.service;
bool get _isWeb => _service.isWeb;
bool get _isGeneric => _service.isGeneric;
bool get _ready => _isWeb ? _webDir != null : _jarBytes != null;
bool get _busy =>
_phase == DeployPhase.building ||
_phase == DeployPhase.packaging ||
_phase == DeployPhase.deploying ||
_phase == DeployPhase.reverting;
String get _artifactName =>
_isWeb ? (_webDir == null ? '' : baseName(_webDir!)) : (_jarName ?? '');
String get _resolvedTarget =>
_isWeb ? _service.remoteRoot : _service.remotePath(_jarName ?? '<jar>');
/// nginx is configured to serve somewhere this service doesn't deploy to —
/// the upload will succeed but the site won't change.
bool get _rootMismatch =>
_isWeb &&
_nginxRoots != null &&
_nginxRoots!.isNotEmpty &&
!_nginxRoots!.any((r) => _sameDir(r, _service.remoteRoot));
bool _sameDir(String a, String b) {
String trim(String s) =>
s.endsWith('/') && s.length > 1 ? s.substring(0, s.length - 1) : s;
return trim(a.trim()) == trim(b.trim());
}
@override
void initState() {
super.initState();
_projectPath = _service.localProjectPath;
}
@override
void dispose() {
for (final s in _subscriptions) {
s.cancel();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewDeploy>.reactive(
viewModelBuilder: () => ViewDeploy(context, this),
onViewModelReady: _onReady,
builder: (context, viewModel, child) => Scaffold(
appBar: AppBar(
title: text(
'${_server.name} · ${_service.name}',
20,
TextType.Bold,
color: context.jp.text,
),
actions: [
if (viewModel.isConnected)
IconButton(
tooltip: 'Disconnect',
onPressed: _onDisconnect,
icon: const Icon(Icons.link_off),
),
],
),
body: Column(
children: [
_targetHeader(),
Expanded(
child: LayoutBuilder(
builder: (context, c) {
if (c.maxWidth <= 820) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 20),
children: [
..._controls(viewModel),
const SizedBox(height: 14),
_console(expand: false),
],
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 5,
child: ListView(children: _controls(viewModel)),
),
const SizedBox(width: 16),
Expanded(flex: 4, child: _console(expand: true)),
],
),
);
},
),
),
],
),
),
);
}
void _onReady(ViewDeploy 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 {
final model = _model;
if (model == null) return;
_subscriptions.addAll([
model.logs.listen(_onLogLine),
model.buildLogs.listen(_onLogLine),
model.steps.listen(_onSteps),
model.buildSteps.listen(_onSteps),
model.transferProgress.listen(_onTransfer),
]);
if (_isWeb) model.loadNginxRoots(_server);
}
void _onLogLine(LogLine line) {
if (!mounted) return;
setState(() => _lines.add(line));
}
void _onSteps(List<DeployStep> steps) {
if (!mounted) return;
setState(() => _steps = steps);
}
void _onTransfer(TransferProgress? progress) {
if (!mounted) return;
setState(() => _transfer = progress);
}
// ── named handlers ─────────────────────────────────────────────────────────
void _onPickArtifact() {
if (_isWeb) {
_model?.pickWebFolder();
} else {
_model?.pickArtifact(_isGeneric);
}
}
void _onDragEntered(DropEventDetails _) => setState(() => _dragging = true);
void _onDragExited(DropEventDetails _) => setState(() => _dragging = false);
void _onDragDone(DropDoneDetails details) {
setState(() => _dragging = false);
_model?.stageDropped(details, _isWeb, _isGeneric);
}
void _onPickProjectDir() => _model?.pickProjectDirectory(_server, _service);
Future<void> _onBuild() async {
if (_projectPath.trim().isEmpty) {
_notify('Set the path to the source code first.');
return;
}
await _model?.saveProjectPath(_server, _service, _projectPath);
setState(() {
_phase = DeployPhase.building;
_lines.clear();
_transfer = null;
_lastResult = null;
_succeeded = false;
_following = false;
_jarBytes = null;
_jarName = null;
_webDir = null;
});
_model?.buildFromSource(_service, _isWeb);
}
void _onDeploy() {
if (!_ready) {
_notify('Stage an artifact first.');
return;
}
setState(() {
_phase = DeployPhase.deploying;
_transfer = null;
_lastResult = null;
_following = false;
});
if (_isWeb) {
_model?.deployWeb(_server, _service, _webDir!);
} else {
_model?.deployJar(_server, _service, _jarBytes!, _jarName!);
}
}
void _onRevert() {
setState(() => _phase = DeployPhase.reverting);
_model?.revert(_server, _service);
}
void _onDiscardBackup() => _model?.discardBackup();
void _onCancel() {
_model?.cancel(_phase == DeployPhase.building);
_notify('Cancelling…');
}
void _onDisconnect() => _model?.disconnect();
void _onToggleFollow() => _model?.toggleFollow(_server, _service, _following);
void _onClearStaged() {
setState(() {
_jarBytes = null;
_jarName = null;
_webDir = null;
_source = '';
_lastResult = null;
_succeeded = false;
});
}
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)));
}
// ── ConnectDeploy ──────────────────────────────────────────────────────────
@override
void onNginxRootsLoaded(List<String> roots) {
if (!mounted) return;
setState(() => _nginxRoots = roots);
}
@override
void onJarStaged(Uint8List bytes, String name, String source) {
if (!mounted) return;
setState(() {
_jarBytes = bytes;
_jarName = name;
_webDir = null;
_source = source;
_lastResult = null;
_succeeded = false;
_phase = DeployPhase.idle;
});
if (source == 'Built from source') {
_notify('Build ready — proceed to deploy.');
}
}
@override
void onWebStaged(String directory, String source) {
if (!mounted) return;
setState(() {
_webDir = directory;
_jarBytes = null;
_jarName = null;
_source = source;
_lastResult = null;
_succeeded = false;
_phase = DeployPhase.idle;
});
if (source == 'Built from source') {
_notify('Build ready — proceed to deploy.');
}
}
@override
void onBuildFailed(String message) {
if (!mounted) return;
setState(() {
_phase = DeployPhase.done;
_lastResult = DeployResult(false, false, message);
});
}
@override
void onPackagingStarted() {
if (!mounted) return;
setState(() => _phase = DeployPhase.packaging);
}
@override
void onDeployFinished(DeployResult result) {
if (!mounted) return;
setState(() {
_phase = DeployPhase.done;
_lastResult = result;
_succeeded = result.ok;
});
}
@override
void onRevertFinished(DeployResult result) {
if (!mounted) return;
setState(() {
_phase = DeployPhase.done;
_lastResult = result;
});
}
@override
void onBackupDiscarded() {
if (!mounted) return;
setState(() => _lastResult = null);
}
@override
void onFollowChanged(bool following) {
if (!mounted) return;
setState(() => _following = following);
}
// ── screen-only widgets ────────────────────────────────────────────────────
/// Source chooser (or ready panel), live steps, transfer stats, result.
List<Widget> _controls(ViewDeploy model) {
return [
if (_rootMismatch) ...[_rootWarning(), const SizedBox(height: 14)],
if (_ready) _readyPanel() else _sourceChooser(model),
if (_steps.isNotEmpty) ...[
const SizedBox(height: 14),
StepList(steps: _steps),
],
if (_transfer != null && !_transfer!.complete) ...[
const SizedBox(height: 14),
TransferCard(progress: _transfer!),
],
if (_lastResult != null) ...[
const SizedBox(height: 14),
_resultBanner(_lastResult!),
],
];
}
Widget _console({required bool expand}) => LogConsole(
logs: _lines,
height: expand ? null : 320,
live: _following,
onClear: _onClearLogs,
onCopy: _onCopyLogs,
);
Widget _rootWarning() {
return Container(
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
decoration: BoxDecoration(
color: PeachTheme.warn.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: PeachTheme.warn.withValues(alpha: 0.5)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.warning_amber, color: PeachTheme.warn),
const SizedBox(width: 10),
Expanded(
child: text(
'nginx serves ${_nginxRoots!.join(', ')} — but this service '
'deploys to ${_service.remoteRoot}. Files will upload, but the '
'site won\'t be served until they match. Edit the service\'s '
'web root (or your nginx config).',
12.5,
TextType.Regular,
color: context.jp.textDim,
height: 1.35,
),
),
],
),
);
}
Widget _targetHeader() {
final jp = context.jp;
final kindColor = _isWeb ? PeachTheme.accent : PeachTheme.seed;
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
decoration: BoxDecoration(
color: jp.surface,
border: Border(bottom: BorderSide(color: jp.border)),
),
child: Wrap(
spacing: 18,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_kindChip(kindColor),
_headerItem(
Icons.dns,
'${_server.username}@${_server.host}:${_server.port}',
),
_headerItem(
_isWeb ? Icons.language : Icons.folder_zip,
_resolvedTarget,
),
if (_isWeb)
_headerItem(
Icons.dns_outlined,
'nginx: ${_service.nginxService.isEmpty ? "nginx" : _service.nginxService}',
)
else if (_service.serviceName.isNotEmpty)
_headerItem(
Icons.settings_suggest,
'unit: ${_service.serviceName}',
),
_headerItem(
_server.useSudo ? Icons.shield : Icons.shield_outlined,
_server.useSudo ? 'sudo' : 'no-sudo',
),
],
),
);
}
Widget _kindChip(Color color) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withValues(alpha: 0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_isWeb
? Icons.language
: (_isGeneric ? Icons.terminal : Icons.coffee),
size: 13,
color: color,
),
const SizedBox(width: 5),
text(
_isWeb
? _service.framework.label
: (_isGeneric ? 'Service' : 'Jar service'),
11.5,
TextType.Bold,
color: color,
),
],
),
);
Widget _headerItem(IconData icon, String label) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: context.jp.textDim),
const SizedBox(width: 6),
text(label, 12.5, TextType.Regular, color: context.jp.textDim),
],
);
Widget _sourceChooser(ViewDeploy model) {
return LayoutBuilder(
builder: (context, c) {
final dropCard = _dropCard();
final buildCard = _buildCard(model);
if (c.maxWidth <= 680) {
return Column(
children: [
dropCard,
const SizedBox(height: 10),
_orBadge(),
const SizedBox(height: 10),
buildCard,
],
);
}
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: dropCard),
const SizedBox(width: 14),
_orBadge(),
const SizedBox(width: 14),
Expanded(child: buildCard),
],
),
);
},
);
}
Widget _orBadge() => Center(
child: Container(
width: 34,
height: 34,
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.jp.surface2,
shape: BoxShape.circle,
border: Border.all(color: context.jp.border),
),
child: text('OR', 10, TextType.Bold, color: context.jp.textDim),
),
);
Widget _optionShell({
required String tag,
required IconData icon,
required String title,
required Widget child,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: PeachTheme.panel(context, radius: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(7),
decoration: BoxDecoration(
color: PeachTheme.seed.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(9),
),
child: Icon(icon, size: 18, color: PeachTheme.seed),
),
const SizedBox(width: 10),
text(title, 15, TextType.Bold, color: context.jp.text),
const Spacer(),
text(
tag,
11,
TextType.Bold,
color: context.jp.textFaint,
letterSpacing: 1,
),
],
),
const SizedBox(height: 14),
child,
],
),
);
}
Widget _dropCard() {
return _optionShell(
tag: 'OPTION 1',
icon: _isWeb ? Icons.drive_folder_upload : Icons.upload_file,
title: _isWeb
? 'Use a built folder'
: (_isGeneric ? 'Drag a file' : 'Drag a jar'),
child: DropTarget(
onDragEntered: _onDragEntered,
onDragExited: _onDragExited,
onDragDone: _onDragDone,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: _onPickArtifact,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
height: 120,
decoration: BoxDecoration(
gradient: _dragging
? LinearGradient(
colors: [
PeachTheme.seed.withValues(alpha: 0.22),
PeachTheme.seed.withValues(alpha: 0.06),
],
)
: null,
color: _dragging ? null : context.jp.surface2,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: _dragging ? PeachTheme.seed : context.jp.border,
width: _dragging ? 2 : 1,
),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_isWeb ? Icons.folder_special : Icons.upload_file,
size: 32,
color: PeachTheme.seed.withValues(alpha: 0.85),
),
const SizedBox(height: 8),
text(
_isWeb
? 'Drop the built site folder, or click to choose'
: (_isGeneric
? 'Drop any file here, or click to browse'
: 'Drop a .jar here, or click to browse'),
14,
TextType.Regular,
color: context.jp.text,
align: TextAlign.center,
),
],
),
),
),
),
),
);
}
Widget _buildCard(ViewDeploy model) {
final supported = model.buildSupported;
final hasPath = _projectPath.trim().isNotEmpty;
final building = _phase == DeployPhase.building;
return _optionShell(
tag: 'OPTION 2',
icon: Icons.build,
title: 'Build from source',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: context.jp.surface2,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: context.jp.border),
),
child: Row(
children: [
Icon(
Icons.folder,
size: 18,
color: hasPath ? PeachTheme.accent : context.jp.textFaint,
),
const SizedBox(width: 8),
Expanded(
child: text(
hasPath ? _projectPath : 'No project path set',
12.5,
TextType.Regular,
color: hasPath ? context.jp.textDim : context.jp.textFaint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
TextButton(
onPressed: supported ? _onPickProjectDir : null,
child: text(
hasPath ? 'Change' : 'Choose',
13,
TextType.Bold,
color: colorPrimary,
),
),
],
),
),
const SizedBox(height: 6),
text(
supported
? (_isWeb
? 'Builds ${_service.framework.label}, copies the output, '
'then asks you to deploy. Path is saved.'
: 'Auto-detects Maven / Gradle, builds, then asks you to '
'deploy. Path is saved.')
: 'Local builds run on desktop only.',
12,
TextType.Regular,
color: context.jp.textFaint,
),
const SizedBox(height: 12),
if (building)
Row(
children: [
Expanded(
child: FilledButton(
onPressed: null,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_spinner(),
const SizedBox(width: 8),
text('Building…', 14, TextType.Bold, color: colorWhite),
],
),
),
),
const SizedBox(width: 10),
_cancelButton(),
],
)
else
FilledButton.icon(
onPressed: !supported || !hasPath ? null : _onBuild,
icon: const Icon(Icons.build),
label: text('Run build', 14, TextType.Bold, color: colorWhite),
),
],
),
);
}
Widget _readyPanel() {
final ok = _succeeded && _phase == DeployPhase.done;
final accent = PeachTheme.seed;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.jp.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: accent.withValues(alpha: ok ? 0.7 : 0.5)),
boxShadow: [
...PeachTheme.softShadow(Theme.of(context).brightness),
...PeachTheme.glow(accent, opacity: ok ? 0.26 : 0.14, blur: 30),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
ok
? Icons.check_circle
: (_isWeb ? Icons.language : Icons.inventory_2),
color: accent,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: text(
_artifactName,
15,
TextType.Bold,
color: context.jp.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (ok) ...[const SizedBox(width: 8), _deployedBadge()],
],
),
const SizedBox(height: 2),
text(
'${ok ? "Deployed" : (_source.isEmpty ? "Ready" : _source)}'
'${_jarBytes != null ? " · ${formatBytes(_jarBytes!.length)}" : ""}'
' → $_resolvedTarget',
12,
TextType.Regular,
color: context.jp.textDim,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
TextButton.icon(
onPressed: _busy ? null : _onClearStaged,
icon: Icon(ok ? Icons.add : Icons.close, size: 16),
label: text(
ok ? 'New' : 'Change',
13,
TextType.Bold,
color: colorPrimary,
),
),
],
),
const SizedBox(height: 14),
if (_busy)
_busyActions()
else if (ok)
_successActions()
else
_stagedActions(),
],
),
);
}
Widget _deployedBadge() => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: PeachTheme.seed.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check, size: 11, color: PeachTheme.seed),
const SizedBox(width: 3),
text(
'DEPLOYED',
10,
TextType.Bold,
color: PeachTheme.seed,
letterSpacing: 0.6,
),
],
),
);
Widget _busyActions() {
final label = switch (_phase) {
DeployPhase.packaging => 'Packaging…',
DeployPhase.deploying => 'Deploying…',
_ => 'Reverting…',
};
return Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: null,
icon: _spinner(),
label: text(label, 14, TextType.Bold, color: colorWhite),
),
),
const SizedBox(width: 10),
_cancelButton(),
],
);
}
Widget _successActions() {
return Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _onDeploy,
icon: const Icon(Icons.refresh, size: 18),
label: text(
_isWeb ? 'Redeploy site' : 'Redeploy',
14,
TextType.Bold,
color: context.jp.textDim,
),
style: OutlinedButton.styleFrom(
foregroundColor: context.jp.textDim,
side: BorderSide(color: context.jp.border),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
),
const SizedBox(width: 10),
_tailButton(),
],
);
}
Widget _stagedActions() {
return Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _onDeploy,
icon: const Icon(Icons.rocket_launch),
label: text(
_isWeb
? 'Proceed to publish to nginx'
: 'Proceed to deploy & restart',
14,
TextType.Bold,
color: colorWhite,
),
),
),
const SizedBox(width: 10),
_tailButton(),
],
);
}
Widget _tailButton() => OutlinedButton.icon(
onPressed: _onToggleFollow,
icon: Icon(_following ? Icons.stop_circle : Icons.stream, size: 18),
label: text(
_following ? 'Stop tail' : 'Live tail',
13,
TextType.Bold,
color: _following ? PeachTheme.seed : context.jp.textDim,
),
style: OutlinedButton.styleFrom(
foregroundColor: _following ? PeachTheme.seed : context.jp.textDim,
side: BorderSide(color: _following ? PeachTheme.seed : context.jp.border),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
Widget _cancelButton() => OutlinedButton.icon(
onPressed: _onCancel,
icon: const Icon(Icons.cancel, size: 18),
label: text('Cancel', 13, TextType.Bold, color: PeachTheme.danger),
style: OutlinedButton.styleFrom(
foregroundColor: PeachTheme.danger,
side: BorderSide(color: PeachTheme.danger.withValues(alpha: 0.7)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
Widget _resultBanner(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),
),
if (result.canRevert && _phase != DeployPhase.reverting) ...[
TextButton(
onPressed: _onDiscardBackup,
child: text(
'Keep new',
13,
TextType.Bold,
color: context.jp.textDim,
),
),
const SizedBox(width: 4),
FilledButton.icon(
onPressed: _onRevert,
icon: const Icon(Icons.undo, size: 16),
label: text('Revert', 13, TextType.Bold, color: colorWhite),
style: FilledButton.styleFrom(
backgroundColor: color,
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
),
),
],
if (_phase == DeployPhase.reverting)
Padding(
padding: const EdgeInsets.only(right: 8),
child: _spinner(),
),
],
),
);
}
Widget _spinner() => SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: colorWhite),
);
}