Agrego frontend app

This commit is contained in:
Pablo
2026-08-22 19:08:19 -03:00
parent 824e092b29
commit 2d0797e627
254 changed files with 37231 additions and 0 deletions
@@ -0,0 +1,62 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/config/supabase_config.dart';
import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart';
import 'package:gimnasio_soma/features/huerfanas/domain/repositories/huerfanas_repository.dart';
class HuerfanasRepositoryImpl implements HuerfanasRepository {
Future<String> _getToken() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token == null) throw Exception('Sin sesión activa');
return token;
}
@override
Future<List<ReservaHuerfana>> obtenerHuerfanas({String? estado}) async {
final token = await _getToken();
final params = <String, dynamic>{'p_token': token};
if (estado != null) params['p_estado'] = estado;
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerReservasHuerfanas,
params: params,
);
if (response is List) {
return response
.map((e) => ReservaHuerfana.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<void> resolverHuerfana(String huerfanaId, String nuevoEstado) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcResolverHuerfana,
params: {
'p_token': token,
'p_huerfana_id': huerfanaId,
'p_nuevo_estado': nuevoEstado,
},
);
}
@override
Future<void> moverHuerfana(String huerfanaId, String turnoId) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcMoverReservaHuerfana,
params: {
'p_token': token,
'p_huerfana_id': huerfanaId,
'p_turno_id': turnoId,
},
);
}
}
@@ -0,0 +1,59 @@
enum EstadoHuerfana { pendiente, reubicado, resuelta }
class ReservaHuerfana {
final String huerfanaId;
final String clienteId;
final String nombre;
final String? apellido;
final String? telefono;
final String actividadNombre;
final String fechaOriginal;
final String horaInicioOriginal;
final EstadoHuerfana estado;
final String creadaEn;
const ReservaHuerfana({
required this.huerfanaId,
required this.clienteId,
required this.nombre,
this.apellido,
this.telefono,
required this.actividadNombre,
required this.fechaOriginal,
required this.horaInicioOriginal,
required this.estado,
required this.creadaEn,
});
String get displayName =>
apellido != null ? '$nombre $apellido' : nombre;
String get initials {
final parts = displayName.trim().split(' ');
if (parts.length == 1) return parts[0][0].toUpperCase();
return '${parts[0][0]}${parts.last[0]}'.toUpperCase();
}
static EstadoHuerfana _parseEstado(String s) {
return switch (s) {
'reubicado' => EstadoHuerfana.reubicado,
'resuelta' => EstadoHuerfana.resuelta,
_ => EstadoHuerfana.pendiente,
};
}
factory ReservaHuerfana.fromMap(Map<String, dynamic> m) {
return ReservaHuerfana(
huerfanaId: m['huerfana_id'] as String,
clienteId: m['cliente_id'] as String,
nombre: m['nombre'] as String,
apellido: m['apellido'] as String?,
telefono: m['telefono'] as String?,
actividadNombre: m['actividad_nombre'] as String,
fechaOriginal: m['fecha_original'] as String,
horaInicioOriginal: m['hora_inicio_original'] as String,
estado: _parseEstado(m['estado_resolucion'] as String? ?? 'pendiente'),
creadaEn: m['creada_en'] as String,
);
}
}
@@ -0,0 +1,13 @@
import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart';
abstract class HuerfanasRepository {
/// [estado] puede ser 'pendiente', 'reubicado', 'resuelta', o null para todos.
Future<List<ReservaHuerfana>> obtenerHuerfanas({String? estado});
/// [nuevoEstado] debe ser 'pendiente', 'reubicado' o 'resuelta'.
Future<void> resolverHuerfana(String huerfanaId, String nuevoEstado);
/// Reserva [turnoId] para el cliente de la huérfana y la marca como 'reubicado'
/// en una sola transacción atómica.
Future<void> moverHuerfana(String huerfanaId, String turnoId);
}
@@ -0,0 +1,140 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:gimnasio_soma/features/huerfanas/data/repositories/huerfanas_repository_impl.dart';
import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart';
import 'package:gimnasio_soma/features/huerfanas/domain/repositories/huerfanas_repository.dart';
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
final huerfanasRepositoryProvider = Provider<HuerfanasRepository>((ref) {
return HuerfanasRepositoryImpl();
});
String _errorMessage(Object e) {
if (e is PostgrestException) return e.message;
return e.toString().replaceFirst('Exception: ', '');
}
final huerfanasProvider =
StateNotifierProvider<HuerfanasNotifier, AsyncValue<List<ReservaHuerfana>>>(
(ref) {
return HuerfanasNotifier(ref, ref.read(huerfanasRepositoryProvider));
});
class HuerfanasNotifier
extends StateNotifier<AsyncValue<List<ReservaHuerfana>>> {
final Ref _ref;
final HuerfanasRepository _repository;
String? _currentEstado = 'pendiente';
HuerfanasNotifier(this._ref, this._repository)
: super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
try {
final data = await _repository.obtenerHuerfanas(estado: _currentEstado);
state = AsyncValue.data(data);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<void> filtrar(String? estado) async {
_currentEstado = estado;
await load();
}
String? get currentEstado => _currentEstado;
/// Retorna null si tuvo éxito, o un mensaje de error.
Future<String?> resolver(String huerfanaId, String nuevoEstado) async {
try {
await _repository.resolverHuerfana(huerfanaId, nuevoEstado);
await load();
return null;
} catch (e) {
return _errorMessage(e);
}
}
/// Reserva el turno y marca la huérfana como 'reubicado' atómicamente.
/// Retorna null si tuvo éxito, o un mensaje de error.
///
/// La reubicación ocupa un cupo en [turnoId]. Invalidamos turnosProvider
/// para que la pantalla de Turnos no muestre un cupo desactualizado si ya
/// tenía esa semana cacheada de antes.
Future<String?> mover(String huerfanaId, String turnoId) async {
try {
await _repository.moverHuerfana(huerfanaId, turnoId);
await load();
_ref.invalidate(turnosProvider);
return null;
} catch (e) {
return _errorMessage(e);
}
}
/// Marca todas las huérfanas del conjunto como 'resuelta' (best-effort).
Future<void> notificarLote(Iterable<String> ids) async {
for (final id in ids) {
try {
await _repository.resolverHuerfana(id, 'resuelta');
} catch (_) {
// best-effort: continúa con las demás aunque alguna falle
}
}
await load();
}
}
// ── Selección múltiple ────────────────────────────────────────────────────────
final huerfanasModoSeleccionProvider = StateProvider<bool>((ref) => false);
class _SeleccionNotifier extends StateNotifier<Set<String>> {
_SeleccionNotifier() : super({});
void toggle(String id) {
final next = {...state};
if (next.contains(id)) {
next.remove(id);
} else {
next.add(id);
}
state = next;
}
void limpiar() => state = {};
}
final huerfanasSeleccionProvider =
StateNotifierProvider<_SeleccionNotifier, Set<String>>(
(ref) => _SeleccionNotifier());
// ── Badge sidebar ─────────────────────────────────────────────────────────────
/// Cantidad de reservas huérfanas pendientes — usado para el badge en sidebar.
///
/// Observa [huerfanasProvider] para recomputarse tras cualquier mutación.
/// Si el filtro activo es 'pendiente' o null derivamos el conteo en memoria
/// (sin RPC extra). Si el filtro es otro, hacemos una consulta independiente.
final huerfanasPendienteCountProvider =
FutureProvider.autoDispose<int>((ref) async {
final state = ref.watch(huerfanasProvider);
final notifier = ref.read(huerfanasProvider.notifier);
final lista = state.valueOrNull;
if (lista != null) {
if (notifier.currentEstado == 'pendiente') return lista.length;
if (notifier.currentEstado == null) {
return lista.where((r) => r.estado == EstadoHuerfana.pendiente).length;
}
}
// Filtro activo no es pendiente/todas: hacemos la consulta directa.
final repo = ref.read(huerfanasRepositoryProvider);
final pendientes = await repo.obtenerHuerfanas(estado: 'pendiente');
return pendientes.length;
});
@@ -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',
),
),
],
),
);
}
}
@@ -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),
),
),
],
),
),
),
],
),
),
),
),
);
}
}