Integration fonctionnalités V1 ( resas + gestion machines
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user