Agrego frontend app
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import 'package:gimnasio_soma/core/config/app_constants.dart';
|
||||
import 'package:gimnasio_soma/core/config/supabase_config.dart';
|
||||
import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart';
|
||||
import 'package:gimnasio_soma/features/auth/domain/repositories/auth_repository.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AuthRepositoryImpl implements AuthRepository {
|
||||
@override
|
||||
Future<UserSession> login(String dni, String password) async {
|
||||
// fc_ingresar(dni_input, password_plain_input) → TABLE(token uuid, rol text)
|
||||
final loginResponse = await SupabaseConfig.rpc(
|
||||
AppConstants.rpcLogin,
|
||||
params: {
|
||||
'dni_input': dni,
|
||||
'password_plain_input': password,
|
||||
},
|
||||
);
|
||||
|
||||
if (loginResponse == null ||
|
||||
(loginResponse is List && loginResponse.isEmpty)) {
|
||||
throw Exception('DNI o contraseña incorrectos');
|
||||
}
|
||||
|
||||
// Supabase devuelve TABLE como List<Map>
|
||||
final row = (loginResponse as List)[0] as Map<String, dynamic>;
|
||||
final token = row['token'] as String;
|
||||
final rol = row['rol'] as String;
|
||||
|
||||
// Persistir token
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(AppConstants.tokenKey, token);
|
||||
|
||||
// Intentar obtener datos completos del usuario
|
||||
// fc_obtener_usuario_por_token(p_token, p_user_token) → jsonb
|
||||
try {
|
||||
final userData = await SupabaseConfig.rpc(
|
||||
AppConstants.rpcGetUserByToken,
|
||||
params: {
|
||||
'p_token': token,
|
||||
'p_user_token': token,
|
||||
},
|
||||
);
|
||||
|
||||
if (userData != null && userData is Map<String, dynamic>) {
|
||||
return UserSession.fromUserData(userData, token);
|
||||
}
|
||||
} catch (_) {
|
||||
// Si falla (ej: sin permiso 'ver_usuarios'), usamos datos básicos
|
||||
}
|
||||
|
||||
// Fallback: datos básicos del login
|
||||
return UserSession.fromLogin(token: token, role: rol, dni: dni);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserSession?> validateSession() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString(AppConstants.tokenKey);
|
||||
|
||||
if (token == null || token.isEmpty) return null;
|
||||
|
||||
try {
|
||||
// fc_iniciar_sesion_por_token(p_token) → text (rol), extiende TTL
|
||||
final rol = await SupabaseConfig.rpc(
|
||||
AppConstants.rpcIniciarSesionPorToken,
|
||||
params: {'p_token': token},
|
||||
);
|
||||
|
||||
if (rol == null || (rol is String && rol.isEmpty)) {
|
||||
await prefs.remove(AppConstants.tokenKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Intentar obtener datos completos
|
||||
try {
|
||||
final userData = await SupabaseConfig.rpc(
|
||||
AppConstants.rpcGetUserByToken,
|
||||
params: {
|
||||
'p_token': token,
|
||||
'p_user_token': token,
|
||||
},
|
||||
);
|
||||
|
||||
if (userData != null && userData is Map<String, dynamic>) {
|
||||
return UserSession.fromUserData(userData, token);
|
||||
}
|
||||
} catch (_) {
|
||||
// Sin permiso, usamos datos básicos
|
||||
}
|
||||
|
||||
return UserSession(token: token, role: rol as String);
|
||||
} catch (_) {
|
||||
await prefs.remove(AppConstants.tokenKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString(AppConstants.tokenKey);
|
||||
|
||||
if (token != null) {
|
||||
try {
|
||||
// fc_eliminar_sesion(p_token) → void
|
||||
await SupabaseConfig.rpc(
|
||||
AppConstants.rpcDestroySession,
|
||||
params: {'p_token': token},
|
||||
);
|
||||
} catch (_) {
|
||||
// Silently fail - limpiamos el token local de todas formas
|
||||
}
|
||||
}
|
||||
|
||||
await prefs.remove(AppConstants.tokenKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cambiarPropiaContrasena({
|
||||
required String passwordActual,
|
||||
required String passwordNueva,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString(AppConstants.tokenKey);
|
||||
if (token == null) throw Exception('Sin sesión activa');
|
||||
|
||||
// fc_cambiar_propia_contrasena(p_token, p_password_actual, p_password_nueva) → void
|
||||
await SupabaseConfig.rpc(
|
||||
AppConstants.rpcCambiarPropiaContrasena,
|
||||
params: {
|
||||
'p_token': token,
|
||||
'p_password_actual': passwordActual,
|
||||
'p_password_nueva': passwordNueva,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
class UserSession {
|
||||
final String token;
|
||||
final String role;
|
||||
// UUID del usuario. Null al recién loguear (fc_ingresar no lo devuelve);
|
||||
// se rellena al primer fc_obtener_usuario_por_token. Necesario para
|
||||
// ownership checks (ej. "este pago lo creé yo").
|
||||
final String? id;
|
||||
final String nombre;
|
||||
final String apellido;
|
||||
final String dni;
|
||||
final String? mail;
|
||||
final String? telefono;
|
||||
|
||||
const UserSession({
|
||||
required this.token,
|
||||
required this.role,
|
||||
this.id,
|
||||
this.nombre = '',
|
||||
this.apellido = '',
|
||||
this.dni = '',
|
||||
this.mail,
|
||||
this.telefono,
|
||||
});
|
||||
|
||||
/// Crear desde fc_ingresar (solo token + rol) + el DNI que ingresó el usuario.
|
||||
factory UserSession.fromLogin({
|
||||
required String token,
|
||||
required String role,
|
||||
required String dni,
|
||||
}) {
|
||||
return UserSession(token: token, role: role, dni: dni);
|
||||
}
|
||||
|
||||
/// Crear desde fc_obtener_usuario_por_token (jsonb completo).
|
||||
factory UserSession.fromUserData(Map<String, dynamic> map, String token) {
|
||||
return UserSession(
|
||||
token: token,
|
||||
role: map['rol'] as String? ?? '',
|
||||
id: map['id'] as String?,
|
||||
nombre: map['nombre'] as String? ?? '',
|
||||
apellido: map['apellido'] as String? ?? '',
|
||||
dni: map['dni'] as String? ?? '',
|
||||
mail: map['mail'] as String?,
|
||||
telefono: map['telefono'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
String get displayName {
|
||||
if (nombre.isNotEmpty && apellido.isNotEmpty) return '$nombre $apellido';
|
||||
if (nombre.isNotEmpty) return nombre;
|
||||
return dni;
|
||||
}
|
||||
|
||||
/// Getter unificado: staff incluye superadmin, admin y profesor
|
||||
bool get isStaff => role == 'superadmin' || role == 'admin' || role == 'profesor';
|
||||
|
||||
bool get isUsuario => role == 'usuario' || role == 'cliente';
|
||||
|
||||
/// True sólo para el rol 'superadmin'. Habilita en UI las acciones
|
||||
/// que el backend gobierna con el permiso 'gestionar_cualquier_pago'.
|
||||
bool get isSuperadmin => role == 'superadmin';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart';
|
||||
|
||||
abstract class AuthRepository {
|
||||
Future<UserSession> login(String dni, String password);
|
||||
Future<UserSession?> validateSession();
|
||||
Future<void> logout();
|
||||
Future<void> cambiarPropiaContrasena({
|
||||
required String passwordActual,
|
||||
required String passwordNueva,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gimnasio_soma/features/auth/data/repositories/auth_repository_impl.dart';
|
||||
import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart';
|
||||
import 'package:gimnasio_soma/features/auth/domain/repositories/auth_repository.dart';
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
return AuthRepositoryImpl();
|
||||
});
|
||||
|
||||
final authStateProvider =
|
||||
StateNotifierProvider<AuthNotifier, AsyncValue<UserSession?>>((ref) {
|
||||
return AuthNotifier(ref.read(authRepositoryProvider));
|
||||
});
|
||||
|
||||
/// Listenable que notifica al GoRouter cuando cambia el estado de auth.
|
||||
/// Solo notifica cuando el estado pasa de logueado a no-logueado o viceversa,
|
||||
/// no cuando pasa a loading (para evitar recrear el router innecesariamente).
|
||||
final authChangeNotifierProvider = Provider<AuthChangeNotifier>((ref) {
|
||||
final notifier = AuthChangeNotifier();
|
||||
ref.listen<AsyncValue<UserSession?>>(authStateProvider, (prev, next) {
|
||||
final wasLoggedIn = prev?.valueOrNull != null;
|
||||
final isLoggedIn = next.valueOrNull != null;
|
||||
if (wasLoggedIn != isLoggedIn) {
|
||||
notifier.notify();
|
||||
}
|
||||
});
|
||||
return notifier;
|
||||
});
|
||||
|
||||
class AuthChangeNotifier extends ChangeNotifier {
|
||||
void notify() => notifyListeners();
|
||||
}
|
||||
|
||||
class AuthNotifier extends StateNotifier<AsyncValue<UserSession?>> {
|
||||
final AuthRepository _repository;
|
||||
|
||||
AuthNotifier(this._repository) : super(const AsyncValue.data(null));
|
||||
|
||||
Future<void> checkSession() async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final session = await _repository.validateSession();
|
||||
state = AsyncValue.data(session);
|
||||
} catch (e, st) {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> login(String dni, String password) async {
|
||||
try {
|
||||
final session = await _repository.login(dni, password);
|
||||
state = AsyncValue.data(session);
|
||||
return null;
|
||||
} catch (e) {
|
||||
state = const AsyncValue.data(null);
|
||||
return e.toString().replaceFirst('Exception: ', '');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _repository.logout();
|
||||
state = const AsyncValue.data(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
|
||||
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
|
||||
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_desktop.dart';
|
||||
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_mobile.dart';
|
||||
|
||||
/// Breakpoint para cambiar entre mobile y desktop layout.
|
||||
const _kDesktopBreakpoint = 800.0;
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _animController;
|
||||
late final Animation<double> _fadeIn;
|
||||
late final Animation<Offset> _slideMobile;
|
||||
late final Animation<Offset> _slideDesktop;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
|
||||
_fadeIn = CurvedAnimation(
|
||||
parent: _animController,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
|
||||
// Mobile: form slides up
|
||||
_slideMobile = Tween<Offset>(
|
||||
begin: const Offset(0, 0.15),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animController,
|
||||
curve: Curves.easeOutCubic,
|
||||
));
|
||||
|
||||
// Desktop: form slides in from right
|
||||
_slideDesktop = Tween<Offset>(
|
||||
begin: const Offset(0.08, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animController,
|
||||
curve: Curves.easeOutCubic,
|
||||
));
|
||||
|
||||
_animController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleLogin(String dni, String password) async {
|
||||
final error = await ref
|
||||
.read(authStateProvider.notifier)
|
||||
.login(dni, password)
|
||||
.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () => 'Tiempo de espera agotado. Verificá tu conexión.',
|
||||
);
|
||||
|
||||
if (!mounted || error == null) return;
|
||||
|
||||
SomaToast.show(context, message: error, type: ToastType.error);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
final isDesktop = width >= _kDesktopBreakpoint;
|
||||
|
||||
return Scaffold(
|
||||
body: isDesktop
|
||||
? LoginDesktop(
|
||||
onSubmit: _handleLogin,
|
||||
fadeIn: _fadeIn,
|
||||
slideIn: _slideDesktop,
|
||||
)
|
||||
: LoginMobile(
|
||||
onSubmit: _handleLogin,
|
||||
fadeIn: _fadeIn,
|
||||
slideUp: _slideMobile,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
|
||||
import 'package:gimnasio_soma/core/widgets/soma_logo.dart';
|
||||
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
|
||||
|
||||
class SplashScreen extends ConsumerStatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkSession());
|
||||
}
|
||||
|
||||
Future<void> _checkSession() async {
|
||||
try {
|
||||
await ref.read(authStateProvider.notifier).checkSession();
|
||||
} catch (_) {
|
||||
// Error en la validación, ir a login
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final user = ref.read(authStateProvider).valueOrNull;
|
||||
if (user == null) {
|
||||
context.go('/login');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SomaLogo(width: 160),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: SomaColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
|
||||
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_form.dart';
|
||||
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_hero.dart';
|
||||
|
||||
class LoginDesktop extends StatelessWidget {
|
||||
final Future<void> Function(String dni, String password) onSubmit;
|
||||
final Animation<double> fadeIn;
|
||||
final Animation<Offset> slideIn;
|
||||
|
||||
const LoginDesktop({
|
||||
super.key,
|
||||
required this.onSubmit,
|
||||
required this.fadeIn,
|
||||
required this.slideIn,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
// ── Hero (left half) ──
|
||||
const Expanded(
|
||||
flex: 5,
|
||||
child: LoginHero(),
|
||||
),
|
||||
|
||||
// ── Form (right half) ──
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: FadeTransition(
|
||||
opacity: fadeIn,
|
||||
child: SlideTransition(
|
||||
position: slideIn,
|
||||
child: Container(
|
||||
height: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 48,
|
||||
vertical: 40,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
LoginForm(onSubmit: onSubmit),
|
||||
const SizedBox(height: 32),
|
||||
Center(
|
||||
child: Text(
|
||||
'v0.1.0',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: SomaColors.darkOnSurfaceVariant
|
||||
.withAlpha(100),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
|
||||
import 'package:gimnasio_soma/core/widgets/soma_primary_button.dart';
|
||||
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
|
||||
|
||||
class LoginForm extends StatefulWidget {
|
||||
final Future<void> Function(String dni, String password) onSubmit;
|
||||
|
||||
const LoginForm({super.key, required this.onSubmit});
|
||||
|
||||
@override
|
||||
State<LoginForm> createState() => _LoginFormState();
|
||||
}
|
||||
|
||||
class _LoginFormState extends State<LoginForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _dniController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dniController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleSubmit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
await widget.onSubmit(
|
||||
_dniController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Iniciar sesión',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Ingresá tus datos para continuar',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: SomaColors.darkOnSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
SomaTextField(
|
||||
controller: _dniController,
|
||||
hintText: 'Ej: 12345678',
|
||||
labelText: 'DNI',
|
||||
prefixIcon: Icons.badge_outlined,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(8),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'El DNI es requerido';
|
||||
}
|
||||
if (value.length < 7) {
|
||||
return 'DNI inválido';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SomaTextField(
|
||||
controller: _passwordController,
|
||||
hintText: 'Tu contraseña',
|
||||
labelText: 'Contraseña',
|
||||
prefixIcon: Icons.lock_outline,
|
||||
obscureText: _obscurePassword,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withAlpha(130),
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() => _obscurePassword = !_obscurePassword);
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'La contraseña es requerida';
|
||||
}
|
||||
if (value.length < 4) {
|
||||
return 'La contraseña es muy corta';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SomaPrimaryButton(
|
||||
text: 'Ingresar',
|
||||
onPressed: _handleSubmit,
|
||||
isLoading: _isLoading,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
|
||||
|
||||
class LoginHero extends StatelessWidget {
|
||||
const LoginHero({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF1A1A1A), Color(0xFF121212)],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Decorative circles
|
||||
Positioned(
|
||||
top: -60,
|
||||
left: -60,
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: SomaColors.primary.withAlpha(15),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -40,
|
||||
right: -40,
|
||||
child: Container(
|
||||
width: 150,
|
||||
height: 150,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: SomaColors.primary.withAlpha(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Content
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/logo.png',
|
||||
width: 180,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Gimnasio',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w300,
|
||||
letterSpacing: 6,
|
||||
color: SomaColors.darkOnSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
|
||||
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_form.dart';
|
||||
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_hero.dart';
|
||||
|
||||
class LoginMobile extends StatelessWidget {
|
||||
final Future<void> Function(String dni, String password) onSubmit;
|
||||
final Animation<double> fadeIn;
|
||||
final Animation<Offset> slideUp;
|
||||
|
||||
const LoginMobile({
|
||||
super.key,
|
||||
required this.onSubmit,
|
||||
required this.fadeIn,
|
||||
required this.slideUp,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light,
|
||||
child: SingleChildScrollView(
|
||||
child: SizedBox(
|
||||
height: screenHeight,
|
||||
child: Column(
|
||||
children: [
|
||||
// ── Hero (top 35%) ──
|
||||
const Expanded(
|
||||
flex: 35,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: LoginHero(),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Form (bottom 65%) ──
|
||||
Expanded(
|
||||
flex: 65,
|
||||
child: FadeTransition(
|
||||
opacity: fadeIn,
|
||||
child: SlideTransition(
|
||||
position: slideUp,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(28, 36, 28, 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(32),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withAlpha(80),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, -4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: LoginForm(onSubmit: onSubmit),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Text(
|
||||
'v0.1.0',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: SomaColors.darkOnSurfaceVariant
|
||||
.withAlpha(100),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user