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,212 @@
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 '../../../core/theme/machine_status_theme.dart';
import '../../booking/data/booking_repository.dart';
import '../data/machine_repository.dart';
import '../domain/machine_detail.dart';
/// Réservation de créneau pour une machine.
class MachineBookingScreen extends ConsumerStatefulWidget {
const MachineBookingScreen({super.key, required this.machineUuid});
final String machineUuid;
@override
ConsumerState<MachineBookingScreen> createState() => _MachineBookingScreenState();
}
class _MachineBookingScreenState extends ConsumerState<MachineBookingScreen> {
DateTime _selectedDate = DateTime.now();
TimeSlot? _selectedSlot;
bool _isBooking = false;
Future<void> _confirmBooking(MachineDetail detail) async {
final slot = _selectedSlot;
if (slot == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sélectionnez un créneau horaire')),
);
return;
}
setState(() => _isBooking = true);
try {
await ref.read(bookingRepositoryProvider).createBooking(
machineUuid: detail.machine.uuid,
slotStart: slot.start,
slotEnd: slot.end,
);
ref.invalidate(bookingsProvider);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Créneau réservé 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(() => _isBooking = false);
}
}
@override
Widget build(BuildContext context) {
final detailAsync = ref.watch(machineDetailProvider(widget.machineUuid));
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(title: const Text('Réserver un créneau')),
body: detailAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => Center(child: Text('Machine introuvable', style: Theme.of(context).textTheme.titleMedium)),
data: (detail) {
final machine = detail.machine;
final availabilityAsync = ref.watch(
machineAvailabilityProvider((uuid: machine.uuid, date: _selectedDate)),
);
return Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: AppColors.primary.withValues(alpha: 0.1),
child: Icon(
MachineStatusTheme.iconForType(machine.type),
color: AppColors.primary,
),
),
title: Text(machine.name),
subtitle: Text(
[
machine.typeLabel,
if (detail.establishmentName != null) detail.establishmentName,
].join(' · '),
),
),
),
const SizedBox(height: 20),
Text('Choisir un 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('Créneaux disponibles', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Vous aurez 30 min pour démarrer après le début du créneau.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
availabilityAsync.when(
loading: () => const Center(child: Padding(
padding: EdgeInsets.all(24),
child: CircularProgressIndicator(),
)),
error: (_, __) => const Text('Impossible de charger les créneaux.'),
data: (slots) {
if (slots.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: 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: _isBooking ? null : () => _confirmBooking(detail),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
textStyle: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
icon: _isBooking
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.event_available_rounded, size: 24),
label: Text(_isBooking ? 'Réservation…' : 'Confirmer la réservation'),
),
),
),
),
],
);
},
),
);
}
}