import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:stacked/stacked.dart'; import '../../about/external/initial/SystemdSpec.dart'; import '../../about/internal/application/ServerProfile.dart'; import '../../about/internal/application/StepTracker.dart'; import '../../about/internal/application/TextType.dart'; import '../../comms/Comms.dart' show DeployResult, LogLine; import '../../designs/LogConsole.dart'; import '../../designs/StepList.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import 'ConnectSystemd.dart'; import 'Systemd.dart'; import 'ViewSystemd.dart'; class SystemdState extends State implements ConnectSystemd { static const _dependencyOptions = { 'mariadb.service': 'MariaDB', 'mysql.service': 'MySQL', 'postgresql.service': 'PostgreSQL', 'docker.service': 'Docker', 'redis.service': 'Redis', 'nginx.service': 'nginx', }; ViewSystemd? _model; StreamSubscription? _logSub; StreamSubscription>? _stepSub; final _lines = []; List _steps = []; bool _applying = false; DeployResult? _result; late final TextEditingController _name; late final TextEditingController _description; late final TextEditingController _user; late final TextEditingController _workDir; late final TextEditingController _exec; late final TextEditingController _restartSec; late final TextEditingController _environment; SystemdType _type = SystemdType.simple; RestartPolicy _restart = RestartPolicy.onFailure; final Set _after = {}; bool _javaExit = false; bool _enableOnBoot = true; bool _startNow = true; ServerProfile get _server => widget.server; @override void initState() { super.initState(); final service = widget.service; final isJar = service != null && !service.isWeb; final jarPath = service == null ? '/opt/app/app.jar' : service.remotePath( service.remoteJarName.trim().isEmpty ? 'app.jar' : service.remoteJarName, ); _name = TextEditingController( text: service?.serviceName.trim().isNotEmpty == true ? service!.serviceName.trim() : (service?.name ?? 'myapp'), ); _description = TextEditingController( text: 'Peach · ${service?.name ?? 'service'}', ); _user = TextEditingController(text: _server.username); _workDir = TextEditingController(text: service?.remoteRoot ?? '/opt/app'); _exec = TextEditingController( text: isJar ? '/usr/bin/java -jar $jarPath' : '', ); _restartSec = TextEditingController(text: '5'); _environment = TextEditingController(); _javaExit = isJar; } @override void dispose() { _logSub?.cancel(); _stepSub?.cancel(); for (final c in [ _name, _description, _user, _workDir, _exec, _restartSec, _environment, ]) { c.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewSystemd(context, this), onViewModelReady: _onReady, builder: (context, viewModel, child) => Scaffold( appBar: AppBar( title: text( 'Register systemd service', 20, TextType.Bold, color: context.jp.text, ), ), body: ListView( padding: const EdgeInsets.fromLTRB(20, 18, 20, 40), children: [ _sectionTitle('1 · Unit', Icons.badge_outlined), _field('Unit name', _name, hint: 'orders → orders.service'), _field('Description', _description), Row( children: [ Expanded(child: _typeDropdown()), const SizedBox(width: 12), Expanded(child: _restartDropdown()), ], ), const SizedBox(height: 12), _sectionTitle('2 · Process', Icons.terminal), _field('User', _user), _field('Working directory', _workDir, hint: '/opt/app'), _field( 'ExecStart', _exec, hint: '/usr/bin/java -jar /opt/app/app.jar', mono: true, ), Row( children: [ SizedBox( width: 130, child: _field( 'RestartSec', _restartSec, keyboard: TextInputType.number, ), ), const SizedBox(width: 12), Expanded( child: SwitchListTile( contentPadding: EdgeInsets.zero, dense: true, value: _javaExit, onChanged: _onJavaExitChanged, title: text( 'JVM SuccessExitStatus=143', 14, TextType.Regular, color: context.jp.text, ), ), ), ], ), _field( 'Environment (KEY=VALUE per line)', _environment, hint: 'SPRING_PROFILES_ACTIVE=prod\nJAVA_OPTS=-Xmx512m', mono: true, lines: 3, ), const SizedBox(height: 12), _sectionTitle('3 · Dependencies', Icons.account_tree), text( 'Starts after network.target', 12, TextType.Regular, color: context.jp.textFaint, ), const SizedBox(height: 8), _dependencyChips(), const SizedBox(height: 12), SwitchListTile( contentPadding: EdgeInsets.zero, value: _enableOnBoot, onChanged: _onEnableChanged, title: text( 'Enable on boot', 14, TextType.Regular, color: context.jp.text, ), subtitle: text( 'systemctl enable', 12, TextType.Light, color: context.jp.textDim, ), ), SwitchListTile( contentPadding: EdgeInsets.zero, value: _startNow, onChanged: _onStartNowChanged, title: text( 'Start now', 14, TextType.Regular, color: context.jp.text, ), subtitle: text( 'systemctl restart after install', 12, TextType.Light, color: context.jp.textDim, ), ), const SizedBox(height: 16), _sectionTitle('4 · Preview & confirm', Icons.rule), _previewCard(_spec()), const SizedBox(height: 10), _plannedCommands(_spec()), const SizedBox(height: 14), FilledButton.icon( onPressed: _applying ? null : _onApply, icon: _applying ? SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: colorWhite, ), ) : const Icon(Icons.check_circle), label: text( _applying ? 'Registering…' : 'Install & register unit', 14, TextType.Bold, color: colorWhite, ), ), if (_steps.isNotEmpty) ...[ const SizedBox(height: 16), StepList(steps: _steps), ], if (_result != null) ...[ const SizedBox(height: 14), _resultBanner(_result!), ], if (_lines.isNotEmpty) ...[ const SizedBox(height: 14), LogConsole( logs: _lines, height: 260, onClear: _onClearLogs, onCopy: _onCopyLogs, ), ], ], ), ), ); } void _onReady(ViewSystemd 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 { _logSub = _model?.logs.listen(_onLogLine); _stepSub = _model?.steps.listen(_onSteps); } void _onLogLine(LogLine line) { if (!mounted) return; setState(() => _lines.add(line)); } void _onSteps(List steps) { if (!mounted) return; setState(() => _steps = steps); } SystemdSpec _spec() => SystemdSpec( unitName: _name.text, description: _description.text, type: _type, user: _user.text, workingDirectory: _workDir.text, execStart: _exec.text, restart: _restart, restartSec: int.tryParse(_restartSec.text) ?? 5, environmentLines: _environment.text, afterUnits: ['network.target', ..._after], javaSuccessExit: _javaExit, enableOnBoot: _enableOnBoot, startNow: _startNow, ); // ── named handlers ───────────────────────────────────────────────────────── void _onApply() { final spec = _spec(); if (spec.safeName.isEmpty) { _notify('Enter a unit name.'); return; } if (spec.execStart.trim().isEmpty) { _notify('Enter an ExecStart command.'); return; } setState(() { _applying = true; _lines.clear(); _result = null; }); _model?.installUnit(_server, spec, widget.service); } void _onTypeChanged(SystemdType? v) => setState(() => _type = v ?? SystemdType.simple); void _onRestartChanged(RestartPolicy? v) => setState(() => _restart = v ?? RestartPolicy.onFailure); void _onJavaExitChanged(bool v) => setState(() => _javaExit = v); void _onEnableChanged(bool v) => setState(() => _enableOnBoot = v); void _onStartNowChanged(bool v) => setState(() => _startNow = v); void _onDependencyChanged(String unit, bool selected) { setState(() { if (selected) { _after.add(unit); } else { _after.remove(unit); } }); } /// The preview re-renders from the live field values, so every keystroke /// has to repaint. void _onFieldChanged(String _) => setState(() {}); void _onCopyUnit(SystemdSpec spec) { Clipboard.setData(ClipboardData(text: spec.render())); _notify('Unit file copied.'); } 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))); } // ── ConnectSystemd ───────────────────────────────────────────────────────── @override void onUnitInstalled(DeployResult result) { if (!mounted) return; setState(() { _applying = false; _result = result; }); } // ── screen-only widgets ──────────────────────────────────────────────────── Widget _sectionTitle(String title, IconData icon) => Padding( padding: const EdgeInsets.only(top: 10, bottom: 10), child: Row( children: [ Icon(icon, size: 16, color: PeachTheme.seed), const SizedBox(width: 8), text( title.toUpperCase(), 12.5, TextType.Bold, color: context.jp.text, letterSpacing: 0.8, ), ], ), ); Widget _field( String label, TextEditingController controller, { String? hint, bool mono = false, int lines = 1, TextInputType? keyboard, }) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: TextField( controller: controller, maxLines: lines, keyboardType: keyboard, onChanged: _onFieldChanged, style: mono ? const TextStyle(fontFamily: 'monospace', fontSize: 13) : null, decoration: InputDecoration(labelText: label, hintText: hint), ), ); } Widget _typeDropdown() => DropdownButtonFormField( initialValue: _type, decoration: const InputDecoration(labelText: 'Type'), items: SystemdType.values .map((t) => DropdownMenuItem(value: t, child: Text(t.name))) .toList(), onChanged: _onTypeChanged, ); Widget _restartDropdown() => DropdownButtonFormField( initialValue: _restart, decoration: const InputDecoration(labelText: 'Restart'), items: RestartPolicy.values .map((r) => DropdownMenuItem(value: r, child: Text(r.label))) .toList(), onChanged: _onRestartChanged, ); Widget _dependencyChips() { final jp = context.jp; return Wrap( spacing: 8, runSpacing: 8, children: _dependencyOptions.entries.map((entry) { final on = _after.contains(entry.key); return FilterChip( label: Text(entry.value), selected: on, onSelected: (s) => _onDependencyChanged(entry.key, s), showCheckmark: false, selectedColor: PeachTheme.seed.withValues(alpha: 0.20), backgroundColor: jp.surface2, side: BorderSide(color: on ? PeachTheme.seed : jp.border), labelStyle: TextStyle( color: on ? PeachTheme.seed : jp.textDim, fontWeight: FontWeight.w600, ), ); }).toList(), ); } Widget _previewCard(SystemdSpec spec) { final jp = context.jp; return Container( width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: jp.consoleBg, borderRadius: BorderRadius.circular(14), border: Border.all(color: ConsoleColors.frame), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.description, size: 15, color: PeachTheme.seed), const SizedBox(width: 8), Expanded( child: Text( '/etc/systemd/system/${spec.safeName}.service', style: const TextStyle( fontFamily: 'monospace', fontSize: 12, color: ConsoleColors.header, ), ), ), IconButton( tooltip: 'Copy', iconSize: 15, color: ConsoleColors.header, onPressed: () => _onCopyUnit(spec), icon: const Icon(Icons.copy_all), ), ], ), const Divider(color: ConsoleColors.frame), SelectableText( spec.render(), style: const TextStyle( fontFamily: 'monospace', fontSize: 12.5, height: 1.4, color: ConsoleColors.body, ), ), ], ), ); } Widget _plannedCommands(SystemdSpec spec) { final jp = context.jp; final commands = spec.plannedCommands(sudo: _server.useSudo); return Container( padding: const EdgeInsets.all(14), decoration: PeachTheme.panel(context, radius: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ text( 'THESE COMMANDS WILL RUN', 11, TextType.Bold, color: jp.textDim, letterSpacing: 0.8, ), const SizedBox(height: 8), for (final command in commands) Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ Icon(Icons.chevron_right, size: 15, color: jp.textFaint), const SizedBox(width: 4), Expanded( child: text( command, 12.5, TextType.Regular, color: jp.textDim, ), ), ], ), ), ], ), ); } 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), ), ], ), ); } }