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,376 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/services/whatsapp_service.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart';
import 'package:gimnasio_soma/features/huerfanas/presentation/providers/huerfanas_provider.dart';
const _meses = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
class BulkNotifyDialog extends ConsumerStatefulWidget {
final List<ReservaHuerfana> seleccionadas;
const BulkNotifyDialog({super.key, required this.seleccionadas});
@override
ConsumerState<BulkNotifyDialog> createState() => _BulkNotifyDialogState();
}
class _BulkNotifyDialogState extends ConsumerState<BulkNotifyDialog> {
late final Set<String> _listos = {};
bool _cargando = false;
String _fmtFecha(String fechaIso) {
final d = DateTime.tryParse(fechaIso);
if (d == null) return fechaIso;
return '${d.day} ${_meses[d.month]} ${d.year}';
}
String _mensajeWa(ReservaHuerfana r) =>
'Hola ${r.nombre}, te contactamos desde el gimnasio SOMA. '
'Tu reserva de ${r.actividadNombre} del ${_fmtFecha(r.fechaOriginal)} '
'quedó sin turno disponible. '
'Por favor, coordiná una nueva reserva cuando puedas. ¡Muchas gracias!';
Future<void> _abrirWa(ReservaHuerfana r) async {
final ok = await WhatsAppService.abrirChat(
telefono: r.telefono,
mensaje: _mensajeWa(r),
);
if (!mounted) return;
if (!ok) {
final sinNumero = WhatsAppService.normalizarNumeroAr(r.telefono) == null;
SomaToast.show(
context,
message: sinNumero
? '${r.displayName} no tiene número de teléfono registrado. '
'Podés agregarlo desde la pantalla de Usuarios.'
: 'No se pudo abrir WhatsApp para ${r.displayName}.',
type: ToastType.error,
);
}
}
Future<void> _marcarListos() async {
if (_listos.isEmpty) return;
setState(() => _cargando = true);
await ref.read(huerfanasProvider.notifier).notificarLote(_listos);
if (!mounted) return;
final n = _listos.length;
Navigator.pop(context);
SomaToast.show(
context,
message: n == 1 ? '1 reserva marcada como resuelta' : '$n reservas marcadas como resueltas',
type: ToastType.success,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final n = widget.seleccionadas.length;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520, maxHeight: 560),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
Expanded(
child: Text(
n == 1 ? 'Notificar a 1 cliente' : 'Notificar a $n clientes',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 6, 24, 12),
child: Text(
'Abrí WhatsApp para cada cliente y marcá "Listo" cuando lo hayas enviado.',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(150),
),
),
),
const Divider(height: 1),
// Lista
Flexible(
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
shrinkWrap: true,
itemCount: widget.seleccionadas.length,
separatorBuilder: (_, _) => const Divider(height: 1, indent: 20, endIndent: 20),
itemBuilder: (_, i) {
final r = widget.seleccionadas[i];
final listo = _listos.contains(r.huerfanaId);
return _ClienteRow(
reserva: r,
listo: listo,
onAbrirWa: () => _abrirWa(r),
onToggleListo: () {
setState(() {
if (listo) {
_listos.remove(r.huerfanaId);
} else {
_listos.add(r.huerfanaId);
}
});
},
);
},
),
),
const Divider(height: 1),
// Footer
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Row(
children: [
if (_listos.isNotEmpty)
Text(
'${_listos.length} listo${_listos.length == 1 ? '' : 's'}',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(150),
),
),
const Spacer(),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cerrar'),
),
const SizedBox(width: 8),
ElevatedButton(
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 40)),
onPressed: _listos.isEmpty || _cargando ? null : _marcarListos,
child: _cargando
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.onPrimary,
),
)
: Text(
_listos.isEmpty
? 'Marcar como resueltas'
: 'Marcar ${_listos.length} como resuelta${_listos.length == 1 ? '' : 's'}',
),
),
],
),
),
],
),
),
);
}
}
class _ClienteRow extends StatefulWidget {
final ReservaHuerfana reserva;
final bool listo;
final VoidCallback onAbrirWa;
final VoidCallback onToggleListo;
const _ClienteRow({
required this.reserva,
required this.listo,
required this.onAbrirWa,
required this.onToggleListo,
});
@override
State<_ClienteRow> createState() => _ClienteRowState();
}
class _ClienteRowState extends State<_ClienteRow> {
bool _hoveredWa = false;
bool _hoveredListo = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const waColor = Color(0xFF25D366);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
// Avatar
CircleAvatar(
radius: 16,
backgroundColor: SomaColors.primary.withAlpha(28),
child: Text(
widget.reserva.initials,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: SomaColors.primaryText,
),
),
),
const SizedBox(width: 10),
// Nombre + teléfono
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.reserva.displayName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
widget.reserva.telefono ?? 'Sin teléfono',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(
widget.reserva.telefono != null ? 130 : 80,
),
),
),
],
),
),
// Botón WhatsApp
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hoveredWa = true),
onExit: (_) => setState(() => _hoveredWa = false),
child: GestureDetector(
onTap: widget.onAbrirWa,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(7),
color: waColor.withAlpha(_hoveredWa ? 38 : 16),
border: Border.all(
color: waColor.withAlpha(_hoveredWa ? 110 : 60),
width: _hoveredWa ? 0.8 : 0.5,
),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_outlined, size: 13, color: waColor),
SizedBox(width: 4),
Text(
'Abrir WA',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: waColor,
),
),
],
),
),
),
),
const SizedBox(width: 10),
// Toggle "Listo"
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hoveredListo = true),
onExit: (_) => setState(() => _hoveredListo = false),
child: GestureDetector(
onTap: widget.onToggleListo,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: widget.listo
? SomaColors.primary.withAlpha(
_hoveredListo ? 35 : 20,
)
: _hoveredListo
? theme.colorScheme.onSurface.withAlpha(10)
: Colors.transparent,
border: Border.all(
color: widget.listo
? SomaColors.primary.withAlpha(
_hoveredListo ? 160 : 100,
)
: theme.colorScheme.onSurface.withAlpha(
_hoveredListo ? 90 : 50,
),
width: 0.8,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check,
size: 12,
color: widget.listo
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(
_hoveredListo ? 100 : 60,
),
),
const SizedBox(width: 3),
Text(
'Listo',
style: TextStyle(
fontSize: 11,
fontWeight: widget.listo
? FontWeight.w700
: FontWeight.w500,
color: widget.listo
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(
_hoveredListo ? 140 : 100,
),
),
),
],
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,642 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart';
import 'package:gimnasio_soma/features/huerfanas/presentation/providers/huerfanas_provider.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
const _meses = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
const _diasSemana = ['', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
class TurnoPickerSheet extends ConsumerStatefulWidget {
final ReservaHuerfana reserva;
/// Se invoca tras reubicar exitosamente con el turno elegido y la fecha del día.
/// El llamador usa esta info para mostrar el toast de éxito con la acción de WA.
final void Function(Turno turno, DateTime fecha)? onReubicadoExito;
const TurnoPickerSheet({
super.key,
required this.reserva,
this.onReubicadoExito,
});
@override
ConsumerState<TurnoPickerSheet> createState() => _TurnoPickerSheetState();
}
class _TurnoPickerSheetState extends ConsumerState<TurnoPickerSheet> {
late DateTime _semanaActual;
bool _soloMismaActividad = true;
AsyncValue<SemanaTurnos> _semanaTurnos = const AsyncValue.loading();
bool _reubicando = false;
@override
void initState() {
super.initState();
_semanaActual = _lunesDe(DateTime.now());
WidgetsBinding.instance.addPostFrameCallback((_) => _cargarSemana());
}
DateTime _lunesDe(DateTime d) =>
DateTime(d.year, d.month, d.day - (d.weekday - 1));
Future<void> _cargarSemana() async {
setState(() => _semanaTurnos = const AsyncValue.loading());
try {
final repo = ref.read(turnosRepositoryProvider);
final semana = await repo.obtenerSemana(_semanaActual);
if (mounted) setState(() => _semanaTurnos = AsyncValue.data(semana));
} catch (e, st) {
if (mounted) setState(() => _semanaTurnos = AsyncValue.error(e, st));
}
}
void _irSemanaAnterior() {
setState(() => _semanaActual = _semanaActual.subtract(const Duration(days: 7)));
_cargarSemana();
}
void _irSemanaSiguiente() {
setState(() => _semanaActual = _semanaActual.add(const Duration(days: 7)));
_cargarSemana();
}
String _fmtSemana() {
final fin = _semanaActual.add(const Duration(days: 6));
final inicioStr = '${_semanaActual.day} ${_meses[_semanaActual.month]}';
final finStr = '${fin.day} ${_meses[fin.month]}';
return 'Sem del $inicioStr al $finStr';
}
String _fmtFechaCorta(DateTime d) =>
'${_diasSemana[d.weekday]} ${d.day} ${_meses[d.month]}';
Future<void> _seleccionarTurno(Turno turno, DiaTurnos dia) async {
final nombreFmt =
'${_diasSemana[dia.fecha.weekday]} ${dia.fecha.day} ${_meses[dia.fecha.month]} ${turno.horaInicio}';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Confirmar reubicación'),
content: Text(
'¿Reubicar a ${widget.reserva.displayName} al turno de '
'${turno.actividad.nombre} del $nombreFmt '
'(${turno.disponible}/${turno.capacidadMaxima} cupos)?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 36)),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Confirmar'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _reubicando = true);
final error = await ref
.read(huerfanasProvider.notifier)
.mover(widget.reserva.huerfanaId, turno.id);
if (!mounted) return;
setState(() => _reubicando = false);
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
return;
}
final callback = widget.onReubicadoExito;
Navigator.pop(context);
callback?.call(turno, dia.fecha);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return DraggableScrollableSheet(
initialChildSize: 0.72,
maxChildSize: 0.92,
minChildSize: 0.4,
expand: false,
builder: (ctx, scrollController) {
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Drag handle
Padding(
padding: const EdgeInsets.only(top: 10, bottom: 4),
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withAlpha(50),
borderRadius: BorderRadius.circular(2),
),
),
),
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Reubicar a ${widget.reserva.displayName}',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.pop(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
],
),
Text(
'${widget.reserva.actividadNombre} · '
'${_fmtFechaCorta(DateTime.tryParse(widget.reserva.fechaOriginal) ?? DateTime.now())} '
'${widget.reserva.horaInicioOriginal} (original)',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
),
const Divider(height: 16, indent: 20, endIndent: 20),
// Navegación semanal + filtro
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: _irSemanaAnterior,
tooltip: 'Semana anterior',
),
Expanded(
child: Text(
_fmtSemana(),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: _irSemanaSiguiente,
tooltip: 'Semana siguiente',
),
],
),
),
// Toggle filtro actividad
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: _FiltroToggle(
actividadNombre: widget.reserva.actividadNombre,
soloMismaActividad: _soloMismaActividad,
onChanged: (v) => setState(() => _soloMismaActividad = v),
),
),
// Contenido
Expanded(
child: _reubicando
? const Center(
child: CircularProgressIndicator(
color: SomaColors.primary,
),
)
: _semanaTurnos.when(
loading: () => const Center(
child: CircularProgressIndicator(
color: SomaColors.primary,
),
),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 40,
color: theme.colorScheme.onSurface.withAlpha(80),
),
const SizedBox(height: 8),
Text(
e.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
const SizedBox(height: 12),
TextButton.icon(
onPressed: _cargarSemana,
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Reintentar'),
),
],
),
),
data: (semana) => _buildDias(
semana,
scrollController,
theme,
),
),
),
],
),
);
},
);
}
Widget _buildDias(
SemanaTurnos semana,
ScrollController scrollController,
ThemeData theme,
) {
final dias = List.generate(7, (i) {
final fecha = _semanaActual.add(Duration(days: i));
return semana.diaPara(fecha) ??
DiaTurnos(
fecha: fecha,
diaSemana: fecha.weekday,
estado: DiaEstado.cerrado,
turnos: const [],
);
});
// Filtrar turnos por actividad si aplica
List<Turno> turnosDelDia(DiaTurnos dia) {
if (dia.estado == DiaEstado.cerrado) return [];
final todos = dia.turnos.where((t) => !t.estaLleno).toList();
if (!_soloMismaActividad) return todos;
return todos
.where(
(t) =>
t.actividad.nombre.toLowerCase() ==
widget.reserva.actividadNombre.toLowerCase(),
)
.toList();
}
// Si con el filtro no hay nada en toda la semana, mostrar aviso
final hayAlgo = dias.any((d) => turnosDelDia(d).isNotEmpty);
if (!hayAlgo) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.event_busy_outlined,
size: 44,
color: theme.colorScheme.onSurface.withAlpha(50),
),
const SizedBox(height: 10),
Text(
_soloMismaActividad
? 'Sin turnos disponibles de\n${widget.reserva.actividadNombre} esta semana'
: 'Sin turnos disponibles esta semana',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: theme.colorScheme.onSurface.withAlpha(120),
),
),
],
),
);
}
return ListView.builder(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
itemCount: dias.length,
itemBuilder: (_, i) {
final dia = dias[i];
final turnos = turnosDelDia(dia);
if (turnos.isEmpty) return const SizedBox.shrink();
return _DiaSection(
dia: dia,
turnos: turnos,
onTurnoTap: _seleccionarTurno,
);
},
);
}
}
// ── Toggle filtro actividad ───────────────────────────────────────────────────
class _FiltroToggle extends StatelessWidget {
final String actividadNombre;
final bool soloMismaActividad;
final ValueChanged<bool> onChanged;
const _FiltroToggle({
required this.actividadNombre,
required this.soloMismaActividad,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: 30,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withAlpha(80),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
_ToggleItem(
label: actividadNombre,
selected: soloMismaActividad,
onTap: () => onChanged(true),
),
_ToggleItem(
label: 'Todas las actividades',
selected: !soloMismaActividad,
onTap: () => onChanged(false),
),
],
),
);
}
}
class _ToggleItem extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
const _ToggleItem({
required this.label,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: selected ? theme.colorScheme.surface : Colors.transparent,
borderRadius: BorderRadius.circular(6),
boxShadow: selected
? [
BoxShadow(
color: Colors.black.withAlpha(18),
blurRadius: 4,
offset: const Offset(0, 1),
)
]
: null,
),
alignment: Alignment.center,
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight:
selected ? FontWeight.w700 : FontWeight.w500,
color: selected
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withAlpha(140),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
);
}
}
// ── Sección de día con turnos ─────────────────────────────────────────────────
class _DiaSection extends StatelessWidget {
final DiaTurnos dia;
final List<Turno> turnos;
final Future<void> Function(Turno, DiaTurnos) onTurnoTap;
const _DiaSection({
required this.dia,
required this.turnos,
required this.onTurnoTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final label =
'${_diasSemana[dia.fecha.weekday]} ${dia.fecha.day} ${_meses[dia.fecha.month]}';
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withAlpha(160),
letterSpacing: 0.3,
),
),
),
...turnos.map((t) => _TurnoPill(turno: t, dia: dia, onTap: onTurnoTap)),
],
),
);
}
}
class _TurnoPill extends StatefulWidget {
final Turno turno;
final DiaTurnos dia;
final Future<void> Function(Turno, DiaTurnos) onTap;
const _TurnoPill({
required this.turno,
required this.dia,
required this.onTap,
});
@override
State<_TurnoPill> createState() => _TurnoPillState();
}
class _TurnoPillState extends State<_TurnoPill> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: () => widget.onTap(widget.turno, widget.dia),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Rail izquierdo
AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: 3,
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(_hovered ? 255 : 180),
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(4),
),
),
),
Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 9,
),
decoration: BoxDecoration(
color: _hovered
? SomaColors.primary.withAlpha(12)
: theme.colorScheme.surface,
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(8),
),
border: Border.all(
color: _hovered
? SomaColors.primary.withAlpha(80)
: theme.colorScheme.surfaceContainerHighest,
width: _hovered ? 0.8 : 0.5,
),
),
child: Row(
children: [
// Hora
SizedBox(
width: 46,
child: Text(
widget.turno.horaInicio,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
Container(
width: 1,
height: 16,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: theme.colorScheme.surfaceContainerHighest,
),
// Actividad
Expanded(
child: Text(
widget.turno.actividad.nombre,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(200),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Cupos
Text(
'${widget.turno.disponible}/${widget.turno.capacidadMaxima}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: widget.turno.disponible <= 2
? SomaColors.error.withAlpha(200)
: SomaColors.primary.withAlpha(200),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
const SizedBox(width: 6),
AnimatedContainer(
duration: const Duration(milliseconds: 120),
child: Icon(
Icons.arrow_forward_ios_rounded,
size: 12,
color: theme.colorScheme.onSurface
.withAlpha(_hovered ? 160 : 80),
),
),
],
),
),
),
],
),
),
),
),
);
}
}