Agrego frontend app
This commit is contained in:
+62
@@ -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);
|
||||
}
|
||||
+140
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user