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,142 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/config/supabase_config.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/domain/repositories/turnos_repository.dart';
class TurnosRepositoryImpl implements TurnosRepository {
Future<String> _getToken() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token == null) throw Exception('Sin sesión activa');
return token;
}
String _formatDate(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
@override
Future<SemanaTurnos> obtenerSemana(DateTime weekStart) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerTurnos,
params: {
'p_token': token,
'p_fecha_inicio': _formatDate(weekStart),
'p_cantidad_dias': 7,
},
);
if (response is Map) {
return SemanaTurnos.fromResponse(
weekStart,
response.cast<String, dynamic>(),
);
}
return SemanaTurnos.fromResponse(weekStart, const {});
}
@override
Future<void> crearTurnoManual({
required DateTime fecha,
required int actividadId,
required String horaInicio,
required String horaFin,
required int capacidad,
}) async {
final token = await _getToken();
// Admin workaround documentado en function_guide: fc_upsert_turno
// está marcado BACKEND y se usa intencionalmente sólo desde aquí
// para que Juani agregue un turno suelto sin tocar el schedule.
await SupabaseConfig.rpc(
AppConstants.rpcUpsertTurno,
params: {
'p_token': token,
'p_fecha': _formatDate(fecha),
'p_actividad_id': actividadId,
'p_hora_inicio': horaInicio,
'p_hora_fin': horaFin,
'p_capacidad_maxima': capacidad,
},
);
}
@override
Future<List<InscriptoTurno>> obtenerInscriptos(String turnoId) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerReservasTurno,
params: {'p_token': token, 'p_turno_id': turnoId},
);
if (response is List) {
return response
.map((e) => InscriptoTurno.fromMap((e as Map).cast<String, dynamic>()))
.toList();
}
return const [];
}
@override
Future<void> reservarAdmin({
required String turnoId,
required String clienteId,
}) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcReservarTurnoAdmin,
params: {
'p_token': token,
'p_cliente_id': clienteId,
'p_turno_id': turnoId,
},
);
}
@override
Future<void> cancelarReservaAdmin(String reservaId) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcCancelarReservaAdmin,
params: {'p_token': token, 'p_reserva_id': reservaId},
);
}
@override
Future<EstadoCupo> obtenerEstadoCupo({
required String clienteId,
required DateTime fecha,
}) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerEstadoCupo,
params: {
'p_token': token,
'p_cliente_id': clienteId,
'p_fecha': _formatDate(fecha),
},
);
if (response is Map) {
return EstadoCupo.fromMap(response.cast<String, dynamic>());
}
return const EstadoCupo(
usados: 0,
disponibles: 0,
limiteTotal: 0,
tienePlan: false,
);
}
@override
Future<Map<String, dynamic>> limpiarTurnosAntiguos({
int diasAntiguedad = 30,
}) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcLimpiarTurnosAntiguos,
params: {'p_token': token, 'p_dias_antiguedad': diasAntiguedad},
);
if (response is Map) return response.cast<String, dynamic>();
return {'status': 'error', 'mensaje': 'Respuesta inesperada'};
}
}
@@ -0,0 +1,195 @@
enum DiaEstado { cerrado, normal, horarioDiferente }
DiaEstado _parseEstado(String? raw) {
switch (raw) {
case 'cerrado':
return DiaEstado.cerrado;
case 'horario_diferente':
return DiaEstado.horarioDiferente;
default:
return DiaEstado.normal;
}
}
class TurnoActividad {
final int id;
final String nombre;
final bool libre;
const TurnoActividad({
required this.id,
required this.nombre,
required this.libre,
});
factory TurnoActividad.fromMap(Map<String, dynamic> map) {
return TurnoActividad(
id: (map['id'] as num?)?.toInt() ?? 0,
nombre: map['nombre'] as String? ?? '',
libre: map['libre'] as bool? ?? false,
);
}
}
class Turno {
final String id;
final String horaInicio;
final String horaFin;
final int capacidadMaxima;
final int ocupacion;
final TurnoActividad actividad;
const Turno({
required this.id,
required this.horaInicio,
required this.horaFin,
required this.capacidadMaxima,
required this.ocupacion,
required this.actividad,
});
factory Turno.fromMap(Map<String, dynamic> map) {
return Turno(
id: map['id'] as String? ?? '',
horaInicio: map['hora_inicio'] as String? ?? '',
horaFin: map['hora_fin'] as String? ?? '',
capacidadMaxima: (map['capacidad_maxima'] as num?)?.toInt() ?? 0,
ocupacion: (map['ocupacion'] as num?)?.toInt() ?? 0,
actividad: TurnoActividad.fromMap(
(map['actividad'] as Map?)?.cast<String, dynamic>() ?? const {},
),
);
}
int get disponible {
final libre = capacidadMaxima - ocupacion;
return libre < 0 ? 0 : libre;
}
bool get estaLleno => capacidadMaxima > 0 && disponible == 0;
}
class DiaTurnos {
final DateTime fecha;
final int diaSemana;
final DiaEstado estado;
final List<Turno> turnos;
const DiaTurnos({
required this.fecha,
required this.diaSemana,
required this.estado,
required this.turnos,
});
factory DiaTurnos.fromMap(DateTime fecha, Map<String, dynamic> map) {
final lista = (map['turnos'] as List?)
?.map((e) => Turno.fromMap((e as Map).cast<String, dynamic>()))
.toList() ??
const <Turno>[];
return DiaTurnos(
fecha: fecha,
diaSemana: (map['dia_semana'] as num?)?.toInt() ?? fecha.weekday,
estado: _parseEstado(map['estado'] as String?),
turnos: lista,
);
}
}
class SemanaTurnos {
final DateTime weekStart;
final Map<String, DiaTurnos> _byKey;
const SemanaTurnos._(this.weekStart, this._byKey);
factory SemanaTurnos.fromResponse(
DateTime weekStart,
Map<String, dynamic> raw,
) {
final map = <String, DiaTurnos>{};
raw.forEach((dateKey, value) {
if (value is! Map) return;
final fecha = DateTime.tryParse(dateKey);
if (fecha == null) return;
final f = DateTime(fecha.year, fecha.month, fecha.day);
map[_keyFor(f)] =
DiaTurnos.fromMap(f, value.cast<String, dynamic>());
});
return SemanaTurnos._(weekStart, map);
}
static String _keyFor(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
DiaTurnos? diaPara(DateTime fecha) => _byKey[_keyFor(fecha)];
bool contieneFecha(DateTime fecha) => _byKey.containsKey(_keyFor(fecha));
}
class InscriptoTurno {
final String reservaId;
final String clienteId;
final String nombre;
final String? apellido;
final bool cancelada;
const InscriptoTurno({
required this.reservaId,
required this.clienteId,
required this.nombre,
this.apellido,
this.cancelada = false,
});
factory InscriptoTurno.fromMap(Map<String, dynamic> map) {
return InscriptoTurno(
reservaId: map['reserva_id'] as String? ?? '',
clienteId: map['cliente_id'] as String? ?? '',
nombre: map['nombre'] as String? ?? '',
apellido: map['apellido'] as String?,
cancelada: map['cancelada'] as bool? ?? false,
);
}
String get displayName {
if (nombre.isNotEmpty && apellido != null && apellido!.isNotEmpty) {
return '$nombre $apellido';
}
return nombre.isNotEmpty ? nombre : '?';
}
String get initials {
if (nombre.isNotEmpty) {
if (apellido != null && apellido!.isNotEmpty) {
return '${nombre[0]}${apellido![0]}'.toUpperCase();
}
return nombre[0].toUpperCase();
}
return '?';
}
}
class EstadoCupo {
final int usados;
final int disponibles;
final int limiteTotal;
final bool tienePlan;
const EstadoCupo({
required this.usados,
required this.disponibles,
required this.limiteTotal,
required this.tienePlan,
});
factory EstadoCupo.fromMap(Map<String, dynamic> map) {
return EstadoCupo(
usados: (map['usados'] as num?)?.toInt() ?? 0,
disponibles: (map['disponibles'] as num?)?.toInt() ?? 0,
limiteTotal: (map['limite_total'] as num?)?.toInt() ?? 0,
tienePlan: map['tiene_plan'] as bool? ?? false,
);
}
}
@@ -0,0 +1,40 @@
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
abstract class TurnosRepository {
/// Carga la semana completa empezando en [weekStart] (lunes recomendado).
/// Una sola llamada trae 7 días consecutivos; la JIT del backend
/// materializa los turnos faltantes según el horario vigente.
Future<SemanaTurnos> obtenerSemana(DateTime weekStart);
/// Workaround admin para crear un turno suelto fuera del schedule regular.
/// El backend lo marca como `es_especial=true`.
Future<void> crearTurnoManual({
required DateTime fecha,
required int actividadId,
required String horaInicio,
required String horaFin,
required int capacidad,
});
/// Lista las reservas del turno (devuelve también canceladas).
Future<List<InscriptoTurno>> obtenerInscriptos(String turnoId);
/// Reserva como admin: bypassa deuda/plan/semana/fecha pasada,
/// sólo respeta capacidad del turno.
Future<void> reservarAdmin({
required String turnoId,
required String clienteId,
});
/// Cancela una reserva como admin: bypassa ownership y antelación.
Future<void> cancelarReservaAdmin(String reservaId);
/// Cupo semanal del cliente para la semana que contiene [fecha].
Future<EstadoCupo> obtenerEstadoCupo({
required String clienteId,
required DateTime fecha,
});
/// Mantenimiento: borra turnos vacíos de más de [diasAntiguedad] días.
Future<Map<String, dynamic>> limpiarTurnosAntiguos({int diasAntiguedad = 30});
}
@@ -0,0 +1,126 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:gimnasio_soma/features/turnos/data/repositories/turnos_repository_impl.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/domain/repositories/turnos_repository.dart';
String _errorMessage(Object e) {
if (e is PostgrestException) return e.message;
return e.toString().replaceFirst('Exception: ', '');
}
DateTime _normalizeWeekStart(DateTime d) {
final monday = DateTime(d.year, d.month, d.day - (d.weekday - 1));
return monday;
}
final turnosRepositoryProvider = Provider<TurnosRepository>((ref) {
return TurnosRepositoryImpl();
});
final turnosProvider =
StateNotifierProvider<TurnosNotifier, AsyncValue<SemanaTurnos?>>((ref) {
return TurnosNotifier(ref.read(turnosRepositoryProvider));
});
class TurnosNotifier extends StateNotifier<AsyncValue<SemanaTurnos?>> {
final TurnosRepository _repository;
DateTime? _currentWeek;
TurnosNotifier(this._repository) : super(const AsyncValue.data(null));
DateTime? get semanaActual => _currentWeek;
/// Carga la semana que contiene [referencia] (se normaliza al lunes).
/// Si ya estamos en esa semana, no recarga (a menos que [force] sea true).
Future<String?> cargarSemana(
DateTime referencia, {
bool force = false,
}) async {
final weekStart = _normalizeWeekStart(referencia);
if (!force && _currentWeek == weekStart && state.value != null) {
return null;
}
_currentWeek = weekStart;
state = const AsyncValue.loading();
try {
final semana = await _repository.obtenerSemana(weekStart);
if (_currentWeek != weekStart) return null; // semana cambió mientras cargaba
state = AsyncValue.data(semana);
return null;
} catch (e, st) {
if (_currentWeek != weekStart) return null;
state = AsyncValue.error(e, st);
return _errorMessage(e);
}
}
/// Refresca la semana actual en segundo plano, sin parpadeo: mantiene los
/// datos visibles mientras recarga y solo los reemplaza cuando llegan. Evita
/// el "corte" de volver a estado loading, que vaciaría la grilla a un spinner
/// (se nota, por ejemplo, al cerrar el diálogo de inscriptos).
Future<String?> refrescar() async {
final week = _currentWeek;
if (week == null) return null;
try {
final semana = await _repository.obtenerSemana(week);
if (_currentWeek != week) return null; // semana cambió mientras cargaba
state = AsyncValue.data(semana);
return null;
} catch (e) {
if (_currentWeek != week) return null;
// No pisamos los datos visibles con un error: los dejamos en pantalla y
// devolvemos el mensaje para que la pantalla muestre un toast.
return _errorMessage(e);
}
}
Future<String?> crearTurnoManual({
required DateTime fecha,
required int actividadId,
required String horaInicio,
required String horaFin,
required int capacidad,
}) async {
try {
await _repository.crearTurnoManual(
fecha: fecha,
actividadId: actividadId,
horaInicio: horaInicio,
horaFin: horaFin,
capacidad: capacidad,
);
await refrescar();
return null;
} catch (e) {
return _errorMessage(e);
}
}
Future<String?> limpiarAntiguos({int dias = 30}) async {
try {
final result =
await _repository.limpiarTurnosAntiguos(diasAntiguedad: dias);
final eliminados = result['turnos_eliminados'] ?? 0;
return 'Se eliminaron $eliminados turnos antiguos';
} catch (e) {
return _errorMessage(e);
}
}
}
/// Lista de reservas de un turno (incluye canceladas; filtrar en UI).
final inscriptosTurnoProvider =
FutureProvider.autoDispose.family<List<InscriptoTurno>, String>(
(ref, turnoId) =>
ref.read(turnosRepositoryProvider).obtenerInscriptos(turnoId),
);
/// Cupo semanal del cliente para la fecha pedida (usados/disponibles/total).
final estadoCupoProvider = FutureProvider.autoDispose
.family<EstadoCupo, ({String clienteId, DateTime fecha})>(
(ref, params) => ref.read(turnosRepositoryProvider).obtenerEstadoCupo(
clienteId: params.clienteId,
fecha: params.fecha,
),
);
@@ -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'),
),
],
),
),
);
}
}
@@ -0,0 +1,707 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart';
import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_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/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
String _errorMessage(Object e) {
if (e is PostgrestException) return e.message;
return e.toString().replaceFirst('Exception: ', '');
}
enum _Violacion { ninguna, sinPlan, actividadNoEnPlan }
class AsignarUsuarioTurnoDialog extends ConsumerStatefulWidget {
final Turno turno;
final DateTime fecha;
const AsignarUsuarioTurnoDialog({
super.key,
required this.turno,
required this.fecha,
});
@override
ConsumerState<AsignarUsuarioTurnoDialog> createState() =>
_AsignarUsuarioTurnoDialogState();
}
class _AsignarUsuarioTurnoDialogState
extends ConsumerState<AsignarUsuarioTurnoDialog> {
final Set<String> _selectedIds = {};
final Map<String, Usuario> _selectedMap = {};
bool _loading = false;
String _searchQuery = '';
String? _filterPlanId; // null = todos
_Violacion _getViolacion(Usuario user, List<TipoCuota> planes) {
if (widget.turno.actividad.libre) return _Violacion.ninguna;
if (user.tipoCuota == null) return _Violacion.sinPlan;
final plan = planes.where((p) => p.id == user.tipoCuota).firstOrNull;
if (plan == null) return _Violacion.sinPlan;
if (!plan.actividadesIds.contains(widget.turno.actividad.id)) {
return _Violacion.actividadNoEnPlan;
}
return _Violacion.ninguna;
}
void _toggle(Usuario u) {
setState(() {
if (_selectedIds.contains(u.id)) {
_selectedIds.remove(u.id);
_selectedMap.remove(u.id);
} else {
_selectedIds.add(u.id);
_selectedMap[u.id] = u;
}
});
}
Future<void> _submit() async {
if (_selectedIds.isEmpty) return;
setState(() => _loading = true);
int ok = 0;
String? lastError;
for (final u in _selectedMap.values) {
try {
await ref.read(turnosRepositoryProvider).reservarAdmin(
turnoId: widget.turno.id,
clienteId: u.id,
);
ok++;
} catch (e) {
lastError = '${u.displayName}: ${_errorMessage(e)}';
}
}
if (!mounted) return;
setState(() => _loading = false);
if (lastError != null) {
SomaToast.show(context, message: lastError, type: ToastType.error);
}
if (ok > 0 && mounted) Navigator.of(context).pop(true);
}
bool get _canSubmit => _selectedIds.isNotEmpty && !_loading;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final width = MediaQuery.of(context).size.width;
final usuariosAsync = ref.watch(usuariosProvider);
final planesAsync = ref.watch(tiposCuotaProvider);
final planes = planesAsync.valueOrNull ?? [];
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: width >= 660 ? (width - 580) / 2 : 12,
vertical: 28,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 580, maxHeight: 760),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Header ───────────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 12, 0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: cs.primary.withAlpha(25),
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.group_add_outlined,
size: 18, color: cs.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Asignar usuarios',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.w700)),
Text(
'${widget.turno.actividad.nombre} · '
'${widget.turno.horaInicio} ${widget.turno.horaFin}',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(140)),
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// ── Contenido ────────────────────────────────────────────────────
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Filtro por plan ──────────────────────────────────────
planesAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (allPlanes) => _PlanFilterChips(
planes: allPlanes,
selectedPlanId: _filterPlanId,
onSelected: (id) =>
setState(() => _filterPlanId = id),
),
),
const SizedBox(height: 10),
// ── Lista de usuarios ────────────────────────────────────
usuariosAsync.when(
loading: () => const Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
error: (_, _) => Text(
'Error cargando usuarios',
style: TextStyle(
color: SomaColors.error, fontSize: 13),
),
data: (usuarios) {
final clientes = usuarios
.where(
(u) => u.isActive && u.rol == 'cliente')
.toList();
// Filtro por plan
final planFiltrados = _filterPlanId == null
? clientes
: clientes
.where(
(u) => u.tipoCuota == _filterPlanId)
.toList();
// Filtro por búsqueda
final filtrados = _searchQuery.isEmpty
? planFiltrados
: planFiltrados
.where((u) => u.displayName
.toLowerCase()
.contains(
_searchQuery.toLowerCase()))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: InputDecoration(
hintText: 'Buscar usuario...',
prefixIcon:
const Icon(Icons.search, size: 18),
contentPadding:
const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
isDense: true,
border: OutlineInputBorder(
borderRadius:
BorderRadius.circular(10),
borderSide: BorderSide(
color: cs.outline.withAlpha(60)),
),
),
onChanged: (v) =>
setState(() => _searchQuery = v),
),
const SizedBox(height: 8),
if (filtrados.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(
vertical: 16),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.person_off_outlined,
size: 18,
color:
cs.onSurface.withAlpha(80)),
const SizedBox(width: 8),
Text(
_filterPlanId != null
? 'Sin usuarios con este plan'
: 'Sin resultados',
style: TextStyle(
fontSize: 13,
color: cs.onSurface
.withAlpha(120)),
),
],
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 280),
child: ListView.builder(
shrinkWrap: true,
itemCount: filtrados.length,
itemBuilder: (_, i) {
final u = filtrados[i];
final selected =
_selectedIds.contains(u.id);
final plan = planes
.where((p) =>
p.id == u.tipoCuota)
.firstOrNull;
final violacion =
_getViolacion(u, planes);
return _UserListItem(
user: u,
plan: plan,
violacion: violacion,
selected: selected,
onToggle: () => _toggle(u),
);
},
),
),
],
);
},
),
// ── Panel de seleccionados ───────────────────────────────
if (_selectedMap.isNotEmpty) ...[
const SizedBox(height: 16),
Divider(color: cs.surfaceContainerHighest),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.check_circle_outline,
size: 15, color: SomaColors.success),
const SizedBox(width: 6),
Text(
'${_selectedMap.length} seleccionado${_selectedMap.length == 1 ? '' : 's'}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: cs.onSurface.withAlpha(160),
),
),
],
),
const SizedBox(height: 8),
..._selectedMap.values.map((u) {
final violacion = _getViolacion(u, planes);
return _SelectedUserRow(
user: u,
fecha: widget.fecha,
violacion: violacion,
actividad: widget.turno.actividad.nombre,
onRemove: () => _toggle(u),
);
}),
],
const SizedBox(height: 4),
],
),
),
),
// ── Botones ───────────────────────────────────────────────────
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancelar',
style: TextStyle(
color: cs.onSurface.withAlpha(178))),
),
const SizedBox(width: 12),
FilledButton(
onPressed: _canSubmit ? _submit : null,
style: FilledButton.styleFrom(
minimumSize: const Size(0, 42)),
child: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.onPrimary))
: Text(_selectedMap.length > 1
? 'Asignar (${_selectedMap.length})'
: 'Asignar'),
),
],
),
),
],
),
),
);
}
}
// ── Chips de filtro por plan ───────────────────────────────────────────────────
class _PlanFilterChips extends StatelessWidget {
final List<TipoCuota> planes;
final String? selectedPlanId;
final ValueChanged<String?> onSelected;
const _PlanFilterChips({
required this.planes,
required this.selectedPlanId,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
if (planes.isEmpty) return const SizedBox.shrink();
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_FilterChip(
label: 'Todos',
selected: selectedPlanId == null,
onTap: () => onSelected(null),
cs: cs,
),
...planes.map((p) => _FilterChip(
label: p.nombre,
selected: selectedPlanId == p.id,
onTap: () =>
onSelected(selectedPlanId == p.id ? null : p.id),
cs: cs,
)),
],
),
);
}
}
class _FilterChip extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
final ColorScheme cs;
const _FilterChip({
required this.label,
required this.selected,
required this.onTap,
required this.cs,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.only(right: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: selected
? SomaColors.primary.withAlpha(30)
: cs.surfaceContainerHighest.withAlpha(80),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: selected
? SomaColors.primary.withAlpha(160)
: cs.outline.withAlpha(40),
width: selected ? 1 : 0.5,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected
? SomaColors.primaryText
: cs.onSurface.withAlpha(160),
),
),
),
);
}
}
// ── Fila de usuario en la lista ───────────────────────────────────────────────
class _UserListItem extends StatelessWidget {
final Usuario user;
final TipoCuota? plan;
final _Violacion violacion;
final bool selected;
final VoidCallback onToggle;
const _UserListItem({
required this.user,
required this.plan,
required this.violacion,
required this.selected,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final hasViolacion = violacion != _Violacion.ninguna;
return InkWell(
onTap: onToggle,
borderRadius: BorderRadius.circular(8),
child: Container(
margin: const EdgeInsets.only(bottom: 3),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: selected
? SomaColors.primary.withAlpha(20)
: Colors.transparent,
border: Border.all(
color: selected
? SomaColors.primary.withAlpha(90)
: Colors.transparent,
width: 0.8,
),
),
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: Checkbox(
value: selected,
onChanged: (_) => onToggle(),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
activeColor: SomaColors.primary,
checkColor: SomaColors.onPrimary,
side: BorderSide(
color: cs.outline.withAlpha(140), width: 1.2),
),
),
const SizedBox(width: 10),
CircleAvatar(
radius: 13,
backgroundColor: SomaColors.primary.withAlpha(30),
child: Text(user.initials,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: cs.onSurface)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.displayName,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
if (plan != null)
Text(
plan!.nombre,
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(120)),
overflow: TextOverflow.ellipsis,
)
else
Text(
'Sin plan',
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(80),
fontStyle: FontStyle.italic),
),
],
),
),
if (hasViolacion)
Padding(
padding: const EdgeInsets.only(left: 6),
child: Tooltip(
message: violacion == _Violacion.sinPlan
? 'Sin plan asignado'
: 'Plan no incluye esta actividad',
child: Icon(Icons.warning_amber_rounded,
size: 16, color: const Color(0xFFE67700)),
),
),
],
),
),
);
}
}
// ── Fila de usuario seleccionado (panel inferior) ─────────────────────────────
class _SelectedUserRow extends ConsumerWidget {
final Usuario user;
final DateTime fecha;
final _Violacion violacion;
final String actividad;
final VoidCallback onRemove;
const _SelectedUserRow({
required this.user,
required this.fecha,
required this.violacion,
required this.actividad,
required this.onRemove,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = Theme.of(context).colorScheme;
final cupoAsync =
ref.watch(estadoCupoProvider((clienteId: user.id, fecha: fecha)));
final hasViolacion = violacion != _Violacion.ninguna;
final warningColor = const Color(0xFFE67700);
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withAlpha(50),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: cs.outline.withAlpha(30), width: 0.5),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 14,
backgroundColor: SomaColors.primary.withAlpha(30),
child: Text(user.initials,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: cs.onSurface)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.displayName,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Cupo inline
cupoAsync.when(
loading: () => const SizedBox(
height: 12,
width: 12,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
error: (_, _) => const SizedBox.shrink(),
data: (cupo) {
if (!cupo.tienePlan) {
return Row(children: [
Icon(Icons.info_outline,
size: 12, color: cs.onSurface.withAlpha(100)),
const SizedBox(width: 4),
Text('Sin plan',
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(120))),
]);
}
final lleno = cupo.disponibles == 0;
final cupoColor =
lleno ? SomaColors.error : SomaColors.success;
return Row(children: [
Icon(Icons.calendar_today_outlined,
size: 12, color: cs.onSurface.withAlpha(120)),
const SizedBox(width: 4),
Text(
'${cupo.usados}/${cupo.limiteTotal} días usados',
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(140)),
),
const SizedBox(width: 8),
Text(
'${cupo.disponibles} disp.',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: cupoColor),
),
]);
},
),
// Violación de plan
if (hasViolacion) ...[
const SizedBox(height: 3),
Row(children: [
Icon(Icons.warning_amber_rounded,
size: 12, color: warningColor),
const SizedBox(width: 4),
Expanded(
child: Text(
violacion == _Violacion.sinPlan
? 'Sin plan · se asignará igual'
: 'Plan no incluye $actividad · se asignará igual',
style: TextStyle(
fontSize: 11, color: warningColor),
overflow: TextOverflow.ellipsis,
),
),
]),
],
],
),
),
IconButton(
icon: Icon(Icons.close,
size: 16, color: cs.onSurface.withAlpha(140)),
tooltip: 'Quitar de la selección',
onPressed: onRemove,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
padding: EdgeInsets.zero,
),
],
),
);
}
}
@@ -0,0 +1,250 @@
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/presentation/providers/actividades_provider.dart';
class CrearTurnoDialog extends ConsumerStatefulWidget {
const CrearTurnoDialog({super.key});
@override
ConsumerState<CrearTurnoDialog> createState() => _CrearTurnoDialogState();
}
class _CrearTurnoDialogState extends ConsumerState<CrearTurnoDialog> {
int? _actividadId;
TimeOfDay _horaInicio = const TimeOfDay(hour: 8, minute: 0);
TimeOfDay _horaFin = const TimeOfDay(hour: 9, minute: 0);
int _capacidad = 10;
final _capacidadCtrl = TextEditingController(text: '10');
@override
void dispose() {
_capacidadCtrl.dispose();
super.dispose();
}
Future<void> _pickTime({required bool isStart}) async {
final initial = isStart ? _horaInicio : _horaFin;
final picked = await showTimePicker(
context: context,
initialTime: initial,
builder: (ctx, child) => Theme(
data: Theme.of(ctx).copyWith(
colorScheme: Theme.of(ctx).colorScheme.copyWith(
primary: SomaColors.primary,
onPrimary: SomaColors.onPrimary,
),
),
child: child!,
),
);
if (picked == null) return;
setState(() {
if (isStart) {
_horaInicio = picked;
if (_toMin(picked) >= _toMin(_horaFin)) {
_horaFin = TimeOfDay(hour: (picked.hour + 1) % 24, minute: picked.minute);
}
} else {
_horaFin = picked;
}
});
}
int _toMin(TimeOfDay t) => t.hour * 60 + t.minute;
String _fmt(TimeOfDay t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
bool get _valid =>
_actividadId != null && _toMin(_horaInicio) < _toMin(_horaFin) && _capacidad > 0;
void _submit() {
if (!_valid) return;
Navigator.of(context).pop(<String, dynamic>{
'actividad_id': _actividadId,
'hora_inicio': _fmt(_horaInicio),
'hora_fin': _fmt(_horaFin),
'capacidad': _capacidad,
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final width = MediaQuery.of(context).size.width;
final actividadesAsync = ref.watch(actividadesProvider);
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: width >= 600 ? (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: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
const Text('Agregar turno',
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),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Actividad
actividadesAsync.when(
loading: () => const Center(
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) => DropdownButtonFormField<int>(
initialValue: _actividadId,
items: actividades
.where((a) => a.activo)
.map((a) => DropdownMenuItem(value: a.id, child: Text(a.nombre)))
.toList(),
onChanged: (v) => setState(() => _actividadId = v),
decoration: const InputDecoration(
labelText: 'Actividad *',
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
),
),
const SizedBox(height: 20),
// Horario
Row(
children: [
Expanded(
child: _TimeField(
label: 'Desde',
value: _fmt(_horaInicio),
onTap: () => _pickTime(isStart: true),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Icon(Icons.arrow_forward, size: 18,
color: cs.onSurface.withAlpha(100)),
),
Expanded(
child: _TimeField(
label: 'Hasta',
value: _fmt(_horaFin),
onTap: () => _pickTime(isStart: false),
),
),
],
),
if (_toMin(_horaInicio) >= _toMin(_horaFin))
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text('La hora de fin debe ser mayor',
style: TextStyle(color: SomaColors.error, fontSize: 12)),
),
const SizedBox(height: 20),
// Capacidad
TextField(
controller: _capacidadCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Capacidad máxima *',
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
onChanged: (v) => setState(() => _capacidad = int.tryParse(v) ?? 0),
),
const SizedBox(height: 8),
],
),
),
const Divider(height: 1),
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: cs.onSurface.withAlpha(178))),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _valid ? _submit : null,
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 42)),
child: const Text('Agregar'),
),
],
),
),
],
),
),
);
}
}
class _TimeField extends StatelessWidget {
final String label;
final String value;
final VoidCallback onTap;
const _TimeField({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: const 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)),
],
),
),
),
],
);
}
}
@@ -0,0 +1,380 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
const _diasNombres = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo',
];
const _meses = [
'', 'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio',
'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre',
];
Color _barColor(int ocupacion, int capacidad, ColorScheme cs) {
if (capacidad == 0) return cs.outline;
final ratio = ocupacion / capacidad;
if (ratio < 0.5) return SomaColors.success;
if (ratio < 0.85) return const Color(0xFFFFB300);
return SomaColors.error;
}
class DiaInscriptosSheet extends ConsumerWidget {
final DiaTurnos dia;
final DateTime fecha;
final bool isAdmin;
const DiaInscriptosSheet({
super.key,
required this.dia,
required this.fecha,
required this.isAdmin,
});
Future<void> _cancelar(
WidgetRef ref,
BuildContext context,
InscriptoTurno inscripto,
String turnoId,
) async {
try {
await ref.read(turnosRepositoryProvider).cancelarReservaAdmin(inscripto.reservaId);
ref.invalidate(inscriptosTurnoProvider(turnoId));
} catch (e) {
if (context.mounted) {
SomaToast.show(
context,
message: e is PostgrestException
? e.message
: e.toString().replaceFirst('Exception: ', ''),
type: ToastType.error,
);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = Theme.of(context).colorScheme;
final turnos = [...dia.turnos]
..sort((a, b) => a.horaInicio.compareTo(b.horaInicio));
final nombreDia = _diasNombres[fecha.weekday - 1];
final fechaLabel = '${fecha.day} de ${_meses[fecha.month]}';
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.65,
minChildSize: 0.3,
maxChildSize: 0.92,
builder: (ctx, scrollController) {
return Column(
children: [
// Drag handle
Container(
margin: const EdgeInsets.only(top: 12, bottom: 4),
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurface.withAlpha(60),
borderRadius: BorderRadius.circular(2),
),
),
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 12, 12),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: cs.primary.withAlpha(25),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.calendar_today_outlined,
size: 18,
color: cs.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nombreDia,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
Text(
fechaLabel,
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(140),
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
Divider(
height: 1,
thickness: 0.5,
color: cs.surfaceContainerHighest,
),
// Body
Expanded(
child: ListView.separated(
controller: scrollController,
padding: const EdgeInsets.all(16),
itemCount: turnos.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) {
final turno = turnos[i];
return _TurnoSection(
turno: turno,
isAdmin: isAdmin,
onCancelar: (inscripto) =>
_cancelar(ref, context, inscripto, turno.id),
);
},
),
),
],
);
},
);
}
}
// ── Sección de un turno con sus inscriptos ────────────────────────────────────
class _TurnoSection extends ConsumerWidget {
final Turno turno;
final bool isAdmin;
final void Function(InscriptoTurno) onCancelar;
const _TurnoSection({
required this.turno,
required this.isAdmin,
required this.onCancelar,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = Theme.of(context).colorScheme;
final inscriptosAsync = ref.watch(inscriptosTurnoProvider(turno.id));
final barColor = _barColor(turno.ocupacion, turno.capacidadMaxima, cs);
return Container(
decoration: BoxDecoration(
color: cs.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline.withAlpha(30), width: 0.5),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header del turno
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 4, color: barColor),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
turno.actividad.nombre,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'${turno.horaInicio} ${turno.horaFin}',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(140),
),
),
],
),
),
if (turno.capacidadMaxima > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: barColor.withAlpha(25),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: barColor.withAlpha(80),
width: 0.7,
),
),
child: Text(
turno.estaLleno
? 'LLENO'
: '${turno.ocupacion}/${turno.capacidadMaxima}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: barColor,
),
),
),
],
),
),
),
],
),
),
Divider(
height: 1,
thickness: 0.5,
color: cs.outline.withAlpha(25),
),
// Lista de inscriptos
inscriptosAsync.when(
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
error: (_, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Text(
'Error cargando inscriptos',
style: TextStyle(fontSize: 12, color: cs.error),
),
),
data: (inscriptos) {
final activos = inscriptos.where((i) => !i.cancelada).toList();
if (activos.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Row(
children: [
Icon(
Icons.person_off_outlined,
size: 15,
color: cs.onSurface.withAlpha(60),
),
const SizedBox(width: 8),
Text(
'Sin inscriptos',
style: TextStyle(
fontSize: 13,
color: cs.onSurface.withAlpha(100),
),
),
],
),
);
}
return Column(
children: [
for (final inscripto in activos)
_InscriptoRow(
inscripto: inscripto,
isAdmin: isAdmin,
onCancelar: () => onCancelar(inscripto),
),
],
);
},
),
],
),
);
}
}
// ── Fila de inscripto ─────────────────────────────────────────────────────────
class _InscriptoRow extends StatelessWidget {
final InscriptoTurno inscripto;
final bool isAdmin;
final VoidCallback onCancelar;
const _InscriptoRow({
required this.inscripto,
required this.isAdmin,
required this.onCancelar,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
CircleAvatar(
radius: 14,
backgroundColor: SomaColors.primary.withAlpha(30),
child: Text(
inscripto.initials,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: cs.onSurface,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
inscripto.displayName,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
if (isAdmin)
IconButton(
icon: Icon(
Icons.person_remove_outlined,
size: 16,
color: cs.error.withAlpha(180),
),
tooltip: 'Cancelar inscripción',
onPressed: onCancelar,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
),
],
),
);
}
}
@@ -0,0 +1,245 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
String _errorMessage(Object e) {
if (e is PostgrestException) return e.message;
return e.toString().replaceFirst('Exception: ', '');
}
class InscriptosTurnoDialog extends ConsumerWidget {
final Turno turno;
/// Se invoca cuando se canceló alguna inscripción. La pantalla lo usa para
/// refrescar los cupos al cerrar SOLO si hubo cambios; si el diálogo se abrió
/// y cerró sin tocar nada, no se refresca y se evita el rebuild de la grilla
/// (que trababa la animación de cierre).
final VoidCallback? onCambio;
const InscriptosTurnoDialog({super.key, required this.turno, this.onCambio});
Future<void> _cancelar(
WidgetRef ref, BuildContext context, InscriptoTurno inscripto) async {
try {
await ref
.read(turnosRepositoryProvider)
.cancelarReservaAdmin(inscripto.reservaId);
ref.invalidate(inscriptosTurnoProvider(turno.id));
onCambio?.call();
} catch (e) {
if (context.mounted) {
SomaToast.show(context,
message: _errorMessage(e), type: ToastType.error);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final inscriptosAsync = ref.watch(inscriptosTurnoProvider(turno.id));
final width = MediaQuery.of(context).size.width;
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: width >= 600 ? (width - 440) / 2 : 20,
vertical: 40,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440, maxHeight: 520),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 12, 0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: cs.primary.withAlpha(25),
borderRadius: BorderRadius.circular(10),
),
child:
Icon(Icons.group_outlined, size: 18, color: cs.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
turno.actividad.nombre,
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w700),
overflow: TextOverflow.ellipsis,
),
Text(
'${turno.horaInicio} ${turno.horaFin}',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(140)),
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
Flexible(
child: inscriptosAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 40, color: cs.onSurface.withAlpha(80)),
const SizedBox(height: 12),
Text(
_errorMessage(e),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13, color: cs.onSurface.withAlpha(140)),
),
],
),
),
data: (inscriptos) {
// Filtrar canceladas para la vista principal de inscritos
final activos =
inscriptos.where((i) => !i.cancelada).toList();
if (activos.isEmpty) {
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.person_off_outlined,
size: 48, color: cs.onSurface.withAlpha(60)),
const SizedBox(height: 12),
Text('Sin inscriptos en este turno',
style: TextStyle(
fontSize: 14,
color: cs.onSurface.withAlpha(130))),
],
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 10),
child: Row(
children: [
Text(
'${activos.length} inscripto${activos.length == 1 ? '' : 's'}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: cs.onSurface.withAlpha(150)),
),
const Spacer(),
if (turno.capacidadMaxima > 0)
Text(
'${turno.disponible} cupo${turno.disponible == 1 ? '' : 's'} libre${turno.disponible == 1 ? '' : 's'}',
style: TextStyle(
fontSize: 12,
color: turno.disponible == 0
? SomaColors.error
: SomaColors.success),
),
],
),
),
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: activos.length,
separatorBuilder: (_, i) =>
const SizedBox(height: 6),
itemBuilder: (_, i) => _InscriptoRow(
inscripto: activos[i],
onCancelar: () =>
_cancelar(ref, context, activos[i]),
),
),
),
],
);
},
),
),
],
),
),
);
}
}
class _InscriptoRow extends StatelessWidget {
final InscriptoTurno inscripto;
final VoidCallback onCancelar;
const _InscriptoRow({required this.inscripto, required this.onCancelar});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withAlpha(80),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline.withAlpha(30), width: 0.5),
),
child: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: SomaColors.primary.withAlpha(30),
child: Text(inscripto.initials,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: cs.onSurface)),
),
const SizedBox(width: 10),
Expanded(
child: Text(inscripto.displayName,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis),
),
IconButton(
icon: Icon(Icons.person_remove_outlined,
size: 18, color: cs.error.withAlpha(180)),
tooltip: 'Cancelar inscripción',
onPressed: onCancelar,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
),
],
),
);
}
}
@@ -0,0 +1,382 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/presentation/widgets/turno_slot_tile.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',
];
const _minColumnWidth = 160.0;
bool _isToday(DateTime d) {
final now = DateTime.now();
return d.year == now.year && d.month == now.month && d.day == now.day;
}
bool _isPast(DateTime d) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
return DateTime(d.year, d.month, d.day).isBefore(today);
}
class SemanaTurnosGrid extends StatelessWidget {
final SemanaTurnos semana;
final DateTime weekStart;
final List<int> diasVisibles;
final bool isAdmin;
final void Function(DateTime fecha) onCrearTurno;
final void Function(Turno turno, DateTime fecha) onAsignar;
final void Function(Turno turno) onVerInscriptos;
final void Function(DateTime fecha, DiaTurnos? dia)? onTapDia;
const SemanaTurnosGrid({
super.key,
required this.semana,
required this.weekStart,
required this.diasVisibles,
required this.isAdmin,
required this.onCrearTurno,
required this.onAsignar,
required this.onVerInscriptos,
this.onTapDia,
});
@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 isPast = _isPast(fecha);
if (i > 0) {
rowChildren.add(Container(
width: 1,
color: theme.colorScheme.surfaceContainerHighest,
));
}
final columna = _DiaTurnosColumna(
fecha: fecha,
nombreDia: _diasSemana[diaIdx],
dia: dia,
isPast: isPast,
isAdmin: isAdmin,
onCrear: () => onCrearTurno(fecha),
onAsignar: (t) => onAsignar(t, fecha),
onVerInscriptos: onVerInscriptos,
onTapDia: onTapDia != null ? () => onTapDia!(fecha, dia) : null,
);
rowChildren.add(
useScroll
? SizedBox(width: _minColumnWidth, child: columna)
: Expanded(child: columna),
);
}
final row = Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: rowChildren,
);
if (!useScroll) return row;
final totalWidth = count * _minColumnWidth + (count - 1).toDouble();
return Scrollbar(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: totalWidth,
height: constraints.maxHeight,
child: row,
),
),
);
},
);
}
}
// ── Columna de un día ──────────────────────────────────────────────────────────
class _DiaTurnosColumna extends StatelessWidget {
final DateTime fecha;
final String nombreDia;
final DiaTurnos? dia;
final bool isPast;
final bool isAdmin;
final VoidCallback onCrear;
final void Function(Turno) onAsignar;
final void Function(Turno) onVerInscriptos;
final VoidCallback? onTapDia;
const _DiaTurnosColumna({
required this.fecha,
required this.nombreDia,
required this.dia,
required this.isPast,
required this.isAdmin,
required this.onCrear,
required this.onAsignar,
required this.onVerInscriptos,
this.onTapDia,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DiaTurnosHeader(
nombreDia: nombreDia,
fecha: fecha,
dia: dia,
isPast: isPast,
isAdmin: isAdmin,
onCrear: onCrear,
onTap: onTapDia,
),
Expanded(
child: _DiaTurnosBody(
dia: dia,
isPast: isPast,
isAdmin: isAdmin,
onAsignar: onAsignar,
onVerInscriptos: onVerInscriptos,
),
),
],
);
}
}
// ── Header ─────────────────────────────────────────────────────────────────────
class _DiaTurnosHeader extends StatelessWidget {
final String nombreDia;
final DateTime fecha;
final DiaTurnos? dia;
final bool isPast;
final bool isAdmin;
final VoidCallback onCrear;
final VoidCallback? onTap;
const _DiaTurnosHeader({
required this.nombreDia,
required this.fecha,
required this.dia,
required this.isPast,
required this.isAdmin,
required this.onCrear,
this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final today = _isToday(fecha);
final esCerrado = dia?.estado == DiaEstado.cerrado;
final esEspecial = dia?.estado == DiaEstado.horarioDiferente;
final puedeAgregar = isAdmin && !isPast && !esCerrado;
final content = Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 4, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nombreDia,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: today
? SomaColors.primaryText
: cs.onSurface.withAlpha(155),
),
),
const SizedBox(height: 1),
Text(
'${fecha.day} ${_mesesCortos[fecha.month]}',
style: TextStyle(
fontSize: 11,
color: today
? SomaColors.primaryText.withAlpha(180)
: cs.onSurface.withAlpha(115),
),
),
],
),
),
if (esCerrado)
_HeaderBadge(icon: Icons.block, color: SomaColors.error)
else if (esEspecial)
_HeaderBadge(icon: Icons.event_note, color: SomaColors.primary),
if (puedeAgregar) ...[
const SizedBox(width: 2),
SizedBox(
width: 28,
height: 28,
child: IconButton(
padding: EdgeInsets.zero,
icon: Icon(Icons.add, size: 16, color: cs.onSurface.withAlpha(130)),
tooltip: 'Agregar turno',
onPressed: onCrear,
),
),
] else
const SizedBox(width: 32),
],
),
);
final decoration = BoxDecoration(
color: today ? SomaColors.primary.withAlpha(22) : null,
border: Border(
bottom: BorderSide(
color: today
? SomaColors.primary.withAlpha(120)
: cs.surfaceContainerHighest,
width: today ? 1.5 : 1,
),
),
);
if (onTap == null) {
return DecoratedBox(decoration: decoration, child: content);
}
return DecoratedBox(
decoration: decoration,
child: InkWell(
onTap: onTap,
child: content,
),
);
}
}
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 _DiaTurnosBody extends StatelessWidget {
final DiaTurnos? dia;
final bool isPast;
final bool isAdmin;
final void Function(Turno) onAsignar;
final void Function(Turno) onVerInscriptos;
const _DiaTurnosBody({
required this.dia,
required this.isPast,
required this.isAdmin,
required this.onAsignar,
required this.onVerInscriptos,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
if (dia == null) {
return Center(
child: Text('',
style: TextStyle(fontSize: 18, color: cs.onSurface.withAlpha(55))),
);
}
if (dia!.estado == DiaEstado.cerrado) {
return Container(
color: SomaColors.error.withAlpha(10),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.block, size: 22, color: SomaColors.error.withAlpha(130)),
const SizedBox(height: 6),
Text('Cerrado',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: SomaColors.error.withAlpha(160),
)),
],
),
),
);
}
if (dia!.turnos.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_busy_outlined,
size: 22, color: cs.onSurface.withAlpha(50)),
const SizedBox(height: 6),
Text(
isPast ? 'Sin turnos' : 'Sin turnos\ngenerados',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(90)),
),
],
),
);
}
final turnos = [...dia!.turnos]
..sort((a, b) => a.horaInicio.compareTo(b.horaInicio));
return ListView.separated(
padding: const EdgeInsets.all(8),
itemCount: turnos.length,
separatorBuilder: (_, _) => const SizedBox(height: 6),
itemBuilder: (context, index) {
final turno = turnos[index];
final readOnly = isPast || !isAdmin;
return TurnoSlotTile(
turno: turno,
readOnly: readOnly,
onAsignar: readOnly ? null : () => onAsignar(turno),
onVerInscriptos: isAdmin ? () => onVerInscriptos(turno) : null,
);
},
);
}
}
@@ -0,0 +1,378 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
Color _barColor(int ocupacion, int capacidad, ColorScheme cs) {
if (capacidad == 0) return cs.outline;
final ratio = ocupacion / capacidad;
if (ratio < 0.5) return SomaColors.success;
if (ratio < 0.85) return const Color(0xFFFFB300);
return SomaColors.error;
}
class TurnoSlotTile extends StatelessWidget {
final Turno turno;
final bool readOnly;
final VoidCallback? onAsignar;
final VoidCallback? onVerInscriptos;
const TurnoSlotTile({
super.key,
required this.turno,
this.readOnly = false,
this.onAsignar,
this.onVerInscriptos,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final barColor = _barColor(turno.ocupacion, turno.capacidadMaxima, cs);
final estaLleno = turno.estaLleno;
final canTap = !readOnly && onVerInscriptos != null;
return LayoutBuilder(
builder: (context, constraints) {
final isCompact = constraints.maxWidth < 200;
return MouseRegion(
cursor: canTap ? SystemMouseCursors.click : SystemMouseCursors.basic,
// Material propio (en vez de Ink) para que el fondo y el ripple se
// pinten en la capa de la card y queden recortados al ListView. Con
// Ink la decoración se pintaba sobre el Material ancestro y "sangraba"
// por encima de las cabeceras al scrollear.
child: Material(
color: cs.surface,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: cs.surfaceContainerHighest,
width: 0.5,
),
),
child: InkWell(
onTap: canTap ? onVerInscriptos : null,
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 4,
decoration: BoxDecoration(
color: barColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10),
bottomLeft: Radius.circular(10),
),
),
),
Expanded(
child: isCompact
? _CompactContent(
turno: turno,
barColor: barColor,
estaLleno: estaLleno,
readOnly: readOnly,
onAsignar: onAsignar,
)
: _WideContent(
turno: turno,
barColor: barColor,
estaLleno: estaLleno,
readOnly: readOnly,
onAsignar: onAsignar,
onVerInscriptos: onVerInscriptos,
),
),
],
),
),
),
),
);
},
);
}
}
class _CompactContent extends StatelessWidget {
final Turno turno;
final Color barColor;
final bool estaLleno;
final bool readOnly;
final VoidCallback? onAsignar;
const _CompactContent({
required this.turno,
required this.barColor,
required this.estaLleno,
required this.readOnly,
this.onAsignar,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final hasMenu = !readOnly && onAsignar != null;
return Padding(
padding: EdgeInsets.fromLTRB(10, 8, hasMenu ? 0 : 10, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(
child: Text(
'${turno.horaInicio} ${turno.horaFin}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: cs.onSurface.withAlpha(170),
fontFeatures: const [FontFeature.tabularFigures()],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
_SlotBadge(
estaLleno: estaLleno,
ocupacion: turno.ocupacion,
maxima: turno.capacidadMaxima,
barColor: barColor,
compact: true,
),
if (hasMenu) _SlotMenu(onAsignar: onAsignar, onVerInscriptos: null),
],
),
const SizedBox(height: 2),
Text(
turno.actividad.nombre,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: cs.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
class _WideContent extends StatelessWidget {
final Turno turno;
final Color barColor;
final bool estaLleno;
final bool readOnly;
final VoidCallback? onAsignar;
final VoidCallback? onVerInscriptos;
const _WideContent({
required this.turno,
required this.barColor,
required this.estaLleno,
required this.readOnly,
this.onAsignar,
this.onVerInscriptos,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final hasMenu = !readOnly && (onAsignar != null || onVerInscriptos != null);
return Padding(
padding: EdgeInsets.fromLTRB(12, 10, hasMenu ? 4 : 12, 10),
child: Row(
children: [
SizedBox(
width: 90,
child: Text(
'${turno.horaInicio} ${turno.horaFin}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: cs.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
Container(
width: 1,
height: 28,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: cs.surfaceContainerHighest,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(
child: Text(
turno.actividad.nombre,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: cs.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (turno.actividad.libre) ...[
const SizedBox(width: 6),
_LibreTag(),
],
],
),
const SizedBox(height: 3),
_SlotBadge(
estaLleno: estaLleno,
ocupacion: turno.ocupacion,
maxima: turno.capacidadMaxima,
barColor: barColor,
compact: false,
),
],
),
),
if (hasMenu)
_SlotMenu(onAsignar: onAsignar, onVerInscriptos: onVerInscriptos),
],
),
);
}
}
// ── Slot badge: "X / Y" o "LLENO" ─────────────────────────────────────────────
class _SlotBadge extends StatelessWidget {
final bool estaLleno;
final int ocupacion;
final int maxima;
final Color barColor;
final bool compact;
const _SlotBadge({
required this.estaLleno,
required this.ocupacion,
required this.maxima,
required this.barColor,
required this.compact,
});
@override
Widget build(BuildContext context) {
if (maxima == 0) return const SizedBox.shrink();
final label = estaLleno ? 'LLENO' : '$ocupacion/$maxima';
return Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 5 : 6,
vertical: 2,
),
decoration: BoxDecoration(
color: barColor.withAlpha(25),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: barColor.withAlpha(80), width: 0.7),
),
child: Text(
label,
style: TextStyle(
fontSize: compact ? 9 : 10,
fontWeight: FontWeight.w700,
color: barColor,
letterSpacing: 0.3,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
);
}
}
class _LibreTag extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: SomaColors.success.withAlpha(22),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Libre',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: SomaColors.success,
letterSpacing: 0.2,
),
),
);
}
}
// ── Menú ───────────────────────────────────────────────────────────────────────
class _SlotMenu extends StatelessWidget {
final VoidCallback? onAsignar;
final VoidCallback? onVerInscriptos;
const _SlotMenu({this.onAsignar, this.onVerInscriptos});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final items = <PopupMenuEntry<String>>[];
if (onAsignar != null) {
items.add(PopupMenuItem(
value: 'asignar',
height: 44,
child: Row(children: [
Icon(Icons.person_add_outlined, size: 16, color: cs.primary),
const SizedBox(width: 10),
const Text('Asignar usuario', style: TextStyle(fontSize: 13)),
]),
));
}
if (onVerInscriptos != null) {
items.add(PopupMenuItem(
value: 'inscriptos',
height: 44,
child: Row(children: [
Icon(Icons.group_outlined, size: 16, color: cs.onSurface.withAlpha(153)),
const SizedBox(width: 10),
const Text('Ver inscriptos', style: TextStyle(fontSize: 13)),
]),
));
}
if (items.isEmpty) return const SizedBox(width: 36);
return PopupMenuButton<String>(
icon: Icon(Icons.more_vert, size: 18, color: cs.onSurface.withAlpha(100)),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 44),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 8,
itemBuilder: (_) => items,
onSelected: (val) {
if (val == 'asignar') onAsignar?.call();
if (val == 'inscriptos') onVerInscriptos?.call();
},
);
}
}