Files
mobile/lib/features/machines/presentation/machine_booking_screen.dart

208 lines
8.4 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'),
),
),
),
),
],
);
},
),
);
}
}