Agrego frontend app
This commit is contained in:
@@ -0,0 +1,926 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_context_menu/flutter_context_menu.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_header_help.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/huerfanas/presentation/widgets/bulk_notify_dialog.dart';
|
||||
import 'package:gimnasio_soma/features/huerfanas/presentation/widgets/turno_picker_sheet.dart';
|
||||
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
|
||||
|
||||
const _meses = [
|
||||
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
|
||||
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
|
||||
];
|
||||
|
||||
class HuerfanasScreen extends ConsumerWidget {
|
||||
const HuerfanasScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(huerfanasProvider);
|
||||
final notifier = ref.read(huerfanasProvider.notifier);
|
||||
final currentEstado = notifier.currentEstado;
|
||||
final isWide = MediaQuery.of(context).size.width >= 800;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final modoSeleccion = ref.watch(huerfanasModoSeleccionProvider);
|
||||
final seleccionadas = ref.watch(huerfanasSeleccionProvider);
|
||||
final seleccionNotifier = ref.read(huerfanasSeleccionProvider.notifier);
|
||||
|
||||
// Sólo aplica a pendientes
|
||||
final lista = state.valueOrNull ?? [];
|
||||
final pendientes = lista
|
||||
.where((r) => r.estado == EstadoHuerfana.pendiente)
|
||||
.toList();
|
||||
final seleccionadasValidas = seleccionadas
|
||||
.where((id) => pendientes.any((r) => r.huerfanaId == id))
|
||||
.toSet();
|
||||
|
||||
void toggleModoSeleccion() {
|
||||
if (modoSeleccion) {
|
||||
seleccionNotifier.limpiar();
|
||||
}
|
||||
ref.read(huerfanasModoSeleccionProvider.notifier).state = !modoSeleccion;
|
||||
}
|
||||
|
||||
void abrirBulkNotify() {
|
||||
final items = pendientes
|
||||
.where((r) => seleccionadasValidas.contains(r.huerfanaId))
|
||||
.toList();
|
||||
if (items.isEmpty) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => BulkNotifyDialog(seleccionadas: items),
|
||||
).then((_) {
|
||||
// limpiar selección al cerrar el dialog
|
||||
seleccionNotifier.limpiar();
|
||||
ref.read(huerfanasModoSeleccionProvider.notifier).state = false;
|
||||
});
|
||||
}
|
||||
|
||||
void abrirPickerSheet(ReservaHuerfana reserva) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => TurnoPickerSheet(
|
||||
reserva: reserva,
|
||||
onReubicadoExito: (Turno turno, DateTime fecha) {
|
||||
if (!context.mounted) return;
|
||||
final fechaStr =
|
||||
'${fecha.day} ${_meses[fecha.month]} ${turno.horaInicio}';
|
||||
SomaToast.show(
|
||||
context,
|
||||
message: 'Reubicado al $fechaStr',
|
||||
type: ToastType.success,
|
||||
action: reserva.telefono != null
|
||||
? SnackBarAction(
|
||||
label: 'Avisar por WhatsApp',
|
||||
textColor: SomaColors.onPrimary,
|
||||
onPressed: () => WhatsAppService.abrirChat(
|
||||
telefono: reserva.telefono,
|
||||
mensaje: 'Hola ${reserva.nombre}, te reasignamos al turno de '
|
||||
'${turno.actividad.nombre} del $fechaStr. ¡Te esperamos!',
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> abrirWhatsApp(ReservaHuerfana reserva) async {
|
||||
final nombre = reserva.nombre;
|
||||
final actividad = reserva.actividadNombre;
|
||||
final d = DateTime.tryParse(reserva.fechaOriginal);
|
||||
final fecha = d != null ? '${d.day} ${_meses[d.month]} ${d.year}' : reserva.fechaOriginal;
|
||||
|
||||
final ok = await WhatsAppService.abrirChat(
|
||||
telefono: reserva.telefono,
|
||||
mensaje: 'Hola $nombre, te contactamos desde el gimnasio SOMA. '
|
||||
'Tu reserva de $actividad del $fecha quedó sin turno disponible. '
|
||||
'Por favor, coordiná una nueva reserva cuando puedas. ¡Muchas gracias!',
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (!ok) {
|
||||
final sinNumero = WhatsAppService.normalizarNumeroAr(reserva.telefono) == null;
|
||||
SomaToast.show(
|
||||
context,
|
||||
message: sinNumero
|
||||
? '${reserva.displayName} no tiene número de teléfono registrado. '
|
||||
'Podés agregarlo desde la pantalla de Usuarios.'
|
||||
: 'No se pudo abrir WhatsApp.',
|
||||
type: ToastType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sólo ofrecer marcar si está pendiente
|
||||
if (reserva.estado == EstadoHuerfana.pendiente) {
|
||||
SomaToast.show(
|
||||
context,
|
||||
message: 'WhatsApp abierto',
|
||||
type: ToastType.info,
|
||||
action: SnackBarAction(
|
||||
label: 'Marcar resuelta',
|
||||
textColor: SomaColors.onPrimary,
|
||||
onPressed: () async {
|
||||
final error = await ref
|
||||
.read(huerfanasProvider.notifier)
|
||||
.resolver(reserva.huerfanaId, 'resuelta');
|
||||
if (!context.mounted) return;
|
||||
if (error != null) {
|
||||
SomaToast.show(context, message: error, type: ToastType.error);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
// ── Header ──────────────────────────────────────────────────────────
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
isWide ? 32 : 16,
|
||||
isWide ? 28 : 16,
|
||||
isWide ? 32 : 16,
|
||||
0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Reservas sin turno',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SomaHeaderHelp(
|
||||
items: [
|
||||
SomaHelpItem(
|
||||
icon: Icons.filter_alt_outlined,
|
||||
text: 'Filtrá por estado: pendientes, reubicadas, '
|
||||
'resueltas o todas.',
|
||||
),
|
||||
SomaHelpItem(
|
||||
icon: Icons.checklist_outlined,
|
||||
text: 'Modo selección: elegí varias reservas para '
|
||||
'notificarlas por WhatsApp de una sola vez.',
|
||||
),
|
||||
SomaHelpItem(
|
||||
icon: Icons.refresh,
|
||||
text: 'Recarga la lista de reservas sin turno.',
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
// Toggle selección múltiple
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
modoSeleccion
|
||||
? Icons.checklist_rounded
|
||||
: Icons.checklist_outlined,
|
||||
size: 20,
|
||||
color: modoSeleccion
|
||||
? SomaColors.primary
|
||||
: null,
|
||||
),
|
||||
tooltip: modoSeleccion ? 'Cancelar selección' : 'Seleccionar',
|
||||
onPressed: toggleModoSeleccion,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
tooltip: 'Recargar',
|
||||
onPressed: () {
|
||||
seleccionNotifier.limpiar();
|
||||
ref.read(huerfanasModoSeleccionProvider.notifier).state =
|
||||
false;
|
||||
notifier.load();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Filter chips (ocultos en modo selección) ─────────────────────
|
||||
if (!modoSeleccion)
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
isWide ? 32 : 16, 12, isWide ? 32 : 16, 0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_FilterChip(
|
||||
label: 'Pendientes',
|
||||
selected: currentEstado == 'pendiente',
|
||||
color: SomaColors.error,
|
||||
onTap: () => notifier.filtrar('pendiente'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
label: 'Reubicadas',
|
||||
selected: currentEstado == 'reubicado',
|
||||
color: SomaColors.primary,
|
||||
onTap: () => notifier.filtrar('reubicado'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
label: 'Resueltas',
|
||||
selected: currentEstado == 'resuelta',
|
||||
color: theme.colorScheme.secondary,
|
||||
onTap: () => notifier.filtrar('resuelta'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
label: 'Todas',
|
||||
selected: currentEstado == null,
|
||||
color: theme.colorScheme.onSurface,
|
||||
onTap: () => notifier.filtrar(null),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
// Etiqueta modo selección
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
isWide ? 32 : 16, 12, isWide ? 32 : 16, 0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurface.withAlpha(120),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Seleccioná los clientes pendientes que querés notificar en lote',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurface.withAlpha(140),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── Content ──────────────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: state.when(
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: SomaColors.primary),
|
||||
),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline,
|
||||
size: 48,
|
||||
color: theme.colorScheme.onSurface.withAlpha(100)),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
e.toString().replaceFirst('Exception: ', ''),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () => notifier.load(),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('Reintentar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (lista) {
|
||||
// En modo selección sólo mostramos pendientes
|
||||
final listaFiltrada =
|
||||
modoSeleccion ? pendientes : lista;
|
||||
|
||||
if (listaFiltrada.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline,
|
||||
size: 56,
|
||||
color:
|
||||
theme.colorScheme.onSurface.withAlpha(60)),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
modoSeleccion
|
||||
? 'Sin reservas pendientes para notificar'
|
||||
: currentEstado == 'pendiente'
|
||||
? 'Sin reservas pendientes'
|
||||
: 'Sin reservas en este estado',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: theme.colorScheme.onSurface.withAlpha(130),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
isWide ? 32 : 16, 8, isWide ? 32 : 16, 32,
|
||||
),
|
||||
itemCount: listaFiltrada.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final reserva = listaFiltrada[index];
|
||||
return _HuerfanaCard(
|
||||
reserva: reserva,
|
||||
modoSeleccion: modoSeleccion,
|
||||
seleccionada: seleccionadasValidas
|
||||
.contains(reserva.huerfanaId),
|
||||
onToggleSeleccion: () =>
|
||||
seleccionNotifier.toggle(reserva.huerfanaId),
|
||||
onWhatsApp:
|
||||
() => abrirWhatsApp(reserva),
|
||||
onReubicar: () => abrirPickerSheet(reserva),
|
||||
onResolver: (nuevoEstado) async {
|
||||
final error = await ref
|
||||
.read(huerfanasProvider.notifier)
|
||||
.resolver(reserva.huerfanaId, nuevoEstado);
|
||||
if (!context.mounted) return;
|
||||
if (error != null) {
|
||||
SomaToast.show(context,
|
||||
message: error, type: ToastType.error);
|
||||
} else {
|
||||
SomaToast.show(context,
|
||||
message: 'Estado actualizado',
|
||||
type: ToastType.success);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// ── Bottom bar de selección ──────────────────────────────────────
|
||||
if (modoSeleccion)
|
||||
_SelectionBar(
|
||||
count: seleccionadasValidas.length,
|
||||
onNotificar: seleccionadasValidas.isEmpty ? null : abrirBulkNotify,
|
||||
onCancelar: toggleModoSeleccion,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filter chip ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _FilterChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool selected;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _FilterChip({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: selected ? color.withAlpha(22) : Colors.transparent,
|
||||
border: Border.all(
|
||||
color: selected ? color.withAlpha(100) : color.withAlpha(40),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: selected ? color : color.withAlpha(150),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card de reserva huérfana ──────────────────────────────────────────────────
|
||||
|
||||
class _HuerfanaCard extends StatelessWidget {
|
||||
final ReservaHuerfana reserva;
|
||||
final bool modoSeleccion;
|
||||
final bool seleccionada;
|
||||
final VoidCallback onToggleSeleccion;
|
||||
final Future<void> Function() onWhatsApp;
|
||||
final VoidCallback onReubicar;
|
||||
final Future<void> Function(String nuevoEstado) onResolver;
|
||||
|
||||
const _HuerfanaCard({
|
||||
required this.reserva,
|
||||
required this.modoSeleccion,
|
||||
required this.seleccionada,
|
||||
required this.onToggleSeleccion,
|
||||
required this.onWhatsApp,
|
||||
required this.onReubicar,
|
||||
required this.onResolver,
|
||||
});
|
||||
|
||||
String _fmtFecha(String fechaIso) {
|
||||
final d = DateTime.tryParse(fechaIso);
|
||||
if (d == null) return fechaIso;
|
||||
return '${d.day} ${_meses[d.month]} ${d.year}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isPendiente = reserva.estado == EstadoHuerfana.pendiente;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: modoSeleccion ? onToggleSeleccion : null,
|
||||
onSecondaryTapDown: modoSeleccion
|
||||
? null
|
||||
: (details) {
|
||||
final entries = <ContextMenuEntry<String>>[];
|
||||
|
||||
if (isPendiente) {
|
||||
entries.add(MenuItem(
|
||||
label: const Text('Reubicar'),
|
||||
icon: const Icon(Icons.swap_horiz, size: 16),
|
||||
value: 'reubicar',
|
||||
));
|
||||
if (reserva.telefono != null) {
|
||||
entries.add(MenuItem(
|
||||
label: const Text('Notificar por WhatsApp'),
|
||||
icon: const Icon(Icons.chat_outlined,
|
||||
size: 16, color: Color(0xFF25D366)),
|
||||
value: 'whatsapp',
|
||||
));
|
||||
}
|
||||
entries.add(MenuItem(
|
||||
label: const Text('Marcar como resuelta'),
|
||||
icon: const Icon(Icons.notifications_none, size: 16),
|
||||
value: 'resuelta',
|
||||
));
|
||||
} else {
|
||||
if (reserva.telefono != null) {
|
||||
entries.add(MenuItem(
|
||||
label: const Text('Notificar por WhatsApp'),
|
||||
icon: const Icon(Icons.chat_outlined,
|
||||
size: 16, color: Color(0xFF25D366)),
|
||||
value: 'whatsapp',
|
||||
));
|
||||
}
|
||||
entries.add(MenuItem(
|
||||
label: const Text('Volver a pendiente'),
|
||||
icon: const Icon(Icons.undo, size: 16),
|
||||
value: 'pendiente',
|
||||
));
|
||||
}
|
||||
|
||||
showContextMenu<String>(
|
||||
context,
|
||||
contextMenu: ContextMenu<String>(
|
||||
position: details.globalPosition,
|
||||
entries: entries,
|
||||
),
|
||||
onItemSelected: (v) {
|
||||
switch (v) {
|
||||
case 'reubicar':
|
||||
onReubicar();
|
||||
case 'whatsapp':
|
||||
onWhatsApp();
|
||||
case 'resuelta':
|
||||
onResolver('resuelta');
|
||||
case 'pendiente':
|
||||
onResolver('pendiente');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border.all(
|
||||
color: modoSeleccion && seleccionada
|
||||
? SomaColors.primary.withAlpha(140)
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
width: modoSeleccion && seleccionada ? 1.5 : 0.5,
|
||||
),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Checkbox en modo selección / rail de estado normal
|
||||
if (modoSeleccion)
|
||||
_SelectionRail(seleccionada: seleccionada)
|
||||
else
|
||||
Container(
|
||||
width: 4,
|
||||
color: switch (reserva.estado) {
|
||||
EstadoHuerfana.pendiente => SomaColors.error.withAlpha(180),
|
||||
EstadoHuerfana.reubicado => SomaColors.primary.withAlpha(180),
|
||||
EstadoHuerfana.resuelta =>
|
||||
theme.colorScheme.secondary.withAlpha(180),
|
||||
},
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Row superior: avatar + info + badge
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: SomaColors.primary.withAlpha(30),
|
||||
child: Text(
|
||||
reserva.initials,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SomaColors.primaryText,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
reserva.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (reserva.telefono != null)
|
||||
Text(
|
||||
reserva.telefono!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurface
|
||||
.withAlpha(130),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!modoSeleccion) _EstadoBadge(estado: reserva.estado),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Actividad + fecha
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.sports_gymnastics_outlined,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurface.withAlpha(120)),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(
|
||||
child: Text(
|
||||
reserva.actividadNombre,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onSurface.withAlpha(180),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.calendar_today_outlined,
|
||||
size: 13,
|
||||
color: theme.colorScheme.onSurface.withAlpha(100)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${_fmtFecha(reserva.fechaOriginal)} ${reserva.horaInicioOriginal}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurface.withAlpha(150),
|
||||
fontFeatures: const [
|
||||
FontFeature.tabularFigures()
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Acciones (sólo cuando no estamos en modo selección)
|
||||
if (!modoSeleccion) ...[
|
||||
if (isPendiente) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
_ActionButton(
|
||||
icon: Icons.chat_outlined,
|
||||
label: 'WhatsApp',
|
||||
color: const Color(0xFF25D366),
|
||||
onTap: onWhatsApp,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ActionButton(
|
||||
icon: Icons.swap_horiz,
|
||||
label: 'Reubicar',
|
||||
color: SomaColors.primary,
|
||||
onTap: () => onReubicar(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ActionButton(
|
||||
icon: Icons.notifications_none,
|
||||
label: 'Resuelta',
|
||||
color: theme.colorScheme.secondary,
|
||||
onTap: () => onResolver('resuelta'),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
_ActionButton(
|
||||
icon: Icons.chat_outlined,
|
||||
label: 'WhatsApp',
|
||||
color: const Color(0xFF25D366),
|
||||
onTap: onWhatsApp,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () => onResolver('pendiente'),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
'Volver a pendiente',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.onSurface
|
||||
.withAlpha(120),
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor:
|
||||
theme.colorScheme.onSurface
|
||||
.withAlpha(80),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rail de selección ─────────────────────────────────────────────────────────
|
||||
|
||||
class _SelectionRail extends StatelessWidget {
|
||||
final bool seleccionada;
|
||||
const _SelectionRail({required this.seleccionada});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: 40,
|
||||
color: seleccionada
|
||||
? SomaColors.primary.withAlpha(20)
|
||||
: Colors.transparent,
|
||||
child: Center(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: seleccionada ? SomaColors.primary : Colors.transparent,
|
||||
border: Border.all(
|
||||
color: seleccionada
|
||||
? SomaColors.primary
|
||||
: Theme.of(context).colorScheme.onSurface.withAlpha(80),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: seleccionada
|
||||
? const Icon(Icons.check, size: 12, color: SomaColors.onPrimary)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Badge de estado ───────────────────────────────────────────────────────────
|
||||
|
||||
class _EstadoBadge extends StatelessWidget {
|
||||
final EstadoHuerfana estado;
|
||||
const _EstadoBadge({required this.estado});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final (label, color) = switch (estado) {
|
||||
EstadoHuerfana.pendiente => ('Pendiente', SomaColors.error),
|
||||
EstadoHuerfana.reubicado => ('Reubicado', SomaColors.primary),
|
||||
EstadoHuerfana.resuelta =>
|
||||
('Resuelta', theme.colorScheme.secondary),
|
||||
};
|
||||
final textColor = estado == EstadoHuerfana.reubicado
|
||||
? SomaColors.primaryText
|
||||
: color;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(18),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
border: Border.all(color: color.withAlpha(60), width: 0.5),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Botón de acción ───────────────────────────────────────────────────────────
|
||||
|
||||
class _ActionButton extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
final dynamic Function() onTap;
|
||||
|
||||
const _ActionButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ActionButton> createState() => _ActionButtonState();
|
||||
}
|
||||
|
||||
class _ActionButtonState extends State<_ActionButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: () => widget.onTap(),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
color: widget.color.withAlpha(_hovered ? 38 : 16),
|
||||
border: Border.all(
|
||||
color: widget.color.withAlpha(_hovered ? 110 : 60),
|
||||
width: _hovered ? 0.8 : 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(widget.icon, size: 13, color: widget.color),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
widget.label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: widget.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bottom bar de selección ───────────────────────────────────────────────────
|
||||
|
||||
class _SelectionBar extends StatelessWidget {
|
||||
final int count;
|
||||
final VoidCallback? onNotificar;
|
||||
final VoidCallback onCancelar;
|
||||
|
||||
const _SelectionBar({
|
||||
required this.count,
|
||||
required this.onNotificar,
|
||||
required this.onCancelar,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
count == 0
|
||||
? 'Sin selección'
|
||||
: count == 1
|
||||
? '1 seleccionado'
|
||||
: '$count seleccionados',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onSurface.withAlpha(160),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: onCancelar,
|
||||
child: const Text('Cancelar'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 38)),
|
||||
onPressed: onNotificar,
|
||||
icon: const Icon(Icons.chat_outlined, size: 16),
|
||||
label: Text(
|
||||
count == 0
|
||||
? 'Notificar en lote'
|
||||
: 'Notificar $count por WhatsApp',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user