import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; import '../../../about/internal/application/DeploymentRecord.dart'; import '../../../about/internal/application/NavigatorType.dart'; import '../../../about/internal/application/NotificationType.dart'; import '../../../about/internal/application/ServerProfile.dart'; import '../../../about/internal/application/ServiceConfig.dart'; import '../../../about/internal/application/TextType.dart'; import '../../../configs/Navigator.dart'; import '../../../designs/text/Text.dart'; import '../../../utils/Colors.dart'; import '../../configuration/Configuration.dart'; import '../../deploy/batch/BatchDeploy.dart'; import '../../deploy/single/Deploy.dart'; import '../../docker/manager/DockerManager.dart'; import '../../files/Files.dart'; import '../../health/Health.dart'; import '../../kafka/Kafka.dart'; import '../../logs/Logs.dart'; import '../../redis/Redis.dart'; import '../../service/ServiceEditor.dart'; import '../../systemd/Systemd.dart'; import '../editor/ServerEditor.dart'; import 'ConnectServerDetail.dart'; import 'ServerDetail.dart'; import 'ViewServerDetail.dart'; class ServerDetailState extends State implements ConnectServerDetail { ViewServerDetail? _model; late ServerProfile _server; List _history = []; @override void initState() { super.initState(); _server = widget.server.copy(); _history = widget.history; } @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewServerDetail(context, this), onViewModelReady: _onReady, builder: (context, viewModel, child) => Scaffold( appBar: AppBar( title: text(_server.name, 20, TextType.Bold, color: context.jp.text), actions: [ IconButton( tooltip: 'Edit server', onPressed: _onEditServer, icon: const Icon(Icons.edit_outlined), ), IconButton( tooltip: 'Delete server', onPressed: _onDeleteServer, icon: const Icon(Icons.delete_outline), ), const SizedBox(width: 4), ], ), floatingActionButton: FloatingActionButton.extended( onPressed: _onAddService, icon: const Icon(Icons.add), label: text('Add service', 14, TextType.Bold, color: colorWhite), ), body: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), children: [ _connectionCard(), const SizedBox(height: 18), _toolsSection(), const SizedBox(height: 20), _sectionHeader(), const SizedBox(height: 10), if (_server.services.isEmpty) _noServices() else LayoutBuilder( builder: (context, c) { const gap = 12.0; final cols = c.maxWidth >= 760 ? 2 : 1; final w = (c.maxWidth - gap * (cols - 1)) / cols; return Wrap( spacing: gap, runSpacing: gap, children: [ for (final svc in _server.services) SizedBox(width: w, child: _serviceCard(svc)), ], ); }, ), ], ), ), ); } void _onReady(ViewServerDetail 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 { _model?.loadServer(_server.id); } // ── named handlers: server ───────────────────────────────────────────────── Future _onEditServer() async { final edited = await PeachNavigation().navigateToPageWithData( ServerEditor(server: _server), context, ); if (edited is! ServerProfile) return; // Preserve the services list on the edited connection. edited.services = _server.services; _model?.saveServer(edited); } void _onDeleteServer() { _model?.showApplicationNotification( NotificationType.warning, 'Delete "${_server.name}"?', 'Removes the server, its services and its stored password.', true, true, null, action: 'Delete server', positiveAction: _onConfirmDeleteServer, ); } void _onConfirmDeleteServer() { Navigator.pop(context); _model?.deleteServer(_server); } // ── named handlers: services ─────────────────────────────────────────────── Future _onAddService() async { final created = await PeachNavigation().navigateToPageWithData( const ServiceEditor(), context, ); if (created is! ServiceConfig) return; _model?.addService(_server, created); } Future _onEditService(ServiceConfig svc) async { final edited = await PeachNavigation().navigateToPageWithData( ServiceEditor(service: svc), context, ); if (edited is! ServiceConfig) return; _model?.updateService(_server, edited); } void _onDeleteService(ServiceConfig svc) => _model?.removeService(_server, svc); Future _onDeploy(ServiceConfig svc) async { await PeachNavigation().navigateToPageWithData( Deploy(server: _server, service: svc), context, ); _model?.loadServer(_server.id); } void _onViewLogs(ServiceConfig svc) { PeachNavigation().navigateToPage( NavigatorType.justOpen, Logs(server: _server, service: svc), context, ); } Future _onRegisterUnit(ServiceConfig? svc) async { await PeachNavigation().navigateToPageWithData( Systemd(server: _server, service: svc), context, ); _model?.loadServer(_server.id); } // ── named handlers: tools ────────────────────────────────────────────────── Future _onOpenBatch() async { await PeachNavigation().navigateToPageWithData( BatchDeploy(server: _server), context, ); _model?.loadServer(_server.id); } void _onOpenConfiguration() => _open(Configuration(server: _server)); void _onOpenHealth() => _open(Health(servers: [_server])); void _onOpenFiles() => _open(Files(server: _server)); void _onOpenDocker() => _open(DockerManager(server: _server)); void _onOpenRedis() => _open(Redis(server: _server)); void _onOpenKafka() => _open(Kafka(server: _server)); void _onNewUnit() => _onRegisterUnit(null); void _open(Widget destination) => PeachNavigation().navigateToPage( NavigatorType.justOpen, destination, context, ); // ── ConnectServerDetail ──────────────────────────────────────────────────── @override void onServerLoaded(ServerProfile server, List history) { if (!mounted) return; setState(() { _server = server; _history = history; }); } @override void onServerDeleted() { if (!mounted) return; Navigator.pop(context, true); } // ── screen-only widgets ──────────────────────────────────────────────────── Widget _connectionCard() { return Container( padding: const EdgeInsets.all(16), decoration: PeachTheme.panel(context, radius: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _line( Icons.dns, '${_server.username}@${_server.host}:${_server.port}', ), const SizedBox(height: 8), _line( _server.useSudo ? Icons.shield : Icons.shield_outlined, _server.useSudo ? 'sudo enabled' : 'no sudo', ), const SizedBox(height: 8), _line(Icons.lock, 'password stored encrypted'), ], ), ); } Widget _line(IconData icon, String label) => Row( children: [ Icon(icon, size: 15, color: PeachTheme.seed.withValues(alpha: 0.9)), const SizedBox(width: 8), Expanded( child: text(label, 13, TextType.Regular, color: context.jp.textDim), ), ], ); Widget _toolsSection() { return LayoutBuilder( builder: (context, c) { const gap = 12.0; final cols = c.maxWidth >= 520 ? 4 : 2; final w = (c.maxWidth - gap * (cols - 1)) / cols; return Wrap( spacing: gap, runSpacing: gap, children: [ _tool( w, Icons.rocket_launch, 'Build & deploy all', 'Batch build + deploy', PeachTheme.seed, _onOpenBatch, ), _tool( w, Icons.fact_check_outlined, 'Configuration', 'Environment & setup', PeachTheme.warn, _onOpenConfiguration, ), _tool( w, Icons.monitor_heart, 'Health', 'Service status', PeachTheme.danger, _onOpenHealth, ), _tool( w, Icons.folder_open, 'Files', 'Browse / transfer', PeachTheme.accent, _onOpenFiles, ), _tool( w, Icons.inventory_2, 'Docker', 'Containers & images', PeachTheme.accent, _onOpenDocker, ), _tool( w, Icons.data_object, 'Redis', 'Browse keys / values', PeachTheme.warn, _onOpenRedis, ), _tool( w, Icons.hub, 'Kafka', 'Topics · groups · lag', PeachTheme.violet, _onOpenKafka, ), _tool( w, Icons.miscellaneous_services, 'New unit', 'systemd wizard', PeachTheme.violet, _onNewUnit, ), ], ); }, ); } Widget _tool( double w, IconData icon, String title, String sub, Color color, VoidCallback onTap, ) { final jp = context.jp; return SizedBox( width: w, child: InkWell( borderRadius: BorderRadius.circular(16), onTap: onTap, child: Container( padding: const EdgeInsets.all(14), decoration: PeachTheme.panel(context, radius: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: color.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(11), border: Border.all(color: color.withValues(alpha: 0.3)), ), child: Icon(icon, color: color, size: 20), ), const SizedBox(height: 12), text( title, 14, TextType.Bold, color: jp.text, maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 1), text( sub, 11.5, TextType.Regular, color: jp.textFaint, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ), ); } Widget _sectionHeader() => Row( children: [ text( 'SERVICES', 12, TextType.Bold, color: context.jp.textDim, letterSpacing: 1.2, ), const SizedBox(width: 8), text( '${_server.services.length}', 12, TextType.Bold, color: PeachTheme.seed, ), ], ); Widget _serviceCard(ServiceConfig svc) { final jp = context.jp; final count = _history.where((h) => h.serviceId == svc.id).length; return Container( padding: const EdgeInsets.all(14), decoration: PeachTheme.panel(context, radius: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 38, height: 38, decoration: BoxDecoration( color: (svc.isWeb ? PeachTheme.accent : PeachTheme.seed) .withValues(alpha: 0.12), borderRadius: BorderRadius.circular(9), ), child: Icon( svc.isWeb ? Icons.language : (svc.isGeneric ? Icons.terminal : Icons.settings_suggest), color: svc.isWeb ? PeachTheme.accent : PeachTheme.seed, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ text( svc.name, 15, TextType.Regular, color: jp.text, maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), text( svc.isWeb ? svc.remoteRoot : svc.remotePath( svc.remoteJarName.isEmpty ? '' : svc.remoteJarName, ), 12, TextType.Regular, color: jp.textDim, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), if (!svc.isWeb) IconButton( onPressed: () => _onRegisterUnit(svc), icon: const Icon( Icons.settings_applications_outlined, size: 20, ), tooltip: 'Register systemd unit', ), IconButton( onPressed: () => _onEditService(svc), icon: const Icon(Icons.edit_outlined, size: 20), tooltip: 'Edit', ), IconButton( onPressed: () => _onDeleteService(svc), icon: const Icon(Icons.delete_outline, size: 20), tooltip: 'Delete', ), ], ), const SizedBox(height: 10), Row( children: [ Expanded( child: Wrap( spacing: 6, runSpacing: 6, children: [ if (svc.isWeb) _tag('◆ ${svc.framework.label}') else if (svc.serviceName.isNotEmpty) _tag('unit: ${svc.serviceName}'), if (svc.canBuild) _tag('⚙ builds from source'), if (count > 0) _tag('$count deploys'), ], ), ), const SizedBox(width: 8), OutlinedButton.icon( onPressed: () => _onViewLogs(svc), icon: const Icon(Icons.article_outlined, size: 18), label: text('Logs', 13, TextType.Bold, color: jp.textDim), style: OutlinedButton.styleFrom( foregroundColor: jp.textDim, side: BorderSide(color: jp.border), padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 12, ), ), ), const SizedBox(width: 8), FilledButton.icon( onPressed: () => _onDeploy(svc), icon: const Icon(Icons.rocket_launch, size: 18), label: text('Deploy', 13, TextType.Bold, color: colorWhite), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), ), ), ], ), ], ), ); } Widget _tag(String label) => Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: context.jp.surface2, borderRadius: BorderRadius.circular(20), ), child: text(label, 11, TextType.Regular, color: context.jp.textDim), ); Widget _noServices() => Container( padding: const EdgeInsets.symmetric(vertical: 30), alignment: Alignment.center, child: Column( children: [ Icon( Icons.settings_suggest, size: 40, color: PeachTheme.seed.withValues(alpha: 0.5), ), const SizedBox(height: 10), text('No services yet', 14, TextType.Bold, color: context.jp.text), const SizedBox(height: 4), text( 'Add a service to deploy a jar to this server.', 13, TextType.Regular, color: context.jp.textDim, ), ], ), ); }