import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/models/models.dart'; import '../../core/models/severity_mapping.dart'; import '../../core/network/api_exception.dart'; import '../../core/routing/route_paths.dart'; import '../../core/theme/theme_x.dart'; import '../../core/widgets/error_banner.dart'; import '../../core/widgets/slide_over_panel.dart'; import '../../core/widgets/status_pill.dart'; import '../../data/repositories/services_repository.dart'; import 'widgets/service_detail_body.dart'; import 'widgets/service_form_dialog.dart'; import 'widgets/services_table.dart'; class ServicesScreen extends ConsumerStatefulWidget { const ServicesScreen({super.key, this.serviceId}); /// Present when routed via `/services?id=...` — renders the slide-over /// detail panel for this service alongside the still-visible table. final String? serviceId; @override ConsumerState createState() => _ServicesScreenState(); } class _ServicesScreenState extends ConsumerState { final _busyIds = {}; Future _run(String id, Future Function() action, {String? successMessage}) async { setState(() => _busyIds.add(id)); try { await action(); ref.invalidate(servicesListProvider); if (mounted && successMessage != null) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(successMessage))); } } catch (e) { if (mounted) { final message = e is ApiException ? e.userMessage : e.toString(); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } } finally { if (mounted) setState(() => _busyIds.remove(id)); } } void _toggleRunning(Service service) { final repo = ref.read(servicesRepositoryProvider); if (repo == null) return; _run(service.id, () => service.status == 'up' ? repo.stop(service.id) : repo.start(service.id)); } void _backupNow(Service service) { final repo = ref.read(servicesRepositoryProvider); if (repo == null) return; _run(service.id, () => repo.backupNow(service.id), successMessage: 'Backup started'); } Future _delete(Service service) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Delete service?'), content: Text('This removes "${service.name}" from NodeMaster. It does not stop or delete the container.'), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), FilledButton( style: FilledButton.styleFrom(backgroundColor: context.status.critical), onPressed: () => Navigator.of(context).pop(true), child: const Text('Delete'), ), ], ), ); if (confirmed != true) return; final repo = ref.read(servicesRepositoryProvider); if (repo == null) return; await _run(service.id, () => repo.delete(service.id)); if (mounted) context.go(RoutePaths.services); } @override Widget build(BuildContext context) { final servicesAsync = ref.watch(servicesListProvider); return Stack( children: [ Padding( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( servicesAsync.maybeWhen( data: (services) => '${services.length} SERVICES', orElse: () => 'SERVICES', ), style: context.text.titleSmall, ), FilledButton.icon( onPressed: () => showServiceFormDialog(context), icon: const Icon(Icons.add, size: 17), label: const Text('Add service'), ), ], ), const SizedBox(height: 12), Expanded( child: servicesAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, stackTrace) => Align( alignment: Alignment.topCenter, child: ErrorBanner( message: error is ApiException ? error.userMessage : error.toString(), onRetry: () => ref.invalidate(servicesListProvider), ), ), data: (services) => SingleChildScrollView( child: ServicesTable( services: services, busyServiceIds: _busyIds, onRowTap: (service) => context.go(RoutePaths.serviceDetail(service.id)), onToggleRunning: _toggleRunning, ), ), ), ), ], ), ), _buildDetailPanel(servicesAsync), ], ); } Widget _buildDetailPanel(AsyncValue> servicesAsync) { final id = widget.serviceId; final service = servicesAsync.maybeWhen( data: (services) { for (final s in services) { if (s.id == id) return s; } return null; }, orElse: () => null, ); return SlideOverPanel( open: id != null, onClose: () => context.go(RoutePaths.services), title: service?.name ?? '', subtitle: service == null ? null : StatusPill(label: service.status, severity: severityForServiceStatus(service.status)), body: service == null ? const SizedBox.shrink() : ServiceDetailBody(service: service), headerActions: service == null ? const [] : [ IconButton( tooltip: 'Edit', icon: const Icon(Icons.edit_outlined, size: 18), onPressed: () => showServiceFormDialog(context, existing: service), ), ], actions: service == null ? const [] : [ OutlinedButton.icon( onPressed: () => _toggleRunning(service), icon: Icon(service.status == 'up' ? Icons.stop_rounded : Icons.play_arrow_rounded, size: 17), label: Text(service.status == 'up' ? 'Stop' : 'Start'), ), OutlinedButton.icon( onPressed: service.backup == null ? null : () => _backupNow(service), icon: const Icon(Icons.cloud_outlined, size: 17), label: const Text('Backup'), ), OutlinedButton.icon( style: OutlinedButton.styleFrom(foregroundColor: context.status.critical), onPressed: () => _delete(service), icon: const Icon(Icons.delete_outline, size: 17), label: const Text('Delete'), ), ], ); } }