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,17 @@
import 'package:gimnasio_soma/core/config/app_constants.dart';
class AppConfig {
final int pagosVentanaEdicionMinutos;
const AppConfig({required this.pagosVentanaEdicionMinutos});
factory AppConfig.fromMap(Map<String, dynamic> map) => AppConfig(
pagosVentanaEdicionMinutos:
(map['pagos.ventana_edicion_minutos'] as num?)?.toInt() ??
AppConstants.pagosVentanaEdicionMinutosDefault,
);
static const AppConfig defaults = AppConfig(
pagosVentanaEdicionMinutos: AppConstants.pagosVentanaEdicionMinutosDefault,
);
}
@@ -0,0 +1,18 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_config.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/config/supabase_config.dart';
final appConfigProvider = FutureProvider<AppConfig>((ref) async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
final client = SupabaseConfig.client;
final params = <String, dynamic>{};
if (token != null) params['p_token'] = token;
final response = await client.rpc(AppConstants.rpcGetConfig, params: params);
if (response is Map) {
return AppConfig.fromMap(Map<String, dynamic>.from(response));
}
return AppConfig.defaults;
});
@@ -0,0 +1,86 @@
class AppConstants {
AppConstants._();
static const String appName = 'SOMA PRO';
// Layout breakpoints
static const double kDesktopBreakpoint = 800.0;
static const double kWideBreakpoint = 1200.0;
// Avatar radii
static const double kAvatarRadiusSm = 16.0;
static const double kAvatarRadiusMd = 20.0;
static const double kAvatarRadiusLg = 28.0;
// SharedPreferences keys
static const String tokenKey = 'session_token';
static const String usuariosViewModeKey = 'usuarios_view_mode';
static const String pagosViewModeKey = 'pagos_view_mode';
// Supabase RPC names - Auth
static const String rpcLogin = 'fc_ingresar';
static const String rpcIniciarSesionPorToken = 'fc_iniciar_sesion_por_token';
static const String rpcGetUserByToken = 'fc_obtener_usuario_por_token';
static const String rpcDestroySession = 'fc_eliminar_sesion';
// Supabase RPC names - Usuarios
static const String rpcGetUsuarios = 'fc_obtener_usuarios';
static const String rpcInsertUsuario = 'fc_insertar_usuario';
static const String rpcUpdateUsuario = 'fc_modificar_usuario';
static const String rpcToggleUsuarioStatus = 'fc_modificar_estado_usuario';
static const String rpcDeleteUsuario = 'fc_eliminar_usuario';
static const String rpcCambiarPropiaContrasena = 'fc_cambiar_propia_contrasena';
static const String rpcResetearContrasenaUsuario = 'fc_resetear_contrasena_usuario';
// Supabase RPC names - Config
static const String rpcGetConfig = 'fc_obtener_config';
// Supabase RPC names - Pagos
static const String rpcGetPagos = 'fc_obtener_pagos';
static const String rpcGetMisPagos = 'fc_obtener_mis_pagos';
static const String rpcInsertPago = 'fc_insertar_pago';
static const String rpcEditarPago = 'fc_editar_pago';
static const String rpcAnularPago = 'fc_anular_pago';
static const String rpcGetMetodosPago = 'fc_obtener_metodos_pago';
static const String rpcUpdateMetodoPago = 'fc_modificar_metodos_pago';
// Fallback local cuando appConfigProvider aún no resolvió o falló.
static const int pagosVentanaEdicionMinutosDefault = 30;
// Supabase RPC names - Actividades
static const String rpcGetActividadesTipoCuota = 'fc_obtener_actividades_tipo_cuota';
static const String rpcGetActividades = 'fc_obtener_actividades';
static const String rpcInsertActividad = 'fc_insertar_actividad';
static const String rpcUpdateActividad = 'fc_modificar_actividad';
static const String rpcDeleteActividad = 'fc_eliminar_actividad';
// Supabase RPC names - Horarios
static const String rpcObtenerHorarios = 'fc_obtener_horarios';
static const String rpcObtenerPlanificacionesFuturas = 'fc_obtener_planificaciones_futuras';
static const String rpcInsertHorarioConActividades = 'fc_insertar_horario_con_actividades';
static const String rpcEliminarDiaEspecial = 'fc_eliminar_dia_especial';
// Supabase RPC names - Huerfanas
static const String rpcObtenerReservasHuerfanas = 'fc_obtener_reservas_huerfanas';
static const String rpcResolverHuerfana = 'fc_resolver_huerfana';
static const String rpcMoverReservaHuerfana = 'fc_mover_reserva_huerfana';
// Supabase RPC names - Turnos
static const String rpcObtenerTurnos = 'fc_obtener_turnos';
static const String rpcUpsertTurno = 'fc_upsert_turno';
static const String rpcLimpiarTurnosAntiguos = 'fc_limpiar_turnos_antiguos';
static const String rpcObtenerReservasTurno = 'fc_obtener_reservas_turno';
static const String rpcReservarTurnoAdmin = 'fc_reservar_turno_admin';
static const String rpcCancelarReservaAdmin = 'fc_cancelar_reserva_admin';
static const String rpcObtenerEstadoCupo = 'fc_obtener_estado_cupo';
// Supabase RPC names - Usuarios Cuota
static const String rpcGetUsuarioTipoCuota = 'fc_obtener_usuario_tipo_cuota';
// Supabase RPC names - Tipos de Cuota
static const String rpcGetTiposCuota = 'fc_obtener_tipo_cuota';
static const String rpcInsertTipoCuota = 'fc_insertar_tipo_cuota';
static const String rpcUpdateTipoCuota = 'fc_modificar_tipo_cuota';
static const String rpcDeleteTipoCuota = 'fc_eliminar_tipo_cuota';
}
@@ -0,0 +1,52 @@
/// Configuración de entorno horneada en tiempo de compilación.
///
/// Los valores entran al binario por `--dart-define-from-file=config/<env>.json`
/// (ver `config/dev.json` y `config/prod.json` en la raíz del proyecto). Son
/// constantes de compilación: se leen con `String.fromEnvironment` y quedan
/// fijas en el binario. NO hay archivo `.env` ni carga en runtime.
///
/// Si la app se compila/corre sin pasar el archivo de config, estas constantes
/// quedan vacías y [assertConfigured] corta el arranque con un mensaje claro,
/// en vez de dejar que la app intente conectarse a una URL vacía.
class AppEnv {
AppEnv._();
/// Identidad del entorno: `'dev'` o `'prod'`. Vacío si no se pasó el archivo.
static const String env = String.fromEnvironment('ENV');
/// URL del proyecto Supabase del entorno.
static const String supabaseUrl = String.fromEnvironment('SUPABASE_URL');
/// Clave pública (anon) del entorno. Es pública por diseño (protegida por RLS),
/// por eso puede vivir en `config/*.json` versionado. El secreto real
/// (service_role / DATABASE_URL) nunca entra acá.
static const String supabaseAnonKey =
String.fromEnvironment('SUPABASE_ANON_KEY');
static bool get isDev => env == 'dev';
static bool get isProd => env == 'prod';
/// Falla temprano y con un mensaje accionable si la app se compiló sin
/// `--dart-define-from-file=config/<env>.json`, o con un `ENV` inválido.
static void assertConfigured() {
final faltantes = <String>[
if (env.isEmpty) 'ENV',
if (supabaseUrl.isEmpty) 'SUPABASE_URL',
if (supabaseAnonKey.isEmpty) 'SUPABASE_ANON_KEY',
];
if (faltantes.isNotEmpty) {
throw StateError(
'Configuración de entorno ausente: ${faltantes.join(', ')}. '
'Compilá/corré pasando el archivo de entorno, por ejemplo:\n'
' flutter run --dart-define-from-file=config/dev.json\n'
' flutter build windows --dart-define-from-file=config/prod.json',
);
}
if (env != 'dev' && env != 'prod') {
throw StateError(
'ENV inválido: "$env". Debe ser "dev" o "prod" '
'(ver config/dev.json y config/prod.json).',
);
}
}
}
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/config/app_env.dart';
/// Envuelve la app en una cinta diagonal con el nombre del entorno cuando NO es
/// prod (ej. "DEV"), para no confundir el entorno de pruebas con el real. En
/// prod devuelve el hijo sin envolver: no se ve ninguna cinta.
class EnvBanner extends StatelessWidget {
const EnvBanner({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
if (AppEnv.isProd) return child;
return Banner(
message: AppEnv.env.toUpperCase(),
location: BannerLocation.topStart,
color: Colors.red.shade700,
child: child,
);
}
}
@@ -0,0 +1,23 @@
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:gimnasio_soma/core/config/app_env.dart';
import 'package:gimnasio_soma/core/services/soma_logger.dart';
class SupabaseConfig {
static SupabaseClient get client => Supabase.instance.client;
static Future<void> initialize() async {
await Supabase.initialize(
url: AppEnv.supabaseUrl,
anonKey: AppEnv.supabaseAnonKey,
);
}
/// RPC con logging automático de request, params, duración y resultado.
static Future<dynamic> rpc(String fn, {Map<String, dynamic>? params}) {
return SomaLogger.instance.logRpc(
fn,
params ?? {},
() async => await client.rpc(fn, params: params),
);
}
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
enum SomaRole { staff, usuario }
class NavigationItem {
final String label;
final IconData icon;
final IconData activeIcon;
final String path;
final Set<SomaRole> allowedRoles;
const NavigationItem({
required this.label,
required this.icon,
required this.activeIcon,
required this.path,
required this.allowedRoles,
});
bool isVisibleTo(String userRole) {
// Mapear múltiples roles de BD al rol unificado 'staff'
if (userRole == 'superadmin' || userRole == 'admin' || userRole == 'profesor') {
return allowedRoles.contains(SomaRole.staff);
}
return allowedRoles.contains(SomaRole.usuario);
}
}
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'navigation_item.dart';
const List<NavigationItem> kNavigationItems = [
NavigationItem(
label: 'Usuarios',
icon: Icons.people_outline,
activeIcon: Icons.people,
path: '/usuarios',
allowedRoles: {SomaRole.staff},
),
NavigationItem(
label: 'Pagos',
icon: Icons.payment_outlined,
activeIcon: Icons.payment,
path: '/pagos',
allowedRoles: {SomaRole.staff, SomaRole.usuario},
),
// Rutinas: módulo aún no desarrollado. Se oculta de la navegación hasta
// implementarlo. La ruta /rutinas sigue definida en app_router (el guard
// redirige al primer ítem visible si se accede directamente).
NavigationItem(
label: 'Actividades',
icon: Icons.sports_gymnastics_outlined,
activeIcon: Icons.sports_gymnastics,
path: '/actividades',
allowedRoles: {SomaRole.staff},
),
NavigationItem(
label: 'Horarios',
icon: Icons.schedule_outlined,
activeIcon: Icons.schedule,
path: '/horarios',
allowedRoles: {SomaRole.staff, SomaRole.usuario},
),
NavigationItem(
label: 'Turnos',
icon: Icons.event_available_outlined,
activeIcon: Icons.event_available,
path: '/turnos',
allowedRoles: {SomaRole.staff},
),
NavigationItem(
label: 'Planes',
icon: Icons.card_membership_outlined,
activeIcon: Icons.card_membership,
path: '/planes',
allowedRoles: {SomaRole.staff},
),
NavigationItem(
label: 'Sin turno',
icon: Icons.warning_amber_outlined,
activeIcon: Icons.warning_amber,
path: '/huerfanas',
allowedRoles: {SomaRole.staff},
),
];
List<NavigationItem> getVisibleItems(String userRole) {
return kNavigationItems.where((item) => item.isVisibleTo(userRole)).toList();
}
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:gimnasio_soma/core/navigation/navigation_item.dart';
import 'package:gimnasio_soma/core/navigation/navigation_items.dart';
import 'package:gimnasio_soma/core/navigation/widgets/soma_drawer.dart';
import 'package:gimnasio_soma/core/navigation/widgets/soma_sidebar.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
const _kDesktopBreakpoint = 800.0;
class NavigationShell extends ConsumerWidget {
final Widget child;
const NavigationShell({super.key, required this.child});
static const _subPageTitles = {
'/pagos/metodos': 'Métodos de pago',
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final width = MediaQuery.of(context).size.width;
final isDesktop = width >= _kDesktopBreakpoint;
final user = ref.watch(authStateProvider).valueOrNull;
final currentPath = GoRouterState.of(context).matchedLocation;
final effectiveRole = user?.role ?? '';
final visibleItems =
user != null ? getVisibleItems(effectiveRole) : <NavigationItem>[];
// Detectar si estamos en una sub-página
final isSubPage = !visibleItems.any((i) => i.path == currentPath) &&
!{'/gallery', '/logs', '/perfil'}.contains(currentPath);
final parentItem = isSubPage
? visibleItems
.where((i) => currentPath.startsWith(i.path))
.firstOrNull
: null;
final theme = Theme.of(context);
if (isDesktop) {
return Scaffold(
body: Row(
children: [
SomaSidebar(
items: visibleItems,
currentPath: currentPath,
user: user,
),
Expanded(
child: Column(
children: [
if (isSubPage && parentItem != null)
_BackBar(
parentLabel: parentItem.label,
onBack: () => context.go(parentItem.path),
theme: theme,
),
Expanded(child: child),
],
),
),
],
),
);
}
// Mobile
return Scaffold(
appBar: AppBar(
title: Text(_titleForPath(currentPath)),
leading: isSubPage && parentItem != null
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go(parentItem.path),
)
: Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.menu),
onPressed: () => Scaffold.of(ctx).openDrawer(),
),
),
),
drawer: isSubPage
? null
: SomaDrawer(
items: visibleItems,
currentPath: currentPath,
user: user,
),
body: child,
);
}
String _titleForPath(String path) {
// Primero buscar título específico de sub-página
final subTitle = _subPageTitles[path];
if (subTitle != null) return subTitle;
final item =
kNavigationItems.where((i) => path.startsWith(i.path)).firstOrNull;
return item?.label ?? 'SOMA PRO';
}
}
class _BackBar extends StatelessWidget {
final String parentLabel;
final VoidCallback onBack;
final ThemeData theme;
const _BackBar({
required this.parentLabel,
required this.onBack,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Container(
height: 44,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withAlpha(80),
border: Border(
bottom: BorderSide(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
),
child: InkWell(
onTap: onBack,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Icon(
Icons.arrow_back,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(178),
),
const SizedBox(width: 8),
Text(
'Volver a $parentLabel',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(178),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:gimnasio_soma/core/navigation/navigation_item.dart';
import 'package:gimnasio_soma/core/navigation/widgets/soma_nav_tile.dart';
import 'package:gimnasio_soma/core/navigation/widgets/soma_user_card.dart';
import 'package:gimnasio_soma/core/widgets/soma_logo.dart';
import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
class SomaDrawer extends ConsumerWidget {
final List<NavigationItem> items;
final String currentPath;
final UserSession? user;
const SomaDrawer({
super.key,
required this.items,
required this.currentPath,
required this.user,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Drawer(
backgroundColor: Theme.of(context).colorScheme.surface,
child: SafeArea(
child: Column(
children: [
const SizedBox(height: 20),
// Logo
const SomaLogo(width: 100),
const SizedBox(height: 24),
Divider(
height: 1,
thickness: 0.5,
indent: 16,
endIndent: 16,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
const SizedBox(height: 8),
// Nav items
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final item = items[index];
final isActive = currentPath.startsWith(item.path);
return SomaNavTile(
item: item,
isActive: isActive,
onTap: () {
Navigator.of(context).pop();
context.go(item.path);
},
);
},
),
),
// Footer
Divider(
height: 1,
thickness: 0.5,
indent: 16,
endIndent: 16,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
SomaUserCard(
user: user,
onTap: () {
Navigator.of(context).pop();
context.go('/perfil');
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
children: [
const Spacer(),
IconButton(
icon: const Icon(Icons.logout, size: 20),
tooltip: 'Cerrar sesión',
onPressed: () {
Navigator.of(context).pop();
ref.read(authStateProvider.notifier).logout();
},
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(153),
),
],
),
),
const SizedBox(height: 8),
],
),
),
);
}
}
@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/navigation/navigation_item.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
class SomaNavTile extends StatelessWidget {
final NavigationItem item;
final bool isActive;
final bool isCollapsed;
final VoidCallback onTap;
final int? badge;
const SomaNavTile({
super.key,
required this.item,
required this.isActive,
required this.onTap,
this.isCollapsed = false,
this.badge,
});
@override
Widget build(BuildContext context) {
final tile = InkWell(
onTap: onTap,
mouseCursor: SystemMouseCursors.click,
borderRadius: BorderRadius.circular(8),
child: Container(
height: 44,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isActive
? SomaColors.primary.withAlpha(18)
: Colors.transparent,
),
child: Row(
children: [
// Franja izquierda de acento: reemplaza Border(left:...) no uniforme
Container(
width: 3,
color: isActive ? SomaColors.primary : Colors.transparent,
),
Expanded(
child: Padding(
padding: EdgeInsets.fromLTRB(isCollapsed ? 0 : 9, 0, isCollapsed ? 0 : 12, 0),
child: Row(
mainAxisAlignment: isCollapsed
? MainAxisAlignment.center
: MainAxisAlignment.start,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Icon(
isActive ? item.activeIcon : item.icon,
size: 20,
color: isActive
? SomaColors.primary
: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(153),
),
if (badge != null && badge! > 0)
Positioned(
top: -4,
right: -5,
child: Container(
padding: const EdgeInsets.all(2),
constraints: const BoxConstraints(
minWidth: 14,
minHeight: 14,
),
decoration: BoxDecoration(
color: SomaColors.error,
borderRadius: BorderRadius.circular(7),
),
child: Text(
badge! > 99 ? '99+' : '$badge',
style: const TextStyle(
fontSize: 8,
fontWeight: FontWeight.w700,
color: Colors.white,
height: 1,
),
textAlign: TextAlign.center,
),
),
),
],
),
if (!isCollapsed) ...[
const SizedBox(width: 12),
Expanded(
child: Text(
item.label,
style: TextStyle(
fontSize: 14,
fontWeight:
isActive ? FontWeight.w600 : FontWeight.w500,
color: isActive
? Theme.of(context).colorScheme.onSurface
: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(178),
),
),
),
if (badge != null && badge! > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: SomaColors.error,
borderRadius: BorderRadius.circular(10),
),
child: Text(
badge! > 99 ? '99+' : '$badge',
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
],
],
),
),
),
],
),
),
);
if (isCollapsed) {
return Tooltip(
message: item.label,
preferBelow: false,
child: tile,
);
}
return tile;
}
}
@@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:gimnasio_soma/core/navigation/navigation_item.dart';
import 'package:gimnasio_soma/core/navigation/widgets/soma_nav_tile.dart';
import 'package:gimnasio_soma/core/navigation/widgets/soma_user_card.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_logo.dart';
import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
import 'package:gimnasio_soma/features/huerfanas/presentation/providers/huerfanas_provider.dart';
final sidebarCollapsedProvider = StateProvider<bool>((ref) => false);
class SomaSidebar extends ConsumerWidget {
final List<NavigationItem> items;
final String currentPath;
final UserSession? user;
const SomaSidebar({
super.key,
required this.items,
required this.currentPath,
required this.user,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isCollapsed = ref.watch(sidebarCollapsedProvider);
Future<void> confirmLogout() async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Cerrar sesión'),
content: const Text('¿Estás seguro de que querés cerrar sesión?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 40)),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Cerrar sesión'),
),
],
),
);
if (confirm != true) return;
ref.read(authStateProvider.notifier).logout();
}
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
width: isCollapsed ? 64 : 240,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
right: BorderSide(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
width: 1,
),
),
),
child: Column(
children: [
// Logo
Padding(
padding: EdgeInsets.fromLTRB(
isCollapsed ? 8 : 16,
20,
isCollapsed ? 8 : 16,
8,
),
child: AnimatedCrossFade(
duration: const Duration(milliseconds: 200),
crossFadeState: isCollapsed
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: const SomaLogo(width: 90),
secondChild: const SomaLogo(width: 32),
),
),
const SizedBox(height: 16),
// Nav items
Expanded(
child: ListView.separated(
padding: EdgeInsets.symmetric(
horizontal: isCollapsed ? 8 : 12,
),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final item = items[index];
final isActive = currentPath.startsWith(item.path);
int? badge;
if (item.path == '/huerfanas') {
badge = ref.watch(huerfanasPendienteCountProvider).valueOrNull;
}
return SomaNavTile(
item: item,
isActive: isActive,
isCollapsed: isCollapsed,
badge: badge,
onTap: () => context.go(item.path),
);
},
),
),
// Footer
Divider(
height: 1,
thickness: 0.5,
indent: isCollapsed ? 8 : 16,
endIndent: isCollapsed ? 8 : 16,
),
SomaUserCard(
user: user,
isCollapsed: isCollapsed,
onTap: () => context.go('/perfil'),
),
// Actions
Padding(
padding: EdgeInsets.symmetric(
horizontal: isCollapsed ? 8 : 12,
vertical: 4,
),
child: _SidebarActionTile(
isCollapsed: isCollapsed,
icon: Icons.logout,
label: 'Cerrar sesión',
color: SomaColors.error,
onTap: confirmLogout,
),
),
const SizedBox(height: 2),
// Collapse toggle
Padding(
padding: EdgeInsets.symmetric(
horizontal: isCollapsed ? 8 : 12,
vertical: 4,
),
child: _SidebarActionTile(
isCollapsed: isCollapsed,
icon: isCollapsed
? Icons.keyboard_double_arrow_right
: Icons.keyboard_double_arrow_left,
label: isCollapsed ? 'Expandir panel' : 'Colapsar panel',
onTap: () => ref.read(sidebarCollapsedProvider.notifier).state =
!isCollapsed,
),
),
const SizedBox(height: 8),
],
),
);
}
}
class _SidebarActionTile extends StatefulWidget {
final bool isCollapsed;
final IconData icon;
final String label;
final VoidCallback onTap;
final Color? color;
const _SidebarActionTile({
required this.isCollapsed,
required this.icon,
required this.label,
required this.onTap,
this.color,
});
@override
State<_SidebarActionTile> createState() => _SidebarActionTileState();
}
class _SidebarActionTileState extends State<_SidebarActionTile> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final baseColor = widget.color ?? Theme.of(context).colorScheme.onSurface;
if (widget.isCollapsed) {
return Tooltip(
message: widget.label,
waitDuration: const Duration(milliseconds: 600),
child: IconButton(
icon: Icon(widget.icon, size: 20),
onPressed: widget.onTap,
color: baseColor.withAlpha(178),
splashRadius: 18,
),
);
}
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: _hovered ? baseColor.withAlpha(15) : Colors.transparent,
),
child: Row(
children: [
Icon(widget.icon, size: 18, color: baseColor.withAlpha(190)),
const SizedBox(width: 10),
Text(
widget.label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: baseColor.withAlpha(190),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart';
class SomaUserCard extends StatelessWidget {
final UserSession? user;
final bool isCollapsed;
final VoidCallback? onTap;
const SomaUserCard({
super.key,
required this.user,
this.isCollapsed = false,
this.onTap,
});
String _getInitials() {
if (user == null) return '?';
final n = user!.nombre;
final a = user!.apellido;
if (n.isNotEmpty && a.isNotEmpty) {
return '${n[0]}${a[0]}'.toUpperCase();
}
if (n.isNotEmpty) return n[0].toUpperCase();
if (user!.dni.length >= 2) return user!.dni.substring(0, 2);
return '?';
}
@override
Widget build(BuildContext context) {
final avatar = CircleAvatar(
radius: 16,
backgroundColor: SomaColors.primary.withAlpha(40),
child: Text(
_getInitials(),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
if (isCollapsed) {
return Tooltip(
message: user?.displayName ?? '',
child: InkWell(
onTap: onTap,
mouseCursor: SystemMouseCursors.click,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(child: avatar),
),
),
);
}
return InkWell(
onTap: onTap,
mouseCursor: SystemMouseCursors.click,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
avatar,
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
user?.displayName ?? '',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
user?.role ?? '',
style: TextStyle(
fontSize: 11,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(130),
),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/navigation/navigation_items.dart';
import 'package:gimnasio_soma/core/navigation/navigation_shell.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
import 'package:gimnasio_soma/features/auth/presentation/screens/login_screen.dart';
import 'package:gimnasio_soma/features/auth/presentation/screens/splash_screen.dart';
import 'package:gimnasio_soma/features/dev/logs_screen.dart';
import 'package:gimnasio_soma/features/dev/widget_gallery_screen.dart';
import 'package:gimnasio_soma/features/actividades/presentation/screens/actividades_screen.dart';
import 'package:gimnasio_soma/features/perfil/presentation/screens/cambiar_contrasena_screen.dart';
import 'package:gimnasio_soma/features/perfil/presentation/screens/perfil_screen.dart';
import 'package:gimnasio_soma/features/horarios/presentation/screens/horarios_screen.dart';
import 'package:gimnasio_soma/features/pagos/presentation/screens/metodos_pago_screen.dart';
import 'package:gimnasio_soma/features/pagos/presentation/screens/pagos_screen.dart';
import 'package:gimnasio_soma/features/tipos_cuota/presentation/screens/tipos_cuota_screen.dart';
import 'package:gimnasio_soma/features/huerfanas/presentation/screens/huerfanas_screen.dart';
import 'package:gimnasio_soma/features/turnos/presentation/screens/turnos_screen.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/screens/usuarios_screen.dart';
final routerProvider = Provider<GoRouter>((ref) {
final authChangeNotifier = ref.watch(authChangeNotifierProvider);
return GoRouter(
initialLocation: '/splash',
refreshListenable: authChangeNotifier,
redirect: (context, state) {
final authState = ref.read(authStateProvider);
final user = authState.valueOrNull;
final isLoggedIn = user != null;
final location = state.matchedLocation;
final isAuthScreen = location == '/login' || location == '/splash';
// No logueado y no está en login/splash → ir a login
if (!isLoggedIn && !isAuthScreen) return '/login';
final effectiveRole = user?.role ?? '';
// Logueado y está en login o splash → ir al primer item visible
if (isLoggedIn && isAuthScreen) {
final items = getVisibleItems(effectiveRole);
return items.isNotEmpty ? items.first.path : '/usuarios';
}
// Guard: si el usuario navega a una ruta sin permiso
if (isLoggedIn) {
final visiblePaths =
getVisibleItems(effectiveRole).map((i) => i.path).toSet();
final matched = state.matchedLocation;
final isStaff = user.isStaff;
// Rutas internas/de staff que no figuran en el menú pero deben
// quedar bloqueadas para clientes.
const staffOnlyPaths = {
'/gallery',
'/logs',
'/perfil/cambiar-contrasena',
};
if (staffOnlyPaths.contains(matched) && !isStaff) {
return visiblePaths.isNotEmpty ? visiblePaths.first : '/usuarios';
}
if (matched != '/gallery' &&
matched != '/perfil' &&
matched != '/perfil/cambiar-contrasena' &&
matched != '/logs' &&
!visiblePaths.any((p) => matched.startsWith(p))) {
return visiblePaths.isNotEmpty ? visiblePaths.first : '/usuarios';
}
}
return null;
},
routes: [
// Splash - validación de sesión
GoRoute(
path: '/splash',
builder: (context, state) => const SplashScreen(),
),
// Login - fuera del shell
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
// Shell con navegación
ShellRoute(
builder: (context, state, child) => NavigationShell(child: child),
routes: [
GoRoute(
path: '/usuarios',
pageBuilder: (c, s) => _fade(s, const UsuariosScreen()),
),
GoRoute(
path: '/pagos',
pageBuilder: (c, s) => _fade(s, const PagosScreen()),
),
GoRoute(
path: '/pagos/metodos',
pageBuilder: (c, s) => _fade(s, const MetodosPagoScreen()),
),
GoRoute(
path: '/actividades',
pageBuilder: (c, s) => _fade(s, const ActividadesScreen()),
),
GoRoute(
path: '/horarios',
pageBuilder: (c, s) => _fade(s, const HorariosScreen()),
),
GoRoute(
path: '/turnos',
pageBuilder: (c, s) => _fade(s, const TurnosScreen()),
),
GoRoute(
path: '/planes',
pageBuilder: (c, s) => _fade(s, const TiposCuotaScreen()),
),
GoRoute(
path: '/huerfanas',
pageBuilder: (c, s) => _fade(s, const HuerfanasScreen()),
),
GoRoute(
path: '/perfil',
pageBuilder: (c, s) => _fade(s, const PerfilScreen()),
),
GoRoute(
path: '/perfil/cambiar-contrasena',
pageBuilder: (c, s) => _fade(s, const CambiarContrasenaScreen()),
),
GoRoute(
path: '/gallery',
pageBuilder: (c, s) => _fade(s, const WidgetGalleryScreen()),
),
GoRoute(
path: '/logs',
pageBuilder: (c, s) => _fade(s, const LogsScreen()),
),
],
),
],
);
});
CustomTransitionPage<void> _fade(GoRouterState state, Widget child) =>
CustomTransitionPage<void>(
key: state.pageKey,
child: child,
transitionDuration: const Duration(milliseconds: 180),
reverseTransitionDuration: const Duration(milliseconds: 140),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
child: child,
),
);
@@ -0,0 +1,192 @@
import 'dart:collection';
import 'dart:developer' as developer;
import 'package:flutter/foundation.dart';
enum LogLevel { debug, info, warning, error }
class LogEntry {
final DateTime timestamp;
final LogLevel level;
final String tag;
final String message;
final String? detail;
final Duration? duration;
LogEntry({
required this.timestamp,
required this.level,
required this.tag,
required this.message,
this.detail,
this.duration,
});
String get formatted {
final ts =
'${timestamp.hour.toString().padLeft(2, '0')}:'
'${timestamp.minute.toString().padLeft(2, '0')}:'
'${timestamp.second.toString().padLeft(2, '0')}.'
'${timestamp.millisecond.toString().padLeft(3, '0')}';
final lvl = level.name.toUpperCase().padRight(5);
final dur = duration != null ? ' (${duration!.inMilliseconds}ms)' : '';
return '[$ts] $lvl [$tag] $message$dur';
}
}
class SomaLogger {
SomaLogger._();
static final SomaLogger instance = SomaLogger._();
static const int _maxEntries = 300;
final Queue<LogEntry> _entries = Queue<LogEntry>();
/// Plataforma detectada al iniciar.
String platform = 'unknown';
List<LogEntry> get entries => _entries.toList();
int get count => _entries.length;
void _log(LogLevel level, String tag, String message,
{String? detail, Duration? duration}) {
final entry = LogEntry(
timestamp: DateTime.now(),
level: level,
tag: tag,
message: message,
detail: detail,
duration: duration,
);
_entries.addLast(entry);
while (_entries.length > _maxEntries) {
_entries.removeFirst();
}
// Salida a consola multiplataforma
developer.log(
entry.formatted,
name: 'SOMA',
level: _levelToInt(level),
);
if (detail != null) {
developer.log(' $detail', name: 'SOMA', level: _levelToInt(level));
}
}
int _levelToInt(LogLevel level) {
switch (level) {
case LogLevel.debug:
return 500;
case LogLevel.info:
return 800;
case LogLevel.warning:
return 900;
case LogLevel.error:
return 1000;
}
}
void debug(String tag, String message,
{String? detail, Duration? duration}) =>
_log(LogLevel.debug, tag, message, detail: detail, duration: duration);
void info(String tag, String message,
{String? detail, Duration? duration}) =>
_log(LogLevel.info, tag, message, detail: detail, duration: duration);
void warning(String tag, String message,
{String? detail, Duration? duration}) =>
_log(LogLevel.warning, tag, message, detail: detail, duration: duration);
void error(String tag, String message,
{String? detail, Duration? duration}) =>
_log(LogLevel.error, tag, message, detail: detail, duration: duration);
/// Wrapper para llamadas RPC con logging automático de request y timing.
Future<T> logRpc<T>(
String rpcName,
Map<String, dynamic> params,
Future<T> Function() call,
) async {
final sanitized = _sanitizeParams(params);
debug('RPC', '-> $rpcName', detail: sanitized);
final sw = Stopwatch()..start();
try {
final result = await call();
sw.stop();
info('RPC', '<- $rpcName OK',
duration: sw.elapsed, detail: _summarizeResult(result));
return result;
} catch (e) {
sw.stop();
error('RPC', 'x $rpcName FAIL',
duration: sw.elapsed, detail: e.toString());
rethrow;
}
}
String _sanitizeParams(Map<String, dynamic> params) {
final safe = <String, dynamic>{};
for (final entry in params.entries) {
if (entry.key.contains('token') || entry.key.contains('password')) {
safe[entry.key] = '***';
} else {
safe[entry.key] = entry.value;
}
}
return safe.toString();
}
String _summarizeResult(dynamic result) {
if (result is List) return '${result.length} items';
if (result is Map) return '${result.length} keys';
if (result is bool) return result.toString();
if (result == null) return 'null';
if (result is String && result.length > 80) {
return '${result.substring(0, 80)}...';
}
return result.toString();
}
/// Detecta plataforma actual.
void detectPlatform() {
if (kIsWeb) {
platform = 'Web';
} else {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
platform = 'Android';
case TargetPlatform.iOS:
platform = 'iOS';
case TargetPlatform.windows:
platform = 'Windows';
case TargetPlatform.macOS:
platform = 'macOS';
case TargetPlatform.linux:
platform = 'Linux';
case TargetPlatform.fuchsia:
platform = 'Fuchsia';
}
}
info('App', 'Plataforma: $platform');
}
void clear() => _entries.clear();
/// Exporta logs como texto plano.
String export() {
final buf = StringBuffer();
buf.writeln('=== SOMA Logs ===');
buf.writeln('Plataforma: $platform');
buf.writeln('Exportado: ${DateTime.now()}');
buf.writeln('Entradas: ${_entries.length}');
buf.writeln('');
for (final e in _entries) {
buf.writeln(e.formatted);
if (e.detail != null) buf.writeln(' ${e.detail}');
}
return buf.toString();
}
}
@@ -0,0 +1,32 @@
import 'package:url_launcher/url_launcher.dart';
class WhatsAppService {
WhatsAppService._();
/// Normaliza un número argentino a formato internacional sin +.
/// Retorna null si el número es inválido (vacío o menos de 8 dígitos).
static String? normalizarNumeroAr(String? rawPhone) {
if (rawPhone == null || rawPhone.trim().isEmpty) return null;
final digits = rawPhone.replaceAll(RegExp(r'[^\d]'), '');
if (digits.length < 8) return null;
if (digits.startsWith('0054')) return '54${digits.substring(4)}';
if (digits.startsWith('54') && digits.length >= 10) return digits;
if (digits.startsWith('0')) return '54${digits.substring(1)}';
return '54$digits';
}
/// Abre WhatsApp con un mensaje pre-armado.
/// Retorna true si se abrió correctamente, false si el número es inválido
/// o si el sistema no pudo lanzar la URL.
static Future<bool> abrirChat({
required String? telefono,
required String mensaje,
}) async {
final number = normalizarNumeroAr(telefono);
if (number == null) return false;
final uri = Uri.parse(
'https://wa.me/$number?text=${Uri.encodeComponent(mensaje)}',
);
return launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
abstract final class ActivityColors {
static const List<Color> _palette = [
Color(0xFFE07272), // coral
Color(0xFFD4904A), // ámbar terroso
Color(0xFF9FC44A), // lima
Color(0xFF4AC49A), // menta
Color(0xFF4A9ED4), // cielo
Color(0xFF7A6ED4), // lavanda
Color(0xFFC46EC4), // malva
Color(0xFFD46E96), // rosa
];
static Color forId(int actividadId) => _palette[actividadId % _palette.length];
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
class SomaColors {
SomaColors._();
// Primary
static const Color primary = Color(0xFFFFD600);
static const Color primaryDark = Color(0xFFFFC800);
static const Color primaryLight = Color(0xFFFFE24D);
// Variante legible de primary para texto sobre fondo claro
static const Color primaryText = Color(0xFF8B7000);
// Dark theme
static const Color darkBackground = Color(0xFF121212);
static const Color darkSurface = Color(0xFF1E1E1E);
static const Color darkSurfaceVariant = Color(0xFF2C2C2C);
static const Color darkOnBackground = Color(0xFFFFFFFF);
static const Color darkOnSurface = Color(0xFFE0E0E0);
static const Color darkOnSurfaceVariant = Color(0xFF9E9E9E);
// Semantic
static const Color success = Color(0xFF4CAF50);
static const Color error = Color(0xFFEF5350);
static const Color info = Color(0xFFFFD600);
// On primary (text on yellow)
static const Color onPrimary = Color(0xFF1A1A1A);
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'soma_colors.dart';
class SomaTheme {
SomaTheme._();
static ThemeData get dark => ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: SomaColors.darkBackground,
colorScheme: const ColorScheme.dark(
primary: SomaColors.primary,
onPrimary: SomaColors.onPrimary,
surface: SomaColors.darkSurface,
onSurface: SomaColors.darkOnSurface,
surfaceContainerHighest: SomaColors.darkSurfaceVariant,
error: SomaColors.error,
),
appBarTheme: const AppBarTheme(
backgroundColor: SomaColors.darkBackground,
foregroundColor: SomaColors.darkOnBackground,
elevation: 0,
systemOverlayStyle: SystemUiOverlayStyle.light,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: SomaColors.darkSurfaceVariant,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: SomaColors.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: SomaColors.error, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: SomaColors.error, width: 2),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
hintStyle: const TextStyle(color: SomaColors.darkOnSurfaceVariant),
labelStyle: const TextStyle(color: SomaColors.darkOnSurfaceVariant),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.primary,
foregroundColor: SomaColors.onPrimary,
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: SomaColors.primary,
minimumSize: const Size(double.infinity, 52),
side: const BorderSide(color: SomaColors.primary, width: 1.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: SomaColors.primary,
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
tabBarTheme: const TabBarThemeData(
labelColor: SomaColors.darkOnSurface,
unselectedLabelColor: SomaColors.darkOnSurfaceVariant,
indicatorColor: SomaColors.primary,
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return SomaColors.primary;
return Colors.transparent;
}),
foregroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return SomaColors.onPrimary;
return null;
}),
iconColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return SomaColors.onPrimary;
return null;
}),
),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
contentTextStyle: const TextStyle(fontSize: 14),
),
);
}
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
class SomaHelpItem {
final IconData icon;
final String text;
const SomaHelpItem({required this.icon, required this.text});
}
/// Botón "?" para poner al lado del título de una pantalla. Al tocarlo
/// muestra un globito explicando qué hace cada ícono/acción visible.
class SomaHeaderHelp extends StatelessWidget {
final List<SomaHelpItem> items;
const SomaHeaderHelp({super.key, required this.items});
void _show(BuildContext context) {
final button = context.findRenderObject() as RenderBox;
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
final position = RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(
Offset(0, button.size.height + 6),
ancestor: overlay,
),
button.localToGlobal(
button.size.bottomRight(Offset(0, button.size.height + 6)),
ancestor: overlay,
),
),
Offset.zero & overlay.size,
);
showMenu<void>(
context: context,
position: position,
constraints: const BoxConstraints(maxWidth: 300),
items: [
PopupMenuItem<void>(
enabled: false,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < items.length; i++) ...[
if (i > 0) const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(items[i].icon, size: 16, color: SomaColors.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
items[i].text,
style: const TextStyle(fontSize: 12.5, height: 1.3),
),
),
],
),
],
],
),
),
],
);
}
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(
Icons.help_outline,
size: 18,
color: Theme.of(context).colorScheme.onSurface.withAlpha(120),
),
tooltip: 'Ayuda',
visualDensity: VisualDensity.compact,
onPressed: () => _show(context),
);
}
}
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
class SomaLogo extends StatelessWidget {
final double? width;
final double? height;
const SomaLogo({
super.key,
this.width,
this.height,
});
@override
Widget build(BuildContext context) {
return Image.asset(
'assets/logo.png',
width: width,
height: height,
fit: BoxFit.contain,
);
}
}
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
class SomaPrimaryButton extends StatelessWidget {
final String text;
final VoidCallback? onPressed;
final bool isLoading;
final IconData? icon;
const SomaPrimaryButton({
super.key,
required this.text,
this.onPressed,
this.isLoading = false,
this.icon,
});
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: isLoading ? null : onPressed,
child: isLoading
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.black87,
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 20),
const SizedBox(width: 8),
],
Text(text),
],
),
);
}
}
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
class SomaSecondaryButton extends StatelessWidget {
final String text;
final VoidCallback? onPressed;
final bool isLoading;
final IconData? icon;
const SomaSecondaryButton({
super.key,
required this.text,
this.onPressed,
this.isLoading = false,
this.icon,
});
@override
Widget build(BuildContext context) {
return OutlinedButton(
onPressed: isLoading ? null : onPressed,
child: isLoading
? SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Theme.of(context).colorScheme.primary,
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 20),
const SizedBox(width: 8),
],
Text(text),
],
),
);
}
}
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class SomaTextField extends StatelessWidget {
final TextEditingController? controller;
final String? hintText;
final String? labelText;
final IconData? prefixIcon;
final Widget? suffixIcon;
final bool obscureText;
final TextInputType keyboardType;
final List<TextInputFormatter>? inputFormatters;
final String? Function(String?)? validator;
final void Function(String)? onChanged;
final bool enabled;
final int maxLines;
const SomaTextField({
super.key,
this.controller,
this.hintText,
this.labelText,
this.prefixIcon,
this.suffixIcon,
this.obscureText = false,
this.keyboardType = TextInputType.text,
this.inputFormatters,
this.validator,
this.onChanged,
this.enabled = true,
this.maxLines = 1,
});
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
validator: validator,
onChanged: onChanged,
enabled: enabled,
maxLines: maxLines,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
),
decoration: InputDecoration(
hintText: hintText,
labelText: labelText,
prefixIcon: prefixIcon != null
? Icon(
prefixIcon,
color: Theme.of(context).colorScheme.onSurface.withAlpha(153),
size: 22,
)
: null,
suffixIcon: suffixIcon,
),
);
}
}
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
enum ToastType { success, error, info }
class SomaToast {
SomaToast._();
static void show(
BuildContext context, {
required String message,
ToastType type = ToastType.info,
SnackBarAction? action,
}) {
ScaffoldMessenger.of(context).clearSnackBars();
final (Color bg, Color text, IconData icon) = switch (type) {
ToastType.success => (SomaColors.success, Colors.white, Icons.check_circle_outline),
ToastType.error => (SomaColors.error, Colors.white, Icons.error_outline),
ToastType.info => (SomaColors.primary, SomaColors.onPrimary, Icons.info_outline),
};
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(icon, color: text, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
style: TextStyle(
color: text,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
backgroundColor: bg,
duration: Duration(seconds: action != null ? 5 : 3),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
action: action,
),
);
}
}
@@ -0,0 +1,65 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/config/supabase_config.dart';
import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart';
import 'package:gimnasio_soma/features/actividades/domain/repositories/actividades_repository.dart';
class ActividadesRepositoryImpl implements ActividadesRepository {
Future<String> _getToken() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token == null) throw Exception('Sin sesión activa');
return token;
}
@override
Future<List<Actividad>> getActividades({bool? soloActivas}) async {
final token = await _getToken();
final params = <String, dynamic>{'p_token': token};
if (soloActivas != null) params['p_solo_activas'] = soloActivas;
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetActividades,
params: params,
);
if (response is List) {
return response
.map((e) => Actividad.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<void> insertActividad(Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcInsertActividad,
params: {'p_token': token, 'p_datos': datos},
);
}
@override
Future<void> updateActividad(int id, Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcUpdateActividad,
params: {'p_token': token, 'p_actividad_id': id, 'p_datos': datos},
);
}
@override
Future<bool> deleteActividad(int id) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcDeleteActividad,
params: {'p_token': token, 'p_actividad_id': id},
);
return response == true;
}
}
@@ -0,0 +1,47 @@
class Actividad {
final int id;
final String nombre;
final int duracion; // minutos
final int capacidadPorDefecto;
final bool libre;
final bool activo;
const Actividad({
required this.id,
required this.nombre,
required this.duracion,
required this.capacidadPorDefecto,
this.libre = false,
this.activo = true,
});
factory Actividad.fromMap(Map<String, dynamic> map) {
return Actividad(
id: map['id'] as int,
nombre: map['nombre'] as String? ?? '',
duracion: (map['duracion'] as num?)?.toInt() ?? 0,
capacidadPorDefecto: (map['capacidad_por_defecto'] as num?)?.toInt() ?? 0,
libre: map['libre'] as bool? ?? false,
activo: map['activo'] as bool? ?? true,
);
}
Map<String, dynamic> toMap() {
return {
'nombre': nombre,
'duracion': duracion,
'capacidad_por_defecto': capacidadPorDefecto,
'libre': libre,
'activo': activo,
};
}
String get duracionDisplay {
if (duracion >= 60) {
final h = duracion ~/ 60;
final m = duracion % 60;
return m > 0 ? '${h}h ${m}min' : '${h}h';
}
return '${duracion}min';
}
}
@@ -0,0 +1,8 @@
import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart';
abstract class ActividadesRepository {
Future<List<Actividad>> getActividades({bool? soloActivas});
Future<void> insertActividad(Map<String, dynamic> datos);
Future<void> updateActividad(int id, Map<String, dynamic> datos);
Future<bool> deleteActividad(int id);
}
@@ -0,0 +1,67 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/features/actividades/data/repositories/actividades_repository_impl.dart';
import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart';
import 'package:gimnasio_soma/features/actividades/domain/repositories/actividades_repository.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
String _errorMessage(Object e) {
if (e is PostgrestException) return e.message;
return e.toString().replaceFirst('Exception: ', '');
}
final actividadesRepositoryProvider = Provider<ActividadesRepository>((ref) {
return ActividadesRepositoryImpl();
});
final actividadesProvider =
StateNotifierProvider<ActividadesNotifier, AsyncValue<List<Actividad>>>((ref) {
return ActividadesNotifier(ref.read(actividadesRepositoryProvider));
});
class ActividadesNotifier extends StateNotifier<AsyncValue<List<Actividad>>> {
final ActividadesRepository _repository;
ActividadesNotifier(this._repository) : super(const AsyncValue.loading()) {
loadActividades();
}
Future<void> loadActividades() async {
state = const AsyncValue.loading();
try {
final actividades = await _repository.getActividades();
state = AsyncValue.data(actividades);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<String?> insertActividad(Map<String, dynamic> datos) async {
try {
await _repository.insertActividad(datos);
await loadActividades();
return null;
} catch (e) {
return _errorMessage(e);
}
}
Future<String?> updateActividad(int id, Map<String, dynamic> datos) async {
try {
await _repository.updateActividad(id, datos);
await loadActividades();
return null;
} catch (e) {
return _errorMessage(e);
}
}
Future<String?> deleteActividad(int id) async {
try {
await _repository.deleteActividad(id);
await loadActividades();
return null;
} catch (e) {
return _errorMessage(e);
}
}
}
@@ -0,0 +1,445 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/theme/activity_colors.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart';
import 'package:gimnasio_soma/features/actividades/presentation/providers/actividades_provider.dart';
import 'package:gimnasio_soma/features/actividades/presentation/widgets/actividad_form_dialog.dart';
class ActividadesScreen extends ConsumerStatefulWidget {
const ActividadesScreen({super.key});
@override
ConsumerState<ActividadesScreen> createState() => _ActividadesScreenState();
}
class _ActividadesScreenState extends ConsumerState<ActividadesScreen> {
Future<void> _showCreateDialog() async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => const ActividadFormDialog(),
);
if (result == null || !mounted) return;
final error =
await ref.read(actividadesProvider.notifier).insertActividad(result);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Actividad creada', type: ToastType.success);
}
}
Future<void> _showEditDialog(Actividad actividad) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => ActividadFormDialog(actividad: actividad),
);
if (result == null || !mounted) return;
final error = await ref
.read(actividadesProvider.notifier)
.updateActividad(actividad.id, result);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Actividad actualizada', type: ToastType.success);
}
}
Future<void> _deleteActividad(Actividad actividad) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Eliminar actividad'),
content: Text(
'¿Estás seguro de que querés eliminar "${actividad.nombre}"?\n'
'Esta acción no se puede deshacer.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
minimumSize: const Size(0, 40),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Eliminar'),
),
],
),
);
if (confirm != true || !mounted) return;
final error = await ref
.read(actividadesProvider.notifier)
.deleteActividad(actividad.id);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Actividad eliminada', type: ToastType.success);
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(actividadesProvider);
final isWide = MediaQuery.of(context).size.width >= 800;
return Scaffold(
body: Column(
children: [
// Header
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
12,
),
child: Row(
children: [
const Text(
'Actividades',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const Spacer(),
_AddButton(isWide: isWide, onTap: _showCreateDialog),
],
),
),
// Lista
Expanded(
child: state.when(
loading: () => const Center(
child: CircularProgressIndicator(color: SomaColors.primary),
),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 48,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(100)),
const SizedBox(height: 12),
Text(
e.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(153),
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () => ref
.read(actividadesProvider.notifier)
.loadActividades(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (actividades) {
if (actividades.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.fitness_center_outlined,
size: 56,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(60)),
const SizedBox(height: 12),
Text(
'No hay actividades',
style: TextStyle(
fontSize: 15,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(130),
),
),
],
),
);
}
return RefreshIndicator(
color: SomaColors.primary,
onRefresh: () => ref
.read(actividadesProvider.notifier)
.loadActividades(),
child: ListView.separated(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 4, isWide ? 32 : 16, 80,
),
itemCount: actividades.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final act = actividades[index];
return _ActividadCard(
actividad: act,
onEdit: () => _showEditDialog(act),
onDelete: () => _deleteActividad(act),
);
},
),
);
},
),
),
],
),
);
}
}
class _ActividadCard extends StatelessWidget {
final Actividad actividad;
final VoidCallback onEdit;
final VoidCallback onDelete;
const _ActividadCard({
required this.actividad,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: onEdit,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: Row(
children: [
// Ícono
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: actividad.activo
? ActivityColors.forId(actividad.id).withAlpha(30)
: theme.colorScheme.onSurface.withAlpha(15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.fitness_center,
size: 20,
color: actividad.activo
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withAlpha(100),
),
),
const SizedBox(width: 14),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
actividad.nombre,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (!actividad.activo)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withAlpha(20),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'Inactiva',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color:
theme.colorScheme.onSurface.withAlpha(130),
),
),
),
if (actividad.libre)
Padding(
padding: const EdgeInsets.only(left: 6),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: SomaColors.success.withAlpha(20),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'Libre',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: SomaColors.success,
),
),
),
),
],
),
const SizedBox(height: 3),
Row(
children: [
Icon(Icons.timer_outlined,
size: 14,
color:
theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(width: 4),
Text(
actividad.duracionDisplay,
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(130),
),
),
Text(
'',
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(80),
),
),
Icon(Icons.group_outlined,
size: 14,
color:
theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(width: 4),
Text(
'${actividad.capacidadPorDefecto} personas',
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
],
),
),
// Actions
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(100),
),
itemBuilder: (_) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit_outlined, size: 18),
SizedBox(width: 8),
Text('Editar'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_outline, size: 18, color: SomaColors.error),
SizedBox(width: 8),
Text('Eliminar',
style: TextStyle(color: SomaColors.error)),
],
),
),
],
onSelected: (v) {
if (v == 'edit') onEdit();
if (v == 'delete') onDelete();
},
),
],
),
),
);
}
}
class _AddButton extends StatelessWidget {
final bool isWide;
final VoidCallback onTap;
const _AddButton({required this.isWide, required this.onTap});
@override
Widget build(BuildContext context) {
if (isWide) {
return ElevatedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.add, size: 20),
label: const Text('Nueva'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
);
}
return SizedBox(
height: 42,
width: 42,
child: IconButton.filled(
onPressed: onTap,
icon: const Icon(Icons.add, size: 22),
style: IconButton.styleFrom(
backgroundColor: SomaColors.primary,
foregroundColor: SomaColors.onPrimary,
),
),
);
}
}
@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart';
class ActividadFormDialog extends StatefulWidget {
final Actividad? actividad;
const ActividadFormDialog({super.key, this.actividad});
@override
State<ActividadFormDialog> createState() => _ActividadFormDialogState();
}
class _ActividadFormDialogState extends State<ActividadFormDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nombreCtrl;
late final TextEditingController _duracionCtrl;
late final TextEditingController _capacidadCtrl;
late bool _libre;
bool get _isEditing => widget.actividad != null;
@override
void initState() {
super.initState();
_nombreCtrl = TextEditingController(text: widget.actividad?.nombre ?? '');
_duracionCtrl = TextEditingController(
text: widget.actividad != null ? widget.actividad!.duracion.toString() : '',
);
_capacidadCtrl = TextEditingController(
text: widget.actividad != null
? widget.actividad!.capacidadPorDefecto.toString()
: '',
);
_libre = widget.actividad?.libre ?? false;
}
@override
void dispose() {
_nombreCtrl.dispose();
_duracionCtrl.dispose();
_capacidadCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final data = <String, dynamic>{
'nombre': _nombreCtrl.text.trim(),
'duracion': int.tryParse(_duracionCtrl.text.trim()) ?? 0,
'capacidad_por_defecto': int.tryParse(_capacidadCtrl.text.trim()) ?? 0,
'libre': _libre,
};
Navigator.of(context).pop(data);
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isWide = width >= 600;
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isWide ? (width - 440) / 2 : 20,
vertical: 24,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
Text(
_isEditing ? 'Editar Actividad' : 'Nueva Actividad',
style: const 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),
// Form
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SomaTextField(
controller: _nombreCtrl,
labelText: 'Nombre *',
prefixIcon: Icons.fitness_center,
validator: (v) => v == null || v.trim().isEmpty
? 'Nombre requerido'
: null,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: SomaTextField(
controller: _duracionCtrl,
labelText: 'Duración (min) *',
prefixIcon: Icons.timer_outlined,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(4),
],
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Requerido';
}
final n = int.tryParse(v.trim());
if (n == null || n <= 0) return 'Inválido';
return null;
},
),
),
const SizedBox(width: 12),
Expanded(
child: SomaTextField(
controller: _capacidadCtrl,
labelText: 'Capacidad *',
prefixIcon: Icons.group_outlined,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(4),
],
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Requerido';
}
final n = int.tryParse(v.trim());
if (n == null || n <= 0) return 'Inválido';
return null;
},
),
),
],
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text(
'Actividad libre',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
subtitle: const Text(
'No requiere turno previo',
style: TextStyle(fontSize: 12),
),
value: _libre,
activeThumbColor: SomaColors.primary,
contentPadding: EdgeInsets.zero,
onChanged: (v) => setState(() => _libre = v),
),
const SizedBox(height: 8),
],
),
),
),
),
const Divider(height: 1),
// Actions
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: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: Text(_isEditing ? 'Guardar' : 'Crear'),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,137 @@
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/config/supabase_config.dart';
import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart';
import 'package:gimnasio_soma/features/auth/domain/repositories/auth_repository.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AuthRepositoryImpl implements AuthRepository {
@override
Future<UserSession> login(String dni, String password) async {
// fc_ingresar(dni_input, password_plain_input) → TABLE(token uuid, rol text)
final loginResponse = await SupabaseConfig.rpc(
AppConstants.rpcLogin,
params: {
'dni_input': dni,
'password_plain_input': password,
},
);
if (loginResponse == null ||
(loginResponse is List && loginResponse.isEmpty)) {
throw Exception('DNI o contraseña incorrectos');
}
// Supabase devuelve TABLE como List<Map>
final row = (loginResponse as List)[0] as Map<String, dynamic>;
final token = row['token'] as String;
final rol = row['rol'] as String;
// Persistir token
final prefs = await SharedPreferences.getInstance();
await prefs.setString(AppConstants.tokenKey, token);
// Intentar obtener datos completos del usuario
// fc_obtener_usuario_por_token(p_token, p_user_token) → jsonb
try {
final userData = await SupabaseConfig.rpc(
AppConstants.rpcGetUserByToken,
params: {
'p_token': token,
'p_user_token': token,
},
);
if (userData != null && userData is Map<String, dynamic>) {
return UserSession.fromUserData(userData, token);
}
} catch (_) {
// Si falla (ej: sin permiso 'ver_usuarios'), usamos datos básicos
}
// Fallback: datos básicos del login
return UserSession.fromLogin(token: token, role: rol, dni: dni);
}
@override
Future<UserSession?> validateSession() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token == null || token.isEmpty) return null;
try {
// fc_iniciar_sesion_por_token(p_token) → text (rol), extiende TTL
final rol = await SupabaseConfig.rpc(
AppConstants.rpcIniciarSesionPorToken,
params: {'p_token': token},
);
if (rol == null || (rol is String && rol.isEmpty)) {
await prefs.remove(AppConstants.tokenKey);
return null;
}
// Intentar obtener datos completos
try {
final userData = await SupabaseConfig.rpc(
AppConstants.rpcGetUserByToken,
params: {
'p_token': token,
'p_user_token': token,
},
);
if (userData != null && userData is Map<String, dynamic>) {
return UserSession.fromUserData(userData, token);
}
} catch (_) {
// Sin permiso, usamos datos básicos
}
return UserSession(token: token, role: rol as String);
} catch (_) {
await prefs.remove(AppConstants.tokenKey);
return null;
}
}
@override
Future<void> logout() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token != null) {
try {
// fc_eliminar_sesion(p_token) → void
await SupabaseConfig.rpc(
AppConstants.rpcDestroySession,
params: {'p_token': token},
);
} catch (_) {
// Silently fail - limpiamos el token local de todas formas
}
}
await prefs.remove(AppConstants.tokenKey);
}
@override
Future<void> cambiarPropiaContrasena({
required String passwordActual,
required String passwordNueva,
}) async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token == null) throw Exception('Sin sesión activa');
// fc_cambiar_propia_contrasena(p_token, p_password_actual, p_password_nueva) → void
await SupabaseConfig.rpc(
AppConstants.rpcCambiarPropiaContrasena,
params: {
'p_token': token,
'p_password_actual': passwordActual,
'p_password_nueva': passwordNueva,
},
);
}
}
@@ -0,0 +1,62 @@
class UserSession {
final String token;
final String role;
// UUID del usuario. Null al recién loguear (fc_ingresar no lo devuelve);
// se rellena al primer fc_obtener_usuario_por_token. Necesario para
// ownership checks (ej. "este pago lo creé yo").
final String? id;
final String nombre;
final String apellido;
final String dni;
final String? mail;
final String? telefono;
const UserSession({
required this.token,
required this.role,
this.id,
this.nombre = '',
this.apellido = '',
this.dni = '',
this.mail,
this.telefono,
});
/// Crear desde fc_ingresar (solo token + rol) + el DNI que ingresó el usuario.
factory UserSession.fromLogin({
required String token,
required String role,
required String dni,
}) {
return UserSession(token: token, role: role, dni: dni);
}
/// Crear desde fc_obtener_usuario_por_token (jsonb completo).
factory UserSession.fromUserData(Map<String, dynamic> map, String token) {
return UserSession(
token: token,
role: map['rol'] as String? ?? '',
id: map['id'] as String?,
nombre: map['nombre'] as String? ?? '',
apellido: map['apellido'] as String? ?? '',
dni: map['dni'] as String? ?? '',
mail: map['mail'] as String?,
telefono: map['telefono'] as String?,
);
}
String get displayName {
if (nombre.isNotEmpty && apellido.isNotEmpty) return '$nombre $apellido';
if (nombre.isNotEmpty) return nombre;
return dni;
}
/// Getter unificado: staff incluye superadmin, admin y profesor
bool get isStaff => role == 'superadmin' || role == 'admin' || role == 'profesor';
bool get isUsuario => role == 'usuario' || role == 'cliente';
/// True sólo para el rol 'superadmin'. Habilita en UI las acciones
/// que el backend gobierna con el permiso 'gestionar_cualquier_pago'.
bool get isSuperadmin => role == 'superadmin';
}
@@ -0,0 +1,11 @@
import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart';
abstract class AuthRepository {
Future<UserSession> login(String dni, String password);
Future<UserSession?> validateSession();
Future<void> logout();
Future<void> cambiarPropiaContrasena({
required String passwordActual,
required String passwordNueva,
});
}
@@ -0,0 +1,65 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/features/auth/data/repositories/auth_repository_impl.dart';
import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart';
import 'package:gimnasio_soma/features/auth/domain/repositories/auth_repository.dart';
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepositoryImpl();
});
final authStateProvider =
StateNotifierProvider<AuthNotifier, AsyncValue<UserSession?>>((ref) {
return AuthNotifier(ref.read(authRepositoryProvider));
});
/// Listenable que notifica al GoRouter cuando cambia el estado de auth.
/// Solo notifica cuando el estado pasa de logueado a no-logueado o viceversa,
/// no cuando pasa a loading (para evitar recrear el router innecesariamente).
final authChangeNotifierProvider = Provider<AuthChangeNotifier>((ref) {
final notifier = AuthChangeNotifier();
ref.listen<AsyncValue<UserSession?>>(authStateProvider, (prev, next) {
final wasLoggedIn = prev?.valueOrNull != null;
final isLoggedIn = next.valueOrNull != null;
if (wasLoggedIn != isLoggedIn) {
notifier.notify();
}
});
return notifier;
});
class AuthChangeNotifier extends ChangeNotifier {
void notify() => notifyListeners();
}
class AuthNotifier extends StateNotifier<AsyncValue<UserSession?>> {
final AuthRepository _repository;
AuthNotifier(this._repository) : super(const AsyncValue.data(null));
Future<void> checkSession() async {
state = const AsyncValue.loading();
try {
final session = await _repository.validateSession();
state = AsyncValue.data(session);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<String?> login(String dni, String password) async {
try {
final session = await _repository.login(dni, password);
state = AsyncValue.data(session);
return null;
} catch (e) {
state = const AsyncValue.data(null);
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<void> logout() async {
await _repository.logout();
state = const AsyncValue.data(null);
}
}
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_desktop.dart';
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_mobile.dart';
/// Breakpoint para cambiar entre mobile y desktop layout.
const _kDesktopBreakpoint = 800.0;
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _animController;
late final Animation<double> _fadeIn;
late final Animation<Offset> _slideMobile;
late final Animation<Offset> _slideDesktop;
@override
void initState() {
super.initState();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_fadeIn = CurvedAnimation(
parent: _animController,
curve: Curves.easeOut,
);
// Mobile: form slides up
_slideMobile = Tween<Offset>(
begin: const Offset(0, 0.15),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animController,
curve: Curves.easeOutCubic,
));
// Desktop: form slides in from right
_slideDesktop = Tween<Offset>(
begin: const Offset(0.08, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animController,
curve: Curves.easeOutCubic,
));
_animController.forward();
}
@override
void dispose() {
_animController.dispose();
super.dispose();
}
Future<void> _handleLogin(String dni, String password) async {
final error = await ref
.read(authStateProvider.notifier)
.login(dni, password)
.timeout(
const Duration(seconds: 15),
onTimeout: () => 'Tiempo de espera agotado. Verificá tu conexión.',
);
if (!mounted || error == null) return;
SomaToast.show(context, message: error, type: ToastType.error);
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isDesktop = width >= _kDesktopBreakpoint;
return Scaffold(
body: isDesktop
? LoginDesktop(
onSubmit: _handleLogin,
fadeIn: _fadeIn,
slideIn: _slideDesktop,
)
: LoginMobile(
onSubmit: _handleLogin,
fadeIn: _fadeIn,
slideUp: _slideMobile,
),
);
}
}
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_logo.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@override
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends ConsumerState<SplashScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _checkSession());
}
Future<void> _checkSession() async {
try {
await ref.read(authStateProvider.notifier).checkSession();
} catch (_) {
// Error en la validación, ir a login
}
if (!mounted) return;
final user = ref.read(authStateProvider).valueOrNull;
if (user == null) {
context.go('/login');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SomaLogo(width: 160),
const SizedBox(height: 32),
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: SomaColors.primary,
),
),
],
),
),
);
}
}
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_form.dart';
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_hero.dart';
class LoginDesktop extends StatelessWidget {
final Future<void> Function(String dni, String password) onSubmit;
final Animation<double> fadeIn;
final Animation<Offset> slideIn;
const LoginDesktop({
super.key,
required this.onSubmit,
required this.fadeIn,
required this.slideIn,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
// ── Hero (left half) ──
const Expanded(
flex: 5,
child: LoginHero(),
),
// ── Form (right half) ──
Expanded(
flex: 4,
child: FadeTransition(
opacity: fadeIn,
child: SlideTransition(
position: slideIn,
child: Container(
height: double.infinity,
color: Theme.of(context).colorScheme.surface,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 48,
vertical: 40,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LoginForm(onSubmit: onSubmit),
const SizedBox(height: 32),
Center(
child: Text(
'v0.1.0',
style: TextStyle(
fontSize: 11,
color: SomaColors.darkOnSurfaceVariant
.withAlpha(100),
),
),
),
],
),
),
),
),
),
),
),
),
],
);
}
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_primary_button.dart';
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
class LoginForm extends StatefulWidget {
final Future<void> Function(String dni, String password) onSubmit;
const LoginForm({super.key, required this.onSubmit});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _dniController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
bool _isLoading = false;
@override
void dispose() {
_dniController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleSubmit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
await widget.onSubmit(
_dniController.text.trim(),
_passwordController.text,
);
if (mounted) setState(() => _isLoading = false);
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Iniciar sesión',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 6),
Text(
'Ingresá tus datos para continuar',
style: TextStyle(
fontSize: 14,
color: SomaColors.darkOnSurfaceVariant,
),
),
const SizedBox(height: 28),
SomaTextField(
controller: _dniController,
hintText: 'Ej: 12345678',
labelText: 'DNI',
prefixIcon: Icons.badge_outlined,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(8),
],
validator: (value) {
if (value == null || value.isEmpty) {
return 'El DNI es requerido';
}
if (value.length < 7) {
return 'DNI inválido';
}
return null;
},
),
const SizedBox(height: 16),
SomaTextField(
controller: _passwordController,
hintText: 'Tu contraseña',
labelText: 'Contraseña',
prefixIcon: Icons.lock_outline,
obscureText: _obscurePassword,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(130),
size: 20,
),
onPressed: () {
setState(() => _obscurePassword = !_obscurePassword);
},
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'La contraseña es requerida';
}
if (value.length < 4) {
return 'La contraseña es muy corta';
}
return null;
},
),
const SizedBox(height: 32),
SomaPrimaryButton(
text: 'Ingresar',
onPressed: _handleSubmit,
isLoading: _isLoading,
),
],
),
);
}
}
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
class LoginHero extends StatelessWidget {
const LoginHero({super.key});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF1A1A1A), Color(0xFF121212)],
),
),
child: Stack(
children: [
// Decorative circles
Positioned(
top: -60,
left: -60,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SomaColors.primary.withAlpha(15),
),
),
),
Positioned(
bottom: -40,
right: -40,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SomaColors.primary.withAlpha(10),
),
),
),
// Content
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/logo.png',
width: 180,
),
const SizedBox(height: 12),
Text(
'Gimnasio',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w300,
letterSpacing: 6,
color: SomaColors.darkOnSurfaceVariant,
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_form.dart';
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_hero.dart';
class LoginMobile extends StatelessWidget {
final Future<void> Function(String dni, String password) onSubmit;
final Animation<double> fadeIn;
final Animation<Offset> slideUp;
const LoginMobile({
super.key,
required this.onSubmit,
required this.fadeIn,
required this.slideUp,
});
@override
Widget build(BuildContext context) {
final screenHeight = MediaQuery.of(context).size.height;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: SingleChildScrollView(
child: SizedBox(
height: screenHeight,
child: Column(
children: [
// ── Hero (top 35%) ──
const Expanded(
flex: 35,
child: SafeArea(
bottom: false,
child: LoginHero(),
),
),
// ── Form (bottom 65%) ──
Expanded(
flex: 65,
child: FadeTransition(
opacity: fadeIn,
child: SlideTransition(
position: slideUp,
child: Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(28, 36, 28, 24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(32),
),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(80),
blurRadius: 20,
offset: const Offset(0, -4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: LoginForm(onSubmit: onSubmit),
),
const SizedBox(height: 12),
Center(
child: Text(
'v0.1.0',
style: TextStyle(
fontSize: 11,
color: SomaColors.darkOnSurfaceVariant
.withAlpha(100),
),
),
),
],
),
),
),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,415 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gimnasio_soma/core/services/soma_logger.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
class LogsScreen extends StatefulWidget {
const LogsScreen({super.key});
@override
State<LogsScreen> createState() => _LogsScreenState();
}
class _LogsScreenState extends State<LogsScreen> {
LogLevel? _filter;
String _search = '';
List<LogEntry> get _filteredEntries {
var entries = SomaLogger.instance.entries.reversed.toList();
if (_filter != null) {
entries = entries.where((e) => e.level == _filter).toList();
}
if (_search.isNotEmpty) {
final q = _search.toLowerCase();
entries = entries
.where((e) =>
e.message.toLowerCase().contains(q) ||
e.tag.toLowerCase().contains(q) ||
(e.detail?.toLowerCase().contains(q) ?? false))
.toList();
}
return entries;
}
void _copyAll() {
final text = SomaLogger.instance.export();
Clipboard.setData(ClipboardData(text: text));
SomaToast.show(context,
message: 'Logs copiados al portapapeles', type: ToastType.success);
}
void _clearLogs() {
SomaLogger.instance.clear();
setState(() {});
SomaToast.show(context, message: 'Logs limpiados', type: ToastType.info);
}
@override
Widget build(BuildContext context) {
final isWide = MediaQuery.of(context).size.width >= 800;
final theme = Theme.of(context);
final entries = _filteredEntries;
return Scaffold(
body: Column(
children: [
// Header
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
8,
),
child: Column(
children: [
Row(
children: [
const Text(
'Logs',
style:
TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(25),
borderRadius: BorderRadius.circular(6),
),
child: Text(
SomaLogger.instance.platform,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
),
),
const Spacer(),
IconButton(
onPressed: _copyAll,
icon: const Icon(Icons.copy, size: 20),
tooltip: 'Copiar logs',
),
IconButton(
onPressed: _clearLogs,
icon: const Icon(Icons.delete_outline, size: 20),
tooltip: 'Limpiar logs',
),
IconButton(
onPressed: () => setState(() {}),
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Refrescar',
),
],
),
const SizedBox(height: 8),
// Filtros
Row(
children: [
Expanded(
child: SizedBox(
height: 36,
child: TextField(
onChanged: (v) => setState(() => _search = v),
decoration: InputDecoration(
hintText: 'Buscar...',
prefixIcon:
const Icon(Icons.search, size: 18),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color:
theme.colorScheme.surfaceContainerHighest,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color:
theme.colorScheme.surfaceContainerHighest,
),
),
),
style: const TextStyle(fontSize: 13),
),
),
),
const SizedBox(width: 8),
_FilterChip(
label: 'Todos',
selected: _filter == null,
onTap: () => setState(() => _filter = null),
),
const SizedBox(width: 4),
_FilterChip(
label: 'RPC',
selected: _search == 'RPC',
onTap: () => setState(() {
_search = _search == 'RPC' ? '' : 'RPC';
}),
color: SomaColors.primary,
),
const SizedBox(width: 4),
_FilterChip(
label: 'Error',
selected: _filter == LogLevel.error,
onTap: () => setState(() {
_filter =
_filter == LogLevel.error ? null : LogLevel.error;
}),
color: SomaColors.error,
),
],
),
],
),
),
// Contador
Padding(
padding: EdgeInsets.symmetric(horizontal: isWide ? 32 : 16),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'${entries.length} entradas',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
),
),
const SizedBox(height: 4),
// Lista
Expanded(
child: entries.isEmpty
? Center(
child: Text(
'Sin logs',
style: TextStyle(
fontSize: 14,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
)
: ListView.builder(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 12,
0,
isWide ? 32 : 12,
80,
),
itemCount: entries.length,
itemBuilder: (context, index) {
return _LogEntryTile(entry: entries[index]);
},
),
),
],
),
);
}
}
class _LogEntryTile extends StatelessWidget {
final LogEntry entry;
const _LogEntryTile({required this.entry});
Color _levelColor() {
switch (entry.level) {
case LogLevel.debug:
return Colors.grey;
case LogLevel.info:
return Colors.blue;
case LogLevel.warning:
return Colors.orange;
case LogLevel.error:
return SomaColors.error;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final ts =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:'
'${entry.timestamp.minute.toString().padLeft(2, '0')}:'
'${entry.timestamp.second.toString().padLeft(2, '0')}';
final dur = entry.duration != null
? ' ${entry.duration!.inMilliseconds}ms'
: '';
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: InkWell(
borderRadius: BorderRadius.circular(6),
onTap: entry.detail != null
? () => _showDetail(context)
: null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: entry.level == LogLevel.error
? SomaColors.error.withAlpha(8)
: Colors.transparent,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timestamp
Text(
ts,
style: TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: theme.colorScheme.onSurface.withAlpha(80),
),
),
const SizedBox(width: 6),
// Level badge
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(top: 5),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _levelColor(),
),
),
const SizedBox(width: 6),
// Tag
Text(
'[${entry.tag}]',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
fontFamily: 'monospace',
color: theme.colorScheme.onSurface.withAlpha(140),
),
),
const SizedBox(width: 6),
// Message
Expanded(
child: Text(
entry.message,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: theme.colorScheme.onSurface,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
// Duration
if (dur.isNotEmpty)
Text(
dur,
style: TextStyle(
fontSize: 11,
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
// Detail indicator
if (entry.detail != null)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(
Icons.info_outline,
size: 14,
color: theme.colorScheme.onSurface.withAlpha(60),
),
),
],
),
),
),
);
}
void _showDetail(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
'[${entry.tag}] ${entry.message}',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
content: SingleChildScrollView(
child: SelectableText(
entry.detail ?? '',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
),
),
actions: [
TextButton(
onPressed: () {
Clipboard.setData(
ClipboardData(text: '${entry.formatted}\n${entry.detail}'));
Navigator.of(ctx).pop();
},
child: const Text('Copiar'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cerrar'),
),
],
),
);
}
}
class _FilterChip extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
final Color? color;
const _FilterChip({
required this.label,
required this.selected,
required this.onTap,
this.color,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final c = color ?? SomaColors.primary;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: selected ? c.withAlpha(25) : Colors.transparent,
border: Border.all(
color: selected ? c : theme.colorScheme.surfaceContainerHighest,
width: 0.8,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected ? c : theme.colorScheme.onSurface.withAlpha(130),
),
),
),
);
}
}
@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/widgets/soma_logo.dart';
import 'package:gimnasio_soma/core/widgets/soma_primary_button.dart';
import 'package:gimnasio_soma/core/widgets/soma_secondary_button.dart';
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
class WidgetGalleryScreen extends ConsumerStatefulWidget {
const WidgetGalleryScreen({super.key});
@override
ConsumerState<WidgetGalleryScreen> createState() =>
_WidgetGalleryScreenState();
}
class _WidgetGalleryScreenState extends ConsumerState<WidgetGalleryScreen> {
final _textController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true;
@override
void dispose() {
_textController.dispose();
_passwordController.dispose();
super.dispose();
}
void _simulateLoading() async {
setState(() => _isLoading = true);
await Future.delayed(const Duration(seconds: 2));
if (mounted) setState(() => _isLoading = false);
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authStateProvider);
final user = authState.valueOrNull;
return Scaffold(
appBar: AppBar(
title: const Text('Widget Gallery'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => ref.read(authStateProvider.notifier).logout(),
tooltip: 'Cerrar sesión',
),
],
),
body: ListView(
padding: const EdgeInsets.all(24),
children: [
// User Info
if (user != null) ...[
_SectionTitle('Usuario Logueado'),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Nombre: ${user.displayName}',
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 8),
Text(
'DNI: ${user.dni}',
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 8),
Text(
'Rol: ${user.role}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
),
const SizedBox(height: 32),
],
// Logo
_SectionTitle('Logo'),
const Center(child: SomaLogo(width: 200)),
const SizedBox(height: 32),
// Primary Buttons
_SectionTitle('Botones Primarios'),
SomaPrimaryButton(
text: 'Botón Normal',
onPressed: () => SomaToast.show(
context,
message: 'Botón presionado',
type: ToastType.info,
),
),
const SizedBox(height: 12),
SomaPrimaryButton(
text: 'Con Ícono',
icon: Icons.check,
onPressed: () => SomaToast.show(
context,
message: 'Botón con ícono presionado',
type: ToastType.success,
),
),
const SizedBox(height: 12),
SomaPrimaryButton(
text: 'Loading...',
isLoading: _isLoading,
onPressed: _simulateLoading,
),
const SizedBox(height: 12),
const SomaPrimaryButton(text: 'Deshabilitado', onPressed: null),
const SizedBox(height: 32),
// Secondary Buttons
_SectionTitle('Botones Secundarios'),
SomaSecondaryButton(
text: 'Botón Outline',
onPressed: () => SomaToast.show(
context,
message: 'Botón secundario presionado',
type: ToastType.info,
),
),
const SizedBox(height: 12),
SomaSecondaryButton(
text: 'Con Ícono',
icon: Icons.edit,
onPressed: () {},
),
const SizedBox(height: 12),
SomaSecondaryButton(
text: 'Loading...',
isLoading: _isLoading,
onPressed: _simulateLoading,
),
const SizedBox(height: 12),
const SomaSecondaryButton(text: 'Deshabilitado', onPressed: null),
const SizedBox(height: 32),
// Text Fields
_SectionTitle('Campos de Texto'),
SomaTextField(
controller: _textController,
hintText: 'Ingresá tu nombre',
labelText: 'Nombre',
prefixIcon: Icons.person,
),
const SizedBox(height: 16),
SomaTextField(
controller: _passwordController,
hintText: 'Ingresá tu contraseña',
labelText: 'Contraseña',
prefixIcon: Icons.lock,
obscureText: _obscurePassword,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(() => _obscurePassword = !_obscurePassword);
},
),
),
const SizedBox(height: 16),
const SomaTextField(
hintText: 'Campo deshabilitado',
labelText: 'Deshabilitado',
prefixIcon: Icons.block,
enabled: false,
),
const SizedBox(height: 32),
// Toasts
_SectionTitle('Notificaciones (Toasts)'),
ElevatedButton(
onPressed: () => SomaToast.show(
context,
message: 'Operación exitosa',
type: ToastType.success,
),
child: const Text('Toast Success'),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => SomaToast.show(
context,
message: 'Error en la operación',
type: ToastType.error,
),
child: const Text('Toast Error'),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => SomaToast.show(
context,
message: 'Información importante',
type: ToastType.info,
),
child: const Text('Toast Info'),
),
const SizedBox(height: 32),
// Colors
_SectionTitle('Paleta de Colores'),
Row(
children: [
Expanded(
child: _ColorBox(
color: Theme.of(context).colorScheme.primary,
label: 'Primary',
),
),
const SizedBox(width: 12),
Expanded(
child: _ColorBox(
color: Theme.of(context).colorScheme.surface,
label: 'Surface',
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _ColorBox(
color: Theme.of(context).colorScheme.error,
label: 'Error',
),
),
const SizedBox(width: 12),
Expanded(
child: _ColorBox(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
label: 'Surface Variant',
),
),
],
),
const SizedBox(height: 48),
],
),
);
}
}
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle(this.title);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
title,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
);
}
}
class _ColorBox extends StatelessWidget {
final Color color;
final String label;
const _ColorBox({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Container(
height: 80,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.onSurface.withAlpha(51),
),
),
child: Center(
child: Text(
label,
style: TextStyle(
color: _getContrastColor(color),
fontWeight: FontWeight.w600,
),
),
),
);
}
Color _getContrastColor(Color background) {
final luminance = background.computeLuminance();
return luminance > 0.5 ? Colors.black87 : Colors.white;
}
}
@@ -0,0 +1,175 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/config/supabase_config.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart';
import 'package:gimnasio_soma/features/horarios/domain/repositories/horarios_repository.dart';
class HorariosRepositoryImpl implements HorariosRepository {
Future<String> _getToken() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token == null) throw Exception('Sin sesión activa');
return token;
}
String _formatDate(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
@override
Future<SemanaHorarios> obtenerSemana(DateTime weekStart) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerHorarios,
params: {
'p_token': token,
'p_fecha_inicio': _formatDate(weekStart),
'p_cantidad_dias': 7,
},
);
if (response is Map<String, dynamic>) {
return SemanaHorarios.fromResponse(weekStart, response);
}
return SemanaHorarios.fromResponse(weekStart, {});
}
@override
Future<void> guardarDia({
required DateTime fecha,
required bool esEspecial,
String? motivo,
required List<Map<String, dynamic>> bloques,
DateTime? validoDesde,
Alcance? alcance,
}) async {
final token = await _getToken();
final datos = <String, dynamic>{
'fecha': _formatDate(fecha),
'es_especial': esEspecial,
'rangos': bloques,
};
if (esEspecial && motivo != null && motivo.isNotEmpty) {
datos['motivo'] = motivo;
}
if (!esEspecial) {
if (validoDesde != null) {
datos['valido_desde'] = _formatDate(validoDesde);
}
if (alcance != null) {
datos['alcance'] = alcance.toJson();
}
}
await SupabaseConfig.rpc(
AppConstants.rpcInsertHorarioConActividades,
params: {
'p_token': token,
'p_datos': datos,
},
);
}
@override
Future<void> eliminarDiaEspecial(DateTime fecha) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcEliminarDiaEspecial,
params: {
'p_token': token,
'p_fecha': _formatDate(fecha),
},
);
}
@override
Future<List<DiaEspecialResumen>> listarDiasEspeciales({int dias = 90}) async {
final token = await _getToken();
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
// fc_obtener_horarios acepta p_cantidad_dias entre 1 y 31, así que partimos
// el rango pedido en chunks de hasta 31 días y disparamos las llamadas en
// paralelo.
const chunkSize = 31;
final futures = <Future<dynamic>>[];
for (var offset = 0; offset < dias; offset += chunkSize) {
final restantes = dias - offset;
final tamano = restantes < chunkSize ? restantes : chunkSize;
futures.add(
SupabaseConfig.rpc(
AppConstants.rpcObtenerHorarios,
params: {
'p_token': token,
'p_fecha_inicio': _formatDate(hoy.add(Duration(days: offset))),
'p_cantidad_dias': tamano,
},
),
);
}
final responses = await Future.wait(futures);
final resumen = <DiaEspecialResumen>[];
for (final response in responses) {
if (response is! Map<String, dynamic>) continue;
for (final entry in response.entries) {
final dayMap = entry.value;
if (dayMap is! Map<String, dynamic>) continue;
final tipo = dayMap['tipo'] as String?;
if (tipo == null || tipo == 'normal') continue;
final fecha = DateTime.tryParse(entry.key);
if (fecha == null) continue;
resumen.add(DiaEspecialResumen.fromHorariosResponse(fecha, dayMap));
}
}
resumen.sort((a, b) => a.fecha.compareTo(b.fecha));
return resumen;
}
@override
Future<List<PlanificacionFutura>> futurosParaDiaSemana({
required int diaSemana,
required DateTime desde,
int meses = 6,
}) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerPlanificacionesFuturas,
params: {
'p_token': token,
'p_dia_semana': diaSemana,
'p_desde': _formatDate(desde),
'p_meses': meses,
},
);
if (response is List) {
return response
.map((e) => PlanificacionFutura.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<int> contarHuerfanasDesde(DateTime instante) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerReservasHuerfanas,
params: {
'p_token': token,
'p_creada_desde': instante.toIso8601String(),
},
);
if (response is List) return response.length;
return 0;
}
}
@@ -0,0 +1,44 @@
/// Cómo interactúa una edición de plantilla regular con planificaciones
/// futuras existentes para el mismo día de la semana.
///
/// El backend define tres variantes (ver §5.3 del brief de horarios):
/// * `indefinido` → borra las planificaciones futuras posteriores y deja el
/// nuevo horario sin fecha de fin.
/// * `hasta_proximo` (default backend) → respeta la próxima planificación
/// futura, cerrando el nuevo horario justo antes.
/// * `hasta` + fecha → cierra el nuevo horario en una fecha específica. Si
/// hay planificaciones futuras dentro del intervalo, el
/// backend rechaza con `ConflictoAlcance`.
sealed class Alcance {
const Alcance();
/// Serialización aceptada por `fc_insertar_horario_con_actividades`.
Map<String, dynamic> toJson();
}
class AlcanceIndefinido extends Alcance {
const AlcanceIndefinido();
@override
Map<String, dynamic> toJson() => const {'tipo': 'indefinido'};
}
class AlcanceHastaProximo extends Alcance {
const AlcanceHastaProximo();
@override
Map<String, dynamic> toJson() => const {'tipo': 'hasta_proximo'};
}
class AlcanceHasta extends Alcance {
final DateTime fecha;
const AlcanceHasta(this.fecha);
@override
Map<String, dynamic> toJson() {
final f = '${fecha.year}-${fecha.month.toString().padLeft(2, '0')}-'
'${fecha.day.toString().padLeft(2, '0')}';
return {'tipo': 'hasta', 'fecha': f};
}
}
@@ -0,0 +1,63 @@
class BloqueActividadEspecial {
final int id;
final String horaInicio;
final String horaFin;
final int actividadId;
final String actividadNombre;
final int actividadDuracion;
const BloqueActividadEspecial({
required this.id,
required this.horaInicio,
required this.horaFin,
required this.actividadId,
required this.actividadNombre,
required this.actividadDuracion,
});
factory BloqueActividadEspecial.fromMap(Map<String, dynamic> m) {
final act = m['actividad'] as Map<String, dynamic>;
return BloqueActividadEspecial(
id: m['id'] as int,
horaInicio: m['hora_inicio'] as String,
horaFin: m['hora_fin'] as String,
actividadId: act['id'] as int,
actividadNombre: act['nombre'] as String,
actividadDuracion: act['duracion'] as int,
);
}
}
class DiaEspecialResumen {
final DateTime fecha;
final String tipo; // 'cerrado' | 'horario_diferente'
final String? motivo;
final List<BloqueActividadEspecial> rangos;
const DiaEspecialResumen({
required this.fecha,
required this.tipo,
this.motivo,
required this.rangos,
});
bool get esCerrado => tipo == 'cerrado';
/// Construye un resumen a partir del item de día devuelto por
/// `fc_obtener_horarios` (donde la fecha viene como clave del objeto raíz y
/// el valor trae `tipo`, `motivo`, `horarios`).
factory DiaEspecialResumen.fromHorariosResponse(
DateTime fecha,
Map<String, dynamic> m,
) {
final rawHorarios = m['horarios'] as List<dynamic>? ?? [];
return DiaEspecialResumen(
fecha: fecha,
tipo: m['tipo'] as String,
motivo: m['motivo'] as String?,
rangos: rawHorarios
.map((e) => BloqueActividadEspecial.fromMap(e as Map<String, dynamic>))
.toList(),
);
}
}
@@ -0,0 +1,127 @@
import 'package:supabase_flutter/supabase_flutter.dart';
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
String _fmt(DateTime d) => '${d.day} ${_mesesCortos[d.month]} ${d.year}';
/// Errores tipados que el módulo de horarios produce al consumir la API de
/// backend. El traductor [fromException] mapea mensajes conocidos de las
/// funciones PL/pgSQL a una variante específica; mensajes no reconocidos
/// caen en [Desconocido] conservando el texto original.
sealed class HorarioError {
const HorarioError();
/// Texto en español listo para mostrar al usuario. Distinto del raw del
/// backend: explica el problema y, donde aplica, sugiere la acción.
String mensajeUsuario();
static final RegExp _conflictoRegex = RegExp(
r'Conflicto: existe una planificación con valido_desde = '
r'(\d{4}-\d{2}-\d{2}) dentro del intervalo '
r'\[(\d{4}-\d{2}-\d{2}), (\d{4}-\d{2}-\d{2})\]',
);
static final RegExp _alcanceFechaRegex = RegExp(
r'alcance\.fecha \((\d{4}-\d{2}-\d{2})\) no puede ser anterior a '
r'valido_desde \((\d{4}-\d{2}-\d{2})\)',
);
factory HorarioError.fromException(Object e) {
final raw = e is PostgrestException
? e.message
: e.toString().replaceFirst('Exception: ', '');
final mConflicto = _conflictoRegex.firstMatch(raw);
if (mConflicto != null) {
final vd = DateTime.tryParse(mConflicto.group(1)!);
final id = DateTime.tryParse(mConflicto.group(2)!);
final ih = DateTime.tryParse(mConflicto.group(3)!);
if (vd != null && id != null && ih != null) {
return ConflictoAlcance(
validoDesdeConflicto: vd,
intervaloDesde: id,
intervaloHasta: ih,
);
}
}
final mAlcance = _alcanceFechaRegex.firstMatch(raw);
if (mAlcance != null) {
final af = DateTime.tryParse(mAlcance.group(1)!);
final vd = DateTime.tryParse(mAlcance.group(2)!);
if (af != null && vd != null) {
return AlcanceFechaAnterior(alcanceFecha: af, validoDesde: vd);
}
}
if (raw.contains('valido_desde no puede ser una fecha pasada')) {
return const FechaPasada();
}
if (raw.contains('No se pueden alterar horarios en fechas pasadas')) {
return const FechaPasadaEspecial();
}
return Desconocido(raw);
}
}
class ConflictoAlcance extends HorarioError {
final DateTime validoDesdeConflicto;
final DateTime intervaloDesde;
final DateTime intervaloHasta;
const ConflictoAlcance({
required this.validoDesdeConflicto,
required this.intervaloDesde,
required this.intervaloHasta,
});
@override
String mensajeUsuario() =>
'Ya hay un horario planificado para el ${_fmt(validoDesdeConflicto)}, '
'que cae dentro del rango elegido (${_fmt(intervaloDesde)}'
'${_fmt(intervaloHasta)}). Cambiá el alcance o eliminá esa '
'planificación antes de continuar.';
}
class FechaPasada extends HorarioError {
const FechaPasada();
@override
String mensajeUsuario() =>
'La fecha de vigencia no puede ser anterior a hoy.';
}
class AlcanceFechaAnterior extends HorarioError {
final DateTime alcanceFecha;
final DateTime validoDesde;
const AlcanceFechaAnterior({
required this.alcanceFecha,
required this.validoDesde,
});
@override
String mensajeUsuario() =>
'La fecha de fin (${_fmt(alcanceFecha)}) no puede ser anterior al '
'inicio de vigencia (${_fmt(validoDesde)}).';
}
class FechaPasadaEspecial extends HorarioError {
const FechaPasadaEspecial();
@override
String mensajeUsuario() =>
'No se pueden modificar horarios en fechas pasadas.';
}
class Desconocido extends HorarioError {
final String raw;
const Desconocido(this.raw);
@override
String mensajeUsuario() => raw;
}
@@ -0,0 +1,131 @@
class BloqueActividadInfo {
final int id;
final String nombre;
final int duracion;
final int capacidad;
const BloqueActividadInfo({
required this.id,
required this.nombre,
required this.duracion,
required this.capacidad,
});
factory BloqueActividadInfo.fromMap(Map<String, dynamic> m) {
return BloqueActividadInfo(
id: m['id'] as int,
nombre: m['nombre'] as String,
duracion: m['duracion'] as int,
capacidad: m['capacidad'] as int,
);
}
}
class BloqueHorario {
final int id;
final String horaInicio;
final String horaFin;
final BloqueActividadInfo actividad;
const BloqueHorario({
required this.id,
required this.horaInicio,
required this.horaFin,
required this.actividad,
});
factory BloqueHorario.fromMap(Map<String, dynamic> m) {
return BloqueHorario(
id: m['id'] as int,
horaInicio: m['hora_inicio'] as String,
horaFin: m['hora_fin'] as String,
actividad:
BloqueActividadInfo.fromMap(m['actividad'] as Map<String, dynamic>),
);
}
}
enum TipoDia { normal, horarioDiferente, cerrado }
class DiaHorarios {
final DateTime fecha;
final int diaSemana;
final TipoDia tipo;
final String? motivo;
final List<BloqueHorario> bloques;
/// Solo presente para días `normal`: cuándo entró a regir la plantilla.
final DateTime? validoDesde;
/// Solo presente para días `normal`: cuándo deja de regir la plantilla
/// (`null` = vigencia indefinida).
final DateTime? validoHasta;
const DiaHorarios({
required this.fecha,
required this.diaSemana,
required this.tipo,
this.motivo,
required this.bloques,
this.validoDesde,
this.validoHasta,
});
bool get esCerrado => tipo == TipoDia.cerrado;
bool get esEspecial => tipo != TipoDia.normal;
factory DiaHorarios.fromMap(DateTime fecha, Map<String, dynamic> m) {
final tipoStr = m['tipo'] as String;
final tipo = switch (tipoStr) {
'cerrado' => TipoDia.cerrado,
'horario_diferente' => TipoDia.horarioDiferente,
_ => TipoDia.normal,
};
final rawBloques = m['horarios'] as List<dynamic>? ?? [];
final validoDesdeStr = m['valido_desde'] as String?;
final validoHastaStr = m['valido_hasta'] as String?;
return DiaHorarios(
fecha: fecha,
diaSemana: m['dia_semana'] as int,
tipo: tipo,
motivo: m['motivo'] as String?,
bloques: rawBloques
.map((e) => BloqueHorario.fromMap(e as Map<String, dynamic>))
.toList(),
validoDesde:
validoDesdeStr != null ? DateTime.tryParse(validoDesdeStr) : null,
validoHasta:
validoHastaStr != null ? DateTime.tryParse(validoHastaStr) : null,
);
}
}
class SemanaHorarios {
final DateTime weekStart;
final Map<String, DiaHorarios> _byKey;
SemanaHorarios({required this.weekStart, required Map<String, DiaHorarios> byKey})
: _byKey = byKey;
factory SemanaHorarios.fromResponse(DateTime weekStart, Map<String, dynamic> raw) {
final byKey = <String, DiaHorarios>{};
for (final entry in raw.entries) {
final fecha = DateTime.tryParse(entry.key);
if (fecha == null) continue;
final diaMap = entry.value;
if (diaMap is Map<String, dynamic>) {
byKey[entry.key] = DiaHorarios.fromMap(fecha, diaMap);
}
}
return SemanaHorarios(weekStart: weekStart, byKey: byKey);
}
String _key(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
DiaHorarios? diaPara(DateTime fecha) => _byKey[_key(fecha)];
List<DiaHorarios> get dias => _byKey.values.toList()
..sort((a, b) => a.fecha.compareTo(b.fecha));
bool contieneFecha(DateTime fecha) => _byKey.containsKey(_key(fecha));
}
@@ -0,0 +1,27 @@
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
/// Plantilla regular futura para un día de la semana, tal como la devuelve
/// `fc_obtener_planificaciones_futuras`. Sólo se incluyen plantillas cuyo
/// `valido_desde` es estrictamente posterior al `p_desde` consultado.
class PlanificacionFutura {
final DateTime validoDesde;
final DateTime? validoHasta;
final List<BloqueHorario> rangos;
const PlanificacionFutura({
required this.validoDesde,
this.validoHasta,
required this.rangos,
});
factory PlanificacionFutura.fromMap(Map<String, dynamic> m) {
final vh = m['valido_hasta'] as String?;
return PlanificacionFutura(
validoDesde: DateTime.parse(m['valido_desde'] as String),
validoHasta: vh != null ? DateTime.tryParse(vh) : null,
rangos: (m['rangos'] as List<dynamic>? ?? [])
.map((e) => BloqueHorario.fromMap(e as Map<String, dynamic>))
.toList(),
);
}
}
@@ -0,0 +1,48 @@
import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart';
abstract class HorariosRepository {
/// Obtener horarios de una semana (7 días desde [weekStart]).
Future<SemanaHorarios> obtenerSemana(DateTime weekStart);
/// Guardar horario para un día (PUT: reemplaza todos los bloques).
/// [esEspecial]=false → SCD upsert en horario regular.
/// [esEspecial]=true + bloques vacíos → marca el día como cerrado.
/// [esEspecial]=true + bloques → crea horario_diferente.
///
/// [validoDesde] y [alcance] **sólo aplican a día regular**; el backend
/// los ignora cuando [esEspecial] es true. Si vienen `null`, no se mandan
/// y el backend usa sus defaults (`valido_desde = hoy`,
/// `alcance = hasta_proximo`).
Future<void> guardarDia({
required DateTime fecha,
required bool esEspecial,
String? motivo,
required List<Map<String, dynamic>> bloques,
DateTime? validoDesde,
Alcance? alcance,
});
/// Eliminar día especial (restaura el horario normal para esa fecha).
Future<void> eliminarDiaEspecial(DateTime fecha);
/// Listar los días especiales programados en una ventana hacia adelante.
/// [dias] = cantidad de días a inspeccionar desde hoy (default 90).
Future<List<DiaEspecialResumen>> listarDiasEspeciales({int dias = 90});
/// Plantillas regulares con `valido_desde` estrictamente posterior a [desde]
/// para el [diaSemana] dado (1=lunes ... 7=domingo, ISODOW). Ventana de
/// inspección controlada por [meses] (default 6, máx 24).
Future<List<PlanificacionFutura>> futurosParaDiaSemana({
required int diaSemana,
required DateTime desde,
int meses = 6,
});
/// Cuenta las reservas huérfanas generadas desde [instante] (UTC).
/// Usado para informar al admin cuántas reservas quedaron sin turno
/// tras una operación de escritura en horarios.
Future<int> contarHuerfanasDesde(DateTime instante);
}
@@ -0,0 +1,177 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/features/horarios/data/repositories/horarios_repository_impl.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_error.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/domain/repositories/horarios_repository.dart';
final horariosRepositoryProvider = Provider<HorariosRepository>((ref) {
return HorariosRepositoryImpl();
});
/// Semana actual de horarios.
final horariosProvider =
StateNotifierProvider<HorariosNotifier, AsyncValue<SemanaHorarios?>>((ref) {
return HorariosNotifier(ref, ref.read(horariosRepositoryProvider));
});
class HorariosNotifier extends StateNotifier<AsyncValue<SemanaHorarios?>> {
final Ref _ref;
final HorariosRepository _repository;
DateTime? _currentWeekStart;
HorariosNotifier(this._ref, this._repository)
: super(const AsyncValue.data(null));
/// Refresca las vistas que dependen de los mismos datos que la semana pero
/// que viven en otros providers: la lista de días especiales (puntos
/// naranjas del calendario + pestaña "Especiales") y el mapa de cambios de
/// plantilla a futuro (puntos azules). Se llama tras cada escritura para que
/// ninguna vista quede desincronizada.
///
/// Usa `invalidate` y no `load()` a propósito: si se hacen varias escrituras
/// seguidas —p. ej. copiar un día a varios destinos— las invalidaciones se
/// fusionan en una sola recarga por vista en vez de una por escritura.
void _refrescarDerivados() {
_ref.invalidate(diasEspecialesProvider);
_ref.invalidate(diasCambioProvider);
}
Future<void> cargarSemana(DateTime weekStart, {bool force = false}) async {
final monday = _toMonday(weekStart);
if (!force && _currentWeekStart != null && _currentWeekStart == monday) {
return;
}
_currentWeekStart = monday;
state = const AsyncValue.loading();
try {
final data = await _repository.obtenerSemana(monday);
state = AsyncValue.data(data);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<void> refrescar() async {
if (_currentWeekStart == null) return;
await cargarSemana(_currentWeekStart!, force: true);
}
/// Guarda el horario de un día y refresca la semana.
///
/// Retorna `(null, N)` si tuvo éxito, donde N es la cantidad de reservas
/// que quedaron huérfanas a raíz de la operación (0 = ninguna).
/// Retorna `(HorarioError, 0)` si hubo error.
///
/// [validoDesde] y [alcance] sólo aplican a día regular; el backend los
/// ignora cuando [esEspecial] es true. Si vienen null se usan los defaults
/// del backend (hoy, `hasta_proximo`).
Future<(HorarioError?, int)> guardarDia({
required DateTime fecha,
required bool esEspecial,
String? motivo,
required List<Map<String, dynamic>> bloques,
DateTime? validoDesde,
Alcance? alcance,
}) async {
final preOp = DateTime.now().toUtc();
try {
await _repository.guardarDia(
fecha: fecha,
esEspecial: esEspecial,
motivo: motivo,
bloques: bloques,
validoDesde: validoDesde,
alcance: alcance,
);
await refrescar();
_refrescarDerivados();
final n = await _repository.contarHuerfanasDesde(preOp);
return (null, n);
} catch (e) {
return (HorarioError.fromException(e), 0);
}
}
/// Elimina la excepción de un día especial y refresca.
///
/// Retorna `(null, N)` si tuvo éxito (N = huérfanas generadas),
/// o `(HorarioError, 0)` si hubo error.
Future<(HorarioError?, int)> eliminarDiaEspecial(DateTime fecha) async {
final preOp = DateTime.now().toUtc();
try {
await _repository.eliminarDiaEspecial(fecha);
await refrescar();
_refrescarDerivados();
final n = await _repository.contarHuerfanasDesde(preOp);
return (null, n);
} catch (e) {
return (HorarioError.fromException(e), 0);
}
}
DateTime _toMonday(DateTime d) =>
d.subtract(Duration(days: d.weekday - 1));
}
/// Lista de días especiales para la vista auxiliar.
final diasEspecialesProvider =
StateNotifierProvider<DiasEspecialesNotifier, AsyncValue<List<DiaEspecialResumen>>>(
(ref) {
return DiasEspecialesNotifier(ref.read(horariosRepositoryProvider));
});
class DiasEspecialesNotifier
extends StateNotifier<AsyncValue<List<DiaEspecialResumen>>> {
final HorariosRepository _repository;
DiasEspecialesNotifier(this._repository) : super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
try {
final data = await _repository.listarDiasEspeciales();
data.sort((a, b) => a.fecha.compareTo(b.fecha));
state = AsyncValue.data(data);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
}
/// Días (normalizados a medianoche) en los que arranca una nueva plantilla
/// regular dentro de los próximos 12 meses. Alimenta los puntos azules del
/// calendario.
///
/// Es un [FutureProvider] para que el panel lo lea de forma perezosa (sólo al
/// abrirse) y para que [HorariosNotifier] pueda invalidarlo tras cada
/// escritura, manteniéndolo en sync con el resto de las vistas.
final diasCambioProvider = FutureProvider<Set<DateTime>>((ref) async {
final repo = ref.watch(horariosRepositoryProvider);
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
final results = await Future.wait(
List.generate(
7,
(i) => repo.futurosParaDiaSemana(
diaSemana: i + 1,
desde: hoy,
meses: 12,
),
),
);
final cambios = <DateTime>{};
for (final list in results) {
for (final p in list) {
cambios.add(DateTime(
p.validoDesde.year,
p.validoDesde.month,
p.validoDesde.day,
));
}
}
return cambios;
});
@@ -0,0 +1,597 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_header_help.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/dias_especiales_view.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/copiar_dia_dialog.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/horarios_calendar_panel.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/editar_dia_dialog.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/semana_tabla_view.dart';
enum _HorariosTab { semanal, especiales }
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
const _meses = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
class HorariosScreen extends ConsumerStatefulWidget {
const HorariosScreen({super.key});
@override
ConsumerState<HorariosScreen> createState() => _HorariosScreenState();
}
class _HorariosScreenState extends ConsumerState<HorariosScreen> {
_HorariosTab _currentTab = _HorariosTab.semanal;
late DateTime _weekStart;
late DateTime _selectedDay;
List<int> _diasVisibles = [0, 1, 2, 3, 4];
@override
void initState() {
super.initState();
final today = DateTime.now();
_weekStart = _toMonday(today);
_selectedDay = today;
WidgetsBinding.instance.addPostFrameCallback((_) => _cargarSemana());
}
DateTime _toMonday(DateTime d) => d.subtract(Duration(days: d.weekday - 1));
String _fmtShort(DateTime d) => '${d.day} ${_meses[d.month]}';
String _weekLabel() {
final end = _weekStart.add(const Duration(days: 6));
return '${_fmtShort(_weekStart)} ${_fmtShort(end)} ${end.year}';
}
void _cargarSemana() {
ref.read(horariosProvider.notifier).cargarSemana(_weekStart);
}
void _prevWeek() {
setState(() {
_weekStart = _weekStart.subtract(const Duration(days: 7));
_selectedDay = _weekStart;
});
_cargarSemana();
}
void _nextWeek() {
setState(() {
_weekStart = _weekStart.add(const Duration(days: 7));
_selectedDay = _weekStart;
});
_cargarSemana();
}
bool get _isAdmin {
final user = ref.read(authStateProvider).valueOrNull;
return user != null && user.isStaff;
}
Future<void> _editarDia(DateTime fecha, DiaHorarios? dia) async {
final huerfanas = await showDialog<int>(
context: context,
builder: (_) => EditarDiaDialog(
dia: dia,
fecha: fecha,
weekStart: _weekStart,
),
);
if (!mounted) return;
final n = huerfanas ?? 0;
if (n > 0) _showHuerfanasToast(n);
}
void _showHuerfanasToast(int n) {
SomaToast.show(
context,
message: '$n ${n == 1 ? 'reserva quedó huérfana' : 'reservas quedaron huérfanas'}',
type: ToastType.info,
action: SnackBarAction(
label: 'Ver',
textColor: SomaColors.onPrimary,
onPressed: () => context.go('/huerfanas'),
),
);
}
Future<void> _eliminarBloque(
DiaHorarios dia, BloqueHorario bloque) async {
final seen = <String>{};
final restantes = <Map<String, dynamic>>[];
for (final b in dia.bloques) {
if (b.id == bloque.id) continue;
final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}';
if (!seen.add(key)) continue;
restantes.add({
'actividad_id': b.actividad.id,
'hora_inicio': b.horaInicio,
'hora_fin': b.horaFin,
});
}
final (error, huerfanas) = await ref.read(horariosProvider.notifier).guardarDia(
fecha: dia.fecha,
esEspecial: dia.esEspecial,
motivo: dia.motivo,
bloques: restantes,
);
if (!mounted) return;
if (error != null) {
SomaToast.show(
context,
message: error.mensajeUsuario(),
type: ToastType.error,
);
} else if (huerfanas > 0) {
_showHuerfanasToast(huerfanas);
}
}
void _navigateToWeek(DateTime fecha) {
setState(() {
_weekStart = _toMonday(fecha);
_selectedDay = fecha;
_currentTab = _HorariosTab.semanal;
});
_cargarSemana();
}
Future<void> _copiarDia(DiaHorarios origen, SemanaHorarios semana) async {
final huerfanas = await showDialog<int>(
context: context,
builder: (_) => CopiarDiaDialog(
origen: origen,
weekStart: _weekStart,
semana: semana,
),
);
if (!mounted) return;
if ((huerfanas ?? 0) > 0) _showHuerfanasToast(huerfanas!);
}
@override
Widget build(BuildContext context) {
final isWide = MediaQuery.of(context).size.width >= 800;
final isAdmin = _isAdmin;
return Scaffold(
body: Column(
children: [
// Header
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
0,
),
child: Row(
children: [
const Text(
'Horarios',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
SomaHeaderHelp(
items: [
if (isAdmin)
const SomaHelpItem(
icon: Icons.calendar_view_week_outlined,
text: 'Semanal / Especiales: cambiá entre la grilla '
'semanal y los días especiales (feriados, eventos).',
),
const SomaHelpItem(
icon: Icons.chevron_left,
text: 'Las flechas navegan entre semanas.',
),
const SomaHelpItem(
icon: Icons.tune,
text: 'Días visibles: elegí qué días de la semana se '
'muestran en la tabla.',
),
if (isAdmin)
const SomaHelpItem(
icon: Icons.edit_outlined,
text: 'Editar día: modificá los horarios y '
'actividades del día seleccionado.',
),
if (isAdmin)
const SomaHelpItem(
icon: Icons.copy_outlined,
text: 'Copiar a...: replica los bloques del día '
'seleccionado a otros días.',
),
const SomaHelpItem(
icon: Icons.refresh,
text: 'Recarga los horarios de la semana actual.',
),
],
),
const Spacer(),
if (isAdmin) ...[
_ViewToggle(
currentTab: _currentTab,
onChanged: (tab) => setState(() => _currentTab = tab),
),
const SizedBox(width: 8),
],
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Recargar',
onPressed: () {
if (_currentTab == _HorariosTab.semanal) {
ref.read(horariosProvider.notifier).refrescar();
} else {
ref.read(diasEspecialesProvider.notifier).load();
}
},
),
],
),
),
// Content
Expanded(
child: Stack(
children: [
_currentTab == _HorariosTab.especiales && isAdmin
? const DiasEspecialesView()
: _SemanalView(
weekStart: _weekStart,
selectedDay: _selectedDay,
isAdmin: isAdmin,
isWide: isWide,
weekLabel: _weekLabel(),
onPrevWeek: _prevWeek,
onNextWeek: _nextWeek,
onDaySelected: (d) => setState(() => _selectedDay = d),
onEditarDia: _editarDia,
onCopiarDia: _copiarDia,
onEliminarBloque: isAdmin ? _eliminarBloque : null,
diasVisibles: _diasVisibles,
onDiasVisiblesChanged: (v) =>
setState(() => _diasVisibles = v),
),
Positioned(
right: 0,
top: 0,
bottom: 0,
child: HorariosCalendarPanel(
onNavigateToWeek: _navigateToWeek,
),
),
],
),
),
],
),
);
}
}
// ── View Toggle ────────────────────────────────────────────────────────────────
class _ViewToggle extends StatelessWidget {
final _HorariosTab currentTab;
final ValueChanged<_HorariosTab> onChanged;
const _ViewToggle({required this.currentTab, required this.onChanged});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: theme.inputDecorationTheme.fillColor,
),
padding: const EdgeInsets.all(2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ToggleItem(
label: 'Semanal',
selected: currentTab == _HorariosTab.semanal,
onTap: () => onChanged(_HorariosTab.semanal),
),
_ToggleItem(
label: 'Especiales',
selected: currentTab == _HorariosTab.especiales,
onTap: () => onChanged(_HorariosTab.especiales),
),
],
),
);
}
}
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 InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: selected ? theme.colorScheme.surface : Colors.transparent,
boxShadow: selected
? [
BoxShadow(
color: Colors.black.withAlpha(15),
blurRadius: 2,
offset: const Offset(0, 1),
),
]
: null,
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withAlpha(130),
),
),
),
);
}
}
// ── Vista Semanal ──────────────────────────────────────────────────────────────
class _SemanalView extends ConsumerWidget {
final DateTime weekStart;
final DateTime selectedDay;
final bool isAdmin;
final bool isWide;
final String weekLabel;
final VoidCallback onPrevWeek;
final VoidCallback onNextWeek;
final ValueChanged<DateTime> onDaySelected;
final Future<void> Function(DateTime, DiaHorarios?) onEditarDia;
final Future<void> Function(DiaHorarios, SemanaHorarios) onCopiarDia;
final Future<void> Function(DiaHorarios, BloqueHorario)? onEliminarBloque;
final List<int> diasVisibles;
final ValueChanged<List<int>> onDiasVisiblesChanged;
const _SemanalView({
required this.weekStart,
required this.selectedDay,
required this.isAdmin,
required this.isWide,
required this.weekLabel,
required this.onPrevWeek,
required this.onNextWeek,
required this.onDaySelected,
required this.onEditarDia,
required this.onCopiarDia,
this.onEliminarBloque,
required this.diasVisibles,
required this.onDiasVisiblesChanged,
});
void _openDiasConfig(BuildContext context) {
var local = List<int>.from(diasVisibles);
showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setLocal) => AlertDialog(
title: const Text('Días visibles'),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
content: SizedBox(
width: 260,
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(7, (i) {
final checked = local.contains(i);
return CheckboxListTile(
title: Text(_diasSemana[i]),
value: checked,
activeColor: SomaColors.primary,
checkColor: SomaColors.onPrimary,
onChanged: (local.length == 1 && checked)
? null
: (val) {
setLocal(() {
if (val == true) {
local = ([...local, i])..sort();
} else {
local =
local.where((d) => d != i).toList();
}
});
onDiasVisiblesChanged(local);
},
);
}),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Listo'),
),
],
),
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(horariosProvider);
final theme = Theme.of(context);
final hPad = isWide ? 32.0 : 16.0;
return Column(
children: [
// Week navigation
Padding(
padding: EdgeInsets.symmetric(horizontal: hPad, vertical: 12),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: onPrevWeek,
visualDensity: VisualDensity.compact,
),
Expanded(
child: Text(
weekLabel,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: onNextWeek,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.tune, size: 20),
tooltip: 'Días visibles',
visualDensity: VisualDensity.compact,
onPressed: () => _openDiasConfig(context),
),
],
),
),
// Tabla semanal
Expanded(
child: state.when(
loading: () => const Center(
child: CircularProgressIndicator(color: SomaColors.primary),
),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 48,
color: theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(height: 12),
Text(
e.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () =>
ref.read(horariosProvider.notifier).refrescar(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (semana) {
if (semana == null) {
return const Center(
child: CircularProgressIndicator(color: SomaColors.primary),
);
}
final dia = semana.diaPara(selectedDay);
return Column(
children: [
Expanded(
child: SemanaTablaView(
semana: semana,
diasVisibles: diasVisibles,
weekStart: weekStart,
selectedDay: selectedDay,
isAdmin: isAdmin,
onSelectDia: onDaySelected,
onEditarDia: (fecha, dia) {
onDaySelected(fecha);
onEditarDia(fecha, dia);
},
onCopiarDia: isAdmin
? (d) => onCopiarDia(d, semana)
: null,
onEliminarBloque: onEliminarBloque,
),
),
if (isAdmin)
Padding(
padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 16),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => onEditarDia(selectedDay, dia),
icon: const Icon(Icons.edit_outlined, size: 18),
label: const Text('Editar día'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 44),
side: BorderSide(
color: SomaColors.primary.withAlpha(120)),
),
),
),
if (dia != null &&
!dia.esEspecial &&
dia.bloques.isNotEmpty) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => onCopiarDia(dia, semana),
icon: const Icon(Icons.copy_outlined, size: 18),
label: const Text('Copiar a...'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 44),
side: BorderSide(
color: theme.colorScheme.onSurface
.withAlpha(60)),
foregroundColor:
theme.colorScheme.onSurface.withAlpha(160),
),
),
],
],
),
),
],
);
},
),
),
],
);
}
}
@@ -0,0 +1,358 @@
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/domain/entities/actividad.dart';
import 'package:gimnasio_soma/features/actividades/presentation/providers/actividades_provider.dart';
class AgregarBloqueDialog extends ConsumerStatefulWidget {
const AgregarBloqueDialog({super.key});
@override
ConsumerState<AgregarBloqueDialog> createState() =>
_AgregarBloqueDialogState();
}
class _AgregarBloqueDialogState extends ConsumerState<AgregarBloqueDialog> {
Actividad? _actividad;
TimeOfDay _horaInicio = const TimeOfDay(hour: 8, minute: 0);
TimeOfDay _horaFin = const TimeOfDay(hour: 9, minute: 0);
int get _duracion => _actividad?.duracion ?? 0;
int get _totalMinutos =>
_timeToMinutes(_horaFin) - _timeToMinutes(_horaInicio);
int get _sobrante => _duracion > 0 ? _totalMinutos % _duracion : 0;
bool get _esFaltante =>
_duracion > 0 && _totalMinutos > 0 && _totalMinutos < _duracion;
bool get _haySobrante =>
_duracion > 0 && _totalMinutos > 0 && !_esFaltante && _sobrante > 0;
TimeOfDay get _horaFinEfectiva {
if (!_haySobrante) return _horaFin;
final mins = _timeToMinutes(_horaFin) - _sobrante;
return TimeOfDay(hour: mins ~/ 60, minute: mins % 60);
}
Future<void> _pickTime({required bool isStart}) async {
final initial = isStart ? _horaInicio : _horaFin;
final picked = await showTimePicker(
context: context,
initialTime: initial,
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: SomaColors.primary,
onPrimary: SomaColors.onPrimary,
),
),
child: child!,
);
},
);
if (picked == null) return;
setState(() {
if (isStart) {
_horaInicio = picked;
// Auto-ajustar hora fin si es menor
if (_timeToMinutes(picked) >= _timeToMinutes(_horaFin)) {
_horaFin = TimeOfDay(
hour: (picked.hour + 1) % 24,
minute: picked.minute,
);
}
} else {
_horaFin = picked;
}
});
}
int _timeToMinutes(TimeOfDay t) => t.hour * 60 + t.minute;
String _formatTime(TimeOfDay t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
void _submit() {
if (_actividad == null) return;
if (_timeToMinutes(_horaInicio) >= _timeToMinutes(_horaFin)) return;
if (_esFaltante) return;
Navigator.of(context).pop(<String, dynamic>{
'actividad_id': _actividad!.id,
'_nombre': _actividad!.nombre,
'hora_inicio': _formatTime(_horaInicio),
'hora_fin': _formatTime(_horaFinEfectiva),
});
}
@override
Widget build(BuildContext context) {
final actividadesAsync = ref.watch(actividadesProvider);
final width = MediaQuery.of(context).size.width;
final isWide = width >= 600;
final theme = Theme.of(context);
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isWide ? (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: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
const Text(
'Agregar bloque',
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),
// Form
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Actividad dropdown
actividadesAsync.when(
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(12),
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) {
final activas =
actividades.where((a) => a.activo).toList();
return DropdownButtonFormField<int>(
initialValue: _actividad?.id,
items: activas
.map((a) => DropdownMenuItem(
value: a.id,
child: Text(a.nombre),
))
.toList(),
onChanged: (v) {
if (v == null) return;
setState(() => _actividad =
activas.firstWhere((a) => a.id == v));
},
decoration: const InputDecoration(
labelText: 'Actividad *',
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 14),
),
validator: (v) =>
v == null ? 'Seleccioná una actividad' : null,
);
},
),
const SizedBox(height: 20),
// Hora inicio / fin
Row(
children: [
Expanded(
child: _TimePickerField(
label: 'Desde',
value: _formatTime(_horaInicio),
onTap: () => _pickTime(isStart: true),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Icon(Icons.arrow_forward,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(100)),
),
Expanded(
child: _TimePickerField(
label: 'Hasta',
value: _formatTime(_horaFin),
onTap: () => _pickTime(isStart: false),
),
),
],
),
if (_timeToMinutes(_horaInicio) >= _timeToMinutes(_horaFin))
_BloqueWarning(
icon: Icons.error_outline,
color: SomaColors.error,
message: 'La hora de fin debe ser mayor a la de inicio',
)
else if (_esFaltante)
_BloqueWarning(
icon: Icons.error_outline,
color: SomaColors.error,
message:
'El rango (${_totalMinutos}min) es menor a la duración mínima de ${_actividad!.nombre} (${_duracion}min). No se puede insertar.',
)
else if (_haySobrante)
_BloqueWarning(
icon: Icons.info_outline,
color: Colors.amber.shade700,
message:
'Se recortarán ${_sobrante}min — se insertará hasta ${_formatTime(_horaFinEfectiva)}.',
),
const SizedBox(height: 8),
],
),
),
const Divider(height: 1),
// Actions
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: theme.colorScheme.onSurface.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _actividad != null &&
_timeToMinutes(_horaInicio) <
_timeToMinutes(_horaFin) &&
!_esFaltante
? _submit
: null,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: const Text('Agregar'),
),
],
),
),
],
),
),
);
}
}
class _BloqueWarning extends StatelessWidget {
final IconData icon;
final Color color;
final String message;
const _BloqueWarning({
required this.icon,
required this.color,
required this.message,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 15, color: color),
const SizedBox(width: 6),
Expanded(
child: Text(
message,
style: TextStyle(color: color, fontSize: 12),
),
),
],
),
);
}
}
class _TimePickerField extends StatelessWidget {
final String label;
final String value;
final VoidCallback onTap;
const _TimePickerField({
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: 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,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
),
),
],
);
}
}
@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart';
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
String _fmt(DateTime d) =>
'${d.day} ${_mesesCortos[d.month]} ${d.year}';
/// Selector tipado para el campo `alcance` del upsert regular.
///
/// Sólo tiene sentido cuando hay [futuros]; el caller debe encargarse de
/// ocultarlo cuando la lista está vacía. La fecha límite válida para el
/// caso `hasta` se calcula a partir del primer elemento de [futuros] menos
/// un día.
class AlcanceSelector extends StatelessWidget {
final List<PlanificacionFutura> futuros;
final DateTime validoDesde;
final Alcance alcance;
final ValueChanged<Alcance> onChanged;
const AlcanceSelector({
super.key,
required this.futuros,
required this.validoDesde,
required this.alcance,
required this.onChanged,
}) : assert(futuros.length > 0,
'AlcanceSelector debe recibir al menos una planificación futura');
DateTime get _proximo => futuros.first.validoDesde;
DateTime get _lastDateHasta => _proximo.subtract(const Duration(days: 1));
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hayMultiples = futuros.length > 1;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: SomaColors.primary.withAlpha(10),
border: Border.all(
color: SomaColors.primary.withAlpha(60),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warning_amber_rounded,
size: 16, color: SomaColors.primaryText),
const SizedBox(width: 6),
Expanded(
child: Text(
hayMultiples
? 'Hay ${futuros.length} horarios planificados a futuro (próximo: ${_fmt(_proximo)})'
: 'Hay un horario planificado a partir del ${_fmt(_proximo)}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'¿Cómo interactúa este cambio con lo ya planificado?',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 10),
_AlcanceOption(
label: 'Respetar planificación futura',
sublabel:
'El nuevo horario regirá hasta el ${_fmt(_lastDateHasta)}. '
'Desde el ${_fmt(_proximo)} se mantiene lo ya planificado.',
selected: alcance is AlcanceHastaProximo,
onTap: () => onChanged(const AlcanceHastaProximo()),
),
const SizedBox(height: 6),
_AlcanceOption(
label: hayMultiples
? 'Sobrescribir todas las planificaciones futuras'
: 'Sobrescribir y eliminar la planificación futura',
sublabel: hayMultiples
? 'Se eliminarán las ${futuros.length} planificaciones futuras para este día. El nuevo horario regirá sin fecha de fin.'
: 'Se eliminará el horario planificado para el ${_fmt(_proximo)}. El nuevo horario regirá sin fecha de fin.',
selected: alcance is AlcanceIndefinido,
destructive: true,
onTap: () => onChanged(const AlcanceIndefinido()),
),
],
),
);
}
}
class _AlcanceOption extends StatelessWidget {
final String label;
final String sublabel;
final bool selected;
final bool destructive;
final VoidCallback onTap;
const _AlcanceOption({
required this.label,
required this.sublabel,
required this.selected,
required this.onTap,
this.destructive = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = destructive ? SomaColors.error : SomaColors.primary;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: selected ? color.withAlpha(18) : theme.colorScheme.surface,
border: Border.all(
color: selected
? color.withAlpha(110)
: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 1),
child: Icon(
selected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
size: 16,
color: selected
? color
: theme.colorScheme.onSurface.withAlpha(110),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
color: selected
? (destructive
? SomaColors.error
: SomaColors.primaryText)
: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
sublabel,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(150),
),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,621 @@
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/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_error.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart';
import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/alcance_selector.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/valido_desde_selector.dart';
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
const _diasCortos = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
/// Diálogo para copiar los bloques de un día regular a uno o más días de la
/// semana. Expone controles de vigencia (valido_desde + alcance) y llama
/// al upsert RPC una vez por destino seleccionado.
///
/// Retorna `int` (total de huérfanas generadas) o `null` si se canceló.
class CopiarDiaDialog extends ConsumerStatefulWidget {
final DiaHorarios origen;
final DateTime weekStart;
final SemanaHorarios semana;
const CopiarDiaDialog({
super.key,
required this.origen,
required this.weekStart,
required this.semana,
});
@override
ConsumerState<CopiarDiaDialog> createState() => _CopiarDiaDialogState();
}
class _CopiarDiaDialogState extends ConsumerState<CopiarDiaDialog> {
final Set<int> _destinos = {};
late DateTime _validoDesde;
Alcance _alcance = const AlcanceHastaProximo();
bool _saving = false;
bool _futurosLoading = false;
List<PlanificacionFutura>? _futurosCombinados;
Map<int, HorarioError>? _errores;
int get _origenIdx => widget.origen.diaSemana - 1; // ISODOW 1-based → 0-based
@override
void initState() {
super.initState();
final now = DateTime.now();
_validoDesde = DateTime(now.year, now.month, now.day);
}
Future<void> _cargarFuturos() async {
if (_destinos.isEmpty) {
setState(() {
_futurosCombinados = [];
_futurosLoading = false;
});
return;
}
setState(() {
_futurosLoading = true;
_futurosCombinados = null;
});
try {
final repo = ref.read(horariosRepositoryProvider);
final lists = await Future.wait(
_destinos.map((i) => repo.futurosParaDiaSemana(
diaSemana: i + 1,
desde: _validoDesde,
)),
);
if (!mounted) return;
final todos = lists.expand((l) => l).toList();
todos.sort((a, b) => a.validoDesde.compareTo(b.validoDesde));
setState(() {
_futurosCombinados = todos;
_futurosLoading = false;
if (todos.isEmpty) {
_alcance = const AlcanceIndefinido();
} else if (_alcance is! AlcanceIndefinido) {
_alcance = const AlcanceHastaProximo();
}
});
} catch (_) {
if (!mounted) return;
setState(() {
_futurosCombinados = const [];
_futurosLoading = false;
_alcance = const AlcanceHastaProximo();
});
}
}
Future<void> _onValidoDesdeChanged(DateTime nuevo) async {
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
setState(() {
_validoDesde = nuevo.isBefore(hoy) ? hoy : nuevo;
_futurosCombinados = null;
});
await _cargarFuturos();
}
void _toggleDestino(int i) {
setState(() {
if (_destinos.contains(i)) {
_destinos.remove(i);
} else {
_destinos.add(i);
}
_futurosCombinados = null;
_errores = null;
});
_cargarFuturos();
}
List<Map<String, dynamic>> get _bloquesCopia {
final seen = <String>{};
final result = <Map<String, dynamic>>[];
for (final b in widget.origen.bloques) {
final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}';
if (!seen.add(key)) continue;
result.add({
'actividad_id': b.actividad.id,
'hora_inicio': b.horaInicio,
'hora_fin': b.horaFin,
});
}
return result;
}
Future<void> _guardar() async {
setState(() {
_saving = true;
_errores = null;
});
final bloques = _bloquesCopia;
final mandarMetadata = _futurosCombinados != null;
final validoDesde = mandarMetadata ? _validoDesde : null;
final alcance = mandarMetadata ? _alcance : null;
final destinosSorted = _destinos.toList()..sort();
final errores = <int, HorarioError>{};
int totalHuerfanas = 0;
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
for (final i in destinosSorted) {
// El RPC solo usa la fecha para derivar el ISODOW en días regulares.
// Si la fecha de esta semana ya pasó, avanzamos 7 días para obtener
// el mismo weekday la semana siguiente y evitar el rechazo del backend.
DateTime fecha = widget.weekStart.add(Duration(days: i));
if (fecha.isBefore(hoy)) fecha = fecha.add(const Duration(days: 7));
final (error, huerfanas) =
await ref.read(horariosProvider.notifier).guardarDia(
fecha: fecha,
esEspecial: false,
bloques: bloques,
validoDesde: validoDesde,
alcance: alcance,
);
if (error != null) {
errores[i] = error;
if (error is ConflictoAlcance) {
if (!mounted) return;
setState(() {
_saving = false;
_errores = errores;
_alcance = const AlcanceHastaProximo();
_futurosCombinados = null;
});
_cargarFuturos();
return;
}
} else {
totalHuerfanas += huerfanas;
}
}
if (!mounted) return;
setState(() => _saving = false);
if (errores.isEmpty) {
final n = destinosSorted.length;
SomaToast.show(
context,
message: n == 1
? 'Horario copiado a ${_diasSemana[destinosSorted.first]}'
: 'Horario copiado a $n días',
type: ToastType.success,
);
Navigator.of(context).pop(totalHuerfanas);
} else {
setState(() => _errores = errores);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final origenLabel = _diasSemana[_origenIdx];
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 620),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
Expanded(
child: Text(
'Copiar $origenLabel a...',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w700),
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed:
_saving ? null : () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// Body
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Day chips
Text(
'Días destino',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 8),
_DestinosChips(
origenIdx: _origenIdx,
destinos: _destinos,
onToggle: _toggleDestino,
),
// Sub-labels per selected destination
if (_destinos.isNotEmpty) ...[
const SizedBox(height: 10),
for (final i in _destinos.toList()..sort())
_DestinoInfo(
label: _diasSemana[i],
diaActual: widget.semana.diaPara(
widget.weekStart.add(Duration(days: i))),
),
],
// Bloques preview
const SizedBox(height: 20),
Text(
'Actividades a copiar',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 8),
_BloquesPreview(bloques: widget.origen.bloques),
// Vigencia (only when destinations are selected)
if (_destinos.isNotEmpty) ...[
const SizedBox(height: 20),
ValidoDesdeSelector(
fecha: _validoDesde,
weekdayTarget: null,
onChanged: _onValidoDesdeChanged,
),
const SizedBox(height: 12),
if (_futurosLoading && _futurosCombinados == null)
const Padding(
padding: EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.primary,
),
),
SizedBox(width: 8),
Text(
'Verificando planificaciones futuras…',
style: TextStyle(fontSize: 12),
),
],
),
)
else if (_futurosCombinados != null &&
_futurosCombinados!.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
'No hay horarios planificados a futuro: el horario '
'copiado regirá de manera indefinida.',
style: TextStyle(
fontSize: 11,
color:
theme.colorScheme.onSurface.withAlpha(140),
fontStyle: FontStyle.italic,
),
),
)
else if (_futurosCombinados != null &&
_futurosCombinados!.isNotEmpty)
AlcanceSelector(
futuros: _futurosCombinados!,
validoDesde: _validoDesde,
alcance: _alcance,
onChanged: (a) => setState(() => _alcance = a),
),
],
// Error panel
if (_errores != null && _errores!.isNotEmpty) ...[
const SizedBox(height: 12),
for (final entry in _errores!.entries)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'${_diasSemana[entry.key]}: ${entry.value.mensajeUsuario()}',
style: const TextStyle(
color: SomaColors.error, fontSize: 12),
),
),
],
const SizedBox(height: 4),
],
),
),
),
const Divider(height: 1),
// Footer
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
children: [
const Spacer(),
TextButton(
onPressed:
_saving ? null : () => Navigator.of(context).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed:
_destinos.isEmpty || _saving || _futurosLoading
? null
: _guardar,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42)),
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.onPrimary,
),
)
: Text(
_destinos.isEmpty
? 'Copiar'
: 'Copiar a ${_destinos.length} '
'${_destinos.length == 1 ? 'día' : 'días'}',
),
),
],
),
),
],
),
),
);
}
}
// ── Chips de destino (multi-select) ───────────────────────────────────────────
class _DestinosChips extends StatelessWidget {
final int origenIdx;
final Set<int> destinos;
final ValueChanged<int> onToggle;
const _DestinosChips({
required this.origenIdx,
required this.destinos,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: List.generate(7, (i) {
final isOrigen = i == origenIdx;
final isSelected = destinos.contains(i);
return Expanded(
child: Padding(
padding: EdgeInsets.only(right: i < 6 ? 4 : 0),
child: InkWell(
onTap: isOrigen ? null : () => onToggle(i),
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isOrigen
? theme.colorScheme.surfaceContainerHighest.withAlpha(30)
: isSelected
? SomaColors.primary.withAlpha(22)
: theme.colorScheme.surfaceContainerHighest
.withAlpha(60),
border: Border.all(
color: isOrigen
? theme.colorScheme.surfaceContainerHighest
.withAlpha(60)
: isSelected
? SomaColors.primary.withAlpha(100)
: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Center(
child: Text(
_diasCortos[i],
style: TextStyle(
fontSize: 12,
fontWeight:
isSelected ? FontWeight.w700 : FontWeight.w500,
color: isOrigen
? theme.colorScheme.onSurface.withAlpha(60)
: isSelected
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(130),
),
),
),
),
),
),
);
}),
);
}
}
// ── Info por destino seleccionado ─────────────────────────────────────────────
class _DestinoInfo extends StatelessWidget {
final String label;
final DiaHorarios? diaActual;
const _DestinoInfo({required this.label, required this.diaActual});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bloques = diaActual?.bloques ?? [];
final tieneContenido = bloques.isNotEmpty;
final String desc;
final Color color;
if (tieneContenido) {
final n = bloques.length;
desc = '$n ${n == 1 ? 'actividad' : 'actividades'} — se reemplazarán';
color = Colors.orange;
} else {
desc = 'vacío';
color = theme.colorScheme.onSurface.withAlpha(100);
}
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(Icons.arrow_forward, size: 12,
color: SomaColors.primary.withAlpha(160)),
const SizedBox(width: 6),
Text(
'$label: ',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
Expanded(
child: Text(
desc,
style: TextStyle(fontSize: 12, color: color),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
// ── Preview de bloques (read-only) ────────────────────────────────────────────
class _BloquesPreview extends StatelessWidget {
final List<BloqueHorario> bloques;
const _BloquesPreview({required this.bloques});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (bloques.isEmpty) {
return Text(
'Sin actividades',
style: TextStyle(
fontSize: 12, color: theme.colorScheme.onSurface.withAlpha(120)),
);
}
final seen = <String>{};
final unique = <BloqueHorario>[];
for (final b in bloques) {
final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}';
if (!seen.add(key)) continue;
unique.add(b);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: unique
.map((b) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.surfaceContainerHighest
.withAlpha(50),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: Row(
children: [
Container(
width: 3,
height: 20,
decoration: BoxDecoration(
color: SomaColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
SizedBox(
width: 84,
child: Text(
'${b.horaInicio}${b.horaFin}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
Expanded(
child: Text(
b.actividad.nombre,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
))
.toList(),
);
}
}
@@ -0,0 +1,342 @@
import 'package:flutter/material.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/horario_actividad_tile.dart';
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
class DiaColumna extends StatelessWidget {
final DateTime fecha;
final String nombreDia;
final DiaHorarios? dia;
final bool isSelected;
final bool isAdmin;
final VoidCallback onSelectDia;
final void Function(DateTime, DiaHorarios?) onEditarDia;
final VoidCallback? onCopiarDia;
final void Function(BloqueHorario)? onEliminarBloque;
const DiaColumna({
super.key,
required this.fecha,
required this.nombreDia,
required this.dia,
required this.isSelected,
required this.isAdmin,
required this.onSelectDia,
required this.onEditarDia,
this.onCopiarDia,
this.onEliminarBloque,
});
@override
Widget build(BuildContext context) {
final col = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DiaHeader(
nombreDia: nombreDia,
fecha: fecha,
dia: dia,
isSelected: isSelected,
onTap: onSelectDia,
),
Expanded(
child: _DiaBody(
fecha: fecha,
dia: dia,
isAdmin: isAdmin,
onEditarDia: onEditarDia,
onEliminarBloque: onEliminarBloque,
),
),
],
);
if (!isAdmin) return col;
return GestureDetector(
onSecondaryTapDown: (details) {
showContextMenu<String>(
context,
contextMenu: ContextMenu<String>(
position: details.globalPosition,
entries: [
MenuItem(
label: const Text('Editar día'),
icon: const Icon(Icons.edit_outlined, size: 16),
value: 'edit',
),
if (onCopiarDia != null)
MenuItem(
label: const Text('Copiar a...'),
icon: const Icon(Icons.copy_outlined, size: 16),
value: 'copy',
),
],
),
onItemSelected: (v) {
if (v == 'edit') onEditarDia(fecha, dia);
if (v == 'copy') onCopiarDia!();
},
);
},
child: col,
);
}
}
// ── Header ─────────────────────────────────────────────────────────────────────
class _DiaHeader extends StatelessWidget {
final String nombreDia;
final DateTime fecha;
final DiaHorarios? dia;
final bool isSelected;
final VoidCallback onTap;
const _DiaHeader({
required this.nombreDia,
required this.fecha,
required this.dia,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final esEspecial = dia?.esEspecial ?? false;
final esCerrado = dia?.esCerrado ?? false;
final hastaLabel = (dia != null &&
dia!.tipo == TipoDia.normal &&
dia!.validoHasta != null)
? '${dia!.validoHasta!.day} ${_mesesCortos[dia!.validoHasta!.month]}'
: null;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: InkWell(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
decoration: BoxDecoration(
color: isSelected
? SomaColors.primary.withAlpha(28)
: Colors.transparent,
border: Border(
bottom: BorderSide(
color: isSelected
? SomaColors.primary
: theme.colorScheme.surfaceContainerHighest,
width: isSelected ? 2 : 1,
),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nombreDia,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isSelected
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 1),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'${fecha.day} ${_mesesCortos[fecha.month]}',
style: TextStyle(
fontSize: 11,
color: isSelected
? SomaColors.primaryText.withAlpha(180)
: theme.colorScheme.onSurface.withAlpha(120),
),
),
if (hastaLabel != null) ...[
const SizedBox(width: 6),
Flexible(
child: Tooltip(
message:
'Esta plantilla rige hasta el ${dia!.validoHasta!.day} ${_mesesCortos[dia!.validoHasta!.month]} ${dia!.validoHasta!.year}.',
child: Text(
hastaLabel,
style: TextStyle(
fontSize: 10,
fontStyle: FontStyle.italic,
color: isSelected
? SomaColors.primaryText.withAlpha(160)
: theme.colorScheme.onSurface
.withAlpha(100),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
],
),
],
),
),
if (esCerrado)
_HeaderBadge(icon: Icons.block, color: SomaColors.error)
else if (esEspecial)
_HeaderBadge(icon: Icons.event_note, color: SomaColors.primary),
],
),
),
),
);
}
}
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 _DiaBody extends StatelessWidget {
final DateTime fecha;
final DiaHorarios? dia;
final bool isAdmin;
final void Function(DateTime, DiaHorarios?) onEditarDia;
final void Function(BloqueHorario)? onEliminarBloque;
const _DiaBody({
required this.fecha,
required this.dia,
required this.isAdmin,
required this.onEditarDia,
this.onEliminarBloque,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (dia == null) {
return Center(
child: Text(
'',
style: TextStyle(
fontSize: 18,
color: theme.colorScheme.onSurface.withAlpha(60),
),
),
);
}
if (dia!.esCerrado) {
return Container(
color: SomaColors.error.withAlpha(10),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.block, size: 24, color: SomaColors.error.withAlpha(140)),
const SizedBox(height: 6),
Text(
'Cerrado',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: SomaColors.error.withAlpha(160),
),
),
if (dia!.motivo != null && dia!.motivo!.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
dia!.motivo!,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
color: theme.colorScheme.onSurface.withAlpha(100),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
);
}
if (dia!.bloques.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.event_busy_outlined,
size: 24,
color: theme.colorScheme.onSurface.withAlpha(50),
),
const SizedBox(height: 6),
Text(
'Sin actividades',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
],
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(8),
itemCount: dia!.bloques.length,
separatorBuilder: (_, _) => const SizedBox(height: 6),
itemBuilder: (context, index) {
final bloque = dia!.bloques[index];
return HorarioActividadTile(
bloque: bloque,
onTap: isAdmin ? () => onEditarDia(fecha, dia) : null,
onDelete: onEliminarBloque != null
? () => onEliminarBloque!(bloque)
: null,
);
},
);
}
}
@@ -0,0 +1,404 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart';
import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/editar_dia_dialog.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
const _meses = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
class DiasEspecialesView extends ConsumerWidget {
const DiasEspecialesView({super.key});
String _fmtFecha(DateTime d) =>
'${_diasSemana[d.weekday - 1]}, ${d.day} de ${_meses[d.month]} ${d.year}';
Future<void> _eliminar(
BuildContext context, WidgetRef ref, DiaEspecialResumen dia) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Restaurar horario normal'),
content: Text(
'¿Restaurar el horario regular para el ${_fmtFecha(dia.fecha)}?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
minimumSize: const Size(0, 40),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Restaurar'),
),
],
),
);
if (confirm != true || !context.mounted) return;
final (error, huerfanas) = await ref
.read(horariosProvider.notifier)
.eliminarDiaEspecial(dia.fecha);
if (!context.mounted) return;
if (error != null) {
SomaToast.show(
context,
message: error.mensajeUsuario(),
type: ToastType.error,
);
} else {
if (huerfanas > 0) {
_showHuerfanasToast(context, huerfanas);
} else {
SomaToast.show(
context,
message: 'Excepción eliminada',
type: ToastType.success,
);
}
// La recarga de la lista la dispara HorariosNotifier al invalidar
// diasEspecialesProvider tras eliminar la excepción.
}
}
void _showHuerfanasToast(BuildContext context, int n) {
SomaToast.show(
context,
message: '$n ${n == 1 ? 'reserva quedó huérfana' : 'reservas quedaron huérfanas'}',
type: ToastType.info,
action: SnackBarAction(
label: 'Ver',
textColor: SomaColors.onPrimary,
onPressed: () => context.go('/huerfanas'),
),
);
}
Future<void> _editar(
BuildContext context, WidgetRef ref, DiaEspecialResumen resumen) async {
// Convert DiaEspecialResumen to DiaHorarios for EditarDiaDialog
final tipo = resumen.esCerrado ? TipoDia.cerrado : TipoDia.horarioDiferente;
final bloques = resumen.rangos
.map((r) => BloqueHorario(
id: r.id,
horaInicio: r.horaInicio,
horaFin: r.horaFin,
actividad: BloqueActividadInfo(
id: r.actividadId,
nombre: r.actividadNombre,
duracion: r.actividadDuracion,
capacidad: 0,
),
))
.toList();
final diaHorarios = DiaHorarios(
fecha: resumen.fecha,
diaSemana: resumen.fecha.weekday,
tipo: tipo,
motivo: resumen.motivo,
bloques: bloques,
);
final huerfanas = await showDialog<int>(
context: context,
builder: (_) => EditarDiaDialog(
dia: diaHorarios,
fecha: resumen.fecha,
weekStart: resumen.fecha.subtract(
Duration(days: resumen.fecha.weekday - 1),
),
),
);
if (!context.mounted) return;
final n = huerfanas ?? 0;
if (n > 0) _showHuerfanasToast(context, n);
// Si el diálogo guardó algo, HorariosNotifier ya invalidó
// diasEspecialesProvider y la lista se recarga sola; si se canceló, no hay
// nada que refrescar.
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(diasEspecialesProvider);
final theme = Theme.of(context);
final isWide = MediaQuery.of(context).size.width >= 800;
return state.when(
loading: () => const Center(
child: CircularProgressIndicator(color: SomaColors.primary),
),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 48, color: theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(height: 12),
Text(
e.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () =>
ref.read(diasEspecialesProvider.notifier).load(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (especiales) {
if (especiales.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_note_outlined,
size: 56,
color: theme.colorScheme.onSurface.withAlpha(60)),
const SizedBox(height: 12),
Text(
'Sin días especiales configurados',
style: TextStyle(
fontSize: 15,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
const SizedBox(height: 6),
Text(
'Editá un día desde la vista Semanal para marcarlo como especial.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
],
),
);
}
return ListView.separated(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 16, isWide ? 32 : 16, 32,
),
itemCount: especiales.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final dia = especiales[index];
return _EspecialCard(
dia: dia,
fechaLabel: _fmtFecha(dia.fecha),
onEditar: () => _editar(context, ref, dia),
onEliminar: () => _eliminar(context, ref, dia),
);
},
);
},
);
}
}
class _EspecialCard extends StatelessWidget {
final DiaEspecialResumen dia;
final String fechaLabel;
final VoidCallback onEditar;
final VoidCallback onEliminar;
const _EspecialCard({
required this.dia,
required this.fechaLabel,
required this.onEditar,
required this.onEliminar,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final railColor = dia.esCerrado
? SomaColors.error.withAlpha(180)
: SomaColors.primary.withAlpha(180);
return InkWell(
onTap: onEditar,
borderRadius: BorderRadius.circular(12),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 4, color: railColor),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 11, 8, 11),
child: Row(
children: [
// Icon
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: dia.esCerrado
? SomaColors.error.withAlpha(16)
: SomaColors.primary.withAlpha(18),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
dia.esCerrado ? Icons.block : Icons.schedule,
size: 20,
color: dia.esCerrado
? SomaColors.error
: SomaColors.primary,
),
),
const SizedBox(width: 14),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(
child: Text(
fechaLabel,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
_TipoBadge(esCerrado: dia.esCerrado),
],
),
const SizedBox(height: 3),
Text(
dia.motivo?.isNotEmpty == true
? dia.motivo!
: dia.esCerrado
? 'Sin motivo especificado'
: '${dia.rangos.length} actividad${dia.rangos.length == 1 ? '' : 'es'}',
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(130),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Menu
PopupMenuButton<String>(
icon: Icon(Icons.more_vert,
size: 18,
color:
theme.colorScheme.onSurface.withAlpha(130)),
itemBuilder: (_) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit_outlined, size: 18),
SizedBox(width: 8),
Text('Editar'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.restore_outlined,
size: 18, color: SomaColors.error),
SizedBox(width: 8),
Text('Restaurar normal',
style:
TextStyle(color: SomaColors.error)),
],
),
),
],
onSelected: (v) {
if (v == 'edit') onEditar();
if (v == 'delete') onEliminar();
},
),
],
),
),
),
],
),
),
),
);
}
}
class _TipoBadge extends StatelessWidget {
final bool esCerrado;
const _TipoBadge({required this.esCerrado});
@override
Widget build(BuildContext context) {
final color = esCerrado ? SomaColors.error : SomaColors.primary;
final textColor = esCerrado ? SomaColors.error : SomaColors.primaryText;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(18),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: color.withAlpha(60), width: 0.5),
),
child: Text(
esCerrado ? 'Cerrado' : 'Especial',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: textColor,
),
),
);
}
}
@@ -0,0 +1,975 @@
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/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_error.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart';
import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/agregar_bloque_dialog.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/alcance_selector.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/valido_desde_selector.dart';
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
const _diasCortos = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
class EditarDiaDialog extends ConsumerStatefulWidget {
final DiaHorarios? dia;
final DateTime fecha;
final DateTime weekStart;
const EditarDiaDialog({
super.key,
required this.dia,
required this.fecha,
required this.weekStart,
});
@override
ConsumerState<EditarDiaDialog> createState() => _EditarDiaDialogState();
}
class _EditarDiaDialogState extends ConsumerState<EditarDiaDialog> {
late bool _esEspecial;
late bool _esCerrado;
final _motivoController = TextEditingController();
late List<Map<String, dynamic>> _bloques;
late int _selectedWeekdayIndex;
late DateTime _selectedFecha;
bool _saving = false;
HorarioError? _error;
// Modo Normal: estado de vigencia y alcance del cambio.
late DateTime _validoDesde;
Alcance _alcance = const AlcanceHastaProximo();
List<PlanificacionFutura>? _futuros;
bool _futurosLoading = false;
@override
void initState() {
super.initState();
_esEspecial = widget.dia?.esEspecial ?? false;
_selectedWeekdayIndex = widget.fecha.weekday - 1;
_selectedFecha = widget.fecha;
_esCerrado = false;
_bloques = [];
_applyDia(widget.dia);
final hoy = _hoy();
// CU2.c — editar planificación futura existente: si el día actual ya
// tiene una plantilla vigente con valido_desde futuro, pre-cargamos ese
// valor para que guardar equivalga a editar esa misma planificación.
// Si no, usamos la fecha exacta del calendario que Juani está viendo
// (alineado con el nuevo default del backend: valido_desde = fecha).
// Clampear a hoy por si Juani navega hacia semanas pasadas.
final validoDesdeDia = widget.dia?.validoDesde;
_validoDesde =
(validoDesdeDia != null && validoDesdeDia.isAfter(hoy))
? validoDesdeDia
: (widget.fecha.isBefore(hoy) ? hoy : widget.fecha);
if (!_esEspecial) {
WidgetsBinding.instance.addPostFrameCallback((_) => _cargarFuturos());
}
}
DateTime _hoy() {
final n = DateTime.now();
return DateTime(n.year, n.month, n.day);
}
Future<void> _cargarFuturos() async {
final diaSemana = _fechaParaGuardar.weekday; // ISODOW 1..7
setState(() => _futurosLoading = true);
try {
final lista =
await ref.read(horariosRepositoryProvider).futurosParaDiaSemana(
diaSemana: diaSemana,
desde: _validoDesde,
);
if (!mounted) return;
setState(() {
_futuros = lista;
_futurosLoading = false;
// Si no hay futuros, el alcance es 'indefinido' implícito.
// Si hay, mantenemos el default backend 'hasta_proximo' salvo que ya
// hubiera una elección del usuario distinta.
if (lista.isEmpty) {
_alcance = const AlcanceIndefinido();
} else if (_alcance is AlcanceHasta) {
// Si la fecha del 'hasta' previa quedó fuera del rango válido tras
// recargar futuros, retrocedemos al default.
final lastValid =
lista.first.validoDesde.subtract(const Duration(days: 1));
final f = (_alcance as AlcanceHasta).fecha;
if (f.isBefore(_validoDesde) || f.isAfter(lastValid)) {
_alcance = const AlcanceHastaProximo();
}
} else if (_alcance is AlcanceIndefinido) {
// Mantener selección explícita del usuario.
} else {
_alcance = const AlcanceHastaProximo();
}
});
} catch (_) {
if (!mounted) return;
// Si falla, asumimos que no hay futuros conocidos: el backend usará
// su default 'hasta_proximo' al guardar. El usuario verá el formulario
// sin selector hasta que vuelva a abrir.
setState(() {
_futuros = const [];
_futurosLoading = false;
_alcance = const AlcanceHastaProximo();
});
}
}
/// Mutates _esCerrado, _motivoController, _bloques from a DiaHorarios snapshot.
/// Must be called inside setState (or during initState).
void _applyDia(DiaHorarios? dia) {
_esCerrado = dia?.esCerrado ?? false;
_motivoController.text = dia?.motivo ?? '';
final seen = <String>{};
_bloques = [];
for (final b in dia?.bloques ?? []) {
final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}';
if (!seen.add(key)) continue;
_bloques.add({
'actividad_id': b.actividad.id,
'hora_inicio': b.horaInicio,
'hora_fin': b.horaFin,
'_nombre': b.actividad.nombre,
});
}
}
void _onWeekdayChanged(int index) {
final semana = ref.read(horariosProvider).valueOrNull;
final newFecha = widget.weekStart.add(Duration(days: index));
final nuevoDia = semana?.diaPara(newFecha);
setState(() {
_selectedWeekdayIndex = index;
_error = null;
_applyDia(nuevoDia);
// Si el nuevo día tiene plantilla vigente con valido_desde futuro,
// saltamos a esa fecha (CU2.c). Si no, al próximo día con esa weekday.
final hoy = _hoy();
final vd = nuevoDia?.validoDesde;
_validoDesde = (vd != null && vd.isAfter(hoy))
? vd
: (newFecha.isBefore(hoy) ? hoy : newFecha);
_alcance = const AlcanceHastaProximo();
_futuros = null;
});
_cargarFuturos();
}
Future<void> _onValidoDesdeChanged(DateTime nuevo) async {
final hoy = _hoy();
final clamped = nuevo.isBefore(hoy) ? hoy : nuevo;
setState(() {
_validoDesde = clamped;
_futuros = null;
_error = null;
});
await _cargarFuturos();
}
@override
void dispose() {
_motivoController.dispose();
super.dispose();
}
DateTime get _fechaParaGuardar => _esEspecial
? _selectedFecha
: widget.weekStart.add(Duration(days: _selectedWeekdayIndex));
String get _diaLabel => _esEspecial
? '${_diasSemana[_selectedFecha.weekday - 1]} ${_selectedFecha.day} ${_mesesCortos[_selectedFecha.month]}'
: _diasSemana[_selectedWeekdayIndex];
Future<void> _addBloque() async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => const AgregarBloqueDialog(),
);
if (result == null) return;
setState(() => _bloques = [..._bloques, result]);
}
void _removeBloque(int index) {
setState(() {
_bloques = List<Map<String, dynamic>>.from(_bloques)..removeAt(index);
});
}
Future<void> _guardar() async {
setState(() {
_saving = true;
_error = null;
});
final bloquesPayload = _bloques.map((b) {
return {
'actividad_id': b['actividad_id'],
'hora_inicio': b['hora_inicio'],
'hora_fin': b['hora_fin'],
};
}).toList();
// Para modo Normal, solo mandamos alcance/validoDesde si tenemos info
// confiable. Si _futuros vino vacío explícitamente, mandamos los valores
// elegidos. Si es null (todavía cargando o falló), dejamos que decida
// el backend con sus defaults.
final esRegular = !_esEspecial;
final mandarMetadata = esRegular && _futuros != null;
final (error, huerfanas) =
await ref.read(horariosProvider.notifier).guardarDia(
fecha: _fechaParaGuardar,
esEspecial: _esEspecial,
motivo: _esEspecial ? _motivoController.text.trim() : null,
bloques: _esEspecial && _esCerrado ? [] : bloquesPayload,
validoDesde: mandarMetadata ? _validoDesde : null,
alcance: mandarMetadata ? _alcance : null,
);
if (!mounted) return;
setState(() => _saving = false);
if (error != null) {
setState(() {
_error = error;
// Si el backend rechazó por conflicto de alcance, retrocedemos a
// 'hasta_proximo' para que el usuario reintente con una opción que
// siempre es segura. También refrescamos el mapa de futuros por si
// el conflicto delata una planificación que no teníamos cacheada.
if (error is ConflictoAlcance) {
_alcance = const AlcanceHastaProximo();
}
});
if (error is ConflictoAlcance) {
await _cargarFuturos();
}
} else {
SomaToast.show(context, message: 'Horario guardado', type: ToastType.success);
Navigator.of(context).pop(huerfanas);
}
}
Future<void> _eliminarExcepcion() async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Restaurar horario normal'),
content: const Text(
'Se eliminará la excepción y el día volverá a usar el horario regular.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
minimumSize: const Size(0, 40),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Restaurar'),
),
],
),
);
if (confirm != true || !mounted) return;
setState(() {
_saving = true;
_error = null;
});
final (error, huerfanas) =
await ref.read(horariosProvider.notifier).eliminarDiaEspecial(
widget.dia!.fecha,
);
if (!mounted) return;
setState(() => _saving = false);
if (error != null) {
setState(() => _error = error);
} else {
SomaToast.show(
context,
message: 'Excepción eliminada, se aplica horario regular',
type: ToastType.success,
);
Navigator.of(context).pop(huerfanas);
}
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isWide = width >= 600;
final theme = Theme.of(context);
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isWide ? (width - 480) / 2 : 20,
vertical: 24,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 600),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
Expanded(
child: Text(
'Editar $_diaLabel',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed:
_saving ? null : () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// Body
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Tipo toggle
Row(
children: [
Expanded(
child: _TipoOption(
label: 'Normal',
icon: Icons.calendar_today_outlined,
selected: !_esEspecial,
onTap: () {
setState(() {
_esEspecial = false;
_esCerrado = false;
_selectedWeekdayIndex =
_selectedFecha.weekday - 1;
_futuros = null;
});
_cargarFuturos();
},
),
),
const SizedBox(width: 8),
Expanded(
child: _TipoOption(
label: 'Especial',
icon: Icons.event_note_outlined,
selected: _esEspecial,
onTap: () => setState(() {
_esEspecial = true;
_selectedFecha = widget.weekStart
.add(Duration(days: _selectedWeekdayIndex));
}),
),
),
],
),
// Día selector
const SizedBox(height: 16),
if (!_esEspecial)
_WeekdaySelector(
selected: _selectedWeekdayIndex,
onChanged: _onWeekdayChanged,
)
else
_FechaSelector(
fecha: _selectedFecha,
onChanged: (d) => setState(() => _selectedFecha = d),
),
// Vigencia y alcance (sólo modo Normal)
if (!_esEspecial) ...[
const SizedBox(height: 16),
ValidoDesdeSelector(
fecha: _validoDesde,
weekdayTarget: _fechaParaGuardar.weekday,
onChanged: _onValidoDesdeChanged,
),
const SizedBox(height: 12),
if (_futurosLoading && _futuros == null)
const Padding(
padding: EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.primary,
),
),
SizedBox(width: 8),
Text(
'Verificando planificaciones futuras…',
style: TextStyle(fontSize: 12),
),
],
),
)
else if (_futuros != null && _futuros!.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
'No hay horarios planificados a futuro: este '
'horario regirá desde el inicio de vigencia '
'de manera indefinida.',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(140),
fontStyle: FontStyle.italic,
),
),
)
else if (_futuros != null && _futuros!.isNotEmpty)
AlcanceSelector(
futuros: _futuros!,
validoDesde: _validoDesde,
alcance: _alcance,
onChanged: (a) => setState(() => _alcance = a),
),
],
// Especial options
if (_esEspecial) ...[
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _SubOption(
label: 'Horario diferente',
selected: !_esCerrado,
onTap: () =>
setState(() => _esCerrado = false),
),
),
const SizedBox(width: 8),
Expanded(
child: _SubOption(
label: 'Cerrado',
selected: _esCerrado,
isDestructive: true,
onTap: () =>
setState(() => _esCerrado = true),
),
),
],
),
const SizedBox(height: 14),
TextField(
controller: _motivoController,
decoration: const InputDecoration(
labelText: 'Motivo (opcional)',
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 14),
),
),
],
// Bloques (solo si no está cerrado)
if (!(_esEspecial && _esCerrado)) ...[
const SizedBox(height: 20),
Text(
'Actividades',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 6),
if (_bloques.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(
child: Text(
'Sin actividades — el día quedará vacío',
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(120),
),
),
),
)
else
...List.generate(_bloques.length, (i) {
final b = _bloques[i];
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: _BloqueEditRow(
horaInicio: b['hora_inicio'] as String,
horaFin: b['hora_fin'] as String,
nombre: b['_nombre'] as String? ?? '',
onDelete: () => _removeBloque(i),
),
);
}),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: _addBloque,
icon: const Icon(Icons.add, size: 18),
label: const Text('Agregar actividad'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 44),
foregroundColor: SomaColors.primary,
side: BorderSide(
color: SomaColors.primary.withAlpha(100)),
),
),
],
// Error
if (_error != null) ...[
const SizedBox(height: 10),
Text(
_error!.mensajeUsuario(),
style: TextStyle(
color: SomaColors.error,
fontSize: 12,
),
),
],
const SizedBox(height: 4),
],
),
),
),
const Divider(height: 1),
// Actions
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
children: [
// Eliminar excepción (solo si el día actual ya es especial en BD)
if (widget.dia?.esEspecial == true)
TextButton(
onPressed: _saving ? null : _eliminarExcepcion,
style: TextButton.styleFrom(
foregroundColor: SomaColors.error,
),
child: const Text('Restaurar normal'),
),
const Spacer(),
TextButton(
onPressed:
_saving ? null : () => Navigator.of(context).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _saving ? null : _guardar,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.onPrimary,
),
)
: const Text('Guardar'),
),
],
),
),
],
),
),
);
}
}
class _TipoOption extends StatelessWidget {
final String label;
final IconData icon;
final bool selected;
final VoidCallback onTap;
const _TipoOption({
required this.label,
required this.icon,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: selected
? SomaColors.primary.withAlpha(22)
: theme.colorScheme.surfaceContainerHighest.withAlpha(80),
border: Border.all(
color: selected
? SomaColors.primary.withAlpha(100)
: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon,
size: 16,
color: selected
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(130)),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
),
);
}
}
class _SubOption extends StatelessWidget {
final String label;
final bool selected;
final bool isDestructive;
final VoidCallback onTap;
const _SubOption({
required this.label,
required this.selected,
this.isDestructive = false,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = isDestructive ? SomaColors.error : SomaColors.primary;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: selected ? color.withAlpha(18) : Colors.transparent,
border: Border.all(
color: selected
? color.withAlpha(80)
: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (selected)
Icon(Icons.radio_button_checked,
size: 14, color: color)
else
Icon(Icons.radio_button_unchecked,
size: 14,
color: theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected
? (isDestructive ? SomaColors.error : SomaColors.primaryText)
: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
),
);
}
}
class _BloqueEditRow extends StatelessWidget {
final String horaInicio;
final String horaFin;
final String nombre;
final VoidCallback onDelete;
const _BloqueEditRow({
required this.horaInicio,
required this.horaFin,
required this.nombre,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: theme.colorScheme.surfaceContainerHighest.withAlpha(60),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 4, color: SomaColors.primary),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 4, 10),
child: Row(
children: [
SizedBox(
width: 94,
child: Text(
'$horaInicio $horaFin',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
Container(
width: 1,
height: 24,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: theme.colorScheme.surfaceContainerHighest,
),
Expanded(
child: Text(
nombre,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: Icon(
Icons.delete_outline,
size: 18,
color: SomaColors.error.withAlpha(180),
),
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 40, minHeight: 40),
onPressed: onDelete,
),
],
),
),
),
],
),
),
);
}
}
// ── Weekday selector (Normal) ──────────────────────────────────────────────────
class _WeekdaySelector extends StatelessWidget {
final int selected;
final ValueChanged<int> onChanged;
const _WeekdaySelector({required this.selected, required this.onChanged});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: List.generate(_diasCortos.length, (i) {
final isSelected = i == selected;
return Expanded(
child: Padding(
padding: EdgeInsets.only(right: i < _diasCortos.length - 1 ? 4 : 0),
child: InkWell(
onTap: () => onChanged(i),
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isSelected
? SomaColors.primary.withAlpha(22)
: theme.colorScheme.surfaceContainerHighest.withAlpha(60),
border: Border.all(
color: isSelected
? SomaColors.primary.withAlpha(100)
: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Center(
child: Text(
_diasCortos[i],
style: TextStyle(
fontSize: 12,
fontWeight:
isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(130),
),
),
),
),
),
),
);
}),
);
}
}
// ── Date picker button (Especial) ──────────────────────────────────────────────
class _FechaSelector extends StatelessWidget {
final DateTime fecha;
final ValueChanged<DateTime> onChanged;
const _FechaSelector({required this.fecha, required this.onChanged});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final label =
'${_diasSemana[fecha.weekday - 1]}, ${fecha.day} ${_mesesCortos[fecha.month]} ${fecha.year}';
return InkWell(
onTap: () async {
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
final initial = fecha.isBefore(hoy) ? hoy : fecha;
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: hoy,
lastDate: DateTime(2100),
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: SomaColors.primary,
onPrimary: SomaColors.onPrimary,
),
),
child: child!,
),
);
if (picked != null) onChanged(picked);
},
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: theme.colorScheme.surfaceContainerHighest.withAlpha(60),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.calendar_month_outlined,
size: 18,
color: SomaColors.primary,
),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
Icon(
Icons.expand_more,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(120),
),
],
),
),
);
}
}
@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:gimnasio_soma/core/theme/activity_colors.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
class _CapacidadBadge extends StatelessWidget {
final int capacidad;
const _CapacidadBadge({required this.capacidad});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.people_outline,
size: 11,
color: theme.colorScheme.onSurface.withAlpha(100),
),
const SizedBox(width: 3),
Flexible(
child: Text(
'$capacidad personas',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(110),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
class HorarioActividadTile extends StatelessWidget {
final BloqueHorario bloque;
final VoidCallback? onTap;
final VoidCallback? onDelete;
const HorarioActividadTile({
super.key,
required this.bloque,
this.onTap,
this.onDelete,
});
Widget _buildCompactContent(ThemeData theme) {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${bloque.horaInicio} ${bloque.horaFin}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(170),
fontFeatures: const [FontFeature.tabularFigures()],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
bloque.actividad.nombre,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildWideContent(ThemeData theme) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
children: [
// Hora — monoespaciada, ancho fijo
SizedBox(
width: 94,
child: Text(
'${bloque.horaInicio} ${bloque.horaFin}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
// Separador vertical sutil
Container(
width: 1,
height: 28,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: theme.colorScheme.surfaceContainerHighest,
),
// Nombre actividad + capacidad
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
bloque.actividad.nombre,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
_CapacidadBadge(
capacidad: bloque.actividad.capacidad,
),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return LayoutBuilder(
builder: (context, constraints) {
final isCompact = constraints.maxWidth < 200;
final tile = MouseRegion(
cursor:
onTap != null ? SystemMouseCursors.click : MouseCursor.defer,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Rail de actividad — color por actividad
Container(
width: 4,
color: ActivityColors.forId(bloque.actividad.id),
),
// Contenido
Expanded(
child: isCompact
? _buildCompactContent(theme)
: _buildWideContent(theme),
),
],
),
),
),
),
),
);
if (onDelete == null) return tile;
return GestureDetector(
onSecondaryTapDown: (details) {
showContextMenu<String>(
context,
contextMenu: ContextMenu<String>(
position: details.globalPosition,
entries: [
MenuItem(
label: const Text(
'Eliminar',
style: TextStyle(color: SomaColors.error),
),
icon: const Icon(Icons.delete_outline,
size: 16, color: SomaColors.error),
value: 'delete',
),
],
),
onItemSelected: (v) {
if (v == 'delete') onDelete!();
},
);
},
child: tile,
);
},
);
}
}
@@ -0,0 +1,531 @@
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/horarios/domain/entities/dia_especial.dart';
import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart';
const _colorEspecial = Color(0xFFFF9800);
const _colorCambio = Color(0xFF2196F3);
const _handleWidth = 22.0;
const _panelWidth = 280.0;
const _mesesLargos = [
'',
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
const _diasCortos = ['L', 'M', 'X', 'J', 'V', 'S', 'D'];
/// Panel de calendario que se desliza desde el borde derecho de la pantalla.
///
/// Debe colocarse con [Positioned(right: 0, top: 0, bottom: 0)] dentro de un
/// [Stack] que envuelva el área de contenido. La franja-handle (~22px) siempre
/// está visible en el borde derecho; al hacer clic el panel de 280px se
/// desliza hacia la izquierda superponiéndose sobre la tabla semanal.
///
/// Indicadores en el calendario:
/// • Naranja → día especial (cualquier tipo)
/// • Azul → arranca nueva plantilla regular ese día
class HorariosCalendarPanel extends ConsumerStatefulWidget {
final ValueChanged<DateTime> onNavigateToWeek;
const HorariosCalendarPanel({super.key, required this.onNavigateToWeek});
@override
ConsumerState<HorariosCalendarPanel> createState() =>
_HorariosCalendarPanelState();
}
class _HorariosCalendarPanelState
extends ConsumerState<HorariosCalendarPanel> {
bool _open = false;
late DateTime _month;
@override
void initState() {
super.initState();
final now = DateTime.now();
_month = DateTime(now.year, now.month);
}
void _toggle() => setState(() => _open = !_open);
void _prevMonth() => setState(
() => _month = DateTime(_month.year, _month.month - 1),
);
void _nextMonth() => setState(
() => _month = DateTime(_month.year, _month.month + 1),
);
@override
Widget build(BuildContext context) {
// Observamos los providers sólo con el panel abierto: así no disparamos sus
// RPC hasta que el usuario lo abre, y mientras está cerrado las
// invalidaciones tras escribir se fusionan en una sola recarga (relevante
// al copiar un día a varios destinos).
final especiales = _open
? (ref.watch(diasEspecialesProvider).valueOrNull ??
const <DiaEspecialResumen>[])
: const <DiaEspecialResumen>[];
final especSet = <DateTime>{};
for (final e in especiales) {
especSet.add(DateTime(e.fecha.year, e.fecha.month, e.fecha.day));
}
final cambiosAsync = _open ? ref.watch(diasCambioProvider) : null;
final diasCambio = cambiosAsync?.valueOrNull ?? const <DateTime>{};
final cargandoCambios = cambiosAsync?.isLoading ?? false;
// Row: [Panel animado (izq)] [Handle (der)]
// Posicionado con right:0, top:0, bottom:0 desde el parent Stack.
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Panel: crece de 0 → _panelWidth hacia la izquierda
ClipRect(
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOut,
width: _open ? _panelWidth : 0,
child: OverflowBox(
maxWidth: _panelWidth,
alignment: Alignment.centerRight,
child: _PanelContent(
month: _month,
diasEspeciales: especSet,
diasCambio: diasCambio,
cargandoCambios: cargandoCambios,
onPrevMonth: _prevMonth,
onNextMonth: _nextMonth,
onDayTap: (fecha) {
widget.onNavigateToWeek(fecha);
setState(() => _open = false);
},
),
),
),
),
// Handle: siempre visible en el borde derecho
_SideHandle(isOpen: _open, onTap: _toggle),
],
);
}
}
// ── Side handle ────────────────────────────────────────────────────────────────
class _SideHandle extends StatefulWidget {
final bool isOpen;
final VoidCallback onTap;
const _SideHandle({required this.isOpen, required this.onTap});
@override
State<_SideHandle> createState() => _SideHandleState();
}
class _SideHandleState extends State<_SideHandle> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visible = _hovered || widget.isOpen;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Opacity(
opacity: visible ? 1.0 : 0.0,
child: IgnorePointer(
ignoring: !visible,
child: GestureDetector(
onTap: widget.onTap,
child: Container(
width: _handleWidth,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
left: BorderSide(
color: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(10),
blurRadius: 4,
offset: const Offset(-2, 0),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.calendar_month_outlined,
size: 13,
color: theme.colorScheme.onSurface.withAlpha(130),
),
const SizedBox(height: 5),
AnimatedRotation(
turns: widget.isOpen ? 0.5 : 0,
duration: const Duration(milliseconds: 220),
child: Icon(
Icons.chevron_right,
size: 13,
color: theme.colorScheme.onSurface.withAlpha(90),
),
),
],
),
),
),
),
),
);
}
}
// ── Panel content ──────────────────────────────────────────────────────────────
class _PanelContent extends StatelessWidget {
final DateTime month;
final Set<DateTime> diasEspeciales;
final Set<DateTime> diasCambio;
final bool cargandoCambios;
final VoidCallback onPrevMonth;
final VoidCallback onNextMonth;
final ValueChanged<DateTime> onDayTap;
const _PanelContent({
required this.month,
required this.diasEspeciales,
required this.diasCambio,
required this.cargandoCambios,
required this.onPrevMonth,
required this.onNextMonth,
required this.onDayTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: _panelWidth,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
left: BorderSide(
color: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
),
child: Column(
children: [
_MonthHeader(
month: month,
onPrev: onPrevMonth,
onNext: onNextMonth,
),
_CalendarGrid(
month: month,
diasEspeciales: diasEspeciales,
diasCambio: diasCambio,
onDayTap: onDayTap,
),
const SizedBox(height: 10),
_Legend(cargandoCambios: cargandoCambios),
],
),
),
);
}
}
// ── Month header ───────────────────────────────────────────────────────────────
class _MonthHeader extends StatelessWidget {
final DateTime month;
final VoidCallback onPrev;
final VoidCallback onNext;
const _MonthHeader({
required this.month,
required this.onPrev,
required this.onNext,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 6),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left, size: 18),
onPressed: onPrev,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
Expanded(
child: Text(
'${_mesesLargos[month.month]} ${month.year}',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right, size: 18),
onPressed: onNext,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
],
),
);
}
}
// ── Calendar grid ──────────────────────────────────────────────────────────────
class _CalendarGrid extends StatelessWidget {
final DateTime month;
final Set<DateTime> diasEspeciales;
final Set<DateTime> diasCambio;
final ValueChanged<DateTime> onDayTap;
const _CalendarGrid({
required this.month,
required this.diasEspeciales,
required this.diasCambio,
required this.onDayTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final firstDay = DateTime(month.year, month.month, 1);
final offset = firstDay.weekday - 1; // Lun=0, Dom=6
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
final rows = ((offset + daysInMonth) / 7).ceil();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Headers de días
Row(
children: _diasCortos.map((d) {
return Expanded(
child: Center(
child: Text(
d,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
),
);
}).toList(),
),
const SizedBox(height: 2),
// Filas de días
...List.generate(rows, (row) {
return Row(
children: List.generate(7, (col) {
final dayNum = row * 7 + col - offset + 1;
if (dayNum < 1 || dayNum > daysInMonth) {
return const Expanded(child: SizedBox(height: 34));
}
final fecha = DateTime(month.year, month.month, dayNum);
return Expanded(
child: _DayCell(
day: dayNum,
isToday: fecha == today,
isEspecial: diasEspeciales.contains(fecha),
isCambio: diasCambio.contains(fecha),
onTap: () => onDayTap(fecha),
),
);
}),
);
}),
],
),
);
}
}
// ── Day cell ───────────────────────────────────────────────────────────────────
class _DayCell extends StatelessWidget {
final int day;
final bool isToday;
final bool isEspecial;
final bool isCambio;
final VoidCallback onTap;
const _DayCell({
required this.day,
required this.isToday,
required this.isEspecial,
required this.isCambio,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Container(
height: 34,
margin: const EdgeInsets.all(1),
decoration: isToday
? BoxDecoration(
color: SomaColors.primary.withAlpha(50),
borderRadius: BorderRadius.circular(6),
)
: null,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$day',
style: TextStyle(
fontSize: 12,
fontWeight: isToday ? FontWeight.w700 : FontWeight.w500,
color: isToday
? SomaColors.primaryText
: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 2),
// Espacio reservado siempre para mantener altura uniforme
SizedBox(
height: 5,
child: (isEspecial || isCambio)
? Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (isEspecial) const _Dot(color: _colorEspecial),
if (isEspecial && isCambio) const SizedBox(width: 2),
if (isCambio) const _Dot(color: _colorCambio),
],
)
: null,
),
],
),
),
);
}
}
class _Dot extends StatelessWidget {
final Color color;
const _Dot({required this.color});
@override
Widget build(BuildContext context) {
return Container(
width: 4,
height: 4,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
}
}
// ── Legend ─────────────────────────────────────────────────────────────────────
class _Legend extends StatelessWidget {
final bool cargandoCambios;
const _Legend({required this.cargandoCambios});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
_LegendItem(color: _colorEspecial, label: 'Día especial'),
const SizedBox(width: 14),
if (cargandoCambios)
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 8,
height: 8,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: theme.colorScheme.onSurface.withAlpha(80),
),
),
const SizedBox(width: 5),
Text(
'Cargando...',
style: TextStyle(
fontSize: 10,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
],
)
else
_LegendItem(color: _colorCambio, label: 'Nuevo horario'),
],
),
);
}
}
class _LegendItem extends StatelessWidget {
final Color color;
final String label;
const _LegendItem({required this.color, required this.label});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 10,
color: theme.colorScheme.onSurface.withAlpha(120),
),
),
],
);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/dia_columna.dart';
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo',
];
const _minColumnWidth = 160.0;
bool _isSameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
class SemanaTablaView extends StatelessWidget {
final SemanaHorarios semana;
/// Índices de días a mostrar (0 = Lunes … 6 = Domingo).
final List<int> diasVisibles;
final DateTime weekStart;
final DateTime? selectedDay;
final bool isAdmin;
final void Function(DateTime) onSelectDia;
final void Function(DateTime, DiaHorarios?) onEditarDia;
final void Function(DiaHorarios)? onCopiarDia;
final void Function(DiaHorarios, BloqueHorario)? onEliminarBloque;
const SemanaTablaView({
super.key,
required this.semana,
required this.diasVisibles,
required this.weekStart,
required this.selectedDay,
required this.isAdmin,
required this.onSelectDia,
required this.onEditarDia,
this.onCopiarDia,
this.onEliminarBloque,
});
@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 isSelected =
selectedDay != null && _isSameDay(fecha, selectedDay!);
if (i > 0) {
rowChildren.add(Container(
width: 1,
color: theme.colorScheme.surfaceContainerHighest,
));
}
final columna = DiaColumna(
fecha: fecha,
nombreDia: _diasSemana[diaIdx],
dia: dia,
isSelected: isSelected,
isAdmin: isAdmin,
onSelectDia: () => onSelectDia(fecha),
onEditarDia: (f, d) => onEditarDia(f, d),
onCopiarDia: dia != null &&
!dia.esEspecial &&
dia.bloques.isNotEmpty &&
onCopiarDia != null
? () => onCopiarDia!(dia)
: null,
onEliminarBloque: dia != null && onEliminarBloque != null
? (bloque) => onEliminarBloque!(dia, bloque)
: null,
);
rowChildren.add(
useScroll
? SizedBox(width: _minColumnWidth, child: columna)
: Expanded(child: columna),
);
}
final row = Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: rowChildren,
);
if (!useScroll) return row;
// Ancho total: columnas + separadores de 1px
final totalWidth =
count * _minColumnWidth + (count - 1).toDouble();
return Scrollbar(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: totalWidth,
height: constraints.maxHeight,
child: row,
),
),
);
},
);
}
}
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.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',
];
/// Selector de fecha para el campo `valido_desde` de un horario regular.
///
/// [weekdayTarget] (1=Lun..7=Dom) restringe la selección a fechas del mismo
/// día de la semana que el día siendo editado. Pasar `null` para permitir
/// cualquier fecha (útil cuando se copia a múltiples días de la semana).
class ValidoDesdeSelector extends StatelessWidget {
final DateTime fecha;
final int? weekdayTarget;
final ValueChanged<DateTime> onChanged;
const ValidoDesdeSelector({
super.key,
required this.fecha,
required this.weekdayTarget,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final label =
'${_diasSemana[fecha.weekday - 1]}, ${fecha.day} ${_mesesCortos[fecha.month]} ${fecha.year}';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Entra en vigor el',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 6),
InkWell(
onTap: () async {
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
final target = weekdayTarget;
final DateTime initial;
if (target != null) {
if (!fecha.isBefore(hoy) && fecha.weekday == target) {
initial = fecha;
} else {
final diff = (target - hoy.weekday + 7) % 7;
initial = hoy.add(Duration(days: diff));
}
} else {
initial = fecha.isBefore(hoy) ? hoy : fecha;
}
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: hoy,
lastDate: DateTime(2100),
selectableDayPredicate:
target != null ? (d) => d.weekday == target : null,
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: SomaColors.primary,
onPrimary: SomaColors.onPrimary,
),
),
child: child!,
),
);
if (picked != null) onChanged(picked);
},
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: theme.colorScheme.surfaceContainerHighest.withAlpha(60),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
children: [
const Icon(Icons.schedule, size: 18, color: SomaColors.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
Icon(
Icons.expand_more,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(120),
),
],
),
),
),
],
);
}
}
@@ -0,0 +1,62 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/config/supabase_config.dart';
import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart';
import 'package:gimnasio_soma/features/huerfanas/domain/repositories/huerfanas_repository.dart';
class HuerfanasRepositoryImpl implements HuerfanasRepository {
Future<String> _getToken() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token == null) throw Exception('Sin sesión activa');
return token;
}
@override
Future<List<ReservaHuerfana>> obtenerHuerfanas({String? estado}) async {
final token = await _getToken();
final params = <String, dynamic>{'p_token': token};
if (estado != null) params['p_estado'] = estado;
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerReservasHuerfanas,
params: params,
);
if (response is List) {
return response
.map((e) => ReservaHuerfana.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<void> resolverHuerfana(String huerfanaId, String nuevoEstado) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcResolverHuerfana,
params: {
'p_token': token,
'p_huerfana_id': huerfanaId,
'p_nuevo_estado': nuevoEstado,
},
);
}
@override
Future<void> moverHuerfana(String huerfanaId, String turnoId) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcMoverReservaHuerfana,
params: {
'p_token': token,
'p_huerfana_id': huerfanaId,
'p_turno_id': turnoId,
},
);
}
}
@@ -0,0 +1,59 @@
enum EstadoHuerfana { pendiente, reubicado, resuelta }
class ReservaHuerfana {
final String huerfanaId;
final String clienteId;
final String nombre;
final String? apellido;
final String? telefono;
final String actividadNombre;
final String fechaOriginal;
final String horaInicioOriginal;
final EstadoHuerfana estado;
final String creadaEn;
const ReservaHuerfana({
required this.huerfanaId,
required this.clienteId,
required this.nombre,
this.apellido,
this.telefono,
required this.actividadNombre,
required this.fechaOriginal,
required this.horaInicioOriginal,
required this.estado,
required this.creadaEn,
});
String get displayName =>
apellido != null ? '$nombre $apellido' : nombre;
String get initials {
final parts = displayName.trim().split(' ');
if (parts.length == 1) return parts[0][0].toUpperCase();
return '${parts[0][0]}${parts.last[0]}'.toUpperCase();
}
static EstadoHuerfana _parseEstado(String s) {
return switch (s) {
'reubicado' => EstadoHuerfana.reubicado,
'resuelta' => EstadoHuerfana.resuelta,
_ => EstadoHuerfana.pendiente,
};
}
factory ReservaHuerfana.fromMap(Map<String, dynamic> m) {
return ReservaHuerfana(
huerfanaId: m['huerfana_id'] as String,
clienteId: m['cliente_id'] as String,
nombre: m['nombre'] as String,
apellido: m['apellido'] as String?,
telefono: m['telefono'] as String?,
actividadNombre: m['actividad_nombre'] as String,
fechaOriginal: m['fecha_original'] as String,
horaInicioOriginal: m['hora_inicio_original'] as String,
estado: _parseEstado(m['estado_resolucion'] as String? ?? 'pendiente'),
creadaEn: m['creada_en'] as String,
);
}
}
@@ -0,0 +1,13 @@
import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart';
abstract class HuerfanasRepository {
/// [estado] puede ser 'pendiente', 'reubicado', 'resuelta', o null para todos.
Future<List<ReservaHuerfana>> obtenerHuerfanas({String? estado});
/// [nuevoEstado] debe ser 'pendiente', 'reubicado' o 'resuelta'.
Future<void> resolverHuerfana(String huerfanaId, String nuevoEstado);
/// Reserva [turnoId] para el cliente de la huérfana y la marca como 'reubicado'
/// en una sola transacción atómica.
Future<void> moverHuerfana(String huerfanaId, String turnoId);
}
@@ -0,0 +1,140 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:gimnasio_soma/features/huerfanas/data/repositories/huerfanas_repository_impl.dart';
import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart';
import 'package:gimnasio_soma/features/huerfanas/domain/repositories/huerfanas_repository.dart';
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
final huerfanasRepositoryProvider = Provider<HuerfanasRepository>((ref) {
return HuerfanasRepositoryImpl();
});
String _errorMessage(Object e) {
if (e is PostgrestException) return e.message;
return e.toString().replaceFirst('Exception: ', '');
}
final huerfanasProvider =
StateNotifierProvider<HuerfanasNotifier, AsyncValue<List<ReservaHuerfana>>>(
(ref) {
return HuerfanasNotifier(ref, ref.read(huerfanasRepositoryProvider));
});
class HuerfanasNotifier
extends StateNotifier<AsyncValue<List<ReservaHuerfana>>> {
final Ref _ref;
final HuerfanasRepository _repository;
String? _currentEstado = 'pendiente';
HuerfanasNotifier(this._ref, this._repository)
: super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
try {
final data = await _repository.obtenerHuerfanas(estado: _currentEstado);
state = AsyncValue.data(data);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<void> filtrar(String? estado) async {
_currentEstado = estado;
await load();
}
String? get currentEstado => _currentEstado;
/// Retorna null si tuvo éxito, o un mensaje de error.
Future<String?> resolver(String huerfanaId, String nuevoEstado) async {
try {
await _repository.resolverHuerfana(huerfanaId, nuevoEstado);
await load();
return null;
} catch (e) {
return _errorMessage(e);
}
}
/// Reserva el turno y marca la huérfana como 'reubicado' atómicamente.
/// Retorna null si tuvo éxito, o un mensaje de error.
///
/// La reubicación ocupa un cupo en [turnoId]. Invalidamos turnosProvider
/// para que la pantalla de Turnos no muestre un cupo desactualizado si ya
/// tenía esa semana cacheada de antes.
Future<String?> mover(String huerfanaId, String turnoId) async {
try {
await _repository.moverHuerfana(huerfanaId, turnoId);
await load();
_ref.invalidate(turnosProvider);
return null;
} catch (e) {
return _errorMessage(e);
}
}
/// Marca todas las huérfanas del conjunto como 'resuelta' (best-effort).
Future<void> notificarLote(Iterable<String> ids) async {
for (final id in ids) {
try {
await _repository.resolverHuerfana(id, 'resuelta');
} catch (_) {
// best-effort: continúa con las demás aunque alguna falle
}
}
await load();
}
}
// ── Selección múltiple ────────────────────────────────────────────────────────
final huerfanasModoSeleccionProvider = StateProvider<bool>((ref) => false);
class _SeleccionNotifier extends StateNotifier<Set<String>> {
_SeleccionNotifier() : super({});
void toggle(String id) {
final next = {...state};
if (next.contains(id)) {
next.remove(id);
} else {
next.add(id);
}
state = next;
}
void limpiar() => state = {};
}
final huerfanasSeleccionProvider =
StateNotifierProvider<_SeleccionNotifier, Set<String>>(
(ref) => _SeleccionNotifier());
// ── Badge sidebar ─────────────────────────────────────────────────────────────
/// Cantidad de reservas huérfanas pendientes — usado para el badge en sidebar.
///
/// Observa [huerfanasProvider] para recomputarse tras cualquier mutación.
/// Si el filtro activo es 'pendiente' o null derivamos el conteo en memoria
/// (sin RPC extra). Si el filtro es otro, hacemos una consulta independiente.
final huerfanasPendienteCountProvider =
FutureProvider.autoDispose<int>((ref) async {
final state = ref.watch(huerfanasProvider);
final notifier = ref.read(huerfanasProvider.notifier);
final lista = state.valueOrNull;
if (lista != null) {
if (notifier.currentEstado == 'pendiente') return lista.length;
if (notifier.currentEstado == null) {
return lista.where((r) => r.estado == EstadoHuerfana.pendiente).length;
}
}
// Filtro activo no es pendiente/todas: hacemos la consulta directa.
final repo = ref.read(huerfanasRepositoryProvider);
final pendientes = await repo.obtenerHuerfanas(estado: 'pendiente');
return pendientes.length;
});
@@ -0,0 +1,926 @@
import 'package:flutter/material.dart';
import 'package:flutter_context_menu/flutter_context_menu.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_header_help.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/huerfanas/presentation/widgets/bulk_notify_dialog.dart';
import 'package:gimnasio_soma/features/huerfanas/presentation/widgets/turno_picker_sheet.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
const _meses = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
class HuerfanasScreen extends ConsumerWidget {
const HuerfanasScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(huerfanasProvider);
final notifier = ref.read(huerfanasProvider.notifier);
final currentEstado = notifier.currentEstado;
final isWide = MediaQuery.of(context).size.width >= 800;
final theme = Theme.of(context);
final modoSeleccion = ref.watch(huerfanasModoSeleccionProvider);
final seleccionadas = ref.watch(huerfanasSeleccionProvider);
final seleccionNotifier = ref.read(huerfanasSeleccionProvider.notifier);
// Sólo aplica a pendientes
final lista = state.valueOrNull ?? [];
final pendientes = lista
.where((r) => r.estado == EstadoHuerfana.pendiente)
.toList();
final seleccionadasValidas = seleccionadas
.where((id) => pendientes.any((r) => r.huerfanaId == id))
.toSet();
void toggleModoSeleccion() {
if (modoSeleccion) {
seleccionNotifier.limpiar();
}
ref.read(huerfanasModoSeleccionProvider.notifier).state = !modoSeleccion;
}
void abrirBulkNotify() {
final items = pendientes
.where((r) => seleccionadasValidas.contains(r.huerfanaId))
.toList();
if (items.isEmpty) return;
showDialog(
context: context,
builder: (_) => BulkNotifyDialog(seleccionadas: items),
).then((_) {
// limpiar selección al cerrar el dialog
seleccionNotifier.limpiar();
ref.read(huerfanasModoSeleccionProvider.notifier).state = false;
});
}
void abrirPickerSheet(ReservaHuerfana reserva) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => TurnoPickerSheet(
reserva: reserva,
onReubicadoExito: (Turno turno, DateTime fecha) {
if (!context.mounted) return;
final fechaStr =
'${fecha.day} ${_meses[fecha.month]} ${turno.horaInicio}';
SomaToast.show(
context,
message: 'Reubicado al $fechaStr',
type: ToastType.success,
action: reserva.telefono != null
? SnackBarAction(
label: 'Avisar por WhatsApp',
textColor: SomaColors.onPrimary,
onPressed: () => WhatsAppService.abrirChat(
telefono: reserva.telefono,
mensaje: 'Hola ${reserva.nombre}, te reasignamos al turno de '
'${turno.actividad.nombre} del $fechaStr. ¡Te esperamos!',
),
)
: null,
);
},
),
);
}
Future<void> abrirWhatsApp(ReservaHuerfana reserva) async {
final nombre = reserva.nombre;
final actividad = reserva.actividadNombre;
final d = DateTime.tryParse(reserva.fechaOriginal);
final fecha = d != null ? '${d.day} ${_meses[d.month]} ${d.year}' : reserva.fechaOriginal;
final ok = await WhatsAppService.abrirChat(
telefono: reserva.telefono,
mensaje: 'Hola $nombre, te contactamos desde el gimnasio SOMA. '
'Tu reserva de $actividad del $fecha quedó sin turno disponible. '
'Por favor, coordiná una nueva reserva cuando puedas. ¡Muchas gracias!',
);
if (!context.mounted) return;
if (!ok) {
final sinNumero = WhatsAppService.normalizarNumeroAr(reserva.telefono) == null;
SomaToast.show(
context,
message: sinNumero
? '${reserva.displayName} no tiene número de teléfono registrado. '
'Podés agregarlo desde la pantalla de Usuarios.'
: 'No se pudo abrir WhatsApp.',
type: ToastType.error,
);
return;
}
// Sólo ofrecer marcar si está pendiente
if (reserva.estado == EstadoHuerfana.pendiente) {
SomaToast.show(
context,
message: 'WhatsApp abierto',
type: ToastType.info,
action: SnackBarAction(
label: 'Marcar resuelta',
textColor: SomaColors.onPrimary,
onPressed: () async {
final error = await ref
.read(huerfanasProvider.notifier)
.resolver(reserva.huerfanaId, 'resuelta');
if (!context.mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
}
},
),
);
}
}
return Scaffold(
body: Column(
children: [
// ── Header ──────────────────────────────────────────────────────────
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
0,
),
child: Row(
children: [
const Text(
'Reservas sin turno',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SomaHeaderHelp(
items: [
SomaHelpItem(
icon: Icons.filter_alt_outlined,
text: 'Filtrá por estado: pendientes, reubicadas, '
'resueltas o todas.',
),
SomaHelpItem(
icon: Icons.checklist_outlined,
text: 'Modo selección: elegí varias reservas para '
'notificarlas por WhatsApp de una sola vez.',
),
SomaHelpItem(
icon: Icons.refresh,
text: 'Recarga la lista de reservas sin turno.',
),
],
),
const Spacer(),
// Toggle selección múltiple
IconButton(
icon: Icon(
modoSeleccion
? Icons.checklist_rounded
: Icons.checklist_outlined,
size: 20,
color: modoSeleccion
? SomaColors.primary
: null,
),
tooltip: modoSeleccion ? 'Cancelar selección' : 'Seleccionar',
onPressed: toggleModoSeleccion,
),
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Recargar',
onPressed: () {
seleccionNotifier.limpiar();
ref.read(huerfanasModoSeleccionProvider.notifier).state =
false;
notifier.load();
},
),
],
),
),
// ── Filter chips (ocultos en modo selección) ─────────────────────
if (!modoSeleccion)
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 12, isWide ? 32 : 16, 0,
),
child: Row(
children: [
_FilterChip(
label: 'Pendientes',
selected: currentEstado == 'pendiente',
color: SomaColors.error,
onTap: () => notifier.filtrar('pendiente'),
),
const SizedBox(width: 8),
_FilterChip(
label: 'Reubicadas',
selected: currentEstado == 'reubicado',
color: SomaColors.primary,
onTap: () => notifier.filtrar('reubicado'),
),
const SizedBox(width: 8),
_FilterChip(
label: 'Resueltas',
selected: currentEstado == 'resuelta',
color: theme.colorScheme.secondary,
onTap: () => notifier.filtrar('resuelta'),
),
const SizedBox(width: 8),
_FilterChip(
label: 'Todas',
selected: currentEstado == null,
color: theme.colorScheme.onSurface,
onTap: () => notifier.filtrar(null),
),
],
),
)
else
// Etiqueta modo selección
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 12, isWide ? 32 : 16, 0,
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 14,
color: theme.colorScheme.onSurface.withAlpha(120),
),
const SizedBox(width: 6),
Text(
'Seleccioná los clientes pendientes que querés notificar en lote',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(140),
),
),
],
),
),
const SizedBox(height: 8),
// ── Content ──────────────────────────────────────────────────────
Expanded(
child: state.when(
loading: () => const Center(
child: CircularProgressIndicator(color: SomaColors.primary),
),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 48,
color: theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(height: 12),
Text(
e.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () => notifier.load(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (lista) {
// En modo selección sólo mostramos pendientes
final listaFiltrada =
modoSeleccion ? pendientes : lista;
if (listaFiltrada.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline,
size: 56,
color:
theme.colorScheme.onSurface.withAlpha(60)),
const SizedBox(height: 12),
Text(
modoSeleccion
? 'Sin reservas pendientes para notificar'
: currentEstado == 'pendiente'
? 'Sin reservas pendientes'
: 'Sin reservas en este estado',
style: TextStyle(
fontSize: 15,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
);
}
return ListView.separated(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 8, isWide ? 32 : 16, 32,
),
itemCount: listaFiltrada.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final reserva = listaFiltrada[index];
return _HuerfanaCard(
reserva: reserva,
modoSeleccion: modoSeleccion,
seleccionada: seleccionadasValidas
.contains(reserva.huerfanaId),
onToggleSeleccion: () =>
seleccionNotifier.toggle(reserva.huerfanaId),
onWhatsApp:
() => abrirWhatsApp(reserva),
onReubicar: () => abrirPickerSheet(reserva),
onResolver: (nuevoEstado) async {
final error = await ref
.read(huerfanasProvider.notifier)
.resolver(reserva.huerfanaId, nuevoEstado);
if (!context.mounted) return;
if (error != null) {
SomaToast.show(context,
message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Estado actualizado',
type: ToastType.success);
}
},
);
},
);
},
),
),
// ── Bottom bar de selección ──────────────────────────────────────
if (modoSeleccion)
_SelectionBar(
count: seleccionadasValidas.length,
onNotificar: seleccionadasValidas.isEmpty ? null : abrirBulkNotify,
onCancelar: toggleModoSeleccion,
),
],
),
);
}
}
// ── Filter chip ───────────────────────────────────────────────────────────────
class _FilterChip extends StatelessWidget {
final String label;
final bool selected;
final Color color;
final VoidCallback onTap;
const _FilterChip({
required this.label,
required this.selected,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
mouseCursor: SystemMouseCursors.click,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: selected ? color.withAlpha(22) : Colors.transparent,
border: Border.all(
color: selected ? color.withAlpha(100) : color.withAlpha(40),
width: 1,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? color : color.withAlpha(150),
),
),
),
);
}
}
// ── Card de reserva huérfana ──────────────────────────────────────────────────
class _HuerfanaCard extends StatelessWidget {
final ReservaHuerfana reserva;
final bool modoSeleccion;
final bool seleccionada;
final VoidCallback onToggleSeleccion;
final Future<void> Function() onWhatsApp;
final VoidCallback onReubicar;
final Future<void> Function(String nuevoEstado) onResolver;
const _HuerfanaCard({
required this.reserva,
required this.modoSeleccion,
required this.seleccionada,
required this.onToggleSeleccion,
required this.onWhatsApp,
required this.onReubicar,
required this.onResolver,
});
String _fmtFecha(String fechaIso) {
final d = DateTime.tryParse(fechaIso);
if (d == null) return fechaIso;
return '${d.day} ${_meses[d.month]} ${d.year}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isPendiente = reserva.estado == EstadoHuerfana.pendiente;
return GestureDetector(
onTap: modoSeleccion ? onToggleSeleccion : null,
onSecondaryTapDown: modoSeleccion
? null
: (details) {
final entries = <ContextMenuEntry<String>>[];
if (isPendiente) {
entries.add(MenuItem(
label: const Text('Reubicar'),
icon: const Icon(Icons.swap_horiz, size: 16),
value: 'reubicar',
));
if (reserva.telefono != null) {
entries.add(MenuItem(
label: const Text('Notificar por WhatsApp'),
icon: const Icon(Icons.chat_outlined,
size: 16, color: Color(0xFF25D366)),
value: 'whatsapp',
));
}
entries.add(MenuItem(
label: const Text('Marcar como resuelta'),
icon: const Icon(Icons.notifications_none, size: 16),
value: 'resuelta',
));
} else {
if (reserva.telefono != null) {
entries.add(MenuItem(
label: const Text('Notificar por WhatsApp'),
icon: const Icon(Icons.chat_outlined,
size: 16, color: Color(0xFF25D366)),
value: 'whatsapp',
));
}
entries.add(MenuItem(
label: const Text('Volver a pendiente'),
icon: const Icon(Icons.undo, size: 16),
value: 'pendiente',
));
}
showContextMenu<String>(
context,
contextMenu: ContextMenu<String>(
position: details.globalPosition,
entries: entries,
),
onItemSelected: (v) {
switch (v) {
case 'reubicar':
onReubicar();
case 'whatsapp':
onWhatsApp();
case 'resuelta':
onResolver('resuelta');
case 'pendiente':
onResolver('pendiente');
}
},
);
},
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: modoSeleccion && seleccionada
? SomaColors.primary.withAlpha(140)
: theme.colorScheme.surfaceContainerHighest,
width: modoSeleccion && seleccionada ? 1.5 : 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Checkbox en modo selección / rail de estado normal
if (modoSeleccion)
_SelectionRail(seleccionada: seleccionada)
else
Container(
width: 4,
color: switch (reserva.estado) {
EstadoHuerfana.pendiente => SomaColors.error.withAlpha(180),
EstadoHuerfana.reubicado => SomaColors.primary.withAlpha(180),
EstadoHuerfana.resuelta =>
theme.colorScheme.secondary.withAlpha(180),
},
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row superior: avatar + info + badge
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 18,
backgroundColor: SomaColors.primary.withAlpha(30),
child: Text(
reserva.initials,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: SomaColors.primaryText,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
reserva.displayName,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (reserva.telefono != null)
Text(
reserva.telefono!,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
],
),
),
if (!modoSeleccion) _EstadoBadge(estado: reserva.estado),
],
),
const SizedBox(height: 10),
// Actividad + fecha
Row(
children: [
Icon(Icons.sports_gymnastics_outlined,
size: 14,
color: theme.colorScheme.onSurface.withAlpha(120)),
const SizedBox(width: 5),
Expanded(
child: Text(
reserva.actividadNombre,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(180),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Icon(Icons.calendar_today_outlined,
size: 13,
color: theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(width: 4),
Text(
'${_fmtFecha(reserva.fechaOriginal)} ${reserva.horaInicioOriginal}',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(150),
fontFeatures: const [
FontFeature.tabularFigures()
],
),
),
],
),
// Acciones (sólo cuando no estamos en modo selección)
if (!modoSeleccion) ...[
if (isPendiente) ...[
const SizedBox(height: 10),
Row(
children: [
_ActionButton(
icon: Icons.chat_outlined,
label: 'WhatsApp',
color: const Color(0xFF25D366),
onTap: onWhatsApp,
),
const SizedBox(width: 8),
_ActionButton(
icon: Icons.swap_horiz,
label: 'Reubicar',
color: SomaColors.primary,
onTap: () => onReubicar(),
),
const SizedBox(width: 8),
_ActionButton(
icon: Icons.notifications_none,
label: 'Resuelta',
color: theme.colorScheme.secondary,
onTap: () => onResolver('resuelta'),
),
],
),
] else ...[
const SizedBox(height: 6),
Row(
children: [
_ActionButton(
icon: Icons.chat_outlined,
label: 'WhatsApp',
color: const Color(0xFF25D366),
onTap: onWhatsApp,
),
const SizedBox(width: 8),
InkWell(
onTap: () => onResolver('pendiente'),
borderRadius: BorderRadius.circular(6),
mouseCursor: SystemMouseCursors.click,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
child: Text(
'Volver a pendiente',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface
.withAlpha(120),
decoration: TextDecoration.underline,
decorationColor:
theme.colorScheme.onSurface
.withAlpha(80),
),
),
),
),
],
),
],
],
],
),
),
),
],
),
),
),
);
}
}
// ── Rail de selección ─────────────────────────────────────────────────────────
class _SelectionRail extends StatelessWidget {
final bool seleccionada;
const _SelectionRail({required this.seleccionada});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 40,
color: seleccionada
? SomaColors.primary.withAlpha(20)
: Colors.transparent,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 18,
height: 18,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: seleccionada ? SomaColors.primary : Colors.transparent,
border: Border.all(
color: seleccionada
? SomaColors.primary
: Theme.of(context).colorScheme.onSurface.withAlpha(80),
width: 1.5,
),
),
child: seleccionada
? const Icon(Icons.check, size: 12, color: SomaColors.onPrimary)
: null,
),
),
);
}
}
// ── Badge de estado ───────────────────────────────────────────────────────────
class _EstadoBadge extends StatelessWidget {
final EstadoHuerfana estado;
const _EstadoBadge({required this.estado});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final (label, color) = switch (estado) {
EstadoHuerfana.pendiente => ('Pendiente', SomaColors.error),
EstadoHuerfana.reubicado => ('Reubicado', SomaColors.primary),
EstadoHuerfana.resuelta =>
('Resuelta', theme.colorScheme.secondary),
};
final textColor = estado == EstadoHuerfana.reubicado
? SomaColors.primaryText
: color;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(18),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: color.withAlpha(60), width: 0.5),
),
child: Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: textColor,
),
),
);
}
}
// ── Botón de acción ───────────────────────────────────────────────────────────
class _ActionButton extends StatefulWidget {
final IconData icon;
final String label;
final Color color;
final dynamic Function() onTap;
const _ActionButton({
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
@override
State<_ActionButton> createState() => _ActionButtonState();
}
class _ActionButtonState extends State<_ActionButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: () => widget.onTap(),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(7),
color: widget.color.withAlpha(_hovered ? 38 : 16),
border: Border.all(
color: widget.color.withAlpha(_hovered ? 110 : 60),
width: _hovered ? 0.8 : 0.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(widget.icon, size: 13, color: widget.color),
const SizedBox(width: 5),
Text(
widget.label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: widget.color,
),
),
],
),
),
),
);
}
}
// ── Bottom bar de selección ───────────────────────────────────────────────────
class _SelectionBar extends StatelessWidget {
final int count;
final VoidCallback? onNotificar;
final VoidCallback onCancelar;
const _SelectionBar({
required this.count,
required this.onNotificar,
required this.onCancelar,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 14),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
top: BorderSide(
color: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
),
child: Row(
children: [
Text(
count == 0
? 'Sin selección'
: count == 1
? '1 seleccionado'
: '$count seleccionados',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const Spacer(),
TextButton(
onPressed: onCancelar,
child: const Text('Cancelar'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 38)),
onPressed: onNotificar,
icon: const Icon(Icons.chat_outlined, size: 16),
label: Text(
count == 0
? 'Notificar en lote'
: 'Notificar $count por WhatsApp',
),
),
],
),
);
}
}
@@ -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),
),
),
],
),
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,156 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/config/supabase_config.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/metodo_pago.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
import 'package:gimnasio_soma/features/pagos/domain/repositories/pagos_repository.dart';
class PagosRepositoryImpl implements PagosRepository {
Future<String> _getToken() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token == null) throw Exception('Sin sesión activa');
return token;
}
@override
Future<List<Pago>> getPagos({
int pagina = 1,
int cantidad = 50,
String? dni,
bool incluirAnulados = false,
}) async {
final token = await _getToken();
final params = <String, dynamic>{
'p_token': token,
'p_pagina': pagina,
'p_cantidad': cantidad,
'p_incluir_anulados': incluirAnulados,
};
if (dni != null && dni.isNotEmpty) {
params['p_dni'] = dni;
}
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetPagos,
params: params,
);
if (response is List) {
return response
.map((e) => Pago.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<List<Pago>> getMisPagos({
int pagina = 1,
int cantidad = 20,
}) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetMisPagos,
params: {
'p_token': token,
'p_pagina': pagina,
'p_cantidad': cantidad,
},
);
if (response is List) {
return response
.map((e) => Pago.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<void> insertPago(Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcInsertPago,
params: {
'p_datos': datos,
'p_token': token,
},
);
}
@override
Future<Pago> editPago(String id, Map<String, dynamic> datos) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcEditarPago,
params: {
'p_id': id,
'p_datos': datos,
'p_token': token,
},
);
if (response is Map<String, dynamic>) {
return Pago.fromMap(response);
}
throw Exception('Respuesta inválida al editar el pago.');
}
@override
Future<Pago> anularPago(String id, String? motivo) async {
final token = await _getToken();
// El backend hace NULLIF(trim(p_motivo), ''); igualmente normalizamos
// a null antes de mandar para evitar enviar whitespace innecesario.
final motivoNormalizado =
(motivo == null || motivo.trim().isEmpty) ? null : motivo;
final response = await SupabaseConfig.rpc(
AppConstants.rpcAnularPago,
params: {
'p_id': id,
'p_motivo': motivoNormalizado,
'p_token': token,
},
);
if (response is Map<String, dynamic>) {
return Pago.fromMap(response);
}
throw Exception('Respuesta inválida al anular el pago.');
}
@override
Future<List<MetodoPago>> getMetodosPago() async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetMetodosPago,
params: {'p_token': token},
);
if (response is List) {
return response
.map((e) => MetodoPago.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<void> updateMetodoPago(Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcUpdateMetodoPago,
params: {
'p_token': token,
'p_datos': datos,
},
);
}
}
@@ -0,0 +1,22 @@
class MetodoPago {
final int id;
final String descripcion;
final bool activo;
final String? icono;
const MetodoPago({
required this.id,
required this.descripcion,
this.activo = true,
this.icono,
});
factory MetodoPago.fromMap(Map<String, dynamic> map) {
return MetodoPago(
id: map['id'] as int,
descripcion: map['descripcion'] as String? ?? '',
activo: map['activo'] as bool? ?? true,
icono: map['icono'] as String?,
);
}
}
@@ -0,0 +1,158 @@
import 'package:gimnasio_soma/features/pagos/domain/entities/pago_tipo.dart';
class Pago {
final String id;
final PagoTipo tipo;
// Cuando tipo == ajuste, anioMesPagado puede venir vacío (NULL en DB).
final String anioMesPagado; // date string "YYYY-MM-DD" (siempre día 1) o vacío
final DateTime? fechaPago;
final double montoTotal;
// Cuando tipo == ajuste, metodo puede venir vacío (NULL en DB).
final String metodo;
final Map<String, dynamic>? detalle;
// Sólo presente en fc_obtener_pagos (admin), no en fc_obtener_mis_pagos.
final PagoCliente? cliente;
// Auditoría de creación. createdBy es el UUID del autor — habilita
// gating local "este pago lo creé yo".
final DateTime? createdAt;
final String? createdBy;
final String? createdByNombre;
// Auditoría de última edición (null si nunca se editó).
final DateTime? updatedAt;
final String? updatedByNombre;
// Auditoría de anulación (soft-delete).
final DateTime? anuladoAt;
final String? anuladoPorNombre;
final String? motivoAnulacion;
const Pago({
required this.id,
required this.tipo,
required this.anioMesPagado,
this.fechaPago,
required this.montoTotal,
required this.metodo,
this.detalle,
this.cliente,
this.createdAt,
this.createdBy,
this.createdByNombre,
this.updatedAt,
this.updatedByNombre,
this.anuladoAt,
this.anuladoPorNombre,
this.motivoAnulacion,
});
factory Pago.fromMap(Map<String, dynamic> map) {
return Pago(
id: map['id'] as String,
tipo: PagoTipo.fromString(map['tipo'] as String?),
anioMesPagado: map['anio_mes_pagado'] as String? ?? '',
fechaPago: _parseDate(map['fecha_pago']),
montoTotal: (map['monto_total'] as num?)?.toDouble() ?? 0,
metodo: map['metodo'] as String? ?? '',
detalle: map['detalle'] as Map<String, dynamic>?,
cliente: map['cliente'] != null
? PagoCliente.fromMap(map['cliente'] as Map<String, dynamic>)
: null,
createdAt: _parseDate(map['created_at']),
createdBy: map['created_by'] as String?,
createdByNombre: map['created_by_nombre'] as String?,
updatedAt: _parseDate(map['updated_at']),
updatedByNombre: map['updated_by_nombre'] as String?,
anuladoAt: _parseDate(map['anulado_at']),
anuladoPorNombre: map['anulado_por_nombre'] as String?,
motivoAnulacion: map['motivo_anulacion'] as String?,
);
}
static DateTime? _parseDate(dynamic raw) {
if (raw == null) return null;
return DateTime.tryParse(raw.toString());
}
/// Mes y año formateado: "Marzo 2026"
String get mesPagadoDisplay {
final date = DateTime.tryParse(anioMesPagado);
if (date == null) return anioMesPagado;
const meses = [
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
return '${meses[date.month]} ${date.year}';
}
/// Fecha de pago formateada: "15/03/2026"
String get fechaPagoDisplay {
if (fechaPago == null) return '-';
final d = fechaPago!;
return '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
}
bool get isAnulado => anuladoAt != null;
bool get isEditado => updatedAt != null;
/// True si el pago todavía está dentro de la ventana de edición desde su
/// creación. La ventana se pasa como parámetro para no acoplar la entidad
/// a un provider; el caller obtiene el valor de
/// AppConstants.pagosVentanaEdicionMinutosDefault.
///
/// El backend sigue siendo la fuente de verdad: este getter sirve sólo
/// para gating local de UI.
bool isEditableWindow(int ventanaMinutos) {
if (createdAt == null) return false;
final diff = DateTime.now().difference(createdAt!).inMinutes;
return diff < ventanaMinutos;
}
/// True si el actor puede editar este pago. La ventana aplica a todos
/// (incluso superadmin); ownership sólo si NO es superadmin.
/// El backend es la fuente de verdad; este getter es para gating local.
bool puedeEditar(String? actorUserId, bool isSuperadmin, int ventanaMinutos) {
if (isAnulado) return false;
if (!isEditableWindow(ventanaMinutos)) return false;
if (isSuperadmin) return true;
if (actorUserId == null || createdBy == null) return false;
return actorUserId == createdBy;
}
/// True si el actor puede anular este pago. Superadmin bypassea ownership
/// y ventana; el resto necesita ser owner y estar dentro de ventana.
bool puedeAnular(String? actorUserId, bool isSuperadmin, int ventanaMinutos) {
if (isAnulado) return false;
if (isSuperadmin) return true;
if (actorUserId == null || createdBy == null) return false;
if (actorUserId != createdBy) return false;
return isEditableWindow(ventanaMinutos);
}
}
class PagoCliente {
final String nombre;
final String apellido;
final String dni;
const PagoCliente({
required this.nombre,
required this.apellido,
required this.dni,
});
factory PagoCliente.fromMap(Map<String, dynamic> map) {
return PagoCliente(
nombre: map['nombre'] as String? ?? '',
apellido: map['apellido'] as String? ?? '',
dni: map['dni'] as String? ?? '',
);
}
String get displayName {
if (nombre.isNotEmpty && apellido.isNotEmpty) return '$nombre $apellido';
if (nombre.isNotEmpty) return nombre;
return dni;
}
}
@@ -0,0 +1,57 @@
/// Discriminador de la tabla pagos. El backend acepta los cuatro valores
/// del schema; el frontend de este sprint sólo CREA cuotaMensual (los
/// correctivos llegan desde SQL directo hasta que tengan sus propias
/// funciones fc_*).
enum PagoTipo {
cuotaMensual,
devolucion,
descuentoRetroactivo,
ajuste;
/// Parsea el valor del backend. Default defensivo: cuotaMensual.
/// El backfill de la migración garantiza que pagos.tipo nunca sea NULL,
/// pero ante un valor desconocido no rompemos el parseo.
factory PagoTipo.fromString(String? raw) {
switch (raw) {
case 'cuota_mensual':
return PagoTipo.cuotaMensual;
case 'devolucion':
return PagoTipo.devolucion;
case 'descuento_retroactivo':
return PagoTipo.descuentoRetroactivo;
case 'ajuste':
return PagoTipo.ajuste;
default:
return PagoTipo.cuotaMensual;
}
}
/// Valor snake_case que espera el backend.
String get backendValue {
switch (this) {
case PagoTipo.cuotaMensual:
return 'cuota_mensual';
case PagoTipo.devolucion:
return 'devolucion';
case PagoTipo.descuentoRetroactivo:
return 'descuento_retroactivo';
case PagoTipo.ajuste:
return 'ajuste';
}
}
/// Etiqueta legible en español. Vive con la entidad por simplicidad
/// (SOMA es app monolingüe).
String get displayName {
switch (this) {
case PagoTipo.cuotaMensual:
return 'Cuota mensual';
case PagoTipo.devolucion:
return 'Devolución';
case PagoTipo.descuentoRetroactivo:
return 'Descuento retroactivo';
case PagoTipo.ajuste:
return 'Ajuste';
}
}
}
@@ -0,0 +1,42 @@
import 'package:gimnasio_soma/features/pagos/domain/entities/metodo_pago.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
abstract class PagosRepository {
/// Obtener pagos (admin: todos o filtrados por DNI).
/// [incluirAnulados] default false — equivale al toggle "Mostrar anulados"
/// que el backend gobierna con el parámetro p_incluir_anulados.
Future<List<Pago>> getPagos({
int pagina = 1,
int cantidad = 50,
String? dni,
bool incluirAnulados = false,
});
/// Obtener pagos propios del usuario logueado. Siempre incluye anulados:
/// el cliente puede haber visto el pago antes de la edición/anulación,
/// la app cliente marca visualmente los modificados.
Future<List<Pago>> getMisPagos({int pagina = 1, int cantidad = 20});
/// Registrar un nuevo pago.
Future<void> insertPago(Map<String, dynamic> datos);
/// Editar un pago existente. Devuelve el pago actualizado (shape de
/// fc_obtener_pagos para un único objeto).
/// El backend rechaza el llamado si:
/// - el pago no existe o está anulado.
/// - el actor no tiene gestionar_cualquier_pago y no es el creador.
/// - el actor no tiene gestionar_cualquier_pago y la ventana venció.
/// - se intenta editar cliente_id o tipo.
Future<Pago> editPago(String id, Map<String, dynamic> datos);
/// Anular (soft-delete) un pago. [motivo] puede ser null o vacío;
/// el backend lo trimea y persiste como NULL en ese caso. Devuelve
/// el pago actualizado. Misma matriz de permisos que [editPago].
Future<Pago> anularPago(String id, String? motivo);
/// Obtener métodos de pago activos.
Future<List<MetodoPago>> getMetodosPago();
/// Actualizar un método de pago (descripción, activo, icono).
Future<void> updateMetodoPago(Map<String, dynamic> datos);
}
@@ -0,0 +1,89 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
class PagoConUsuario {
final Pago pago;
final Usuario usuario;
const PagoConUsuario({required this.pago, required this.usuario});
}
class PagosEstadoMes {
final String mes;
final List<PagoConUsuario> pagaron;
final List<Usuario> unMesSinPagar;
final List<Usuario> masDe1MesSinPagar;
const PagosEstadoMes({
required this.mes,
required this.pagaron,
required this.unMesSinPagar,
required this.masDe1MesSinPagar,
});
int get total => pagaron.length + unMesSinPagar.length + masDe1MesSinPagar.length;
double get progressValue => total == 0 ? 0 : pagaron.length / total;
}
/// Clasifica usuarios activos con plan en 3 grupos para el mes dado ("YYYY-MM"):
/// - pagaron: tienen pago registrado ese mes
/// - unMesSinPagar: no pagaron ese mes pero sí el anterior
/// - masDe1MesSinPagar: no pagaron ese mes ni el anterior
final pagosEstadoProvider =
FutureProvider.autoDispose.family<PagosEstadoMes, String>((ref, mes) async {
final usuarios = await ref.watch(allUsuariosProvider.future);
final pagosValue = ref.watch(pagosProvider);
final pagos = pagosValue.valueOrNull ?? [];
// Mes anterior
final parts = mes.split('-');
final mesDate = DateTime(int.parse(parts[0]), int.parse(parts[1]));
final mesAnteriorDate = DateTime(mesDate.year, mesDate.month - 1);
final mesAnterior =
'${mesAnteriorDate.year}-${mesAnteriorDate.month.toString().padLeft(2, '0')}';
// Indexar pagos por DNI para el mes seleccionado y el anterior
final pagosMes = <String, Pago>{};
final pagosAnterior = <String, bool>{};
for (final p in pagos) {
if (p.cliente == null) continue;
if (p.anioMesPagado.startsWith('$mes-')) {
pagosMes[p.cliente!.dni] = p;
}
if (p.anioMesPagado.startsWith('$mesAnterior-')) {
pagosAnterior[p.cliente!.dni] = true;
}
}
final conPlan =
usuarios.where((u) => u.isActive && u.tipoCuota != null).toList();
final pagaron = <PagoConUsuario>[];
final unMes = <Usuario>[];
final masDe1Mes = <Usuario>[];
for (final u in conPlan) {
final pago = pagosMes[u.dni];
if (pago != null) {
pagaron.add(PagoConUsuario(pago: pago, usuario: u));
} else if (pagosAnterior.containsKey(u.dni)) {
unMes.add(u);
} else {
masDe1Mes.add(u);
}
}
pagaron.sort((a, b) => a.usuario.displayName.compareTo(b.usuario.displayName));
unMes.sort((a, b) => a.displayName.compareTo(b.displayName));
masDe1Mes.sort((a, b) => a.displayName.compareTo(b.displayName));
return PagosEstadoMes(
mes: mes,
pagaron: pagaron,
unMesSinPagar: unMes,
masDe1MesSinPagar: masDe1Mes,
);
});
@@ -0,0 +1,47 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
class PagosFilter {
final String? selectedMonth; // "YYYY-MM" o null para todos
final String? selectedMetodo; // Nombre del método o null para todos
const PagosFilter({
this.selectedMonth,
this.selectedMetodo,
});
PagosFilter copyWith({
String? Function()? selectedMonth,
String? Function()? selectedMetodo,
}) {
return PagosFilter(
selectedMonth:
selectedMonth != null ? selectedMonth() : this.selectedMonth,
selectedMetodo:
selectedMetodo != null ? selectedMetodo() : this.selectedMetodo,
);
}
bool get hasActiveFilters =>
selectedMonth != null || selectedMetodo != null;
}
final pagosFilterProvider =
StateNotifierProvider.autoDispose<PagosFilterNotifier, PagosFilter>((ref) {
return PagosFilterNotifier();
});
class PagosFilterNotifier extends StateNotifier<PagosFilter> {
PagosFilterNotifier() : super(const PagosFilter());
void setMonth(String? month) {
state = state.copyWith(selectedMonth: () => month);
}
void setMetodo(String? metodo) {
state = state.copyWith(selectedMetodo: () => metodo);
}
void clearFilters() {
state = const PagosFilter();
}
}
@@ -0,0 +1,259 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
import 'package:gimnasio_soma/features/pagos/data/repositories/pagos_repository_impl.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/metodo_pago.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago_tipo.dart';
import 'package:gimnasio_soma/features/pagos/domain/repositories/pagos_repository.dart';
final pagosRepositoryProvider = Provider<PagosRepository>((ref) {
return PagosRepositoryImpl();
});
/// Métodos de pago con CRUD. Dato de configuración, persiste en sesión.
final metodosPagoProvider = StateNotifierProvider<MetodosPagoNotifier,
AsyncValue<List<MetodoPago>>>((ref) {
return MetodosPagoNotifier(ref.read(pagosRepositoryProvider));
});
class MetodosPagoNotifier
extends StateNotifier<AsyncValue<List<MetodoPago>>> {
final PagosRepository _repository;
MetodosPagoNotifier(this._repository)
: super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
try {
final data = await _repository.getMetodosPago();
state = AsyncValue.data(data);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<String?> update(Map<String, dynamic> datos) async {
try {
await _repository.updateMetodoPago(datos);
await load();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
}
/// Últimos pagos de un usuario por DNI (para detalle de usuario).
/// autoDispose: se descarta al salir del detalle y refetchea al volver.
/// [incluirAnulados] default false; cambiar a true para el toggle
/// "Mostrar anulados" en el detalle.
final userPagosProvider = FutureProvider.autoDispose
.family<List<Pago>, ({String dni, bool incluirAnulados})>((ref, args) async {
final repo = ref.read(pagosRepositoryProvider);
return repo.getPagos(
dni: args.dni,
cantidad: 10,
incluirAnulados: args.incluirAnulados,
);
});
/// Historial completo de pagos por DNI.
/// cantidad=200 cubre 2 años de cuotas + correctivos teóricos máximos (96)
/// con margen 2x. Si en algún momento un usuario supera esto, hay que pensar
/// en paginación dedicada.
/// autoDispose: se descarta al salir del historial y refetchea al volver.
final userHistorialProvider = FutureProvider.autoDispose
.family<List<Pago>, ({String dni, bool incluirAnulados})>((ref, args) async {
final repo = ref.read(pagosRepositoryProvider);
return repo.getPagos(
dni: args.dni,
cantidad: 200,
incluirAnulados: args.incluirAnulados,
);
});
/// Lista de pagos. autoDispose: al navegar fuera de la pantalla de pagos
/// los datos se descartan; al volver se cargan frescos del backend.
final pagosProvider =
StateNotifierProvider.autoDispose<PagosNotifier, AsyncValue<List<Pago>>>((ref) {
final user = ref.read(authStateProvider).valueOrNull;
final isAdmin = user != null && user.isStaff;
return PagosNotifier(ref.read(pagosRepositoryProvider), isAdmin);
});
class PagosNotifier extends StateNotifier<AsyncValue<List<Pago>>> {
final PagosRepository _repository;
String? _searchDni;
bool _viewingOwn;
bool _incluirAnulados = false;
PagosNotifier(this._repository, bool isAdmin)
: _viewingOwn = !isAdmin,
super(const AsyncValue.loading()) {
if (isAdmin) {
loadPagos();
} else {
loadMisPagos();
}
}
bool get incluirAnulados => _incluirAnulados;
/// Cargar pagos como admin (todos o filtrados por DNI).
Future<void> loadPagos() async {
_viewingOwn = false;
state = const AsyncValue.loading();
try {
final pagos = await _repository.getPagos(
dni: _searchDni,
incluirAnulados: _incluirAnulados,
);
if (!mounted) return;
state = AsyncValue.data(pagos);
} catch (e, st) {
if (!mounted) return;
state = AsyncValue.error(e, st);
}
}
/// Cargar pagos propios del usuario logueado.
/// El backend siempre incluye anulados para el cliente.
Future<void> loadMisPagos() async {
_viewingOwn = true;
state = const AsyncValue.loading();
try {
final pagos = await _repository.getMisPagos();
if (!mounted) return;
state = AsyncValue.data(pagos);
} catch (e, st) {
if (!mounted) return;
state = AsyncValue.error(e, st);
}
}
Future<void> search(String? dni) async {
_searchDni = (dni == null || dni.isEmpty) ? null : dni;
await loadPagos();
}
/// Toggle "Mostrar anulados". Sólo afecta la vista admin
/// (loadMisPagos siempre los incluye).
Future<void> setIncluirAnulados(bool value) async {
if (_incluirAnulados == value) return;
_incluirAnulados = value;
if (!_viewingOwn) {
await loadPagos();
}
}
Future<String?> insertPago(Map<String, dynamic> datos) async {
try {
await _repository.insertPago(datos);
if (_viewingOwn) {
await loadMisPagos();
} else {
await loadPagos();
}
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<String?> editPago(String id, Map<String, dynamic> datos) async {
try {
await _repository.editPago(id, datos);
// Refetch para consistencia: el backend devuelve el pago actualizado
// pero la lista puede haber cambiado de orden (fecha_pago editada).
if (_viewingOwn) {
await loadMisPagos();
} else {
await loadPagos();
}
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<String?> anularPago(String id, String? motivo) async {
try {
await _repository.anularPago(id, motivo);
if (_viewingOwn) {
await loadMisPagos();
} else {
await loadPagos();
}
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
}
/// Mapa DNI → fecha del último pago (fechaPago del pago más reciente).
/// Filtra por tipo == cuotaMensual: un correctivo (devolución, descuento o
/// ajuste) no representa "haber pagado el mes" — esto matchea el filtro
/// que aplica fc_reservar_turno en la regla de los 2 meses.
/// Se invalida automáticamente cuando la lista de usuarios cambia.
final ultimoPagoMapProvider =
FutureProvider.autoDispose<Map<String, DateTime?>>((ref) async {
ref.watch(allUsuariosProvider);
final repo = ref.read(pagosRepositoryProvider);
final pagos = await repo.getPagos(cantidad: 500);
final map = <String, DateTime?>{};
for (final p in pagos) {
if (p.tipo != PagoTipo.cuotaMensual) continue;
if (p.cliente == null) continue;
final dni = p.cliente!.dni;
if (!map.containsKey(dni)) {
// La lista viene ordenada más reciente primero
map[dni] = p.fechaPago ?? DateTime.tryParse(p.anioMesPagado);
}
}
return map;
});
// ── Deudores ───────────────────────────────────────────────────────────────────
class Deudor {
final Usuario usuario;
final int mesesAdeudados;
const Deudor({required this.usuario, required this.mesesAdeudados});
}
int _calcMesesAdeudados(DateTime? ultimoPago, DateTime currentMonth) {
if (ultimoPago == null) return 24;
final lastMonth = DateTime(ultimoPago.year, ultimoPago.month);
final diff = (currentMonth.year - lastMonth.year) * 12 +
currentMonth.month -
lastMonth.month;
return diff.clamp(0, 24);
}
/// Lista de socios activos con plan que adeudan al menos un mes, ordenados
/// de mayor a menor cantidad de meses sin pagar.
final deudoresProvider = FutureProvider.autoDispose<List<Deudor>>((ref) async {
final usuarios = await ref.watch(allUsuariosProvider.future);
final ultimoPagoMap = await ref.watch(ultimoPagoMapProvider.future);
final now = DateTime.now();
final currentMonth = DateTime(now.year, now.month);
final deudores = <Deudor>[];
for (final u in usuarios) {
if (!u.isActive || u.tipoCuota == null) continue;
final meses = _calcMesesAdeudados(ultimoPagoMap[u.dni], currentMonth);
if (meses <= 0) continue;
deudores.add(Deudor(usuario: u, mesesAdeudados: meses));
}
deudores.sort((a, b) => b.mesesAdeudados.compareTo(a.mesesAdeudados));
return deudores;
});
@@ -0,0 +1,47 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
enum PagosViewMode { overview, list, estado }
final pagosViewModeProvider =
StateNotifierProvider<PagosViewModeNotifier, PagosViewMode>((ref) {
return PagosViewModeNotifier();
});
class PagosViewModeNotifier extends StateNotifier<PagosViewMode> {
PagosViewModeNotifier() : super(PagosViewMode.overview) {
_loadViewMode();
}
Future<void> _loadViewMode() async {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(AppConstants.pagosViewModeKey);
state = switch (stored) {
'list' => PagosViewMode.list,
'estado' => PagosViewMode.estado,
_ => PagosViewMode.overview,
};
}
Future<void> setMode(PagosViewMode mode) async {
state = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
AppConstants.pagosViewModeKey,
switch (mode) {
PagosViewMode.list => 'list',
PagosViewMode.estado => 'estado',
PagosViewMode.overview => 'overview',
},
);
}
Future<void> toggle() async {
await setMode(switch (state) {
PagosViewMode.overview => PagosViewMode.list,
PagosViewMode.list => PagosViewMode.estado,
PagosViewMode.estado => PagosViewMode.overview,
});
}
}
@@ -0,0 +1,457 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_header_help.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/metodo_pago.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
class MetodosPagoScreen extends ConsumerWidget {
const MetodosPagoScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(metodosPagoProvider);
final isWide = MediaQuery.of(context).size.width >= 800;
final theme = Theme.of(context);
return Scaffold(
body: Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
12,
),
child: Row(
children: [
const Text(
'Métodos de pago',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SomaHeaderHelp(
items: [
SomaHelpItem(
icon: Icons.touch_app_outlined,
text: 'Tocá un método para activarlo, desactivarlo o '
'cambiarle el ícono.',
),
SomaHelpItem(
icon: Icons.refresh,
text: 'Recarga la lista de métodos de pago.',
),
],
),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Recargar',
onPressed: () =>
ref.read(metodosPagoProvider.notifier).load(),
),
],
),
),
Expanded(
child: state.when(
loading: () => const Center(
child: CircularProgressIndicator(color: SomaColors.primary),
),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 48,
color: theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(height: 12),
Text(
e.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () =>
ref.read(metodosPagoProvider.notifier).load(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (metodos) {
if (metodos.isEmpty) {
return Center(
child: Text(
'No hay métodos de pago',
style: TextStyle(
fontSize: 15,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
);
}
return ListView.separated(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 4, isWide ? 32 : 16, 80,
),
itemCount: metodos.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
return _MetodoCard(metodo: metodos[index]);
},
);
},
),
),
],
),
);
}
}
class _MetodoCard extends ConsumerWidget {
final MetodoPago metodo;
const _MetodoCard({required this.metodo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final railColor = metodo.activo
? SomaColors.success.withAlpha(180)
: theme.colorScheme.surfaceContainerHighest;
final card = InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _showEditDialog(context, ref),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Rail de estado
Container(width: 4, color: railColor),
// Contenido
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 11, 8, 11),
child: Row(
children: [
// Ícono
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: metodo.activo
? SomaColors.primary.withAlpha(20)
: theme.colorScheme.onSurface.withAlpha(12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
_iconForMetodo(metodo.icono),
size: 20,
color: metodo.activo
? SomaColors.primary
: theme.colorScheme.onSurface.withAlpha(80),
),
),
const SizedBox(width: 14),
// Descripción + badge
Expanded(
child: Row(
children: [
Expanded(
child: Text(
metodo.descripcion,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
_EstadoBadge(activo: metodo.activo),
],
),
),
const SizedBox(width: 4),
// Toggle activo
Switch(
value: metodo.activo,
activeTrackColor: SomaColors.success,
activeThumbColor: Colors.white,
onChanged: (value) async {
final error =
await ref.read(metodosPagoProvider.notifier).update({
'id': metodo.id,
'activo': value,
});
if (!context.mounted) return;
if (error != null) {
SomaToast.show(context,
message: error, type: ToastType.error);
}
},
),
// Edit
IconButton(
icon: Icon(
Icons.edit_outlined,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(130),
),
tooltip: 'Editar',
onPressed: () => _showEditDialog(context, ref),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 34,
minHeight: 34,
),
),
],
),
),
),
],
),
),
),
);
if (!metodo.activo) {
return Opacity(opacity: 0.6, child: card);
}
return card;
}
static const _iconOptions = [
(null, Icons.payment_outlined, 'Sin ícono'),
('efectivo', Icons.payments_outlined, 'Efectivo'),
('transferencia', Icons.account_balance_outlined, 'Transferencia'),
('tarjeta', Icons.credit_card_outlined, 'Tarjeta'),
('qr', Icons.qr_code, 'QR'),
];
Future<void> _showEditDialog(BuildContext context, WidgetRef ref) async {
final ctrl = TextEditingController(text: metodo.descripcion);
String? selectedIcon = metodo.icono;
final result = await showDialog<({String descripcion, String? icono})>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setLocal) {
final theme = Theme.of(ctx);
return AlertDialog(
title: const Text('Editar método de pago'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: ctrl,
decoration: const InputDecoration(
labelText: 'Descripción',
contentPadding:
EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
autofocus: true,
maxLength: 50,
),
const SizedBox(height: 4),
Text(
'Ícono',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(150),
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _iconOptions.map((opt) {
final (key, icon, label) = opt;
final isSelected = selectedIcon == key;
return GestureDetector(
onTap: () => setLocal(() => selectedIcon = key),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? SomaColors.primary.withAlpha(22)
: theme.colorScheme.surfaceContainerHighest
.withAlpha(80),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected
? SomaColors.primary
: theme.colorScheme.surfaceContainerHighest,
width: isSelected ? 1.5 : 0.8,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: isSelected
? SomaColors.primary
: theme.colorScheme.onSurface.withAlpha(150),
),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? SomaColors.primary
: theme.colorScheme.onSurface
.withAlpha(150),
),
),
],
),
),
);
}).toList(),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(178),
),
),
),
ElevatedButton(
onPressed: () {
final text = ctrl.text.trim();
if (text.isNotEmpty) {
Navigator.of(ctx)
.pop((descripcion: text, icono: selectedIcon));
}
},
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 40),
),
child: const Text('Guardar'),
),
],
);
},
),
);
ctrl.dispose();
if (result == null || !context.mounted) return;
final changed = result.descripcion != metodo.descripcion ||
result.icono != metodo.icono;
if (!changed) return;
final error = await ref.read(metodosPagoProvider.notifier).update({
'id': metodo.id,
'descripcion': result.descripcion,
'icono': result.icono,
});
if (!context.mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Método actualizado', type: ToastType.success);
}
}
IconData _iconForMetodo(String? icono) {
switch (icono) {
case 'efectivo':
case 'cash':
return Icons.payments_outlined;
case 'transferencia':
case 'transfer':
return Icons.account_balance_outlined;
case 'tarjeta':
case 'card':
return Icons.credit_card_outlined;
case 'qr':
return Icons.qr_code;
default:
return Icons.payment_outlined;
}
}
}
class _EstadoBadge extends StatelessWidget {
final bool activo;
const _EstadoBadge({required this.activo});
@override
Widget build(BuildContext context) {
final color = activo ? SomaColors.success : SomaColors.error;
final label = activo ? 'Activo' : 'Inactivo';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: color.withAlpha(18),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: color.withAlpha(60), width: 0.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 5,
height: 5,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
),
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,452 @@
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_text_field.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
/// Dialog de anulación de un pago. Devuelve `true` en éxito y `null` /
/// `false` al cancelar. Errores del backend se muestran inline; el dialog
/// no se cierra hasta éxito o cancelación explícita.
///
/// Presets de motivo: orientados a errores de carga. NO incluye
/// "Cliente devolvió plata" — devolución no es anulación
/// (ver plan: "Anular vs correctivos").
class AnularPagoDialog extends ConsumerStatefulWidget {
final Pago pago;
const AnularPagoDialog({super.key, required this.pago});
@override
ConsumerState<AnularPagoDialog> createState() => _AnularPagoDialogState();
}
class _AnularPagoDialogState extends ConsumerState<AnularPagoDialog> {
static const _presets = <String>[
'Cobro duplicado',
'Cliente equivocado',
'Error de monto',
'Error de mes',
];
final _motivoCtrl = TextEditingController();
String? _selectedPreset;
bool _submitting = false;
String? _error;
@override
void dispose() {
_motivoCtrl.dispose();
super.dispose();
}
void _onPresetTap(String preset) {
setState(() {
_selectedPreset = preset;
_motivoCtrl.text = preset;
_motivoCtrl.selection = TextSelection.collapsed(
offset: preset.length,
);
});
}
void _onMotivoChanged(String value) {
// Si el texto deja de coincidir con el preset seleccionado, deselecciono.
if (_selectedPreset != null && value.trim() != _selectedPreset) {
setState(() => _selectedPreset = null);
}
}
Future<void> _submit() async {
if (_submitting) return;
setState(() {
_submitting = true;
_error = null;
});
final motivo = _motivoCtrl.text.trim();
final err = await ref
.read(pagosProvider.notifier)
.anularPago(widget.pago.id, motivo.isEmpty ? null : motivo);
if (!mounted) return;
if (err == null) {
Navigator.of(context).pop(true);
} else {
setState(() {
_submitting = false;
_error = err;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final pago = widget.pago;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 480,
maxHeight: MediaQuery.of(context).size.height * 0.9,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_Header(pago: pago, submitting: _submitting),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PagoResumenCard(pago: pago),
const SizedBox(height: 14),
_Warning(),
const SizedBox(height: 18),
Text(
'Motivo (opcional)',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: cs.onSurface.withAlpha(160),
letterSpacing: 0.5,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: _presets.map((p) {
final selected = _selectedPreset == p;
return FilterChip(
label: Text(p),
selected: selected,
onSelected: _submitting
? null
: (_) => _onPresetTap(p),
selectedColor: SomaColors.primary.withAlpha(40),
checkmarkColor: SomaColors.primaryText,
labelStyle: TextStyle(
fontSize: 12,
fontWeight:
selected ? FontWeight.w600 : FontWeight.w400,
color: selected
? SomaColors.primaryText
: cs.onSurface,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: selected
? SomaColors.primary
: cs.surfaceContainerHighest,
width: 0.8,
),
),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
);
}).toList(),
),
const SizedBox(height: 10),
SomaTextField(
controller: _motivoCtrl,
labelText: null,
hintText:
'Escribí un motivo o seleccioná uno arriba',
maxLines: 2,
enabled: !_submitting,
onChanged: _onMotivoChanged,
),
if (_error != null) ...[
const SizedBox(height: 14),
_ErrorBanner(message: _error!),
],
],
),
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _submitting
? null
: () => Navigator.of(context).pop(false),
child: Text(
'Cancelar',
style: TextStyle(
color: cs.onSurface.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: _submitting ? null : _submit,
icon: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.cancel_outlined, size: 18),
label: Text(_submitting ? 'Anulando…' : 'Anular pago'),
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
minimumSize: const Size(0, 42),
),
),
],
),
),
],
),
),
);
}
}
class _Header extends StatelessWidget {
final Pago pago;
final bool submitting;
const _Header({required this.pago, required this.submitting});
String get _initials {
final c = pago.cliente;
if (c == null) return '?';
final n = c.nombre.isNotEmpty ? c.nombre[0] : '';
final a = c.apellido.isNotEmpty ? c.apellido[0] : '';
final combo = (n + a).toUpperCase();
return combo.isEmpty ? '?' : combo;
}
String get _displayName =>
pago.cliente?.displayName ?? 'Pago';
String get _subtitle =>
pago.cliente != null ? 'DNI ${pago.cliente!.dni}' : '';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
return Container(
padding: const EdgeInsets.fromLTRB(20, 18, 12, 18),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: SomaColors.error.withAlpha(30),
shape: BoxShape.circle,
),
child: Center(
child: Text(
_initials,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: SomaColors.error,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Anular pago',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: cs.onSurface,
),
),
const SizedBox(height: 2),
Text(
_subtitle.isEmpty ? _displayName : '$_displayName · $_subtitle',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(130),
),
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
onPressed: submitting
? null
: () => Navigator.of(context).pop(false),
icon: const Icon(Icons.close, size: 20),
style: IconButton.styleFrom(
backgroundColor: cs.surfaceContainerHighest,
),
),
],
),
);
}
}
class _PagoResumenCard extends StatelessWidget {
final Pago pago;
const _PagoResumenCard({required this.pago});
String _formatMonto(double n) =>
n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2);
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: cs.surfaceContainerHighest, width: 0.8),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pago.mesPagadoDisplay,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: cs.onSurface,
),
),
const SizedBox(height: 2),
Text(
'${pago.metodo} · Cargado ${pago.fechaPagoDisplay}',
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(140),
),
),
],
),
),
Text(
'\$${_formatMonto(pago.montoTotal)}',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: SomaColors.success,
),
),
],
),
);
}
}
class _Warning extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: cs.errorContainer.withAlpha(80),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: SomaColors.error.withAlpha(60),
width: 0.8,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline,
size: 16,
color: SomaColors.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Esta acción no elimina el pago. Queda registrado como '
'anulado con tu nombre y, si dejás motivo, también con esa nota.',
style: TextStyle(
fontSize: 12,
color: cs.onSurface,
height: 1.35,
),
),
),
],
),
);
}
}
class _ErrorBanner extends StatelessWidget {
final String message;
const _ErrorBanner({required this.message});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: SomaColors.error.withAlpha(28),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: SomaColors.error.withAlpha(120),
width: 0.8,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.error_outline,
size: 16,
color: SomaColors.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: TextStyle(
fontSize: 12,
color: SomaColors.error,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}
@@ -0,0 +1,431 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
class PagoCard extends StatelessWidget {
final Pago pago;
final bool showCliente;
final VoidCallback? onTap;
final VoidCallback? onHistorial;
// Callbacks de acción admin. Si ambos null y pago no anulado → no se
// muestra el menú 3-puntos (vista cliente o admin sin permiso).
final VoidCallback? onEdit;
final VoidCallback? onAnular;
const PagoCard({
super.key,
required this.pago,
this.showCliente = true,
this.onTap,
this.onHistorial,
this.onEdit,
this.onAnular,
});
bool get _showMenu => !pago.isAnulado && (onEdit != null || onAnular != null);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final anulado = pago.isAnulado;
final editado = pago.isEditado && !anulado;
final railColor = anulado
? SomaColors.error.withAlpha(180)
: SomaColors.success.withAlpha(180);
final montoColor = anulado
? SomaColors.error.withAlpha(160)
: SomaColors.success;
final montoDecoration = anulado ? TextDecoration.lineThrough : null;
final mainTextColor = anulado
? cs.onSurface.withAlpha(140)
: cs.onSurface;
return GestureDetector(
onSecondaryTap: onHistorial,
onLongPress: onHistorial,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 4, color: railColor),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 11, 8, 11),
child: Row(
children: [
Opacity(
opacity: anulado ? 0.55 : 1,
child: _MonthStamp(anioMes: pago.anioMesPagado),
),
const SizedBox(width: 14),
// Info central
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
showCliente && pago.cliente != null
? pago.cliente!.displayName
: pago.mesPagadoDisplay,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: mainTextColor,
decoration: montoDecoration,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
if (anulado)
const _AnuladoBadge()
else
_MetodoBadge(metodo: pago.metodo),
if (!anulado &&
showCliente &&
pago.cliente != null) ...[
const SizedBox(width: 6),
Text(
pago.mesPagadoDisplay,
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(110),
),
),
],
if (!anulado &&
pago.detalle?['tipo_cuota'] != null) ...[
const SizedBox(width: 6),
Flexible(
child: Text(
pago.detalle!['tipo_cuota'].toString(),
style: const TextStyle(
fontSize: 11,
color: SomaColors.primaryText,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
if (anulado) ...[
const SizedBox(height: 3),
Text(
_anuladoSubline(pago),
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(130),
fontStyle: FontStyle.italic,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'\$${_formatMonto(pago.montoTotal)}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: montoColor,
decoration: montoDecoration,
),
),
const SizedBox(height: 3),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (editado) ...[
Tooltip(
message: _editadoTooltip(pago),
child: Icon(
Icons.edit_outlined,
size: 12,
color: cs.onSurface.withAlpha(120),
),
),
const SizedBox(width: 4),
],
Text(
pago.fechaPagoDisplay,
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(110),
),
),
],
),
],
),
if (_showMenu) ...[
const SizedBox(width: 4),
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
size: 18,
color: cs.onSurface.withAlpha(140),
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 44,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 8,
tooltip: 'Acciones',
itemBuilder: (_) => [
if (onEdit != null)
PopupMenuItem(
value: 'edit',
height: 44,
child: Row(
children: [
Icon(
Icons.edit_outlined,
size: 18,
color: cs.onSurface.withAlpha(180),
),
const SizedBox(width: 10),
const Text('Editar'),
],
),
),
if (onAnular != null)
PopupMenuItem(
value: 'anular',
height: 44,
child: Row(
children: [
Icon(
Icons.cancel_outlined,
size: 18,
color: SomaColors.error,
),
const SizedBox(width: 10),
Text(
'Anular',
style: TextStyle(
color: SomaColors.error,
),
),
],
),
),
],
onSelected: (val) {
if (val == 'edit') onEdit?.call();
if (val == 'anular') onAnular?.call();
},
),
],
],
),
),
),
],
),
),
),
),
);
}
static String _formatMonto(double n) =>
n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2);
static String _anuladoSubline(Pago p) {
final motivo = (p.motivoAnulacion?.trim().isNotEmpty ?? false)
? p.motivoAnulacion!.trim()
: 'Sin motivo';
final autor = p.anuladoPorNombre ?? 'admin';
final hace = p.anuladoAt != null ? _timeagoEs(p.anuladoAt!) : '';
return hace.isEmpty
? '$motivo · por $autor'
: '$motivo · por $autor · $hace';
}
static String _editadoTooltip(Pago p) {
final autor = p.updatedByNombre ?? 'admin';
final cuando = p.updatedAt;
if (cuando == null) return 'Editado por $autor';
final f =
'${cuando.day.toString().padLeft(2, '0')}/${cuando.month.toString().padLeft(2, '0')}/${cuando.year}';
return 'Editado por $autor el $f';
}
static String _timeagoEs(DateTime when) {
final diff = DateTime.now().difference(when);
if (diff.inSeconds < 60) return 'hace unos segundos';
if (diff.inMinutes < 60) return 'hace ${diff.inMinutes} min';
if (diff.inHours < 24) return 'hace ${diff.inHours} h';
if (diff.inDays < 30) {
final d = diff.inDays;
return d == 1 ? 'hace 1 día' : 'hace $d días';
}
if (diff.inDays < 365) {
final m = (diff.inDays / 30).floor();
return m == 1 ? 'hace 1 mes' : 'hace $m meses';
}
final y = (diff.inDays / 365).floor();
return y == 1 ? 'hace 1 año' : 'hace $y años';
}
}
/// Badge "ANULADO" en lugar del método cuando el pago está soft-deleted.
class _AnuladoBadge extends StatelessWidget {
const _AnuladoBadge();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: SomaColors.error.withAlpha(28),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'ANULADO',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: SomaColors.error,
letterSpacing: 0.5,
),
),
);
}
}
/// Stamp de mes estilo mini-calendario.
class _MonthStamp extends StatelessWidget {
final String anioMes; // 'YYYY-MM-DD' o 'YYYY-MM'
const _MonthStamp({required this.anioMes});
static const _meses = [
'',
'ENE',
'FEB',
'MAR',
'ABR',
'MAY',
'JUN',
'JUL',
'AGO',
'SEP',
'OCT',
'NOV',
'DIC',
];
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final date = DateTime.tryParse(anioMes);
final mes = date != null ? _meses[date.month] : '??';
final anio = date != null ? date.year.toString().substring(2) : '';
return Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(18),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: SomaColors.primary.withAlpha(45), width: 0.5),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
margin: const EdgeInsets.fromLTRB(5, 5, 5, 3),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: SomaColors.primary,
borderRadius: BorderRadius.circular(4),
),
child: Text(
mes,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w800,
color: SomaColors.onPrimary,
letterSpacing: 0.4,
height: 1,
),
),
),
Text(
anio,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
height: 1.1,
),
),
],
),
);
}
}
/// Badge del método de pago.
class _MetodoBadge extends StatelessWidget {
final String metodo;
const _MetodoBadge({required this.metodo});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
metodo,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
);
}
}
@@ -0,0 +1,525 @@
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/auth/presentation/providers/auth_provider.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
class PagoDetailDialog extends ConsumerWidget {
final Pago pago;
// Callbacks opcionales para acciones admin. Si ambos null, no se muestra
// el footer de acciones (vista cliente, o admin sin permiso sobre este pago).
// El call-site decide cerrar el detail dialog antes de abrir el siguiente.
final VoidCallback? onEdit;
final VoidCallback? onAnular;
const PagoDetailDialog({
super.key,
required this.pago,
this.onEdit,
this.onAnular,
});
bool get _showActions =>
!pago.isAnulado && (onEdit != null || onAnular != null);
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final user = ref.watch(authStateProvider).valueOrNull;
final isAdmin = user?.isStaff ?? false;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
constraints: BoxConstraints(
maxWidth: 500,
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: pago.isAnulado
? SomaColors.error.withAlpha(30)
: SomaColors.primary.withAlpha(30),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
pago.isAnulado
? Icons.cancel_outlined
: Icons.receipt_long,
color: pago.isAnulado
? SomaColors.error
: SomaColors.primary,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pago.isAnulado ? 'Pago anulado' : 'Detalle de Pago',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
pago.mesPagadoDisplay,
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, size: 20),
style: IconButton.styleFrom(
backgroundColor:
theme.colorScheme.surfaceContainerHighest,
),
),
],
),
),
// Content
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Banner de auditoría arriba de todo si está anulado o editado.
if (pago.isAnulado)
_AnuladoBanner(pago: pago)
else if (pago.isEditado)
_EditadoBanner(pago: pago),
if (pago.isAnulado || pago.isEditado)
const SizedBox(height: 16),
// Cliente (solo si es admin)
if (isAdmin && pago.cliente != null) ...[
_DetailRow(
icon: Icons.person_outline,
label: 'Cliente',
value: pago.cliente!.displayName,
valueStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
_DetailRow(
icon: Icons.badge_outlined,
label: 'DNI',
value: pago.cliente!.dni,
),
const SizedBox(height: 16),
],
// Monto (destacado)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: 20,
horizontal: 16,
),
decoration: BoxDecoration(
color: (pago.isAnulado ? SomaColors.error : SomaColors.success)
.withAlpha(14),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: (pago.isAnulado
? SomaColors.error
: SomaColors.success)
.withAlpha(50),
width: 0.8,
),
),
child: Column(
children: [
Text(
pago.isAnulado ? 'MONTO ANULADO' : 'MONTO TOTAL',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: (pago.isAnulado
? SomaColors.error
: SomaColors.success)
.withAlpha(180),
letterSpacing: 0.8,
),
),
const SizedBox(height: 8),
Text(
'\$${_formatMonto(pago.montoTotal)}',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w700,
color: pago.isAnulado
? SomaColors.error
: SomaColors.success,
decoration: pago.isAnulado
? TextDecoration.lineThrough
: null,
height: 1,
),
),
],
),
),
const SizedBox(height: 24),
_SectionTitle(label: 'Detalle'),
const SizedBox(height: 14),
_DetailRow(
icon: Icons.payment_outlined,
label: 'Método de Pago',
value: pago.metodo,
valueStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
_DetailRow(
icon: Icons.calendar_today_outlined,
label: 'Fecha de Pago',
value: pago.fechaPagoDisplay,
),
const SizedBox(height: 16),
_DetailRow(
icon: Icons.event_note_outlined,
label: 'Mes Pagado',
value: pago.mesPagadoDisplay,
),
if (pago.detalle != null && pago.detalle!.isNotEmpty) ...[
const SizedBox(height: 24),
_SectionTitle(label: 'Información Adicional'),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest
.withAlpha(80),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: pago.detalle!.entries.map((entry) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: Text(
'${entry.key}:',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
),
Expanded(
flex: 3,
child: Text(
entry.value.toString(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
),
),
],
),
);
}).toList(),
),
),
],
],
),
),
),
if (_showActions) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (onAnular != null)
OutlinedButton.icon(
onPressed: onAnular,
icon: const Icon(Icons.cancel_outlined, size: 16),
label: const Text('Anular'),
style: OutlinedButton.styleFrom(
foregroundColor: SomaColors.error,
side: BorderSide(
color: SomaColors.error.withAlpha(140),
width: 1,
),
minimumSize: const Size(0, 40),
),
),
if (onAnular != null && onEdit != null)
const SizedBox(width: 10),
if (onEdit != null)
ElevatedButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined, size: 16),
label: const Text('Editar'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 40),
),
),
],
),
),
],
],
),
),
);
}
String _formatMonto(double n) =>
n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2);
}
class _AnuladoBanner extends StatelessWidget {
final Pago pago;
const _AnuladoBanner({required this.pago});
String get _fecha {
final a = pago.anuladoAt;
if (a == null) return '';
return '${a.day.toString().padLeft(2, '0')}/${a.month.toString().padLeft(2, '0')}/${a.year}';
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final autor = pago.anuladoPorNombre ?? 'admin';
final motivo = (pago.motivoAnulacion?.trim().isNotEmpty ?? false)
? pago.motivoAnulacion!.trim()
: null;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: SomaColors.error.withAlpha(20),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: SomaColors.error.withAlpha(80), width: 0.8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.cancel_outlined,
size: 18,
color: SomaColors.error,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_fecha.isEmpty
? 'Anulado por $autor'
: 'Anulado por $autor el $_fecha',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: SomaColors.error,
),
),
if (motivo != null) ...[
const SizedBox(height: 4),
Text(
'Motivo: $motivo',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(180),
height: 1.35,
),
),
],
],
),
),
],
),
);
}
}
class _EditadoBanner extends StatelessWidget {
final Pago pago;
const _EditadoBanner({required this.pago});
String get _fecha {
final u = pago.updatedAt;
if (u == null) return '';
return '${u.day.toString().padLeft(2, '0')}/${u.month.toString().padLeft(2, '0')}/${u.year}';
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final autor = pago.updatedByNombre ?? 'admin';
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: cs.tertiaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(
Icons.edit_outlined,
size: 16,
color: cs.onTertiaryContainer,
),
const SizedBox(width: 10),
Expanded(
child: Text(
_fecha.isEmpty
? 'Última edición: $autor'
: 'Última edición: $autor el $_fecha',
style: TextStyle(
fontSize: 12,
color: cs.onTertiaryContainer,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}
class _SectionTitle extends StatelessWidget {
final String label;
const _SectionTitle({required this.label});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Container(
width: 3,
height: 14,
decoration: BoxDecoration(
color: SomaColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 7),
Text(
label.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withAlpha(130),
letterSpacing: 0.6,
),
),
],
);
}
}
class _DetailRow extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final TextStyle? valueStyle;
const _DetailRow({
required this.icon,
required this.label,
required this.value,
this.valueStyle,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(100),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
const SizedBox(height: 2),
Text(
value,
style: valueStyle ??
TextStyle(
fontSize: 14,
color: theme.colorScheme.onSurface,
),
),
],
),
),
],
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,392 @@
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/pagos/presentation/providers/pagos_provider.dart';
import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_form_dialog.dart';
import 'package:gimnasio_soma/features/pagos/presentation/widgets/usuario_historial_dialog.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
class PagosDeudoresView extends ConsumerWidget {
const PagosDeudoresView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final deudoresAsync = ref.watch(deudoresProvider);
final isWide = MediaQuery.of(context).size.width >= 800;
final theme = Theme.of(context);
return deudoresAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator(color: SomaColors.primary)),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 48, color: theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(height: 12),
Text(
e.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(color: theme.colorScheme.onSurface.withAlpha(153)),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () => ref.invalidate(deudoresProvider),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (deudores) {
if (deudores.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline,
size: 60,
color: SomaColors.success.withAlpha(160)),
const SizedBox(height: 16),
const Text(
'Todos al día',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
Text(
'No hay socios con cuotas pendientes',
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
);
}
final unMes = deudores.where((d) => d.mesesAdeudados == 1).length;
final masDe1 = deudores.where((d) => d.mesesAdeudados > 1).length;
return Column(
children: [
// Resumen
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 4, isWide ? 32 : 16, 8,
),
child: Row(
children: [
_SummaryChip(
label: '${deudores.length} deudor${deudores.length == 1 ? '' : 'es'}',
color: theme.colorScheme.onSurface,
),
if (unMes > 0) ...[
const SizedBox(width: 8),
_SummaryChip(
label: '$unMes × 1 mes',
color: Colors.orange,
),
],
if (masDe1 > 0) ...[
const SizedBox(width: 8),
_SummaryChip(
label: '$masDe1 × 2+ meses',
color: SomaColors.error,
),
],
],
),
),
// Lista
Expanded(
child: ListView.separated(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 0, isWide ? 32 : 16, 80,
),
itemCount: deudores.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, i) => _DeudorCard(deudor: deudores[i]),
),
),
],
);
},
);
}
}
// ── Chip de resumen ────────────────────────────────────────────────────────────
class _SummaryChip extends StatelessWidget {
final String label;
final Color color;
const _SummaryChip({required this.label, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withAlpha(16),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withAlpha(50), width: 0.8),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color.withAlpha(200),
),
),
);
}
}
// ── Tarjeta de deudor ──────────────────────────────────────────────────────────
class _DeudorCard extends ConsumerWidget {
final Deudor deudor;
const _DeudorCard({required this.deudor});
@override
Widget build(BuildContext context, WidgetRef ref) {
final u = deudor.usuario;
final meses = deudor.mesesAdeudados;
final theme = Theme.of(context);
final railColor =
meses == 1 ? Colors.orange : SomaColors.error;
return Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Rail de severidad
Container(width: 4, color: railColor.withAlpha(180)),
// Contenido
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
child: Row(
children: [
// Avatar
CircleAvatar(
radius: 18,
backgroundColor: railColor.withAlpha(30),
child: Text(
u.initials,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: railColor,
),
),
),
const SizedBox(width: 12),
// Nombre + DNI + badge
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
u.displayName,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Row(
children: [
Text(
u.dni,
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(120),
),
),
const SizedBox(width: 8),
_MesesBadge(meses: meses),
],
),
],
),
),
// Acciones
IconButton(
icon: Icon(
Icons.history,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(130),
),
tooltip: 'Ver historial',
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => _verHistorial(context),
),
IconButton(
icon: const Icon(
Icons.chat_outlined,
size: 18,
color: Color(0xFF25D366),
),
tooltip: 'Enviar WhatsApp',
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => _abrirWhatsApp(context),
),
IconButton(
icon: const Icon(
Icons.payment_outlined,
size: 18,
color: SomaColors.primary,
),
tooltip: 'Registrar pago',
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => _registrarPago(context, ref),
),
],
),
),
),
],
),
),
);
}
void _verHistorial(BuildContext context) {
showDialog(
context: context,
builder: (_) => UsuarioHistorialDialog(
dni: deudor.usuario.dni,
nombre: deudor.usuario.displayName,
initials: deudor.usuario.initials,
),
);
}
Future<void> _abrirWhatsApp(BuildContext context) async {
final meses = deudor.mesesAdeudados;
final nombre = deudor.usuario.nombre;
final mesesStr = meses == 1 ? '1 mes' : '$meses meses';
final ok = await WhatsAppService.abrirChat(
telefono: deudor.usuario.telefono,
mensaje: 'Hola $nombre, te contactamos desde el gimnasio SOMA. '
'Tenés $mesesStr de cuota pendiente. '
'Por favor, coordiná el pago cuando puedas. ¡Muchas gracias!',
);
if (!context.mounted) return;
if (!ok) {
SomaToast.show(
context,
message: WhatsAppService.normalizarNumeroAr(deudor.usuario.telefono) == null
? 'No se puede enviar WhatsApp: el número de teléfono del usuario es inválido o está vacío'
: 'No se pudo abrir WhatsApp',
type: ToastType.error,
);
}
}
Future<void> _registrarPago(BuildContext context, WidgetRef ref) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => PagoFormDialog(prefilledDni: deudor.usuario.dni),
);
if (result == null || !context.mounted) return;
final planUpdate =
result.remove('actualizar_plan') as Map<String, dynamic>?;
final error = await ref.read(pagosProvider.notifier).insertPago(result);
if (!context.mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
return;
}
if (planUpdate != null) {
final planError =
await ref.read(usuariosProvider.notifier).updateUsuario({
'id': planUpdate['usuario_id'],
'tipo_cuota': planUpdate['tipo_cuota_id'],
});
if (context.mounted && planError != null) {
SomaToast.show(
context,
message: 'Pago registrado, pero error al actualizar plan: $planError',
type: ToastType.info,
);
return;
}
}
if (context.mounted) {
SomaToast.show(
context,
message:
planUpdate != null ? 'Pago registrado y plan actualizado' : 'Pago registrado',
type: ToastType.success,
);
}
}
}
// ── Badge de meses ─────────────────────────────────────────────────────────────
class _MesesBadge extends StatelessWidget {
final int meses;
const _MesesBadge({required this.meses});
@override
Widget build(BuildContext context) {
final color = meses == 1 ? Colors.orange : SomaColors.error;
final label = meses == 1 ? '1 mes sin pagar' : '$meses meses sin pagar';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(18),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color.withAlpha(55), width: 0.5),
),
child: Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
}
@@ -0,0 +1,823 @@
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/pagos/domain/entities/pago.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_estado_provider.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_detail_dialog.dart';
import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_form_dialog.dart';
import 'package:gimnasio_soma/features/pagos/presentation/widgets/usuario_historial_dialog.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
const _amberColor = Color(0xFFFF8F00);
class PagosEstadoView extends ConsumerStatefulWidget {
const PagosEstadoView({super.key, required this.initialMes});
final String initialMes;
@override
ConsumerState<PagosEstadoView> createState() => _PagosEstadoViewState();
}
class _PagosEstadoViewState extends ConsumerState<PagosEstadoView> {
late String _selectedMes;
@override
void initState() {
super.initState();
_selectedMes = widget.initialMes;
}
List<String> _getLast12Months() {
final now = DateTime.now();
return List.generate(12, (i) {
final date = DateTime(now.year, now.month - i, 1);
return '${date.year}-${date.month.toString().padLeft(2, '0')}';
});
}
String _formatMonth(String yearMonth) {
final parts = yearMonth.split('-');
if (parts.length != 2) return yearMonth;
final month = int.tryParse(parts[1]) ?? 0;
const meses = [
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
return '${meses[month]} ${parts[0]}';
}
@override
Widget build(BuildContext context) {
final isWide = MediaQuery.of(context).size.width >= 800;
final hPad = isWide ? 32.0 : 16.0;
final months = _getLast12Months();
return Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(hPad, 4, hPad, 8),
child: Row(
children: [
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
width: 0.8,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButton<String>(
value: _selectedMes,
selectedItemBuilder: (ctx) => months
.map(
(m) => Align(
alignment: Alignment.centerLeft,
child: Text(
_formatMonth(m),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Theme.of(ctx).colorScheme.onSurface,
),
),
),
)
.toList(),
underline: const SizedBox(),
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
items: months
.map(
(m) => DropdownMenuItem<String>(
value: m,
child: Text(
_formatMonth(m),
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
)
.toList(),
onChanged: (v) {
if (v != null) setState(() => _selectedMes = v);
},
),
),
),
],
),
),
Expanded(
child: _EstadoContent(mes: _selectedMes, hPad: hPad),
),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _EstadoContent extends ConsumerWidget {
const _EstadoContent({required this.mes, required this.hPad});
final String mes;
final double hPad;
@override
Widget build(BuildContext context, WidgetRef ref) {
final pagosLoading = ref.watch(pagosProvider).isLoading;
final estadoAsync = ref.watch(pagosEstadoProvider(mes));
final deudoresAsync = ref.watch(deudoresProvider);
if (pagosLoading || estadoAsync.isLoading) {
return const Center(child: CircularProgressIndicator(color: SomaColors.primary));
}
if (estadoAsync.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48,
color: Theme.of(context).colorScheme.onSurface.withAlpha(100)),
const SizedBox(height: 12),
Text(
estadoAsync.error.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withAlpha(153)),
),
],
),
);
}
final estado = estadoAsync.valueOrNull;
if (estado == null) return const SizedBox.shrink();
if (estado.total == 0) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.group_outlined, size: 56,
color: Theme.of(context).colorScheme.onSurface.withAlpha(60)),
const SizedBox(height: 12),
Text(
'No hay socios con plan activo',
style: TextStyle(
fontSize: 15,
color: Theme.of(context).colorScheme.onSurface.withAlpha(130)),
),
],
),
);
}
// Build meses map from deudoresProvider to show exact count for +1 month debtors
final mesesMap = <String, int>{};
if (deudoresAsync.hasValue) {
for (final d in deudoresAsync.requireValue) {
mesesMap[d.usuario.id] = d.mesesAdeudados;
}
}
return ListView(
padding: EdgeInsets.fromLTRB(hPad, 4, hPad, 80),
children: [
_SummaryCard(estado: estado),
if (estado.masDe1MesSinPagar.isNotEmpty) ...[
const SizedBox(height: 16),
_Section(
title: 'Más de un mes sin pagar',
count: estado.masDe1MesSinPagar.length,
accentColor: SomaColors.error,
initiallyExpanded: true,
children: estado.masDe1MesSinPagar
.map((u) => _NoPageCard(
usuario: u,
meses: mesesMap[u.id] ?? 2,
accentColor: SomaColors.error,
))
.toList(),
),
],
if (estado.unMesSinPagar.isNotEmpty) ...[
const SizedBox(height: 4),
_Section(
title: 'Sin pagar este mes',
count: estado.unMesSinPagar.length,
accentColor: _amberColor,
initiallyExpanded: true,
children: estado.unMesSinPagar
.map((u) => _NoPageCard(
usuario: u,
meses: 1,
accentColor: _amberColor,
))
.toList(),
),
],
if (estado.pagaron.isNotEmpty) ...[
const SizedBox(height: 4),
_Section(
title: 'Pagaron',
count: estado.pagaron.length,
accentColor: SomaColors.success,
initiallyExpanded: false,
children: estado.pagaron
.map((e) => _PagaronCard(pagoConUsuario: e))
.toList(),
),
],
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _Section extends StatefulWidget {
final String title;
final int count;
final Color accentColor;
final bool initiallyExpanded;
final List<Widget> children;
const _Section({
required this.title,
required this.count,
required this.accentColor,
required this.initiallyExpanded,
required this.children,
});
@override
State<_Section> createState() => _SectionState();
}
class _SectionState extends State<_Section> {
late bool _expanded;
@override
void initState() {
super.initState();
_expanded = widget.initiallyExpanded;
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Container(
width: 3,
height: 14,
decoration: BoxDecoration(
color: widget.accentColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 7),
Text(
widget.title.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: cs.onSurface.withAlpha(153),
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: widget.accentColor.withAlpha(25),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${widget.count}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: widget.accentColor,
),
),
),
const Spacer(),
Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
size: 18,
color: cs.onSurface.withAlpha(120),
),
],
),
),
),
if (_expanded) ...[
const SizedBox(height: 6),
...widget.children.map(
(c) => Padding(padding: const EdgeInsets.only(bottom: 8), child: c),
),
],
const SizedBox(height: 4),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _SummaryCard extends StatelessWidget {
const _SummaryCard({required this.estado});
final PagosEstadoMes estado;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.surfaceContainerHighest, width: 0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${estado.pagaron.length}',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
color: SomaColors.success,
height: 1,
),
),
Padding(
padding: const EdgeInsets.only(bottom: 4, left: 6),
child: Text(
'de ${estado.total} socios pagaron',
style: TextStyle(fontSize: 15, color: cs.onSurface.withAlpha(180)),
),
),
],
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: estado.progressValue,
minHeight: 8,
backgroundColor: cs.surfaceContainerHighest,
valueColor: const AlwaysStoppedAnimation<Color>(SomaColors.success),
),
),
if (estado.unMesSinPagar.isNotEmpty ||
estado.masDe1MesSinPagar.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
children: [
if (estado.unMesSinPagar.isNotEmpty) ...[
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: _amberColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 5),
Text(
'${estado.unMesSinPagar.length} ${estado.unMesSinPagar.length == 1 ? 'debe' : 'deben'} este mes',
style:
TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(120)),
),
],
if (estado.unMesSinPagar.isNotEmpty &&
estado.masDe1MesSinPagar.isNotEmpty)
Text(
' · ',
style:
TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(80)),
),
if (estado.masDe1MesSinPagar.isNotEmpty) ...[
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: SomaColors.error,
shape: BoxShape.circle,
),
),
const SizedBox(width: 5),
Text(
'${estado.masDe1MesSinPagar.length} ${estado.masDe1MesSinPagar.length == 1 ? 'moroso' : 'morosos'}',
style:
TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(120)),
),
],
],
),
],
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _NoPageCard extends ConsumerWidget {
final Usuario usuario;
final int meses;
final Color accentColor;
const _NoPageCard({
required this.usuario,
required this.meses,
required this.accentColor,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 4, color: accentColor.withAlpha(180)),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
child: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: accentColor.withAlpha(30),
child: Text(
usuario.initials,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: accentColor,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
usuario.displayName,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Row(
children: [
Text(
usuario.dni,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(120),
),
),
const SizedBox(width: 8),
_MesesBadge(meses: meses),
],
),
],
),
),
IconButton(
icon: Icon(Icons.history, size: 18,
color: theme.colorScheme.onSurface.withAlpha(130)),
tooltip: 'Ver historial',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => _verHistorial(context),
),
IconButton(
icon: const Icon(Icons.chat_outlined, size: 18,
color: Color(0xFF25D366)),
tooltip: 'Enviar WhatsApp',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => _abrirWhatsApp(context),
),
IconButton(
icon: const Icon(Icons.payment_outlined, size: 18,
color: SomaColors.primary),
tooltip: 'Registrar pago',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => _registrarPago(context, ref),
),
],
),
),
),
],
),
),
);
}
void _verHistorial(BuildContext context) {
showDialog(
context: context,
builder: (_) => UsuarioHistorialDialog(
dni: usuario.dni,
nombre: usuario.displayName,
initials: usuario.initials,
),
);
}
Future<void> _abrirWhatsApp(BuildContext context) async {
final mesesStr = meses == 1 ? '1 mes' : '$meses meses';
final ok = await WhatsAppService.abrirChat(
telefono: usuario.telefono,
mensaje: 'Hola ${usuario.nombre}, te contactamos desde el gimnasio SOMA. '
'Tenés $mesesStr de cuota pendiente. '
'Por favor, coordiná el pago cuando puedas. ¡Muchas gracias!',
);
if (!context.mounted) return;
if (!ok) {
SomaToast.show(
context,
message: WhatsAppService.normalizarNumeroAr(usuario.telefono) == null
? 'No se puede enviar WhatsApp: el número de teléfono del usuario es inválido o está vacío'
: 'No se pudo abrir WhatsApp',
type: ToastType.error,
);
}
}
Future<void> _registrarPago(BuildContext context, WidgetRef ref) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => PagoFormDialog(prefilledDni: usuario.dni),
);
if (result == null || !context.mounted) return;
final planUpdate = result.remove('actualizar_plan') as Map<String, dynamic>?;
final error = await ref.read(pagosProvider.notifier).insertPago(result);
if (!context.mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
return;
}
if (planUpdate != null) {
final planError = await ref.read(usuariosProvider.notifier).updateUsuario({
'id': planUpdate['usuario_id'],
'tipo_cuota': planUpdate['tipo_cuota_id'],
});
if (context.mounted && planError != null) {
SomaToast.show(context,
message: 'Pago registrado, pero error al actualizar plan: $planError',
type: ToastType.info);
return;
}
}
if (context.mounted) {
SomaToast.show(
context,
message: planUpdate != null
? 'Pago registrado y plan actualizado'
: 'Pago registrado',
type: ToastType.success,
);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _PagaronCard extends StatelessWidget {
final dynamic pagoConUsuario;
const _PagaronCard({required this.pagoConUsuario});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final u = pagoConUsuario.usuario as Usuario;
final pago = pagoConUsuario.pago as Pago;
return Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 4, color: SomaColors.success.withAlpha(180)),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
child: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: SomaColors.success.withAlpha(30),
child: Text(
u.initials,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: SomaColors.success,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
u.displayName,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Row(
children: [
Text(
u.dni,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(120),
),
),
const SizedBox(width: 8),
_MontoChip(monto: pago.montoTotal),
],
),
],
),
),
IconButton(
icon: Icon(Icons.history, size: 18,
color: theme.colorScheme.onSurface.withAlpha(130)),
tooltip: 'Ver historial',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => showDialog(
context: context,
builder: (_) => UsuarioHistorialDialog(
dni: u.dni,
nombre: u.displayName,
initials: u.initials,
),
),
),
IconButton(
icon: Icon(Icons.receipt_outlined, size: 18,
color: theme.colorScheme.onSurface.withAlpha(130)),
tooltip: 'Ver pago',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => showDialog(
context: context,
builder: (_) => PagoDetailDialog(pago: pago),
),
),
],
),
),
),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _MontoChip extends StatelessWidget {
final double monto;
const _MontoChip({required this.monto});
String get _label {
if (monto >= 1000) {
final k = monto / 1000;
return '\$${k % 1 == 0 ? k.toStringAsFixed(0) : k.toStringAsFixed(1)}k';
}
return '\$${monto.toStringAsFixed(0)}';
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: SomaColors.success.withAlpha(18),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: SomaColors.success.withAlpha(55), width: 0.5),
),
child: Text(
_label,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: SomaColors.success,
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _MesesBadge extends StatelessWidget {
final int meses;
const _MesesBadge({required this.meses});
@override
Widget build(BuildContext context) {
final color = meses == 1 ? _amberColor : SomaColors.error;
final label = meses == 1 ? '1 mes sin pagar' : '$meses meses sin pagar';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(18),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color.withAlpha(55), width: 0.5),
),
child: Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
}
@@ -0,0 +1,397 @@
import 'package:file_picker/file_picker.dart';
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/pagos/presentation/providers/pagos_provider.dart';
import 'package:gimnasio_soma/features/pagos/utils/pagos_import.dart';
class PagosImportDialog extends ConsumerStatefulWidget {
const PagosImportDialog({super.key});
@override
ConsumerState<PagosImportDialog> createState() => _PagosImportDialogState();
}
class _PagosImportDialogState extends ConsumerState<PagosImportDialog> {
_Step _step = _Step.idle;
PagosImportResult? _result;
int _imported = 0;
int _failed = 0;
String? _currentError;
Future<void> _pickAndParse() async {
setState(() => _step = _Step.picking);
final picked = await FilePicker.platform.pickFiles(
dialogTitle: 'Seleccionar CSV de pagos',
type: FileType.custom,
allowedExtensions: ['csv'],
withData: true,
);
if (picked == null || picked.files.isEmpty) {
setState(() => _step = _Step.idle);
return;
}
final bytes = picked.files.first.bytes;
if (bytes == null) {
setState(() {
_step = _Step.idle;
_currentError = 'No se pudo leer el archivo';
});
return;
}
final result = parsePagosCsv(bytes);
setState(() {
_result = result;
_step = result.parseErrors.isNotEmpty ? _Step.idle : _Step.preview;
_currentError = result.parseErrors.isNotEmpty ? result.parseErrors.first : null;
});
}
Future<void> _runImport() async {
final result = _result!;
final metodosAsync = ref.read(metodosPagoProvider);
final metodos = metodosAsync.valueOrNull ?? [];
// Construir mapa nombre (lowercase) → id
final metodoMap = <String, int>{
for (final m in metodos) m.descripcion.toLowerCase().trim(): m.id,
};
setState(() {
_step = _Step.importing;
_imported = 0;
_failed = 0;
});
for (final row in result.valid) {
final metodoId = _resolveMetodo(row.metodoNombre, metodoMap);
if (metodoId == null) {
setState(() => _failed++);
continue;
}
final datos = <String, dynamic>{
'dni': row.dni,
'metodo_id': metodoId,
'anio_mes_pagado': row.anioMesPagado,
'monto_total': row.montoTotal,
if (row.fechaPago != null) 'fecha_pago': row.fechaPago,
};
final error = await ref.read(pagosProvider.notifier).insertPago(datos);
if (mounted) {
setState(() {
if (error == null) {
_imported++;
} else {
_failed++;
}
});
}
}
if (mounted) setState(() => _step = _Step.done);
}
int? _resolveMetodo(String nombre, Map<String, int> metodoMap) {
// Exact match (case-insensitive)
return metodoMap[nombre.toLowerCase().trim()];
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: const Text('Importar pagos desde CSV'),
content: SizedBox(
width: 480,
child: _buildContent(theme),
),
actions: _buildActions(theme),
);
}
Widget _buildContent(ThemeData theme) {
switch (_step) {
case _Step.idle:
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Seleccioná un archivo CSV exportado desde esta app. '
'Las columnas requeridas son: dni, anio_mes_pagado, '
'monto_total, metodo.',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(180),
),
),
if (_currentError != null) ...[
const SizedBox(height: 12),
_ErrorChip(message: _currentError!),
],
],
);
case _Step.picking:
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: CircularProgressIndicator(color: SomaColors.primary),
),
);
case _Step.preview:
final valid = _result!.valid;
final invalid = _result!.invalid;
final metodos = ref.watch(metodosPagoProvider).valueOrNull ?? [];
final metodoMap = <String, int>{
for (final m in metodos) m.descripcion.toLowerCase().trim(): m.id,
};
final sinMetodo = valid
.where((r) => _resolveMetodo(r.metodoNombre, metodoMap) == null)
.toList();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SummaryRow(
icon: Icons.check_circle_outline,
color: SomaColors.success,
label: '${valid.length} filas válidas',
),
if (sinMetodo.isNotEmpty) ...[
const SizedBox(height: 4),
_SummaryRow(
icon: Icons.warning_amber_outlined,
color: Colors.orange,
label: '${sinMetodo.length} con método de pago no reconocido '
'(se saltarán)',
),
],
if (invalid.isNotEmpty) ...[
const SizedBox(height: 4),
_SummaryRow(
icon: Icons.error_outline,
color: SomaColors.error,
label: '${invalid.length} filas con errores (se saltarán)',
),
],
if (invalid.isNotEmpty || sinMetodo.isNotEmpty) ...[
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 160),
child: ListView(
shrinkWrap: true,
children: [
...sinMetodo.map((r) => _ErrorRow(
rowNum: r.rowNumber,
msg: 'Método no reconocido: "${r.metodoNombre}"',
)),
...invalid.map((r) => _ErrorRow(
rowNum: r.rowNumber,
msg: r.validationError ?? 'Error desconocido',
)),
],
),
),
],
],
);
case _Step.importing:
final total = _result!.valid.length;
final done = _imported + _failed;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
LinearProgressIndicator(
value: total > 0 ? done / total : null,
color: SomaColors.primary,
),
const SizedBox(height: 12),
Text(
'Importando $done / $total...',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 8),
],
);
case _Step.done:
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
if (_imported > 0)
_SummaryRow(
icon: Icons.check_circle_outline,
color: SomaColors.success,
label: '$_imported pago${_imported == 1 ? '' : 's'} importado${_imported == 1 ? '' : 's'} correctamente',
),
if (_failed > 0) ...[
const SizedBox(height: 4),
_SummaryRow(
icon: Icons.error_outline,
color: SomaColors.error,
label: '$_failed fila${_failed == 1 ? '' : 's'} con error',
),
],
const SizedBox(height: 8),
],
);
}
}
List<Widget> _buildActions(ThemeData theme) {
switch (_step) {
case _Step.idle:
return [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancelar'),
),
FilledButton.icon(
onPressed: _pickAndParse,
icon: const Icon(Icons.folder_open_outlined, size: 18),
label: const Text('Seleccionar archivo'),
),
];
case _Step.picking:
case _Step.importing:
return const [];
case _Step.preview:
final importable = _result!.valid.isNotEmpty;
return [
TextButton(
onPressed: () {
setState(() {
_step = _Step.idle;
_result = null;
});
},
child: const Text('Cambiar archivo'),
),
FilledButton(
onPressed: importable ? _runImport : null,
child: Text(
'Importar ${_result!.valid.length} pago${_result!.valid.length == 1 ? '' : 's'}',
),
),
];
case _Step.done:
return [
FilledButton(
onPressed: () {
Navigator.pop(context);
if (_imported > 0) {
SomaToast.show(
context,
message: '$_imported pago${_imported == 1 ? '' : 's'} importado${_imported == 1 ? '' : 's'}',
type: ToastType.success,
);
}
},
child: const Text('Cerrar'),
),
];
}
}
}
// ── Helpers visuales ──────────────────────────────────────────────────────────
enum _Step { idle, picking, preview, importing, done }
class _SummaryRow extends StatelessWidget {
final IconData icon;
final Color color;
final String label;
const _SummaryRow({
required this.icon,
required this.color,
required this.label,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: TextStyle(fontSize: 13, color: color),
),
),
],
);
}
}
class _ErrorRow extends StatelessWidget {
final int rowNum;
final String msg;
const _ErrorRow({required this.rowNum, required this.msg});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
'Fila $rowNum: $msg',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurface.withAlpha(160),
),
),
);
}
}
class _ErrorChip extends StatelessWidget {
final String message;
const _ErrorChip({required this.message});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: SomaColors.error.withAlpha(14),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: SomaColors.error.withAlpha(50), width: 0.8),
),
child: Row(
children: [
Icon(Icons.error_outline, size: 14, color: SomaColors.error),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: const TextStyle(fontSize: 12, color: SomaColors.error),
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,821 @@
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/pagos/domain/entities/pago.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
/// Muestra el historial de pagos de un usuario en dos layers:
/// 1. Grilla calendar de meses (verde = pagó, gris = no estuvo).
/// Pinta sólo en base a pagos NO anulados (un pago anulado no
/// cuenta el mes como pagado).
/// 2. Lista cronológica de pagos individuales debajo, con toggle
/// "Mostrar anulados" para que Juani vea exactamente qué se cargó,
/// qué se editó y qué se anuló (con motivo y autor).
///
/// El fetch al backend pide siempre incluirAnulados=true (un único request);
/// el toggle de la sección filtra localmente.
class UsuarioHistorialDialog extends ConsumerStatefulWidget {
const UsuarioHistorialDialog({
super.key,
required this.dni,
required this.nombre,
required this.initials,
});
final String dni;
final String nombre;
final String initials;
@override
ConsumerState<UsuarioHistorialDialog> createState() =>
_UsuarioHistorialDialogState();
}
class _UsuarioHistorialDialogState
extends ConsumerState<UsuarioHistorialDialog> {
bool _incluirAnulados = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final historialAsync = ref.watch(
userHistorialProvider((dni: widget.dni, incluirAnulados: true)),
);
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
constraints: BoxConstraints(
maxWidth: 560,
maxHeight: MediaQuery.of(context).size.height * 0.9,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Container(
padding: const EdgeInsets.fromLTRB(20, 20, 12, 20),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(30),
shape: BoxShape.circle,
),
child: Center(
child: Text(
widget.initials,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: SomaColors.primary,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.nombre,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: cs.onSurface,
),
),
Text(
'DNI ${widget.dni}',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(130),
),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, size: 20),
style: IconButton.styleFrom(
backgroundColor: cs.surfaceContainerHighest,
),
),
],
),
),
// Contenido scrollable
Flexible(
fit: FlexFit.loose,
child: historialAsync.when(
loading: () => const Padding(
padding: EdgeInsets.all(40),
child: Center(
child: CircularProgressIndicator(color: SomaColors.primary),
),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Text(
'Error al cargar historial',
style: TextStyle(color: cs.onSurface.withAlpha(130)),
),
),
),
data: (pagosAll) {
// pagosAll viene del backend con anulados incluidos.
// Para la grilla calendar: sólo los efectivos (no anulados).
// Para la lista cronológica: filtra según toggle local.
final pagosEfectivos =
pagosAll.where((p) => !p.isAnulado).toList();
final pagosLista = _incluirAnulados
? pagosAll
: pagosEfectivos;
final paidMonths = <String>{};
for (final p in pagosEfectivos) {
if (p.anioMesPagado.length >= 7) {
paidMonths.add(p.anioMesPagado.substring(0, 7));
}
}
final totalEfectivo = pagosEfectivos.fold<double>(
0,
(sum, p) => sum + p.montoTotal,
);
final anuladosCount =
pagosAll.where((p) => p.isAnulado).length;
final now = DateTime.now();
final currentMonth =
'${now.year}-${now.month.toString().padLeft(2, '0')}';
final months = _buildMonthRange(pagosEfectivos, currentMonth);
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Stats
_StatsRow(
pagoCount: pagosEfectivos.length,
totalMonto: totalEfectivo,
firstMonth: months.isNotEmpty ? months.first : null,
anuladosCount: anuladosCount,
),
const SizedBox(height: 20),
// Section: Historial calendar
_sectionLabel(context, 'Historial'),
const SizedBox(height: 12),
if (months.isEmpty)
Center(
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 16),
child: Text(
'Sin pagos registrados',
style: TextStyle(
color: cs.onSurface.withAlpha(130),
),
),
),
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: months
.map(
(m) => _MonthTile(
yearMonth: m,
paid: paidMonths.contains(m),
isCurrent: m == currentMonth,
),
)
.toList(),
),
const SizedBox(height: 14),
// Leyenda
Row(
children: [
_LegendDot(
color: SomaColors.success,
label: 'Pagó',
),
const SizedBox(width: 16),
_LegendDot(
color: cs.onSurface.withAlpha(40),
label: 'No estuvo',
),
],
),
const SizedBox(height: 24),
// Section: Detalle de pagos + toggle anulados
Row(
children: [
Expanded(
child:
_sectionLabel(context, 'Detalle de pagos'),
),
FilterChip(
label: const Text('Mostrar anulados'),
selected: _incluirAnulados,
onSelected: (v) =>
setState(() => _incluirAnulados = v),
avatar: anuladosCount > 0
? CircleAvatar(
radius: 9,
backgroundColor: _incluirAnulados
? SomaColors.error
: SomaColors.error.withAlpha(120),
child: Text(
'$anuladosCount',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
)
: null,
labelStyle: TextStyle(
fontSize: 11,
color: cs.onSurface,
),
selectedColor:
SomaColors.error.withAlpha(30),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: _incluirAnulados
? SomaColors.error.withAlpha(160)
: cs.surfaceContainerHighest,
width: 0.8,
),
),
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 10),
if (pagosLista.isEmpty)
Center(
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 16),
child: Text(
'Sin pagos para mostrar',
style: TextStyle(
color: cs.onSurface.withAlpha(130),
),
),
),
)
else
Column(
children: pagosLista
.map((p) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: _PagoListItem(pago: p),
))
.toList(),
),
],
),
);
},
),
),
],
),
),
);
}
/// Genera la lista de meses desde el primero pagado hasta el mes actual.
/// Recibe sólo los pagos efectivos (anulados ya filtrados).
List<String> _buildMonthRange(List<Pago> pagos, String currentMonth) {
if (pagos.isEmpty) return [];
String? firstMonth;
for (final p in pagos) {
if (p.anioMesPagado.length < 7) continue;
final m = p.anioMesPagado.substring(0, 7);
if (firstMonth == null || m.compareTo(firstMonth) < 0) {
firstMonth = m;
}
}
if (firstMonth == null) return [];
final result = <String>[];
final startParts = firstMonth.split('-');
var cursor = DateTime(int.parse(startParts[0]), int.parse(startParts[1]));
final endParts = currentMonth.split('-');
final end = DateTime(int.parse(endParts[0]), int.parse(endParts[1]));
while (!cursor.isAfter(end)) {
result.add('${cursor.year}-${cursor.month.toString().padLeft(2, '0')}');
cursor = DateTime(cursor.year, cursor.month + 1);
}
return result;
}
}
// ─────────────────────────────────────────────────────────────────────────────
Widget _sectionLabel(BuildContext context, String text) {
final theme = Theme.of(context);
return Row(
children: [
Container(
width: 3,
height: 14,
decoration: BoxDecoration(
color: SomaColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 7),
Text(
text.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
],
);
}
// ─────────────────────────────────────────────────────────────────────────────
class _StatsRow extends StatelessWidget {
const _StatsRow({
required this.pagoCount,
required this.totalMonto,
required this.firstMonth,
required this.anuladosCount,
});
final int pagoCount;
final double totalMonto;
final String? firstMonth;
final int anuladosCount;
String _formatMonto(double n) {
if (n >= 1000000) return '\$${(n / 1000000).toStringAsFixed(1)}M';
if (n >= 1000) {
final k = n / 1000;
return '\$${k % 1 == 0 ? k.toStringAsFixed(0) : k.toStringAsFixed(1)}k';
}
return '\$${n.toStringAsFixed(0)}';
}
String? _formatFirstMonth(String? m) {
if (m == null) return null;
final parts = m.split('-');
if (parts.length != 2) return m;
const abrev = [
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
final month = int.tryParse(parts[1]) ?? 0;
final year = parts[0].substring(2);
return "${abrev[month]} '$year";
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: cs.surfaceContainerHighest, width: 0.8),
),
child: Row(
children: [
_StatCell(
value: '$pagoCount',
label: pagoCount == 1 ? 'pago' : 'pagos',
),
_StatDivider(),
_StatCell(
value: _formatMonto(totalMonto),
label: 'total acumulado',
),
if (firstMonth != null) ...[
_StatDivider(),
_StatCell(
value: _formatFirstMonth(firstMonth) ?? firstMonth!,
label: 'primer pago',
),
],
if (anuladosCount > 0) ...[
_StatDivider(),
_StatCell(
value: '$anuladosCount',
label: anuladosCount == 1 ? 'anulado' : 'anulados',
valueColor: SomaColors.error,
),
],
],
),
);
}
}
class _StatCell extends StatelessWidget {
const _StatCell({
required this.value,
required this.label,
this.valueColor,
});
final String value;
final String label;
final Color? valueColor;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Expanded(
child: Column(
children: [
Text(
value,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: valueColor ?? cs.onSurface,
),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
fontSize: 10,
color: cs.onSurface.withAlpha(120),
),
textAlign: TextAlign.center,
),
],
),
);
}
}
class _StatDivider extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
height: 32,
child: VerticalDivider(
width: 1,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _MonthTile extends StatelessWidget {
const _MonthTile({
required this.yearMonth,
required this.paid,
required this.isCurrent,
});
final String yearMonth; // "YYYY-MM"
final bool paid;
final bool isCurrent;
static const _mesAbrev = [
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final parts = yearMonth.split('-');
final month = int.tryParse(parts[1]) ?? 0;
final year = parts[0].substring(2);
final Color bg;
final Color textColor;
final Color borderColor;
final Widget icon;
if (paid) {
bg = SomaColors.success.withAlpha(18);
textColor = SomaColors.success;
borderColor = SomaColors.success.withAlpha(90);
icon = Icon(Icons.check_rounded, size: 14, color: SomaColors.success);
} else if (isCurrent) {
bg = SomaColors.primary.withAlpha(12);
textColor = cs.onSurface;
borderColor = SomaColors.primary.withAlpha(120);
icon = Icon(
Icons.radio_button_unchecked,
size: 12,
color: cs.onSurface.withAlpha(80),
);
} else {
bg = cs.surfaceContainerHighest.withAlpha(80);
textColor = cs.onSurface.withAlpha(100);
borderColor = cs.surfaceContainerHighest;
icon = SizedBox(
height: 14,
child: Center(
child: Container(
width: 12,
height: 1.5,
color: cs.onSurface.withAlpha(40),
),
),
);
}
return Container(
width: 52,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: borderColor, width: 0.8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_mesAbrev[month],
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: textColor,
),
),
const SizedBox(height: 2),
Text(
"'$year",
style: TextStyle(
fontSize: 9,
color: textColor.withAlpha(180),
),
),
const SizedBox(height: 4),
icon,
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _LegendDot extends StatelessWidget {
const _LegendDot({required this.color, required this.label});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurface.withAlpha(120),
),
),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
/// Item compacto de la lista cronológica de pagos. Si el pago está anulado
/// muestra sub-línea VISIBLE con motivo + autor + tiempo. Si está editado
/// muestra mini ícono lápiz con tooltip (info secundaria).
class _PagoListItem extends StatelessWidget {
final Pago pago;
const _PagoListItem({required this.pago});
static const _mesAbrev = [
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
String _mesAbreviado() {
final d = DateTime.tryParse(pago.anioMesPagado);
if (d == null) return pago.anioMesPagado;
return "${_mesAbrev[d.month]} '${d.year.toString().substring(2)}";
}
String _formatMonto(double n) =>
n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2);
String _cargadoLabel() {
final f = pago.fechaPago;
if (f == null) return 'Cargado —';
return 'Cargado ${f.day.toString().padLeft(2, '0')}/${f.month.toString().padLeft(2, '0')}';
}
String _anuladoSubline() {
final motivo = (pago.motivoAnulacion?.trim().isNotEmpty ?? false)
? pago.motivoAnulacion!.trim()
: 'Sin motivo';
final autor = pago.anuladoPorNombre ?? 'admin';
final hace = pago.anuladoAt != null ? _timeagoEs(pago.anuladoAt!) : '';
return hace.isEmpty
? '$motivo · por $autor'
: '$motivo · por $autor · $hace';
}
String _editadoTooltip() {
final autor = pago.updatedByNombre ?? 'admin';
final cuando = pago.updatedAt;
if (cuando == null) return 'Editado por $autor';
final f =
'${cuando.day.toString().padLeft(2, '0')}/${cuando.month.toString().padLeft(2, '0')}/${cuando.year}';
return 'Editado por $autor el $f';
}
static String _timeagoEs(DateTime when) {
final diff = DateTime.now().difference(when);
if (diff.inSeconds < 60) return 'hace unos segundos';
if (diff.inMinutes < 60) return 'hace ${diff.inMinutes} min';
if (diff.inHours < 24) return 'hace ${diff.inHours} h';
if (diff.inDays < 30) {
final d = diff.inDays;
return d == 1 ? 'hace 1 día' : 'hace $d días';
}
if (diff.inDays < 365) {
final m = (diff.inDays / 30).floor();
return m == 1 ? 'hace 1 mes' : 'hace $m meses';
}
final y = (diff.inDays / 365).floor();
return y == 1 ? 'hace 1 año' : 'hace $y años';
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final anulado = pago.isAnulado;
final editado = pago.isEditado && !anulado;
final mainTextColor =
anulado ? cs.onSurface.withAlpha(140) : cs.onSurface;
final montoColor =
anulado ? SomaColors.error.withAlpha(160) : SomaColors.success;
final decoration = anulado ? TextDecoration.lineThrough : null;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: cs.surfaceContainerHighest, width: 0.6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
SizedBox(
width: 60,
child: Text(
_mesAbreviado(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: mainTextColor,
decoration: decoration,
),
),
),
SizedBox(
width: 70,
child: Text(
'\$${_formatMonto(pago.montoTotal)}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: montoColor,
decoration: decoration,
),
),
),
Expanded(
child: Text(
pago.metodo,
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(140),
),
overflow: TextOverflow.ellipsis,
),
),
if (editado) ...[
Tooltip(
message: _editadoTooltip(),
child: Icon(
Icons.edit_outlined,
size: 12,
color: cs.onSurface.withAlpha(140),
),
),
const SizedBox(width: 6),
],
Text(
_cargadoLabel(),
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(120),
),
),
],
),
if (anulado) ...[
const SizedBox(height: 6),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color: SomaColors.error.withAlpha(28),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'ANULADO',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: SomaColors.error,
letterSpacing: 0.5,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_anuladoSubline(),
style: TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
color: cs.onSurface.withAlpha(130),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
);
}
}
@@ -0,0 +1,173 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
class PagosExport {
// ── CSV ───────────────────────────────────────────────────────────────────
static Future<void> exportToCsv(
BuildContext context,
List<Pago> pagos, {
String? filtroMes,
}) async {
final csvBytes = _buildCsvBytes(pagos);
final stamp = DateTime.now();
final defaultName =
'pagos_${stamp.year}${stamp.month.toString().padLeft(2, '0')}${stamp.day.toString().padLeft(2, '0')}.csv';
final outputPath = await FilePicker.platform.saveFile(
dialogTitle: 'Guardar pagos como CSV',
fileName: defaultName,
type: FileType.custom,
allowedExtensions: ['csv'],
);
if (outputPath == null) return; // usuario canceló
await File(outputPath).writeAsBytes(csvBytes);
if (context.mounted) {
SomaToast.show(context, message: 'CSV guardado correctamente', type: ToastType.success);
}
}
static List<int> _buildCsvBytes(List<Pago> pagos) {
final buf = StringBuffer();
buf.writeln('dni,nombre_apellido,anio_mes_pagado,monto_total,metodo,fecha_pago');
for (final p in pagos) {
final c = p.cliente;
final dni = _csvCell(c?.dni ?? '');
final nombre = _csvCell('${c?.nombre ?? ''} ${c?.apellido ?? ''}'.trim());
// anio_mes: usar solo YYYY-MM para reimportar
final mes = p.anioMesPagado.length >= 7 ? p.anioMesPagado.substring(0, 7) : p.anioMesPagado;
final monto = p.montoTotal.toStringAsFixed(2);
final metodo = _csvCell(p.metodo);
final fecha = p.fechaPago != null
? '${p.fechaPago!.year}-'
'${p.fechaPago!.month.toString().padLeft(2, '0')}-'
'${p.fechaPago!.day.toString().padLeft(2, '0')}'
: '';
buf.writeln('$dni,$nombre,$mes,$monto,$metodo,$fecha');
}
// BOM para compatibilidad con Excel (UTF-8)
return [0xEF, 0xBB, 0xBF, ...utf8.encode(buf.toString())];
}
// Envuelve la celda en comillas si contiene coma, comilla o salto de línea.
static String _csvCell(String value) {
if (value.contains(',') || value.contains('"') || value.contains('\n')) {
return '"${value.replaceAll('"', '""')}"';
}
return value;
}
// ── PDF ───────────────────────────────────────────────────────────────────
static Future<void> exportToPdf(
BuildContext context,
List<Pago> pagos, {
String? filtroMes,
}) async {
final doc = _buildPdfDocument(pagos, filtroMes: filtroMes);
await Printing.layoutPdf(
onLayout: (_) => doc.save(),
name: filtroMes != null ? 'Pagos $filtroMes' : 'Pagos',
);
}
static pw.Document _buildPdfDocument(List<Pago> pagos, {String? filtroMes}) {
final doc = pw.Document();
final totalMonto = pagos.fold<double>(0, (sum, p) => sum + p.montoTotal);
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.symmetric(horizontal: 32, vertical: 36),
header: (_) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'SOMA Listado de Pagos',
style: pw.TextStyle(
fontSize: 16,
fontWeight: pw.FontWeight.bold,
),
),
if (filtroMes != null)
pw.Text(
filtroMes,
style: const pw.TextStyle(fontSize: 11),
),
],
),
pw.SizedBox(height: 4),
pw.Divider(thickness: 0.5),
],
),
footer: (ctx) => pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'Total: \$${totalMonto.toStringAsFixed(2)} · ${pagos.length} pago${pagos.length == 1 ? '' : 's'}',
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),
),
pw.Text(
'Pág. ${ctx.pageNumber} / ${ctx.pagesCount}',
style: const pw.TextStyle(fontSize: 9),
),
],
),
build: (ctx) => [
pw.TableHelper.fromTextArray(
headers: ['DNI', 'Socio', 'Mes pagado', 'Método', 'Monto'],
headerStyle: pw.TextStyle(
fontSize: 9,
fontWeight: pw.FontWeight.bold,
),
cellStyle: const pw.TextStyle(fontSize: 9),
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200),
cellAlignments: {
0: pw.Alignment.centerLeft,
1: pw.Alignment.centerLeft,
2: pw.Alignment.centerLeft,
3: pw.Alignment.centerLeft,
4: pw.Alignment.centerRight,
},
columnWidths: {
0: const pw.FixedColumnWidth(72),
1: const pw.FlexColumnWidth(2.5),
2: const pw.FlexColumnWidth(1.8),
3: const pw.FlexColumnWidth(1.8),
4: const pw.FixedColumnWidth(68),
},
data: pagos.map((p) {
final c = p.cliente;
return [
c?.dni ?? '',
c != null ? '${c.apellido}, ${c.nombre}'.trim() : '',
p.mesPagadoDisplay,
p.metodo,
'\$${p.montoTotal.toStringAsFixed(2)}',
];
}).toList(),
),
],
),
);
return doc;
}
}
@@ -0,0 +1,169 @@
import 'dart:convert';
/// Fila parseada de un CSV de pagos.
class PagosImportRow {
final int rowNumber;
final String dni;
final String anioMesPagado; // formato YYYY-MM-01
final double montoTotal;
final String metodoNombre;
final String? fechaPago; // YYYY-MM-DD, opcional
final String? validationError;
const PagosImportRow({
required this.rowNumber,
required this.dni,
required this.anioMesPagado,
required this.montoTotal,
required this.metodoNombre,
this.fechaPago,
this.validationError,
});
bool get isValid => validationError == null;
}
/// Resultado del parseo de un CSV exportado por la app.
class PagosImportResult {
final List<PagosImportRow> rows; // incluye válidas e inválidas
final List<String> parseErrors; // errores que impidieron leer el archivo
const PagosImportResult({required this.rows, this.parseErrors = const []});
List<PagosImportRow> get valid => rows.where((r) => r.isValid).toList();
List<PagosImportRow> get invalid => rows.where((r) => !r.isValid).toList();
}
/// Parsea el contenido de un CSV exportado con [PagosExport.exportToCsv].
/// Columnas esperadas: dni, nombre_apellido, anio_mes_pagado, monto_total, metodo, fecha_pago
PagosImportResult parsePagosCsv(List<int> bytes) {
// Quitar BOM UTF-8 si está presente
final content = bytes.length >= 3 &&
bytes[0] == 0xEF &&
bytes[1] == 0xBB &&
bytes[2] == 0xBF
? utf8.decode(bytes.sublist(3))
: utf8.decode(bytes);
final lines = content
.replaceAll('\r\n', '\n')
.replaceAll('\r', '\n')
.split('\n')
.where((l) => l.trim().isNotEmpty)
.toList();
if (lines.isEmpty) {
return const PagosImportResult(
rows: [],
parseErrors: ['El archivo está vacío'],
);
}
// Verificar encabezado
final headerCells = _splitCsvLine(lines[0]);
const expectedHeaders = [
'dni',
'nombre_apellido',
'anio_mes_pagado',
'monto_total',
'metodo',
'fecha_pago',
];
final missingHeaders = expectedHeaders
.where((h) => !headerCells.map((c) => c.toLowerCase()).contains(h))
.toList();
if (missingHeaders.isNotEmpty) {
return PagosImportResult(
rows: const [],
parseErrors: [
'Formato de archivo incorrecto. Columnas faltantes: ${missingHeaders.join(', ')}',
],
);
}
final headerIndex = {
for (var i = 0; i < headerCells.length; i++) headerCells[i].toLowerCase(): i
};
final rows = <PagosImportRow>[];
for (var i = 1; i < lines.length; i++) {
final cells = _splitCsvLine(lines[i]);
if (cells.length < 4) continue;
int col(String name) => headerIndex[name] ?? -1;
String get(String name) {
final idx = col(name);
return (idx >= 0 && idx < cells.length) ? cells[idx].trim() : '';
}
final rowNum = i;
final dni = get('dni');
final mesRaw = get('anio_mes_pagado'); // YYYY-MM o YYYY-MM-DD
final montoStr = get('monto_total');
final metodo = get('metodo');
final fechaRaw = get('fecha_pago');
// Validaciones
String? error;
if (dni.isEmpty) {
error = 'DNI vacío';
} else if (mesRaw.isEmpty || !RegExp(r'^\d{4}-\d{2}').hasMatch(mesRaw)) {
error = 'Mes inválido: "$mesRaw"';
} else if (double.tryParse(montoStr) == null ||
(double.tryParse(montoStr) ?? 0) <= 0) {
error = 'Monto inválido: "$montoStr"';
} else if (metodo.isEmpty) {
error = 'Método vacío';
}
// Normalizar anio_mes_pagado a YYYY-MM-01
final anioMes = mesRaw.length >= 7
? '${mesRaw.substring(0, 7)}-01'
: mesRaw;
// Normalizar fecha_pago (aceptar YYYY-MM-DD, dejar null si vacío/inválido)
String? fechaFinal;
if (fechaRaw.isNotEmpty &&
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(fechaRaw)) {
fechaFinal = fechaRaw;
}
rows.add(PagosImportRow(
rowNumber: rowNum,
dni: dni,
anioMesPagado: anioMes,
montoTotal: double.tryParse(montoStr) ?? 0,
metodoNombre: metodo,
fechaPago: fechaFinal,
validationError: error,
));
}
return PagosImportResult(rows: rows);
}
/// Divide una línea CSV respetando celdas entre comillas.
List<String> _splitCsvLine(String line) {
final result = <String>[];
final buf = StringBuffer();
var inQuotes = false;
for (var i = 0; i < line.length; i++) {
final ch = line[i];
if (ch == '"') {
if (inQuotes && i + 1 < line.length && line[i + 1] == '"') {
buf.write('"');
i++;
} else {
inQuotes = !inQuotes;
}
} else if (ch == ',' && !inQuotes) {
result.add(buf.toString());
buf.clear();
} else {
buf.write(ch);
}
}
result.add(buf.toString());
return result;
}
@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
import 'package:gimnasio_soma/core/widgets/soma_primary_button.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
/// Autoservicio: el propio usuario logueado cambia su contraseña,
/// confirmando primero la actual. Accesible desde Perfil para admin/superadmin.
class CambiarContrasenaScreen extends ConsumerStatefulWidget {
const CambiarContrasenaScreen({super.key});
@override
ConsumerState<CambiarContrasenaScreen> createState() =>
_CambiarContrasenaScreenState();
}
class _CambiarContrasenaScreenState
extends ConsumerState<CambiarContrasenaScreen> {
final _formKey = GlobalKey<FormState>();
final _actualCtrl = TextEditingController();
final _nuevaCtrl = TextEditingController();
final _repetirCtrl = TextEditingController();
bool _obscureActual = true;
bool _obscureNueva = true;
bool _obscureRepetir = true;
bool _isLoading = false;
@override
void dispose() {
_actualCtrl.dispose();
_nuevaCtrl.dispose();
_repetirCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
await ref.read(authRepositoryProvider).cambiarPropiaContrasena(
passwordActual: _actualCtrl.text,
passwordNueva: _nuevaCtrl.text,
);
if (!mounted) return;
SomaToast.show(context,
message: 'Contraseña actualizada', type: ToastType.success);
context.pop();
} catch (e) {
if (!mounted) return;
SomaToast.show(
context,
message: e.toString().replaceFirst('Exception: ', ''),
type: ToastType.error,
);
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Widget _passwordField({
required TextEditingController controller,
required String label,
required bool obscure,
required VoidCallback onToggle,
String? Function(String?)? validator,
}) {
return SomaTextField(
controller: controller,
labelText: label,
prefixIcon: Icons.lock_outline,
obscureText: obscure,
suffixIcon: IconButton(
icon: Icon(
obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined,
color: Theme.of(context).colorScheme.onSurface.withAlpha(130),
size: 20,
),
onPressed: onToggle,
),
validator: validator,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Cambiar contraseña')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_passwordField(
controller: _actualCtrl,
label: 'Contraseña actual',
obscure: _obscureActual,
onToggle: () =>
setState(() => _obscureActual = !_obscureActual),
validator: (v) => (v == null || v.isEmpty)
? 'Ingresá tu contraseña actual'
: null,
),
const SizedBox(height: 16),
_passwordField(
controller: _nuevaCtrl,
label: 'Contraseña nueva',
obscure: _obscureNueva,
onToggle: () =>
setState(() => _obscureNueva = !_obscureNueva),
validator: (v) {
if (v == null || v.isEmpty) {
return 'Ingresá la contraseña nueva';
}
if (v.length < 8) return 'Mínimo 8 caracteres';
return null;
},
),
const SizedBox(height: 16),
_passwordField(
controller: _repetirCtrl,
label: 'Repetir contraseña nueva',
obscure: _obscureRepetir,
onToggle: () =>
setState(() => _obscureRepetir = !_obscureRepetir),
validator: (v) {
if (v != _nuevaCtrl.text) {
return 'Las contraseñas no coinciden';
}
return null;
},
),
const SizedBox(height: 28),
SomaPrimaryButton(
text: 'Guardar',
onPressed: _submit,
isLoading: _isLoading,
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,312 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
class PerfilScreen extends ConsumerWidget {
const PerfilScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(authStateProvider).valueOrNull;
final isWide = MediaQuery.of(context).size.width >= 800;
final theme = Theme.of(context);
if (user == null) return const SizedBox.shrink();
String initials() {
if (user.nombre.isNotEmpty && user.apellido.isNotEmpty) {
return '${user.nombre[0]}${user.apellido[0]}'.toUpperCase();
}
if (user.nombre.isNotEmpty) return user.nombre[0].toUpperCase();
if (user.dni.length >= 2) return user.dni.substring(0, 2);
return '?';
}
String rolDisplay() {
switch (user.role) {
case 'superadmin':
return 'Super Admin';
case 'admin':
return 'Administrador';
case 'profesor':
return 'Profesor';
case 'cliente':
return 'Cliente';
default:
return user.role;
}
}
return Scaffold(
body: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
32,
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
children: [
// Header
const Align(
alignment: Alignment.centerLeft,
child: Text(
'Mi Perfil',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
),
const SizedBox(height: 24),
// Avatar con ring amarillo
Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: SomaColors.primary,
width: 2.5,
),
),
child: CircleAvatar(
radius: 40,
backgroundColor: SomaColors.primary.withAlpha(35),
child: Text(
initials(),
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
),
),
const SizedBox(height: 14),
Text(
user.displayName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(18),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: SomaColors.primary.withAlpha(60),
width: 0.5,
),
),
child: Text(
rolDisplay(),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: SomaColors.primaryText,
),
),
),
const SizedBox(height: 28),
// Info cards
_InfoTile(
icon: Icons.badge_outlined,
label: 'DNI',
value: user.dni,
),
if (user.mail != null && user.mail!.isNotEmpty)
_InfoTile(
icon: Icons.email_outlined,
label: 'Email',
value: user.mail!,
),
if (user.telefono != null && user.telefono!.isNotEmpty)
_InfoTile(
icon: Icons.phone_outlined,
label: 'Teléfono',
value: user.telefono!,
),
const SizedBox(height: 24),
// Cambiar contraseña (staff)
if (user.isStaff)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: ListTile(
leading: Icon(
Icons.lock_outline,
size: 20,
color: theme.colorScheme.onSurface.withAlpha(153),
),
title: const Text(
'Cambiar contraseña',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w500),
),
trailing: Icon(
Icons.chevron_right,
size: 20,
color: theme.colorScheme.onSurface.withAlpha(100),
),
contentPadding: EdgeInsets.zero,
onTap: () => context.go('/perfil/cambiar-contrasena'),
),
),
),
// Logs (admin)
if (user.isStaff)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: ListTile(
leading: Icon(
Icons.terminal,
size: 20,
color:
theme.colorScheme.onSurface.withAlpha(153),
),
title: const Text(
'Ver logs',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w500),
),
trailing: Icon(
Icons.chevron_right,
size: 20,
color:
theme.colorScheme.onSurface.withAlpha(100),
),
contentPadding: EdgeInsets.zero,
onTap: () => context.go('/logs'),
),
),
),
const SizedBox(height: 16),
// Logout
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () =>
ref.read(authStateProvider.notifier).logout(),
icon: const Icon(Icons.logout, size: 18),
label: const Text('Cerrar sesión'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 48),
foregroundColor: SomaColors.error,
side: const BorderSide(
color: SomaColors.error, width: 0.8),
),
),
),
],
),
),
),
),
);
}
}
class _InfoTile extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const _InfoTile({
required this.icon,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(16),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
size: 18,
color: SomaColors.primary,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(110),
),
),
Text(
value,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,83 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/config/supabase_config.dart';
import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart';
import 'package:gimnasio_soma/features/tipos_cuota/domain/repositories/tipos_cuota_repository.dart';
class TiposCuotaRepositoryImpl implements TiposCuotaRepository {
Future<String> _getToken() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token == null) throw Exception('Sin sesión activa');
return token;
}
@override
Future<List<TipoCuota>> getTiposCuota() async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetTiposCuota,
params: {'p_token': token},
);
if (response is List) {
return response
.map((e) => TipoCuota.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<void> insertTipoCuota(Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcInsertTipoCuota,
params: {'p_token': token, 'p_datos': datos},
);
}
@override
Future<void> updateTipoCuota(String id, Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcUpdateTipoCuota,
params: {'p_token': token, 'p_id': id, 'p_datos': datos},
);
}
@override
Future<bool> deleteTipoCuota(String id) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcDeleteTipoCuota,
params: {'p_token': token, 'p_id': id},
);
return response == true;
}
@override
Future<List<Map<String, dynamic>>> getActividadesTipoCuota(
String tipoCuotaId) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetActividadesTipoCuota,
params: {
'p_token': token,
'p_tipo_cuota_id': tipoCuotaId,
},
);
if (response is List) {
return response
.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
}
return [];
}
}
@@ -0,0 +1,57 @@
class TipoCuota {
final String id;
final String nombre;
final String? descripcion;
final int diasSemana;
final double precio;
final bool paraSocios;
final int diaDePago;
final double? recargo;
final List<int> actividadesIds;
const TipoCuota({
required this.id,
required this.nombre,
this.descripcion,
required this.diasSemana,
required this.precio,
required this.paraSocios,
this.diaDePago = 10,
this.recargo,
this.actividadesIds = const [],
});
factory TipoCuota.fromMap(Map<String, dynamic> map) {
return TipoCuota(
id: map['id'] as String? ?? '',
nombre: map['nombre'] as String? ?? '',
descripcion: map['descripcion'] as String?,
diasSemana: (map['dias_semana'] as num?)?.toInt() ?? 0,
precio: (map['precio'] as num?)?.toDouble() ?? 0,
paraSocios: map['parasocios'] as bool? ?? false,
diaDePago: (map['dia_de_pago'] as num?)?.toInt() ?? 10,
recargo: (map['recargo'] as num?)?.toDouble(),
actividadesIds: (map['actividades_ids'] as List?)
?.map((e) => (e as num).toInt())
.toList() ??
[],
);
}
Map<String, dynamic> toMap() {
return {
'nombre': nombre,
'descripcion': descripcion,
'dias_semana': diasSemana,
'precio': precio,
'para_socios': paraSocios,
'dia_de_pago': diaDePago,
if (recargo != null) 'recargo': recargo,
if (actividadesIds.isNotEmpty) 'actividades_ids': actividadesIds,
};
}
String get precioDisplay => '\$${precio.toStringAsFixed(precio.truncateToDouble() == precio ? 0 : 2)}';
String get diasDisplay => '$diasSemana día${diasSemana != 1 ? 's' : ''}/sem';
}
@@ -0,0 +1,11 @@
import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart';
abstract class TiposCuotaRepository {
Future<List<TipoCuota>> getTiposCuota();
Future<void> insertTipoCuota(Map<String, dynamic> datos);
Future<void> updateTipoCuota(String id, Map<String, dynamic> datos);
Future<bool> deleteTipoCuota(String id);
/// Obtener actividades asociadas a un tipo de cuota.
Future<List<Map<String, dynamic>>> getActividadesTipoCuota(String tipoCuotaId);
}
@@ -0,0 +1,71 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/features/tipos_cuota/data/repositories/tipos_cuota_repository_impl.dart';
import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart';
import 'package:gimnasio_soma/features/tipos_cuota/domain/repositories/tipos_cuota_repository.dart';
final tiposCuotaRepositoryProvider = Provider<TiposCuotaRepository>((ref) {
return TiposCuotaRepositoryImpl();
});
/// Actividades asociadas a un tipo de cuota (por id).
final actividadesTipoCuotaProvider =
FutureProvider.family<List<Map<String, dynamic>>, String>((ref, id) async {
final repo = ref.read(tiposCuotaRepositoryProvider);
return repo.getActividadesTipoCuota(id);
});
final tiposCuotaProvider =
StateNotifierProvider<TiposCuotaNotifier, AsyncValue<List<TipoCuota>>>(
(ref) {
return TiposCuotaNotifier(ref.read(tiposCuotaRepositoryProvider));
});
class TiposCuotaNotifier extends StateNotifier<AsyncValue<List<TipoCuota>>> {
final TiposCuotaRepository _repository;
TiposCuotaNotifier(this._repository)
: super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
try {
final data = await _repository.getTiposCuota();
state = AsyncValue.data(data);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<String?> insertTipoCuota(Map<String, dynamic> datos) async {
try {
await _repository.insertTipoCuota(datos);
await load();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<String?> updateTipoCuota(
String id, Map<String, dynamic> datos) async {
try {
await _repository.updateTipoCuota(id, datos);
await load();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<String?> deleteTipoCuota(String id) async {
try {
await _repository.deleteTipoCuota(id);
await load();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
}
@@ -0,0 +1,616 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_header_help.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/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/tipos_cuota/presentation/widgets/tipo_cuota_form_dialog.dart';
class TiposCuotaScreen extends ConsumerStatefulWidget {
const TiposCuotaScreen({super.key});
@override
ConsumerState<TiposCuotaScreen> createState() => _TiposCuotaScreenState();
}
class _TiposCuotaScreenState extends ConsumerState<TiposCuotaScreen> {
Future<void> _showCreateDialog() async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => const TipoCuotaFormDialog(),
);
if (result == null || !mounted) return;
final error =
await ref.read(tiposCuotaProvider.notifier).insertTipoCuota(result);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Plan creado', type: ToastType.success);
}
}
Future<void> _showEditDialog(TipoCuota tc) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => TipoCuotaFormDialog(tipoCuota: tc),
);
if (result == null || !mounted) return;
final error = await ref
.read(tiposCuotaProvider.notifier)
.updateTipoCuota(tc.id, result);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Plan actualizado', type: ToastType.success);
}
}
Future<void> _deleteTipoCuota(TipoCuota tc) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Eliminar plan'),
content: Text(
'¿Estás seguro de que querés eliminar "${tc.nombre}"?\n'
'Los usuarios con este plan quedarán sin cuota asignada.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
minimumSize: const Size(0, 40),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Eliminar'),
),
],
),
);
if (confirm != true || !mounted) return;
final error =
await ref.read(tiposCuotaProvider.notifier).deleteTipoCuota(tc.id);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Plan eliminado', type: ToastType.success);
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(tiposCuotaProvider);
final isWide = MediaQuery.of(context).size.width >= 800;
return Scaffold(
body: Column(
children: [
// Header
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
12,
),
child: Row(
children: [
const Text(
'Planes',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SomaHeaderHelp(
items: [
SomaHelpItem(
icon: Icons.add,
text: 'Creá un plan con precio, días de uso y día de '
'pago. Los usuarios se asignan a un plan desde '
'Usuarios.',
),
],
),
const Spacer(),
_AddButton(isWide: isWide, onTap: _showCreateDialog),
],
),
),
// Lista
Expanded(
child: state.when(
loading: () => const Center(
child: CircularProgressIndicator(color: SomaColors.primary),
),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 48,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(100)),
const SizedBox(height: 12),
Text(
e.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(153),
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () =>
ref.read(tiposCuotaProvider.notifier).load(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (planes) {
if (planes.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.card_membership_outlined,
size: 56,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(60)),
const SizedBox(height: 12),
Text(
'No hay planes de cuota',
style: TextStyle(
fontSize: 15,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(130),
),
),
],
),
);
}
return RefreshIndicator(
color: SomaColors.primary,
onRefresh: () =>
ref.read(tiposCuotaProvider.notifier).load(),
child: ListView.separated(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 4, isWide ? 32 : 16, 80,
),
itemCount: planes.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final tc = planes[index];
return _TipoCuotaCard(
tipoCuota: tc,
onEdit: () => _showEditDialog(tc),
onDelete: () => _deleteTipoCuota(tc),
);
},
),
);
},
),
),
],
),
);
}
}
class _TipoCuotaCard extends ConsumerStatefulWidget {
final TipoCuota tipoCuota;
final VoidCallback onEdit;
final VoidCallback onDelete;
const _TipoCuotaCard({
required this.tipoCuota,
required this.onEdit,
required this.onDelete,
});
@override
ConsumerState<_TipoCuotaCard> createState() => _TipoCuotaCardState();
}
class _TipoCuotaCardState extends ConsumerState<_TipoCuotaCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final tc = widget.tipoCuota;
final theme = Theme.of(context);
return InkWell(
onTap: () => setState(() => _expanded = !_expanded),
borderRadius: BorderRadius.circular(12),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Rail de plan — amarillo
Container(width: 4, color: SomaColors.primary),
// Contenido
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 8, 12),
child: Column(
children: [
Row(
children: [
// Precio destacado en stamp
Container(
constraints: const BoxConstraints(
minWidth: 56,
minHeight: 48,
),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(18),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: SomaColors.primary.withAlpha(40),
width: 0.5,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
tc.precioDisplay,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
height: 1,
),
),
Text(
'/mes',
style: TextStyle(
fontSize: 9,
color: theme.colorScheme.onSurface
.withAlpha(100),
),
),
],
),
),
const SizedBox(width: 14),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
tc.nombre,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (tc.paraSocios) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: SomaColors.primary
.withAlpha(18),
borderRadius:
BorderRadius.circular(5),
border: Border.all(
color: SomaColors.primary
.withAlpha(60),
width: 0.5,
),
),
child: const Text(
'Socios',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: SomaColors.primaryText,
),
),
),
],
],
),
const SizedBox(height: 4),
Wrap(
spacing: 8,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.calendar_view_week,
size: 12,
color: theme.colorScheme.onSurface
.withAlpha(100)),
const SizedBox(width: 3),
Text(
tc.diasDisplay,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event,
size: 12,
color: theme.colorScheme.onSurface
.withAlpha(100)),
const SizedBox(width: 3),
Text(
'Vence el ${tc.diaDePago}',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
],
),
if (tc.recargo != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add_circle_outline,
size: 12,
color: SomaColors.error
.withAlpha(160)),
const SizedBox(width: 3),
Text(
'Recargo \$${tc.recargo!.toStringAsFixed(0)}',
style: const TextStyle(
fontSize: 12,
color: SomaColors.error,
),
),
],
),
],
),
],
),
),
Icon(
_expanded
? Icons.expand_less
: Icons.expand_more,
size: 20,
color: theme.colorScheme.onSurface.withAlpha(120),
),
// Actions
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
size: 18,
color:
theme.colorScheme.onSurface.withAlpha(100),
),
itemBuilder: (_) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit_outlined, size: 18),
SizedBox(width: 8),
Text('Editar'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_outline,
size: 18, color: SomaColors.error),
SizedBox(width: 8),
Text('Eliminar',
style:
TextStyle(color: SomaColors.error)),
],
),
),
],
onSelected: (v) {
if (v == 'edit') widget.onEdit();
if (v == 'delete') widget.onDelete();
},
),
],
),
// Actividades expandibles
if (_expanded) _buildActividades(theme),
],
),
),
),
],
),
),
),
);
}
Widget _buildActividades(ThemeData theme) {
final actividadesAsync =
ref.watch(actividadesTipoCuotaProvider(widget.tipoCuota.id));
return Padding(
padding: const EdgeInsets.only(top: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
height: 1,
color: theme.colorScheme.surfaceContainerHighest,
),
const SizedBox(height: 10),
Row(
children: [
Icon(Icons.sports_gymnastics,
size: 14,
color: theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(width: 6),
Text(
'Actividades incluidas',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
const SizedBox(height: 8),
actividadesAsync.when(
loading: () => const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
error: (_, _) => Text(
'Error cargando actividades',
style: TextStyle(fontSize: 12, color: SomaColors.error),
),
data: (actividades) {
if (actividades.isEmpty) {
return Text(
'Sin actividades asignadas',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(100),
),
);
}
return Wrap(
spacing: 6,
runSpacing: 4,
children: actividades.map((a) {
final nombre = a['nombre'] ?? '';
final activo = a['activo'] == true;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: activo
? SomaColors.primary.withAlpha(15)
: theme.colorScheme.onSurface.withAlpha(8),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: activo
? SomaColors.primary.withAlpha(50)
: theme.colorScheme.onSurface.withAlpha(30),
width: 0.5,
),
),
child: Text(
nombre.toString(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: activo
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(80),
),
),
);
}).toList(),
);
},
),
],
),
);
}
}
class _AddButton extends StatelessWidget {
final bool isWide;
final VoidCallback onTap;
const _AddButton({required this.isWide, required this.onTap});
@override
Widget build(BuildContext context) {
if (isWide) {
return ElevatedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.add, size: 20),
label: const Text('Nuevo'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
);
}
return SizedBox(
height: 42,
width: 42,
child: IconButton.filled(
onPressed: onTap,
icon: const Icon(Icons.add, size: 22),
style: IconButton.styleFrom(
backgroundColor: SomaColors.primary,
foregroundColor: SomaColors.onPrimary,
),
),
);
}
}
@@ -0,0 +1,269 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart';
class TipoCuotaFormDialog extends StatefulWidget {
final TipoCuota? tipoCuota;
const TipoCuotaFormDialog({super.key, this.tipoCuota});
@override
State<TipoCuotaFormDialog> createState() => _TipoCuotaFormDialogState();
}
class _TipoCuotaFormDialogState extends State<TipoCuotaFormDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nombreCtrl;
late final TextEditingController _descripcionCtrl;
late final TextEditingController _diasCtrl;
late final TextEditingController _precioCtrl;
late final TextEditingController _diaPagoCtrl;
late final TextEditingController _recargoCtrl;
late bool _paraSocios;
bool get _isEditing => widget.tipoCuota != null;
@override
void initState() {
super.initState();
final tc = widget.tipoCuota;
_nombreCtrl = TextEditingController(text: tc?.nombre ?? '');
_descripcionCtrl = TextEditingController(text: tc?.descripcion ?? '');
_diasCtrl = TextEditingController(
text: tc != null ? tc.diasSemana.toString() : '');
_precioCtrl = TextEditingController(
text: tc != null ? tc.precio.toStringAsFixed(2) : '');
_diaPagoCtrl = TextEditingController(
text: tc != null ? tc.diaDePago.toString() : '10');
_recargoCtrl = TextEditingController(
text: tc?.recargo != null ? tc!.recargo!.toStringAsFixed(2) : '');
_paraSocios = tc?.paraSocios ?? false;
}
@override
void dispose() {
_nombreCtrl.dispose();
_descripcionCtrl.dispose();
_diasCtrl.dispose();
_precioCtrl.dispose();
_diaPagoCtrl.dispose();
_recargoCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final data = <String, dynamic>{
'nombre': _nombreCtrl.text.trim(),
'dias_semana': int.tryParse(_diasCtrl.text.trim()) ?? 0,
'precio': double.tryParse(_precioCtrl.text.trim()) ?? 0,
'para_socios': _paraSocios,
'dia_de_pago': int.tryParse(_diaPagoCtrl.text.trim()) ?? 10,
};
final desc = _descripcionCtrl.text.trim();
if (desc.isNotEmpty) data['descripcion'] = desc;
final recargo = double.tryParse(_recargoCtrl.text.trim());
if (recargo != null) data['recargo'] = recargo;
Navigator.of(context).pop(data);
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isWide = width >= 600;
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isWide ? (width - 480) / 2 : 20,
vertical: 24,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
Text(
_isEditing ? 'Editar Plan' : 'Nuevo Plan',
style: const 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),
// Form
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SomaTextField(
controller: _nombreCtrl,
labelText: 'Nombre *',
prefixIcon: Icons.label_outline,
validator: (v) => v == null || v.trim().isEmpty
? 'Nombre requerido'
: null,
),
const SizedBox(height: 16),
SomaTextField(
controller: _descripcionCtrl,
labelText: 'Descripción',
prefixIcon: Icons.notes,
maxLines: 2,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: SomaTextField(
controller: _diasCtrl,
labelText: 'Días/semana *',
prefixIcon: Icons.calendar_view_week,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(1),
],
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Requerido';
}
final n = int.tryParse(v.trim());
if (n == null || n < 1 || n > 7) {
return '1-7';
}
return null;
},
),
),
const SizedBox(width: 12),
Expanded(
child: SomaTextField(
controller: _precioCtrl,
labelText: 'Precio *',
prefixIcon: Icons.attach_money,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true),
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Requerido';
}
final n = double.tryParse(v.trim());
if (n == null || n < 0) return 'Inválido';
return null;
},
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: SomaTextField(
controller: _diaPagoCtrl,
labelText: 'Día de pago',
prefixIcon: Icons.event,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(2),
],
),
),
const SizedBox(width: 12),
Expanded(
child: SomaTextField(
controller: _recargoCtrl,
labelText: 'Recargo',
prefixIcon: Icons.trending_up,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true),
),
),
],
),
const SizedBox(height: 12),
SwitchListTile(
title: const Text(
'Para socios',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w500),
),
subtitle: const Text(
'Plan exclusivo para socios',
style: TextStyle(fontSize: 12),
),
value: _paraSocios,
activeThumbColor: SomaColors.primary,
contentPadding: EdgeInsets.zero,
onChanged: (v) => setState(() => _paraSocios = v),
),
const SizedBox(height: 8),
],
),
),
),
),
const Divider(height: 1),
// Actions
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: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: Text(_isEditing ? 'Guardar' : 'Crear'),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,142 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/config/supabase_config.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/domain/repositories/turnos_repository.dart';
class TurnosRepositoryImpl implements TurnosRepository {
Future<String> _getToken() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token == null) throw Exception('Sin sesión activa');
return token;
}
String _formatDate(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
@override
Future<SemanaTurnos> obtenerSemana(DateTime weekStart) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerTurnos,
params: {
'p_token': token,
'p_fecha_inicio': _formatDate(weekStart),
'p_cantidad_dias': 7,
},
);
if (response is Map) {
return SemanaTurnos.fromResponse(
weekStart,
response.cast<String, dynamic>(),
);
}
return SemanaTurnos.fromResponse(weekStart, const {});
}
@override
Future<void> crearTurnoManual({
required DateTime fecha,
required int actividadId,
required String horaInicio,
required String horaFin,
required int capacidad,
}) async {
final token = await _getToken();
// Admin workaround documentado en function_guide: fc_upsert_turno
// está marcado BACKEND y se usa intencionalmente sólo desde aquí
// para que Juani agregue un turno suelto sin tocar el schedule.
await SupabaseConfig.rpc(
AppConstants.rpcUpsertTurno,
params: {
'p_token': token,
'p_fecha': _formatDate(fecha),
'p_actividad_id': actividadId,
'p_hora_inicio': horaInicio,
'p_hora_fin': horaFin,
'p_capacidad_maxima': capacidad,
},
);
}
@override
Future<List<InscriptoTurno>> obtenerInscriptos(String turnoId) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerReservasTurno,
params: {'p_token': token, 'p_turno_id': turnoId},
);
if (response is List) {
return response
.map((e) => InscriptoTurno.fromMap((e as Map).cast<String, dynamic>()))
.toList();
}
return const [];
}
@override
Future<void> reservarAdmin({
required String turnoId,
required String clienteId,
}) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcReservarTurnoAdmin,
params: {
'p_token': token,
'p_cliente_id': clienteId,
'p_turno_id': turnoId,
},
);
}
@override
Future<void> cancelarReservaAdmin(String reservaId) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcCancelarReservaAdmin,
params: {'p_token': token, 'p_reserva_id': reservaId},
);
}
@override
Future<EstadoCupo> obtenerEstadoCupo({
required String clienteId,
required DateTime fecha,
}) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerEstadoCupo,
params: {
'p_token': token,
'p_cliente_id': clienteId,
'p_fecha': _formatDate(fecha),
},
);
if (response is Map) {
return EstadoCupo.fromMap(response.cast<String, dynamic>());
}
return const EstadoCupo(
usados: 0,
disponibles: 0,
limiteTotal: 0,
tienePlan: false,
);
}
@override
Future<Map<String, dynamic>> limpiarTurnosAntiguos({
int diasAntiguedad = 30,
}) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcLimpiarTurnosAntiguos,
params: {'p_token': token, 'p_dias_antiguedad': diasAntiguedad},
);
if (response is Map) return response.cast<String, dynamic>();
return {'status': 'error', 'mensaje': 'Respuesta inesperada'};
}
}

Some files were not shown because too many files have changed in this diff Show More