import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; import '../../about/internal/application/ServiceConfig.dart'; import '../../about/internal/application/TextType.dart'; import '../../designs/input/InputFields.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import '../../utils/Validators.dart'; import 'ConnectServiceEditor.dart'; import 'ServiceEditor.dart'; import 'ViewServiceEditor.dart'; class ServiceEditorState extends State implements ConnectServiceEditor { ViewServiceEditor? _model; final _formKey = GlobalKey(); late final ServiceConfig _svc; late final TextEditingController _projectPath; @override void initState() { super.initState(); _svc = widget.service?.copy() ?? ServiceConfig(); _projectPath = TextEditingController(text: _svc.localProjectPath); } @override void dispose() { _projectPath.dispose(); super.dispose(); } bool get _isNew => widget.service == null; bool get _isWeb => _svc.kind == ServiceKind.webApp; bool get _isGeneric => _svc.kind == ServiceKind.generic; @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewServiceEditor(context, this), onViewModelReady: _onReady, builder: (context, viewModel, child) => Scaffold( appBar: AppBar( title: text( _isNew ? 'New service' : 'Edit service', 20, TextType.Bold, color: context.jp.text, ), actions: [ Padding( padding: const EdgeInsets.only(right: 12), child: FilledButton.icon( onPressed: _onSave, icon: const Icon(Icons.check, size: 18), label: text('Save', 14, TextType.Bold, color: colorWhite), ), ), ], ), body: Form( key: _formKey, child: ListView( padding: const EdgeInsets.fromLTRB(20, 20, 20, 40), children: [ inputField( label: 'Service label', hint: _isWeb ? 'marketing-site' : 'orders-api', initial: _svc.name, onSaved: _onSaveName, validator: requiredField, ), inputSection(context, 'Deployment type'), _kindSelector(), if (_isWeb) ..._webFields() else if (_isGeneric) ..._genericFields() else ..._jarFields(), inputSection(context, 'Build from source (optional)'), _sectionHint(_buildBlurb(viewModel)), Padding( padding: const EdgeInsets.only(bottom: 14), child: TextFormField( controller: _projectPath, decoration: InputDecoration( labelText: 'Local project path', hintText: r'C:\code\app or /home/me/app', suffixIcon: IconButton( tooltip: 'Choose folder', icon: const Icon(Icons.folder_open), onPressed: _onPickProjectDir, ), ), ), ), inputField( label: 'Build command (optional)', hint: _buildCommandHint(), initial: _svc.buildCommand, onSaved: _onSaveBuildCommand, ), if (_isWeb) inputField( label: 'Build output dir (optional)', hint: 'blank → auto (dist/ · build/ · build/web)', initial: _svc.outputSubdir, onSaved: _onSaveOutputSubdir, ) else inputField( label: _isGeneric ? 'Built artifact path (optional)' : 'Built jar pattern (optional)', hint: _isGeneric ? 'e.g. target/release/myapp (explicit path)' : 'blank → target/*.jar or build/libs/*.jar', initial: _svc.jarPattern, onSaved: _onSaveJarPattern, ), ..._healthSection(), ], ), ), ), ); } void _onReady(ViewServiceEditor 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 {} String _buildBlurb(ViewServiceEditor model) { if (!model.canBuildLocally) return 'Local builds run on desktop only.'; return _isWeb ? 'Point at the local project and Peach builds the site, then ' 'publishes it to nginx.' : 'Point at the local project and Peach builds the jar ' '(Maven/Gradle) before deploying.'; } String _buildCommandHint() { if (_isWeb) { final fallback = _svc.framework.defaultBuild; return 'blank → ${fallback.isEmpty ? "none" : fallback}'; } return _isGeneric ? 'e.g. make build / cargo build --release' : 'blank → mvn -DskipTests package / gradlew build'; } // ── named handlers ───────────────────────────────────────────────────────── void _onSaveName(String v) => _svc.name = v; void _onSaveBuildCommand(String v) => _svc.buildCommand = v; void _onSaveOutputSubdir(String v) => _svc.outputSubdir = v; void _onSaveJarPattern(String v) => _svc.jarPattern = v; void _onSaveRemoteDir(String v) => _svc.remoteDir = v; void _onSaveRemoteJarName(String v) => _svc.remoteJarName = v; void _onSaveServiceName(String v) => _svc.serviceName = v; void _onSaveLogCommand(String v) => _svc.logCommand = v; void _onSaveNginxService(String v) => _svc.nginxService = v.trim().isEmpty ? 'nginx' : v; void _onSaveHealthTarget(String v) => _svc.healthTarget = v; void _onPickProjectDir() => _model?.pickProjectDirectory(); void _onSave() { if (!_formKey.currentState!.validate()) return; _formKey.currentState!.save(); _svc.localProjectPath = _projectPath.text.trim(); Navigator.of(context).pop(_svc); } /// Switching kind moves the remote directory to that kind's default, but /// only when the user hasn't typed their own. void _onSelectKind(ServiceKind kind) { setState(() { _svc.kind = kind; final dir = _svc.remoteDir.trim(); if (kind == ServiceKind.webApp && (dir.isEmpty || dir == '/opt/app')) { _svc.remoteDir = '/var/www/html'; } else if (kind != ServiceKind.webApp && dir == '/var/www/html') { _svc.remoteDir = '/opt/app'; } }); } void _onSelectFramework(WebFramework f) => setState(() => _svc.framework = f); void _onSelectHealthType(HealthCheckType h) => setState(() => _svc.healthType = h); // ── ConnectServiceEditor ─────────────────────────────────────────────────── @override void onProjectDirectoryPicked(String path) { if (!mounted) return; setState(() => _projectPath.text = path); } // ── screen-only widgets ──────────────────────────────────────────────────── List _healthSection() => [ inputSection(context, 'Health check'), _sectionHint( 'How the Health dashboard probes this service. "systemd (auto)" ' 'checks the unit is active.', ), Padding( padding: const EdgeInsets.only(bottom: 12), child: Wrap( spacing: 8, runSpacing: 8, children: HealthCheckType.values .map( (h) => _choice( h.label, _svc.healthType == h, () => _onSelectHealthType(h), ), ) .toList(), ), ), if (_svc.healthType == HealthCheckType.http || _svc.healthType == HealthCheckType.tcp || _svc.healthType == HealthCheckType.command) inputField( label: switch (_svc.healthType) { HealthCheckType.http => 'Health URL', HealthCheckType.tcp => 'Port', _ => 'Health command', }, hint: switch (_svc.healthType) { HealthCheckType.http => 'http://localhost:8080/health', HealthCheckType.tcp => '8080', _ => 'systemctl is-active myapp', }, initial: _svc.healthTarget, onSaved: _onSaveHealthTarget, ), ]; Widget _kindSelector() { return Wrap( spacing: 12, runSpacing: 12, children: [ _kindCard( ServiceKind.jarService, Icons.settings_suggest, 'Jar service', 'systemd + jar', ), _kindCard( ServiceKind.webApp, Icons.language, 'Web app', 'static → nginx', ), _kindCard(ServiceKind.generic, Icons.terminal, 'Generic', 'any binary'), ], ); } Widget _kindCard(ServiceKind kind, IconData icon, String title, String sub) { final jp = context.jp; final active = _svc.kind == kind; return InkWell( borderRadius: BorderRadius.circular(12), onTap: () => _onSelectKind(kind), child: AnimatedContainer( duration: const Duration(milliseconds: 120), width: 150, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: active ? PeachTheme.seed.withValues(alpha: 0.12) : jp.surface2, borderRadius: BorderRadius.circular(12), border: Border.all( color: active ? PeachTheme.seed : jp.border, width: active ? 1.6 : 1, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, color: active ? PeachTheme.seed : jp.textDim), const SizedBox(height: 10), text(title, 14, TextType.Bold, color: jp.text), const SizedBox(height: 2), text(sub, 11.5, TextType.Regular, color: jp.textFaint), ], ), ), ); } Widget _frameworkSelector() { return Padding( padding: const EdgeInsets.only(bottom: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 4, bottom: 8), child: text( 'Framework', 13, TextType.Regular, color: context.jp.textDim, ), ), Wrap( spacing: 8, runSpacing: 8, children: WebFramework.values .map( (f) => _choice( f.label, _svc.framework == f, () => _onSelectFramework(f), ), ) .toList(), ), ], ), ); } Widget _choice(String label, bool active, VoidCallback onSelected) { final jp = context.jp; return ChoiceChip( selected: active, label: Text(label), onSelected: (_) => onSelected(), showCheckmark: false, selectedColor: PeachTheme.seed.withValues(alpha: 0.20), backgroundColor: jp.surface2, side: BorderSide(color: active ? PeachTheme.seed : jp.border), labelStyle: TextStyle( color: active ? PeachTheme.seed : jp.textDim, fontWeight: FontWeight.w600, ), ); } List _jarFields() => [ inputSection(context, 'Jar service'), inputField( label: 'Remote directory', hint: '/opt/orders', initial: _svc.remoteDir, onSaved: _onSaveRemoteDir, validator: validRemotePath, ), inputField( label: 'Jar filename on server (optional)', hint: 'orders.jar — blank keeps the dropped name', initial: _svc.remoteJarName, onSaved: _onSaveRemoteJarName, ), inputField( label: 'systemd unit name (optional)', hint: 'orders → systemctl restart orders', initial: _svc.serviceName, onSaved: _onSaveServiceName, ), inputField( label: 'Log command (optional)', hint: 'blank → journalctl -u -n 200', initial: _svc.logCommand, onSaved: _onSaveLogCommand, ), ]; List _webFields() => [ const SizedBox(height: 6), _frameworkSelector(), inputField( label: 'nginx web root', hint: '/var/www/marketing', initial: _svc.remoteDir, onSaved: _onSaveRemoteDir, validator: validRemotePath, ), inputField( label: 'nginx unit name', hint: 'nginx', initial: _svc.nginxService, onSaved: _onSaveNginxService, ), inputField( label: 'Log command (optional)', hint: 'blank → journalctl -u nginx -n 200', initial: _svc.logCommand, onSaved: _onSaveLogCommand, ), ]; List _genericFields() => [ inputSection(context, 'Service (any binary / script)'), inputField( label: 'Remote directory', hint: '/opt/myapp', initial: _svc.remoteDir, onSaved: _onSaveRemoteDir, validator: validRemotePath, ), inputField( label: 'Target filename on server', hint: 'myapp (the uploaded file is chmod +x)', initial: _svc.remoteJarName, onSaved: _onSaveRemoteJarName, ), inputField( label: 'systemd unit name (optional)', hint: 'myapp → systemctl restart myapp', initial: _svc.serviceName, onSaved: _onSaveServiceName, ), inputField( label: 'Log command (optional)', hint: 'blank → journalctl -u -n 200', initial: _svc.logCommand, onSaved: _onSaveLogCommand, ), ]; Widget _sectionHint(String message) => Padding( padding: const EdgeInsets.only(bottom: 12), child: text(message, 12.5, TextType.Regular, color: context.jp.textFaint), ); }