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 implements ConnectBatchDeploy { ViewBatchDeploy? _model; final _subscriptions = []; final _lines = []; List _steps = []; final Set _selected = {}; final Map _status = {}; final Map _detail = {}; late List _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.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 _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 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 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, ), ), ), ], ], ), ), ); } }