Files
2026-07-27 14:42:23 +02:00

197 lines
7.1 KiB
Dart

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<ServicesScreen> createState() => _ServicesScreenState();
}
class _ServicesScreenState extends ConsumerState<ServicesScreen> {
final _busyIds = <String>{};
Future<void> _run(String id, Future<void> 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<void> _delete(Service service) async {
final confirmed = await showDialog<bool>(
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<List<Service>> 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'),
),
],
);
}
}