1031 lines
31 KiB
Dart
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),
|
|
);
|
|
}
|