bLOQUE p1 BACKEND Y SEGURIDAD, AUTENTICACION CON SUPABASE. jwt. RBAC CRUD
This commit is contained in:
0
recolecta_app/lib/features/addresses/.gitkeep
Normal file
0
recolecta_app/lib/features/addresses/.gitkeep
Normal 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();
|
||||
});
|
||||
120
recolecta_app/lib/features/addresses/colonias_selector.dart
Normal file
120
recolecta_app/lib/features/addresses/colonias_selector.dart
Normal 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!)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
110
recolecta_app/lib/features/addresses/new_address_page.dart
Normal file
110
recolecta_app/lib/features/addresses/new_address_page.dart
Normal 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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
0
recolecta_app/lib/features/admin/.gitkeep
Normal file
0
recolecta_app/lib/features/admin/.gitkeep
Normal file
0
recolecta_app/lib/features/auth/.gitkeep
Normal file
0
recolecta_app/lib/features/auth/.gitkeep
Normal file
164
recolecta_app/lib/features/auth/login_page.dart
Normal file
164
recolecta_app/lib/features/auth/login_page.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
200
recolecta_app/lib/features/auth/register_page.dart
Normal file
200
recolecta_app/lib/features/auth/register_page.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
0
recolecta_app/lib/features/driver/.gitkeep
Normal file
0
recolecta_app/lib/features/driver/.gitkeep
Normal file
0
recolecta_app/lib/features/eta/.gitkeep
Normal file
0
recolecta_app/lib/features/eta/.gitkeep
Normal file
0
recolecta_app/lib/features/feedback/.gitkeep
Normal file
0
recolecta_app/lib/features/feedback/.gitkeep
Normal file
0
recolecta_app/lib/features/notifications/.gitkeep
Normal file
0
recolecta_app/lib/features/notifications/.gitkeep
Normal file
Reference in New Issue
Block a user