Resolve merge conflicts: README + ignore IDE files

This commit is contained in:
David
2026-05-23 07:11:33 -06:00
parent abfbb255fe
commit 6ff72c738d
27 changed files with 2123 additions and 335 deletions

View File

@@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'models/auth_session.dart';
import 'screens/auth_screen.dart';
import 'screens/dashboard_screen.dart';
import 'services/address_repository.dart';
import 'services/auth_repository.dart';
@@ -12,8 +10,8 @@ class MyApp extends StatelessWidget {
AuthRepository? authRepository,
AddressRepository? addressRepository,
this.enableLiveFeatures = true,
}) : _authRepository = authRepository ?? const HttpAuthRepository(),
_addressRepository = addressRepository ?? const HttpAddressRepository();
}) : _authRepository = authRepository ?? const LocalAuthRepository(),
_addressRepository = addressRepository ?? const LocalAddressRepository();
final AuthRepository _authRepository;
final AddressRepository _addressRepository;
@@ -37,7 +35,7 @@ class MyApp extends StatelessWidget {
}
}
class AuthBootstrap extends StatefulWidget {
class AuthBootstrap extends StatelessWidget {
const AuthBootstrap({
super.key,
required this.authRepository,
@@ -49,58 +47,12 @@ class AuthBootstrap extends StatefulWidget {
final AddressRepository addressRepository;
final bool enableLiveFeatures;
@override
State<AuthBootstrap> createState() => _AuthBootstrapState();
}
class _AuthBootstrapState extends State<AuthBootstrap> {
late final Future<AuthSession?> _sessionFuture;
@override
void initState() {
super.initState();
_sessionFuture = widget.authRepository.restoreSession();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<AuthSession?>(
future: _sessionFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const _LoadingView();
}
final session = snapshot.data;
if (session != null) {
return DashboardScreen(
authRepository: widget.authRepository,
addressRepository: widget.addressRepository,
session: session,
savedAddress: null,
enableLiveFeatures: widget.enableLiveFeatures,
);
}
return AuthScreen(
authRepository: widget.authRepository,
addressRepository: widget.addressRepository,
enableLiveFeatures: widget.enableLiveFeatures,
);
},
);
}
}
class _LoadingView extends StatelessWidget {
const _LoadingView();
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
return AuthScreen(
authRepository: authRepository,
addressRepository: addressRepository,
enableLiveFeatures: enableLiveFeatures,
);
}
}

View File

@@ -1,6 +1,4 @@
class AppConfig {
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://10.77.234.29:3000',
);
static const String appName = 'Acceso Local';
static const bool useLocalDataOnly = true;
}

View File

@@ -0,0 +1,22 @@
class CalendarEventEntry {
const CalendarEventEntry({
required this.date,
required this.title,
required this.description,
required this.category,
});
final DateTime date;
final String title;
final String description;
final String category;
factory CalendarEventEntry.fromJson(Map<String, dynamic> json) {
return CalendarEventEntry(
date: DateTime.parse(json['date'].toString()),
title: json['title'].toString(),
description: json['description'].toString(),
category: json['category'].toString(),
);
}
}

View File

@@ -0,0 +1,19 @@
class ColonyRoute {
const ColonyRoute({
required this.colonia,
required this.routeId,
required this.horarioEstimado,
});
final String colonia;
final String routeId;
final String horarioEstimado;
factory ColonyRoute.fromJson(Map<String, dynamic> json) {
return ColonyRoute(
colonia: json['colonia'].toString(),
routeId: json['routeId'].toString(),
horarioEstimado: json['horarioEstimado'].toString(),
);
}
}

View File

@@ -0,0 +1,25 @@
class DemoProfile {
const DemoProfile({
required this.name,
required this.email,
required this.password,
required this.colonia,
required this.routeId,
});
final String name;
final String email;
final String password;
final String colonia;
final String routeId;
factory DemoProfile.fromJson(Map<String, dynamic> json) {
return DemoProfile(
name: json['name'].toString(),
email: json['email'].toString(),
password: json['password'].toString(),
colonia: json['colonia'].toString(),
routeId: json['routeId'].toString(),
);
}
}

View File

@@ -0,0 +1,30 @@
class RouteGuideEntry {
const RouteGuideEntry({
required this.routeId,
required this.wasteType,
required this.schedule,
required this.days,
required this.note,
});
final String routeId;
final String wasteType;
final String schedule;
final List<String> days;
final String note;
factory RouteGuideEntry.fromJson(Map<String, dynamic> json) {
final daysJson = json['days'];
final days = daysJson is List
? daysJson.map((day) => day.toString()).toList(growable: false)
: <String>[];
return RouteGuideEntry(
routeId: json['routeId'].toString(),
wasteType: json['wasteType'].toString(),
schedule: json['schedule'].toString(),
days: days,
note: json['note'].toString(),
);
}
}

View File

@@ -0,0 +1,26 @@
class RouteNotification {
const RouteNotification({
required this.triggerEvent,
required this.condition,
required this.title,
required this.body,
});
final String triggerEvent;
final String condition;
final String title;
final String body;
factory RouteNotification.fromJson(Map<String, dynamic> json) {
final payload = json['pushPayload'] is Map<String, dynamic>
? json['pushPayload'] as Map<String, dynamic>
: <String, dynamic>{};
return RouteNotification(
triggerEvent: json['triggerEvent'].toString(),
condition: json['condition'].toString(),
title: payload['title']?.toString() ?? '',
body: payload['body']?.toString() ?? '',
);
}
}

View File

@@ -0,0 +1,25 @@
class RoutePosition {
const RoutePosition({
required this.positionId,
required this.lat,
required this.lng,
required this.speed,
required this.timestamp,
});
final int positionId;
final double lat;
final double lng;
final double speed;
final DateTime timestamp;
factory RoutePosition.fromJson(Map<String, dynamic> json) {
return RoutePosition(
positionId: (json['positionId'] as num).toInt(),
lat: (json['lat'] as num).toDouble(),
lng: (json['lng'] as num).toDouble(),
speed: (json['speed'] as num).toDouble(),
timestamp: DateTime.parse(json['timestamp'].toString()),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'route_position.dart';
class TruckRoute {
const TruckRoute({
required this.routeId,
required this.name,
required this.truckId,
required this.status,
required this.positions,
});
final String routeId;
final String name;
final int truckId;
final String status;
final List<RoutePosition> positions;
factory TruckRoute.fromJson(Map<String, dynamic> json) {
final positionsJson = json['positions'];
final positions = positionsJson is List
? positionsJson
.whereType<Map<String, dynamic>>()
.map(RoutePosition.fromJson)
.toList(growable: false)
: <RoutePosition>[];
return TruckRoute(
routeId: json['routeId'].toString(),
name: json['name'].toString(),
truckId: (json['truckId'] as num).toInt(),
status: json['status'].toString(),
positions: positions,
);
}
}

View File

@@ -1,12 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../app_config.dart';
import '../models/address_entry.dart';
import '../models/auth_session.dart';
import '../services/auth_repository.dart';
import '../services/address_repository.dart';
import 'dashboard_screen.dart';
final RegExp _lettersOnly = RegExp(r"[a-zA-ZáéíóúÁÉÍÓÚñÑüÜ\s]");
final RegExp _digitsOnly = RegExp(r'[0-9]');
class AddressScreen extends StatefulWidget {
const AddressScreen({
super.key,
@@ -44,6 +47,9 @@ class _AddressScreenState extends State<AddressScreen> {
Future<void> _saveAddress() async {
if (!(_formKey.currentState?.validate() ?? false)) {
setState(() {
_errorMessage = 'Respete los campos';
});
return;
}
@@ -94,7 +100,7 @@ class _AddressScreenState extends State<AddressScreen> {
return;
}
setState(() {
_errorMessage = 'No se pudo guardar la dirección. Revisa el backend.';
_errorMessage = 'No se pudo guardar la dirección. Revisa los datos locales.';
});
} finally {
if (mounted) {
@@ -137,7 +143,7 @@ class _AddressScreenState extends State<AddressScreen> {
),
const SizedBox(height: 8),
Text(
'Ingresa la dirección de tu casa y se enviará al backend para guardarla en PostgreSQL.',
'Ingresa la dirección de tu casa. La app la guardará de forma local para usarla en el tablero.',
style: TextStyle(color: Colors.grey.shade700, height: 1.4),
),
const SizedBox(height: 20),
@@ -151,7 +157,8 @@ class _AddressScreenState extends State<AddressScreen> {
children: [
TextFormField(
controller: _houseNumberController,
keyboardType: TextInputType.text,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.allow(_digitsOnly)],
decoration: const InputDecoration(
labelText: 'Número de casa',
prefixIcon: Icon(Icons.home_outlined),
@@ -159,7 +166,7 @@ class _AddressScreenState extends State<AddressScreen> {
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Ingresa el número de casa';
return 'Respete los campos';
}
return null;
},
@@ -167,6 +174,9 @@ class _AddressScreenState extends State<AddressScreen> {
const SizedBox(height: 16),
TextFormField(
controller: _coloniaController,
keyboardType: TextInputType.name,
textCapitalization: TextCapitalization.words,
inputFormatters: [FilteringTextInputFormatter.allow(_lettersOnly)],
decoration: const InputDecoration(
labelText: 'Colonia',
prefixIcon: Icon(Icons.location_city_outlined),
@@ -174,7 +184,7 @@ class _AddressScreenState extends State<AddressScreen> {
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Ingresa la colonia';
return 'Respete los campos';
}
return null;
},
@@ -182,6 +192,9 @@ class _AddressScreenState extends State<AddressScreen> {
const SizedBox(height: 16),
TextFormField(
controller: _streetController,
keyboardType: TextInputType.name,
textCapitalization: TextCapitalization.words,
inputFormatters: [FilteringTextInputFormatter.allow(_lettersOnly)],
decoration: const InputDecoration(
labelText: 'Calle',
prefixIcon: Icon(Icons.signpost_outlined),
@@ -189,7 +202,7 @@ class _AddressScreenState extends State<AddressScreen> {
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Ingresa la calle';
return 'Respete los campos';
}
return null;
},
@@ -210,11 +223,6 @@ class _AddressScreenState extends State<AddressScreen> {
),
),
const SizedBox(height: 12),
Text(
'Base URL configurada: ${AppConfig.apiBaseUrl}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
textAlign: TextAlign.center,
),
],
),
),

View File

@@ -1,11 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../app_config.dart';
import '../models/demo_profile.dart';
import '../models/auth_session.dart';
import '../services/address_repository.dart';
import '../services/auth_repository.dart';
import '../services/local_seed_repository.dart';
import 'address_screen.dart';
final RegExp _lettersOnly = RegExp(r"[a-zA-ZáéíóúÁÉÍÓÚñÑüÜ\s]");
final RegExp _addressText = RegExp(r"[a-zA-Z0-9áéíóúÁÉÍÓÚñÑüÜ#\-\s]");
final RegExp _emailChars = RegExp(r"[a-zA-Z0-9@._+\-]");
class AuthScreen extends StatefulWidget {
const AuthScreen({
super.key,
@@ -34,6 +40,14 @@ class _AuthScreenState extends State<AuthScreen> {
bool _isLoading = false;
String? _errorMessage;
LocalSeedData? _seedData;
bool _loadingSeedData = true;
@override
void initState() {
super.initState();
_loadSeedData();
}
@override
void dispose() {
@@ -46,8 +60,42 @@ class _AuthScreenState extends State<AuthScreen> {
super.dispose();
}
Future<void> _loadSeedData() async {
final seedData = await LocalSeedRepository.instance.load();
if (!mounted) {
return;
}
setState(() {
_seedData = seedData;
_loadingSeedData = false;
});
}
void _fillDemoProfile(DemoProfile profile) {
_loginEmailController.text = profile.email;
_loginPasswordController.text = profile.password;
_registerNameController.text = profile.name;
_registerEmailController.text = profile.email;
_registerPasswordController.text = profile.password;
_registerConfirmPasswordController.text = profile.password;
}
Future<void> _useDemoProfile(DemoProfile profile) async {
_fillDemoProfile(profile);
await _submit(() {
return widget.authRepository.signIn(
email: profile.email,
password: profile.password,
);
});
}
Future<void> _signIn() async {
if (!(_loginFormKey.currentState?.validate() ?? false)) {
setState(() {
_errorMessage = 'Respete los campos';
});
return;
}
@@ -61,6 +109,9 @@ class _AuthScreenState extends State<AuthScreen> {
Future<void> _signUp() async {
if (!(_registerFormKey.currentState?.validate() ?? false)) {
setState(() {
_errorMessage = 'Respete los campos';
});
return;
}
@@ -106,7 +157,7 @@ class _AuthScreenState extends State<AuthScreen> {
return;
}
setState(() {
_errorMessage = 'No se pudo completar la operación. Verifica el backend y vuelve a intentar.';
_errorMessage = 'No se pudo completar la operación. Revisa los datos locales y vuelve a intentar.';
});
} finally {
if (mounted) {
@@ -167,6 +218,13 @@ class _AuthScreenState extends State<AuthScreen> {
style: TextStyle(color: Colors.grey.shade700, height: 1.4),
),
const SizedBox(height: 20),
if (!_loadingSeedData && _seedData != null && _seedData!.demoProfiles.isNotEmpty) ...[
_DemoProfilesSection(
profiles: _seedData!.demoProfiles,
onProfileSelected: _useDemoProfile,
),
const SizedBox(height: 16),
],
Container(
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
@@ -221,11 +279,6 @@ class _AuthScreenState extends State<AuthScreen> {
),
),
const SizedBox(height: 12),
Text(
'Base URL configurada: ${AppConfig.apiBaseUrl}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
textAlign: TextAlign.center,
),
],
),
),
@@ -264,6 +317,7 @@ class _LoginForm extends StatelessWidget {
TextFormField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
inputFormatters: [FilteringTextInputFormatter.allow(_emailChars)],
decoration: const InputDecoration(
labelText: 'Correo electrónico',
prefixIcon: Icon(Icons.email_outlined),
@@ -273,7 +327,7 @@ class _LoginForm extends StatelessWidget {
if (value == null || value.trim().isEmpty) {
return 'Ingresa tu correo';
}
if (!value.contains('@')) {
if (!value.contains('@') || value.startsWith('@') || value.endsWith('@')) {
return 'Ingresa un correo válido';
}
return null;
@@ -347,6 +401,8 @@ class _RegisterForm extends StatelessWidget {
TextFormField(
controller: nameController,
textCapitalization: TextCapitalization.words,
keyboardType: TextInputType.name,
inputFormatters: [FilteringTextInputFormatter.allow(_lettersOnly)],
decoration: const InputDecoration(
labelText: 'Nombre',
prefixIcon: Icon(Icons.person_outline),
@@ -363,6 +419,7 @@ class _RegisterForm extends StatelessWidget {
TextFormField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
inputFormatters: [FilteringTextInputFormatter.allow(_emailChars)],
decoration: const InputDecoration(
labelText: 'Correo electrónico',
prefixIcon: Icon(Icons.email_outlined),
@@ -372,7 +429,7 @@ class _RegisterForm extends StatelessWidget {
if (value == null || value.trim().isEmpty) {
return 'Ingresa tu correo';
}
if (!value.contains('@')) {
if (!value.contains('@') || value.startsWith('@') || value.endsWith('@')) {
return 'Ingresa un correo válido';
}
return null;
@@ -382,6 +439,7 @@ class _RegisterForm extends StatelessWidget {
TextFormField(
controller: passwordController,
obscureText: true,
inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'\s'))],
decoration: const InputDecoration(
labelText: 'Contraseña',
prefixIcon: Icon(Icons.lock_outline),
@@ -401,6 +459,7 @@ class _RegisterForm extends StatelessWidget {
TextFormField(
controller: confirmPasswordController,
obscureText: true,
inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'\s'))],
decoration: const InputDecoration(
labelText: 'Confirmar contraseña',
prefixIcon: Icon(Icons.lock_reset_outlined),
@@ -458,4 +517,47 @@ class _AuthStatusBanner extends StatelessWidget {
),
);
}
}
class _DemoProfilesSection extends StatelessWidget {
const _DemoProfilesSection({
required this.profiles,
required this.onProfileSelected,
});
final List<DemoProfile> profiles;
final Future<void> Function(DemoProfile profile) onProfileSelected;
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
color: const Color(0xFFF8FAFC),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Perfiles demo', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800)),
const SizedBox(height: 8),
Text('Toca un perfil para llenar el formulario de acceso.', style: TextStyle(color: Colors.grey.shade700)),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: profiles
.map(
(profile) => ActionChip(
label: Text('${profile.name}${profile.routeId}'),
onPressed: () => onProfileSelected(profile),
),
)
.toList(growable: false),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -95,8 +95,8 @@ class HomeScreen extends StatelessWidget {
const SizedBox(height: 12),
_InfoTile(
icon: Icons.storage_outlined,
title: 'Persistencia en PostgreSQL',
subtitle: 'La dirección se envió al backend con el token de sesión para almacenarla en la base de datos.',
title: 'Persistencia local',
subtitle: 'La información se conserva dentro de la app usando los JSON y el almacenamiento local.',
),
const SizedBox(height: 24),
FilledButton.icon(

View File

@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../app_config.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/address_entry.dart';
import '../models/address_record.dart';
import '../models/auth_session.dart';
@@ -14,68 +15,61 @@ abstract class AddressRepository {
Future<List<AddressRecord>> getMyAddresses({required AuthSession session});
}
class HttpAddressRepository implements AddressRepository {
const HttpAddressRepository({http.Client? client}) : _client = client;
final http.Client? _client;
class LocalAddressRepository implements AddressRepository {
const LocalAddressRepository();
static const String _localAddressPrefix = 'local_addresses_';
@override
Future<void> saveAddress({
required AuthSession session,
required AddressEntry address,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/addresses');
late final http.Response response;
try {
response = await (_client ?? http.Client()).post(
uri,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer ${session.token}',
},
body: jsonEncode(address.toJson()),
);
} catch (_) {
throw AddressException(
'No se pudo conectar con el backend en ${AppConfig.apiBaseUrl}.',
);
}
if (response.statusCode < 200 || response.statusCode >= 300) {
final payload = jsonDecode(response.body);
final message = payload['message']?.toString() ?? 'Error al guardar la dirección.';
throw AddressException(message);
}
await _persistLocalAddress(session: session, address: address);
}
@override
Future<List<AddressRecord>> getMyAddresses({required AuthSession session}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/addresses/me');
return _loadLocalAddresses(session);
}
late final http.Response response;
try {
response = await (_client ?? http.Client()).get(
uri,
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ${session.token}',
},
);
} catch (_) {
throw AddressException(
'No se pudo conectar con el backend en ${AppConfig.apiBaseUrl}.',
);
Future<void> _persistLocalAddress({required AuthSession session, required AddressEntry address}) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_localAddressPrefix${session.email.toLowerCase()}';
final existing = await _loadLocalAddresses(session);
final records = <AddressRecord>[
AddressRecord(
id: DateTime.now().millisecondsSinceEpoch,
houseNumber: address.houseNumber,
colonia: address.colonia,
street: address.street,
),
...existing,
];
final encoded = jsonEncode(
records
.map(
(record) => <String, dynamic>{
'id': record.id,
'houseNumber': record.houseNumber,
'colonia': record.colonia,
'street': record.street,
},
)
.toList(growable: false),
);
await prefs.setString(key, encoded);
}
Future<List<AddressRecord>> _loadLocalAddresses(AuthSession session) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_localAddressPrefix${session.email.toLowerCase()}';
final raw = prefs.getString(key);
if (raw == null || raw.trim().isEmpty) {
return <AddressRecord>[];
}
if (response.statusCode < 200 || response.statusCode >= 300) {
final payload = jsonDecode(response.body);
final message = payload['message']?.toString() ?? 'Error al obtener las direcciones.';
throw AddressException(message);
}
final decoded = jsonDecode(response.body);
final decoded = jsonDecode(raw);
if (decoded is! List) {
return <AddressRecord>[];
}

View File

@@ -1,10 +1,9 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../app_config.dart';
import '../models/auth_session.dart';
import 'local_seed_repository.dart';
abstract class AuthRepository {
Future<AuthSession?> restoreSession();
@@ -13,18 +12,31 @@ abstract class AuthRepository {
Future<void> signOut();
}
class HttpAuthRepository implements AuthRepository {
const HttpAuthRepository({http.Client? client}) : _client = client;
final http.Client? _client;
class LocalAuthRepository implements AuthRepository {
const LocalAuthRepository();
static const String _tokenKey = 'auth_token';
static const String _emailKey = 'auth_email';
static const String _nameKey = 'auth_name';
static const String _sessionKey = 'auth_session_json';
static const String _usersKey = 'auth_users_json';
@override
Future<AuthSession?> restoreSession() async {
final prefs = await SharedPreferences.getInstance();
final sessionJson = prefs.getString(_sessionKey);
if (sessionJson != null && sessionJson.trim().isNotEmpty) {
final decoded = jsonDecode(sessionJson);
if (decoded is Map<String, dynamic>) {
final token = decoded['token']?.toString();
final email = decoded['email']?.toString();
final name = decoded['displayName']?.toString();
if (token != null && email != null && token.isNotEmpty && email.isNotEmpty) {
return AuthSession(token: token, email: email, displayName: name ?? email);
}
}
}
final token = prefs.getString(_tokenKey);
final email = prefs.getString(_emailKey);
final name = prefs.getString(_nameKey);
@@ -53,44 +65,71 @@ class HttpAuthRepository implements AuthRepository {
}
Future<AuthSession> _authenticate({required String endpoint, required Map<String, dynamic> body}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}$endpoint');
final email = body['email']?.toString().trim();
final password = body['password']?.toString();
if (email == null || password == null || email.isEmpty) {
throw AuthException('Ingresa correo y contraseña válidos.');
}
late final http.Response response;
try {
response = await (_client ?? http.Client()).post(
uri,
headers: const <String, String>{
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: jsonEncode(body),
final seedData = await LocalSeedRepository.instance.load();
final profile = seedData.profileForCredentials(email, password);
if (profile != null) {
final session = AuthSession(
token: 'local-${profile.email}',
email: profile.email,
displayName: profile.name,
);
} catch (_) {
throw AuthException(
'No se pudo conectar con el backend en ${AppConfig.apiBaseUrl}. Verifica que el servicio esté activo.',
await _persistSession(session);
return session;
}
final localUsers = await _loadLocalUsers();
final normalizedEmail = email.toLowerCase();
final localMatch = localUsers.where((user) {
final userEmail = user['email']?.toString().trim().toLowerCase();
final userPassword = user['password']?.toString();
return userEmail == normalizedEmail && userPassword == password;
}).toList(growable: false);
if (localMatch.isNotEmpty) {
final user = localMatch.first;
final displayName = user['name']?.toString().trim();
final session = AuthSession(
token: 'local-${email.toLowerCase()}',
email: email,
displayName: displayName == null || displayName.isEmpty ? email : displayName,
);
await _persistSession(session);
return session;
}
final Map<String, dynamic> payload = _decodeJson(response.body);
if (response.statusCode < 200 || response.statusCode >= 300) {
final message = payload['message']?.toString() ?? 'Credenciales inválidas o usuario no disponible.';
throw AuthException(message);
if (endpoint == '/auth/register') {
final displayName = body['name']?.toString().trim();
final alreadyInSeed = seedData.profileForCredentials(email, password) != null ||
seedData.demoProfiles.any((profile) => profile.email.trim().toLowerCase() == normalizedEmail);
final alreadyInLocal = localUsers.any((user) => user['email']?.toString().trim().toLowerCase() == normalizedEmail);
if (alreadyInSeed || alreadyInLocal) {
throw AuthException('Este correo ya está registrado.');
}
final createdUser = <String, dynamic>{
'name': displayName == null || displayName.isEmpty ? email : displayName,
'email': email,
'password': password,
'createdAt': DateTime.now().toIso8601String(),
};
await _saveLocalUsers(<Map<String, dynamic>>[...localUsers, createdUser]);
final session = AuthSession(
token: 'local-$email',
email: email,
displayName: displayName == null || displayName.isEmpty ? email : displayName,
);
await _persistSession(session);
return session;
}
final token = payload['token']?.toString();
if (token == null || token.isEmpty) {
throw AuthException('El backend respondió sin token de sesión.');
}
final user = payload['user'] is Map<String, dynamic>
? payload['user'] as Map<String, dynamic>
: <String, dynamic>{};
final emailValue = (user['email'] ?? body['email'])?.toString() ?? body['email'].toString();
final displayName = (user['name'] ?? user['fullName'] ?? body['name'] ?? emailValue).toString();
final session = AuthSession(token: token, email: emailValue, displayName: displayName);
await _persistSession(session);
return session;
throw AuthException('Usuario o contraseña no encontrados en los perfiles locales.');
}
Future<void> _persistSession(AuthSession session) async {
@@ -98,19 +137,36 @@ class HttpAuthRepository implements AuthRepository {
await prefs.setString(_tokenKey, session.token);
await prefs.setString(_emailKey, session.email);
await prefs.setString(_nameKey, session.displayName);
await prefs.setString(
_sessionKey,
jsonEncode(
<String, dynamic>{
'token': session.token,
'email': session.email,
'displayName': session.displayName,
},
),
);
}
Map<String, dynamic> _decodeJson(String responseBody) {
if (responseBody.trim().isEmpty) {
return <String, dynamic>{};
Future<List<Map<String, dynamic>>> _loadLocalUsers() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_usersKey);
if (raw == null || raw.trim().isEmpty) {
return <Map<String, dynamic>>[];
}
final decoded = jsonDecode(responseBody);
if (decoded is Map<String, dynamic>) {
return decoded;
final decoded = jsonDecode(raw);
if (decoded is! List) {
return <Map<String, dynamic>>[];
}
return <String, dynamic>{};
return decoded.whereType<Map<String, dynamic>>().toList(growable: false);
}
Future<void> _saveLocalUsers(List<Map<String, dynamic>> users) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_usersKey, jsonEncode(users));
}
@override
@@ -119,6 +175,7 @@ class HttpAuthRepository implements AuthRepository {
await prefs.remove(_tokenKey);
await prefs.remove(_emailKey);
await prefs.remove(_nameKey);
await prefs.remove(_sessionKey);
}
}

View File

@@ -0,0 +1,159 @@
import 'dart:convert';
import 'package:flutter/services.dart' show rootBundle;
import '../models/colony_route.dart';
import '../models/calendar_event_entry.dart';
import '../models/demo_profile.dart';
import '../models/route_guide_entry.dart';
import '../models/route_notification.dart';
import '../models/truck_route.dart';
class LocalSeedRepository {
LocalSeedRepository._();
static final LocalSeedRepository instance = LocalSeedRepository._();
Future<LocalSeedData>? _cachedLoad;
Future<LocalSeedData> load() {
return _cachedLoad ??= _loadInternal();
}
Future<LocalSeedData> _loadInternal() async {
try {
final routesJson = await rootBundle.loadString('assets/json/rutas.json');
final notificationsJson = await rootBundle.loadString('assets/json/notificaciones.json');
final colonyRoutesJson = await rootBundle.loadString('assets/json/colonias-rutas.json');
final profilesJson = await rootBundle.loadString('assets/json/perfiles.json');
final calendarEventsJson = await rootBundle.loadString('assets/json/calendario.json');
final routeGuidesJson = await rootBundle.loadString('assets/json/guia-rutas.json');
final routes = _decodeList(routesJson).map(TruckRoute.fromJson).toList(growable: false);
final notifications = _decodeList(notificationsJson).map(RouteNotification.fromJson).toList(growable: false);
final colonyRoutes = _decodeList(colonyRoutesJson).map(ColonyRoute.fromJson).toList(growable: false);
final profiles = _decodeList(profilesJson).map(DemoProfile.fromJson).toList(growable: false);
final calendarEvents = _decodeList(calendarEventsJson).map(CalendarEventEntry.fromJson).toList(growable: false);
final routeGuides = _decodeList(routeGuidesJson).map(RouteGuideEntry.fromJson).toList(growable: false);
return LocalSeedData(
routes: routes,
notifications: notifications,
colonyRoutes: colonyRoutes,
demoProfiles: profiles,
calendarEvents: calendarEvents,
routeGuides: routeGuides,
);
} catch (_) {
return LocalSeedData.empty();
}
}
List<Map<String, dynamic>> _decodeList(String responseBody) {
final decoded = jsonDecode(responseBody);
if (decoded is! List) {
return <Map<String, dynamic>>[];
}
return decoded.whereType<Map<String, dynamic>>().toList(growable: false);
}
}
class LocalSeedData {
const LocalSeedData({
required this.routes,
required this.notifications,
required this.colonyRoutes,
required this.demoProfiles,
required this.calendarEvents,
required this.routeGuides,
});
final List<TruckRoute> routes;
final List<RouteNotification> notifications;
final List<ColonyRoute> colonyRoutes;
final List<DemoProfile> demoProfiles;
final List<CalendarEventEntry> calendarEvents;
final List<RouteGuideEntry> routeGuides;
const LocalSeedData.empty()
: routes = const <TruckRoute>[],
notifications = const <RouteNotification>[],
colonyRoutes = const <ColonyRoute>[],
demoProfiles = const <DemoProfile>[],
calendarEvents = const <CalendarEventEntry>[],
routeGuides = const <RouteGuideEntry>[];
TruckRoute? get defaultRoute => routes.isEmpty ? null : routes.first;
TruckRoute? routeForColonia(String? colonia) {
if (colonia == null || colonia.trim().isEmpty) {
return defaultRoute;
}
final routeId = colonyRouteForColonia(colonia)?.routeId;
if (routeId == null) {
return defaultRoute;
}
return routeById(routeId) ?? defaultRoute;
}
ColonyRoute? colonyRouteForColonia(String colonia) {
final normalized = colonia.trim().toLowerCase();
for (final item in colonyRoutes) {
if (item.colonia.trim().toLowerCase() == normalized) {
return item;
}
}
return null;
}
TruckRoute? routeById(String routeId) {
for (final route in routes) {
if (route.routeId == routeId) {
return route;
}
}
return null;
}
DemoProfile? profileForCredentials(String email, String password) {
final normalizedEmail = email.trim().toLowerCase();
for (final profile in demoProfiles) {
if (profile.email.trim().toLowerCase() == normalizedEmail && profile.password == password) {
return profile;
}
}
return null;
}
DemoProfile? profileForColonia(String? colonia) {
if (colonia == null || colonia.trim().isEmpty) {
return demoProfiles.isEmpty ? null : demoProfiles.first;
}
final normalized = colonia.trim().toLowerCase();
for (final profile in demoProfiles) {
if (profile.colonia.trim().toLowerCase() == normalized) {
return profile;
}
}
return demoProfiles.isEmpty ? null : demoProfiles.first;
}
List<CalendarEventEntry> eventsForDay(DateTime day) {
final normalized = DateTime(day.year, day.month, day.day);
return calendarEvents
.where((event) => event.date.year == normalized.year && event.date.month == normalized.month && event.date.day == normalized.day)
.toList(growable: false);
}
RouteGuideEntry? guideForRouteId(String routeId) {
for (final guide in routeGuides) {
if (guide.routeId == routeId) {
return guide;
}
}
return null;
}
}