Co-authored-by: MENDOZA BALLARDO GAEL RICARDO <gael-meb123@users.noreply.github.com>
Co-authored-by: Azareth-Tr <Azareth-Tr@users.noreply.github.com>

modificacion de las vistas principales para el usuario ciudadano, primer avance para el panel admin
This commit is contained in:
shinra32
2026-05-23 03:13:46 -06:00
parent 0279ad05f4
commit 45ffba69b2
33 changed files with 2810 additions and 296 deletions

View File

@@ -0,0 +1,422 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:latlong2/latlong.dart';
import '../../core/constants/auth_constants.dart';
import '../../core/models/colonia.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_widgets.dart';
import '../home/colonias_data.dart';
import 'colonias_provider.dart';
const Map<String, String> _cpToColonia = {
'38000': 'Zona Centro',
'38060': 'Las Arboledas',
'38027': 'San Juanico',
'38037': 'Los Olivos',
'38090': 'Rancho Seco',
'38080': 'Las Insurgentes',
'38086': 'Trojes',
};
class AddAddressPage extends ConsumerStatefulWidget {
const AddAddressPage({super.key});
@override
ConsumerState<AddAddressPage> createState() => _AddAddressPageState();
}
class _AddAddressPageState extends ConsumerState<AddAddressPage> {
final _mapController = MapController();
final _cpCtrl = TextEditingController();
final _calleCtrl = TextEditingController();
final _labelCtrl = TextEditingController(text: 'Mi Casa');
Colonia? _selectedColonia;
LatLng? _selectedLocation;
bool _loading = false;
@override
void dispose() {
_mapController.dispose();
_cpCtrl.dispose();
_calleCtrl.dispose();
_labelCtrl.dispose();
super.dispose();
}
Future<void> _fetchStreetName(LatLng latlng) async {
setState(() => _selectedLocation = latlng);
try {
final dio = Dio();
final response = await dio.get(
'https://nominatim.openstreetmap.org/reverse',
queryParameters: {
'lat': latlng.latitude,
'lon': latlng.longitude,
'format': 'json',
'addressdetails': 1,
},
options: kIsWeb
? null
: Options(headers: {'User-Agent': 'com.onlineshack.recolecta'}),
);
if (response.data?['address'] != null) {
final addr = response.data['address'] as Map;
final road = addr['road'] ?? addr['pedestrian'] ?? addr['street'] ?? '';
final num = addr['house_number'] ?? '';
if ((road as String).isNotEmpty) {
setState(() => _calleCtrl.text = '$road $num'.trim());
}
}
} catch (e) {
debugPrint('Nominatim error: $e');
}
}
void _validarCP(String cp, List<Colonia> colonias) {
if (cp.length != 5) {
if (_selectedColonia != null) {
setState(() {
_selectedColonia = null;
_selectedLocation = null;
_calleCtrl.clear();
});
}
return;
}
final nombre = _cpToColonia[cp];
if (nombre == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Código postal fuera de nuestra zona de servicio.'),
backgroundColor: AppTheme.danger,
behavior: SnackBarBehavior.floating,
),
);
setState(() {
_selectedColonia = null;
_selectedLocation = null;
});
return;
}
final backendC = colonias
.where((c) => c.nombre.toLowerCase() == nombre.toLowerCase())
.firstOrNull;
if (backendC == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Esta colonia aún no tiene horarios configurados.'),
backgroundColor: AppTheme.danger,
behavior: SnackBarBehavior.floating,
),
);
setState(() {
_selectedColonia = null;
_selectedLocation = null;
});
return;
}
setState(() {
_selectedColonia = backendC;
_selectedLocation = kColoniasCoordinates[nombre];
});
FocusScope.of(context).unfocus();
}
Future<void> _guardar() async {
if (_calleCtrl.text.trim().isEmpty || _selectedColonia == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ingresa tu calle y selecciona una colonia'),
behavior: SnackBarBehavior.floating,
),
);
return;
}
setState(() => _loading = true);
try {
const storage = FlutterSecureStorage();
final token = await storage.read(key: authTokenStorageKey) ?? '';
final dio = Dio(
BaseOptions(
baseUrl: const String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:8000',
),
headers: {'Authorization': 'Bearer $token'},
),
);
final body = <String, dynamic>{
'label': _labelCtrl.text.trim().isEmpty
? 'Mi Casa'
: _labelCtrl.text.trim(),
'calle': _calleCtrl.text.trim(),
'colonia': _selectedColonia!.nombre,
};
if (_selectedLocation != null) {
body['lat'] = _selectedLocation!.latitude;
body['lng'] = _selectedLocation!.longitude;
}
await dio.post('/addresses', data: body);
if (mounted) Navigator.pop(context, true);
} on DioException catch (e) {
if (mounted) {
final msg = (e.response?.data is Map)
? e.response!.data['detail'] ?? 'Error al guardar'
: 'Error al guardar la dirección';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
backgroundColor: AppTheme.danger,
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: AppTheme.danger,
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final coloniasList = ref.watch(coloniasProvider).value ?? [];
final baseCenter = _selectedColonia != null
? kColoniaCenter(_selectedColonia!.nombre)
: const LatLng(20.5222, -100.8123);
final mapCenter = _selectedLocation ?? baseCenter;
final bounds = _selectedColonia != null
? LatLngBounds(
LatLng(baseCenter.latitude - 0.01, baseCenter.longitude - 0.01),
LatLng(baseCenter.latitude + 0.01, baseCenter.longitude + 0.01),
)
: null;
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
iconTheme: const IconThemeData(color: AppTheme.textPrimary),
title: const Text(
'Agregar dirección',
style: TextStyle(
color: AppTheme.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppFormCard(
icon: Icons.home_outlined,
title: 'Dirección de tu casa',
child: Column(
children: [
AppFormField(
label: 'Etiqueta',
hint: 'Ej. Mi Casa, Trabajo',
controller: _labelCtrl,
),
const SizedBox(height: 14),
AppFormField(
label: 'Código Postal',
hint: 'Ej. 38000',
controller: _cpCtrl,
keyboardType: TextInputType.number,
onChanged: (v) => _validarCP(v, coloniasList),
),
if (_selectedColonia != null) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.primaryLight.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: AppTheme.primaryMid),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.check_circle_outline,
color: AppTheme.primary,
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Colonia: ${_selectedColonia!.nombre}',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark,
),
),
),
],
),
if (_selectedColonia!.horarioEstimado != null) ...[
const SizedBox(height: 8),
Text(
'Horario ${_selectedColonia!.turno?.toLowerCase() ?? ''}',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textPrimary,
),
),
Text(
_selectedColonia!.horarioEstimado!,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
],
),
),
const SizedBox(height: 14),
AppFormField(
label: 'Calle y número',
hint: 'Av. Insurgentes 245',
controller: _calleCtrl,
),
const SizedBox(height: 16),
const Text(
'Toca el mapa para ubicar tu casa exacta:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Container(
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: AppTheme.border),
),
clipBehavior: Clip.hardEdge,
child: FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: mapCenter,
initialZoom: 15.0,
cameraConstraint: bounds != null
? CameraConstraint.containCenter(bounds: bounds)
: const CameraConstraint.unconstrained(),
onTap: (_, latlng) => _fetchStreetName(latlng),
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.onlineshack.recolecta',
),
if (_selectedLocation != null)
MarkerLayer(
markers: [
Marker(
point: _selectedLocation!,
width: 40,
height: 40,
child: const Icon(
Icons.location_on,
color: AppTheme.danger,
size: 40,
),
),
],
),
],
),
),
] else ...[
const SizedBox(height: 24),
const Center(
child: Text(
'Ingresa un código postal con servicio\npara asignar tu colonia.',
textAlign: TextAlign.center,
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 13,
),
),
),
],
],
),
),
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _loading ? null : _guardar,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _loading
? const SizedBox(
key: ValueKey('loading'),
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Row(
key: ValueKey('text'),
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check, size: 18),
SizedBox(width: 8),
Flexible(
child: Text(
'Guardar dirección',
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),
const SizedBox(height: 24),
],
),
),
);
}
}

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../core/theme/app_theme.dart';
import '../home/colonias_data.dart';
class AddressMapCard extends StatelessWidget {
final String label;
final String street;
final String colonia;
final double? lat;
final double? lng;
const AddressMapCard({
super.key,
required this.label,
required this.street,
required this.colonia,
this.lat,
this.lng,
});
@override
Widget build(BuildContext context) {
// Si existen coordenadas exactas las usa, de lo contrario cae al centro de la colonia
final center = (lat != null && lng != null)
? LatLng(lat!, lng!)
: kColoniaCenter(colonia);
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.border, width: 0.5),
boxShadow: AppTheme.softShadow,
),
clipBehavior: Clip.hardEdge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Mapa no interactivo ──
SizedBox(
height: 130,
child: FlutterMap(
options: MapOptions(
initialCenter: center,
initialZoom: 16.0,
// ¡Esta línea desactiva todas las interacciones!
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.none,
),
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.onlineshack.recolecta',
),
MarkerLayer(
markers: [
Marker(
point: center,
width: 36,
height: 36,
child: const Icon(
Icons.home_rounded,
color: AppTheme.primary,
size: 36,
),
),
],
),
],
),
),
// ── Información en texto ──
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
label.toLowerCase().contains('negocio')
? Icons.storefront
: Icons.home_outlined,
color: AppTheme.primary,
size: 20,
),
const SizedBox(width: 8),
Text(
label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 8),
Text(street, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 2),
Text(
'Colonia $colonia',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
);
}
}

View File

@@ -1,9 +1,28 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../core/models/address_create_request.dart';
import '../../core/models/colonia.dart';
import 'colonias_selector.dart';
import '../home/colonias_data.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_widgets.dart';
import '../../core/constants/auth_constants.dart';
import 'colonias_provider.dart';
const Map<String, String> _cpToColonia = {
'38000': 'Zona Centro',
'38060': 'Las Arboledas',
'38027': 'San Juanico',
'38037': 'Los Olivos',
'38090': 'Rancho Seco',
'38080': 'Las Insurgentes',
'38086': 'Trojes',
};
class NewAddressPage extends ConsumerStatefulWidget {
const NewAddressPage({super.key});
@@ -15,40 +34,168 @@ class NewAddressPage extends ConsumerStatefulWidget {
class _NewAddressPageState extends ConsumerState<NewAddressPage> {
final _formKey = GlobalKey<FormState>();
final _labelController = TextEditingController();
final _cpCtrl = TextEditingController();
final _streetController = TextEditingController();
Colonia? _selectedColonia;
String _tipoInmueble = 'Casa';
final _mapController = MapController();
LatLng? _selectedLocation;
@override
void dispose() {
_labelController.dispose();
_cpCtrl.dispose();
_streetController.dispose();
_mapController.dispose();
super.dispose();
}
void _saveAddress() {
Future<void> _fetchStreetName(LatLng latlng) async {
setState(() => _selectedLocation = latlng);
try {
final dio = Dio();
final response = await dio.get(
'https://nominatim.openstreetmap.org/reverse',
queryParameters: {
'lat': latlng.latitude,
'lon': latlng.longitude,
'format': 'json',
'addressdetails': 1,
},
options: kIsWeb
? null
: Options(headers: {'User-Agent': 'com.onlineshack.recolecta'}),
);
if (response.data != null && response.data['address'] != null) {
final address = response.data['address'];
final road =
address['road'] ?? address['pedestrian'] ?? address['street'] ?? '';
final houseNumber = address['house_number'] ?? '';
if (road.isNotEmpty) {
setState(() {
_streetController.text = '$road $houseNumber'.trim();
});
}
}
} catch (e) {
debugPrint('Aviso: Error al obtener nombre de la calle de OSM: $e');
}
}
void _validarCP(String cp, List<Colonia> colonias) {
if (cp.length != 5) {
if (_selectedColonia != null) {
setState(() {
_selectedColonia = null;
_selectedLocation = null;
_streetController.clear();
});
}
return;
}
final nombre = _cpToColonia[cp];
if (nombre == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Código postal fuera de nuestra zona de servicio actual.',
),
backgroundColor: AppTheme.danger,
),
);
setState(() {
_selectedColonia = null;
_selectedLocation = null;
});
return;
}
final backendC = colonias
.where((c) => c.nombre.toLowerCase() == nombre.toLowerCase())
.firstOrNull;
if (backendC == null) return;
setState(() {
_selectedColonia = backendC;
_selectedLocation = kColoniasCoordinates[nombre];
});
FocusScope.of(context).unfocus(); // Cierra el teclado
}
Future<void> _saveAddress() async {
if (!(_formKey.currentState?.validate() ?? false)) {
return;
}
if (_selectedColonia == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Selecciona una colonia')));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ingresa un código postal válido')),
);
return;
}
final address = AddressCreateRequest(
label: _labelController.text.trim(),
street: _streetController.text.trim(),
colonia: _selectedColonia!.nombre,
);
try {
const storage = FlutterSecureStorage();
final token = await storage.read(key: authTokenStorageKey) ?? '';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Domicilio listo: ${address.toJson()}')),
);
if (token.isNotEmpty) {
final dio = Dio(
BaseOptions(
baseUrl: const String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:8000',
),
headers: {'Authorization': 'Bearer $token'},
),
);
await dio.post(
'/addresses',
data: {
'label': _labelController.text.trim(),
'calle': _streetController.text.trim(),
'colonia': _selectedColonia!.nombre,
},
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Domicilio agregado exitosamente')),
);
Navigator.pop(
context,
true,
); // Devuelve true para recargar la lista en la pantalla anterior
}
}
} catch (e) {
debugPrint('Error al guardar domicilio: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Error al guardar el domicilio'),
backgroundColor: AppTheme.danger,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final baseCenter = _selectedColonia != null
? kColoniasCoordinates[_selectedColonia!.nombre] ??
const LatLng(20.5222, -100.8123)
: const LatLng(20.5222, -100.8123);
final mapCenter = _selectedLocation ?? baseCenter;
final coloniasAsync = ref.watch(coloniasProvider);
final coloniasList = coloniasAsync.value ?? [];
return Scaffold(
appBar: AppBar(title: const Text('Nuevo domicilio')),
body: SafeArea(
@@ -60,37 +207,214 @@ class _NewAddressPageState extends ConsumerState<NewAddressPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
AppFormField(
label: 'Etiqueta',
hint: 'Ej. Casa de mis padres, Oficina...',
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',
const Text(
'Selección de domicilio',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Ingresa la calle'
: null,
),
Row(
children: [
Expanded(
child: Material(
color: Colors.transparent,
child: RadioListTile<String>(
contentPadding: const EdgeInsets.symmetric(
horizontal: 4,
),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
title: const Text(
'Casa',
style: TextStyle(fontSize: 14),
),
value: 'Casa',
groupValue: _tipoInmueble,
onChanged: (v) {
setState(() => _tipoInmueble = v!);
if (_labelController.text.trim().isEmpty ||
_labelController.text == 'Mi Negocio') {
_labelController.text = 'Mi Casa';
}
},
),
),
),
Expanded(
child: Material(
color: Colors.transparent,
child: RadioListTile<String>(
contentPadding: const EdgeInsets.symmetric(
horizontal: 4,
),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
title: const Text(
'Negocio',
style: TextStyle(fontSize: 14),
),
value: 'Negocio',
groupValue: _tipoInmueble,
onChanged: (v) {
setState(() => _tipoInmueble = v!);
if (_labelController.text.trim().isEmpty ||
_labelController.text == 'Mi Casa') {
_labelController.text = 'Mi Negocio';
}
},
),
),
),
],
),
const SizedBox(height: 16),
ColoniasSelector(
labelText: 'Colonia',
initialValue: _selectedColonia,
onChanged: (colonia) {
setState(() => _selectedColonia = colonia);
},
AppFormField(
label: 'Código Postal',
hint: 'Ej. 38000',
controller: _cpCtrl,
keyboardType: TextInputType.number,
onChanged: (v) => _validarCP(v, coloniasList),
),
if (_selectedColonia != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.primaryLight.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: AppTheme.primaryMid),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.check_circle_outline,
color: AppTheme.primary,
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Colonia: ${_selectedColonia!.nombre}',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'Horario ${_selectedColonia!.turno?.toLowerCase() ?? 'asignado'}',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textPrimary,
),
),
Text(
_selectedColonia!.horarioEstimado ??
'Sin horario especificado',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
),
const SizedBox(height: 14),
AppFormField(
label: 'Calle y número',
hint: 'Av. Insurgentes 245',
controller: _streetController,
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Ingresa la calle'
: null,
),
const SizedBox(height: 16),
const Text(
'Toca el mapa para ubicar tu domicilio exacto:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Container(
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: AppTheme.border),
),
clipBehavior: Clip.hardEdge,
child: FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: mapCenter,
initialZoom: 15.0,
onTap: (_, latlng) => _fetchStreetName(latlng),
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.onlineshack.recolecta',
),
if (_selectedLocation != null)
MarkerLayer(
markers: [
Marker(
point: _selectedLocation!,
width: 40,
height: 40,
child: const Icon(
Icons.location_on,
color: AppTheme.danger,
size: 40,
),
),
],
),
],
),
),
] else ...[
const SizedBox(height: 24),
const Center(
child: Text(
'Ingresa un código postal con servicio\npara asignar tu colonia.',
textAlign: TextAlign.center,
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 13,
),
),
),
],
const SizedBox(height: 24),
SizedBox(
height: 52,

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
@@ -11,6 +11,8 @@ import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_widgets.dart';
import '../../core/services/auth_controller.dart';
import '../../core/models/auth_state.dart';
import '../../core/constants/auth_constants.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../core/models/colonia.dart';
import '../home/colonias_data.dart';
import '../addresses/colonias_provider.dart';
@@ -199,34 +201,6 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
FocusScope.of(context).unfocus(); // Cierra el teclado
}
Future<void> _postAddressInBackground(String calle, String colonia) async {
try {
const storage = FlutterSecureStorage();
// Esperar un momento para asegurar que el token se haya guardado
await Future.delayed(const Duration(milliseconds: 500));
final token = await storage.read(key: 'token') ?? '';
if (token.isNotEmpty) {
final dio = Dio(
BaseOptions(
baseUrl: const String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:8000',
),
headers: {'Authorization': 'Bearer $token'},
),
);
await dio.post(
'/addresses',
data: {'label': 'Mi Casa', 'calle': calle, 'colonia': colonia},
);
}
} catch (e) {
debugPrint('Aviso: No se pudo guardar la dirección inicial: $e');
}
}
Future<void> _register() async {
if (_calleCtrl.text.trim().isEmpty || _selectedColonia == null) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -238,32 +212,65 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
return;
}
// Capturar variables antes del proceso asíncrono
final phoneDigits = _telefonoCtrl.text.replaceAll(RegExp(r'\D'), '');
final phone = phoneDigits.isNotEmpty ? '+52$phoneDigits' : '';
final calle = _calleCtrl.text.trim();
final colonia = _selectedColonia!.nombre;
final lat = _selectedLocation?.latitude;
final lng = _selectedLocation?.longitude;
// 1. Registra al usuario
await ref
.read(authControllerProvider.notifier)
.register(
email: _emailCtrl.text.trim(),
phone: _telefonoCtrl.text.trim(),
password: _passCtrl.text,
);
try {
await ref
.read(authControllerProvider.notifier)
.register(
email: _emailCtrl.text.trim(),
phone: phone,
password: _passCtrl.text,
addressCalle: calle,
addressColonia: colonia,
addressLabel: 'Mi Casa',
addressLat: lat,
addressLng: lng,
);
// Si el widget ya no está montado, GoRouter nos redirigió automáticamente al Home por éxito.
if (!mounted) {
_postAddressInBackground(calle, colonia);
return;
// Guardado silencioso de la dirección tras un registro exitoso
_postAddressInBackground(calle, colonia, lat, lng);
} catch (_) {
// El error ya es manejado por el listener y muestra el SnackBar
}
}
// Si seguimos aquí, verificar si hubo un error (ej. contraseña corta)
if (ref.read(authControllerProvider).hasError) return;
Future<void> _postAddressInBackground(
String calle,
String colonia,
double? lat,
double? lng,
) async {
try {
const storage = FlutterSecureStorage();
await Future.delayed(
const Duration(milliseconds: 800),
); // Esperar a que se guarde el JWT
final token = await storage.read(key: authTokenStorageKey) ?? '';
// Fallback: guardar dirección y navegar manualmente
await _postAddressInBackground(calle, colonia);
if (mounted) {
context.go('/home');
if (token.isNotEmpty) {
final dio = Dio(
BaseOptions(
baseUrl: const String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:8000',
),
headers: {'Authorization': 'Bearer $token'},
),
);
await dio.post(
'/addresses',
data: {'label': 'Mi Casa', 'calle': calle, 'colonia': colonia},
);
}
} catch (e) {
debugPrint('Aviso: No se pudo crear la dirección: $e');
}
}
@@ -400,12 +407,7 @@ class _Step1 extends StatelessWidget {
},
),
const SizedBox(height: 14),
AppFormField(
label: 'Teléfono',
hint: '+52 461 123 4567',
controller: telefonoCtrl,
keyboardType: TextInputType.phone,
),
_PhoneField(controller: telefonoCtrl),
const SizedBox(height: 14),
AppFormField(
label: 'Contraseña',
@@ -523,14 +525,6 @@ class _Step2 extends StatelessWidget {
final mapCenter = selectedLocation ?? baseCenter;
// Magia de privacidad: Restringir paneo a 1km a la redonda usando el centro original
final bounds = selectedColonia != null
? LatLngBounds(
LatLng(baseCenter.latitude - 0.01, baseCenter.longitude - 0.01),
LatLng(baseCenter.latitude + 0.01, baseCenter.longitude + 0.01),
)
: null;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
@@ -611,11 +605,13 @@ class _Step2 extends StatelessWidget {
size: 18,
),
const SizedBox(width: 8),
Text(
'Colonia: ${selectedColonia!.nombre}',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark,
Expanded(
child: Text(
'Colonia: ${selectedColonia!.nombre}',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark,
),
),
),
],
@@ -668,9 +664,6 @@ class _Step2 extends StatelessWidget {
options: MapOptions(
initialCenter: mapCenter,
initialZoom: 15.0,
cameraConstraint: bounds != null
? CameraConstraint.containCenter(bounds: bounds)
: const CameraConstraint.unconstrained(),
onTap: (_, latlng) => onLocationChanged(latlng),
),
children: [
@@ -807,7 +800,10 @@ class _Step2 extends StatelessWidget {
children: [
Icon(Icons.check, size: 18),
SizedBox(width: 8),
Text('Registrarme'),
Flexible(
child: Text('Registrarme',
overflow: TextOverflow.ellipsis),
),
],
),
),
@@ -831,6 +827,153 @@ class _Step2 extends StatelessWidget {
}
}
// ── Campo de teléfono con lada ────────────────────────────────────────────────
// Muestra +52 🇲🇽 fijo (escalable a selector multi-país en el futuro).
// Formatea la entrada como 000-000-0000 y valida exactamente 10 dígitos.
class _PhoneField extends StatelessWidget {
final TextEditingController controller;
const _PhoneField({required this.controller});
// Países disponibles (lista para escalamiento futuro)
static const _ladas = [(flag: '🇲🇽', code: '+52', name: 'México')];
@override
Widget build(BuildContext context) {
final lada = _ladas.first;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Teléfono',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Selector de lada (por ahora solo +52)
Container(
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: AppTheme.background,
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: AppTheme.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(lada.flag, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 6),
Text(
lada.code,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
),
const SizedBox(width: 8),
// Número (solo dígitos, formato 000-000-0000)
Expanded(
child: TextFormField(
controller: controller,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
_PhoneInputFormatter(),
],
style: const TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
),
decoration: InputDecoration(
hintText: '000-000-0000',
hintStyle: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
filled: true,
fillColor: AppTheme.background,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 15,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
borderSide: const BorderSide(color: AppTheme.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
borderSide: const BorderSide(color: AppTheme.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
borderSide: const BorderSide(
color: AppTheme.primary,
width: 1.5,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
borderSide: const BorderSide(color: AppTheme.danger),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
borderSide: const BorderSide(
color: AppTheme.danger,
width: 1.5,
),
),
),
validator: (v) {
if (v == null || v.isEmpty) return null; // opcional
final digits = v.replaceAll('-', '');
if (digits.length != 10)
return 'Ingresa exactamente 10 dígitos';
return null;
},
),
),
],
),
],
);
}
}
// Formatea dígitos en tiempo real: 4611234567 → 461-123-4567
class _PhoneInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final String formatted;
if (digits.length <= 3) {
formatted = digits;
} else if (digits.length <= 6) {
formatted = '${digits.substring(0, 3)}-${digits.substring(3)}';
} else {
formatted =
'${digits.substring(0, 3)}-${digits.substring(3, 6)}-${digits.substring(6)}';
}
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}
// ── Opción radio ──────────────────────────────────────────────────────────────
class _RadioOption extends StatelessWidget {
final int value, groupValue;

View File

@@ -0,0 +1,73 @@
// lib/features/eta/eta_model.dart
// Modelo de respuesta del endpoint GET /eta?address_id=X
// El backend NUNCA devuelve coordenadas; solo texto y status.
enum RouteStatus {
pendiente,
enRuta,
completada,
diferida,
reasignada,
}
RouteStatus routeStatusFromString(String s) {
switch (s) {
case 'en_ruta':
return RouteStatus.enRuta;
case 'completada':
return RouteStatus.completada;
case 'diferida':
return RouteStatus.diferida;
case 'reasignada':
return RouteStatus.reasignada;
default:
return RouteStatus.pendiente;
}
}
class EtaResponse {
/// Texto accionable que muestra el ciudadano.
/// Ejemplos: "Llega en aproximadamente 15 minutos"
/// "Servicio del día finalizado"
final String mensaje;
/// Estado de la ruta para mostrar el badge correcto.
final RouteStatus status;
/// Ventana horaria opcional, ej. "7:207:35 p.m."
/// Solo presente cuando positionId == 4 (TRUCK_PROXIMITY).
final String? ventanaHoraria;
const EtaResponse({
required this.mensaje,
required this.status,
this.ventanaHoraria,
});
factory EtaResponse.fromJson(Map<String, dynamic> json) {
return EtaResponse(
mensaje: json['mensaje'] as String,
status: routeStatusFromString(json['status'] as String),
ventanaHoraria: json['ventana_horaria'] as String?,
);
}
/// Estado de progreso local (0-3) mapeado al positionId del backend.
/// Útil para la barra de 4 pasos en la UI.
int get stepIndex {
switch (status) {
case RouteStatus.pendiente:
return 0;
case RouteStatus.enRuta:
return 1;
case RouteStatus.completada:
return 3;
default:
return 2;
}
}
bool get isCompleted => status == RouteStatus.completada;
bool get isNearby =>
ventanaHoraria != null && status == RouteStatus.enRuta;
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../core/constants/auth_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../core/models/ui_models.dart';
import 'colonias_data.dart';
@@ -30,8 +31,8 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
Future<void> _loadData() async {
try {
const storage = FlutterSecureStorage();
final token = await storage.read(key: 'token') ?? '';
final token = await storage.read(key: authTokenStorageKey) ?? '';
if (token.isEmpty) {
if (mounted) setState(() => _isLoading = false);
return;
@@ -53,7 +54,8 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
if (colRes.data is List) {
for (var c in colRes.data) {
final nombre = c['nombre'] ?? c['colonia'] ?? '';
final horario = c['horario_estimado'] ?? c['schedule'] ?? 'Horario no definido';
final horario =
c['horario_estimado'] ?? c['schedule'] ?? 'Horario no definido';
if (nombre.isNotEmpty) {
_horarios[nombre] = horario;
}
@@ -67,14 +69,19 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
final res = await dio.get('/addresses');
List<UIHouseModel> loadedCasas = [];
if (res.data is List) {
loadedCasas = (res.data as List).map((e) => UIHouseModel.fromJson(e)).toList();
loadedCasas = (res.data as List)
.map((e) => UIHouseModel.fromJson(e))
.toList();
}
// 3. Obtener ETA (Tiempo Estimado) para cada domicilio
Map<String, String> loadedEtas = {};
for (var casa in loadedCasas) {
try {
final etaRes = await dio.get('/eta', queryParameters: {'address_id': casa.id});
final etaRes = await dio.get(
'/eta',
queryParameters: {'address_id': casa.id},
);
loadedEtas[casa.id] = etaRes.data['mensaje'] ?? 'Estado desconocido';
} catch (e) {
loadedEtas[casa.id] = 'Calculando...';
@@ -110,29 +117,30 @@ class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
setState(() => _isLoading = true);
_loadData();
},
)
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _casas.isEmpty
? const Center(
child: Text(
'No tienes domicilios registrados.',
style: TextStyle(color: AppTheme.textSecondary),
),
)
: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _casas.length,
separatorBuilder: (_, __) => const SizedBox(height: 24),
itemBuilder: (context, index) {
final casa = _casas[index];
final eta = _etas[casa.id] ?? 'Actualizando...';
final horario = _horarios[casa.colonia] ?? 'Horario asignado a la ruta';
return _HouseEtaCard(casa: casa, etaMsg: eta, horario: horario);
},
),
? const Center(
child: Text(
'No tienes domicilios registrados.',
style: TextStyle(color: AppTheme.textSecondary),
),
)
: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _casas.length,
separatorBuilder: (_, __) => const SizedBox(height: 24),
itemBuilder: (context, index) {
final casa = _casas[index];
final eta = _etas[casa.id] ?? 'Actualizando...';
final horario =
_horarios[casa.colonia] ?? 'Horario asignado a la ruta';
return _HouseEtaCard(casa: casa, etaMsg: eta, horario: horario);
},
),
);
}
}
@@ -151,13 +159,11 @@ class _HouseEtaCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final center = kColoniasCoordinates[casa.colonia] ?? const LatLng(20.5222, -100.8123);
// Restricción del mapa a la colonia (Privacidad por Diseño)
final bounds = LatLngBounds(
LatLng(center.latitude - 0.01, center.longitude - 0.01),
LatLng(center.latitude + 0.01, center.longitude + 0.01),
);
// Si el usuario registró coordenadas, las usamos; si no, el centro de la colonia
final coloniaCenter = kColoniaCenter(casa.colonia);
final pin = (casa.lat != null && casa.lng != null)
? LatLng(casa.lat!, casa.lng!)
: coloniaCenter;
return Container(
decoration: BoxDecoration(
@@ -170,15 +176,15 @@ class _HouseEtaCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Mapa Restringido ──
// ── Mapa Restringido a la colonia ──
SizedBox(
height: 180,
child: FlutterMap(
options: MapOptions(
initialCameraFit: CameraFit.bounds(bounds: bounds),
cameraConstraint: CameraConstraint.contain(bounds: bounds),
initialCenter: pin,
initialZoom: 16.0,
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.drag | InteractiveFlag.pinchZoom,
flags: InteractiveFlag.none,
),
),
children: [
@@ -186,22 +192,24 @@ class _HouseEtaCard extends StatelessWidget {
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.onlineshack.recolecta',
),
CircleLayer(
circles: [
CircleMarker(
point: center,
color: AppTheme.primary.withValues(alpha: 0.15),
borderColor: AppTheme.primary,
borderStrokeWidth: 2,
radius: 400,
useRadiusInMeter: true,
MarkerLayer(
markers: [
Marker(
point: pin,
width: 36,
height: 36,
child: const Icon(
Icons.home_rounded,
color: AppTheme.primary,
size: 36,
),
),
],
),
],
),
),
// ── Recuadro de Información ──
Padding(
padding: const EdgeInsets.all(16),
@@ -223,11 +231,19 @@ class _HouseEtaCard extends StatelessWidget {
],
),
const SizedBox(height: 14),
_InfoRow(icon: Icons.location_on_outlined, title: 'Dirección', value: casa.direccionCompleta),
_InfoRow(
icon: Icons.location_on_outlined,
title: 'Dirección',
value: casa.direccionCompleta,
),
const SizedBox(height: 12),
_InfoRow(icon: Icons.schedule_outlined, title: 'Horario Habitual', value: horario),
_InfoRow(
icon: Icons.schedule_outlined,
title: 'Horario Habitual',
value: horario,
),
const SizedBox(height: 18),
// ── Alerta de ETA en Tiempo Real ──
Container(
padding: const EdgeInsets.all(12),
@@ -239,7 +255,10 @@ class _HouseEtaCard extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.local_shipping_outlined, color: AppTheme.primaryDark),
const Icon(
Icons.local_shipping_outlined,
color: AppTheme.primaryDark,
),
const SizedBox(width: 10),
Expanded(
child: Column(
@@ -282,8 +301,12 @@ class _InfoRow extends StatelessWidget {
final IconData icon;
final String title;
final String value;
const _InfoRow({required this.icon, required this.title, required this.value});
const _InfoRow({
required this.icon,
required this.title,
required this.value,
});
@override
Widget build(BuildContext context) {
@@ -298,12 +321,20 @@ class _InfoRow extends StatelessWidget {
children: [
Text(
title,
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary, fontWeight: FontWeight.w500),
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(fontSize: 14, color: AppTheme.textPrimary, height: 1.3),
style: const TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
height: 1.3,
),
),
],
),

View File

@@ -1,8 +1,5 @@
import 'package:latlong2/latlong.dart';
// Coordenadas de referencia para el centro de cada colonia en Celaya, Gto.
// Para el MVP, estas coordenadas son fijas y coinciden con el JSON de `colonias-rutas`.
// En una versión futura, podrían venir de una API de geocodificación o de la BD.
const Map<String, LatLng> kColoniasCoordinates = {
'Zona Centro': LatLng(20.52254, -100.81153),
'Las Arboledas': LatLng(20.51422, -100.82793),
@@ -12,3 +9,12 @@ const Map<String, LatLng> kColoniasCoordinates = {
'Las Insurgentes': LatLng(20.52427, -100.79548),
'Trojes': LatLng(20.50899, -100.77167),
};
/// Lookup case-insensitive y sin espacios extras.
LatLng kColoniaCenter(String colonia) {
final key = colonia.trim().toLowerCase();
for (final e in kColoniasCoordinates.entries) {
if (e.key.toLowerCase() == key) return e.value;
}
return const LatLng(20.52254, -100.81153); // fallback: Zona Centro
}

View File

@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:go_router/go_router.dart';
import 'package:latlong2/latlong.dart';
import '../../core/constants/auth_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../core/models/ui_models.dart';
import 'colonias_data.dart';
@@ -28,7 +30,7 @@ class _MyHouseScreenState extends State<MyHouseScreen> {
Future<void> _cargarDomicilio() async {
try {
const storage = FlutterSecureStorage();
final token = await storage.read(key: 'token') ?? '';
final token = await storage.read(key: authTokenStorageKey) ?? '';
if (token.isEmpty) {
setState(() => _isLoading = false);
@@ -96,7 +98,11 @@ class _MyHouseScreenState extends State<MyHouseScreen> {
_CasaCard(casa: _casa!),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Mapa del Sector (Restringido)'),
_MapaColoniaRestringido(colonia: _casa!.colonia),
_MapaColoniaRestringido(
colonia: _casa!.colonia,
lat: _casa!.lat,
lng: _casa!.lng,
),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Radio de alerta'),
_RadioAlertaCard(
@@ -120,13 +126,13 @@ class _MyHouseScreenState extends State<MyHouseScreen> {
_HorarioCard(),
const SizedBox(height: 16),
GestureDetector(
onTap: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Funcionalidad próximamente disponible'),
behavior: SnackBarBehavior.floating,
backgroundColor: AppTheme.primary,
),
),
onTap: () async {
final added = await context.push<bool>('/add-address');
if (added == true && mounted) {
setState(() => _isLoading = true);
_cargarDomicilio();
}
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -251,19 +257,14 @@ class _CasaCard extends StatelessWidget {
// ── Mapa de Colonia (Restringido para Privacidad) ──────────────────────────────
class _MapaColoniaRestringido extends StatelessWidget {
final String colonia;
const _MapaColoniaRestringido({required this.colonia});
final double? lat;
final double? lng;
const _MapaColoniaRestringido({required this.colonia, this.lat, this.lng});
@override
Widget build(BuildContext context) {
// Usa las coordenadas del archivo centralizado de datos de colonias.
final center =
kColoniasCoordinates[colonia] ?? const LatLng(20.5222, -100.8123);
// Creamos una "caja" o límite geográfico de aprox 1km a la redonda
final bounds = LatLngBounds(
LatLng(center.latitude - 0.01, center.longitude - 0.01),
LatLng(center.latitude + 0.01, center.longitude + 0.01),
);
final center = kColoniaCenter(colonia);
final pin = (lat != null && lng != null) ? LatLng(lat!, lng!) : center;
return Container(
height: 200,
@@ -274,11 +275,10 @@ class _MapaColoniaRestringido extends StatelessWidget {
clipBehavior: Clip.hardEdge,
child: FlutterMap(
options: MapOptions(
initialCameraFit: CameraFit.bounds(bounds: bounds),
// ESTO ES LA MAGIA DE LA PRIVACIDAD: Bloquea el mapa a esta caja
cameraConstraint: CameraConstraint.contain(bounds: bounds),
initialCenter: pin,
initialZoom: 16.0,
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.drag | InteractiveFlag.pinchZoom,
flags: InteractiveFlag.none,
),
),
children: [
@@ -286,15 +286,17 @@ class _MapaColoniaRestringido extends StatelessWidget {
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.onlineshack.recolecta',
),
CircleLayer(
circles: [
CircleMarker(
point: center,
color: AppTheme.primary.withValues(alpha: 0.2),
borderColor: AppTheme.primary,
borderStrokeWidth: 2,
radius: 350, // 350 metros a la redonda remarcados
useRadiusInMeter: true,
MarkerLayer(
markers: [
Marker(
point: pin,
width: 36,
height: 36,
child: const Icon(
Icons.home_rounded,
color: AppTheme.primary,
size: 36,
),
),
],
),

View File

@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recolecta_app/core/theme/app_theme.dart';
import 'package:recolecta_app/core/widgets/app_widgets.dart';
import 'package:recolecta_app/core/services/auth_controller.dart';
import 'package:recolecta_app/core/api/api_service.dart';
class EditProfileScreen extends ConsumerStatefulWidget {
const EditProfileScreen({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_EditProfileScreenState();
}
class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
bool _isLoading = false;
@override
void initState() {
super.initState();
// TODO: Si deseas pre-llenar los datos, aquí puedes llamar a tu API
// (ej. GET /users/me) usando ref.read(apiServiceProvider)
}
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
super.dispose();
}
Future<void> _saveProfile() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
});
try {
final apiService = ref.read(apiServiceProvider);
await apiService.updateUser({
'name': _nameController.text,
'email': _emailController.text,
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Perfil actualizado con éxito')),
);
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error al actualizar el perfil: $e')),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Editar Perfil'),
actions: [
if (_isLoading)
const Padding(
padding: EdgeInsets.only(right: 16.0),
child: CircularProgressIndicator(),
)
else
TextButton(onPressed: _saveProfile, child: const Text('Guardar')),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Nombre',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Por favor ingresa tu nombre';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Correo Electrónico',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || !value.contains('@')) {
return 'Por favor ingresa un correo válido';
}
return null;
},
),
],
),
),
);
}
}

View File

@@ -22,7 +22,8 @@ class ProfileScreen extends ConsumerWidget {
body: FutureBuilder<_ProfileData>(
future: _loadProfile(storage),
builder: (context, snapshot) {
final profile = snapshot.data ??
final profile =
snapshot.data ??
_ProfileData(
email: authState?.token != null ? '' : '',
role: authState?.userRole ?? 'citizen',
@@ -39,7 +40,7 @@ class ProfileScreen extends ConsumerWidget {
icon: Icons.person_outline,
title: 'Editar perfil',
subtitle: profile.email,
onTap: () {},
onTap: () => context.go('/edit-profile'),
),
AppMenuTile(
icon: Icons.lock_outline,
@@ -110,7 +111,10 @@ class ProfileScreen extends ConsumerWidget {
'Recolecta v1.0.0\nServicio de Limpia · Celaya, Gto.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12, color: AppTheme.textHint, height: 1.6),
fontSize: 12,
color: AppTheme.textHint,
height: 1.6,
),
),
),
const SizedBox(height: 24),
@@ -133,19 +137,26 @@ class ProfileScreen extends ConsumerWidget {
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusLg)),
title: const Text('Cerrar sesión',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary)),
content: const Text('¿Estás seguro de que deseas cerrar sesión?',
style: TextStyle(fontSize: 14, color: AppTheme.textSecondary)),
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
),
title: const Text(
'Cerrar sesión',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
content: const Text(
'¿Estás seguro de que deseas cerrar sesión?',
style: TextStyle(fontSize: 14, color: AppTheme.textSecondary),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom(
foregroundColor: AppTheme.textSecondary),
foregroundColor: AppTheme.textSecondary,
),
child: const Text('Cancelar'),
),
TextButton(
@@ -155,8 +166,10 @@ class ProfileScreen extends ConsumerWidget {
if (context.mounted) context.go('/login');
},
style: TextButton.styleFrom(foregroundColor: AppTheme.danger),
child: const Text('Cerrar sesión',
style: TextStyle(fontWeight: FontWeight.w600)),
child: const Text(
'Cerrar sesión',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
@@ -169,13 +182,9 @@ class _ProfileData {
final String email;
final String role;
const _ProfileData({
this.email = '',
this.role = 'citizen',
});
const _ProfileData({this.email = '', this.role = 'citizen'});
String get iniciales =>
email.isNotEmpty ? email[0].toUpperCase() : 'U';
String get iniciales => email.isNotEmpty ? email[0].toUpperCase() : 'U';
String get displayName => email;
bool get isAdmin => role == 'admin';
@@ -210,9 +219,10 @@ class _ProfileHeader extends StatelessWidget {
child: Text(
profile.iniciales,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark),
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark,
),
),
),
),
@@ -221,18 +231,26 @@ class _ProfileHeader extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(profile.displayName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary)),
Text(
profile.displayName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 2),
Text(profile.email,
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
Text(
profile.email,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 6),
AppStatusBadge.green(
profile.isAdmin ? 'Administrador' : 'Ciudadano'),
profile.isAdmin ? 'Administrador' : 'Ciudadano',
),
],
),
),

View File

@@ -0,0 +1,96 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
// Modelo de mensaje simple
class ChatMessage {
final String role; // 'user', 'assistant', 'system'
final String content;
ChatMessage({required this.role, required this.content});
Map<String, dynamic> toJson() => {'role': role, 'content': content};
}
class AiChatNotifier extends StateNotifier<List<ChatMessage>> {
AiChatNotifier()
: super([
ChatMessage(
role: 'assistant',
content:
'¡Hola! Soy Eco 🍃, la mascota de Recolecta. '
'Estoy aquí para ayudarte a reciclar y separar tu basura correctamente. ¿Tienes alguna duda?',
),
]);
bool isLoading = false;
Future<void> sendMessage(String userText) async {
if (userText.trim().isEmpty) return;
// Añadir mensaje del usuario
final userMsg = ChatMessage(role: 'user', content: userText);
state = [...state, userMsg];
isLoading = true;
try {
final dio = Dio();
// Importante: En producción, la llamada a OpenAI debería hacerse idealmente
// desde tu backend FastAPI para no exponer la API_KEY en la app Flutter.
// Para el MVP/Hackathon, la leemos del entorno (.env o --dart-define)
final apiKey = dotenv.env['OPENAI_API_KEY'] ?? '';
// Contexto del sistema para que la IA actúe como la mascota
final systemPrompt = ChatMessage(
role: 'system',
content:
'Eres Eco, la mascota virtual de la app Recolecta en Celaya. '
'Tu misión es educar a los ciudadanos sobre cómo separar la basura en 4 categorías: '
'Orgánicos (verde), Reciclables (azul), Sanitarios (naranja) y Especiales (morado). '
'Responde siempre de forma muy amigable, entusiasta, usando emojis. '
'Sé muy conciso y breve (máximo 3 oraciones cortas). '
'Nunca reveles ubicaciones de camiones ni te salgas del tema del reciclaje y medio ambiente.',
);
final messagesForApi = [systemPrompt, ...state];
final response = await dio.post(
'https://api.openai.com/v1/chat/completions',
options: Options(
headers: {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
},
),
data: {
'model': 'gpt-3.5-turbo', // Rápido y económico para el hackathon
'messages': messagesForApi.map((m) => m.toJson()).toList(),
'temperature': 0.7,
'max_tokens': 150, // Limitar para que sea conciso
},
);
final botReply = response.data['choices'][0]['message']['content'];
state = [...state, ChatMessage(role: 'assistant', content: botReply)];
} catch (e) {
debugPrint('Error en OpenAI: $e');
state = [
...state,
ChatMessage(
role: 'assistant',
content:
'Uy, tuve un problemita técnico con mi cerebro de hojitas 🧠🍂. ¿Me repites tu pregunta?',
),
];
} finally {
isLoading = false;
}
}
}
final aiChatProvider = StateNotifierProvider<AiChatNotifier, List<ChatMessage>>(
(ref) {
return AiChatNotifier();
},
);

View File

@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Importa Lottie si tus animaciones están en formato Lottie (.json)
// import 'package:lottie/lottie.dart';
import '../../core/theme/app_theme.dart';
import 'ai_chat_provider.dart';
class AiPetChatScreen extends ConsumerStatefulWidget {
const AiPetChatScreen({super.key});
@override
ConsumerState<AiPetChatScreen> createState() => _AiPetChatScreenState();
}
class _AiPetChatScreenState extends ConsumerState<AiPetChatScreen> {
final _textController = TextEditingController();
final _scrollController = ScrollController();
@override
void dispose() {
_textController.dispose();
_scrollController.dispose();
super.dispose();
}
void _sendMessage() async {
final text = _textController.text;
if (text.trim().isEmpty) return;
_textController.clear();
// Ocultar teclado
FocusScope.of(context).unfocus();
// Enviar al provider
await ref.read(aiChatProvider.notifier).sendMessage(text);
// Hacer scroll hacia abajo
_scrollToBottom();
}
void _scrollToBottom() {
if (_scrollController.hasClients) {
Future.delayed(const Duration(milliseconds: 300), () {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
});
}
}
@override
Widget build(BuildContext context) {
final messages = ref.watch(aiChatProvider);
// No podemos leer isLoading directamente de ref.watch(provider) porque es StateNotifierProvider.
// Para leer la variable, leemos el notifier.
final isLoading = ref.watch(aiChatProvider.notifier).isLoading;
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
title: const Text('Pregúntale a Eco 🍃'),
backgroundColor: Colors.transparent,
elevation: 0,
),
body: Column(
children: [
// 1. ÁREA DE LA MASCOTA (Animación)
Container(
height: 150,
width: double.infinity,
decoration: const BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(30),
bottomRight: Radius.circular(30),
),
),
child: Center(
// Reemplaza este Icono con tu animación de Lottie:
// child: Lottie.asset('assets/animations/mascota_feliz.json', height: 120),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.pets, size: 64, color: AppTheme.primary),
const SizedBox(height: 8),
Text(
isLoading ? 'Eco está pensando...' : 'Eco te escucha',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.primaryDark,
),
),
],
),
),
),
// 2. HISTORIAL DE CHAT
Expanded(
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[index];
if (msg.role == 'system') return const SizedBox.shrink();
final isBot = msg.role == 'assistant';
return _ChatBubble(text: msg.content, isBot: isBot);
},
),
),
// Indicador de escritura
if (isLoading)
const Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(strokeWidth: 2),
),
// 3. CAMPO DE TEXTO
SafeArea(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: Colors.white,
child: Row(
children: [
Expanded(
child: TextField(
controller: _textController,
decoration: InputDecoration(
hintText: 'Ej. ¿Dónde tiro las cajas de pizza?',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.grey.shade200,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
),
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: 8),
CircleAvatar(
backgroundColor: AppTheme.primary,
child: IconButton(
icon: const Icon(
Icons.send,
color: Colors.white,
size: 20,
),
onPressed: isLoading ? null : _sendMessage,
),
),
],
),
),
),
],
),
);
}
}
class _ChatBubble extends StatelessWidget {
final String text;
final bool isBot;
const _ChatBubble({required this.text, required this.isBot});
@override
Widget build(BuildContext context) {
return Align(
alignment: isBot ? Alignment.centerLeft : Alignment.centerRight,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: isBot ? Colors.white : AppTheme.primary,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(20),
topRight: const Radius.circular(20),
bottomLeft: isBot ? Radius.zero : const Radius.circular(20),
bottomRight: isBot ? const Radius.circular(20) : Radius.zero,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 2),
),
],
),
child: Text(
text,
style: TextStyle(
color: isBot ? AppTheme.textPrimary : Colors.white,
fontSize: 15,
),
),
),
);
}
}