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,400 @@
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_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/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
import 'package:gimnasio_soma/features/turnos/presentation/widgets/asignar_usuario_turno_dialog.dart';
import 'package:gimnasio_soma/features/turnos/presentation/widgets/crear_turno_dialog.dart';
import 'package:gimnasio_soma/features/turnos/presentation/widgets/dia_inscriptos_sheet.dart';
import 'package:gimnasio_soma/features/turnos/presentation/widgets/inscriptos_turno_dialog.dart';
import 'package:gimnasio_soma/features/turnos/presentation/widgets/semana_turnos_grid.dart';
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo',
];
const _maxWeeksBack = 4;
const _maxWeeksForward = 8;
class TurnosScreen extends ConsumerStatefulWidget {
const TurnosScreen({super.key});
@override
ConsumerState<TurnosScreen> createState() => _TurnosScreenState();
}
class _TurnosScreenState extends ConsumerState<TurnosScreen> {
late DateTime _weekStart;
List<int> _diasVisibles = [0, 1, 2, 3, 4];
static DateTime _toMonday(DateTime d) =>
DateTime(d.year, d.month, d.day - (d.weekday - 1));
DateTime get _minWeek {
final now = DateTime.now();
return _toMonday(DateTime(now.year, now.month, now.day))
.subtract(const Duration(days: 7 * _maxWeeksBack));
}
DateTime get _maxWeek {
final now = DateTime.now();
return _toMonday(DateTime(now.year, now.month, now.day))
.add(const Duration(days: 7 * _maxWeeksForward));
}
bool get _canGoPrev => _weekStart.isAfter(_minWeek);
bool get _canGoNext => _weekStart.isBefore(_maxWeek);
@override
void initState() {
super.initState();
_weekStart = _toMonday(DateTime.now());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _cargarSemana();
});
}
Future<void> _cargarSemana() async {
final week = _weekStart;
final error = await ref.read(turnosProvider.notifier).cargarSemana(week);
if (!mounted || _weekStart != week) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
}
}
Future<void> _refrescar() async {
final error = await ref.read(turnosProvider.notifier).refrescar();
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
}
}
void _prevWeek() {
if (!_canGoPrev) return;
setState(() => _weekStart = _weekStart.subtract(const Duration(days: 7)));
_cargarSemana();
}
void _nextWeek() {
if (!_canGoNext) return;
setState(() => _weekStart = _weekStart.add(const Duration(days: 7)));
_cargarSemana();
}
String _weekLabel() {
final end = _weekStart.add(const Duration(days: 6));
final sameMonth = _weekStart.month == end.month;
if (sameMonth) {
return '${_weekStart.day} ${end.day} ${_mesesCortos[end.month]} ${end.year}';
}
return '${_weekStart.day} ${_mesesCortos[_weekStart.month]} ${end.day} ${_mesesCortos[end.month]} ${end.year}';
}
void _openDiasConfig() {
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();
}
});
setState(() => _diasVisibles = local);
},
);
}),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Listo'),
),
],
),
),
);
}
Future<void> _handleCrearTurno(DateTime fecha) async {
final data = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => const CrearTurnoDialog(),
);
if (data == null || !mounted) return;
final error = await ref.read(turnosProvider.notifier).crearTurnoManual(
fecha: fecha,
actividadId: data['actividad_id'] as int,
horaInicio: data['hora_inicio'] as String,
horaFin: data['hora_fin'] as String,
capacidad: data['capacidad'] as int,
);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
}
}
Future<void> _handleAsignar(Turno turno, DateTime fecha) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AsignarUsuarioTurnoDialog(turno: turno, fecha: fecha),
);
if (ok == true && mounted) await _refrescar();
}
Future<void> _handleVerInscriptos(Turno turno) async {
var huboCambios = false;
await showDialog(
context: context,
builder: (_) => InscriptosTurnoDialog(
turno: turno,
onCambio: () => huboCambios = true,
),
);
// Solo refrescamos si se canceló alguna inscripción. Abrir y cerrar el
// diálogo sin tocar nada no dispara el rebuild de la grilla (que trababa
// la animación de cierre).
if (mounted && huboCambios) await _refrescar();
}
Future<void> _handleVerDia(DateTime fecha, DiaTurnos? dia) async {
if (dia == null || dia.turnos.isEmpty) return;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => DiaInscriptosSheet(
dia: dia,
fecha: fecha,
isAdmin: _isAdmin,
),
);
if (mounted) await _refrescar();
}
bool get _isAdmin {
final user = ref.read(authStateProvider).value;
return user?.isStaff ?? false;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final isWide = MediaQuery.of(context).size.width >= 800;
final hPad = isWide ? 32.0 : 16.0;
final state = ref.watch(turnosProvider);
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(
'Turnos',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
SomaHeaderHelp(
items: [
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 grilla.',
),
const SomaHelpItem(
icon: Icons.touch_app_outlined,
text: 'Tocá un turno para ver o asignar inscriptos, '
'o un día sin turnos para crear uno nuevo.',
),
const SomaHelpItem(
icon: Icons.refresh,
text: 'Recarga los turnos de la semana actual.',
),
],
),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Recargar',
onPressed: state.isLoading ? null : _refrescar,
),
],
),
),
// ── Navegación semanal ────────────────────────────────────────────
Padding(
padding: EdgeInsets.symmetric(horizontal: hPad, vertical: 12),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: _canGoPrev ? _prevWeek : null,
visualDensity: VisualDensity.compact,
),
Expanded(
child: Text(
_weekLabel(),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: cs.onSurface,
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: _canGoNext ? _nextWeek : null,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.tune, size: 20),
tooltip: 'Días visibles',
visualDensity: VisualDensity.compact,
onPressed: _openDiasConfig,
),
],
),
),
// ── Contenido ─────────────────────────────────────────────────────
Expanded(
child: Padding(
padding: EdgeInsets.fromLTRB(hPad, 0, hPad, hPad),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: cs.outline.withAlpha(40),
width: 0.5,
),
),
clipBehavior: Clip.antiAlias,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
child: state.when(
loading: () => Center(
key: const ValueKey('loading'),
child: CircularProgressIndicator(
color: SomaColors.primary),
),
error: (e, _) => _ErrorView(
key: const ValueKey('error'),
message: e.toString().replaceFirst('Exception: ', ''),
onRetry: _cargarSemana,
),
data: (semana) {
if (semana == null) {
return Center(
key: const ValueKey('null'),
child: CircularProgressIndicator(
color: SomaColors.primary),
);
}
return SemanaTurnosGrid(
key: ValueKey(_weekStart),
semana: semana,
weekStart: _weekStart,
diasVisibles: _diasVisibles,
isAdmin: isAdmin,
onCrearTurno: _handleCrearTurno,
onAsignar: _handleAsignar,
onVerInscriptos: _handleVerInscriptos,
onTapDia: _handleVerDia,
);
},
),
),
),
),
),
],
),
);
}
}
// ── Error view ─────────────────────────────────────────────────────────────────
class _ErrorView extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorView({super.key, required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: cs.error.withAlpha(178)),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: cs.onSurface.withAlpha(153)),
),
const SizedBox(height: 20),
OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Reintentar'),
),
],
),
),
);
}
}