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