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,44 @@
/// Cómo interactúa una edición de plantilla regular con planificaciones
/// futuras existentes para el mismo día de la semana.
///
/// El backend define tres variantes (ver §5.3 del brief de horarios):
/// * `indefinido` → borra las planificaciones futuras posteriores y deja el
/// nuevo horario sin fecha de fin.
/// * `hasta_proximo` (default backend) → respeta la próxima planificación
/// futura, cerrando el nuevo horario justo antes.
/// * `hasta` + fecha → cierra el nuevo horario en una fecha específica. Si
/// hay planificaciones futuras dentro del intervalo, el
/// backend rechaza con `ConflictoAlcance`.
sealed class Alcance {
const Alcance();
/// Serialización aceptada por `fc_insertar_horario_con_actividades`.
Map<String, dynamic> toJson();
}
class AlcanceIndefinido extends Alcance {
const AlcanceIndefinido();
@override
Map<String, dynamic> toJson() => const {'tipo': 'indefinido'};
}
class AlcanceHastaProximo extends Alcance {
const AlcanceHastaProximo();
@override
Map<String, dynamic> toJson() => const {'tipo': 'hasta_proximo'};
}
class AlcanceHasta extends Alcance {
final DateTime fecha;
const AlcanceHasta(this.fecha);
@override
Map<String, dynamic> toJson() {
final f = '${fecha.year}-${fecha.month.toString().padLeft(2, '0')}-'
'${fecha.day.toString().padLeft(2, '0')}';
return {'tipo': 'hasta', 'fecha': f};
}
}
@@ -0,0 +1,63 @@
class BloqueActividadEspecial {
final int id;
final String horaInicio;
final String horaFin;
final int actividadId;
final String actividadNombre;
final int actividadDuracion;
const BloqueActividadEspecial({
required this.id,
required this.horaInicio,
required this.horaFin,
required this.actividadId,
required this.actividadNombre,
required this.actividadDuracion,
});
factory BloqueActividadEspecial.fromMap(Map<String, dynamic> m) {
final act = m['actividad'] as Map<String, dynamic>;
return BloqueActividadEspecial(
id: m['id'] as int,
horaInicio: m['hora_inicio'] as String,
horaFin: m['hora_fin'] as String,
actividadId: act['id'] as int,
actividadNombre: act['nombre'] as String,
actividadDuracion: act['duracion'] as int,
);
}
}
class DiaEspecialResumen {
final DateTime fecha;
final String tipo; // 'cerrado' | 'horario_diferente'
final String? motivo;
final List<BloqueActividadEspecial> rangos;
const DiaEspecialResumen({
required this.fecha,
required this.tipo,
this.motivo,
required this.rangos,
});
bool get esCerrado => tipo == 'cerrado';
/// Construye un resumen a partir del item de día devuelto por
/// `fc_obtener_horarios` (donde la fecha viene como clave del objeto raíz y
/// el valor trae `tipo`, `motivo`, `horarios`).
factory DiaEspecialResumen.fromHorariosResponse(
DateTime fecha,
Map<String, dynamic> m,
) {
final rawHorarios = m['horarios'] as List<dynamic>? ?? [];
return DiaEspecialResumen(
fecha: fecha,
tipo: m['tipo'] as String,
motivo: m['motivo'] as String?,
rangos: rawHorarios
.map((e) => BloqueActividadEspecial.fromMap(e as Map<String, dynamic>))
.toList(),
);
}
}
@@ -0,0 +1,127 @@
import 'package:supabase_flutter/supabase_flutter.dart';
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
String _fmt(DateTime d) => '${d.day} ${_mesesCortos[d.month]} ${d.year}';
/// Errores tipados que el módulo de horarios produce al consumir la API de
/// backend. El traductor [fromException] mapea mensajes conocidos de las
/// funciones PL/pgSQL a una variante específica; mensajes no reconocidos
/// caen en [Desconocido] conservando el texto original.
sealed class HorarioError {
const HorarioError();
/// Texto en español listo para mostrar al usuario. Distinto del raw del
/// backend: explica el problema y, donde aplica, sugiere la acción.
String mensajeUsuario();
static final RegExp _conflictoRegex = RegExp(
r'Conflicto: existe una planificación con valido_desde = '
r'(\d{4}-\d{2}-\d{2}) dentro del intervalo '
r'\[(\d{4}-\d{2}-\d{2}), (\d{4}-\d{2}-\d{2})\]',
);
static final RegExp _alcanceFechaRegex = RegExp(
r'alcance\.fecha \((\d{4}-\d{2}-\d{2})\) no puede ser anterior a '
r'valido_desde \((\d{4}-\d{2}-\d{2})\)',
);
factory HorarioError.fromException(Object e) {
final raw = e is PostgrestException
? e.message
: e.toString().replaceFirst('Exception: ', '');
final mConflicto = _conflictoRegex.firstMatch(raw);
if (mConflicto != null) {
final vd = DateTime.tryParse(mConflicto.group(1)!);
final id = DateTime.tryParse(mConflicto.group(2)!);
final ih = DateTime.tryParse(mConflicto.group(3)!);
if (vd != null && id != null && ih != null) {
return ConflictoAlcance(
validoDesdeConflicto: vd,
intervaloDesde: id,
intervaloHasta: ih,
);
}
}
final mAlcance = _alcanceFechaRegex.firstMatch(raw);
if (mAlcance != null) {
final af = DateTime.tryParse(mAlcance.group(1)!);
final vd = DateTime.tryParse(mAlcance.group(2)!);
if (af != null && vd != null) {
return AlcanceFechaAnterior(alcanceFecha: af, validoDesde: vd);
}
}
if (raw.contains('valido_desde no puede ser una fecha pasada')) {
return const FechaPasada();
}
if (raw.contains('No se pueden alterar horarios en fechas pasadas')) {
return const FechaPasadaEspecial();
}
return Desconocido(raw);
}
}
class ConflictoAlcance extends HorarioError {
final DateTime validoDesdeConflicto;
final DateTime intervaloDesde;
final DateTime intervaloHasta;
const ConflictoAlcance({
required this.validoDesdeConflicto,
required this.intervaloDesde,
required this.intervaloHasta,
});
@override
String mensajeUsuario() =>
'Ya hay un horario planificado para el ${_fmt(validoDesdeConflicto)}, '
'que cae dentro del rango elegido (${_fmt(intervaloDesde)}'
'${_fmt(intervaloHasta)}). Cambiá el alcance o eliminá esa '
'planificación antes de continuar.';
}
class FechaPasada extends HorarioError {
const FechaPasada();
@override
String mensajeUsuario() =>
'La fecha de vigencia no puede ser anterior a hoy.';
}
class AlcanceFechaAnterior extends HorarioError {
final DateTime alcanceFecha;
final DateTime validoDesde;
const AlcanceFechaAnterior({
required this.alcanceFecha,
required this.validoDesde,
});
@override
String mensajeUsuario() =>
'La fecha de fin (${_fmt(alcanceFecha)}) no puede ser anterior al '
'inicio de vigencia (${_fmt(validoDesde)}).';
}
class FechaPasadaEspecial extends HorarioError {
const FechaPasadaEspecial();
@override
String mensajeUsuario() =>
'No se pueden modificar horarios en fechas pasadas.';
}
class Desconocido extends HorarioError {
final String raw;
const Desconocido(this.raw);
@override
String mensajeUsuario() => raw;
}
@@ -0,0 +1,131 @@
class BloqueActividadInfo {
final int id;
final String nombre;
final int duracion;
final int capacidad;
const BloqueActividadInfo({
required this.id,
required this.nombre,
required this.duracion,
required this.capacidad,
});
factory BloqueActividadInfo.fromMap(Map<String, dynamic> m) {
return BloqueActividadInfo(
id: m['id'] as int,
nombre: m['nombre'] as String,
duracion: m['duracion'] as int,
capacidad: m['capacidad'] as int,
);
}
}
class BloqueHorario {
final int id;
final String horaInicio;
final String horaFin;
final BloqueActividadInfo actividad;
const BloqueHorario({
required this.id,
required this.horaInicio,
required this.horaFin,
required this.actividad,
});
factory BloqueHorario.fromMap(Map<String, dynamic> m) {
return BloqueHorario(
id: m['id'] as int,
horaInicio: m['hora_inicio'] as String,
horaFin: m['hora_fin'] as String,
actividad:
BloqueActividadInfo.fromMap(m['actividad'] as Map<String, dynamic>),
);
}
}
enum TipoDia { normal, horarioDiferente, cerrado }
class DiaHorarios {
final DateTime fecha;
final int diaSemana;
final TipoDia tipo;
final String? motivo;
final List<BloqueHorario> bloques;
/// Solo presente para días `normal`: cuándo entró a regir la plantilla.
final DateTime? validoDesde;
/// Solo presente para días `normal`: cuándo deja de regir la plantilla
/// (`null` = vigencia indefinida).
final DateTime? validoHasta;
const DiaHorarios({
required this.fecha,
required this.diaSemana,
required this.tipo,
this.motivo,
required this.bloques,
this.validoDesde,
this.validoHasta,
});
bool get esCerrado => tipo == TipoDia.cerrado;
bool get esEspecial => tipo != TipoDia.normal;
factory DiaHorarios.fromMap(DateTime fecha, Map<String, dynamic> m) {
final tipoStr = m['tipo'] as String;
final tipo = switch (tipoStr) {
'cerrado' => TipoDia.cerrado,
'horario_diferente' => TipoDia.horarioDiferente,
_ => TipoDia.normal,
};
final rawBloques = m['horarios'] as List<dynamic>? ?? [];
final validoDesdeStr = m['valido_desde'] as String?;
final validoHastaStr = m['valido_hasta'] as String?;
return DiaHorarios(
fecha: fecha,
diaSemana: m['dia_semana'] as int,
tipo: tipo,
motivo: m['motivo'] as String?,
bloques: rawBloques
.map((e) => BloqueHorario.fromMap(e as Map<String, dynamic>))
.toList(),
validoDesde:
validoDesdeStr != null ? DateTime.tryParse(validoDesdeStr) : null,
validoHasta:
validoHastaStr != null ? DateTime.tryParse(validoHastaStr) : null,
);
}
}
class SemanaHorarios {
final DateTime weekStart;
final Map<String, DiaHorarios> _byKey;
SemanaHorarios({required this.weekStart, required Map<String, DiaHorarios> byKey})
: _byKey = byKey;
factory SemanaHorarios.fromResponse(DateTime weekStart, Map<String, dynamic> raw) {
final byKey = <String, DiaHorarios>{};
for (final entry in raw.entries) {
final fecha = DateTime.tryParse(entry.key);
if (fecha == null) continue;
final diaMap = entry.value;
if (diaMap is Map<String, dynamic>) {
byKey[entry.key] = DiaHorarios.fromMap(fecha, diaMap);
}
}
return SemanaHorarios(weekStart: weekStart, byKey: byKey);
}
String _key(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
DiaHorarios? diaPara(DateTime fecha) => _byKey[_key(fecha)];
List<DiaHorarios> get dias => _byKey.values.toList()
..sort((a, b) => a.fecha.compareTo(b.fecha));
bool contieneFecha(DateTime fecha) => _byKey.containsKey(_key(fecha));
}
@@ -0,0 +1,27 @@
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
/// Plantilla regular futura para un día de la semana, tal como la devuelve
/// `fc_obtener_planificaciones_futuras`. Sólo se incluyen plantillas cuyo
/// `valido_desde` es estrictamente posterior al `p_desde` consultado.
class PlanificacionFutura {
final DateTime validoDesde;
final DateTime? validoHasta;
final List<BloqueHorario> rangos;
const PlanificacionFutura({
required this.validoDesde,
this.validoHasta,
required this.rangos,
});
factory PlanificacionFutura.fromMap(Map<String, dynamic> m) {
final vh = m['valido_hasta'] as String?;
return PlanificacionFutura(
validoDesde: DateTime.parse(m['valido_desde'] as String),
validoHasta: vh != null ? DateTime.tryParse(vh) : null,
rangos: (m['rangos'] as List<dynamic>? ?? [])
.map((e) => BloqueHorario.fromMap(e as Map<String, dynamic>))
.toList(),
);
}
}
@@ -0,0 +1,48 @@
import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart';
abstract class HorariosRepository {
/// Obtener horarios de una semana (7 días desde [weekStart]).
Future<SemanaHorarios> obtenerSemana(DateTime weekStart);
/// Guardar horario para un día (PUT: reemplaza todos los bloques).
/// [esEspecial]=false → SCD upsert en horario regular.
/// [esEspecial]=true + bloques vacíos → marca el día como cerrado.
/// [esEspecial]=true + bloques → crea horario_diferente.
///
/// [validoDesde] y [alcance] **sólo aplican a día regular**; el backend
/// los ignora cuando [esEspecial] es true. Si vienen `null`, no se mandan
/// y el backend usa sus defaults (`valido_desde = hoy`,
/// `alcance = hasta_proximo`).
Future<void> guardarDia({
required DateTime fecha,
required bool esEspecial,
String? motivo,
required List<Map<String, dynamic>> bloques,
DateTime? validoDesde,
Alcance? alcance,
});
/// Eliminar día especial (restaura el horario normal para esa fecha).
Future<void> eliminarDiaEspecial(DateTime fecha);
/// Listar los días especiales programados en una ventana hacia adelante.
/// [dias] = cantidad de días a inspeccionar desde hoy (default 90).
Future<List<DiaEspecialResumen>> listarDiasEspeciales({int dias = 90});
/// Plantillas regulares con `valido_desde` estrictamente posterior a [desde]
/// para el [diaSemana] dado (1=lunes ... 7=domingo, ISODOW). Ventana de
/// inspección controlada por [meses] (default 6, máx 24).
Future<List<PlanificacionFutura>> futurosParaDiaSemana({
required int diaSemana,
required DateTime desde,
int meses = 6,
});
/// Cuenta las reservas huérfanas generadas desde [instante] (UTC).
/// Usado para informar al admin cuántas reservas quedaron sin turno
/// tras una operación de escritura en horarios.
Future<int> contarHuerfanasDesde(DateTime instante);
}