Agrego frontend app
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user