depor_os/lib/screens/booking/booking_screen.dart

1010 lines
30 KiB
Dart
Raw Permalink Normal View History

2026-03-18 11:47:06 +00:00
import 'package:flutter/material.dart';
import '../../core/service_locator.dart';
import '../../models/booking.dart';
// Grid cell dimensions
const double _kSlotW = 82;
const double _kCourtW = 94;
const double _kRowH = 40;
const double _kHeaderH = 34;
class BookingScreen extends StatefulWidget {
const BookingScreen({super.key});
@override
State<BookingScreen> createState() => _BookingScreenState();
}
class _BookingScreenState extends State<BookingScreen> {
final _repo = bookingRepository();
List<BookingActivity> _activities = [];
BookingActivity? _selectedActivity;
DateTime _selectedDate = DateTime.now();
BookingGrid? _grid;
bool _gridLoading = false;
String? _gridError;
({int row, int court})? _selectedCell;
BookingDetail? _bookingDetail;
bool _detailLoading = false;
@override
void initState() {
super.initState();
_loadActivities();
}
Future<void> _loadActivities() async {
try {
final list = await _repo.activities();
setState(() {
_activities = list;
if (list.isNotEmpty) {
_selectedActivity = list.first;
_loadGrid();
}
});
} catch (_) {}
}
Future<void> _loadGrid() async {
final activity = _selectedActivity;
if (activity == null) return;
setState(() {
_gridLoading = true;
_gridError = null;
_selectedCell = null;
_bookingDetail = null;
});
try {
final grid = await _repo.grid(
activityId: activity.id,
date: _fmtDate(_selectedDate),
);
setState(() {
_grid = grid;
_gridLoading = false;
});
} catch (e) {
setState(() {
_gridError = e.toString();
_gridLoading = false;
});
}
}
Future<void> _onCellTap(int row, int court, BookingCell cell) async {
setState(() {
_selectedCell = (row: row, court: court);
_bookingDetail = null;
_detailLoading = cell.isBooked;
});
if (!cell.isBooked) return;
try {
final detail = await _repo.singleBooking(
activityId: _selectedActivity!.id,
date: _fmtDate(_selectedDate),
slotIndex: row,
courtIndex: court,
);
setState(() {
_bookingDetail = detail;
_detailLoading = false;
});
} catch (_) {
setState(() => _detailLoading = false);
}
}
void _handleAction(String action) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$action — pendiente de implementar'),
duration: const Duration(seconds: 2),
),
);
}
Widget _gridCard(BuildContext context) {
final child = _gridLoading
? const Center(child: CircularProgressIndicator())
: _gridError != null
? Center(child: Text(_gridError!))
: _grid == null
? Center(
child: Text('Selecciona una fecha y actividad',
style: TextStyle(
color: Theme.of(context).colorScheme.outline)),
)
: LayoutBuilder(builder: (_, cst) => _GridView(
grid: _grid!,
availableWidth: cst.maxWidth,
selectedCell: _selectedCell,
onCellTap: _onCellTap,
));
return Card.outlined(child: child);
}
Widget _selectorSideBySide() => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CalendarCard(
selectedDate: _selectedDate,
onDateChanged: (d) { setState(() => _selectedDate = d); _loadGrid(); },
),
const SizedBox(width: 12),
Expanded(
child: _ActivityCard(
activities: _activities,
selected: _selectedActivity,
onSelected: (a) { setState(() => _selectedActivity = a); _loadGrid(); },
),
),
],
),
);
Widget _selectorStacked() => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ActivityDropdown(
activities: _activities,
selected: _selectedActivity,
onSelected: (a) { setState(() => _selectedActivity = a); _loadGrid(); },
),
const SizedBox(height: 12),
_CalendarCard(
selectedDate: _selectedDate,
onDateChanged: (d) { setState(() => _selectedDate = d); _loadGrid(); },
),
],
),
);
List<Widget> _leftContents(BuildContext context, {required bool sideBySide}) => [
if (_grid?.celebrations != null)
_CelebrationBanner(text: _grid!.celebrations!),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Text('Reservas', style: Theme.of(context).textTheme.headlineSmall),
),
sideBySide ? _selectorSideBySide() : _selectorStacked(),
const SizedBox(height: 12),
];
Widget _rightPanel() => _RightPanel(
detail: _bookingDetail,
detailLoading: _detailLoading,
onAction: _handleAction,
);
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (_, constraints) {
final w = constraints.maxWidth;
// ── Narrow < 910px: right panel debajo del grid ───────────────────────
final windowW = MediaQuery.sizeOf(context).width;
if (w < 910) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
..._leftContents(context, sideBySide: windowW >= 900),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: _gridCard(context),
),
const Divider(height: 24),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: _rightPanel(),
),
],
),
);
}
// ── Medium/wide ≥ 910px: right panel fijo a la derecha ────────────────
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
..._leftContents(context, sideBySide: true),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: _gridCard(context),
),
),
],
),
),
VerticalDivider(
width: 1,
color: Theme.of(context).colorScheme.outlineVariant,
),
SizedBox(width: 300, child: _rightPanel()),
],
);
});
}
}
// ── Celebration banner ────────────────────────────────────────────────────────
class _CelebrationBanner extends StatelessWidget {
final String text;
const _CelebrationBanner({required this.text});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: cs.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.info_outline, size: 18, color: cs.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
'Celebraciones de hoy: $text',
style: TextStyle(color: cs.onPrimaryContainer, fontSize: 13),
),
),
],
),
);
}
}
// ── Calendar card ─────────────────────────────────────────────────────────────
class _CalendarCard extends StatelessWidget {
final DateTime selectedDate;
final void Function(DateTime) onDateChanged;
const _CalendarCard({
required this.selectedDate,
required this.onDateChanged,
});
@override
Widget build(BuildContext context) {
return Card.outlined(
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('Fecha',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold)),
),
SizedBox(
width: 280,
child: CalendarDatePicker(
initialDate: selectedDate,
firstDate: DateTime(2020),
lastDate: DateTime(2030),
onDateChanged: onDateChanged,
),
),
],
),
),
);
}
}
// ── Activity card ─────────────────────────────────────────────────────────────
class _ActivityCard extends StatelessWidget {
final List<BookingActivity> activities;
final BookingActivity? selected;
final void Function(BookingActivity) onSelected;
const _ActivityCard({
required this.activities,
required this.selected,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Card.outlined(
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Text('Actividad',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: cs.primary, fontWeight: FontWeight.bold)),
),
const SizedBox(height: 4),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: activities.length,
itemBuilder: (context, i) {
final a = activities[i];
final isSelected = a.id == selected?.id;
return InkWell(
onTap: () => onSelected(a),
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: isSelected ? cs.primaryContainer : null,
borderRadius: BorderRadius.circular(4),
),
child: Text(
a.name,
style: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.bold : null,
color: isSelected ? cs.onPrimaryContainer : null,
),
),
),
);
},
),
],
),
),
);
}
}
// ── Activity dropdown (compact, for narrow screens) ───────────────────────────
class _ActivityDropdown extends StatelessWidget {
final List<BookingActivity> activities;
final BookingActivity? selected;
final void Function(BookingActivity) onSelected;
const _ActivityDropdown({
required this.activities,
required this.selected,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
if (activities.isEmpty) return const SizedBox.shrink();
final cs = Theme.of(context).colorScheme;
return DropdownButtonFormField<BookingActivity>(
initialValue: selected,
decoration: InputDecoration(
labelText: 'Actividad',
labelStyle: TextStyle(color: cs.primary, fontWeight: FontWeight.bold),
isDense: true,
border: const OutlineInputBorder(),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
items: activities
.map((a) => DropdownMenuItem(value: a, child: Text(a.name)))
.toList(),
onChanged: (a) { if (a != null) onSelected(a); },
);
}
}
// ── Grid view ─────────────────────────────────────────────────────────────────
class _GridView extends StatelessWidget {
final BookingGrid grid;
final double availableWidth;
final ({int row, int court})? selectedCell;
final void Function(int row, int court, BookingCell cell) onCellTap;
const _GridView({
required this.grid,
required this.availableWidth,
required this.selectedCell,
required this.onCellTap,
});
/// Compute court cell width so all courts fill [availableWidth].
/// Falls back to [_kCourtW] if available space is too narrow.
double _courtWidth() {
final numCourts = grid.courtTitles.length;
if (numCourts == 0) return _kCourtW;
final remaining = availableWidth - _kSlotW;
final expanded = remaining / numCourts;
return expanded >= _kCourtW ? expanded : _kCourtW;
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final courtW = _courtWidth();
final totalW = _kSlotW + courtW * grid.courtTitles.length;
final needsHScroll = totalW > availableWidth;
Widget content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row
Row(
children: [
_headerCell('Horarios', _kSlotW, cs),
...grid.courtTitles.map(
(t) => _headerCell(t, courtW, cs),
),
],
),
// Data rows
...grid.rows.asMap().entries.map((e) {
final rowIdx = e.key;
final row = e.value;
final isOdd = rowIdx.isOdd;
return Row(
children: [
// Time slot cell
Container(
width: _kSlotW,
height: _kRowH,
color: isOdd
? cs.surfaceContainerLow
: cs.surfaceContainerLowest,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(row.timeLabel,
style: const TextStyle(
fontSize: 12, fontWeight: FontWeight.w500)),
),
// Court cells
...row.cells.asMap().entries.map((ce) {
final courtIdx = ce.key + 1; // 1-based
final cell = ce.value;
final isSelected = selectedCell?.row == rowIdx &&
selectedCell?.court == courtIdx;
return _GridCell(
cell: cell,
width: courtW,
isSelected: isSelected,
isOdd: isOdd,
onTap: () => onCellTap(rowIdx, courtIdx, cell),
);
}),
],
);
}),
],
);
if (needsHScroll) {
content = SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: content,
);
}
return SingleChildScrollView(
scrollDirection: Axis.vertical,
child: content,
);
}
Widget _headerCell(String title, double width, ColorScheme cs) {
return Container(
width: width,
height: _kHeaderH,
color: cs.surfaceContainerHigh,
alignment: Alignment.center,
child: Text(
title,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: cs.onSurface,
),
),
);
}
}
class _GridCell extends StatelessWidget {
final BookingCell cell;
final double width;
final bool isSelected;
final bool isOdd;
final VoidCallback onTap;
const _GridCell({
required this.cell,
required this.width,
required this.isSelected,
required this.isOdd,
required this.onTap,
});
Color _bgColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
if (cell.isPaid) return Colors.green.shade400;
if (cell.isBooked) return Colors.red.shade400;
return isOdd ? cs.surfaceContainerLow : cs.surfaceContainerLowest;
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
width: width,
height: _kRowH,
decoration: BoxDecoration(
color: _bgColor(context),
border: isSelected
? Border.all(color: cs.primary, width: 2)
: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
),
alignment: Alignment.center,
child: cell.isBooked
? Text(
cell.raw,
style: const TextStyle(
fontSize: 11,
color: Colors.white,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
)
: null,
),
);
}
}
// ── Right panel ───────────────────────────────────────────────────────────────
class _RightPanel extends StatelessWidget {
final BookingDetail? detail;
final bool detailLoading;
final void Function(String) onAction;
const _RightPanel({
required this.detail,
required this.detailLoading,
required this.onAction,
});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ActionButtons(onAction: onAction),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
if (detailLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: CircularProgressIndicator(),
))
else if (detail == null)
_EmptyDetailPanel()
else
_TicketPanel(detail: detail!),
],
),
);
}
}
// ── Action buttons ────────────────────────────────────────────────────────────
class _ActionButtons extends StatelessWidget {
final void Function(String) onAction;
const _ActionButtons({required this.onAction});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Column(
children: [
// Row 1: booking actions + print actions
Row(
children: [
// Left group: booking ops
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ActionIcon(
icon: Icons.person_add_outlined,
tooltip: 'Añadir suplemento',
onTap: () => onAction('supplement'),
),
_ActionIcon(
icon: Icons.lightbulb_outline,
tooltip: 'Añadir luz',
onTap: () => onAction('light'),
),
_ActionIcon(
icon: Icons.point_of_sale_outlined,
tooltip: 'Cobrar',
onTap: () => onAction('charge'),
),
],
),
),
Container(
width: 1, height: 36, color: cs.outlineVariant),
// Right group: print/cancel
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ActionIcon(
icon: Icons.print_outlined,
tooltip: 'Imprimir ticket',
onTap: () => onAction('print'),
),
_ActionIcon(
icon: Icons.undo,
tooltip: 'Devolver',
onTap: () => onAction('return'),
color: cs.error,
),
_ActionIcon(
icon: Icons.qr_code_2,
tooltip: 'Imprimir QR',
onTap: () => onAction('qr_print'),
),
],
),
),
],
),
const SizedBox(height: 8),
// Row 2: QR + payment methods
Row(
children: [
// QR button
Padding(
padding: const EdgeInsets.only(left: 8),
child: _ActionIcon(
icon: Icons.qr_code,
tooltip: 'QR',
onTap: () => onAction('qr'),
size: 40,
),
),
const Spacer(),
// Payment buttons
_PayButton(
label: 'Efectivo',
color: Colors.green.shade600,
icon: Icons.payments_outlined,
onTap: () => onAction('cash'),
),
const SizedBox(width: 6),
_PayButton(
label: 'Tarjeta',
color: Colors.amber.shade700,
icon: Icons.credit_card,
onTap: () => onAction('card'),
),
const SizedBox(width: 6),
_PayButton(
label: 'Bizum',
color: Colors.teal.shade600,
icon: Icons.phone_android,
onTap: () => onAction('bizum'),
),
const SizedBox(width: 8),
],
),
],
);
}
}
class _ActionIcon extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback onTap;
final Color? color;
final double size;
const _ActionIcon({
required this.icon,
required this.tooltip,
required this.onTap,
this.color,
this.size = 36,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon,
size: 20, color: color ?? Theme.of(context).colorScheme.onSurface),
),
),
);
}
}
class _PayButton extends StatelessWidget {
final String label;
final Color color;
final IconData icon;
final VoidCallback onTap;
const _PayButton({
required this.label,
required this.color,
required this.icon,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: label,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Container(
width: 52,
height: 36,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, size: 20, color: Colors.white),
),
),
);
}
}
// ── Ticket panel ──────────────────────────────────────────────────────────────
class _EmptyDetailPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_FieldBox(label: 'Concepto', value: ''),
const SizedBox(height: 10),
Row(
children: [
Expanded(child: _FieldBox(label: 'Base imponible', value: '0,00')),
const SizedBox(width: 8),
Expanded(child: _FieldBox(label: 'I.V.A.', value: '0,00')),
],
),
const SizedBox(height: 10),
_FieldBox(label: 'Total', value: '0,00'),
const SizedBox(height: 12),
Text('Fecha reserva: —',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.outline)),
const SizedBox(height: 8),
_LineItemsHeader(),
],
);
}
}
class _TicketPanel extends StatelessWidget {
final BookingDetail detail;
const _TicketPanel({required this.detail});
@override
Widget build(BuildContext context) {
final t = detail.ticket;
final cs = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_FieldBox(label: 'Concepto', value: t?.concepto ?? ''),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _FieldBox(
label: 'Base imponible',
value: t != null ? _fmtMoney(t.base) : '')),
const SizedBox(width: 8),
Expanded(
child: _FieldBox(
label: 'I.V.A.',
value: t != null ? _fmtMoney(t.iva) : '')),
],
),
const SizedBox(height: 10),
_FieldBox(
label: 'Total',
value: t != null ? _fmtMoney(t.total) : '',
highlight: true),
const SizedBox(height: 12),
Row(
children: [
Text('Fecha reserva: ',
style: TextStyle(fontSize: 12, color: cs.outline)),
Text(detail.date,
style: const TextStyle(
fontSize: 12, fontWeight: FontWeight.w500)),
const Spacer(),
if (detail.paid)
Chip(
label: const Text('Pagada',
style: TextStyle(fontSize: 11, color: Colors.white)),
backgroundColor: Colors.green.shade600,
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
)
else
Chip(
label: Text('Pendiente',
style: TextStyle(
fontSize: 11, color: cs.onErrorContainer)),
backgroundColor: cs.errorContainer,
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
],
),
const SizedBox(height: 8),
_LineItemsHeader(),
if (t != null)
...t.lines.map((line) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Expanded(
child: Text(line.concepto,
style: const TextStyle(fontSize: 12))),
Text(_fmtMoney(line.precio),
style: const TextStyle(
fontSize: 12, fontWeight: FontWeight.w500)),
],
),
)),
],
);
}
}
class _LineItemsHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 0),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: cs.outlineVariant)),
),
child: Row(
children: [
Expanded(
child: Text('Concepto',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: cs.outline))),
Text('Precio',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: cs.outline)),
],
),
);
}
}
class _FieldBox extends StatelessWidget {
final String label;
final String value;
final bool highlight;
const _FieldBox({
required this.label,
required this.value,
this.highlight = false,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: TextStyle(
fontSize: 11,
color: cs.outline,
fontWeight: FontWeight.w500)),
const SizedBox(height: 3),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: highlight ? cs.primaryContainer : cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: cs.outlineVariant),
),
child: Text(
value,
style: TextStyle(
fontSize: 13,
fontWeight: highlight ? FontWeight.bold : null,
color: highlight ? cs.onPrimaryContainer : null,
),
),
),
],
);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
String _fmtDate(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
String _fmtMoney(double v) =>
'${v.toStringAsFixed(2).replaceAll('.', ',')}';