Agrego frontend app

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