Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
549
lib/Peach/see/deploy/batch/BatchDeployState.dart
Normal file
549
lib/Peach/see/deploy/batch/BatchDeployState.dart
Normal file
@@ -0,0 +1,549 @@
|
||||
import 'dart:async';
|
||||
|
||||
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 LogLine;
|
||||
import '../../../designs/LogConsole.dart';
|
||||
import '../../../designs/StepList.dart';
|
||||
import '../../../designs/text/Text.dart';
|
||||
import '../../../utils/Colors.dart';
|
||||
import 'BatchDeploy.dart';
|
||||
import 'ConnectBatchDeploy.dart';
|
||||
import 'ViewBatchDeploy.dart';
|
||||
|
||||
enum BatchMode { buildDeploy, buildOnly }
|
||||
|
||||
class BatchDeployState extends State<BatchDeploy>
|
||||
implements ConnectBatchDeploy {
|
||||
ViewBatchDeploy? _model;
|
||||
|
||||
final _subscriptions = <StreamSubscription>[];
|
||||
final _lines = <LogLine>[];
|
||||
List<DeployStep> _steps = [];
|
||||
|
||||
final Set<String> _selected = {};
|
||||
final Map<String, BatchState> _status = {};
|
||||
final Map<String, String> _detail = {};
|
||||
|
||||
late List<ServiceConfig> _ordered;
|
||||
BatchMode _mode = BatchMode.buildDeploy;
|
||||
bool _running = false;
|
||||
|
||||
ServerProfile get _server => widget.server;
|
||||
|
||||
/// A service can join a batch only if Peach knows how to build it.
|
||||
bool _eligible(ServiceConfig s) => s.canBuild;
|
||||
|
||||
int get _eligibleCount => _ordered.where(_eligible).length;
|
||||
|
||||
int get _doneCount =>
|
||||
_status.values.where((s) => s == BatchState.done).length;
|
||||
|
||||
int get _failCount =>
|
||||
_status.values.where((s) => s == BatchState.failed).length;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ordered = _server.orderedServices;
|
||||
for (final service in _ordered) {
|
||||
if (_eligible(service)) _selected.add(service.id);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final s in _subscriptions) {
|
||||
s.cancel();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewBatchDeploy>.reactive(
|
||||
viewModelBuilder: () => ViewBatchDeploy(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
'Build & deploy · ${_server.name}',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_header(),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
children: [
|
||||
_reorderHint(),
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
onReorderItem: _onReorder,
|
||||
itemCount: _ordered.length,
|
||||
itemBuilder: (context, i) => Padding(
|
||||
key: ValueKey(_ordered[i].id),
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _serviceTile(_ordered[i], i),
|
||||
),
|
||||
),
|
||||
if (_steps.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
StepList(steps: _steps),
|
||||
],
|
||||
if (_lines.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
LogConsole(
|
||||
logs: _lines,
|
||||
height: 300,
|
||||
onClear: _onClearLogs,
|
||||
onCopy: _onCopyLogs,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
_bottomBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewBatchDeploy 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),
|
||||
]);
|
||||
}
|
||||
|
||||
void _onLogLine(LogLine line) {
|
||||
if (!mounted) return;
|
||||
setState(() => _lines.add(line));
|
||||
}
|
||||
|
||||
void _onSteps(List<DeployStep> steps) {
|
||||
if (!mounted) return;
|
||||
setState(() => _steps = steps);
|
||||
}
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// [newIndex] already accounts for the removed item — `onReorderItem` does
|
||||
/// that adjustment for us, unlike the older `onReorder`.
|
||||
void _onReorder(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
final moved = _ordered.removeAt(oldIndex);
|
||||
_ordered.insert(newIndex, moved);
|
||||
});
|
||||
_model?.saveDeployOrder(_server, _ordered.map((s) => s.id).toList());
|
||||
}
|
||||
|
||||
void _onRun() {
|
||||
final batch = _ordered.where((s) => _selected.contains(s.id)).toList();
|
||||
if (batch.isEmpty) {
|
||||
_notify('Select at least one service.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_running = true;
|
||||
_lines.clear();
|
||||
});
|
||||
_model?.runBatch(
|
||||
_server,
|
||||
batch,
|
||||
deployAfterBuild: _mode == BatchMode.buildDeploy,
|
||||
);
|
||||
}
|
||||
|
||||
void _onStop() => _model?.requestCancel();
|
||||
|
||||
void _onSelectMode(BatchMode mode) => setState(() => _mode = mode);
|
||||
|
||||
void _onToggleService(ServiceConfig service, bool? selected) {
|
||||
setState(() {
|
||||
if (selected == true) {
|
||||
_selected.add(service.id);
|
||||
} else {
|
||||
_selected.remove(service.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onToggleAll() {
|
||||
final eligible = _ordered.where(_eligible).map((s) => s.id).toSet();
|
||||
setState(() {
|
||||
if (_selected.length >= eligible.length) {
|
||||
_selected.clear();
|
||||
} else {
|
||||
_selected
|
||||
..clear()
|
||||
..addAll(eligible);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
// ── ConnectBatchDeploy ─────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onBatchStarted(List<String> serviceIds) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
for (final id in serviceIds) {
|
||||
_status[id] = BatchState.queued;
|
||||
_detail.remove(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onServiceState(String serviceId, BatchState state, {String? detail}) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_status[serviceId] = state;
|
||||
if (detail != null) _detail[serviceId] = detail;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onBatchFinished() {
|
||||
if (!mounted) return;
|
||||
setState(() => _running = false);
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _header() {
|
||||
final jp = context.jp;
|
||||
final allSelected =
|
||||
_selected.length >= _eligibleCount && _eligibleCount > 0;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface,
|
||||
border: Border(bottom: BorderSide(color: jp.border)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
text('Mode', 13, TextType.Bold, color: jp.textDim),
|
||||
const Spacer(),
|
||||
if (_running || _doneCount + _failCount > 0)
|
||||
text(
|
||||
'${_doneCount + _failCount}/${_selected.length} · '
|
||||
'$_doneCount ok · $_failCount failed',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_modeChip(
|
||||
BatchMode.buildDeploy,
|
||||
'Build & deploy',
|
||||
Icons.rocket_launch,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_modeChip(BatchMode.buildOnly, 'Build only', Icons.build),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: _running ? null : _onToggleAll,
|
||||
icon: Icon(
|
||||
allSelected ? Icons.remove_done : Icons.done_all,
|
||||
size: 16,
|
||||
),
|
||||
label: text(
|
||||
allSelected ? 'Clear' : 'Select all',
|
||||
13,
|
||||
TextType.Bold,
|
||||
color: colorPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _reorderHint() => Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8, top: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.drag_indicator, size: 15, color: context.jp.textFaint),
|
||||
const SizedBox(width: 4),
|
||||
text(
|
||||
'Drag to set deployment order — saved automatically',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: context.jp.textFaint,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _modeChip(BatchMode mode, String label, IconData icon) {
|
||||
final jp = context.jp;
|
||||
final active = _mode == mode;
|
||||
return GestureDetector(
|
||||
onTap: _running ? null : () => _onSelectMode(mode),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? PeachTheme.seed.withValues(alpha: 0.14) : jp.surface2,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: active ? PeachTheme.seed : jp.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 15, color: active ? PeachTheme.seed : jp.textDim),
|
||||
const SizedBox(width: 6),
|
||||
text(
|
||||
label,
|
||||
12.5,
|
||||
TextType.Bold,
|
||||
color: active ? PeachTheme.seed : jp.textDim,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _serviceTile(ServiceConfig service, int index) {
|
||||
final jp = context.jp;
|
||||
final eligible = _eligible(service);
|
||||
final selected = _selected.contains(service.id);
|
||||
final state = _status[service.id];
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 12, 12),
|
||||
decoration: PeachTheme.panel(context, radius: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: PeachTheme.seed.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: text(
|
||||
'${index + 1}',
|
||||
12,
|
||||
TextType.Bold,
|
||||
color: PeachTheme.seed,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
if (eligible)
|
||||
Checkbox(
|
||||
value: selected,
|
||||
onChanged: _running ? null : (v) => _onToggleService(service, v),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 40, child: Icon(Icons.block, size: 18)),
|
||||
Icon(
|
||||
service.isWeb
|
||||
? Icons.language
|
||||
: (service.isGeneric ? Icons.terminal : Icons.settings_suggest),
|
||||
size: 18,
|
||||
color: jp.textDim,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
service.name,
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
text(
|
||||
eligible
|
||||
? (_detail[service.id] ??
|
||||
'${service.kind.label} · builds from source')
|
||||
: 'Add a source path to include',
|
||||
11.5,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_statusPill(state),
|
||||
if (!_running) ...[
|
||||
const SizedBox(width: 4),
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Icon(Icons.drag_handle, size: 20, color: jp.textFaint),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statusPill(BatchState? state) {
|
||||
if (state == null) return const SizedBox.shrink();
|
||||
final jp = context.jp;
|
||||
final (color, label, spinning) = switch (state) {
|
||||
BatchState.queued => (jp.textFaint, 'QUEUED', false),
|
||||
BatchState.building => (PeachTheme.warn, 'BUILDING', true),
|
||||
BatchState.deploying => (PeachTheme.accent, 'DEPLOYING', true),
|
||||
BatchState.done => (PeachTheme.seed, 'DONE', false),
|
||||
BatchState.failed => (PeachTheme.danger, 'FAILED', false),
|
||||
BatchState.skipped => (jp.textFaint, 'SKIPPED', false),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (spinning)
|
||||
SizedBox(
|
||||
width: 11,
|
||||
height: 11,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: color),
|
||||
)
|
||||
else
|
||||
Icon(
|
||||
state == BatchState.done
|
||||
? Icons.check
|
||||
: state == BatchState.failed
|
||||
? Icons.close
|
||||
: Icons.circle,
|
||||
size: 11,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
text(label, 10, TextType.Bold, color: color),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bottomBar() {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _running || _selected.isEmpty ? null : _onRun,
|
||||
icon: _running
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorWhite,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_mode == BatchMode.buildOnly
|
||||
? Icons.build
|
||||
: Icons.rocket_launch,
|
||||
),
|
||||
label: text(
|
||||
_running
|
||||
? 'Running…'
|
||||
: (_mode == BatchMode.buildOnly
|
||||
? 'Build ${_selected.length} selected'
|
||||
: 'Build & deploy ${_selected.length} selected'),
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: colorWhite,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_running) ...[
|
||||
const SizedBox(width: 10),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _onStop,
|
||||
icon: const Icon(Icons.cancel, size: 18),
|
||||
label: text(
|
||||
'Stop',
|
||||
13,
|
||||
TextType.Bold,
|
||||
color: PeachTheme.danger,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: PeachTheme.danger,
|
||||
side: BorderSide(
|
||||
color: PeachTheme.danger.withValues(alpha: 0.6),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user