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 implements ConnectDeploy { ViewDeploy? _model; final _subscriptions = []; final _lines = []; List _steps = []; TransferProgress? _transfer; // Staged artifact — one of these, never both. Uint8List? _jarBytes; String? _jarName; String? _webDir; String _source = ''; late String _projectPath; List? _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 ?? ''); /// 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.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 _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 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 _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 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 _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), ); }