Agrego frontend app

This commit is contained in:
Pablo
2026-08-22 19:08:19 -03:00
parent 824e092b29
commit 2d0797e627
254 changed files with 37231 additions and 0 deletions
@@ -0,0 +1,707 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart';
import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
String _errorMessage(Object e) {
if (e is PostgrestException) return e.message;
return e.toString().replaceFirst('Exception: ', '');
}
enum _Violacion { ninguna, sinPlan, actividadNoEnPlan }
class AsignarUsuarioTurnoDialog extends ConsumerStatefulWidget {
final Turno turno;
final DateTime fecha;
const AsignarUsuarioTurnoDialog({
super.key,
required this.turno,
required this.fecha,
});
@override
ConsumerState<AsignarUsuarioTurnoDialog> createState() =>
_AsignarUsuarioTurnoDialogState();
}
class _AsignarUsuarioTurnoDialogState
extends ConsumerState<AsignarUsuarioTurnoDialog> {
final Set<String> _selectedIds = {};
final Map<String, Usuario> _selectedMap = {};
bool _loading = false;
String _searchQuery = '';
String? _filterPlanId; // null = todos
_Violacion _getViolacion(Usuario user, List<TipoCuota> planes) {
if (widget.turno.actividad.libre) return _Violacion.ninguna;
if (user.tipoCuota == null) return _Violacion.sinPlan;
final plan = planes.where((p) => p.id == user.tipoCuota).firstOrNull;
if (plan == null) return _Violacion.sinPlan;
if (!plan.actividadesIds.contains(widget.turno.actividad.id)) {
return _Violacion.actividadNoEnPlan;
}
return _Violacion.ninguna;
}
void _toggle(Usuario u) {
setState(() {
if (_selectedIds.contains(u.id)) {
_selectedIds.remove(u.id);
_selectedMap.remove(u.id);
} else {
_selectedIds.add(u.id);
_selectedMap[u.id] = u;
}
});
}
Future<void> _submit() async {
if (_selectedIds.isEmpty) return;
setState(() => _loading = true);
int ok = 0;
String? lastError;
for (final u in _selectedMap.values) {
try {
await ref.read(turnosRepositoryProvider).reservarAdmin(
turnoId: widget.turno.id,
clienteId: u.id,
);
ok++;
} catch (e) {
lastError = '${u.displayName}: ${_errorMessage(e)}';
}
}
if (!mounted) return;
setState(() => _loading = false);
if (lastError != null) {
SomaToast.show(context, message: lastError, type: ToastType.error);
}
if (ok > 0 && mounted) Navigator.of(context).pop(true);
}
bool get _canSubmit => _selectedIds.isNotEmpty && !_loading;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final width = MediaQuery.of(context).size.width;
final usuariosAsync = ref.watch(usuariosProvider);
final planesAsync = ref.watch(tiposCuotaProvider);
final planes = planesAsync.valueOrNull ?? [];
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: width >= 660 ? (width - 580) / 2 : 12,
vertical: 28,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 580, maxHeight: 760),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Header ───────────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 12, 0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: cs.primary.withAlpha(25),
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.group_add_outlined,
size: 18, color: cs.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Asignar usuarios',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.w700)),
Text(
'${widget.turno.actividad.nombre} · '
'${widget.turno.horaInicio} ${widget.turno.horaFin}',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(140)),
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// ── Contenido ────────────────────────────────────────────────────
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Filtro por plan ──────────────────────────────────────
planesAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (allPlanes) => _PlanFilterChips(
planes: allPlanes,
selectedPlanId: _filterPlanId,
onSelected: (id) =>
setState(() => _filterPlanId = id),
),
),
const SizedBox(height: 10),
// ── Lista de usuarios ────────────────────────────────────
usuariosAsync.when(
loading: () => const Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
error: (_, _) => Text(
'Error cargando usuarios',
style: TextStyle(
color: SomaColors.error, fontSize: 13),
),
data: (usuarios) {
final clientes = usuarios
.where(
(u) => u.isActive && u.rol == 'cliente')
.toList();
// Filtro por plan
final planFiltrados = _filterPlanId == null
? clientes
: clientes
.where(
(u) => u.tipoCuota == _filterPlanId)
.toList();
// Filtro por búsqueda
final filtrados = _searchQuery.isEmpty
? planFiltrados
: planFiltrados
.where((u) => u.displayName
.toLowerCase()
.contains(
_searchQuery.toLowerCase()))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: InputDecoration(
hintText: 'Buscar usuario...',
prefixIcon:
const Icon(Icons.search, size: 18),
contentPadding:
const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
isDense: true,
border: OutlineInputBorder(
borderRadius:
BorderRadius.circular(10),
borderSide: BorderSide(
color: cs.outline.withAlpha(60)),
),
),
onChanged: (v) =>
setState(() => _searchQuery = v),
),
const SizedBox(height: 8),
if (filtrados.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(
vertical: 16),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.person_off_outlined,
size: 18,
color:
cs.onSurface.withAlpha(80)),
const SizedBox(width: 8),
Text(
_filterPlanId != null
? 'Sin usuarios con este plan'
: 'Sin resultados',
style: TextStyle(
fontSize: 13,
color: cs.onSurface
.withAlpha(120)),
),
],
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 280),
child: ListView.builder(
shrinkWrap: true,
itemCount: filtrados.length,
itemBuilder: (_, i) {
final u = filtrados[i];
final selected =
_selectedIds.contains(u.id);
final plan = planes
.where((p) =>
p.id == u.tipoCuota)
.firstOrNull;
final violacion =
_getViolacion(u, planes);
return _UserListItem(
user: u,
plan: plan,
violacion: violacion,
selected: selected,
onToggle: () => _toggle(u),
);
},
),
),
],
);
},
),
// ── Panel de seleccionados ───────────────────────────────
if (_selectedMap.isNotEmpty) ...[
const SizedBox(height: 16),
Divider(color: cs.surfaceContainerHighest),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.check_circle_outline,
size: 15, color: SomaColors.success),
const SizedBox(width: 6),
Text(
'${_selectedMap.length} seleccionado${_selectedMap.length == 1 ? '' : 's'}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: cs.onSurface.withAlpha(160),
),
),
],
),
const SizedBox(height: 8),
..._selectedMap.values.map((u) {
final violacion = _getViolacion(u, planes);
return _SelectedUserRow(
user: u,
fecha: widget.fecha,
violacion: violacion,
actividad: widget.turno.actividad.nombre,
onRemove: () => _toggle(u),
);
}),
],
const SizedBox(height: 4),
],
),
),
),
// ── Botones ───────────────────────────────────────────────────
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancelar',
style: TextStyle(
color: cs.onSurface.withAlpha(178))),
),
const SizedBox(width: 12),
FilledButton(
onPressed: _canSubmit ? _submit : null,
style: FilledButton.styleFrom(
minimumSize: const Size(0, 42)),
child: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.onPrimary))
: Text(_selectedMap.length > 1
? 'Asignar (${_selectedMap.length})'
: 'Asignar'),
),
],
),
),
],
),
),
);
}
}
// ── Chips de filtro por plan ───────────────────────────────────────────────────
class _PlanFilterChips extends StatelessWidget {
final List<TipoCuota> planes;
final String? selectedPlanId;
final ValueChanged<String?> onSelected;
const _PlanFilterChips({
required this.planes,
required this.selectedPlanId,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
if (planes.isEmpty) return const SizedBox.shrink();
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_FilterChip(
label: 'Todos',
selected: selectedPlanId == null,
onTap: () => onSelected(null),
cs: cs,
),
...planes.map((p) => _FilterChip(
label: p.nombre,
selected: selectedPlanId == p.id,
onTap: () =>
onSelected(selectedPlanId == p.id ? null : p.id),
cs: cs,
)),
],
),
);
}
}
class _FilterChip extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
final ColorScheme cs;
const _FilterChip({
required this.label,
required this.selected,
required this.onTap,
required this.cs,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.only(right: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: selected
? SomaColors.primary.withAlpha(30)
: cs.surfaceContainerHighest.withAlpha(80),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: selected
? SomaColors.primary.withAlpha(160)
: cs.outline.withAlpha(40),
width: selected ? 1 : 0.5,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected
? SomaColors.primaryText
: cs.onSurface.withAlpha(160),
),
),
),
);
}
}
// ── Fila de usuario en la lista ───────────────────────────────────────────────
class _UserListItem extends StatelessWidget {
final Usuario user;
final TipoCuota? plan;
final _Violacion violacion;
final bool selected;
final VoidCallback onToggle;
const _UserListItem({
required this.user,
required this.plan,
required this.violacion,
required this.selected,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final hasViolacion = violacion != _Violacion.ninguna;
return InkWell(
onTap: onToggle,
borderRadius: BorderRadius.circular(8),
child: Container(
margin: const EdgeInsets.only(bottom: 3),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: selected
? SomaColors.primary.withAlpha(20)
: Colors.transparent,
border: Border.all(
color: selected
? SomaColors.primary.withAlpha(90)
: Colors.transparent,
width: 0.8,
),
),
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: Checkbox(
value: selected,
onChanged: (_) => onToggle(),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
activeColor: SomaColors.primary,
checkColor: SomaColors.onPrimary,
side: BorderSide(
color: cs.outline.withAlpha(140), width: 1.2),
),
),
const SizedBox(width: 10),
CircleAvatar(
radius: 13,
backgroundColor: SomaColors.primary.withAlpha(30),
child: Text(user.initials,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: cs.onSurface)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.displayName,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
if (plan != null)
Text(
plan!.nombre,
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(120)),
overflow: TextOverflow.ellipsis,
)
else
Text(
'Sin plan',
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(80),
fontStyle: FontStyle.italic),
),
],
),
),
if (hasViolacion)
Padding(
padding: const EdgeInsets.only(left: 6),
child: Tooltip(
message: violacion == _Violacion.sinPlan
? 'Sin plan asignado'
: 'Plan no incluye esta actividad',
child: Icon(Icons.warning_amber_rounded,
size: 16, color: const Color(0xFFE67700)),
),
),
],
),
),
);
}
}
// ── Fila de usuario seleccionado (panel inferior) ─────────────────────────────
class _SelectedUserRow extends ConsumerWidget {
final Usuario user;
final DateTime fecha;
final _Violacion violacion;
final String actividad;
final VoidCallback onRemove;
const _SelectedUserRow({
required this.user,
required this.fecha,
required this.violacion,
required this.actividad,
required this.onRemove,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = Theme.of(context).colorScheme;
final cupoAsync =
ref.watch(estadoCupoProvider((clienteId: user.id, fecha: fecha)));
final hasViolacion = violacion != _Violacion.ninguna;
final warningColor = const Color(0xFFE67700);
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withAlpha(50),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: cs.outline.withAlpha(30), width: 0.5),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 14,
backgroundColor: SomaColors.primary.withAlpha(30),
child: Text(user.initials,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: cs.onSurface)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.displayName,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Cupo inline
cupoAsync.when(
loading: () => const SizedBox(
height: 12,
width: 12,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
error: (_, _) => const SizedBox.shrink(),
data: (cupo) {
if (!cupo.tienePlan) {
return Row(children: [
Icon(Icons.info_outline,
size: 12, color: cs.onSurface.withAlpha(100)),
const SizedBox(width: 4),
Text('Sin plan',
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(120))),
]);
}
final lleno = cupo.disponibles == 0;
final cupoColor =
lleno ? SomaColors.error : SomaColors.success;
return Row(children: [
Icon(Icons.calendar_today_outlined,
size: 12, color: cs.onSurface.withAlpha(120)),
const SizedBox(width: 4),
Text(
'${cupo.usados}/${cupo.limiteTotal} días usados',
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(140)),
),
const SizedBox(width: 8),
Text(
'${cupo.disponibles} disp.',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: cupoColor),
),
]);
},
),
// Violación de plan
if (hasViolacion) ...[
const SizedBox(height: 3),
Row(children: [
Icon(Icons.warning_amber_rounded,
size: 12, color: warningColor),
const SizedBox(width: 4),
Expanded(
child: Text(
violacion == _Violacion.sinPlan
? 'Sin plan · se asignará igual'
: 'Plan no incluye $actividad · se asignará igual',
style: TextStyle(
fontSize: 11, color: warningColor),
overflow: TextOverflow.ellipsis,
),
),
]),
],
],
),
),
IconButton(
icon: Icon(Icons.close,
size: 16, color: cs.onSurface.withAlpha(140)),
tooltip: 'Quitar de la selección',
onPressed: onRemove,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
padding: EdgeInsets.zero,
),
],
),
);
}
}
@@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/actividades/presentation/providers/actividades_provider.dart';
class CrearTurnoDialog extends ConsumerStatefulWidget {
const CrearTurnoDialog({super.key});
@override
ConsumerState<CrearTurnoDialog> createState() => _CrearTurnoDialogState();
}
class _CrearTurnoDialogState extends ConsumerState<CrearTurnoDialog> {
int? _actividadId;
TimeOfDay _horaInicio = const TimeOfDay(hour: 8, minute: 0);
TimeOfDay _horaFin = const TimeOfDay(hour: 9, minute: 0);
int _capacidad = 10;
final _capacidadCtrl = TextEditingController(text: '10');
@override
void dispose() {
_capacidadCtrl.dispose();
super.dispose();
}
Future<void> _pickTime({required bool isStart}) async {
final initial = isStart ? _horaInicio : _horaFin;
final picked = await showTimePicker(
context: context,
initialTime: initial,
builder: (ctx, child) => Theme(
data: Theme.of(ctx).copyWith(
colorScheme: Theme.of(ctx).colorScheme.copyWith(
primary: SomaColors.primary,
onPrimary: SomaColors.onPrimary,
),
),
child: child!,
),
);
if (picked == null) return;
setState(() {
if (isStart) {
_horaInicio = picked;
if (_toMin(picked) >= _toMin(_horaFin)) {
_horaFin = TimeOfDay(hour: (picked.hour + 1) % 24, minute: picked.minute);
}
} else {
_horaFin = picked;
}
});
}
int _toMin(TimeOfDay t) => t.hour * 60 + t.minute;
String _fmt(TimeOfDay t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
bool get _valid =>
_actividadId != null && _toMin(_horaInicio) < _toMin(_horaFin) && _capacidad > 0;
void _submit() {
if (!_valid) return;
Navigator.of(context).pop(<String, dynamic>{
'actividad_id': _actividadId,
'hora_inicio': _fmt(_horaInicio),
'hora_fin': _fmt(_horaFin),
'capacidad': _capacidad,
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final width = MediaQuery.of(context).size.width;
final actividadesAsync = ref.watch(actividadesProvider);
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: width >= 600 ? (width - 420) / 2 : 20,
vertical: 24,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
const Text('Agregar turno',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Actividad
actividadesAsync.when(
loading: () => const Center(
child: SizedBox(
height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)),
),
error: (_, e) => Text('Error cargando actividades',
style: TextStyle(color: SomaColors.error, fontSize: 13)),
data: (actividades) => DropdownButtonFormField<int>(
initialValue: _actividadId,
items: actividades
.where((a) => a.activo)
.map((a) => DropdownMenuItem(value: a.id, child: Text(a.nombre)))
.toList(),
onChanged: (v) => setState(() => _actividadId = v),
decoration: const InputDecoration(
labelText: 'Actividad *',
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
),
),
const SizedBox(height: 20),
// Horario
Row(
children: [
Expanded(
child: _TimeField(
label: 'Desde',
value: _fmt(_horaInicio),
onTap: () => _pickTime(isStart: true),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Icon(Icons.arrow_forward, size: 18,
color: cs.onSurface.withAlpha(100)),
),
Expanded(
child: _TimeField(
label: 'Hasta',
value: _fmt(_horaFin),
onTap: () => _pickTime(isStart: false),
),
),
],
),
if (_toMin(_horaInicio) >= _toMin(_horaFin))
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text('La hora de fin debe ser mayor',
style: TextStyle(color: SomaColors.error, fontSize: 12)),
),
const SizedBox(height: 20),
// Capacidad
TextField(
controller: _capacidadCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Capacidad máxima *',
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
onChanged: (v) => setState(() => _capacidad = int.tryParse(v) ?? 0),
),
const SizedBox(height: 8),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancelar',
style: TextStyle(color: cs.onSurface.withAlpha(178))),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _valid ? _submit : null,
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 42)),
child: const Text('Agregar'),
),
],
),
),
],
),
),
);
}
}
class _TimeField extends StatelessWidget {
final String label;
final String value;
final VoidCallback onTap;
const _TimeField({required this.label, required this.value, required this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: SomaColors.primary,
letterSpacing: 0.5)),
const SizedBox(height: 6),
InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.inputDecorationTheme.fillColor,
),
child: Row(
children: [
Icon(Icons.schedule, size: 20, color: theme.colorScheme.onSurface.withAlpha(130)),
const SizedBox(width: 10),
Text(value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface)),
],
),
),
),
],
);
}
}
@@ -0,0 +1,380 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
const _diasNombres = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo',
];
const _meses = [
'', 'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio',
'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre',
];
Color _barColor(int ocupacion, int capacidad, ColorScheme cs) {
if (capacidad == 0) return cs.outline;
final ratio = ocupacion / capacidad;
if (ratio < 0.5) return SomaColors.success;
if (ratio < 0.85) return const Color(0xFFFFB300);
return SomaColors.error;
}
class DiaInscriptosSheet extends ConsumerWidget {
final DiaTurnos dia;
final DateTime fecha;
final bool isAdmin;
const DiaInscriptosSheet({
super.key,
required this.dia,
required this.fecha,
required this.isAdmin,
});
Future<void> _cancelar(
WidgetRef ref,
BuildContext context,
InscriptoTurno inscripto,
String turnoId,
) async {
try {
await ref.read(turnosRepositoryProvider).cancelarReservaAdmin(inscripto.reservaId);
ref.invalidate(inscriptosTurnoProvider(turnoId));
} catch (e) {
if (context.mounted) {
SomaToast.show(
context,
message: e is PostgrestException
? e.message
: e.toString().replaceFirst('Exception: ', ''),
type: ToastType.error,
);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = Theme.of(context).colorScheme;
final turnos = [...dia.turnos]
..sort((a, b) => a.horaInicio.compareTo(b.horaInicio));
final nombreDia = _diasNombres[fecha.weekday - 1];
final fechaLabel = '${fecha.day} de ${_meses[fecha.month]}';
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.65,
minChildSize: 0.3,
maxChildSize: 0.92,
builder: (ctx, scrollController) {
return Column(
children: [
// Drag handle
Container(
margin: const EdgeInsets.only(top: 12, bottom: 4),
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurface.withAlpha(60),
borderRadius: BorderRadius.circular(2),
),
),
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 12, 12),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: cs.primary.withAlpha(25),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.calendar_today_outlined,
size: 18,
color: cs.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nombreDia,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
Text(
fechaLabel,
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(140),
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
Divider(
height: 1,
thickness: 0.5,
color: cs.surfaceContainerHighest,
),
// Body
Expanded(
child: ListView.separated(
controller: scrollController,
padding: const EdgeInsets.all(16),
itemCount: turnos.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) {
final turno = turnos[i];
return _TurnoSection(
turno: turno,
isAdmin: isAdmin,
onCancelar: (inscripto) =>
_cancelar(ref, context, inscripto, turno.id),
);
},
),
),
],
);
},
);
}
}
// ── Sección de un turno con sus inscriptos ────────────────────────────────────
class _TurnoSection extends ConsumerWidget {
final Turno turno;
final bool isAdmin;
final void Function(InscriptoTurno) onCancelar;
const _TurnoSection({
required this.turno,
required this.isAdmin,
required this.onCancelar,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = Theme.of(context).colorScheme;
final inscriptosAsync = ref.watch(inscriptosTurnoProvider(turno.id));
final barColor = _barColor(turno.ocupacion, turno.capacidadMaxima, cs);
return Container(
decoration: BoxDecoration(
color: cs.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline.withAlpha(30), width: 0.5),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header del turno
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 4, color: barColor),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
turno.actividad.nombre,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'${turno.horaInicio} ${turno.horaFin}',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(140),
),
),
],
),
),
if (turno.capacidadMaxima > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: barColor.withAlpha(25),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: barColor.withAlpha(80),
width: 0.7,
),
),
child: Text(
turno.estaLleno
? 'LLENO'
: '${turno.ocupacion}/${turno.capacidadMaxima}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: barColor,
),
),
),
],
),
),
),
],
),
),
Divider(
height: 1,
thickness: 0.5,
color: cs.outline.withAlpha(25),
),
// Lista de inscriptos
inscriptosAsync.when(
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
error: (_, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Text(
'Error cargando inscriptos',
style: TextStyle(fontSize: 12, color: cs.error),
),
),
data: (inscriptos) {
final activos = inscriptos.where((i) => !i.cancelada).toList();
if (activos.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Row(
children: [
Icon(
Icons.person_off_outlined,
size: 15,
color: cs.onSurface.withAlpha(60),
),
const SizedBox(width: 8),
Text(
'Sin inscriptos',
style: TextStyle(
fontSize: 13,
color: cs.onSurface.withAlpha(100),
),
),
],
),
);
}
return Column(
children: [
for (final inscripto in activos)
_InscriptoRow(
inscripto: inscripto,
isAdmin: isAdmin,
onCancelar: () => onCancelar(inscripto),
),
],
);
},
),
],
),
);
}
}
// ── Fila de inscripto ─────────────────────────────────────────────────────────
class _InscriptoRow extends StatelessWidget {
final InscriptoTurno inscripto;
final bool isAdmin;
final VoidCallback onCancelar;
const _InscriptoRow({
required this.inscripto,
required this.isAdmin,
required this.onCancelar,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
CircleAvatar(
radius: 14,
backgroundColor: SomaColors.primary.withAlpha(30),
child: Text(
inscripto.initials,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: cs.onSurface,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
inscripto.displayName,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
if (isAdmin)
IconButton(
icon: Icon(
Icons.person_remove_outlined,
size: 16,
color: cs.error.withAlpha(180),
),
tooltip: 'Cancelar inscripción',
onPressed: onCancelar,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
),
],
),
);
}
}
@@ -0,0 +1,245 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
String _errorMessage(Object e) {
if (e is PostgrestException) return e.message;
return e.toString().replaceFirst('Exception: ', '');
}
class InscriptosTurnoDialog extends ConsumerWidget {
final Turno turno;
/// Se invoca cuando se canceló alguna inscripción. La pantalla lo usa para
/// refrescar los cupos al cerrar SOLO si hubo cambios; si el diálogo se abrió
/// y cerró sin tocar nada, no se refresca y se evita el rebuild de la grilla
/// (que trababa la animación de cierre).
final VoidCallback? onCambio;
const InscriptosTurnoDialog({super.key, required this.turno, this.onCambio});
Future<void> _cancelar(
WidgetRef ref, BuildContext context, InscriptoTurno inscripto) async {
try {
await ref
.read(turnosRepositoryProvider)
.cancelarReservaAdmin(inscripto.reservaId);
ref.invalidate(inscriptosTurnoProvider(turno.id));
onCambio?.call();
} catch (e) {
if (context.mounted) {
SomaToast.show(context,
message: _errorMessage(e), type: ToastType.error);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final inscriptosAsync = ref.watch(inscriptosTurnoProvider(turno.id));
final width = MediaQuery.of(context).size.width;
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: width >= 600 ? (width - 440) / 2 : 20,
vertical: 40,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440, maxHeight: 520),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 12, 0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: cs.primary.withAlpha(25),
borderRadius: BorderRadius.circular(10),
),
child:
Icon(Icons.group_outlined, size: 18, color: cs.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
turno.actividad.nombre,
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w700),
overflow: TextOverflow.ellipsis,
),
Text(
'${turno.horaInicio} ${turno.horaFin}',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(140)),
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
Flexible(
child: inscriptosAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 40, color: cs.onSurface.withAlpha(80)),
const SizedBox(height: 12),
Text(
_errorMessage(e),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13, color: cs.onSurface.withAlpha(140)),
),
],
),
),
data: (inscriptos) {
// Filtrar canceladas para la vista principal de inscritos
final activos =
inscriptos.where((i) => !i.cancelada).toList();
if (activos.isEmpty) {
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.person_off_outlined,
size: 48, color: cs.onSurface.withAlpha(60)),
const SizedBox(height: 12),
Text('Sin inscriptos en este turno',
style: TextStyle(
fontSize: 14,
color: cs.onSurface.withAlpha(130))),
],
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 10),
child: Row(
children: [
Text(
'${activos.length} inscripto${activos.length == 1 ? '' : 's'}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: cs.onSurface.withAlpha(150)),
),
const Spacer(),
if (turno.capacidadMaxima > 0)
Text(
'${turno.disponible} cupo${turno.disponible == 1 ? '' : 's'} libre${turno.disponible == 1 ? '' : 's'}',
style: TextStyle(
fontSize: 12,
color: turno.disponible == 0
? SomaColors.error
: SomaColors.success),
),
],
),
),
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: activos.length,
separatorBuilder: (_, i) =>
const SizedBox(height: 6),
itemBuilder: (_, i) => _InscriptoRow(
inscripto: activos[i],
onCancelar: () =>
_cancelar(ref, context, activos[i]),
),
),
),
],
);
},
),
),
],
),
),
);
}
}
class _InscriptoRow extends StatelessWidget {
final InscriptoTurno inscripto;
final VoidCallback onCancelar;
const _InscriptoRow({required this.inscripto, required this.onCancelar});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withAlpha(80),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline.withAlpha(30), width: 0.5),
),
child: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: SomaColors.primary.withAlpha(30),
child: Text(inscripto.initials,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: cs.onSurface)),
),
const SizedBox(width: 10),
Expanded(
child: Text(inscripto.displayName,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis),
),
IconButton(
icon: Icon(Icons.person_remove_outlined,
size: 18, color: cs.error.withAlpha(180)),
tooltip: 'Cancelar inscripción',
onPressed: onCancelar,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
),
],
),
);
}
}
@@ -0,0 +1,382 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/presentation/widgets/turno_slot_tile.dart';
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo',
];
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
const _minColumnWidth = 160.0;
bool _isToday(DateTime d) {
final now = DateTime.now();
return d.year == now.year && d.month == now.month && d.day == now.day;
}
bool _isPast(DateTime d) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
return DateTime(d.year, d.month, d.day).isBefore(today);
}
class SemanaTurnosGrid extends StatelessWidget {
final SemanaTurnos semana;
final DateTime weekStart;
final List<int> diasVisibles;
final bool isAdmin;
final void Function(DateTime fecha) onCrearTurno;
final void Function(Turno turno, DateTime fecha) onAsignar;
final void Function(Turno turno) onVerInscriptos;
final void Function(DateTime fecha, DiaTurnos? dia)? onTapDia;
const SemanaTurnosGrid({
super.key,
required this.semana,
required this.weekStart,
required this.diasVisibles,
required this.isAdmin,
required this.onCrearTurno,
required this.onAsignar,
required this.onVerInscriptos,
this.onTapDia,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final count = diasVisibles.length;
return LayoutBuilder(
builder: (context, constraints) {
final available = constraints.maxWidth;
final useScroll = available < count * _minColumnWidth;
final rowChildren = <Widget>[];
for (int i = 0; i < count; i++) {
final diaIdx = diasVisibles[i];
final fecha = weekStart.add(Duration(days: diaIdx));
final dia = semana.diaPara(fecha);
final isPast = _isPast(fecha);
if (i > 0) {
rowChildren.add(Container(
width: 1,
color: theme.colorScheme.surfaceContainerHighest,
));
}
final columna = _DiaTurnosColumna(
fecha: fecha,
nombreDia: _diasSemana[diaIdx],
dia: dia,
isPast: isPast,
isAdmin: isAdmin,
onCrear: () => onCrearTurno(fecha),
onAsignar: (t) => onAsignar(t, fecha),
onVerInscriptos: onVerInscriptos,
onTapDia: onTapDia != null ? () => onTapDia!(fecha, dia) : null,
);
rowChildren.add(
useScroll
? SizedBox(width: _minColumnWidth, child: columna)
: Expanded(child: columna),
);
}
final row = Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: rowChildren,
);
if (!useScroll) return row;
final totalWidth = count * _minColumnWidth + (count - 1).toDouble();
return Scrollbar(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: totalWidth,
height: constraints.maxHeight,
child: row,
),
),
);
},
);
}
}
// ── Columna de un día ──────────────────────────────────────────────────────────
class _DiaTurnosColumna extends StatelessWidget {
final DateTime fecha;
final String nombreDia;
final DiaTurnos? dia;
final bool isPast;
final bool isAdmin;
final VoidCallback onCrear;
final void Function(Turno) onAsignar;
final void Function(Turno) onVerInscriptos;
final VoidCallback? onTapDia;
const _DiaTurnosColumna({
required this.fecha,
required this.nombreDia,
required this.dia,
required this.isPast,
required this.isAdmin,
required this.onCrear,
required this.onAsignar,
required this.onVerInscriptos,
this.onTapDia,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DiaTurnosHeader(
nombreDia: nombreDia,
fecha: fecha,
dia: dia,
isPast: isPast,
isAdmin: isAdmin,
onCrear: onCrear,
onTap: onTapDia,
),
Expanded(
child: _DiaTurnosBody(
dia: dia,
isPast: isPast,
isAdmin: isAdmin,
onAsignar: onAsignar,
onVerInscriptos: onVerInscriptos,
),
),
],
);
}
}
// ── Header ─────────────────────────────────────────────────────────────────────
class _DiaTurnosHeader extends StatelessWidget {
final String nombreDia;
final DateTime fecha;
final DiaTurnos? dia;
final bool isPast;
final bool isAdmin;
final VoidCallback onCrear;
final VoidCallback? onTap;
const _DiaTurnosHeader({
required this.nombreDia,
required this.fecha,
required this.dia,
required this.isPast,
required this.isAdmin,
required this.onCrear,
this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final today = _isToday(fecha);
final esCerrado = dia?.estado == DiaEstado.cerrado;
final esEspecial = dia?.estado == DiaEstado.horarioDiferente;
final puedeAgregar = isAdmin && !isPast && !esCerrado;
final content = Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 4, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nombreDia,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: today
? SomaColors.primaryText
: cs.onSurface.withAlpha(155),
),
),
const SizedBox(height: 1),
Text(
'${fecha.day} ${_mesesCortos[fecha.month]}',
style: TextStyle(
fontSize: 11,
color: today
? SomaColors.primaryText.withAlpha(180)
: cs.onSurface.withAlpha(115),
),
),
],
),
),
if (esCerrado)
_HeaderBadge(icon: Icons.block, color: SomaColors.error)
else if (esEspecial)
_HeaderBadge(icon: Icons.event_note, color: SomaColors.primary),
if (puedeAgregar) ...[
const SizedBox(width: 2),
SizedBox(
width: 28,
height: 28,
child: IconButton(
padding: EdgeInsets.zero,
icon: Icon(Icons.add, size: 16, color: cs.onSurface.withAlpha(130)),
tooltip: 'Agregar turno',
onPressed: onCrear,
),
),
] else
const SizedBox(width: 32),
],
),
);
final decoration = BoxDecoration(
color: today ? SomaColors.primary.withAlpha(22) : null,
border: Border(
bottom: BorderSide(
color: today
? SomaColors.primary.withAlpha(120)
: cs.surfaceContainerHighest,
width: today ? 1.5 : 1,
),
),
);
if (onTap == null) {
return DecoratedBox(decoration: decoration, child: content);
}
return DecoratedBox(
decoration: decoration,
child: InkWell(
onTap: onTap,
child: content,
),
);
}
}
class _HeaderBadge extends StatelessWidget {
final IconData icon;
final Color color;
const _HeaderBadge({required this.icon, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: color.withAlpha(20),
borderRadius: BorderRadius.circular(4),
),
child: Icon(icon, size: 12, color: color.withAlpha(200)),
);
}
}
// ── Body ───────────────────────────────────────────────────────────────────────
class _DiaTurnosBody extends StatelessWidget {
final DiaTurnos? dia;
final bool isPast;
final bool isAdmin;
final void Function(Turno) onAsignar;
final void Function(Turno) onVerInscriptos;
const _DiaTurnosBody({
required this.dia,
required this.isPast,
required this.isAdmin,
required this.onAsignar,
required this.onVerInscriptos,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
if (dia == null) {
return Center(
child: Text('',
style: TextStyle(fontSize: 18, color: cs.onSurface.withAlpha(55))),
);
}
if (dia!.estado == DiaEstado.cerrado) {
return Container(
color: SomaColors.error.withAlpha(10),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.block, size: 22, color: SomaColors.error.withAlpha(130)),
const SizedBox(height: 6),
Text('Cerrado',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: SomaColors.error.withAlpha(160),
)),
],
),
),
);
}
if (dia!.turnos.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_busy_outlined,
size: 22, color: cs.onSurface.withAlpha(50)),
const SizedBox(height: 6),
Text(
isPast ? 'Sin turnos' : 'Sin turnos\ngenerados',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(90)),
),
],
),
);
}
final turnos = [...dia!.turnos]
..sort((a, b) => a.horaInicio.compareTo(b.horaInicio));
return ListView.separated(
padding: const EdgeInsets.all(8),
itemCount: turnos.length,
separatorBuilder: (_, _) => const SizedBox(height: 6),
itemBuilder: (context, index) {
final turno = turnos[index];
final readOnly = isPast || !isAdmin;
return TurnoSlotTile(
turno: turno,
readOnly: readOnly,
onAsignar: readOnly ? null : () => onAsignar(turno),
onVerInscriptos: isAdmin ? () => onVerInscriptos(turno) : null,
);
},
);
}
}
@@ -0,0 +1,378 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
Color _barColor(int ocupacion, int capacidad, ColorScheme cs) {
if (capacidad == 0) return cs.outline;
final ratio = ocupacion / capacidad;
if (ratio < 0.5) return SomaColors.success;
if (ratio < 0.85) return const Color(0xFFFFB300);
return SomaColors.error;
}
class TurnoSlotTile extends StatelessWidget {
final Turno turno;
final bool readOnly;
final VoidCallback? onAsignar;
final VoidCallback? onVerInscriptos;
const TurnoSlotTile({
super.key,
required this.turno,
this.readOnly = false,
this.onAsignar,
this.onVerInscriptos,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final barColor = _barColor(turno.ocupacion, turno.capacidadMaxima, cs);
final estaLleno = turno.estaLleno;
final canTap = !readOnly && onVerInscriptos != null;
return LayoutBuilder(
builder: (context, constraints) {
final isCompact = constraints.maxWidth < 200;
return MouseRegion(
cursor: canTap ? SystemMouseCursors.click : SystemMouseCursors.basic,
// Material propio (en vez de Ink) para que el fondo y el ripple se
// pinten en la capa de la card y queden recortados al ListView. Con
// Ink la decoración se pintaba sobre el Material ancestro y "sangraba"
// por encima de las cabeceras al scrollear.
child: Material(
color: cs.surface,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: cs.surfaceContainerHighest,
width: 0.5,
),
),
child: InkWell(
onTap: canTap ? onVerInscriptos : null,
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 4,
decoration: BoxDecoration(
color: barColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10),
bottomLeft: Radius.circular(10),
),
),
),
Expanded(
child: isCompact
? _CompactContent(
turno: turno,
barColor: barColor,
estaLleno: estaLleno,
readOnly: readOnly,
onAsignar: onAsignar,
)
: _WideContent(
turno: turno,
barColor: barColor,
estaLleno: estaLleno,
readOnly: readOnly,
onAsignar: onAsignar,
onVerInscriptos: onVerInscriptos,
),
),
],
),
),
),
),
);
},
);
}
}
class _CompactContent extends StatelessWidget {
final Turno turno;
final Color barColor;
final bool estaLleno;
final bool readOnly;
final VoidCallback? onAsignar;
const _CompactContent({
required this.turno,
required this.barColor,
required this.estaLleno,
required this.readOnly,
this.onAsignar,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final hasMenu = !readOnly && onAsignar != null;
return Padding(
padding: EdgeInsets.fromLTRB(10, 8, hasMenu ? 0 : 10, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(
child: Text(
'${turno.horaInicio} ${turno.horaFin}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: cs.onSurface.withAlpha(170),
fontFeatures: const [FontFeature.tabularFigures()],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
_SlotBadge(
estaLleno: estaLleno,
ocupacion: turno.ocupacion,
maxima: turno.capacidadMaxima,
barColor: barColor,
compact: true,
),
if (hasMenu) _SlotMenu(onAsignar: onAsignar, onVerInscriptos: null),
],
),
const SizedBox(height: 2),
Text(
turno.actividad.nombre,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: cs.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
class _WideContent extends StatelessWidget {
final Turno turno;
final Color barColor;
final bool estaLleno;
final bool readOnly;
final VoidCallback? onAsignar;
final VoidCallback? onVerInscriptos;
const _WideContent({
required this.turno,
required this.barColor,
required this.estaLleno,
required this.readOnly,
this.onAsignar,
this.onVerInscriptos,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final hasMenu = !readOnly && (onAsignar != null || onVerInscriptos != null);
return Padding(
padding: EdgeInsets.fromLTRB(12, 10, hasMenu ? 4 : 12, 10),
child: Row(
children: [
SizedBox(
width: 90,
child: Text(
'${turno.horaInicio} ${turno.horaFin}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: cs.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
Container(
width: 1,
height: 28,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: cs.surfaceContainerHighest,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(
child: Text(
turno.actividad.nombre,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: cs.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (turno.actividad.libre) ...[
const SizedBox(width: 6),
_LibreTag(),
],
],
),
const SizedBox(height: 3),
_SlotBadge(
estaLleno: estaLleno,
ocupacion: turno.ocupacion,
maxima: turno.capacidadMaxima,
barColor: barColor,
compact: false,
),
],
),
),
if (hasMenu)
_SlotMenu(onAsignar: onAsignar, onVerInscriptos: onVerInscriptos),
],
),
);
}
}
// ── Slot badge: "X / Y" o "LLENO" ─────────────────────────────────────────────
class _SlotBadge extends StatelessWidget {
final bool estaLleno;
final int ocupacion;
final int maxima;
final Color barColor;
final bool compact;
const _SlotBadge({
required this.estaLleno,
required this.ocupacion,
required this.maxima,
required this.barColor,
required this.compact,
});
@override
Widget build(BuildContext context) {
if (maxima == 0) return const SizedBox.shrink();
final label = estaLleno ? 'LLENO' : '$ocupacion/$maxima';
return Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 5 : 6,
vertical: 2,
),
decoration: BoxDecoration(
color: barColor.withAlpha(25),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: barColor.withAlpha(80), width: 0.7),
),
child: Text(
label,
style: TextStyle(
fontSize: compact ? 9 : 10,
fontWeight: FontWeight.w700,
color: barColor,
letterSpacing: 0.3,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
);
}
}
class _LibreTag extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: SomaColors.success.withAlpha(22),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Libre',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: SomaColors.success,
letterSpacing: 0.2,
),
),
);
}
}
// ── Menú ───────────────────────────────────────────────────────────────────────
class _SlotMenu extends StatelessWidget {
final VoidCallback? onAsignar;
final VoidCallback? onVerInscriptos;
const _SlotMenu({this.onAsignar, this.onVerInscriptos});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final items = <PopupMenuEntry<String>>[];
if (onAsignar != null) {
items.add(PopupMenuItem(
value: 'asignar',
height: 44,
child: Row(children: [
Icon(Icons.person_add_outlined, size: 16, color: cs.primary),
const SizedBox(width: 10),
const Text('Asignar usuario', style: TextStyle(fontSize: 13)),
]),
));
}
if (onVerInscriptos != null) {
items.add(PopupMenuItem(
value: 'inscriptos',
height: 44,
child: Row(children: [
Icon(Icons.group_outlined, size: 16, color: cs.onSurface.withAlpha(153)),
const SizedBox(width: 10),
const Text('Ver inscriptos', style: TextStyle(fontSize: 13)),
]),
));
}
if (items.isEmpty) return const SizedBox(width: 36);
return PopupMenuButton<String>(
icon: Icon(Icons.more_vert, size: 18, color: cs.onSurface.withAlpha(100)),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 44),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 8,
itemBuilder: (_) => items,
onSelected: (val) {
if (val == 'asignar') onAsignar?.call();
if (val == 'inscriptos') onVerInscriptos?.call();
},
);
}
}