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 vistas panel admin, login, animaciones y implementacion de mascota
This commit is contained in:
shinra32
2026-05-23 03:58:03 -06:00
parent 45ffba69b2
commit 68d04f3917
33 changed files with 5188 additions and 643 deletions

View File

@@ -164,10 +164,6 @@ class _AddAddressPageState extends ConsumerState<AddAddressPage> {
'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);
@@ -396,19 +392,17 @@ class _AddAddressPageState extends ConsumerState<AddAddressPage> {
color: Colors.white,
),
)
: const Row(
: const FittedBox(
key: ValueKey('text'),
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check, size: 18),
SizedBox(width: 8),
Flexible(
child: Text(
'Guardar dirección',
overflow: TextOverflow.ellipsis,
),
),
],
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check, size: 18),
SizedBox(width: 8),
Text('Guardar dirección'),
],
),
),
),
),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,191 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../models/admin_driver.dart';
import '../models/admin_route.dart';
import '../models/admin_unit.dart';
import '../models/admin_user.dart';
final adminServiceProvider = Provider<AdminService>((ref) {
return AdminService(ref.read(apiClientProvider));
});
class AdminService {
AdminService(this._dio);
final Dio _dio;
// ── Users ───────────────────────────────────────────────────────────────────
Future<List<AdminUserModel>> listUsers() async {
final res = await _dio.get<List<dynamic>>('/admin/users');
return (res.data ?? [])
.whereType<Map>()
.map((e) => AdminUserModel.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
Future<AdminUserModel> createUser({
required String name,
required String password,
String? email,
String? phone,
String role = 'citizen',
}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/admin/users',
data: {
'name': name,
'password': password,
if (email != null && email.isNotEmpty) 'email': email,
if (phone != null && phone.isNotEmpty) 'phone': phone,
'role': role,
},
);
return AdminUserModel.fromJson(res.data!);
}
Future<AdminUserModel> updateUser(
String id, {
String? name,
String? email,
String? role,
}) async {
final res = await _dio.patch<Map<String, dynamic>>(
'/admin/users/$id',
data: {
if (name != null) 'name': name,
if (email != null) 'email': email,
if (role != null) 'role': role,
},
);
return AdminUserModel.fromJson(res.data!);
}
Future<void> deleteUser(String id) async {
await _dio.delete<void>('/admin/users/$id');
}
// ── Routes ──────────────────────────────────────────────────────────────────
Future<List<AdminRouteModel>> listRoutes() async {
final res = await _dio.get<List<dynamic>>('/admin/routes');
return (res.data ?? [])
.whereType<Map>()
.map((e) => AdminRouteModel.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
Future<AdminRouteModel> createRoute({
required String id,
String? name,
int? truckId,
String? turno,
String? status,
}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/admin/routes',
data: {
'id': id,
if (name != null) 'name': name,
if (truckId != null) 'truck_id': truckId,
if (turno != null) 'turno': turno,
if (status != null) 'status': status,
},
);
return AdminRouteModel.fromJson(res.data!);
}
Future<AdminRouteModel> updateRoute(
String id, {
String? name,
int? truckId,
String? turno,
String? status,
}) async {
final res = await _dio.patch<Map<String, dynamic>>(
'/admin/routes/$id',
data: {
if (name != null) 'name': name,
if (truckId != null) 'truck_id': truckId,
if (turno != null) 'turno': turno,
if (status != null) 'status': status,
},
);
return AdminRouteModel.fromJson(res.data!);
}
Future<void> deleteRoute(String id) async {
await _dio.delete<void>('/admin/routes/$id');
}
// ── Units ───────────────────────────────────────────────────────────────────
Future<List<AdminUnitModel>> listUnits() async {
final res = await _dio.get<List<dynamic>>('/admin/units');
return (res.data ?? [])
.whereType<Map>()
.map((e) => AdminUnitModel.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
Future<AdminUnitModel> createUnit({
required int id,
String? plate,
String status = 'active',
}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/admin/units',
data: {'id': id, if (plate != null) 'plate': plate, 'status': status},
);
return AdminUnitModel.fromJson(res.data!);
}
Future<AdminUnitModel> updateUnit(
int id, {
String? plate,
String? status,
}) async {
final res = await _dio.patch<Map<String, dynamic>>(
'/admin/units/$id',
data: {
if (plate != null) 'plate': plate,
if (status != null) 'status': status,
},
);
return AdminUnitModel.fromJson(res.data!);
}
Future<void> deleteUnit(int id) async {
await _dio.delete<void>('/admin/units/$id');
}
// ── Drivers ─────────────────────────────────────────────────────────────────
Future<List<AdminDriverModel>> listDrivers() async {
final res = await _dio.get<List<dynamic>>('/admin/drivers');
return (res.data ?? [])
.whereType<Map>()
.map((e) => AdminDriverModel.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
Future<AdminDriverModel> createDriver({
required String userId,
int? unitId,
}) async {
final res = await _dio.post<Map<String, dynamic>>(
'/admin/drivers',
data: {'user_id': userId, if (unitId != null) 'unit_id': unitId},
);
return AdminDriverModel.fromJson(res.data!);
}
Future<AdminDriverModel> updateDriver(String id, {int? unitId}) async {
final res = await _dio.patch<Map<String, dynamic>>(
'/admin/drivers/$id',
data: {if (unitId != null) 'unit_id': unitId},
);
return AdminDriverModel.fromJson(res.data!);
}
Future<void> deleteDriver(String id) async {
await _dio.delete<void>('/admin/drivers/$id');
}
}

View File

@@ -0,0 +1,31 @@
class AdminDriverModel {
final String id;
final String userId;
final String? userName;
final String? userEmail;
final int? unitId;
final String? plate;
const AdminDriverModel({
required this.id,
required this.userId,
this.userName,
this.userEmail,
this.unitId,
this.plate,
});
factory AdminDriverModel.fromJson(Map<String, dynamic> json) =>
AdminDriverModel(
id: json['id'].toString(),
userId: json['user_id'].toString(),
userName: json['user_name'] as String?,
userEmail: json['user_email'] as String?,
unitId: (json['unit_id'] as num?)?.toInt(),
plate: json['plate'] as String?,
);
String get displayName => userName == null || userName!.trim().isEmpty
? (userEmail ?? userId)
: userName!;
}

View File

@@ -0,0 +1,29 @@
class AdminRouteModel {
final String id;
final String? name;
final int? truckId;
final String? turno;
final String status;
final int currentPositionId;
const AdminRouteModel({
required this.id,
this.name,
this.truckId,
this.turno,
this.status = 'pendiente',
this.currentPositionId = 1,
});
factory AdminRouteModel.fromJson(Map<String, dynamic> json) =>
AdminRouteModel(
id: json['id'].toString(),
name: json['name'] as String?,
truckId: (json['truck_id'] as num?)?.toInt(),
turno: json['turno'] as String?,
status: (json['status'] as String?) ?? 'pendiente',
currentPositionId: (json['current_position_id'] as num?)?.toInt() ?? 1,
);
String get displayName => name == null || name!.trim().isEmpty ? id : name!;
}

View File

@@ -0,0 +1,16 @@
class AdminUnitModel {
final int id;
final String? plate;
final String status;
const AdminUnitModel({required this.id, this.plate, this.status = 'active'});
factory AdminUnitModel.fromJson(Map<String, dynamic> json) => AdminUnitModel(
id: (json['id'] as num).toInt(),
plate: json['plate'] as String?,
status: (json['status'] as String?) ?? 'active',
);
String get displayPlate =>
plate == null || plate!.trim().isEmpty ? '#$id' : plate!;
}

View File

@@ -0,0 +1,34 @@
class AdminUserModel {
final String id;
final String? name;
final String? email;
final String? phone;
final String role;
const AdminUserModel({
required this.id,
this.name,
this.email,
this.phone,
this.role = 'citizen',
});
String get displayName =>
(name == null || name!.trim().isEmpty) ? (email ?? phone ?? id) : name!;
String get initials {
final source = displayName.trim();
if (source.isEmpty) return '?';
final parts = source.split(RegExp(r'\s+'));
if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase();
return (parts[0].substring(0, 1) + parts[1].substring(0, 1)).toUpperCase();
}
factory AdminUserModel.fromJson(Map<String, dynamic> json) => AdminUserModel(
id: json['id'].toString(),
name: json['name'] as String?,
email: json['email'] as String?,
phone: json['phone'] as String?,
role: (json['role'] as String?) ?? 'citizen',
);
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/admin_service.dart';
import '../models/admin_driver.dart';
import '../models/admin_route.dart';
import '../models/admin_unit.dart';
import '../models/admin_user.dart';
final adminUsersProvider = FutureProvider<List<AdminUserModel>>((ref) {
return ref.read(adminServiceProvider).listUsers();
});
final adminRoutesProvider = FutureProvider<List<AdminRouteModel>>((ref) {
return ref.read(adminServiceProvider).listRoutes();
});
final adminUnitsProvider = FutureProvider<List<AdminUnitModel>>((ref) {
return ref.read(adminServiceProvider).listUnits();
});
final adminDriversProvider = FutureProvider<List<AdminDriverModel>>((ref) {
return ref.read(adminServiceProvider).listDrivers();
});

View File

@@ -1,12 +1,13 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:dio/dio.dart';
import '../../core/models/auth_state.dart';
import '../../core/services/auth_controller.dart';
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 'widgets/video_mascot.dart';
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@@ -30,23 +31,18 @@ class _LoginPageState extends ConsumerState<LoginPage> {
) {
if (!mounted) return;
if (next is AsyncError) {
String errorMessage = 'Ocurrió un error inesperado';
final error = next.error;
String msg = 'Ocurrió un error inesperado';
if (error is DioException) {
if (error.response?.data != null && error.response?.data is Map) {
errorMessage =
error.response!.data['detail'] ?? 'Credenciales inválidas';
} else {
errorMessage = 'Error de conexión con el servidor';
}
msg = (error.response?.data is Map)
? error.response!.data['detail'] ?? 'Credenciales inválidas'
: 'Error de conexión con el servidor';
} else {
errorMessage = error.toString();
msg = error.toString();
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMessage),
content: Text(msg),
backgroundColor: AppTheme.danger,
behavior: SnackBarBehavior.floating,
),
@@ -72,171 +68,189 @@ class _LoginPageState extends ConsumerState<LoginPage> {
@override
Widget build(BuildContext context) {
final loading = ref.watch(authControllerProvider).isLoading;
final screenH = MediaQuery.of(context).size.height;
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
iconTheme: const IconThemeData(color: AppTheme.textPrimary),
title: const Text(
'Iniciar sesión',
style: TextStyle(color: AppTheme.textPrimary, fontSize: 16),
),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
body: Column(
children: [
// ── Cabecera verde con mascota ─────────────────────────────
_GreenHeader(height: screenH * 0.38),
// ── Encabezado ──────────────────────────────────────────
Row(
// ── Formulario ─────────────────────────────────────────────
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
),
child: const Icon(
Icons.delete_outline_rounded,
color: AppTheme.primary,
size: 26,
AppFormField(
label: 'Correo electrónico',
hint: 'tu@correo.com',
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
validator: (v) => (v == null || v.trim().isEmpty)
? 'Ingresa tu correo'
: null,
),
const SizedBox(height: 16),
AppFormField(
label: 'Contraseña',
hint: '••••••••',
controller: _passCtrl,
obscureText: _obscurePass,
validator: (v) => (v == null || v.length < 6)
? 'Mínimo 6 caracteres'
: null,
suffix: IconButton(
icon: Icon(
_obscurePass
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
size: 18,
color: AppTheme.textSecondary,
),
onPressed: () =>
setState(() => _obscurePass = !_obscurePass),
),
),
const SizedBox(width: 14),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Recolecta',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {},
style: TextButton.styleFrom(
foregroundColor: AppTheme.primary,
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
Text(
'Bienvenido de nuevo',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
child: const Text(
'¿Olvidaste tu contraseña?',
style: TextStyle(fontSize: 13),
),
],
),
),
const SizedBox(height: 24),
SizedBox(
height: 52,
child: ElevatedButton(
onPressed: loading ? null : _submit,
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 Text(
'Ingresar',
key: ValueKey('text'),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
),
const SizedBox(height: 32),
Center(
child: Wrap(
alignment: WrapAlignment.center,
children: [
const Text(
'¿No tienes cuenta? ',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
GestureDetector(
onTap: () => context.go('/register'),
child: const Text(
'Regístrate',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.primary,
),
),
),
],
),
),
],
),
),
),
),
],
),
);
}
}
const SizedBox(height: 32),
// ── Cabecera con gradiente verde y mascota ───────────────────────────────────
// ── Formulario ──────────────────────────────────────────
AppFormField(
label: 'Correo electrónico',
hint: 'tu@correo.com',
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
validator: (v) => (v == null || v.trim().isEmpty)
? 'Ingresa tu correo'
: null,
),
const SizedBox(height: 16),
AppFormField(
label: 'Contraseña',
hint: '••••••••',
controller: _passCtrl,
obscureText: _obscurePass,
validator: (v) => (v == null || v.length < 6)
? 'Mínimo 6 caracteres'
: null,
suffix: IconButton(
icon: Icon(
_obscurePass
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
size: 18,
color: AppTheme.textSecondary,
),
onPressed: () =>
setState(() => _obscurePass = !_obscurePass),
),
),
class _GreenHeader extends StatelessWidget {
final double height;
const _GreenHeader({required this.height});
const SizedBox(height: 10),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {},
style: TextButton.styleFrom(
foregroundColor: AppTheme.primary,
),
child: const Text(
'¿Olvidaste tu contraseña?',
style: TextStyle(fontSize: 13),
@override
Widget build(BuildContext context) {
return ClipPath(
clipper: _WaveClipper(),
child: Container(
height: height,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
stops: [0.0, 0.6, 1.0],
colors: [Color(0xFF0A4A38), Color(0xFF0F6E56), Color(0xFF1D9E75)],
),
),
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 8),
const VideoMascot(size: 108),
const SizedBox(height: 16),
const Text(
'RecolectApp',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -0.8,
),
),
),
const SizedBox(height: 24),
// ── Botón ───────────────────────────────────────────────
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: loading ? null : _submit,
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 Text('Ingresar', key: ValueKey('text')),
const SizedBox(height: 4),
Text(
'Bienvenido de nuevo',
style: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.82),
fontWeight: FontWeight.w400,
),
),
),
const SizedBox(height: 36),
// ── Crear cuenta ────────────────────────────────────────
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'¿No tienes cuenta? ',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
GestureDetector(
onTap: () => context.go('/register'),
child: const Text(
'Regístrate',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.primary,
),
),
),
],
),
),
],
const SizedBox(height: 28),
],
),
),
),
),
@@ -244,3 +258,29 @@ class _LoginPageState extends ConsumerState<LoginPage> {
);
}
}
class _WaveClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
final path = Path();
path.lineTo(0, size.height - 36);
path.quadraticBezierTo(
size.width * 0.25,
size.height,
size.width * 0.5,
size.height - 18,
);
path.quadraticBezierTo(
size.width * 0.75,
size.height - 36,
size.width,
size.height - 10,
);
path.lineTo(size.width, 0);
path.close();
return path;
}
@override
bool shouldReclip(_WaveClipper old) => false;
}

View File

@@ -40,6 +40,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
final _step1FormKey = GlobalKey<FormState>();
// Paso 1
final _nameCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
final _telefonoCtrl = TextEditingController();
final _passCtrl = TextEditingController();
@@ -91,6 +92,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
@override
void dispose() {
_pageController.dispose();
_nameCtrl.dispose();
_emailCtrl.dispose();
_telefonoCtrl.dispose();
_passCtrl.dispose();
@@ -201,77 +203,19 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
FocusScope.of(context).unfocus(); // Cierra el teclado
}
Future<void> _register() 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;
}
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;
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,
);
// 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
}
}
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) ?? '';
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');
}
void _onRegister() {
final auth = ref.read(authControllerProvider.notifier);
auth.register(
name: _nameCtrl.text,
email: _emailCtrl.text,
phone: _telefonoCtrl.text,
password: _passCtrl.text,
addressCalle: _calleCtrl.text,
addressColonia: _selectedColonia?.nombre,
addressLabel: _tipoInmueble,
addressLat: _selectedLocation?.latitude,
addressLng: _selectedLocation?.longitude,
);
}
@override
@@ -299,35 +243,431 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
children: [
_Step1(
formKey: _step1FormKey,
emailCtrl: _emailCtrl,
telefonoCtrl: _telefonoCtrl,
passCtrl: _passCtrl,
obscurePass: _obscurePass,
onTogglePass: () => setState(() => _obscurePass = !_obscurePass),
onNext: _nextPage,
_buildStep1(context),
_buildStep2(context, loading, coloniasList),
],
),
bottomNavigationBar: _buildBottomControls(context, loading),
);
}
Widget _buildStep1(BuildContext context) {
return Form(
key: _step1FormKey,
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 40),
children: [
const Text(
'Crea tu cuenta',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
_Step2(
mapController: _mapController,
cpCtrl: _cpCtrl,
calleCtrl: _calleCtrl,
selectedColonia: _selectedColonia,
selectedLocation: _selectedLocation,
tipoInmueble: _tipoInmueble,
whatsappNotif: _whatsappNotif,
loading: loading,
onTipoChanged: (v) => setState(() => _tipoInmueble = v),
onCPChanged: (v) => _validarCP(v, coloniasList),
onLocationChanged: _fetchStreetName,
onWhatsappChanged: (v) =>
setState(() => _whatsappNotif = v ?? false),
onRegister: _register,
const SizedBox(height: 8),
const Text(
'Ingresa tus datos para registrarte.',
style: TextStyle(fontSize: 15, color: AppTheme.textSecondary),
),
const SizedBox(height: 28),
AppFormField(
controller: _nameCtrl,
label: 'Nombre completo',
validator: (val) =>
val!.isEmpty ? 'Ingresa tu nombre completo' : null,
),
const SizedBox(height: 16),
AppFormField(
controller: _emailCtrl,
label: 'Correo electrónico',
hint: 'tu@correo.com',
keyboardType: TextInputType.emailAddress,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Ingresa tu correo';
final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+');
if (!emailRegex.hasMatch(v.trim()))
return 'Ingresa un correo válido';
return null;
},
),
const SizedBox(height: 14),
_PhoneField(controller: _telefonoCtrl),
const SizedBox(height: 14),
AppFormField(
label: 'Contraseña',
hint: '••••••••',
controller: _passCtrl,
obscureText: _obscurePass,
validator: (v) {
if (v == null || v.isEmpty) return 'Ingresa una contraseña';
if (v.length < 6) return 'Mínimo 6 caracteres';
return null;
},
suffix: IconButton(
icon: Icon(
_obscurePass
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
size: 18,
color: AppTheme.textSecondary,
),
onPressed: () => setState(() => _obscurePass = !_obscurePass),
),
),
],
),
);
}
Widget _buildStep2(
BuildContext context,
bool loading,
List<Colonia> coloniasList,
) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
AppFormCard(
icon: Icons.home_outlined,
title: 'Dirección de tu casa',
child: Column(
children: [
const Text(
'Tipo de inmueble',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
Row(
children: [
Expanded(
child: Material(
color: Colors.transparent,
child: RadioListTile<String>(
title: const Text(
'Casa',
style: TextStyle(fontSize: 14),
),
value: 'Casa',
groupValue: _tipoInmueble,
onChanged: (v) => setState(() => _tipoInmueble = v!),
),
),
),
Expanded(
child: Material(
color: Colors.transparent,
child: RadioListTile<String>(
title: const Text(
'Negocio',
style: TextStyle(fontSize: 14),
),
value: 'Negocio',
groupValue: _tipoInmueble,
onChanged: (v) => setState(() => _tipoInmueble = v!),
),
),
),
],
),
const SizedBox(height: 8),
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,
),
),
),
],
),
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: _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:
_selectedLocation ??
const LatLng(20.5222, -100.8123),
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: 16),
// ── Sección OCR (Privacidad por diseño) ──
AppFormCard(
icon: Icons.document_scanner_outlined,
title: 'Verificación de Domicilio',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Para prevenir abusos, requerimos validar tu dirección con un recibo (luz o agua). '
'Por privacidad, la imagen será borrada inmediatamente después de la lectura.',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
height: 1.4,
),
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
icon: const Icon(
Icons.upload_file,
color: AppTheme.primary,
),
label: const Text(
'Escanear recibo (OCR)',
style: TextStyle(color: AppTheme.primary),
),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Abriendo cámara... (Próximamente)'),
),
);
},
),
),
],
),
),
const SizedBox(height: 16),
// ── Sección WhatsApp ──
AppFormCard(
icon: Icons.chat_outlined,
title: 'Notificaciones Externas',
child: Column(
children: [
Material(
color: Colors.transparent,
child: CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
activeColor: AppTheme.primary,
value: _whatsappNotif,
onChanged: (v) =>
setState(() => _whatsappNotif = v ?? false),
title: const Text(
'Recibir alertas del camión vía WhatsApp (Próximamente)',
style: TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
),
),
),
),
],
),
),
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: loading ? null : _onRegister,
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(
'Registrarme',
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),
const SizedBox(height: 16),
const Center(
child: Text(
'Al registrarte aceptas los Términos de Servicio\ny la Política de Privacidad.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
height: 1.5,
),
),
),
],
),
);
}
Widget _buildBottomControls(BuildContext context, bool isLoading) {
return Container(
padding: const EdgeInsets.all(
20,
).copyWith(bottom: MediaQuery.of(context).padding.bottom + 20),
decoration: const BoxDecoration(
color: AppTheme.background,
border: Border(top: BorderSide(color: AppTheme.border, width: 0.5)),
),
child: _currentPage == 0
? SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _nextPage,
child: const Text('Continuar'),
),
)
: SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: isLoading ? null : _onRegister,
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Crear mi cuenta'),
),
),
);
}
}
// ── Indicador de pasos ────────────────────────────────────────────────────────
@@ -794,17 +1134,17 @@ class _Step2 extends StatelessWidget {
color: Colors.white,
),
)
: const Row(
: const FittedBox(
key: ValueKey('text'),
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check, size: 18),
SizedBox(width: 8),
Flexible(
child: Text('Registrarme',
overflow: TextOverflow.ellipsis),
),
],
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check, size: 18),
SizedBox(width: 8),
Text('Registrarme'),
],
),
),
),
),

View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
class VideoMascot extends StatelessWidget {
final double size;
const VideoMascot({super.key, this.size = 108});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.1),
),
clipBehavior: Clip.hardEdge,
// Cargamos el archivo como GIF
child: Image.asset(
'assets/animations/blink_saludo.gif',
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
// Plan B: si el archivo no existe o hay error, mostramos la huellita
return const Center(
child: Icon(Icons.pets, color: Colors.white, size: 48),
);
},
),
);
}
}

View File

@@ -0,0 +1,49 @@
// lib/features/eta/eta_provider.dart
// Riverpod AsyncNotifier: carga ETA al abrir la app y al recibir push FCM.
// No hace polling continuo.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recolecta_app/features/eta/eta_model.dart';
import 'package:recolecta_app/features/eta/eta_service.dart';
// ──────────────────────────────────────────
// Provider del addressId activo del ciudadano
// (se puebla en el provider de auth/session)
// ──────────────────────────────────────────
class ActiveAddressIdNotifier extends Notifier<String?> {
@override
String? build() => null;
}
final activeAddressIdProvider =
NotifierProvider<ActiveAddressIdNotifier, String?>(
ActiveAddressIdNotifier.new,
);
// ──────────────────────────────────────────
// AsyncNotifier principal de ETA
// ──────────────────────────────────────────
class EtaNotifier extends AsyncNotifier<EtaResponse> {
@override
Future<EtaResponse> build() async {
final addressId = ref.watch(activeAddressIdProvider);
if (addressId == null) {
throw Exception('No hay domicilio verificado');
}
return ref.read(etaServiceProvider).fetchEta(addressId);
}
/// Llamar desde la UI (botón refrescar) o desde el handler de FCM.
Future<void> refresh() async {
state = const AsyncLoading();
final addressId = ref.read(activeAddressIdProvider);
if (addressId == null) return;
state = await AsyncValue.guard(
() => ref.read(etaServiceProvider).fetchEta(addressId),
);
}
}
final etaProvider = AsyncNotifierProvider<EtaNotifier, EtaResponse>(
EtaNotifier.new,
);

View File

@@ -1,3 +1,14 @@
// lib/features/eta/eta_screen.dart
// Vista principal del ciudadano: ETA con mapa de domicilio y progreso de ruta.
// Fusiona eta_screen.dart (doc-1) + eta_screen_v2.dart (doc-2).
// Orden visual:
// 1. Hero card (estado + ventana horaria)
// 2. Domicilio registrado
// 3. ProgressSteps ← nuevo: justo debajo del mapa/dirección
// 4. PreventionBanner
// 5. FCM badge
// 6. Horario semanal
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -5,43 +16,13 @@ import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_widgets.dart';
import '../../core/network/api_client.dart';
import '../notifications/notification_service.dart';
import '../../shared/widgets/prevention_banner.dart';
import '../../shared/widgets/progress_steps.dart';
// ── Provider de ETA ───────────────────────────────────────────────────────────
final etaProvider = FutureProvider.autoDispose<_EtaResult>((ref) async {
final dio = ref.read(apiClientProvider);
final addressesResp = await dio.get<dynamic>('/addresses');
final raw = addressesResp.data;
List<dynamic> items = const [];
if (raw is List) {
items = raw;
} else if (raw is Map && raw['data'] is List) {
items = raw['data'] as List;
} else if (raw is Map && raw['addresses'] is List) {
items = raw['addresses'] as List;
}
if (items.isEmpty) {
return const _EtaResult.noAddress();
}
final addressId = items.first['id'] as String;
final etaResp = await dio.get<dynamic>(
'/eta',
queryParameters: {'address_id': addressId},
);
final data = etaResp.data as Map<String, dynamic>;
return _EtaResult(
mensaje: data['mensaje'] as String? ?? '',
status: data['status'] as String? ?? '',
direccion: items.first['calle'] as String? ?? '',
colonia: items.first['colonia'] as String? ?? '',
hasAddress: true,
);
});
// ─────────────────────────────────────────────────────────────────────────────
// Modelo de resultado ETA
// ─────────────────────────────────────────────────────────────────────────────
class _EtaResult {
final String mensaje;
final String status;
@@ -58,211 +39,446 @@ class _EtaResult {
});
const _EtaResult.noAddress()
: mensaje = '',
status = '',
direccion = '',
colonia = '',
hasAddress = false;
: mensaje = '',
status = '',
direccion = '',
colonia = '',
hasAddress = false;
// ── Utilidades derivadas ───────────────────────────────────────────────────
bool get isCompleted => status == 'completada';
bool get isNearby =>
mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo');
double get progreso {
if (mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo')) {
return 0.85;
}
if (mensaje.contains('finalizado')) return 1.0;
if (isNearby) return 0.85;
if (isCompleted) return 1.0;
return 0.35;
}
/// Índice para el widget ProgressSteps (0 = inicio, 1 = en ruta, 2 = cerca,
/// 3 = atendiendo, 4 = completado). Ajusta los valores según tu enum real.
int get stepIndex {
if (isCompleted) return 4;
if (isNearby) return 3;
if (status == 'en_ruta') return 2;
return 1;
}
String get etiquetaEstado {
if (status == 'completada') return 'Finalizado';
if (isCompleted) return 'Finalizado';
if (status == 'en_ruta') return 'En ruta';
return 'Pendiente';
}
}
// ── Pantalla ETA ──────────────────────────────────────────────────────────────
class EtaScreen extends ConsumerWidget {
// ─────────────────────────────────────────────────────────────────────────────
// Provider de ETA
// ─────────────────────────────────────────────────────────────────────────────
class _EtaNotifier extends AsyncNotifier<_EtaResult> {
@override
Future<_EtaResult> build() => _fetch();
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(_fetch);
}
Future<_EtaResult> _fetch() async {
final dio = ref.read(apiClientProvider);
final addressesResp = await dio.get<dynamic>('/addresses');
final raw = addressesResp.data;
List<dynamic> items = const [];
if (raw is List) {
items = raw;
} else if (raw is Map && raw['data'] is List) {
items = raw['data'] as List;
} else if (raw is Map && raw['addresses'] is List) {
items = raw['addresses'] as List;
}
if (items.isEmpty) return const _EtaResult.noAddress();
final addressId = items.first['id'] as String;
final etaResp = await dio.get<dynamic>(
'/eta',
queryParameters: {'address_id': addressId},
);
final data = etaResp.data as Map<String, dynamic>;
return _EtaResult(
mensaje: data['mensaje'] as String? ?? '',
status: data['status'] as String? ?? '',
direccion: items.first['calle'] as String? ?? '',
colonia: items.first['colonia'] as String? ?? '',
hasAddress: true,
);
}
}
final etaProvider = AsyncNotifierProvider.autoDispose<_EtaNotifier, _EtaResult>(
_EtaNotifier.new,
);
// Expone el routeId activo (se puebla desde el provider de sesión/domicilio)
class ActiveRouteIdNotifier extends Notifier<String?> {
@override
String? build() => null;
}
final activeRouteIdProvider = NotifierProvider<ActiveRouteIdNotifier, String?>(
ActiveRouteIdNotifier.new,
);
// ─────────────────────────────────────────────────────────────────────────────
// Pantalla principal
// ─────────────────────────────────────────────────────────────────────────────
class EtaScreen extends ConsumerStatefulWidget {
const EtaScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<EtaScreen> createState() => _EtaScreenState();
}
class _EtaScreenState extends ConsumerState<EtaScreen>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Refresca al recibir push FCM (RUTA_PROXIMITY, ROUTE_START, etc.)
NotificationService.onFcmMessage.addListener(_onPush);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
NotificationService.onFcmMessage.removeListener(_onPush);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.read(etaProvider.notifier).refresh();
}
}
void _onPush() => ref.read(etaProvider.notifier).refresh();
@override
Widget build(BuildContext context) {
final etaAsync = ref.watch(etaProvider);
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
title: const Text('Estado del camión'),
title: const Text('Mi recolección'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
icon: const Icon(Icons.refresh_rounded),
tooltip: 'Actualizar',
onPressed: () => ref.invalidate(etaProvider),
onPressed: () => ref.read(etaProvider.notifier).refresh(),
),
],
),
body: etaAsync.when(
loading: () => const _EtaLoading(),
error: (error, _) => _EtaError(
error: error.toString(),
onRetry: () => ref.invalidate(etaProvider),
error: (e, _) => _EtaError(
error: e.toString(),
onRetry: () => ref.read(etaProvider.notifier).refresh(),
),
data: (result) => result.hasAddress
? _EtaContent(result: result)
: _NoAddressState(
onAdd: () => context.go('/addresses/new'),
),
: _NoAddressState(onAdd: () => context.go('/addresses/new')),
),
);
}
}
// ── Contenido ETA ─────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// Contenido principal
// ─────────────────────────────────────────────────────────────────────────────
class _EtaContent extends StatelessWidget {
final _EtaResult result;
const _EtaContent({required this.result});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
// ── Tarjeta de estado principal ────────────────────────────────
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.primaryMid),
boxShadow: AppTheme.softShadow,
return RefreshIndicator(
onRefresh: () => ProviderScope.containerOf(
context,
).read(etaProvider.notifier).refresh(),
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
// ── 1. Hero card ────────────────────────────────────────────────
_EtaHeroCard(result: result),
const SizedBox(height: 16),
// ── 2. Domicilio registrado ─────────────────────────────────────
AppInfoRow(
icon: Icons.home_outlined,
label: 'Col. ${result.colonia}',
value: result.direccion.isEmpty ? 'Mi domicilio' : result.direccion,
trailing: AppStatusBadge.green('Activo'),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: 12),
// ── 3. Pasos de progreso (justo debajo del domicilio) ───────────
ProgressSteps(stepIndex: result.stepIndex),
const SizedBox(height: 12),
// ── 4. Banner de prevención ─────────────────────────────────────
const PreventionBanner(),
const SizedBox(height: 12),
// ── 5. Badge de suscripción FCM ─────────────────────────────────
const _FcmStatusBadge(),
const SizedBox(height: 16),
// ── 6. Horario semanal ──────────────────────────────────────────
AppSectionTitle(title: 'Horario del camión'),
_HorarioCard(),
const SizedBox(height: 24),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Hero card: estado + ventana horaria + barra de progreso
// ─────────────────────────────────────────────────────────────────────────────
class _EtaHeroCard extends StatelessWidget {
final _EtaResult result;
const _EtaHeroCard({required this.result});
Color _bgColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
if (result.isCompleted) return cs.surfaceContainerHighest;
if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50
return const Color(0xFFE1F5EE); // teal-50
}
Color _accentColor(BuildContext context) {
if (result.isCompleted) return Theme.of(context).colorScheme.outline;
if (result.isNearby) return const Color(0xFFBA7517); // amber-400
return const Color(0xFF1D9E75); // teal-400
}
@override
Widget build(BuildContext context) {
final accent = _accentColor(context);
final textTheme = Theme.of(context).textTheme;
return AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: _bgColor(context),
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: accent.withOpacity(0.3)),
boxShadow: AppTheme.softShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Cabecera: icono + etiqueta + punto vivo
Row(
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.delete_outline_rounded,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Camión recolector',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark,
),
),
const SizedBox(height: 2),
AppStatusBadge.green(result.etiquetaEstado),
],
),
),
_LiveDot(active: result.status == 'en_ruta'),
],
),
const SizedBox(height: 20),
// Mensaje ETA
Text(
result.mensaje,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark,
height: 1.3,
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: accent,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.delete_outline_rounded,
color: Colors.white,
size: 24,
),
),
const SizedBox(height: 16),
// Barra de progreso
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: result.progreso,
backgroundColor:
AppTheme.primaryMid.withValues(alpha: 0.35),
valueColor:
const AlwaysStoppedAnimation<Color>(AppTheme.primary),
minHeight: 8,
),
),
const SizedBox(height: 6),
const Row(
children: [
Text('Inicio de ruta',
style: TextStyle(
fontSize: 10, color: AppTheme.primaryDark)),
Spacer(),
Text('Tu casa',
style: TextStyle(
fontSize: 10, color: AppTheme.primaryDark)),
],
),
],
),
),
const SizedBox(height: 16),
// ── Domicilio registrado ───────────────────────────────────────
AppInfoRow(
icon: Icons.home_outlined,
label: 'Col. ${result.colonia}',
value: result.direccion.isEmpty ? 'Mi domicilio' : result.direccion,
trailing: AppStatusBadge.green('Activo'),
),
const SizedBox(height: 16),
// ── Aviso de privacidad ────────────────────────────────────────
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.blueLight,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.shield_outlined, color: AppTheme.blue, size: 18),
SizedBox(width: 10),
const SizedBox(width: 12),
Expanded(
child: Text(
'Tu ubicación exacta y la del camión no se comparten. Solo ves el estado de tu ruta.',
style: TextStyle(
fontSize: 12, color: AppTheme.blue, height: 1.5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Camión recolector',
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: accent,
),
),
const SizedBox(height: 2),
_StatusPill(result: result, accent: accent),
],
),
),
_LiveDot(active: result.status == 'en_ruta'),
],
),
const SizedBox(height: 16),
// Ventana horaria o mensaje de estado
Text(
result.mensaje.isNotEmpty
? result.mensaje
: _windowLabel(result.status),
style: textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
color: accent,
height: 1.2,
),
),
const SizedBox(height: 16),
// Barra de progreso
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: result.progreso,
backgroundColor: accent.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation<Color>(accent),
minHeight: 8,
),
),
const SizedBox(height: 6),
Row(
children: [
Text(
'Inicio de ruta',
style: TextStyle(fontSize: 10, color: accent.withOpacity(0.7)),
),
const Spacer(),
Text(
'Tu casa',
style: TextStyle(fontSize: 10, color: accent.withOpacity(0.7)),
),
],
),
],
),
);
}
String _windowLabel(String s) {
switch (s) {
case 'completada':
return 'Servicio finalizado';
case 'diferida':
return 'Servicio diferido';
case 'reasignada':
return 'Ruta reasignada';
default:
return 'En camino';
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Pill de estado con punto pulsante
// ─────────────────────────────────────────────────────────────────────────────
class _StatusPill extends StatelessWidget {
final _EtaResult result;
final Color accent;
const _StatusPill({required this.result, required this.accent});
@override
Widget build(BuildContext context) {
final label = result.isNearby
? 'Cerca de tu domicilio'
: result.isCompleted
? 'Servicio completado'
: 'En camino a tu sector';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!result.isCompleted) _PulsingDot(color: accent),
if (!result.isCompleted) const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: accent.withOpacity(0.15),
borderRadius: BorderRadius.circular(100),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: accent,
),
),
),
const SizedBox(height: 16),
// ── Horario estimado de la semana ──────────────────────────────
AppSectionTitle(title: 'Horario del camión'),
_HorarioCard(),
],
);
}
}
// ── Punto animado "en vivo" ───────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// Punto pulsante (animación de opacidad)
// ─────────────────────────────────────────────────────────────────────────────
class _PulsingDot extends StatefulWidget {
final Color color;
const _PulsingDot({required this.color});
@override
State<_PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<_PulsingDot>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat(reverse: true);
_anim = Tween<double>(begin: 1.0, end: 0.3).animate(_ctrl);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _anim,
builder: (_, __) => Opacity(
opacity: _anim.value,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: widget.color,
shape: BoxShape.circle,
),
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Punto vivo "EN VIVO" (escala + opacidad)
// ─────────────────────────────────────────────────────────────────────────────
class _LiveDot extends StatefulWidget {
final bool active;
const _LiveDot({required this.active});
@@ -292,34 +508,87 @@ class _LiveDotState extends State<_LiveDot>
@override
Widget build(BuildContext context) {
if (!widget.active) {
return const SizedBox.shrink();
}
if (!widget.active) return const SizedBox.shrink();
return AnimatedBuilder(
animation: _anim,
builder: (_, child) => Container(
builder: (_, __) => Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppTheme.primary
.withValues(alpha: 0.5 + _anim.value * 0.5),
color: AppTheme.primary.withValues(alpha: 0.5 + _anim.value * 0.5),
),
),
);
}
}
// ── Horario ───────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// Badge de suscripción FCM
// ─────────────────────────────────────────────────────────────────────────────
class _FcmStatusBadge extends ConsumerWidget {
const _FcmStatusBadge();
@override
Widget build(BuildContext context, WidgetRef ref) {
final routeId = ref.watch(activeRouteIdProvider);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF1D9E75),
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: Text.rich(
TextSpan(
children: [
const TextSpan(
text: 'Notificaciones activas ',
style: TextStyle(fontWeight: FontWeight.w500),
),
TextSpan(
text: routeId != null
? 'para topic_$routeId'
: '— suscribiendo...',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
style: const TextStyle(fontSize: 12),
),
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Horario semanal
// ─────────────────────────────────────────────────────────────────────────────
class _HorarioCard extends StatelessWidget {
final List<_HorarioDia> _dias = const [
_HorarioDia(dia: 'Lunes', hora: '8:00 10:00 a.m.', activo: true),
_HorarioDia(dia: 'Martes', hora: '8:00 10:00 a.m.', activo: true),
_HorarioDia(dia: 'Miércoles',hora: 'Sin servicio', activo: false),
_HorarioDia(dia: 'Jueves', hora: '8:00 10:00 a.m.', activo: true),
_HorarioDia(dia: 'Viernes', hora: '8:00 10:00 a.m.', activo: true),
_HorarioDia(dia: 'Sábado', hora: '9:00 11:00 a.m.', activo: true),
_HorarioDia(dia: 'Domingo', hora: 'Sin servicio', activo: false),
static const _dias = [
_HorarioDia(dia: 'Lunes', hora: '8:00 10:00 a.m.', activo: true),
_HorarioDia(dia: 'Martes', hora: '8:00 10:00 a.m.', activo: true),
_HorarioDia(dia: 'Miércoles', hora: 'Sin servicio', activo: false),
_HorarioDia(dia: 'Jueves', hora: '8:00 10:00 a.m.', activo: true),
_HorarioDia(dia: 'Viernes', hora: '8:00 10:00 a.m.', activo: true),
_HorarioDia(dia: 'Sábado', hora: '9:00 11:00 a.m.', activo: true),
_HorarioDia(dia: 'Domingo', hora: 'Sin servicio', activo: false),
];
@override
@@ -369,11 +638,16 @@ class _HorarioDia {
final String dia;
final String hora;
final bool activo;
const _HorarioDia(
{required this.dia, required this.hora, required this.activo});
const _HorarioDia({
required this.dia,
required this.hora,
required this.activo,
});
}
// ── Sin domicilio ─────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// Sin domicilio registrado
// ─────────────────────────────────────────────────────────────────────────────
class _NoAddressState extends StatelessWidget {
final VoidCallback onAdd;
const _NoAddressState({required this.onAdd});
@@ -393,23 +667,30 @@ class _NoAddressState extends StatelessWidget {
color: AppTheme.primaryLight,
shape: BoxShape.circle,
),
child: const Icon(Icons.home_outlined,
color: AppTheme.primary, size: 40),
child: const Icon(
Icons.home_outlined,
color: AppTheme.primary,
size: 40,
),
),
const SizedBox(height: 20),
const Text(
'Sin domicilio registrado',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary),
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
'Registra tu domicilio para\nrecibir el ETA de tu ruta.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13, color: AppTheme.textSecondary, height: 1.5),
fontSize: 13,
color: AppTheme.textSecondary,
height: 1.5,
),
),
const SizedBox(height: 24),
SizedBox(
@@ -426,7 +707,9 @@ class _NoAddressState extends StatelessWidget {
}
}
// ── Cargando ──────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// Cargando
// ─────────────────────────────────────────────────────────────────────────────
class _EtaLoading extends StatelessWidget {
const _EtaLoading();
@@ -434,19 +717,23 @@ class _EtaLoading extends StatelessWidget {
Widget build(BuildContext context) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: AppTheme.primary),
SizedBox(height: 16),
Text('Consultando estado del camión…',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 14)),
Text(
'Consultando estado del servicio...',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 14),
),
],
),
);
}
}
// ── Error ─────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// Error
// ─────────────────────────────────────────────────────────────────────────────
class _EtaError extends StatelessWidget {
final String error;
final VoidCallback onRetry;
@@ -460,25 +747,36 @@ class _EtaError extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.wifi_off_outlined,
color: AppTheme.textSecondary, size: 48),
const Icon(
Icons.wifi_off_rounded,
color: AppTheme.textSecondary,
size: 48,
),
const SizedBox(height: 16),
const Text('No se pudo obtener el estado',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary)),
const Text(
'No se pudo obtener el estado',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Text(error,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary)),
Text(
error,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 20),
SizedBox(
width: 160,
child: ElevatedButton(
child: FilledButton.icon(
onPressed: onRetry,
child: const Text('Reintentar'),
icon: const Icon(Icons.refresh_rounded),
label: const Text('Reintentar'),
),
),
],

View File

@@ -0,0 +1,25 @@
// lib/features/eta/eta_service.dart
// Llama a GET /eta?address_id=X via dio.
// La respuesta NUNCA contiene coordenadas (validado en backend + RLS).
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recolecta_app/core/network/api_client.dart';
import 'package:recolecta_app/features/eta/eta_model.dart';
class EtaService {
final Dio _dio;
EtaService(this._dio);
Future<EtaResponse> fetchEta(String addressId) async {
final response = await _dio.get<Map<String, dynamic>>(
'/eta',
queryParameters: {'address_id': addressId},
);
return EtaResponse.fromJson(response.data!);
}
}
final etaServiceProvider = Provider<EtaService>(
(ref) => EtaService(ref.read(apiClientProvider)),
);

View File

@@ -0,0 +1,130 @@
// lib/features/notifications/notification_service.dart
// Gestiona FCM: suscripción a topic, handlers foreground/background.
//
// Regla de privacidad: los payloads de push NUNCA contienen lat/lng.
// El backend solo manda title/body desde notificaciones.json.
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
// Canal Android de alta prioridad para alertas de proximidad
const _kChannelId = 'recolecta_alerts';
const _kChannelName = 'Alertas de recolección';
const _kChannelDesc = 'Notificaciones de llegada del camión recolector';
/// Notifier simple: la EtaScreen lo escucha para refrescar sin polling.
class _FcmMessageNotifier extends ChangeNotifier {
RemoteMessage? lastMessage;
void notify(RemoteMessage msg) {
lastMessage = msg;
notifyListeners();
}
}
// Handler de background/terminated (top-level, fuera de clase)
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// Solo loguear; la EtaScreen se refrescará cuando la app vuelva a foreground.
debugPrint('[FCM background] ${message.notification?.title}');
}
class NotificationService {
NotificationService._();
static final _messaging = FirebaseMessaging.instance;
static final _localNotifications = FlutterLocalNotificationsPlugin();
static final onFcmMessage = _FcmMessageNotifier();
/// Inicializar una sola vez en main.dart
static Future<void> initialize() async {
// Registrar handler de background
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
// Solicitar permisos (iOS + Android 13+)
final settings = await _messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
debugPrint('[FCM] Permission: ${settings.authorizationStatus}');
// Canal Android
const androidChannel = AndroidNotificationChannel(
_kChannelId,
_kChannelName,
description: _kChannelDesc,
importance: Importance.high,
);
await _localNotifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(androidChannel);
// Inicializar flutter_local_notifications
const initSettings = InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
);
await _localNotifications.initialize(initSettings);
// Foreground: mostrar notificación local + notificar EtaScreen
FirebaseMessaging.onMessage.listen((message) {
_showLocalNotification(message);
onFcmMessage.notify(message);
});
// Tap en notificación cuando la app estaba en background
FirebaseMessaging.onMessageOpenedApp.listen((message) {
onFcmMessage.notify(message);
});
// Verificar si la app abrió desde una notificación (terminated)
final initial = await _messaging.getInitialMessage();
if (initial != null) {
onFcmMessage.notify(initial);
}
}
/// Suscribir al topic de la ruta del ciudadano.
/// Llamar justo después de que verified = true en el domicilio.
static Future<void> subscribeToRoute(String routeId) async {
final topic = 'topic_$routeId';
await _messaging.subscribeToTopic(topic);
debugPrint('[FCM] Suscrito a $topic');
}
/// Desuscribir (al cambiar de domicilio / colonia)
static Future<void> unsubscribeFromRoute(String routeId) async {
final topic = 'topic_$routeId';
await _messaging.unsubscribeFromTopic(topic);
debugPrint('[FCM] Desuscrito de $topic');
}
static Future<void> _showLocalNotification(RemoteMessage message) async {
final notification = message.notification;
if (notification == null) return;
// El payload del backend es solo title+body; NUNCA contiene coordenadas.
await _localNotifications.show(
notification.hashCode,
notification.title,
notification.body,
NotificationDetails(
android: AndroidNotificationDetails(
_kChannelId,
_kChannelName,
channelDescription: _kChannelDesc,
importance: Importance.high,
priority: Priority.high,
// Sin ningún campo de mapa o ubicación
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
);
}
}

View File

@@ -1,20 +1,378 @@
// lib/features/notifications/notifications_screen.dart
// Historial de notificaciones FCM recibidas.
// Los items se almacenan en memoria (no en BD) — solo mensajes del topic propio.
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import '../../core/theme/app_theme.dart';
class NotificationsScreen extends StatelessWidget {
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'notification_service.dart';
import '../eta/eta_screen.dart'; // activeRouteIdProvider
// ──────────────────────────────────────────
// Modelo local de item de notificación
// ──────────────────────────────────────────
enum FcmEventType { routeStart, truckProximity, routeCompleted, reassignment, unknown }
FcmEventType _eventTypeFromMessage(RemoteMessage msg) {
final type = msg.data['event'] as String?;
switch (type) {
case 'ROUTE_START':
return FcmEventType.routeStart;
case 'TRUCK_PROXIMITY':
return FcmEventType.truckProximity;
case 'ROUTE_COMPLETED':
return FcmEventType.routeCompleted;
case 'reasignacion':
case 'retraso':
return FcmEventType.reassignment;
default:
return FcmEventType.unknown;
}
}
class NotificationItem {
final String title;
final String body;
final FcmEventType type;
final DateTime receivedAt;
const NotificationItem({
required this.title,
required this.body,
required this.type,
required this.receivedAt,
});
}
// ──────────────────────────────────────────
// Provider: lista de notificaciones en memoria
// ──────────────────────────────────────────
final notificationsListProvider =
NotifierProvider<NotificationsNotifier, List<NotificationItem>>(
NotificationsNotifier.new,
);
class NotificationsNotifier extends Notifier<List<NotificationItem>> {
@override
List<NotificationItem> build() {
// Escuchar mensajes FCM en foreground
NotificationService.onFcmMessage.addListener(_onMessage);
ref.onDispose(
() => NotificationService.onFcmMessage.removeListener(_onMessage),
);
return [];
}
void _onMessage() {
final msg = NotificationService.onFcmMessage.lastMessage;
if (msg == null) return;
final item = NotificationItem(
title: msg.notification?.title ?? 'Recolección',
body: msg.notification?.body ?? '',
type: _eventTypeFromMessage(msg),
receivedAt: DateTime.now(),
);
state = [item, ...state];
}
void clearAll() => state = [];
}
// ──────────────────────────────────────────
// Pantalla de notificaciones
// ──────────────────────────────────────────
class NotificationsScreen extends ConsumerWidget {
const NotificationsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = ref.watch(notificationsListProvider);
final routeId = ref.watch(activeRouteIdProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Notificaciones'),
actions: [
if (items.isNotEmpty)
TextButton(
onPressed: () =>
ref.read(notificationsListProvider.notifier).clearAll(),
child: const Text('Limpiar'),
),
],
),
body: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
// Badge de suscripción FCM
_FcmTopicBadge(routeId: routeId),
const SizedBox(height: 12),
// Aviso de privacidad
_PrivacyNote(),
const SizedBox(height: 16),
if (items.isEmpty)
const _EmptyState()
else ...[
const _SectionLabel(label: 'Recientes'),
...items.map((item) => _NotificationCard(item: item)),
],
],
),
);
}
}
// ──────────────────────────────────────────
// Widgets auxiliares
// ──────────────────────────────────────────
class _FcmTopicBadge extends StatelessWidget {
final String? routeId;
const _FcmTopicBadge({required this.routeId});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(title: const Text('Avisos y Alertas')),
body: const Center(
child: Text(
'Bandeja de entrada de FCM',
style: TextStyle(color: AppTheme.textSecondary),
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF1D9E75),
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: Text.rich(
TextSpan(children: [
const TextSpan(
text: 'Suscrito a ',
style: TextStyle(fontSize: 12),
),
TextSpan(
text: routeId != null
? 'topic_$routeId'
: 'topic pendiente',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const TextSpan(
text: ' · Solo recibes eventos de tu ruta',
style: TextStyle(fontSize: 12),
),
]),
),
),
],
),
);
}
}
class _PrivacyNote extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFAEEDA), // amber-50
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFFAC775)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.info_outline_rounded,
size: 18, color: Color(0xFFBA7517)),
const SizedBox(width: 8),
Expanded(
child: Text(
'Los mensajes no revelan la ubicación del camión. Solo se muestra el tiempo estimado de llegada.',
style: const TextStyle(fontSize: 12, color: Color(0xFF633806)),
maxLines: 3,
),
),
],
),
);
}
}
class _SectionLabel extends StatelessWidget {
final String label;
const _SectionLabel({required this.label});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
label.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.8,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
}
class _NotificationCard extends StatelessWidget {
final NotificationItem item;
const _NotificationCard({required this.item});
IconData get _icon {
switch (item.type) {
case FcmEventType.routeStart:
return Icons.arrow_forward_rounded;
case FcmEventType.truckProximity:
return Icons.local_shipping_rounded;
case FcmEventType.routeCompleted:
return Icons.check_circle_outline_rounded;
case FcmEventType.reassignment:
return Icons.swap_horiz_rounded;
default:
return Icons.notifications_outlined;
}
}
Color _accentColor() {
switch (item.type) {
case FcmEventType.routeStart:
return const Color(0xFF1D9E75);
case FcmEventType.truckProximity:
return const Color(0xFFBA7517);
case FcmEventType.routeCompleted:
return Colors.grey;
case FcmEventType.reassignment:
return const Color(0xFF378ADD);
default:
return Colors.grey;
}
}
String _relativeTime() {
final diff = DateTime.now().difference(item.receivedAt);
if (diff.inMinutes < 1) return 'Ahora mismo';
if (diff.inMinutes < 60) return 'Hace ${diff.inMinutes} min';
if (diff.inHours < 24) return 'Hace ${diff.inHours} h';
return 'Ayer';
}
@override
Widget build(BuildContext context) {
final accent = _accentColor();
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(10),
border: Border(
left: BorderSide(color: accent, width: 3),
top: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
right: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
bottom: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: accent.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(_icon, size: 16, color: accent),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
item.body,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1.4,
),
),
const SizedBox(height: 4),
Text(
_relativeTime(),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.notifications_none_rounded,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
'Sin notificaciones aún',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'Recibirás un aviso cuando el camión esté cerca.',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -0,0 +1,312 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with TickerProviderStateMixin {
late final AnimationController _logoCtrl;
late final AnimationController _textCtrl;
late final AnimationController _bubblesCtrl;
late final Animation<double> _logoScale;
late final Animation<double> _logoOpacity;
late final Animation<Offset> _textSlide;
late final Animation<double> _textOpacity;
late final Animation<double> _subtitleOpacity;
@override
void initState() {
super.initState();
_logoCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_textCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 700),
);
_bubblesCtrl = AnimationController(
vsync: this,
duration: const Duration(seconds: 6),
)..repeat();
_logoScale = Tween<double>(begin: 0.2, end: 1.0).animate(
CurvedAnimation(parent: _logoCtrl, curve: Curves.elasticOut),
);
_logoOpacity = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _logoCtrl,
curve: const Interval(0.0, 0.4, curve: Curves.easeIn),
),
);
_textSlide = Tween<Offset>(
begin: const Offset(0, 0.4),
end: Offset.zero,
).animate(CurvedAnimation(parent: _textCtrl, curve: Curves.easeOut));
_textOpacity = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _textCtrl,
curve: const Interval(0.0, 0.6, curve: Curves.easeIn),
),
);
_subtitleOpacity = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _textCtrl,
curve: const Interval(0.4, 1.0, curve: Curves.easeIn),
),
);
_runSequence();
}
Future<void> _runSequence() async {
await Future.delayed(const Duration(milliseconds: 400));
if (!mounted) return;
_logoCtrl.forward();
await Future.delayed(const Duration(milliseconds: 600));
if (!mounted) return;
_textCtrl.forward();
await Future.delayed(const Duration(milliseconds: 2200));
if (mounted) context.go('/login');
}
@override
void dispose() {
_logoCtrl.dispose();
_textCtrl.dispose();
_bubblesCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
stops: [0.0, 0.55, 1.0],
colors: [
Color(0xFF0A4A38),
Color(0xFF0F6E56),
Color(0xFF1D9E75),
],
),
),
child: Stack(
children: [
// Burbujas decorativas animadas
AnimatedBuilder(
animation: _bubblesCtrl,
builder: (_, _) => CustomPaint(
painter: _BubblesPainter(_bubblesCtrl.value),
size: Size.infinite,
),
),
SafeArea(
child: Column(
children: [
const Spacer(flex: 3),
// Logo central
ScaleTransition(
scale: _logoScale,
child: FadeTransition(
opacity: _logoOpacity,
child: Container(
width: 118,
height: 118,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(34),
border: Border.all(
color: Colors.white.withValues(alpha: 0.35),
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: const Icon(
Icons.recycling_rounded,
size: 64,
color: Colors.white,
),
),
),
),
const SizedBox(height: 36),
// Nombre de la app
SlideTransition(
position: _textSlide,
child: FadeTransition(
opacity: _textOpacity,
child: const Text(
'RecolectApp',
style: TextStyle(
fontSize: 38,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -1.0,
height: 1.1,
),
),
),
),
const SizedBox(height: 10),
// Subtítulo
FadeTransition(
opacity: _subtitleOpacity,
child: Text(
'Sistema de Recolección Inteligente',
style: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.8),
letterSpacing: 0.4,
fontWeight: FontWeight.w400,
),
),
),
const Spacer(flex: 3),
// Indicador de carga
FadeTransition(
opacity: _subtitleOpacity,
child: const Padding(
padding: EdgeInsets.only(bottom: 52),
child: _DotsLoader(),
),
),
],
),
),
],
),
),
);
}
}
// ── Loader de tres puntos ────────────────────────────────────────────────────
class _DotsLoader extends StatefulWidget {
const _DotsLoader();
@override
State<_DotsLoader> createState() => _DotsLoaderState();
}
class _DotsLoaderState extends State<_DotsLoader>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
)..repeat();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctrl,
builder: (_, _) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (i) {
final phase = (_ctrl.value - i * 0.2).clamp(0.0, 1.0);
final wave = (sin(phase * pi)).clamp(0.0, 1.0);
return AnimatedContainer(
duration: Duration.zero,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.35 + 0.65 * wave),
),
);
}),
);
},
);
}
}
// ── Burbujas decorativas de fondo ────────────────────────────────────────────
class _BubblesPainter extends CustomPainter {
final double t;
_BubblesPainter(this.t);
static const _bubbles = [
_Bubble(0.08, 0.15, 60, 0.0),
_Bubble(0.85, 0.08, 90, 0.2),
_Bubble(0.72, 0.78, 50, 0.5),
_Bubble(0.15, 0.85, 70, 0.7),
_Bubble(0.50, 0.05, 40, 0.35),
_Bubble(0.92, 0.55, 35, 0.9),
];
@override
void paint(Canvas canvas, Size size) {
for (final b in _bubbles) {
final phase = (t + b.phase) % 1.0;
final floatY = sin(phase * 2 * pi) * 12;
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.04 + 0.03 * sin(phase * pi))
..style = PaintingStyle.fill;
canvas.drawCircle(
Offset(b.xFrac * size.width, b.yFrac * size.height + floatY),
b.radius,
paint,
);
final strokePaint = Paint()
..color = Colors.white.withValues(alpha: 0.07)
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
canvas.drawCircle(
Offset(b.xFrac * size.width, b.yFrac * size.height + floatY),
b.radius,
strokePaint,
);
}
}
@override
bool shouldRepaint(_BubblesPainter old) => old.t != t;
}
class _Bubble {
final double xFrac, yFrac, radius, phase;
const _Bubble(this.xFrac, this.yFrac, this.radius, this.phase);
}