bLOQUE p1 BACKEND Y SEGURIDAD, AUTENTICACION CON SUPABASE. jwt. RBAC CRUD

This commit is contained in:
shinra32
2026-05-22 19:45:05 -06:00
parent 5dc8390855
commit fc28333e3f
52 changed files with 1605 additions and 109 deletions

View File

@@ -0,0 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models/colonia.dart';
import '../../core/services/colonias_service.dart';
final coloniasProvider = FutureProvider<List<Colonia>>((ref) async {
return ref.read(coloniasServiceProvider).getColonias();
});

View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models/colonia.dart';
import 'colonias_provider.dart';
class ColoniasSelector extends ConsumerWidget {
const ColoniasSelector({
super.key,
required this.onChanged,
this.initialValue,
this.labelText = 'Colonia',
});
final ValueChanged<Colonia> onChanged;
final Colonia? initialValue;
final String labelText;
@override
Widget build(BuildContext context, WidgetRef ref) {
final coloniasAsync = ref.watch(coloniasProvider);
return coloniasAsync.when(
loading: () => const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('Cargando colonias...'),
],
),
),
),
error: (error, stackTrace) => _StateCard(
icon: Icons.error_outline,
title: 'No se pudieron cargar las colonias',
message: error.toString(),
actionLabel: 'Reintentar',
onAction: () => ref.invalidate(coloniasProvider),
),
data: (colonias) {
if (colonias.isEmpty) {
return const _StateCard(
icon: Icons.inbox_outlined,
title: 'Sin colonias disponibles',
message: 'El backend no devolvió colonias todavía.',
);
}
return DropdownButtonFormField<Colonia>(
value: initialValue,
decoration: InputDecoration(labelText: labelText),
items: colonias
.map(
(colonia) => DropdownMenuItem<Colonia>(
value: colonia,
child: Text(
colonia.horarioEstimado == null ||
colonia.horarioEstimado!.isEmpty
? colonia.nombre
: '${colonia.nombre} · ${colonia.horarioEstimado}',
),
),
)
.toList(growable: false),
onChanged: (value) {
if (value != null) {
onChanged(value);
}
},
);
},
);
}
}
class _StateCard extends StatelessWidget {
const _StateCard({
required this.icon,
required this.title,
required this.message,
this.actionLabel,
this.onAction,
});
final IconData icon;
final String title;
final String message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon),
const SizedBox(height: 12),
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 6),
Text(message),
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 12),
TextButton(onPressed: onAction, child: Text(actionLabel!)),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models/address.dart';
import '../../core/models/colonia.dart';
import 'colonias_selector.dart';
class NewAddressPage extends ConsumerStatefulWidget {
const NewAddressPage({super.key});
@override
ConsumerState<NewAddressPage> createState() => _NewAddressPageState();
}
class _NewAddressPageState extends ConsumerState<NewAddressPage> {
final _formKey = GlobalKey<FormState>();
final _labelController = TextEditingController();
final _streetController = TextEditingController();
Colonia? _selectedColonia;
@override
void dispose() {
_labelController.dispose();
_streetController.dispose();
super.dispose();
}
void _saveAddress() {
if (!(_formKey.currentState?.validate() ?? false)) {
return;
}
if (_selectedColonia == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Selecciona una colonia')));
return;
}
final address = AddressModel(
label: _labelController.text.trim(),
street: _streetController.text.trim(),
colonia: _selectedColonia!,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Domicilio listo: ${address.toJson()}')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Nuevo domicilio')),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(24),
children: [
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _labelController,
decoration: const InputDecoration(
labelText: 'Etiqueta',
hintText: 'Casa, trabajo, etc.',
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Ingresa una etiqueta'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _streetController,
decoration: const InputDecoration(
labelText: 'Calle',
hintText: 'Av. Principal 123',
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Ingresa la calle'
: null,
),
const SizedBox(height: 16),
ColoniasSelector(
labelText: 'Colonia',
initialValue: _selectedColonia,
onChanged: (colonia) {
setState(() => _selectedColonia = colonia);
},
),
const SizedBox(height: 24),
SizedBox(
height: 52,
child: FilledButton(
onPressed: _saveAddress,
child: const Text('Guardar domicilio'),
),
),
],
),
),
],
),
),
);
}
}

View File

View File

@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/services/auth_controller.dart';
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) {
return;
}
try {
await ref
.read(authControllerProvider.notifier)
.login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
if (!mounted) {
return;
}
final authState = ref.read(authControllerProvider).asData?.value;
if (authState?.userRole == 'admin') {
context.go('/admin');
return;
}
if (authState?.userRole == 'driver') {
context.go('/driver');
return;
}
final routeId = authState?.routeId;
if (routeId != null && routeId.isNotEmpty) {
context.go('/home?routeId=$routeId');
return;
}
context.go('/home');
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authControllerProvider);
final loading = authState.isLoading;
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 12),
const Icon(Icons.delete_outline_rounded, size: 54),
const SizedBox(height: 16),
Text(
'Recolecta',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
'Accede para ver solo tu ruta asignada.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 28),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Correo electrónico',
hintText: 'tu@correo.com',
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Ingresa tu correo'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Contraseña',
hintText: '••••••••',
suffixIcon: IconButton(
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
validator: (value) => (value == null || value.length < 6)
? 'La contraseña debe tener al menos 6 caracteres'
: null,
),
const SizedBox(height: 24),
SizedBox(
height: 52,
child: FilledButton(
onPressed: loading ? null : _submit,
child: loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('Entrar'),
),
),
const SizedBox(height: 16),
TextButton(
onPressed: () => context.go('/register'),
child: const Text('Crear cuenta'),
),
],
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/services/auth_controller.dart';
class RegisterPage extends ConsumerStatefulWidget {
const RegisterPage({super.key});
@override
ConsumerState<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends ConsumerState<RegisterPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _phoneController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_emailController.dispose();
_phoneController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) {
return;
}
try {
await ref
.read(authControllerProvider.notifier)
.register(
email: _emailController.text.trim(),
phone: _phoneController.text.trim(),
password: _passwordController.text,
);
if (!mounted) {
return;
}
final authState = ref.read(authControllerProvider).asData?.value;
if (authState?.userRole == 'admin') {
context.go('/admin');
return;
}
if (authState?.userRole == 'driver') {
context.go('/driver');
return;
}
final routeId = authState?.routeId;
if (routeId != null && routeId.isNotEmpty) {
context.go('/home?routeId=$routeId');
return;
}
context.go('/home');
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authControllerProvider);
final loading = authState.isLoading;
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 12),
const Icon(Icons.person_add_alt_1_outlined, size: 54),
const SizedBox(height: 16),
Text(
'Crear cuenta',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
'Registra tu correo, teléfono y contraseña para continuar.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 28),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Correo electrónico',
hintText: 'tu@correo.com',
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Ingresa tu correo'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Teléfono',
hintText: '+52 461 123 4567',
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Ingresa tu teléfono'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Contraseña',
hintText: '••••••••',
suffixIcon: IconButton(
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
validator: (value) => (value == null || value.length < 6)
? 'La contraseña debe tener al menos 6 caracteres'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _confirmPasswordController,
obscureText: _obscurePassword,
decoration: const InputDecoration(
labelText: 'Confirmar contraseña',
hintText: '••••••••',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Confirma tu contraseña';
}
if (value != _passwordController.text) {
return 'Las contraseñas no coinciden';
}
return null;
},
),
const SizedBox(height: 24),
SizedBox(
height: 52,
child: FilledButton(
onPressed: loading ? null : _submit,
child: loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('Registrarme'),
),
),
const SizedBox(height: 16),
TextButton(
onPressed: () => context.go('/login'),
child: const Text('Ya tengo cuenta'),
),
],
),
),
),
),
),
),
);
}
}

View File