Agrego frontend app
This commit is contained in:
+175
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+358
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+404
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+220
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+531
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+122
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user