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,175 @@
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/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';
import 'package:gimnasio_soma/features/horarios/domain/repositories/horarios_repository.dart';
class HorariosRepositoryImpl implements HorariosRepository {
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;
}
String _formatDate(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
@override
Future<SemanaHorarios> obtenerSemana(DateTime weekStart) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerHorarios,
params: {
'p_token': token,
'p_fecha_inicio': _formatDate(weekStart),
'p_cantidad_dias': 7,
},
);
if (response is Map<String, dynamic>) {
return SemanaHorarios.fromResponse(weekStart, response);
}
return SemanaHorarios.fromResponse(weekStart, {});
}
@override
Future<void> guardarDia({
required DateTime fecha,
required bool esEspecial,
String? motivo,
required List<Map<String, dynamic>> bloques,
DateTime? validoDesde,
Alcance? alcance,
}) async {
final token = await _getToken();
final datos = <String, dynamic>{
'fecha': _formatDate(fecha),
'es_especial': esEspecial,
'rangos': bloques,
};
if (esEspecial && motivo != null && motivo.isNotEmpty) {
datos['motivo'] = motivo;
}
if (!esEspecial) {
if (validoDesde != null) {
datos['valido_desde'] = _formatDate(validoDesde);
}
if (alcance != null) {
datos['alcance'] = alcance.toJson();
}
}
await SupabaseConfig.rpc(
AppConstants.rpcInsertHorarioConActividades,
params: {
'p_token': token,
'p_datos': datos,
},
);
}
@override
Future<void> eliminarDiaEspecial(DateTime fecha) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcEliminarDiaEspecial,
params: {
'p_token': token,
'p_fecha': _formatDate(fecha),
},
);
}
@override
Future<List<DiaEspecialResumen>> listarDiasEspeciales({int dias = 90}) async {
final token = await _getToken();
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
// fc_obtener_horarios acepta p_cantidad_dias entre 1 y 31, así que partimos
// el rango pedido en chunks de hasta 31 días y disparamos las llamadas en
// paralelo.
const chunkSize = 31;
final futures = <Future<dynamic>>[];
for (var offset = 0; offset < dias; offset += chunkSize) {
final restantes = dias - offset;
final tamano = restantes < chunkSize ? restantes : chunkSize;
futures.add(
SupabaseConfig.rpc(
AppConstants.rpcObtenerHorarios,
params: {
'p_token': token,
'p_fecha_inicio': _formatDate(hoy.add(Duration(days: offset))),
'p_cantidad_dias': tamano,
},
),
);
}
final responses = await Future.wait(futures);
final resumen = <DiaEspecialResumen>[];
for (final response in responses) {
if (response is! Map<String, dynamic>) continue;
for (final entry in response.entries) {
final dayMap = entry.value;
if (dayMap is! Map<String, dynamic>) continue;
final tipo = dayMap['tipo'] as String?;
if (tipo == null || tipo == 'normal') continue;
final fecha = DateTime.tryParse(entry.key);
if (fecha == null) continue;
resumen.add(DiaEspecialResumen.fromHorariosResponse(fecha, dayMap));
}
}
resumen.sort((a, b) => a.fecha.compareTo(b.fecha));
return resumen;
}
@override
Future<List<PlanificacionFutura>> futurosParaDiaSemana({
required int diaSemana,
required DateTime desde,
int meses = 6,
}) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerPlanificacionesFuturas,
params: {
'p_token': token,
'p_dia_semana': diaSemana,
'p_desde': _formatDate(desde),
'p_meses': meses,
},
);
if (response is List) {
return response
.map((e) => PlanificacionFutura.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<int> contarHuerfanasDesde(DateTime instante) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerReservasHuerfanas,
params: {
'p_token': token,
'p_creada_desde': instante.toIso8601String(),
},
);
if (response is List) return response.length;
return 0;
}
}
@@ -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);
}
@@ -0,0 +1,177 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/features/horarios/data/repositories/horarios_repository_impl.dart';
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_error.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/domain/repositories/horarios_repository.dart';
final horariosRepositoryProvider = Provider<HorariosRepository>((ref) {
return HorariosRepositoryImpl();
});
/// Semana actual de horarios.
final horariosProvider =
StateNotifierProvider<HorariosNotifier, AsyncValue<SemanaHorarios?>>((ref) {
return HorariosNotifier(ref, ref.read(horariosRepositoryProvider));
});
class HorariosNotifier extends StateNotifier<AsyncValue<SemanaHorarios?>> {
final Ref _ref;
final HorariosRepository _repository;
DateTime? _currentWeekStart;
HorariosNotifier(this._ref, this._repository)
: super(const AsyncValue.data(null));
/// Refresca las vistas que dependen de los mismos datos que la semana pero
/// que viven en otros providers: la lista de días especiales (puntos
/// naranjas del calendario + pestaña "Especiales") y el mapa de cambios de
/// plantilla a futuro (puntos azules). Se llama tras cada escritura para que
/// ninguna vista quede desincronizada.
///
/// Usa `invalidate` y no `load()` a propósito: si se hacen varias escrituras
/// seguidas —p. ej. copiar un día a varios destinos— las invalidaciones se
/// fusionan en una sola recarga por vista en vez de una por escritura.
void _refrescarDerivados() {
_ref.invalidate(diasEspecialesProvider);
_ref.invalidate(diasCambioProvider);
}
Future<void> cargarSemana(DateTime weekStart, {bool force = false}) async {
final monday = _toMonday(weekStart);
if (!force && _currentWeekStart != null && _currentWeekStart == monday) {
return;
}
_currentWeekStart = monday;
state = const AsyncValue.loading();
try {
final data = await _repository.obtenerSemana(monday);
state = AsyncValue.data(data);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<void> refrescar() async {
if (_currentWeekStart == null) return;
await cargarSemana(_currentWeekStart!, force: true);
}
/// Guarda el horario de un día y refresca la semana.
///
/// Retorna `(null, N)` si tuvo éxito, donde N es la cantidad de reservas
/// que quedaron huérfanas a raíz de la operación (0 = ninguna).
/// Retorna `(HorarioError, 0)` si hubo error.
///
/// [validoDesde] y [alcance] sólo aplican a día regular; el backend los
/// ignora cuando [esEspecial] es true. Si vienen null se usan los defaults
/// del backend (hoy, `hasta_proximo`).
Future<(HorarioError?, int)> guardarDia({
required DateTime fecha,
required bool esEspecial,
String? motivo,
required List<Map<String, dynamic>> bloques,
DateTime? validoDesde,
Alcance? alcance,
}) async {
final preOp = DateTime.now().toUtc();
try {
await _repository.guardarDia(
fecha: fecha,
esEspecial: esEspecial,
motivo: motivo,
bloques: bloques,
validoDesde: validoDesde,
alcance: alcance,
);
await refrescar();
_refrescarDerivados();
final n = await _repository.contarHuerfanasDesde(preOp);
return (null, n);
} catch (e) {
return (HorarioError.fromException(e), 0);
}
}
/// Elimina la excepción de un día especial y refresca.
///
/// Retorna `(null, N)` si tuvo éxito (N = huérfanas generadas),
/// o `(HorarioError, 0)` si hubo error.
Future<(HorarioError?, int)> eliminarDiaEspecial(DateTime fecha) async {
final preOp = DateTime.now().toUtc();
try {
await _repository.eliminarDiaEspecial(fecha);
await refrescar();
_refrescarDerivados();
final n = await _repository.contarHuerfanasDesde(preOp);
return (null, n);
} catch (e) {
return (HorarioError.fromException(e), 0);
}
}
DateTime _toMonday(DateTime d) =>
d.subtract(Duration(days: d.weekday - 1));
}
/// Lista de días especiales para la vista auxiliar.
final diasEspecialesProvider =
StateNotifierProvider<DiasEspecialesNotifier, AsyncValue<List<DiaEspecialResumen>>>(
(ref) {
return DiasEspecialesNotifier(ref.read(horariosRepositoryProvider));
});
class DiasEspecialesNotifier
extends StateNotifier<AsyncValue<List<DiaEspecialResumen>>> {
final HorariosRepository _repository;
DiasEspecialesNotifier(this._repository) : super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
try {
final data = await _repository.listarDiasEspeciales();
data.sort((a, b) => a.fecha.compareTo(b.fecha));
state = AsyncValue.data(data);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
}
/// Días (normalizados a medianoche) en los que arranca una nueva plantilla
/// regular dentro de los próximos 12 meses. Alimenta los puntos azules del
/// calendario.
///
/// Es un [FutureProvider] para que el panel lo lea de forma perezosa (sólo al
/// abrirse) y para que [HorariosNotifier] pueda invalidarlo tras cada
/// escritura, manteniéndolo en sync con el resto de las vistas.
final diasCambioProvider = FutureProvider<Set<DateTime>>((ref) async {
final repo = ref.watch(horariosRepositoryProvider);
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
final results = await Future.wait(
List.generate(
7,
(i) => repo.futurosParaDiaSemana(
diaSemana: i + 1,
desde: hoy,
meses: 12,
),
),
);
final cambios = <DateTime>{};
for (final list in results) {
for (final p in list) {
cambios.add(DateTime(
p.validoDesde.year,
p.validoDesde.month,
p.validoDesde.day,
));
}
}
return cambios;
});
@@ -0,0 +1,597 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/auth/presentation/providers/auth_provider.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/dias_especiales_view.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/copiar_dia_dialog.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/horarios_calendar_panel.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/editar_dia_dialog.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/semana_tabla_view.dart';
enum _HorariosTab { semanal, especiales }
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
const _meses = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
class HorariosScreen extends ConsumerStatefulWidget {
const HorariosScreen({super.key});
@override
ConsumerState<HorariosScreen> createState() => _HorariosScreenState();
}
class _HorariosScreenState extends ConsumerState<HorariosScreen> {
_HorariosTab _currentTab = _HorariosTab.semanal;
late DateTime _weekStart;
late DateTime _selectedDay;
List<int> _diasVisibles = [0, 1, 2, 3, 4];
@override
void initState() {
super.initState();
final today = DateTime.now();
_weekStart = _toMonday(today);
_selectedDay = today;
WidgetsBinding.instance.addPostFrameCallback((_) => _cargarSemana());
}
DateTime _toMonday(DateTime d) => d.subtract(Duration(days: d.weekday - 1));
String _fmtShort(DateTime d) => '${d.day} ${_meses[d.month]}';
String _weekLabel() {
final end = _weekStart.add(const Duration(days: 6));
return '${_fmtShort(_weekStart)} ${_fmtShort(end)} ${end.year}';
}
void _cargarSemana() {
ref.read(horariosProvider.notifier).cargarSemana(_weekStart);
}
void _prevWeek() {
setState(() {
_weekStart = _weekStart.subtract(const Duration(days: 7));
_selectedDay = _weekStart;
});
_cargarSemana();
}
void _nextWeek() {
setState(() {
_weekStart = _weekStart.add(const Duration(days: 7));
_selectedDay = _weekStart;
});
_cargarSemana();
}
bool get _isAdmin {
final user = ref.read(authStateProvider).valueOrNull;
return user != null && user.isStaff;
}
Future<void> _editarDia(DateTime fecha, DiaHorarios? dia) async {
final huerfanas = await showDialog<int>(
context: context,
builder: (_) => EditarDiaDialog(
dia: dia,
fecha: fecha,
weekStart: _weekStart,
),
);
if (!mounted) return;
final n = huerfanas ?? 0;
if (n > 0) _showHuerfanasToast(n);
}
void _showHuerfanasToast(int n) {
SomaToast.show(
context,
message: '$n ${n == 1 ? 'reserva quedó huérfana' : 'reservas quedaron huérfanas'}',
type: ToastType.info,
action: SnackBarAction(
label: 'Ver',
textColor: SomaColors.onPrimary,
onPressed: () => context.go('/huerfanas'),
),
);
}
Future<void> _eliminarBloque(
DiaHorarios dia, BloqueHorario bloque) async {
final seen = <String>{};
final restantes = <Map<String, dynamic>>[];
for (final b in dia.bloques) {
if (b.id == bloque.id) continue;
final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}';
if (!seen.add(key)) continue;
restantes.add({
'actividad_id': b.actividad.id,
'hora_inicio': b.horaInicio,
'hora_fin': b.horaFin,
});
}
final (error, huerfanas) = await ref.read(horariosProvider.notifier).guardarDia(
fecha: dia.fecha,
esEspecial: dia.esEspecial,
motivo: dia.motivo,
bloques: restantes,
);
if (!mounted) return;
if (error != null) {
SomaToast.show(
context,
message: error.mensajeUsuario(),
type: ToastType.error,
);
} else if (huerfanas > 0) {
_showHuerfanasToast(huerfanas);
}
}
void _navigateToWeek(DateTime fecha) {
setState(() {
_weekStart = _toMonday(fecha);
_selectedDay = fecha;
_currentTab = _HorariosTab.semanal;
});
_cargarSemana();
}
Future<void> _copiarDia(DiaHorarios origen, SemanaHorarios semana) async {
final huerfanas = await showDialog<int>(
context: context,
builder: (_) => CopiarDiaDialog(
origen: origen,
weekStart: _weekStart,
semana: semana,
),
);
if (!mounted) return;
if ((huerfanas ?? 0) > 0) _showHuerfanasToast(huerfanas!);
}
@override
Widget build(BuildContext context) {
final isWide = MediaQuery.of(context).size.width >= 800;
final isAdmin = _isAdmin;
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(
'Horarios',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
SomaHeaderHelp(
items: [
if (isAdmin)
const SomaHelpItem(
icon: Icons.calendar_view_week_outlined,
text: 'Semanal / Especiales: cambiá entre la grilla '
'semanal y los días especiales (feriados, eventos).',
),
const SomaHelpItem(
icon: Icons.chevron_left,
text: 'Las flechas navegan entre semanas.',
),
const SomaHelpItem(
icon: Icons.tune,
text: 'Días visibles: elegí qué días de la semana se '
'muestran en la tabla.',
),
if (isAdmin)
const SomaHelpItem(
icon: Icons.edit_outlined,
text: 'Editar día: modificá los horarios y '
'actividades del día seleccionado.',
),
if (isAdmin)
const SomaHelpItem(
icon: Icons.copy_outlined,
text: 'Copiar a...: replica los bloques del día '
'seleccionado a otros días.',
),
const SomaHelpItem(
icon: Icons.refresh,
text: 'Recarga los horarios de la semana actual.',
),
],
),
const Spacer(),
if (isAdmin) ...[
_ViewToggle(
currentTab: _currentTab,
onChanged: (tab) => setState(() => _currentTab = tab),
),
const SizedBox(width: 8),
],
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Recargar',
onPressed: () {
if (_currentTab == _HorariosTab.semanal) {
ref.read(horariosProvider.notifier).refrescar();
} else {
ref.read(diasEspecialesProvider.notifier).load();
}
},
),
],
),
),
// Content
Expanded(
child: Stack(
children: [
_currentTab == _HorariosTab.especiales && isAdmin
? const DiasEspecialesView()
: _SemanalView(
weekStart: _weekStart,
selectedDay: _selectedDay,
isAdmin: isAdmin,
isWide: isWide,
weekLabel: _weekLabel(),
onPrevWeek: _prevWeek,
onNextWeek: _nextWeek,
onDaySelected: (d) => setState(() => _selectedDay = d),
onEditarDia: _editarDia,
onCopiarDia: _copiarDia,
onEliminarBloque: isAdmin ? _eliminarBloque : null,
diasVisibles: _diasVisibles,
onDiasVisiblesChanged: (v) =>
setState(() => _diasVisibles = v),
),
Positioned(
right: 0,
top: 0,
bottom: 0,
child: HorariosCalendarPanel(
onNavigateToWeek: _navigateToWeek,
),
),
],
),
),
],
),
);
}
}
// ── View Toggle ────────────────────────────────────────────────────────────────
class _ViewToggle extends StatelessWidget {
final _HorariosTab currentTab;
final ValueChanged<_HorariosTab> onChanged;
const _ViewToggle({required this.currentTab, required this.onChanged});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: theme.inputDecorationTheme.fillColor,
),
padding: const EdgeInsets.all(2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ToggleItem(
label: 'Semanal',
selected: currentTab == _HorariosTab.semanal,
onTap: () => onChanged(_HorariosTab.semanal),
),
_ToggleItem(
label: 'Especiales',
selected: currentTab == _HorariosTab.especiales,
onTap: () => onChanged(_HorariosTab.especiales),
),
],
),
);
}
}
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 InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: selected ? theme.colorScheme.surface : Colors.transparent,
boxShadow: selected
? [
BoxShadow(
color: Colors.black.withAlpha(15),
blurRadius: 2,
offset: const Offset(0, 1),
),
]
: null,
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withAlpha(130),
),
),
),
);
}
}
// ── Vista Semanal ──────────────────────────────────────────────────────────────
class _SemanalView extends ConsumerWidget {
final DateTime weekStart;
final DateTime selectedDay;
final bool isAdmin;
final bool isWide;
final String weekLabel;
final VoidCallback onPrevWeek;
final VoidCallback onNextWeek;
final ValueChanged<DateTime> onDaySelected;
final Future<void> Function(DateTime, DiaHorarios?) onEditarDia;
final Future<void> Function(DiaHorarios, SemanaHorarios) onCopiarDia;
final Future<void> Function(DiaHorarios, BloqueHorario)? onEliminarBloque;
final List<int> diasVisibles;
final ValueChanged<List<int>> onDiasVisiblesChanged;
const _SemanalView({
required this.weekStart,
required this.selectedDay,
required this.isAdmin,
required this.isWide,
required this.weekLabel,
required this.onPrevWeek,
required this.onNextWeek,
required this.onDaySelected,
required this.onEditarDia,
required this.onCopiarDia,
this.onEliminarBloque,
required this.diasVisibles,
required this.onDiasVisiblesChanged,
});
void _openDiasConfig(BuildContext context) {
var local = List<int>.from(diasVisibles);
showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setLocal) => AlertDialog(
title: const Text('Días visibles'),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
content: SizedBox(
width: 260,
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(7, (i) {
final checked = local.contains(i);
return CheckboxListTile(
title: Text(_diasSemana[i]),
value: checked,
activeColor: SomaColors.primary,
checkColor: SomaColors.onPrimary,
onChanged: (local.length == 1 && checked)
? null
: (val) {
setLocal(() {
if (val == true) {
local = ([...local, i])..sort();
} else {
local =
local.where((d) => d != i).toList();
}
});
onDiasVisiblesChanged(local);
},
);
}),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Listo'),
),
],
),
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(horariosProvider);
final theme = Theme.of(context);
final hPad = isWide ? 32.0 : 16.0;
return Column(
children: [
// Week navigation
Padding(
padding: EdgeInsets.symmetric(horizontal: hPad, vertical: 12),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: onPrevWeek,
visualDensity: VisualDensity.compact,
),
Expanded(
child: Text(
weekLabel,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: onNextWeek,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.tune, size: 20),
tooltip: 'Días visibles',
visualDensity: VisualDensity.compact,
onPressed: () => _openDiasConfig(context),
),
],
),
),
// Tabla semanal
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: () =>
ref.read(horariosProvider.notifier).refrescar(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (semana) {
if (semana == null) {
return const Center(
child: CircularProgressIndicator(color: SomaColors.primary),
);
}
final dia = semana.diaPara(selectedDay);
return Column(
children: [
Expanded(
child: SemanaTablaView(
semana: semana,
diasVisibles: diasVisibles,
weekStart: weekStart,
selectedDay: selectedDay,
isAdmin: isAdmin,
onSelectDia: onDaySelected,
onEditarDia: (fecha, dia) {
onDaySelected(fecha);
onEditarDia(fecha, dia);
},
onCopiarDia: isAdmin
? (d) => onCopiarDia(d, semana)
: null,
onEliminarBloque: onEliminarBloque,
),
),
if (isAdmin)
Padding(
padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 16),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => onEditarDia(selectedDay, dia),
icon: const Icon(Icons.edit_outlined, size: 18),
label: const Text('Editar día'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 44),
side: BorderSide(
color: SomaColors.primary.withAlpha(120)),
),
),
),
if (dia != null &&
!dia.esEspecial &&
dia.bloques.isNotEmpty) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => onCopiarDia(dia, semana),
icon: const Icon(Icons.copy_outlined, size: 18),
label: const Text('Copiar a...'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 44),
side: BorderSide(
color: theme.colorScheme.onSurface
.withAlpha(60)),
foregroundColor:
theme.colorScheme.onSurface.withAlpha(160),
),
),
],
],
),
),
],
);
},
),
),
],
);
}
}
@@ -0,0 +1,358 @@
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/features/actividades/domain/entities/actividad.dart';
import 'package:gimnasio_soma/features/actividades/presentation/providers/actividades_provider.dart';
class AgregarBloqueDialog extends ConsumerStatefulWidget {
const AgregarBloqueDialog({super.key});
@override
ConsumerState<AgregarBloqueDialog> createState() =>
_AgregarBloqueDialogState();
}
class _AgregarBloqueDialogState extends ConsumerState<AgregarBloqueDialog> {
Actividad? _actividad;
TimeOfDay _horaInicio = const TimeOfDay(hour: 8, minute: 0);
TimeOfDay _horaFin = const TimeOfDay(hour: 9, minute: 0);
int get _duracion => _actividad?.duracion ?? 0;
int get _totalMinutos =>
_timeToMinutes(_horaFin) - _timeToMinutes(_horaInicio);
int get _sobrante => _duracion > 0 ? _totalMinutos % _duracion : 0;
bool get _esFaltante =>
_duracion > 0 && _totalMinutos > 0 && _totalMinutos < _duracion;
bool get _haySobrante =>
_duracion > 0 && _totalMinutos > 0 && !_esFaltante && _sobrante > 0;
TimeOfDay get _horaFinEfectiva {
if (!_haySobrante) return _horaFin;
final mins = _timeToMinutes(_horaFin) - _sobrante;
return TimeOfDay(hour: mins ~/ 60, minute: mins % 60);
}
Future<void> _pickTime({required bool isStart}) async {
final initial = isStart ? _horaInicio : _horaFin;
final picked = await showTimePicker(
context: context,
initialTime: initial,
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: SomaColors.primary,
onPrimary: SomaColors.onPrimary,
),
),
child: child!,
);
},
);
if (picked == null) return;
setState(() {
if (isStart) {
_horaInicio = picked;
// Auto-ajustar hora fin si es menor
if (_timeToMinutes(picked) >= _timeToMinutes(_horaFin)) {
_horaFin = TimeOfDay(
hour: (picked.hour + 1) % 24,
minute: picked.minute,
);
}
} else {
_horaFin = picked;
}
});
}
int _timeToMinutes(TimeOfDay t) => t.hour * 60 + t.minute;
String _formatTime(TimeOfDay t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
void _submit() {
if (_actividad == null) return;
if (_timeToMinutes(_horaInicio) >= _timeToMinutes(_horaFin)) return;
if (_esFaltante) return;
Navigator.of(context).pop(<String, dynamic>{
'actividad_id': _actividad!.id,
'_nombre': _actividad!.nombre,
'hora_inicio': _formatTime(_horaInicio),
'hora_fin': _formatTime(_horaFinEfectiva),
});
}
@override
Widget build(BuildContext context) {
final actividadesAsync = ref.watch(actividadesProvider);
final width = MediaQuery.of(context).size.width;
final isWide = width >= 600;
final theme = Theme.of(context);
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isWide ? (width - 420) / 2 : 20,
vertical: 24,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
const Text(
'Agregar bloque',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// Form
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Actividad dropdown
actividadesAsync.when(
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
error: (e, _) => Text(
'Error cargando actividades',
style: TextStyle(color: SomaColors.error, fontSize: 13),
),
data: (actividades) {
final activas =
actividades.where((a) => a.activo).toList();
return DropdownButtonFormField<int>(
initialValue: _actividad?.id,
items: activas
.map((a) => DropdownMenuItem(
value: a.id,
child: Text(a.nombre),
))
.toList(),
onChanged: (v) {
if (v == null) return;
setState(() => _actividad =
activas.firstWhere((a) => a.id == v));
},
decoration: const InputDecoration(
labelText: 'Actividad *',
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 14),
),
validator: (v) =>
v == null ? 'Seleccioná una actividad' : null,
);
},
),
const SizedBox(height: 20),
// Hora inicio / fin
Row(
children: [
Expanded(
child: _TimePickerField(
label: 'Desde',
value: _formatTime(_horaInicio),
onTap: () => _pickTime(isStart: true),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Icon(Icons.arrow_forward,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(100)),
),
Expanded(
child: _TimePickerField(
label: 'Hasta',
value: _formatTime(_horaFin),
onTap: () => _pickTime(isStart: false),
),
),
],
),
if (_timeToMinutes(_horaInicio) >= _timeToMinutes(_horaFin))
_BloqueWarning(
icon: Icons.error_outline,
color: SomaColors.error,
message: 'La hora de fin debe ser mayor a la de inicio',
)
else if (_esFaltante)
_BloqueWarning(
icon: Icons.error_outline,
color: SomaColors.error,
message:
'El rango (${_totalMinutos}min) es menor a la duración mínima de ${_actividad!.nombre} (${_duracion}min). No se puede insertar.',
)
else if (_haySobrante)
_BloqueWarning(
icon: Icons.info_outline,
color: Colors.amber.shade700,
message:
'Se recortarán ${_sobrante}min — se insertará hasta ${_formatTime(_horaFinEfectiva)}.',
),
const SizedBox(height: 8),
],
),
),
const Divider(height: 1),
// Actions
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _actividad != null &&
_timeToMinutes(_horaInicio) <
_timeToMinutes(_horaFin) &&
!_esFaltante
? _submit
: null,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: const Text('Agregar'),
),
],
),
),
],
),
),
);
}
}
class _BloqueWarning extends StatelessWidget {
final IconData icon;
final Color color;
final String message;
const _BloqueWarning({
required this.icon,
required this.color,
required this.message,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 15, color: color),
const SizedBox(width: 6),
Expanded(
child: Text(
message,
style: TextStyle(color: color, fontSize: 12),
),
),
],
),
);
}
}
class _TimePickerField extends StatelessWidget {
final String label;
final String value;
final VoidCallback onTap;
const _TimePickerField({
required this.label,
required this.value,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: SomaColors.primary,
letterSpacing: 0.5,
),
),
const SizedBox(height: 6),
InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.inputDecorationTheme.fillColor,
),
child: Row(
children: [
Icon(
Icons.schedule,
size: 20,
color: theme.colorScheme.onSurface.withAlpha(130),
),
const SizedBox(width: 10),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
),
),
],
);
}
}
@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.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}';
/// Selector tipado para el campo `alcance` del upsert regular.
///
/// Sólo tiene sentido cuando hay [futuros]; el caller debe encargarse de
/// ocultarlo cuando la lista está vacía. La fecha límite válida para el
/// caso `hasta` se calcula a partir del primer elemento de [futuros] menos
/// un día.
class AlcanceSelector extends StatelessWidget {
final List<PlanificacionFutura> futuros;
final DateTime validoDesde;
final Alcance alcance;
final ValueChanged<Alcance> onChanged;
const AlcanceSelector({
super.key,
required this.futuros,
required this.validoDesde,
required this.alcance,
required this.onChanged,
}) : assert(futuros.length > 0,
'AlcanceSelector debe recibir al menos una planificación futura');
DateTime get _proximo => futuros.first.validoDesde;
DateTime get _lastDateHasta => _proximo.subtract(const Duration(days: 1));
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hayMultiples = futuros.length > 1;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: SomaColors.primary.withAlpha(10),
border: Border.all(
color: SomaColors.primary.withAlpha(60),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warning_amber_rounded,
size: 16, color: SomaColors.primaryText),
const SizedBox(width: 6),
Expanded(
child: Text(
hayMultiples
? 'Hay ${futuros.length} horarios planificados a futuro (próximo: ${_fmt(_proximo)})'
: 'Hay un horario planificado a partir del ${_fmt(_proximo)}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'¿Cómo interactúa este cambio con lo ya planificado?',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 10),
_AlcanceOption(
label: 'Respetar planificación futura',
sublabel:
'El nuevo horario regirá hasta el ${_fmt(_lastDateHasta)}. '
'Desde el ${_fmt(_proximo)} se mantiene lo ya planificado.',
selected: alcance is AlcanceHastaProximo,
onTap: () => onChanged(const AlcanceHastaProximo()),
),
const SizedBox(height: 6),
_AlcanceOption(
label: hayMultiples
? 'Sobrescribir todas las planificaciones futuras'
: 'Sobrescribir y eliminar la planificación futura',
sublabel: hayMultiples
? 'Se eliminarán las ${futuros.length} planificaciones futuras para este día. El nuevo horario regirá sin fecha de fin.'
: 'Se eliminará el horario planificado para el ${_fmt(_proximo)}. El nuevo horario regirá sin fecha de fin.',
selected: alcance is AlcanceIndefinido,
destructive: true,
onTap: () => onChanged(const AlcanceIndefinido()),
),
],
),
);
}
}
class _AlcanceOption extends StatelessWidget {
final String label;
final String sublabel;
final bool selected;
final bool destructive;
final VoidCallback onTap;
const _AlcanceOption({
required this.label,
required this.sublabel,
required this.selected,
required this.onTap,
this.destructive = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = destructive ? SomaColors.error : SomaColors.primary;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: selected ? color.withAlpha(18) : theme.colorScheme.surface,
border: Border.all(
color: selected
? color.withAlpha(110)
: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 1),
child: Icon(
selected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
size: 16,
color: selected
? color
: theme.colorScheme.onSurface.withAlpha(110),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
color: selected
? (destructive
? SomaColors.error
: SomaColors.primaryText)
: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
sublabel,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(150),
),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,621 @@
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/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_error.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart';
import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/alcance_selector.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/valido_desde_selector.dart';
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
const _diasCortos = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
/// Diálogo para copiar los bloques de un día regular a uno o más días de la
/// semana. Expone controles de vigencia (valido_desde + alcance) y llama
/// al upsert RPC una vez por destino seleccionado.
///
/// Retorna `int` (total de huérfanas generadas) o `null` si se canceló.
class CopiarDiaDialog extends ConsumerStatefulWidget {
final DiaHorarios origen;
final DateTime weekStart;
final SemanaHorarios semana;
const CopiarDiaDialog({
super.key,
required this.origen,
required this.weekStart,
required this.semana,
});
@override
ConsumerState<CopiarDiaDialog> createState() => _CopiarDiaDialogState();
}
class _CopiarDiaDialogState extends ConsumerState<CopiarDiaDialog> {
final Set<int> _destinos = {};
late DateTime _validoDesde;
Alcance _alcance = const AlcanceHastaProximo();
bool _saving = false;
bool _futurosLoading = false;
List<PlanificacionFutura>? _futurosCombinados;
Map<int, HorarioError>? _errores;
int get _origenIdx => widget.origen.diaSemana - 1; // ISODOW 1-based → 0-based
@override
void initState() {
super.initState();
final now = DateTime.now();
_validoDesde = DateTime(now.year, now.month, now.day);
}
Future<void> _cargarFuturos() async {
if (_destinos.isEmpty) {
setState(() {
_futurosCombinados = [];
_futurosLoading = false;
});
return;
}
setState(() {
_futurosLoading = true;
_futurosCombinados = null;
});
try {
final repo = ref.read(horariosRepositoryProvider);
final lists = await Future.wait(
_destinos.map((i) => repo.futurosParaDiaSemana(
diaSemana: i + 1,
desde: _validoDesde,
)),
);
if (!mounted) return;
final todos = lists.expand((l) => l).toList();
todos.sort((a, b) => a.validoDesde.compareTo(b.validoDesde));
setState(() {
_futurosCombinados = todos;
_futurosLoading = false;
if (todos.isEmpty) {
_alcance = const AlcanceIndefinido();
} else if (_alcance is! AlcanceIndefinido) {
_alcance = const AlcanceHastaProximo();
}
});
} catch (_) {
if (!mounted) return;
setState(() {
_futurosCombinados = const [];
_futurosLoading = false;
_alcance = const AlcanceHastaProximo();
});
}
}
Future<void> _onValidoDesdeChanged(DateTime nuevo) async {
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
setState(() {
_validoDesde = nuevo.isBefore(hoy) ? hoy : nuevo;
_futurosCombinados = null;
});
await _cargarFuturos();
}
void _toggleDestino(int i) {
setState(() {
if (_destinos.contains(i)) {
_destinos.remove(i);
} else {
_destinos.add(i);
}
_futurosCombinados = null;
_errores = null;
});
_cargarFuturos();
}
List<Map<String, dynamic>> get _bloquesCopia {
final seen = <String>{};
final result = <Map<String, dynamic>>[];
for (final b in widget.origen.bloques) {
final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}';
if (!seen.add(key)) continue;
result.add({
'actividad_id': b.actividad.id,
'hora_inicio': b.horaInicio,
'hora_fin': b.horaFin,
});
}
return result;
}
Future<void> _guardar() async {
setState(() {
_saving = true;
_errores = null;
});
final bloques = _bloquesCopia;
final mandarMetadata = _futurosCombinados != null;
final validoDesde = mandarMetadata ? _validoDesde : null;
final alcance = mandarMetadata ? _alcance : null;
final destinosSorted = _destinos.toList()..sort();
final errores = <int, HorarioError>{};
int totalHuerfanas = 0;
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
for (final i in destinosSorted) {
// El RPC solo usa la fecha para derivar el ISODOW en días regulares.
// Si la fecha de esta semana ya pasó, avanzamos 7 días para obtener
// el mismo weekday la semana siguiente y evitar el rechazo del backend.
DateTime fecha = widget.weekStart.add(Duration(days: i));
if (fecha.isBefore(hoy)) fecha = fecha.add(const Duration(days: 7));
final (error, huerfanas) =
await ref.read(horariosProvider.notifier).guardarDia(
fecha: fecha,
esEspecial: false,
bloques: bloques,
validoDesde: validoDesde,
alcance: alcance,
);
if (error != null) {
errores[i] = error;
if (error is ConflictoAlcance) {
if (!mounted) return;
setState(() {
_saving = false;
_errores = errores;
_alcance = const AlcanceHastaProximo();
_futurosCombinados = null;
});
_cargarFuturos();
return;
}
} else {
totalHuerfanas += huerfanas;
}
}
if (!mounted) return;
setState(() => _saving = false);
if (errores.isEmpty) {
final n = destinosSorted.length;
SomaToast.show(
context,
message: n == 1
? 'Horario copiado a ${_diasSemana[destinosSorted.first]}'
: 'Horario copiado a $n días',
type: ToastType.success,
);
Navigator.of(context).pop(totalHuerfanas);
} else {
setState(() => _errores = errores);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final origenLabel = _diasSemana[_origenIdx];
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 620),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
Expanded(
child: Text(
'Copiar $origenLabel a...',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w700),
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed:
_saving ? null : () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// Body
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Day chips
Text(
'Días destino',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 8),
_DestinosChips(
origenIdx: _origenIdx,
destinos: _destinos,
onToggle: _toggleDestino,
),
// Sub-labels per selected destination
if (_destinos.isNotEmpty) ...[
const SizedBox(height: 10),
for (final i in _destinos.toList()..sort())
_DestinoInfo(
label: _diasSemana[i],
diaActual: widget.semana.diaPara(
widget.weekStart.add(Duration(days: i))),
),
],
// Bloques preview
const SizedBox(height: 20),
Text(
'Actividades a copiar',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 8),
_BloquesPreview(bloques: widget.origen.bloques),
// Vigencia (only when destinations are selected)
if (_destinos.isNotEmpty) ...[
const SizedBox(height: 20),
ValidoDesdeSelector(
fecha: _validoDesde,
weekdayTarget: null,
onChanged: _onValidoDesdeChanged,
),
const SizedBox(height: 12),
if (_futurosLoading && _futurosCombinados == null)
const Padding(
padding: EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.primary,
),
),
SizedBox(width: 8),
Text(
'Verificando planificaciones futuras…',
style: TextStyle(fontSize: 12),
),
],
),
)
else if (_futurosCombinados != null &&
_futurosCombinados!.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
'No hay horarios planificados a futuro: el horario '
'copiado regirá de manera indefinida.',
style: TextStyle(
fontSize: 11,
color:
theme.colorScheme.onSurface.withAlpha(140),
fontStyle: FontStyle.italic,
),
),
)
else if (_futurosCombinados != null &&
_futurosCombinados!.isNotEmpty)
AlcanceSelector(
futuros: _futurosCombinados!,
validoDesde: _validoDesde,
alcance: _alcance,
onChanged: (a) => setState(() => _alcance = a),
),
],
// Error panel
if (_errores != null && _errores!.isNotEmpty) ...[
const SizedBox(height: 12),
for (final entry in _errores!.entries)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'${_diasSemana[entry.key]}: ${entry.value.mensajeUsuario()}',
style: const TextStyle(
color: SomaColors.error, fontSize: 12),
),
),
],
const SizedBox(height: 4),
],
),
),
),
const Divider(height: 1),
// Footer
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
children: [
const Spacer(),
TextButton(
onPressed:
_saving ? null : () => Navigator.of(context).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed:
_destinos.isEmpty || _saving || _futurosLoading
? null
: _guardar,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42)),
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.onPrimary,
),
)
: Text(
_destinos.isEmpty
? 'Copiar'
: 'Copiar a ${_destinos.length} '
'${_destinos.length == 1 ? 'día' : 'días'}',
),
),
],
),
),
],
),
),
);
}
}
// ── Chips de destino (multi-select) ───────────────────────────────────────────
class _DestinosChips extends StatelessWidget {
final int origenIdx;
final Set<int> destinos;
final ValueChanged<int> onToggle;
const _DestinosChips({
required this.origenIdx,
required this.destinos,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: List.generate(7, (i) {
final isOrigen = i == origenIdx;
final isSelected = destinos.contains(i);
return Expanded(
child: Padding(
padding: EdgeInsets.only(right: i < 6 ? 4 : 0),
child: InkWell(
onTap: isOrigen ? null : () => onToggle(i),
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isOrigen
? theme.colorScheme.surfaceContainerHighest.withAlpha(30)
: isSelected
? SomaColors.primary.withAlpha(22)
: theme.colorScheme.surfaceContainerHighest
.withAlpha(60),
border: Border.all(
color: isOrigen
? theme.colorScheme.surfaceContainerHighest
.withAlpha(60)
: isSelected
? SomaColors.primary.withAlpha(100)
: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Center(
child: Text(
_diasCortos[i],
style: TextStyle(
fontSize: 12,
fontWeight:
isSelected ? FontWeight.w700 : FontWeight.w500,
color: isOrigen
? theme.colorScheme.onSurface.withAlpha(60)
: isSelected
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(130),
),
),
),
),
),
),
);
}),
);
}
}
// ── Info por destino seleccionado ─────────────────────────────────────────────
class _DestinoInfo extends StatelessWidget {
final String label;
final DiaHorarios? diaActual;
const _DestinoInfo({required this.label, required this.diaActual});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bloques = diaActual?.bloques ?? [];
final tieneContenido = bloques.isNotEmpty;
final String desc;
final Color color;
if (tieneContenido) {
final n = bloques.length;
desc = '$n ${n == 1 ? 'actividad' : 'actividades'} — se reemplazarán';
color = Colors.orange;
} else {
desc = 'vacío';
color = theme.colorScheme.onSurface.withAlpha(100);
}
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(Icons.arrow_forward, size: 12,
color: SomaColors.primary.withAlpha(160)),
const SizedBox(width: 6),
Text(
'$label: ',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
Expanded(
child: Text(
desc,
style: TextStyle(fontSize: 12, color: color),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
// ── Preview de bloques (read-only) ────────────────────────────────────────────
class _BloquesPreview extends StatelessWidget {
final List<BloqueHorario> bloques;
const _BloquesPreview({required this.bloques});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (bloques.isEmpty) {
return Text(
'Sin actividades',
style: TextStyle(
fontSize: 12, color: theme.colorScheme.onSurface.withAlpha(120)),
);
}
final seen = <String>{};
final unique = <BloqueHorario>[];
for (final b in bloques) {
final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}';
if (!seen.add(key)) continue;
unique.add(b);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: unique
.map((b) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.surfaceContainerHighest
.withAlpha(50),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: Row(
children: [
Container(
width: 3,
height: 20,
decoration: BoxDecoration(
color: SomaColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
SizedBox(
width: 84,
child: Text(
'${b.horaInicio}${b.horaFin}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
Expanded(
child: Text(
b.actividad.nombre,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
))
.toList(),
);
}
}
@@ -0,0 +1,342 @@
import 'package:flutter/material.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/horario_actividad_tile.dart';
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
class DiaColumna extends StatelessWidget {
final DateTime fecha;
final String nombreDia;
final DiaHorarios? dia;
final bool isSelected;
final bool isAdmin;
final VoidCallback onSelectDia;
final void Function(DateTime, DiaHorarios?) onEditarDia;
final VoidCallback? onCopiarDia;
final void Function(BloqueHorario)? onEliminarBloque;
const DiaColumna({
super.key,
required this.fecha,
required this.nombreDia,
required this.dia,
required this.isSelected,
required this.isAdmin,
required this.onSelectDia,
required this.onEditarDia,
this.onCopiarDia,
this.onEliminarBloque,
});
@override
Widget build(BuildContext context) {
final col = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DiaHeader(
nombreDia: nombreDia,
fecha: fecha,
dia: dia,
isSelected: isSelected,
onTap: onSelectDia,
),
Expanded(
child: _DiaBody(
fecha: fecha,
dia: dia,
isAdmin: isAdmin,
onEditarDia: onEditarDia,
onEliminarBloque: onEliminarBloque,
),
),
],
);
if (!isAdmin) return col;
return GestureDetector(
onSecondaryTapDown: (details) {
showContextMenu<String>(
context,
contextMenu: ContextMenu<String>(
position: details.globalPosition,
entries: [
MenuItem(
label: const Text('Editar día'),
icon: const Icon(Icons.edit_outlined, size: 16),
value: 'edit',
),
if (onCopiarDia != null)
MenuItem(
label: const Text('Copiar a...'),
icon: const Icon(Icons.copy_outlined, size: 16),
value: 'copy',
),
],
),
onItemSelected: (v) {
if (v == 'edit') onEditarDia(fecha, dia);
if (v == 'copy') onCopiarDia!();
},
);
},
child: col,
);
}
}
// ── Header ─────────────────────────────────────────────────────────────────────
class _DiaHeader extends StatelessWidget {
final String nombreDia;
final DateTime fecha;
final DiaHorarios? dia;
final bool isSelected;
final VoidCallback onTap;
const _DiaHeader({
required this.nombreDia,
required this.fecha,
required this.dia,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final esEspecial = dia?.esEspecial ?? false;
final esCerrado = dia?.esCerrado ?? false;
final hastaLabel = (dia != null &&
dia!.tipo == TipoDia.normal &&
dia!.validoHasta != null)
? '${dia!.validoHasta!.day} ${_mesesCortos[dia!.validoHasta!.month]}'
: null;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: InkWell(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
decoration: BoxDecoration(
color: isSelected
? SomaColors.primary.withAlpha(28)
: Colors.transparent,
border: Border(
bottom: BorderSide(
color: isSelected
? SomaColors.primary
: theme.colorScheme.surfaceContainerHighest,
width: isSelected ? 2 : 1,
),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nombreDia,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isSelected
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 1),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'${fecha.day} ${_mesesCortos[fecha.month]}',
style: TextStyle(
fontSize: 11,
color: isSelected
? SomaColors.primaryText.withAlpha(180)
: theme.colorScheme.onSurface.withAlpha(120),
),
),
if (hastaLabel != null) ...[
const SizedBox(width: 6),
Flexible(
child: Tooltip(
message:
'Esta plantilla rige hasta el ${dia!.validoHasta!.day} ${_mesesCortos[dia!.validoHasta!.month]} ${dia!.validoHasta!.year}.',
child: Text(
hastaLabel,
style: TextStyle(
fontSize: 10,
fontStyle: FontStyle.italic,
color: isSelected
? SomaColors.primaryText.withAlpha(160)
: theme.colorScheme.onSurface
.withAlpha(100),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
],
),
],
),
),
if (esCerrado)
_HeaderBadge(icon: Icons.block, color: SomaColors.error)
else if (esEspecial)
_HeaderBadge(icon: Icons.event_note, color: SomaColors.primary),
],
),
),
),
);
}
}
class _HeaderBadge extends StatelessWidget {
final IconData icon;
final Color color;
const _HeaderBadge({required this.icon, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: color.withAlpha(20),
borderRadius: BorderRadius.circular(4),
),
child: Icon(icon, size: 12, color: color.withAlpha(200)),
);
}
}
// ── Body ───────────────────────────────────────────────────────────────────────
class _DiaBody extends StatelessWidget {
final DateTime fecha;
final DiaHorarios? dia;
final bool isAdmin;
final void Function(DateTime, DiaHorarios?) onEditarDia;
final void Function(BloqueHorario)? onEliminarBloque;
const _DiaBody({
required this.fecha,
required this.dia,
required this.isAdmin,
required this.onEditarDia,
this.onEliminarBloque,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (dia == null) {
return Center(
child: Text(
'',
style: TextStyle(
fontSize: 18,
color: theme.colorScheme.onSurface.withAlpha(60),
),
),
);
}
if (dia!.esCerrado) {
return Container(
color: SomaColors.error.withAlpha(10),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.block, size: 24, color: SomaColors.error.withAlpha(140)),
const SizedBox(height: 6),
Text(
'Cerrado',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: SomaColors.error.withAlpha(160),
),
),
if (dia!.motivo != null && dia!.motivo!.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
dia!.motivo!,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
color: theme.colorScheme.onSurface.withAlpha(100),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
);
}
if (dia!.bloques.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.event_busy_outlined,
size: 24,
color: theme.colorScheme.onSurface.withAlpha(50),
),
const SizedBox(height: 6),
Text(
'Sin actividades',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
],
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(8),
itemCount: dia!.bloques.length,
separatorBuilder: (_, _) => const SizedBox(height: 6),
itemBuilder: (context, index) {
final bloque = dia!.bloques[index];
return HorarioActividadTile(
bloque: bloque,
onTap: isAdmin ? () => onEditarDia(fecha, dia) : null,
onDelete: onEliminarBloque != null
? () => onEliminarBloque!(bloque)
: null,
);
},
);
}
}
@@ -0,0 +1,404 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart';
import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/editar_dia_dialog.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
const _meses = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
class DiasEspecialesView extends ConsumerWidget {
const DiasEspecialesView({super.key});
String _fmtFecha(DateTime d) =>
'${_diasSemana[d.weekday - 1]}, ${d.day} de ${_meses[d.month]} ${d.year}';
Future<void> _eliminar(
BuildContext context, WidgetRef ref, DiaEspecialResumen dia) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Restaurar horario normal'),
content: Text(
'¿Restaurar el horario regular para el ${_fmtFecha(dia.fecha)}?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
minimumSize: const Size(0, 40),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Restaurar'),
),
],
),
);
if (confirm != true || !context.mounted) return;
final (error, huerfanas) = await ref
.read(horariosProvider.notifier)
.eliminarDiaEspecial(dia.fecha);
if (!context.mounted) return;
if (error != null) {
SomaToast.show(
context,
message: error.mensajeUsuario(),
type: ToastType.error,
);
} else {
if (huerfanas > 0) {
_showHuerfanasToast(context, huerfanas);
} else {
SomaToast.show(
context,
message: 'Excepción eliminada',
type: ToastType.success,
);
}
// La recarga de la lista la dispara HorariosNotifier al invalidar
// diasEspecialesProvider tras eliminar la excepción.
}
}
void _showHuerfanasToast(BuildContext context, int n) {
SomaToast.show(
context,
message: '$n ${n == 1 ? 'reserva quedó huérfana' : 'reservas quedaron huérfanas'}',
type: ToastType.info,
action: SnackBarAction(
label: 'Ver',
textColor: SomaColors.onPrimary,
onPressed: () => context.go('/huerfanas'),
),
);
}
Future<void> _editar(
BuildContext context, WidgetRef ref, DiaEspecialResumen resumen) async {
// Convert DiaEspecialResumen to DiaHorarios for EditarDiaDialog
final tipo = resumen.esCerrado ? TipoDia.cerrado : TipoDia.horarioDiferente;
final bloques = resumen.rangos
.map((r) => BloqueHorario(
id: r.id,
horaInicio: r.horaInicio,
horaFin: r.horaFin,
actividad: BloqueActividadInfo(
id: r.actividadId,
nombre: r.actividadNombre,
duracion: r.actividadDuracion,
capacidad: 0,
),
))
.toList();
final diaHorarios = DiaHorarios(
fecha: resumen.fecha,
diaSemana: resumen.fecha.weekday,
tipo: tipo,
motivo: resumen.motivo,
bloques: bloques,
);
final huerfanas = await showDialog<int>(
context: context,
builder: (_) => EditarDiaDialog(
dia: diaHorarios,
fecha: resumen.fecha,
weekStart: resumen.fecha.subtract(
Duration(days: resumen.fecha.weekday - 1),
),
),
);
if (!context.mounted) return;
final n = huerfanas ?? 0;
if (n > 0) _showHuerfanasToast(context, n);
// Si el diálogo guardó algo, HorariosNotifier ya invalidó
// diasEspecialesProvider y la lista se recarga sola; si se canceló, no hay
// nada que refrescar.
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(diasEspecialesProvider);
final theme = Theme.of(context);
final isWide = MediaQuery.of(context).size.width >= 800;
return 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: () =>
ref.read(diasEspecialesProvider.notifier).load(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (especiales) {
if (especiales.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_note_outlined,
size: 56,
color: theme.colorScheme.onSurface.withAlpha(60)),
const SizedBox(height: 12),
Text(
'Sin días especiales configurados',
style: TextStyle(
fontSize: 15,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
const SizedBox(height: 6),
Text(
'Editá un día desde la vista Semanal para marcarlo como especial.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
],
),
);
}
return ListView.separated(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 16, isWide ? 32 : 16, 32,
),
itemCount: especiales.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final dia = especiales[index];
return _EspecialCard(
dia: dia,
fechaLabel: _fmtFecha(dia.fecha),
onEditar: () => _editar(context, ref, dia),
onEliminar: () => _eliminar(context, ref, dia),
);
},
);
},
);
}
}
class _EspecialCard extends StatelessWidget {
final DiaEspecialResumen dia;
final String fechaLabel;
final VoidCallback onEditar;
final VoidCallback onEliminar;
const _EspecialCard({
required this.dia,
required this.fechaLabel,
required this.onEditar,
required this.onEliminar,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final railColor = dia.esCerrado
? SomaColors.error.withAlpha(180)
: SomaColors.primary.withAlpha(180);
return InkWell(
onTap: onEditar,
borderRadius: BorderRadius.circular(12),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 4, color: railColor),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 11, 8, 11),
child: Row(
children: [
// Icon
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: dia.esCerrado
? SomaColors.error.withAlpha(16)
: SomaColors.primary.withAlpha(18),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
dia.esCerrado ? Icons.block : Icons.schedule,
size: 20,
color: dia.esCerrado
? SomaColors.error
: SomaColors.primary,
),
),
const SizedBox(width: 14),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(
child: Text(
fechaLabel,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
_TipoBadge(esCerrado: dia.esCerrado),
],
),
const SizedBox(height: 3),
Text(
dia.motivo?.isNotEmpty == true
? dia.motivo!
: dia.esCerrado
? 'Sin motivo especificado'
: '${dia.rangos.length} actividad${dia.rangos.length == 1 ? '' : 'es'}',
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(130),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Menu
PopupMenuButton<String>(
icon: Icon(Icons.more_vert,
size: 18,
color:
theme.colorScheme.onSurface.withAlpha(130)),
itemBuilder: (_) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit_outlined, size: 18),
SizedBox(width: 8),
Text('Editar'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.restore_outlined,
size: 18, color: SomaColors.error),
SizedBox(width: 8),
Text('Restaurar normal',
style:
TextStyle(color: SomaColors.error)),
],
),
),
],
onSelected: (v) {
if (v == 'edit') onEditar();
if (v == 'delete') onEliminar();
},
),
],
),
),
),
],
),
),
),
);
}
}
class _TipoBadge extends StatelessWidget {
final bool esCerrado;
const _TipoBadge({required this.esCerrado});
@override
Widget build(BuildContext context) {
final color = esCerrado ? SomaColors.error : SomaColors.primary;
final textColor = esCerrado ? SomaColors.error : SomaColors.primaryText;
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(
esCerrado ? 'Cerrado' : 'Especial',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: textColor,
),
),
);
}
}
@@ -0,0 +1,975 @@
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/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_error.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart';
import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/agregar_bloque_dialog.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/alcance_selector.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/valido_desde_selector.dart';
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
const _diasCortos = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
class EditarDiaDialog extends ConsumerStatefulWidget {
final DiaHorarios? dia;
final DateTime fecha;
final DateTime weekStart;
const EditarDiaDialog({
super.key,
required this.dia,
required this.fecha,
required this.weekStart,
});
@override
ConsumerState<EditarDiaDialog> createState() => _EditarDiaDialogState();
}
class _EditarDiaDialogState extends ConsumerState<EditarDiaDialog> {
late bool _esEspecial;
late bool _esCerrado;
final _motivoController = TextEditingController();
late List<Map<String, dynamic>> _bloques;
late int _selectedWeekdayIndex;
late DateTime _selectedFecha;
bool _saving = false;
HorarioError? _error;
// Modo Normal: estado de vigencia y alcance del cambio.
late DateTime _validoDesde;
Alcance _alcance = const AlcanceHastaProximo();
List<PlanificacionFutura>? _futuros;
bool _futurosLoading = false;
@override
void initState() {
super.initState();
_esEspecial = widget.dia?.esEspecial ?? false;
_selectedWeekdayIndex = widget.fecha.weekday - 1;
_selectedFecha = widget.fecha;
_esCerrado = false;
_bloques = [];
_applyDia(widget.dia);
final hoy = _hoy();
// CU2.c — editar planificación futura existente: si el día actual ya
// tiene una plantilla vigente con valido_desde futuro, pre-cargamos ese
// valor para que guardar equivalga a editar esa misma planificación.
// Si no, usamos la fecha exacta del calendario que Juani está viendo
// (alineado con el nuevo default del backend: valido_desde = fecha).
// Clampear a hoy por si Juani navega hacia semanas pasadas.
final validoDesdeDia = widget.dia?.validoDesde;
_validoDesde =
(validoDesdeDia != null && validoDesdeDia.isAfter(hoy))
? validoDesdeDia
: (widget.fecha.isBefore(hoy) ? hoy : widget.fecha);
if (!_esEspecial) {
WidgetsBinding.instance.addPostFrameCallback((_) => _cargarFuturos());
}
}
DateTime _hoy() {
final n = DateTime.now();
return DateTime(n.year, n.month, n.day);
}
Future<void> _cargarFuturos() async {
final diaSemana = _fechaParaGuardar.weekday; // ISODOW 1..7
setState(() => _futurosLoading = true);
try {
final lista =
await ref.read(horariosRepositoryProvider).futurosParaDiaSemana(
diaSemana: diaSemana,
desde: _validoDesde,
);
if (!mounted) return;
setState(() {
_futuros = lista;
_futurosLoading = false;
// Si no hay futuros, el alcance es 'indefinido' implícito.
// Si hay, mantenemos el default backend 'hasta_proximo' salvo que ya
// hubiera una elección del usuario distinta.
if (lista.isEmpty) {
_alcance = const AlcanceIndefinido();
} else if (_alcance is AlcanceHasta) {
// Si la fecha del 'hasta' previa quedó fuera del rango válido tras
// recargar futuros, retrocedemos al default.
final lastValid =
lista.first.validoDesde.subtract(const Duration(days: 1));
final f = (_alcance as AlcanceHasta).fecha;
if (f.isBefore(_validoDesde) || f.isAfter(lastValid)) {
_alcance = const AlcanceHastaProximo();
}
} else if (_alcance is AlcanceIndefinido) {
// Mantener selección explícita del usuario.
} else {
_alcance = const AlcanceHastaProximo();
}
});
} catch (_) {
if (!mounted) return;
// Si falla, asumimos que no hay futuros conocidos: el backend usará
// su default 'hasta_proximo' al guardar. El usuario verá el formulario
// sin selector hasta que vuelva a abrir.
setState(() {
_futuros = const [];
_futurosLoading = false;
_alcance = const AlcanceHastaProximo();
});
}
}
/// Mutates _esCerrado, _motivoController, _bloques from a DiaHorarios snapshot.
/// Must be called inside setState (or during initState).
void _applyDia(DiaHorarios? dia) {
_esCerrado = dia?.esCerrado ?? false;
_motivoController.text = dia?.motivo ?? '';
final seen = <String>{};
_bloques = [];
for (final b in dia?.bloques ?? []) {
final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}';
if (!seen.add(key)) continue;
_bloques.add({
'actividad_id': b.actividad.id,
'hora_inicio': b.horaInicio,
'hora_fin': b.horaFin,
'_nombre': b.actividad.nombre,
});
}
}
void _onWeekdayChanged(int index) {
final semana = ref.read(horariosProvider).valueOrNull;
final newFecha = widget.weekStart.add(Duration(days: index));
final nuevoDia = semana?.diaPara(newFecha);
setState(() {
_selectedWeekdayIndex = index;
_error = null;
_applyDia(nuevoDia);
// Si el nuevo día tiene plantilla vigente con valido_desde futuro,
// saltamos a esa fecha (CU2.c). Si no, al próximo día con esa weekday.
final hoy = _hoy();
final vd = nuevoDia?.validoDesde;
_validoDesde = (vd != null && vd.isAfter(hoy))
? vd
: (newFecha.isBefore(hoy) ? hoy : newFecha);
_alcance = const AlcanceHastaProximo();
_futuros = null;
});
_cargarFuturos();
}
Future<void> _onValidoDesdeChanged(DateTime nuevo) async {
final hoy = _hoy();
final clamped = nuevo.isBefore(hoy) ? hoy : nuevo;
setState(() {
_validoDesde = clamped;
_futuros = null;
_error = null;
});
await _cargarFuturos();
}
@override
void dispose() {
_motivoController.dispose();
super.dispose();
}
DateTime get _fechaParaGuardar => _esEspecial
? _selectedFecha
: widget.weekStart.add(Duration(days: _selectedWeekdayIndex));
String get _diaLabel => _esEspecial
? '${_diasSemana[_selectedFecha.weekday - 1]} ${_selectedFecha.day} ${_mesesCortos[_selectedFecha.month]}'
: _diasSemana[_selectedWeekdayIndex];
Future<void> _addBloque() async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => const AgregarBloqueDialog(),
);
if (result == null) return;
setState(() => _bloques = [..._bloques, result]);
}
void _removeBloque(int index) {
setState(() {
_bloques = List<Map<String, dynamic>>.from(_bloques)..removeAt(index);
});
}
Future<void> _guardar() async {
setState(() {
_saving = true;
_error = null;
});
final bloquesPayload = _bloques.map((b) {
return {
'actividad_id': b['actividad_id'],
'hora_inicio': b['hora_inicio'],
'hora_fin': b['hora_fin'],
};
}).toList();
// Para modo Normal, solo mandamos alcance/validoDesde si tenemos info
// confiable. Si _futuros vino vacío explícitamente, mandamos los valores
// elegidos. Si es null (todavía cargando o falló), dejamos que decida
// el backend con sus defaults.
final esRegular = !_esEspecial;
final mandarMetadata = esRegular && _futuros != null;
final (error, huerfanas) =
await ref.read(horariosProvider.notifier).guardarDia(
fecha: _fechaParaGuardar,
esEspecial: _esEspecial,
motivo: _esEspecial ? _motivoController.text.trim() : null,
bloques: _esEspecial && _esCerrado ? [] : bloquesPayload,
validoDesde: mandarMetadata ? _validoDesde : null,
alcance: mandarMetadata ? _alcance : null,
);
if (!mounted) return;
setState(() => _saving = false);
if (error != null) {
setState(() {
_error = error;
// Si el backend rechazó por conflicto de alcance, retrocedemos a
// 'hasta_proximo' para que el usuario reintente con una opción que
// siempre es segura. También refrescamos el mapa de futuros por si
// el conflicto delata una planificación que no teníamos cacheada.
if (error is ConflictoAlcance) {
_alcance = const AlcanceHastaProximo();
}
});
if (error is ConflictoAlcance) {
await _cargarFuturos();
}
} else {
SomaToast.show(context, message: 'Horario guardado', type: ToastType.success);
Navigator.of(context).pop(huerfanas);
}
}
Future<void> _eliminarExcepcion() async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Restaurar horario normal'),
content: const Text(
'Se eliminará la excepción y el día volverá a usar el horario regular.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
minimumSize: const Size(0, 40),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Restaurar'),
),
],
),
);
if (confirm != true || !mounted) return;
setState(() {
_saving = true;
_error = null;
});
final (error, huerfanas) =
await ref.read(horariosProvider.notifier).eliminarDiaEspecial(
widget.dia!.fecha,
);
if (!mounted) return;
setState(() => _saving = false);
if (error != null) {
setState(() => _error = error);
} else {
SomaToast.show(
context,
message: 'Excepción eliminada, se aplica horario regular',
type: ToastType.success,
);
Navigator.of(context).pop(huerfanas);
}
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isWide = width >= 600;
final theme = Theme.of(context);
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isWide ? (width - 480) / 2 : 20,
vertical: 24,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 600),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
Expanded(
child: Text(
'Editar $_diaLabel',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed:
_saving ? null : () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// Body
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Tipo toggle
Row(
children: [
Expanded(
child: _TipoOption(
label: 'Normal',
icon: Icons.calendar_today_outlined,
selected: !_esEspecial,
onTap: () {
setState(() {
_esEspecial = false;
_esCerrado = false;
_selectedWeekdayIndex =
_selectedFecha.weekday - 1;
_futuros = null;
});
_cargarFuturos();
},
),
),
const SizedBox(width: 8),
Expanded(
child: _TipoOption(
label: 'Especial',
icon: Icons.event_note_outlined,
selected: _esEspecial,
onTap: () => setState(() {
_esEspecial = true;
_selectedFecha = widget.weekStart
.add(Duration(days: _selectedWeekdayIndex));
}),
),
),
],
),
// Día selector
const SizedBox(height: 16),
if (!_esEspecial)
_WeekdaySelector(
selected: _selectedWeekdayIndex,
onChanged: _onWeekdayChanged,
)
else
_FechaSelector(
fecha: _selectedFecha,
onChanged: (d) => setState(() => _selectedFecha = d),
),
// Vigencia y alcance (sólo modo Normal)
if (!_esEspecial) ...[
const SizedBox(height: 16),
ValidoDesdeSelector(
fecha: _validoDesde,
weekdayTarget: _fechaParaGuardar.weekday,
onChanged: _onValidoDesdeChanged,
),
const SizedBox(height: 12),
if (_futurosLoading && _futuros == null)
const Padding(
padding: EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.primary,
),
),
SizedBox(width: 8),
Text(
'Verificando planificaciones futuras…',
style: TextStyle(fontSize: 12),
),
],
),
)
else if (_futuros != null && _futuros!.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
'No hay horarios planificados a futuro: este '
'horario regirá desde el inicio de vigencia '
'de manera indefinida.',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(140),
fontStyle: FontStyle.italic,
),
),
)
else if (_futuros != null && _futuros!.isNotEmpty)
AlcanceSelector(
futuros: _futuros!,
validoDesde: _validoDesde,
alcance: _alcance,
onChanged: (a) => setState(() => _alcance = a),
),
],
// Especial options
if (_esEspecial) ...[
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _SubOption(
label: 'Horario diferente',
selected: !_esCerrado,
onTap: () =>
setState(() => _esCerrado = false),
),
),
const SizedBox(width: 8),
Expanded(
child: _SubOption(
label: 'Cerrado',
selected: _esCerrado,
isDestructive: true,
onTap: () =>
setState(() => _esCerrado = true),
),
),
],
),
const SizedBox(height: 14),
TextField(
controller: _motivoController,
decoration: const InputDecoration(
labelText: 'Motivo (opcional)',
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 14),
),
),
],
// Bloques (solo si no está cerrado)
if (!(_esEspecial && _esCerrado)) ...[
const SizedBox(height: 20),
Text(
'Actividades',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 6),
if (_bloques.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(
child: Text(
'Sin actividades — el día quedará vacío',
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(120),
),
),
),
)
else
...List.generate(_bloques.length, (i) {
final b = _bloques[i];
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: _BloqueEditRow(
horaInicio: b['hora_inicio'] as String,
horaFin: b['hora_fin'] as String,
nombre: b['_nombre'] as String? ?? '',
onDelete: () => _removeBloque(i),
),
);
}),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: _addBloque,
icon: const Icon(Icons.add, size: 18),
label: const Text('Agregar actividad'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 44),
foregroundColor: SomaColors.primary,
side: BorderSide(
color: SomaColors.primary.withAlpha(100)),
),
),
],
// Error
if (_error != null) ...[
const SizedBox(height: 10),
Text(
_error!.mensajeUsuario(),
style: TextStyle(
color: SomaColors.error,
fontSize: 12,
),
),
],
const SizedBox(height: 4),
],
),
),
),
const Divider(height: 1),
// Actions
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
children: [
// Eliminar excepción (solo si el día actual ya es especial en BD)
if (widget.dia?.esEspecial == true)
TextButton(
onPressed: _saving ? null : _eliminarExcepcion,
style: TextButton.styleFrom(
foregroundColor: SomaColors.error,
),
child: const Text('Restaurar normal'),
),
const Spacer(),
TextButton(
onPressed:
_saving ? null : () => Navigator.of(context).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _saving ? null : _guardar,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.onPrimary,
),
)
: const Text('Guardar'),
),
],
),
),
],
),
),
);
}
}
class _TipoOption extends StatelessWidget {
final String label;
final IconData icon;
final bool selected;
final VoidCallback onTap;
const _TipoOption({
required this.label,
required this.icon,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: selected
? SomaColors.primary.withAlpha(22)
: theme.colorScheme.surfaceContainerHighest.withAlpha(80),
border: Border.all(
color: selected
? SomaColors.primary.withAlpha(100)
: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon,
size: 16,
color: selected
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(130)),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
),
);
}
}
class _SubOption extends StatelessWidget {
final String label;
final bool selected;
final bool isDestructive;
final VoidCallback onTap;
const _SubOption({
required this.label,
required this.selected,
this.isDestructive = false,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = isDestructive ? SomaColors.error : SomaColors.primary;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: selected ? color.withAlpha(18) : Colors.transparent,
border: Border.all(
color: selected
? color.withAlpha(80)
: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (selected)
Icon(Icons.radio_button_checked,
size: 14, color: color)
else
Icon(Icons.radio_button_unchecked,
size: 14,
color: theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected
? (isDestructive ? SomaColors.error : SomaColors.primaryText)
: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
),
);
}
}
class _BloqueEditRow extends StatelessWidget {
final String horaInicio;
final String horaFin;
final String nombre;
final VoidCallback onDelete;
const _BloqueEditRow({
required this.horaInicio,
required this.horaFin,
required this.nombre,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: theme.colorScheme.surfaceContainerHighest.withAlpha(60),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 4, color: SomaColors.primary),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 4, 10),
child: Row(
children: [
SizedBox(
width: 94,
child: Text(
'$horaInicio $horaFin',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
Container(
width: 1,
height: 24,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: theme.colorScheme.surfaceContainerHighest,
),
Expanded(
child: Text(
nombre,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: Icon(
Icons.delete_outline,
size: 18,
color: SomaColors.error.withAlpha(180),
),
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 40, minHeight: 40),
onPressed: onDelete,
),
],
),
),
),
],
),
),
);
}
}
// ── Weekday selector (Normal) ──────────────────────────────────────────────────
class _WeekdaySelector extends StatelessWidget {
final int selected;
final ValueChanged<int> onChanged;
const _WeekdaySelector({required this.selected, required this.onChanged});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: List.generate(_diasCortos.length, (i) {
final isSelected = i == selected;
return Expanded(
child: Padding(
padding: EdgeInsets.only(right: i < _diasCortos.length - 1 ? 4 : 0),
child: InkWell(
onTap: () => onChanged(i),
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isSelected
? SomaColors.primary.withAlpha(22)
: theme.colorScheme.surfaceContainerHighest.withAlpha(60),
border: Border.all(
color: isSelected
? SomaColors.primary.withAlpha(100)
: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Center(
child: Text(
_diasCortos[i],
style: TextStyle(
fontSize: 12,
fontWeight:
isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(130),
),
),
),
),
),
),
);
}),
);
}
}
// ── Date picker button (Especial) ──────────────────────────────────────────────
class _FechaSelector extends StatelessWidget {
final DateTime fecha;
final ValueChanged<DateTime> onChanged;
const _FechaSelector({required this.fecha, required this.onChanged});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final label =
'${_diasSemana[fecha.weekday - 1]}, ${fecha.day} ${_mesesCortos[fecha.month]} ${fecha.year}';
return InkWell(
onTap: () async {
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
final initial = fecha.isBefore(hoy) ? hoy : fecha;
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: hoy,
lastDate: DateTime(2100),
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: SomaColors.primary,
onPrimary: SomaColors.onPrimary,
),
),
child: child!,
),
);
if (picked != null) onChanged(picked);
},
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: theme.colorScheme.surfaceContainerHighest.withAlpha(60),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.calendar_month_outlined,
size: 18,
color: SomaColors.primary,
),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
Icon(
Icons.expand_more,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(120),
),
],
),
),
);
}
}
@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:gimnasio_soma/core/theme/activity_colors.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
class _CapacidadBadge extends StatelessWidget {
final int capacidad;
const _CapacidadBadge({required this.capacidad});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.people_outline,
size: 11,
color: theme.colorScheme.onSurface.withAlpha(100),
),
const SizedBox(width: 3),
Flexible(
child: Text(
'$capacidad personas',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(110),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
class HorarioActividadTile extends StatelessWidget {
final BloqueHorario bloque;
final VoidCallback? onTap;
final VoidCallback? onDelete;
const HorarioActividadTile({
super.key,
required this.bloque,
this.onTap,
this.onDelete,
});
Widget _buildCompactContent(ThemeData theme) {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${bloque.horaInicio} ${bloque.horaFin}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(170),
fontFeatures: const [FontFeature.tabularFigures()],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
bloque.actividad.nombre,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildWideContent(ThemeData theme) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
children: [
// Hora — monoespaciada, ancho fijo
SizedBox(
width: 94,
child: Text(
'${bloque.horaInicio} ${bloque.horaFin}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
// Separador vertical sutil
Container(
width: 1,
height: 28,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: theme.colorScheme.surfaceContainerHighest,
),
// Nombre actividad + capacidad
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
bloque.actividad.nombre,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
_CapacidadBadge(
capacidad: bloque.actividad.capacidad,
),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return LayoutBuilder(
builder: (context, constraints) {
final isCompact = constraints.maxWidth < 200;
final tile = MouseRegion(
cursor:
onTap != null ? SystemMouseCursors.click : MouseCursor.defer,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Rail de actividad — color por actividad
Container(
width: 4,
color: ActivityColors.forId(bloque.actividad.id),
),
// Contenido
Expanded(
child: isCompact
? _buildCompactContent(theme)
: _buildWideContent(theme),
),
],
),
),
),
),
),
);
if (onDelete == null) return tile;
return GestureDetector(
onSecondaryTapDown: (details) {
showContextMenu<String>(
context,
contextMenu: ContextMenu<String>(
position: details.globalPosition,
entries: [
MenuItem(
label: const Text(
'Eliminar',
style: TextStyle(color: SomaColors.error),
),
icon: const Icon(Icons.delete_outline,
size: 16, color: SomaColors.error),
value: 'delete',
),
],
),
onItemSelected: (v) {
if (v == 'delete') onDelete!();
},
);
},
child: tile,
);
},
);
}
}
@@ -0,0 +1,531 @@
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/features/horarios/domain/entities/dia_especial.dart';
import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart';
const _colorEspecial = Color(0xFFFF9800);
const _colorCambio = Color(0xFF2196F3);
const _handleWidth = 22.0;
const _panelWidth = 280.0;
const _mesesLargos = [
'',
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
const _diasCortos = ['L', 'M', 'X', 'J', 'V', 'S', 'D'];
/// Panel de calendario que se desliza desde el borde derecho de la pantalla.
///
/// Debe colocarse con [Positioned(right: 0, top: 0, bottom: 0)] dentro de un
/// [Stack] que envuelva el área de contenido. La franja-handle (~22px) siempre
/// está visible en el borde derecho; al hacer clic el panel de 280px se
/// desliza hacia la izquierda superponiéndose sobre la tabla semanal.
///
/// Indicadores en el calendario:
/// • Naranja → día especial (cualquier tipo)
/// • Azul → arranca nueva plantilla regular ese día
class HorariosCalendarPanel extends ConsumerStatefulWidget {
final ValueChanged<DateTime> onNavigateToWeek;
const HorariosCalendarPanel({super.key, required this.onNavigateToWeek});
@override
ConsumerState<HorariosCalendarPanel> createState() =>
_HorariosCalendarPanelState();
}
class _HorariosCalendarPanelState
extends ConsumerState<HorariosCalendarPanel> {
bool _open = false;
late DateTime _month;
@override
void initState() {
super.initState();
final now = DateTime.now();
_month = DateTime(now.year, now.month);
}
void _toggle() => setState(() => _open = !_open);
void _prevMonth() => setState(
() => _month = DateTime(_month.year, _month.month - 1),
);
void _nextMonth() => setState(
() => _month = DateTime(_month.year, _month.month + 1),
);
@override
Widget build(BuildContext context) {
// Observamos los providers sólo con el panel abierto: así no disparamos sus
// RPC hasta que el usuario lo abre, y mientras está cerrado las
// invalidaciones tras escribir se fusionan en una sola recarga (relevante
// al copiar un día a varios destinos).
final especiales = _open
? (ref.watch(diasEspecialesProvider).valueOrNull ??
const <DiaEspecialResumen>[])
: const <DiaEspecialResumen>[];
final especSet = <DateTime>{};
for (final e in especiales) {
especSet.add(DateTime(e.fecha.year, e.fecha.month, e.fecha.day));
}
final cambiosAsync = _open ? ref.watch(diasCambioProvider) : null;
final diasCambio = cambiosAsync?.valueOrNull ?? const <DateTime>{};
final cargandoCambios = cambiosAsync?.isLoading ?? false;
// Row: [Panel animado (izq)] [Handle (der)]
// Posicionado con right:0, top:0, bottom:0 desde el parent Stack.
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Panel: crece de 0 → _panelWidth hacia la izquierda
ClipRect(
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOut,
width: _open ? _panelWidth : 0,
child: OverflowBox(
maxWidth: _panelWidth,
alignment: Alignment.centerRight,
child: _PanelContent(
month: _month,
diasEspeciales: especSet,
diasCambio: diasCambio,
cargandoCambios: cargandoCambios,
onPrevMonth: _prevMonth,
onNextMonth: _nextMonth,
onDayTap: (fecha) {
widget.onNavigateToWeek(fecha);
setState(() => _open = false);
},
),
),
),
),
// Handle: siempre visible en el borde derecho
_SideHandle(isOpen: _open, onTap: _toggle),
],
);
}
}
// ── Side handle ────────────────────────────────────────────────────────────────
class _SideHandle extends StatefulWidget {
final bool isOpen;
final VoidCallback onTap;
const _SideHandle({required this.isOpen, required this.onTap});
@override
State<_SideHandle> createState() => _SideHandleState();
}
class _SideHandleState extends State<_SideHandle> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visible = _hovered || widget.isOpen;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Opacity(
opacity: visible ? 1.0 : 0.0,
child: IgnorePointer(
ignoring: !visible,
child: GestureDetector(
onTap: widget.onTap,
child: Container(
width: _handleWidth,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
left: BorderSide(
color: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(10),
blurRadius: 4,
offset: const Offset(-2, 0),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.calendar_month_outlined,
size: 13,
color: theme.colorScheme.onSurface.withAlpha(130),
),
const SizedBox(height: 5),
AnimatedRotation(
turns: widget.isOpen ? 0.5 : 0,
duration: const Duration(milliseconds: 220),
child: Icon(
Icons.chevron_right,
size: 13,
color: theme.colorScheme.onSurface.withAlpha(90),
),
),
],
),
),
),
),
),
);
}
}
// ── Panel content ──────────────────────────────────────────────────────────────
class _PanelContent extends StatelessWidget {
final DateTime month;
final Set<DateTime> diasEspeciales;
final Set<DateTime> diasCambio;
final bool cargandoCambios;
final VoidCallback onPrevMonth;
final VoidCallback onNextMonth;
final ValueChanged<DateTime> onDayTap;
const _PanelContent({
required this.month,
required this.diasEspeciales,
required this.diasCambio,
required this.cargandoCambios,
required this.onPrevMonth,
required this.onNextMonth,
required this.onDayTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: _panelWidth,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
left: BorderSide(
color: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
),
child: Column(
children: [
_MonthHeader(
month: month,
onPrev: onPrevMonth,
onNext: onNextMonth,
),
_CalendarGrid(
month: month,
diasEspeciales: diasEspeciales,
diasCambio: diasCambio,
onDayTap: onDayTap,
),
const SizedBox(height: 10),
_Legend(cargandoCambios: cargandoCambios),
],
),
),
);
}
}
// ── Month header ───────────────────────────────────────────────────────────────
class _MonthHeader extends StatelessWidget {
final DateTime month;
final VoidCallback onPrev;
final VoidCallback onNext;
const _MonthHeader({
required this.month,
required this.onPrev,
required this.onNext,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 6),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left, size: 18),
onPressed: onPrev,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
Expanded(
child: Text(
'${_mesesLargos[month.month]} ${month.year}',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right, size: 18),
onPressed: onNext,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
],
),
);
}
}
// ── Calendar grid ──────────────────────────────────────────────────────────────
class _CalendarGrid extends StatelessWidget {
final DateTime month;
final Set<DateTime> diasEspeciales;
final Set<DateTime> diasCambio;
final ValueChanged<DateTime> onDayTap;
const _CalendarGrid({
required this.month,
required this.diasEspeciales,
required this.diasCambio,
required this.onDayTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final firstDay = DateTime(month.year, month.month, 1);
final offset = firstDay.weekday - 1; // Lun=0, Dom=6
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
final rows = ((offset + daysInMonth) / 7).ceil();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Headers de días
Row(
children: _diasCortos.map((d) {
return Expanded(
child: Center(
child: Text(
d,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
),
);
}).toList(),
),
const SizedBox(height: 2),
// Filas de días
...List.generate(rows, (row) {
return Row(
children: List.generate(7, (col) {
final dayNum = row * 7 + col - offset + 1;
if (dayNum < 1 || dayNum > daysInMonth) {
return const Expanded(child: SizedBox(height: 34));
}
final fecha = DateTime(month.year, month.month, dayNum);
return Expanded(
child: _DayCell(
day: dayNum,
isToday: fecha == today,
isEspecial: diasEspeciales.contains(fecha),
isCambio: diasCambio.contains(fecha),
onTap: () => onDayTap(fecha),
),
);
}),
);
}),
],
),
);
}
}
// ── Day cell ───────────────────────────────────────────────────────────────────
class _DayCell extends StatelessWidget {
final int day;
final bool isToday;
final bool isEspecial;
final bool isCambio;
final VoidCallback onTap;
const _DayCell({
required this.day,
required this.isToday,
required this.isEspecial,
required this.isCambio,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Container(
height: 34,
margin: const EdgeInsets.all(1),
decoration: isToday
? BoxDecoration(
color: SomaColors.primary.withAlpha(50),
borderRadius: BorderRadius.circular(6),
)
: null,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$day',
style: TextStyle(
fontSize: 12,
fontWeight: isToday ? FontWeight.w700 : FontWeight.w500,
color: isToday
? SomaColors.primaryText
: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 2),
// Espacio reservado siempre para mantener altura uniforme
SizedBox(
height: 5,
child: (isEspecial || isCambio)
? Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (isEspecial) const _Dot(color: _colorEspecial),
if (isEspecial && isCambio) const SizedBox(width: 2),
if (isCambio) const _Dot(color: _colorCambio),
],
)
: null,
),
],
),
),
);
}
}
class _Dot extends StatelessWidget {
final Color color;
const _Dot({required this.color});
@override
Widget build(BuildContext context) {
return Container(
width: 4,
height: 4,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
}
}
// ── Legend ─────────────────────────────────────────────────────────────────────
class _Legend extends StatelessWidget {
final bool cargandoCambios;
const _Legend({required this.cargandoCambios});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
_LegendItem(color: _colorEspecial, label: 'Día especial'),
const SizedBox(width: 14),
if (cargandoCambios)
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 8,
height: 8,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: theme.colorScheme.onSurface.withAlpha(80),
),
),
const SizedBox(width: 5),
Text(
'Cargando...',
style: TextStyle(
fontSize: 10,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
],
)
else
_LegendItem(color: _colorCambio, label: 'Nuevo horario'),
],
),
);
}
}
class _LegendItem extends StatelessWidget {
final Color color;
final String label;
const _LegendItem({required this.color, required this.label});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 10,
color: theme.colorScheme.onSurface.withAlpha(120),
),
),
],
);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/dia_columna.dart';
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo',
];
const _minColumnWidth = 160.0;
bool _isSameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
class SemanaTablaView extends StatelessWidget {
final SemanaHorarios semana;
/// Índices de días a mostrar (0 = Lunes … 6 = Domingo).
final List<int> diasVisibles;
final DateTime weekStart;
final DateTime? selectedDay;
final bool isAdmin;
final void Function(DateTime) onSelectDia;
final void Function(DateTime, DiaHorarios?) onEditarDia;
final void Function(DiaHorarios)? onCopiarDia;
final void Function(DiaHorarios, BloqueHorario)? onEliminarBloque;
const SemanaTablaView({
super.key,
required this.semana,
required this.diasVisibles,
required this.weekStart,
required this.selectedDay,
required this.isAdmin,
required this.onSelectDia,
required this.onEditarDia,
this.onCopiarDia,
this.onEliminarBloque,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final count = diasVisibles.length;
return LayoutBuilder(
builder: (context, constraints) {
final available = constraints.maxWidth;
final useScroll = available < count * _minColumnWidth;
final rowChildren = <Widget>[];
for (int i = 0; i < count; i++) {
final diaIdx = diasVisibles[i];
final fecha = weekStart.add(Duration(days: diaIdx));
final dia = semana.diaPara(fecha);
final isSelected =
selectedDay != null && _isSameDay(fecha, selectedDay!);
if (i > 0) {
rowChildren.add(Container(
width: 1,
color: theme.colorScheme.surfaceContainerHighest,
));
}
final columna = DiaColumna(
fecha: fecha,
nombreDia: _diasSemana[diaIdx],
dia: dia,
isSelected: isSelected,
isAdmin: isAdmin,
onSelectDia: () => onSelectDia(fecha),
onEditarDia: (f, d) => onEditarDia(f, d),
onCopiarDia: dia != null &&
!dia.esEspecial &&
dia.bloques.isNotEmpty &&
onCopiarDia != null
? () => onCopiarDia!(dia)
: null,
onEliminarBloque: dia != null && onEliminarBloque != null
? (bloque) => onEliminarBloque!(dia, bloque)
: null,
);
rowChildren.add(
useScroll
? SizedBox(width: _minColumnWidth, child: columna)
: Expanded(child: columna),
);
}
final row = Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: rowChildren,
);
if (!useScroll) return row;
// Ancho total: columnas + separadores de 1px
final totalWidth =
count * _minColumnWidth + (count - 1).toDouble();
return Scrollbar(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: totalWidth,
height: constraints.maxHeight,
child: row,
),
),
);
},
);
}
}
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
/// Selector de fecha para el campo `valido_desde` de un horario regular.
///
/// [weekdayTarget] (1=Lun..7=Dom) restringe la selección a fechas del mismo
/// día de la semana que el día siendo editado. Pasar `null` para permitir
/// cualquier fecha (útil cuando se copia a múltiples días de la semana).
class ValidoDesdeSelector extends StatelessWidget {
final DateTime fecha;
final int? weekdayTarget;
final ValueChanged<DateTime> onChanged;
const ValidoDesdeSelector({
super.key,
required this.fecha,
required this.weekdayTarget,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final label =
'${_diasSemana[fecha.weekday - 1]}, ${fecha.day} ${_mesesCortos[fecha.month]} ${fecha.year}';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Entra en vigor el',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 6),
InkWell(
onTap: () async {
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
final target = weekdayTarget;
final DateTime initial;
if (target != null) {
if (!fecha.isBefore(hoy) && fecha.weekday == target) {
initial = fecha;
} else {
final diff = (target - hoy.weekday + 7) % 7;
initial = hoy.add(Duration(days: diff));
}
} else {
initial = fecha.isBefore(hoy) ? hoy : fecha;
}
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: hoy,
lastDate: DateTime(2100),
selectableDayPredicate:
target != null ? (d) => d.weekday == target : null,
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: SomaColors.primary,
onPrimary: SomaColors.onPrimary,
),
),
child: child!,
),
);
if (picked != null) onChanged(picked);
},
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: theme.colorScheme.surfaceContainerHighest.withAlpha(60),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
children: [
const Icon(Icons.schedule, size: 18, color: SomaColors.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
Icon(
Icons.expand_more,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(120),
),
],
),
),
),
],
);
}
}