208 lines
8.4 KiB
Dart
208 lines
8.4 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/theme/app_colors.dart';
|
||
import '../../../core/theme/machine_status_theme.dart';
|
||
import '../../../core/time/server_time.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 = ServerTime.startOfToday();
|
||
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 = ServerTime.startOfToday().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(ServerTime.format(normalized, pattern: 'EEE dd/MM')),
|
||
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 =
|
||
'${ServerTime.format(slot.start, pattern: 'HH:mm')} – ${ServerTime.format(slot.end, pattern: 'HH:mm')}';
|
||
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 || _selectedSlot == null)
|
||
? null
|
||
: () => _confirmBooking(detail),
|
||
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'),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|