simulacion de estados y flujo de notificacion, modificacion de estilos en todas las vistas

This commit is contained in:
shinra32
2026-05-23 07:08:49 -06:00
parent ca076607c7
commit 92f570294a
43 changed files with 4335 additions and 2035 deletions

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -11,6 +12,7 @@ import '../../core/network/api_client.dart';
import '../notifications/notification_service.dart';
import '../../shared/widgets/prevention_banner.dart';
import '../../shared/widgets/progress_steps.dart';
import '../separation_guide/ai_pet_chat_screen.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Modelo de resultado ETA
@@ -75,11 +77,19 @@ class _EtaResult {
// Provider de ETA
// ─────────────────────────────────────────────────────────────────────────────
class _EtaNotifier extends AsyncNotifier<_EtaResult> {
Timer? _timer;
@override
Future<_EtaResult> build() => _fetch();
Future<_EtaResult> build() {
// Consulta silenciosa cada 10 segundos para ver el avance en tiempo real
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh());
ref.onDispose(() => _timer?.cancel());
return _fetch();
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(_fetch);
}
@@ -127,6 +137,20 @@ final etaProvider = AsyncNotifierProvider<_EtaNotifier, _EtaResult>(
class ActiveRouteIdNotifier extends Notifier<String?> {
@override
String? build() => null;
void set(String? value) {
debugPrint('📡 [FCM] Evaluando suscripción a la ruta: $value');
if (state != value) {
final oldRoute = state;
state = value;
if (oldRoute != null) NotificationService.unsubscribeFromRoute(oldRoute);
if (value != null) {
NotificationService.subscribeToRoute(
value,
).catchError((e) => debugPrint('❌ Error FCM: $e'));
}
}
}
}
final activeRouteIdProvider = NotifierProvider<ActiveRouteIdNotifier, String?>(
@@ -149,7 +173,7 @@ class _CitizenHomeScreenState extends ConsumerState<CitizenHomeScreen>
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Refresca al recibir push FCM (RUTA_PROXIMITY, ROUTE_START, etc.)
// Refresca al recibir push FCM (RUTA_PROXIMITY, ROUTE_START, etc.)F
NotificationService.onFcmMessage.addListener(_onPush);
}
@@ -242,11 +266,15 @@ class _EtaContent extends StatelessWidget {
const PreventionBanner(),
const SizedBox(height: 12),
// ── 5. Badge de suscripción FCM ─────────────────────────────────
// ── 5. Banner del Chat IA (Eco) ─────────────────────────────────
const _EcoChatBanner(),
const SizedBox(height: 12),
// ── 6. Badge de suscripción FCM ─────────────────────────────────
const _FcmStatusBadge(),
const SizedBox(height: 16),
// ── 6. Horario semanal ──────────────────────────────────────────
// ── 7. Horario semanal ──────────────────────────────────────────
AppSectionTitle(title: 'Horario del camión'),
_HorarioCard(),
const SizedBox(height: 24),
@@ -256,6 +284,71 @@ class _EtaContent extends StatelessWidget {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Banner de Eco (Chat IA)
// ─────────────────────────────────────────────────────────────────────────────
class _EcoChatBanner extends StatelessWidget {
const _EcoChatBanner();
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AiPetChatScreen()),
);
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.primaryDark,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
boxShadow: AppTheme.softShadow,
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.white24,
shape: BoxShape.circle,
),
child: const Icon(
Icons.delete_outline,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'¿Dudas sobre reciclaje?',
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 4),
Text(
'Pregúntale a Eco, tu asistente inteligente',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
),
const Icon(Icons.chevron_right, color: Colors.white),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Mapa de ubicación del domicilio (no interactivo)
// ─────────────────────────────────────────────────────────────────────────────
@@ -322,13 +415,13 @@ class _EtaHeroCard extends StatelessWidget {
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
return const Color(0xFFE8D5DB); // rosa claro institucional
}
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
if (result.isNearby) return const Color(0xFFC8A36A); // beige dorado
return const Color(0xFF9B1B4A); // vino principal
}
@override
@@ -606,7 +699,7 @@ class _FcmStatusBadge extends ConsumerWidget {
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF1D9E75),
color: Color(0xFF1E7A46),
shape: BoxShape.circle,
),
),

View File

@@ -1,13 +1,95 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_widgets.dart';
import '../../shared/widgets/eco_floating_button.dart';
import '../notifications/notification_service.dart';
import '../alerts/alerts_provider.dart';
class CitizenShell extends StatelessWidget {
class CitizenShell extends ConsumerStatefulWidget {
const CitizenShell({super.key, required this.child});
final Widget child;
@override
ConsumerState<CitizenShell> createState() => _CitizenShellState();
}
class _CitizenShellState extends ConsumerState<CitizenShell> {
@override
void initState() {
super.initState();
NotificationService.onFcmMessage.addListener(_onGlobalPush);
}
@override
void dispose() {
NotificationService.onFcmMessage.removeListener(_onGlobalPush);
super.dispose();
}
void _onGlobalPush() {
final msg = NotificationService.onFcmMessage.lastMessage;
if (msg?.notification != null) {
final title = msg!.notification!.title ?? 'Nueva Alerta';
final body = msg.notification!.body ?? '';
// Guardamos la alerta en el historial
ref.read(alertsProvider.notifier).addAlert(title, body);
// Mostramos un globo de notificación amigable dentro de la app
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: AppTheme.primaryDark,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 5),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.notifications_active,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
],
),
const SizedBox(height: 4),
Text(
body,
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
action: SnackBarAction(
label: 'VER',
textColor: AppTheme.primaryLight,
onPressed: () => context.go('/alerts'),
),
),
);
}
}
}
int _currentIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
if (location.startsWith('/alerts')) return 1;
@@ -32,7 +114,7 @@ class CitizenShell extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
body: widget.child,
floatingActionButton: const EcoFloatingButton(),
bottomNavigationBar: AppBottomNav(
currentIndex: _currentIndex(context),

View File

@@ -75,95 +75,170 @@ class _MyHouseScreenState extends State<MyHouseScreen> {
if (_casa == null) {
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(title: const Text('Mi casa')),
body: const Center(child: Text('No tienes un domicilio registrado.')),
body: Column(
children: [
_buildPageHeader(context, showEdit: false),
const Expanded(
child: Center(
child: Text(
'No tienes un domicilio registrado.',
style: TextStyle(fontSize: 15, color: AppTheme.textSecondary),
),
),
),
_buildAddressButton(context),
],
),
);
}
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
title: const Text('Mi casa'),
actions: [
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => _mostrarEditarDireccion(context),
tooltip: 'Editar dirección',
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: _buildPageHeader(context, showEdit: true)),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
sliver: SliverList(
delegate: SliverChildListDelegate([
const AppSectionTitle(title: 'Domicilio registrado'),
_CasaCard(casa: _casa!),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Mapa del Sector (Restringido)'),
_MapaColoniaRestringido(
colonia: _casa!.colonia,
lat: _casa!.lat,
lng: _casa!.lng,
),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Radio de alerta'),
_RadioAlertaCard(
radioActual: _casa!.radioAlertaMetros,
onChanged: (v) => setState(
() => _casa = _casa!.copyWith(radioAlertaMetros: v),
),
),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Notificaciones'),
_NotificacionesCard(
casa: _casa!,
onAlertaCercanaChanged: (v) =>
setState(() => _casa = _casa!.copyWith(alertaCercana: v)),
onAlertaMediaChanged: (v) =>
setState(() => _casa = _casa!.copyWith(alertaMedia: v)),
onRecordatorioChanged: (v) => setState(
() => _casa = _casa!.copyWith(recordatorioDiario: v),
),
),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Horario del camión'),
_HorarioCard(),
const SizedBox(height: 24),
]),
),
),
SliverToBoxAdapter(child: _buildAddressButton(context)),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
);
}
Widget _buildPageHeader(BuildContext context, {required bool showEdit}) {
return Container(
padding: EdgeInsets.fromLTRB(
20,
MediaQuery.of(context).padding.top + 12,
20,
24,
),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(28),
bottomRight: Radius.circular(28),
),
),
child: Row(
children: [
_CasaCard(casa: _casa!),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Mapa del Sector (Restringido)'),
_MapaColoniaRestringido(
colonia: _casa!.colonia,
lat: _casa!.lat,
lng: _casa!.lng,
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.home_outlined,
color: Colors.white,
size: 24,
),
),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Radio de alerta'),
_RadioAlertaCard(
radioActual: _casa!.radioAlertaMetros,
onChanged: (v) =>
setState(() => _casa = _casa!.copyWith(radioAlertaMetros: v)),
const SizedBox(width: 14),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Mi Casa',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
SizedBox(height: 2),
Text(
'Domicilio registrado',
style: TextStyle(fontSize: 13, color: Colors.white70),
),
],
),
),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Notificaciones'),
_NotificacionesCard(
casa: _casa!,
onAlertaCercanaChanged: (v) =>
setState(() => _casa = _casa!.copyWith(alertaCercana: v)),
onAlertaMediaChanged: (v) =>
setState(() => _casa = _casa!.copyWith(alertaMedia: v)),
onRecordatorioChanged: (v) =>
setState(() => _casa = _casa!.copyWith(recordatorioDiario: v)),
),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Horario del camión'),
_HorarioCard(),
const SizedBox(height: 16),
GestureDetector(
onTap: () async {
if (showEdit)
GestureDetector(
onTap: () => _mostrarEditarDireccion(context),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.edit_outlined,
color: Colors.white,
size: 18,
),
),
),
],
),
);
}
Widget _buildAddressButton(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 96),
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () 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(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.primaryMid),
boxShadow: AppTheme.softShadow,
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_home_outlined,
color: AppTheme.primary,
size: 20,
),
SizedBox(width: 8),
Text(
'Agregar otra dirección',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.primary,
),
),
],
),
),
icon: const Icon(Icons.add_home_outlined, size: 20),
label: const Text('Agregar otra dirección'),
),
const SizedBox(height: 24),
],
),
),
);
}
@@ -191,64 +266,82 @@ class _CasaCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.primaryMid, width: 0.8),
border: Border.all(color: AppTheme.border, width: 0.5),
boxShadow: AppTheme.softShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
child: ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.radiusLg - 0.5),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.home_outlined,
color: AppTheme.primary,
size: 24,
),
),
const SizedBox(width: 12),
Container(width: 3, color: AppTheme.primary),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
casa.alias,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.home_outlined,
color: AppTheme.primary,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
casa.alias,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
AppStatusBadge.green(
casa.activa ? 'Activa' : 'Inactiva',
),
],
),
),
],
),
),
const SizedBox(height: 4),
AppStatusBadge.green(casa.activa ? 'Activa' : 'Inactiva'),
],
const SizedBox(height: 14),
const Divider(color: AppTheme.borderLight),
const SizedBox(height: 10),
_DetailRow(
icon: Icons.location_on_outlined,
text: casa.direccionCompleta,
),
const SizedBox(height: 8),
_DetailRow(
icon: Icons.radar_outlined,
text:
'Alerta a ${casa.radioAlertaMetros} m de distancia',
),
],
),
),
),
],
),
const SizedBox(height: 14),
const Divider(color: AppTheme.borderLight),
const SizedBox(height: 10),
_DetailRow(
icon: Icons.location_on_outlined,
text: casa.direccionCompleta,
),
const SizedBox(height: 8),
_DetailRow(
icon: Icons.radar_outlined,
text: 'Alerta a ${casa.radioAlertaMetros} m de distancia',
),
],
),
),
);
}
@@ -263,14 +356,16 @@ class _MapaColoniaRestringido extends StatelessWidget {
@override
Widget build(BuildContext context) {
final center = kColoniaCenter(colonia);
final center =
kColoniasCoordinates[colonia] ?? const LatLng(20.5222, -100.8123);
final pin = (lat != null && lng != null) ? LatLng(lat!, lng!) : center;
return Container(
height: 200,
height: 220,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.border, width: 1),
boxShadow: AppTheme.softShadow,
),
clipBehavior: Clip.hardEdge,
child: FlutterMap(

View File

@@ -1,18 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_widgets.dart';
import '../notifications/notification_service.dart';
import '../alerts/alerts_provider.dart';
import 'citizen_home_screen.dart';
import '../alerts/alerts_screen.dart';
import 'house_screen.dart';
import '../profile/profile_screen.dart';
class MainShell extends StatefulWidget {
class MainShell extends ConsumerStatefulWidget {
const MainShell({super.key});
@override
State<MainShell> createState() => _MainShellState();
ConsumerState<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
class _MainShellState extends ConsumerState<MainShell> {
int _currentIndex = 0;
static const List<Widget> _screens = [
@@ -22,6 +27,77 @@ class _MainShellState extends State<MainShell> {
ProfileScreen(),
];
@override
void initState() {
super.initState();
NotificationService.onFcmMessage.addListener(_onGlobalPush);
}
@override
void dispose() {
NotificationService.onFcmMessage.removeListener(_onGlobalPush);
super.dispose();
}
void _onGlobalPush() {
final msg = NotificationService.onFcmMessage.lastMessage;
if (msg?.notification != null) {
final title = msg!.notification!.title ?? 'Nueva Alerta';
final body = msg.notification!.body ?? '';
ref.read(alertsProvider.notifier).addAlert(title, body);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: AppTheme.primaryDark,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 5),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.notifications_active,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
],
),
const SizedBox(height: 4),
Text(
body,
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
action: SnackBarAction(
label: 'VER',
textColor: AppTheme.primaryLight,
onPressed: () => setState(() => _currentIndex = 1),
),
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(