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
@@ -0,0 +1,224 @@
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/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../machines/data/machine_repository.dart';
import '../../machines/domain/machine_detail.dart';
import '../data/booking_repository.dart';
import '../domain/booking.dart';
/// Modification du créneau d'une réservation existante.
class BookingModifyScreen extends ConsumerStatefulWidget {
const BookingModifyScreen({super.key, required this.bookingUuid});
final String bookingUuid;
@override
ConsumerState<BookingModifyScreen> createState() => _BookingModifyScreenState();
}
class _BookingModifyScreenState extends ConsumerState<BookingModifyScreen> {
DateTime? _selectedDate;
TimeSlot? _selectedSlot;
bool _isSaving = false;
Future<void> _confirmMove(Booking booking) async {
final slot = _selectedSlot;
if (slot == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sélectionnez un nouveau créneau')),
);
return;
}
setState(() => _isSaving = true);
try {
await ref.read(bookingRepositoryProvider).moveBooking(
uuid: booking.uuid,
slotStart: slot.start,
slotEnd: slot.end,
);
ref.invalidate(bookingsProvider);
ref.invalidate(bookingDetailProvider(booking.uuid));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Créneau modifié avec succès'),
backgroundColor: AppColors.success,
),
);
context.go(AppRoutes.bookings);
}
} on BookingException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message), backgroundColor: AppColors.error),
);
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
@override
Widget build(BuildContext context) {
final bookingAsync = ref.watch(bookingDetailProvider(widget.bookingUuid));
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(title: const Text('Modifier le créneau')),
body: bookingAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(child: Text('Réservation introuvable')),
data: (booking) {
if (!booking.canModify) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'Cette réservation ne peut plus être modifiée.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
),
);
}
final selectedDate = _selectedDate ??
DateTime(
booking.slotStart!.year,
booking.slotStart!.month,
booking.slotStart!.day,
);
final availabilityAsync = ref.watch(
machineAvailabilityProvider((uuid: booking.machineUuid, date: selectedDate)),
);
return Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(booking.machineName, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Créneau actuel',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 12),
),
Text(
booking.slotStart != null && booking.slotEnd != null
? '${DateFormat('EEEE dd MMM · HH:mm', 'fr_FR').format(booking.slotStart!)} ${DateFormat('HH:mm').format(booking.slotEnd!)}'
: '',
style: const TextStyle(fontWeight: FontWeight.w600),
),
],
),
),
),
const SizedBox(height: 20),
Text('Nouveau jour', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 10),
SizedBox(
height: 44,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: 6,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final date = DateTime.now().add(Duration(days: index));
final normalized = DateTime(date.year, date.month, date.day);
final isSelected = normalized.year == selectedDate.year &&
normalized.month == selectedDate.month &&
normalized.day == selectedDate.day;
return ChoiceChip(
label: Text(DateFormat('EEE dd/MM', 'fr_FR').format(normalized)),
selected: isSelected,
onSelected: (_) => setState(() {
_selectedDate = normalized;
_selectedSlot = null;
}),
selectedColor: AppColors.primary.withValues(alpha: 0.15),
);
},
),
),
const SizedBox(height: 20),
Text('Nouveau créneau', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
availabilityAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Text('Impossible de charger les créneaux.'),
data: (slots) {
if (slots.isEmpty) {
return Text(
'Aucun créneau disponible ce jour.',
style: Theme.of(context).textTheme.bodyMedium,
);
}
return Wrap(
spacing: 8,
runSpacing: 8,
children: slots.map((slot) {
final label =
'${DateFormat('HH:mm').format(slot.start)} ${DateFormat('HH:mm').format(slot.end)}';
final isSelected = _selectedSlot?.start == slot.start;
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (_) => setState(() => _selectedSlot = slot),
selectedColor: AppColors.primary.withValues(alpha: 0.15),
checkmarkColor: AppColors.primary,
);
}).toList(),
);
},
),
],
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _isSaving ? null : () => _confirmMove(booking),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
textStyle: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
icon: _isSaving
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.check_rounded, size: 24),
label: Text(_isSaving ? 'Enregistrement…' : 'Confirmer le changement'),
),
),
),
),
],
);
},
),
);
}
}
@@ -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),
),
),
),
],
),
],
],
),
),
);
}