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,
),
);
}
}