Agrego frontend app
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gimnasio_soma/core/services/whatsapp_service.dart';
|
||||
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
|
||||
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
|
||||
import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart';
|
||||
import 'package:gimnasio_soma/features/huerfanas/presentation/providers/huerfanas_provider.dart';
|
||||
|
||||
const _meses = [
|
||||
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
|
||||
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
|
||||
];
|
||||
|
||||
class BulkNotifyDialog extends ConsumerStatefulWidget {
|
||||
final List<ReservaHuerfana> seleccionadas;
|
||||
|
||||
const BulkNotifyDialog({super.key, required this.seleccionadas});
|
||||
|
||||
@override
|
||||
ConsumerState<BulkNotifyDialog> createState() => _BulkNotifyDialogState();
|
||||
}
|
||||
|
||||
class _BulkNotifyDialogState extends ConsumerState<BulkNotifyDialog> {
|
||||
late final Set<String> _listos = {};
|
||||
bool _cargando = false;
|
||||
|
||||
String _fmtFecha(String fechaIso) {
|
||||
final d = DateTime.tryParse(fechaIso);
|
||||
if (d == null) return fechaIso;
|
||||
return '${d.day} ${_meses[d.month]} ${d.year}';
|
||||
}
|
||||
|
||||
String _mensajeWa(ReservaHuerfana r) =>
|
||||
'Hola ${r.nombre}, te contactamos desde el gimnasio SOMA. '
|
||||
'Tu reserva de ${r.actividadNombre} del ${_fmtFecha(r.fechaOriginal)} '
|
||||
'quedó sin turno disponible. '
|
||||
'Por favor, coordiná una nueva reserva cuando puedas. ¡Muchas gracias!';
|
||||
|
||||
Future<void> _abrirWa(ReservaHuerfana r) async {
|
||||
final ok = await WhatsAppService.abrirChat(
|
||||
telefono: r.telefono,
|
||||
mensaje: _mensajeWa(r),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!ok) {
|
||||
final sinNumero = WhatsAppService.normalizarNumeroAr(r.telefono) == null;
|
||||
SomaToast.show(
|
||||
context,
|
||||
message: sinNumero
|
||||
? '${r.displayName} no tiene número de teléfono registrado. '
|
||||
'Podés agregarlo desde la pantalla de Usuarios.'
|
||||
: 'No se pudo abrir WhatsApp para ${r.displayName}.',
|
||||
type: ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _marcarListos() async {
|
||||
if (_listos.isEmpty) return;
|
||||
setState(() => _cargando = true);
|
||||
await ref.read(huerfanasProvider.notifier).notificarLote(_listos);
|
||||
if (!mounted) return;
|
||||
final n = _listos.length;
|
||||
Navigator.pop(context);
|
||||
SomaToast.show(
|
||||
context,
|
||||
message: n == 1 ? '1 reserva marcada como resuelta' : '$n reservas marcadas como resueltas',
|
||||
type: ToastType.success,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final n = widget.seleccionadas.length;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520, maxHeight: 560),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
n == 1 ? 'Notificar a 1 cliente' : 'Notificar a $n clientes',
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 6, 24, 12),
|
||||
child: Text(
|
||||
'Abrí WhatsApp para cada cliente y marcá "Listo" cuando lo hayas enviado.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.onSurface.withAlpha(150),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 1),
|
||||
|
||||
// Lista
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
shrinkWrap: true,
|
||||
itemCount: widget.seleccionadas.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1, indent: 20, endIndent: 20),
|
||||
itemBuilder: (_, i) {
|
||||
final r = widget.seleccionadas[i];
|
||||
final listo = _listos.contains(r.huerfanaId);
|
||||
return _ClienteRow(
|
||||
reserva: r,
|
||||
listo: listo,
|
||||
onAbrirWa: () => _abrirWa(r),
|
||||
onToggleListo: () {
|
||||
setState(() {
|
||||
if (listo) {
|
||||
_listos.remove(r.huerfanaId);
|
||||
} else {
|
||||
_listos.add(r.huerfanaId);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 1),
|
||||
|
||||
// Footer
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_listos.isNotEmpty)
|
||||
Text(
|
||||
'${_listos.length} listo${_listos.length == 1 ? '' : 's'}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.onSurface.withAlpha(150),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cerrar'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 40)),
|
||||
onPressed: _listos.isEmpty || _cargando ? null : _marcarListos,
|
||||
child: _cargando
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: SomaColors.onPrimary,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_listos.isEmpty
|
||||
? 'Marcar como resueltas'
|
||||
: 'Marcar ${_listos.length} como resuelta${_listos.length == 1 ? '' : 's'}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClienteRow extends StatefulWidget {
|
||||
final ReservaHuerfana reserva;
|
||||
final bool listo;
|
||||
final VoidCallback onAbrirWa;
|
||||
final VoidCallback onToggleListo;
|
||||
|
||||
const _ClienteRow({
|
||||
required this.reserva,
|
||||
required this.listo,
|
||||
required this.onAbrirWa,
|
||||
required this.onToggleListo,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ClienteRow> createState() => _ClienteRowState();
|
||||
}
|
||||
|
||||
class _ClienteRowState extends State<_ClienteRow> {
|
||||
bool _hoveredWa = false;
|
||||
bool _hoveredListo = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
const waColor = Color(0xFF25D366);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: SomaColors.primary.withAlpha(28),
|
||||
child: Text(
|
||||
widget.reserva.initials,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SomaColors.primaryText,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
// Nombre + teléfono
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.reserva.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
widget.reserva.telefono ?? 'Sin teléfono',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.onSurface.withAlpha(
|
||||
widget.reserva.telefono != null ? 130 : 80,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Botón WhatsApp
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hoveredWa = true),
|
||||
onExit: (_) => setState(() => _hoveredWa = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onAbrirWa,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
color: waColor.withAlpha(_hoveredWa ? 38 : 16),
|
||||
border: Border.all(
|
||||
color: waColor.withAlpha(_hoveredWa ? 110 : 60),
|
||||
width: _hoveredWa ? 0.8 : 0.5,
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.chat_outlined, size: 13, color: waColor),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'Abrir WA',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: waColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 10),
|
||||
|
||||
// Toggle "Listo"
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hoveredListo = true),
|
||||
onExit: (_) => setState(() => _hoveredListo = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onToggleListo,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: widget.listo
|
||||
? SomaColors.primary.withAlpha(
|
||||
_hoveredListo ? 35 : 20,
|
||||
)
|
||||
: _hoveredListo
|
||||
? theme.colorScheme.onSurface.withAlpha(10)
|
||||
: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: widget.listo
|
||||
? SomaColors.primary.withAlpha(
|
||||
_hoveredListo ? 160 : 100,
|
||||
)
|
||||
: theme.colorScheme.onSurface.withAlpha(
|
||||
_hoveredListo ? 90 : 50,
|
||||
),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check,
|
||||
size: 12,
|
||||
color: widget.listo
|
||||
? SomaColors.primaryText
|
||||
: theme.colorScheme.onSurface.withAlpha(
|
||||
_hoveredListo ? 100 : 60,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'Listo',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: widget.listo
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
color: widget.listo
|
||||
? SomaColors.primaryText
|
||||
: theme.colorScheme.onSurface.withAlpha(
|
||||
_hoveredListo ? 140 : 100,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
|
||||
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
|
||||
import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart';
|
||||
import 'package:gimnasio_soma/features/huerfanas/presentation/providers/huerfanas_provider.dart';
|
||||
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
|
||||
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
|
||||
|
||||
const _meses = [
|
||||
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
|
||||
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
|
||||
];
|
||||
|
||||
const _diasSemana = ['', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
|
||||
|
||||
class TurnoPickerSheet extends ConsumerStatefulWidget {
|
||||
final ReservaHuerfana reserva;
|
||||
|
||||
/// Se invoca tras reubicar exitosamente con el turno elegido y la fecha del día.
|
||||
/// El llamador usa esta info para mostrar el toast de éxito con la acción de WA.
|
||||
final void Function(Turno turno, DateTime fecha)? onReubicadoExito;
|
||||
|
||||
const TurnoPickerSheet({
|
||||
super.key,
|
||||
required this.reserva,
|
||||
this.onReubicadoExito,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<TurnoPickerSheet> createState() => _TurnoPickerSheetState();
|
||||
}
|
||||
|
||||
class _TurnoPickerSheetState extends ConsumerState<TurnoPickerSheet> {
|
||||
late DateTime _semanaActual;
|
||||
bool _soloMismaActividad = true;
|
||||
AsyncValue<SemanaTurnos> _semanaTurnos = const AsyncValue.loading();
|
||||
bool _reubicando = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_semanaActual = _lunesDe(DateTime.now());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _cargarSemana());
|
||||
}
|
||||
|
||||
DateTime _lunesDe(DateTime d) =>
|
||||
DateTime(d.year, d.month, d.day - (d.weekday - 1));
|
||||
|
||||
Future<void> _cargarSemana() async {
|
||||
setState(() => _semanaTurnos = const AsyncValue.loading());
|
||||
try {
|
||||
final repo = ref.read(turnosRepositoryProvider);
|
||||
final semana = await repo.obtenerSemana(_semanaActual);
|
||||
if (mounted) setState(() => _semanaTurnos = AsyncValue.data(semana));
|
||||
} catch (e, st) {
|
||||
if (mounted) setState(() => _semanaTurnos = AsyncValue.error(e, st));
|
||||
}
|
||||
}
|
||||
|
||||
void _irSemanaAnterior() {
|
||||
setState(() => _semanaActual = _semanaActual.subtract(const Duration(days: 7)));
|
||||
_cargarSemana();
|
||||
}
|
||||
|
||||
void _irSemanaSiguiente() {
|
||||
setState(() => _semanaActual = _semanaActual.add(const Duration(days: 7)));
|
||||
_cargarSemana();
|
||||
}
|
||||
|
||||
String _fmtSemana() {
|
||||
final fin = _semanaActual.add(const Duration(days: 6));
|
||||
final inicioStr = '${_semanaActual.day} ${_meses[_semanaActual.month]}';
|
||||
final finStr = '${fin.day} ${_meses[fin.month]}';
|
||||
return 'Sem del $inicioStr al $finStr';
|
||||
}
|
||||
|
||||
String _fmtFechaCorta(DateTime d) =>
|
||||
'${_diasSemana[d.weekday]} ${d.day} ${_meses[d.month]}';
|
||||
|
||||
Future<void> _seleccionarTurno(Turno turno, DiaTurnos dia) async {
|
||||
final nombreFmt =
|
||||
'${_diasSemana[dia.fecha.weekday]} ${dia.fecha.day} ${_meses[dia.fecha.month]} ${turno.horaInicio}';
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Confirmar reubicación'),
|
||||
content: Text(
|
||||
'¿Reubicar a ${widget.reserva.displayName} al turno de '
|
||||
'${turno.actividad.nombre} del $nombreFmt '
|
||||
'(${turno.disponible}/${turno.capacidadMaxima} cupos)?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancelar'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 36)),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Confirmar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
setState(() => _reubicando = true);
|
||||
final error = await ref
|
||||
.read(huerfanasProvider.notifier)
|
||||
.mover(widget.reserva.huerfanaId, turno.id);
|
||||
if (!mounted) return;
|
||||
setState(() => _reubicando = false);
|
||||
|
||||
if (error != null) {
|
||||
SomaToast.show(context, message: error, type: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
final callback = widget.onReubicadoExito;
|
||||
Navigator.pop(context);
|
||||
callback?.call(turno, dia.fecha);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.72,
|
||||
maxChildSize: 0.92,
|
||||
minChildSize: 0.4,
|
||||
expand: false,
|
||||
builder: (ctx, scrollController) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Drag handle
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10, bottom: 4),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.onSurface.withAlpha(50),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Reubicar a ${widget.reserva.displayName}',
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'${widget.reserva.actividadNombre} · '
|
||||
'${_fmtFechaCorta(DateTime.tryParse(widget.reserva.fechaOriginal) ?? DateTime.now())} '
|
||||
'${widget.reserva.horaInicioOriginal} (original)',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurface.withAlpha(130),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 16, indent: 20, endIndent: 20),
|
||||
|
||||
// Navegación semanal + filtro
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed: _irSemanaAnterior,
|
||||
tooltip: 'Semana anterior',
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_fmtSemana(),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
onPressed: _irSemanaSiguiente,
|
||||
tooltip: 'Semana siguiente',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Toggle filtro actividad
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||
child: _FiltroToggle(
|
||||
actividadNombre: widget.reserva.actividadNombre,
|
||||
soloMismaActividad: _soloMismaActividad,
|
||||
onChanged: (v) => setState(() => _soloMismaActividad = v),
|
||||
),
|
||||
),
|
||||
|
||||
// Contenido
|
||||
Expanded(
|
||||
child: _reubicando
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: SomaColors.primary,
|
||||
),
|
||||
)
|
||||
: _semanaTurnos.when(
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: SomaColors.primary,
|
||||
),
|
||||
),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 40,
|
||||
color: theme.colorScheme.onSurface.withAlpha(80),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
e.toString().replaceFirst('Exception: ', ''),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface
|
||||
.withAlpha(130),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
onPressed: _cargarSemana,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('Reintentar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (semana) => _buildDias(
|
||||
semana,
|
||||
scrollController,
|
||||
theme,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDias(
|
||||
SemanaTurnos semana,
|
||||
ScrollController scrollController,
|
||||
ThemeData theme,
|
||||
) {
|
||||
final dias = List.generate(7, (i) {
|
||||
final fecha = _semanaActual.add(Duration(days: i));
|
||||
return semana.diaPara(fecha) ??
|
||||
DiaTurnos(
|
||||
fecha: fecha,
|
||||
diaSemana: fecha.weekday,
|
||||
estado: DiaEstado.cerrado,
|
||||
turnos: const [],
|
||||
);
|
||||
});
|
||||
|
||||
// Filtrar turnos por actividad si aplica
|
||||
List<Turno> turnosDelDia(DiaTurnos dia) {
|
||||
if (dia.estado == DiaEstado.cerrado) return [];
|
||||
final todos = dia.turnos.where((t) => !t.estaLleno).toList();
|
||||
if (!_soloMismaActividad) return todos;
|
||||
return todos
|
||||
.where(
|
||||
(t) =>
|
||||
t.actividad.nombre.toLowerCase() ==
|
||||
widget.reserva.actividadNombre.toLowerCase(),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Si con el filtro no hay nada en toda la semana, mostrar aviso
|
||||
final hayAlgo = dias.any((d) => turnosDelDia(d).isNotEmpty);
|
||||
|
||||
if (!hayAlgo) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.event_busy_outlined,
|
||||
size: 44,
|
||||
color: theme.colorScheme.onSurface.withAlpha(50),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
_soloMismaActividad
|
||||
? 'Sin turnos disponibles de\n${widget.reserva.actividadNombre} esta semana'
|
||||
: 'Sin turnos disponibles esta semana',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: theme.colorScheme.onSurface.withAlpha(120),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
|
||||
itemCount: dias.length,
|
||||
itemBuilder: (_, i) {
|
||||
final dia = dias[i];
|
||||
final turnos = turnosDelDia(dia);
|
||||
if (turnos.isEmpty) return const SizedBox.shrink();
|
||||
return _DiaSection(
|
||||
dia: dia,
|
||||
turnos: turnos,
|
||||
onTurnoTap: _seleccionarTurno,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toggle filtro actividad ───────────────────────────────────────────────────
|
||||
|
||||
class _FiltroToggle extends StatelessWidget {
|
||||
final String actividadNombre;
|
||||
final bool soloMismaActividad;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
const _FiltroToggle({
|
||||
required this.actividadNombre,
|
||||
required this.soloMismaActividad,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withAlpha(80),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_ToggleItem(
|
||||
label: actividadNombre,
|
||||
selected: soloMismaActividad,
|
||||
onTap: () => onChanged(true),
|
||||
),
|
||||
_ToggleItem(
|
||||
label: 'Todas las actividades',
|
||||
selected: !soloMismaActividad,
|
||||
onTap: () => onChanged(false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToggleItem extends StatelessWidget {
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ToggleItem({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
margin: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? theme.colorScheme.surface : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
boxShadow: selected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withAlpha(18),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
)
|
||||
]
|
||||
: null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight:
|
||||
selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: selected
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurface.withAlpha(140),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sección de día con turnos ─────────────────────────────────────────────────
|
||||
|
||||
class _DiaSection extends StatelessWidget {
|
||||
final DiaTurnos dia;
|
||||
final List<Turno> turnos;
|
||||
final Future<void> Function(Turno, DiaTurnos) onTurnoTap;
|
||||
|
||||
const _DiaSection({
|
||||
required this.dia,
|
||||
required this.turnos,
|
||||
required this.onTurnoTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final label =
|
||||
'${_diasSemana[dia.fecha.weekday]} ${dia.fecha.day} ${_meses[dia.fecha.month]}';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface.withAlpha(160),
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
...turnos.map((t) => _TurnoPill(turno: t, dia: dia, onTap: onTurnoTap)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TurnoPill extends StatefulWidget {
|
||||
final Turno turno;
|
||||
final DiaTurnos dia;
|
||||
final Future<void> Function(Turno, DiaTurnos) onTap;
|
||||
|
||||
const _TurnoPill({
|
||||
required this.turno,
|
||||
required this.dia,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_TurnoPill> createState() => _TurnoPillState();
|
||||
}
|
||||
|
||||
class _TurnoPillState extends State<_TurnoPill> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: () => widget.onTap(widget.turno, widget.dia),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Rail izquierdo
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
width: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: SomaColors.primary.withAlpha(_hovered ? 255 : 180),
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
left: Radius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 9,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered
|
||||
? SomaColors.primary.withAlpha(12)
|
||||
: theme.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
right: Radius.circular(8),
|
||||
),
|
||||
border: Border.all(
|
||||
color: _hovered
|
||||
? SomaColors.primary.withAlpha(80)
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
width: _hovered ? 0.8 : 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Hora
|
||||
SizedBox(
|
||||
width: 46,
|
||||
child: Text(
|
||||
widget.turno.horaInicio,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 16,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 10),
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
// Actividad
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.turno.actividad.nombre,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onSurface.withAlpha(200),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// Cupos
|
||||
Text(
|
||||
'${widget.turno.disponible}/${widget.turno.capacidadMaxima}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: widget.turno.disponible <= 2
|
||||
? SomaColors.error.withAlpha(200)
|
||||
: SomaColors.primary.withAlpha(200),
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
child: Icon(
|
||||
Icons.arrow_forward_ios_rounded,
|
||||
size: 12,
|
||||
color: theme.colorScheme.onSurface
|
||||
.withAlpha(_hovered ? 160 : 80),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user