Integration fonctionnalités V1 ( resas + gestion machines

This commit is contained in:
bastien
2026-07-03 19:02:48 +02:00
parent 20f383dd62
commit bf191d6396
46 changed files with 4691 additions and 654 deletions
@@ -1,86 +1,228 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../../../core/router/app_router.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';
/// Fournisseur des réservations de l'utilisateur connecté.
final bookingsProvider = FutureProvider<List<Booking>>((ref) async {
final response = await ref.watch(apiClientProvider).get(ApiEndpoints.bookings);
final data = response.data;
List<dynamic> list;
if (data is List<dynamic>) {
list = data;
} else if (data is Map<String, dynamic> && data['data'] is List<dynamic>) {
list = data['data'] as List<dynamic>;
} else {
list = [];
}
return list
.map((json) => Booking.fromJson(json as Map<String, dynamic>))
.toList();
});
/// Écran listant les réservations de l'utilisateur.
/// É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
? DateFormat('EEEE dd MMM à HH:mm', 'fr_FR').format(booking.slotStart!)
: '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 Scaffold(
appBar: AppBar(title: const Text('Mes réservations')),
body: bookingsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
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: [
Text('Erreur : $error'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => ref.invalidate(bookingsProvider),
child: const Text('Réessayer'),
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)),
],
],
),
),
data: (bookings) {
if (bookings.isEmpty) {
return const Center(
child: Text('Aucune réservation.\nRéservez un créneau depuis une laverie.'),
);
}
);
},
);
}
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(bookingsProvider),
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: bookings.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final booking = bookings[index];
return Card(
child: ListTile(
leading: const Icon(Icons.event),
title: Text(booking.machineName),
subtitle: Text(
booking.slotStart != null
? DateFormat('dd/MM/yyyy HH:mm').format(booking.slotStart!)
: 'Créneau à confirmer',
),
trailing: Chip(label: Text(booking.status)),
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
? DateFormat('EEEE dd MMM · HH:mm', 'fr_FR').format(booking.slotStart!)
: '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),
),
),
),
],
),
],
],
),
),
);
}