Agrego frontend app
This commit is contained in:
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user