Agrego frontend app
This commit is contained in:
+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