1010 lines
30 KiB
Dart
1010 lines
30 KiB
Dart
|
|
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('.', ',')} €';
|