Files

230 lines
8.2 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/app_router.dart';
import '../../../core/time/server_time.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/screen_header.dart';
import '../data/booking_repository.dart';
import '../domain/booking.dart';
/// Écran listant les réservations avec annulation et modification.
class BookingsScreen extends ConsumerWidget {
const BookingsScreen({super.key});
Future<void> _cancelBooking(BuildContext context, WidgetRef ref, Booking booking) async {
final slotLabel = booking.slotStart != null
? ServerTime.format(booking.slotStart, pattern: 'EEEE dd MMM à HH:mm')
: 'ce créneau';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Annuler la réservation ?'),
content: Text(
'Le créneau du $slotLabel sera libéré.\n\n'
'Annulation gratuite plus de 2 h avant le créneau, sinon des frais peuvent s\'appliquer.',
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Non')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('Oui, annuler'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
try {
await ref.read(bookingRepositoryProvider).cancelBooking(booking.uuid);
ref.invalidate(bookingsProvider);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Réservation annulée'),
backgroundColor: AppColors.success,
),
);
}
} on BookingException catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message), backgroundColor: AppColors.error),
);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final bookingsAsync = ref.watch(bookingsProvider);
return bookingsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => EmptyState(
icon: Icons.event_busy_outlined,
title: 'Erreur',
subtitle: 'Impossible de charger vos réservations.',
actionLabel: 'Réessayer',
onAction: () => ref.invalidate(bookingsProvider),
),
data: (bookings) {
if (bookings.isEmpty) {
return EmptyState(
icon: Icons.event_outlined,
title: 'Aucune réservation',
subtitle: 'Réservez un créneau jusqu\'à 6 jours\nà l\'avance depuis une laverie.',
actionLabel: 'Voir les laveries',
onAction: () => context.go(AppRoutes.home),
);
}
final upcoming = bookings
.where((b) => b.status != 'cancelled' && b.status != 'completed' && b.status != 'no_show')
.toList();
final past = bookings
.where((b) => b.status == 'cancelled' || b.status == 'completed' || b.status == 'no_show')
.toList();
return RefreshIndicator(
onRefresh: () async => ref.invalidate(bookingsProvider),
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 88),
children: [
const ScreenHeader(
title: 'Mes créneaux',
subtitle: '30 min pour démarrer après notification',
),
if (upcoming.isNotEmpty) ...[
Text('À venir', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...upcoming.map(
(b) => _BookingCard(
booking: b,
highlight: true,
onModify: b.canModify
? () => context.push(AppRoutes.bookingModify(b.uuid))
: null,
onCancel: b.canCancel ? () => _cancelBooking(context, ref, b) : null,
),
),
const SizedBox(height: 16),
],
if (past.isNotEmpty) ...[
Text('Passées', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...past.map((b) => _BookingCard(booking: b, highlight: false)),
],
],
),
);
},
);
}
}
class _BookingCard extends StatelessWidget {
const _BookingCard({
required this.booking,
required this.highlight,
this.onModify,
this.onCancel,
});
final Booking booking;
final bool highlight;
final VoidCallback? onModify;
final VoidCallback? onCancel;
@override
Widget build(BuildContext context) {
final slotText = booking.slotStart != null
? ServerTime.format(booking.slotStart, pattern: 'EEEE dd MMM · HH:mm')
: 'Créneau à confirmer';
return Card(
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: highlight
? BorderSide(color: AppColors.machineReserved.withValues(alpha: 0.4))
: BorderSide.none,
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: (highlight ? AppColors.machineReserved : AppColors.textSecondary)
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.event,
color: highlight ? AppColors.machineReserved : AppColors.textSecondary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(booking.machineName, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 2),
Text(slotText, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
StatusBadge.fromStatus(booking.status),
],
),
if (onModify != null || onCancel != null) ...[
const SizedBox(height: 12),
Row(
children: [
if (onModify != null)
Expanded(
child: OutlinedButton.icon(
onPressed: onModify,
icon: const Icon(Icons.edit_calendar_outlined, size: 18),
label: const Text('Modifier'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(vertical: 10),
),
),
),
if (onModify != null && onCancel != null) const SizedBox(width: 8),
if (onCancel != null)
Expanded(
child: OutlinedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.cancel_outlined, size: 18),
label: const Text('Annuler'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error,
padding: const EdgeInsets.symmetric(vertical: 10),
),
),
),
],
),
],
],
),
),
);
}
}