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

Co-authored-by: Azareth-Tr <Azareth-Tr@users.noreply.github.com>
Co-authored-by: eddgranados12 <eddgranados12@users.noreply.github.com>

vistas de mockup actualizaco
This commit is contained in:
shinra32
2026-05-22 23:50:10 -06:00
parent c91b6e2091
commit fd7b0c132c
44 changed files with 4108 additions and 140 deletions

144
views_v2/app.dart Normal file
View File

@@ -0,0 +1,144 @@
// lib/app.dart
// Root de la app: go_router + bottom navigation de 4 tabs (P3).
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'features/eta/eta_screen.dart';
import 'features/notifications/notifications_screen.dart';
import 'features/feedback/feedback_screen.dart';
import 'features/quiz/quiz_screen.dart';
// ──────────────────────────────────────────
// Router
// ──────────────────────────────────────────
final _router = GoRouter(
initialLocation: '/eta',
routes: [
ShellRoute(
builder: (context, state, child) => _ScaffoldWithNav(child: child),
routes: [
GoRoute(
path: '/eta',
pageBuilder: (context, state) => const NoTransitionPage(
child: EtaScreen(),
),
),
GoRoute(
path: '/notifications',
pageBuilder: (context, state) => const NoTransitionPage(
child: NotificationsScreen(),
),
),
GoRoute(
path: '/feedback',
pageBuilder: (context, state) => const NoTransitionPage(
child: FeedbackScreen(),
),
),
GoRoute(
path: '/quiz',
pageBuilder: (context, state) => const NoTransitionPage(
child: QuizScreen(),
),
),
],
),
],
);
// ──────────────────────────────────────────
// App widget
// ──────────────────────────────────────────
class RecolectaApp extends StatelessWidget {
const RecolectaApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Recolecta',
debugShowCheckedModeBanner: false,
theme: _buildTheme(Brightness.light),
darkTheme: _buildTheme(Brightness.dark),
themeMode: ThemeMode.system,
routerConfig: _router,
);
}
ThemeData _buildTheme(Brightness brightness) {
final isDark = brightness == Brightness.dark;
return ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1D9E75), // teal-400
brightness: brightness,
),
useMaterial3: true,
appBarTheme: AppBarTheme(
backgroundColor:
isDark ? const Color(0xFF1A1A1A) : Colors.white,
foregroundColor: isDark ? Colors.white : Colors.black87,
elevation: 0,
scrolledUnderElevation: 1,
titleTextStyle: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.black87,
),
),
);
}
}
// ──────────────────────────────────────────
// Bottom navigation shell
// ──────────────────────────────────────────
class _ScaffoldWithNav extends StatelessWidget {
final Widget child;
const _ScaffoldWithNav({required this.child});
static const _tabs = [
_TabItem(path: '/eta', icon: Icons.schedule_rounded, label: 'ETA'),
_TabItem(
path: '/notifications',
icon: Icons.notifications_outlined,
label: 'Avisos'),
_TabItem(
path: '/feedback',
icon: Icons.feedback_outlined,
label: 'Buzón'),
_TabItem(
path: '/quiz', icon: Icons.quiz_outlined, label: 'Quiz'),
];
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).uri.path;
final currentIndex =
_tabs.indexWhere((t) => location.startsWith(t.path));
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: currentIndex < 0 ? 0 : currentIndex,
onDestinationSelected: (i) => context.go(_tabs[i].path),
destinations: _tabs
.map(
(t) => NavigationDestination(
icon: Icon(t.icon),
label: t.label,
),
)
.toList(),
height: 64,
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
),
);
}
}
class _TabItem {
final String path;
final IconData icon;
final String label;
const _TabItem(
{required this.path, required this.icon, required this.label});
}

53
views_v2/dio_client.dart Normal file
View File

@@ -0,0 +1,53 @@
// lib/core/dio_client.dart
// Cliente HTTP configurado con base URL, interceptor de JWT y timeouts.
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// La base URL viene de las variables de entorno en flutter_dotenv o dart-define.
// Para el emulador Android: http://10.0.2.2:8000
// Para producción: https://tu-backend.run.app
const String _kBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://10.0.2.2:8000',
);
// Token JWT — se rellena desde el provider de auth tras login
String? _jwtToken;
void setJwtToken(String token) => _jwtToken = token;
void clearJwtToken() => _jwtToken = null;
Dio _buildDio() {
final dio = Dio(
BaseOptions(
baseUrl: _kBaseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
headers: {'Content-Type': 'application/json'},
),
);
// Interceptor: adjunta JWT en cada petición
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
if (_jwtToken != null) {
options.headers['Authorization'] = 'Bearer $_jwtToken';
}
handler.next(options);
},
onError: (error, handler) {
// 401 → limpiar token (el router de go_router redirige al login)
if (error.response?.statusCode == 401) {
clearJwtToken();
}
handler.next(error);
},
),
);
return dio;
}
final dioProvider = Provider<Dio>((ref) => _buildDio());

View File

@@ -0,0 +1,39 @@
// lib/features/feedback/feedback_model.dart
// La queja solo registra target_unit_id (número de unidad), NUNCA el chofer.
enum FeedbackType {
noPaso('no_paso', 'No pasó el camión'),
llegoTarde('llego_tarde', 'Llegó tarde'),
comportamiento('comportamiento', 'Comportamiento'),
otro('otro', 'Otro');
final String value;
final String label;
const FeedbackType(this.value, this.label);
}
class FeedbackRequest {
final String addressId;
final FeedbackType type;
final int rating; // 1-5
final String? message;
/// Solo el número de unidad — nunca el ID del chofer.
final String targetUnitId;
const FeedbackRequest({
required this.addressId,
required this.type,
required this.rating,
required this.targetUnitId,
this.message,
});
Map<String, dynamic> toJson() => {
'address_id': addressId,
'type': type.value,
'rating': rating,
'target_unit_id': targetUnitId, // ej. "101"
if (message != null && message!.isNotEmpty) 'message': message,
// ⚠️ NUNCA se manda: driver_id, driver_name, chofer_*
};
}

View File

@@ -0,0 +1,104 @@
// lib/features/feedback/feedback_provider.dart
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/dio_client.dart';
import 'feedback_model.dart';
// ──────────────────────────────────────────
// Service
// ──────────────────────────────────────────
class FeedbackService {
final Dio _dio;
FeedbackService(this._dio);
Future<void> submit(FeedbackRequest req) async {
await _dio.post<void>('/feedback', data: req.toJson());
}
}
final feedbackServiceProvider = Provider<FeedbackService>(
(ref) => FeedbackService(ref.read(dioProvider)),
);
// ──────────────────────────────────────────
// Estado del formulario
// ──────────────────────────────────────────
enum FeedbackFormStatus { idle, loading, success, error }
class FeedbackFormState {
final FeedbackType selectedType;
final int rating;
final String message;
final FeedbackFormStatus status;
final String? errorMessage;
const FeedbackFormState({
this.selectedType = FeedbackType.noPaso,
this.rating = 3,
this.message = '',
this.status = FeedbackFormStatus.idle,
this.errorMessage,
});
FeedbackFormState copyWith({
FeedbackType? selectedType,
int? rating,
String? message,
FeedbackFormStatus? status,
String? errorMessage,
}) {
return FeedbackFormState(
selectedType: selectedType ?? this.selectedType,
rating: rating ?? this.rating,
message: message ?? this.message,
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
);
}
}
// ──────────────────────────────────────────
// Notifier
// ──────────────────────────────────────────
class FeedbackNotifier extends Notifier<FeedbackFormState> {
@override
FeedbackFormState build() => const FeedbackFormState();
void setType(FeedbackType type) =>
state = state.copyWith(selectedType: type);
void setRating(int r) => state = state.copyWith(rating: r);
void setMessage(String m) => state = state.copyWith(message: m);
void reset() => state = const FeedbackFormState();
Future<void> submit({
required String addressId,
required String unitId,
}) async {
state = state.copyWith(status: FeedbackFormStatus.loading);
try {
final req = FeedbackRequest(
addressId: addressId,
type: state.selectedType,
rating: state.rating,
message: state.message,
targetUnitId: unitId,
);
await ref.read(feedbackServiceProvider).submit(req);
state = state.copyWith(status: FeedbackFormStatus.success);
} on DioException catch (e) {
state = state.copyWith(
status: FeedbackFormStatus.error,
errorMessage: e.message ?? 'Error al enviar',
);
}
}
}
final feedbackProvider =
NotifierProvider<FeedbackNotifier, FeedbackFormState>(
FeedbackNotifier.new,
);

View File

@@ -0,0 +1,354 @@
// lib/features/feedback/feedback_screen.dart
// Buzón de retroalimentación. Expone "Unidad 101", nunca el nombre del chofer.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'feedback_model.dart';
import 'feedback_provider.dart';
import '../eta/eta_provider.dart'; // activeAddressIdProvider
// El unitId activo se obtiene del ETA response o de la sesión del chofer.
// Por simplidad se provee aquí; en producción viene del provider de sesión.
final activeUnitIdProvider = StateProvider<String>((ref) => '101');
class FeedbackScreen extends ConsumerStatefulWidget {
const FeedbackScreen({super.key});
@override
ConsumerState<FeedbackScreen> createState() => _FeedbackScreenState();
}
class _FeedbackScreenState extends ConsumerState<FeedbackScreen> {
final _messageController = TextEditingController();
@override
void dispose() {
_messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final formState = ref.watch(feedbackProvider);
if (formState.status == FeedbackFormStatus.success) {
return _SuccessView(onReset: () {
ref.read(feedbackProvider.notifier).reset();
_messageController.clear();
});
}
return Scaffold(
appBar: AppBar(title: const Text('Buzón de retroalimentación')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Tipo de reporte
const _SectionLabel('Tipo de reporte'),
const SizedBox(height: 8),
_TypeChips(
selected: formState.selectedType,
onSelect: (t) => ref.read(feedbackProvider.notifier).setType(t),
),
const SizedBox(height: 20),
// Rating
const _SectionLabel('Calificación del servicio'),
const SizedBox(height: 8),
_StarRating(
rating: formState.rating,
onRate: (r) => ref.read(feedbackProvider.notifier).setRating(r),
),
const SizedBox(height: 20),
// Unidad (sin exponer chofer)
const _SectionLabel('Unidad involucrada'),
const SizedBox(height: 8),
const _UnitBadge(),
const SizedBox(height: 20),
// Mensaje libre
const _SectionLabel('Descripción (opcional)'),
const SizedBox(height: 8),
TextField(
controller: _messageController,
maxLines: 4,
maxLength: 300,
onChanged: (v) =>
ref.read(feedbackProvider.notifier).setMessage(v),
decoration: InputDecoration(
hintText: 'Cuéntanos qué pasó...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
const SizedBox(height: 8),
// Error
if (formState.status == FeedbackFormStatus.error)
_ErrorBanner(message: formState.errorMessage ?? 'Error'),
const SizedBox(height: 8),
// Submit
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: formState.status == FeedbackFormStatus.loading
? null
: () {
final addressId = ref.read(activeAddressIdProvider);
final unitId = ref.read(activeUnitIdProvider);
if (addressId == null) return;
ref.read(feedbackProvider.notifier).submit(
addressId: addressId,
unitId: unitId,
);
},
child: formState.status == FeedbackFormStatus.loading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Enviar reporte'),
),
),
const SizedBox(height: 24),
],
),
);
}
}
// ──────────────────────────────────────────
// Chips de tipo
// ──────────────────────────────────────────
class _TypeChips extends StatelessWidget {
final FeedbackType selected;
final ValueChanged<FeedbackType> onSelect;
const _TypeChips({required this.selected, required this.onSelect});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: FeedbackType.values.map((t) {
final isSelected = t == selected;
return ChoiceChip(
label: Text(t.label),
selected: isSelected,
onSelected: (_) => onSelect(t),
selectedColor: const Color(0xFFE1F5EE),
side: BorderSide(
color: isSelected
? const Color(0xFF5DCAA5)
: Theme.of(context).colorScheme.outlineVariant,
),
labelStyle: TextStyle(
color: isSelected
? const Color(0xFF085041)
: Theme.of(context).colorScheme.onSurface,
fontSize: 13,
),
);
}).toList(),
);
}
}
// ──────────────────────────────────────────
// Stars
// ──────────────────────────────────────────
class _StarRating extends StatelessWidget {
final int rating;
final ValueChanged<int> onRate;
const _StarRating({required this.rating, required this.onRate});
@override
Widget build(BuildContext context) {
return Row(
children: List.generate(5, (i) {
final filled = i < rating;
return GestureDetector(
onTap: () => onRate(i + 1),
child: Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(
filled ? Icons.star_rounded : Icons.star_outline_rounded,
size: 32,
color: filled
? const Color(0xFFEF9F27)
: Theme.of(context).colorScheme.outlineVariant,
),
),
);
}),
);
}
}
// ──────────────────────────────────────────
// Badge de unidad (sin exponer chofer)
// ──────────────────────────────────────────
class _UnitBadge extends ConsumerWidget {
const _UnitBadge();
@override
Widget build(BuildContext context, WidgetRef ref) {
final unitId = ref.watch(activeUnitIdProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.local_shipping_outlined, size: 16),
const SizedBox(width: 8),
Text(
'Unidad $unitId',
style: const TextStyle(fontSize: 13),
),
],
),
),
const SizedBox(height: 6),
Row(
children: [
Icon(
Icons.shield_outlined,
size: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'Solo se registra el número de unidad. El operador no es identificado.',
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
],
);
}
}
// ──────────────────────────────────────────
// Error banner
// ──────────────────────────────────────────
class _ErrorBanner extends StatelessWidget {
final String message;
const _ErrorBanner({required this.message});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.error_outline, size: 16),
const SizedBox(width: 8),
Expanded(child: Text(message, style: const TextStyle(fontSize: 12))),
],
),
);
}
}
// ──────────────────────────────────────────
// Success view
// ──────────────────────────────────────────
class _SuccessView extends StatelessWidget {
final VoidCallback onReset;
const _SuccessView({required this.onReset});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Buzón de retroalimentación')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64,
height: 64,
decoration: const BoxDecoration(
color: Color(0xFFE1F5EE),
shape: BoxShape.circle,
),
child: const Icon(
Icons.check_circle_outline_rounded,
size: 36,
color: Color(0xFF1D9E75),
),
),
const SizedBox(height: 16),
const Text(
'Reporte enviado',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Text(
'Gracias. Tu retroalimentación ayuda a mejorar el servicio. El reporte fue registrado de forma anónima.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1.5,
),
),
const SizedBox(height: 24),
OutlinedButton(
onPressed: onReset,
child: const Text('Enviar otro reporte'),
),
],
),
),
),
);
}
}
// ──────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────
class _SectionLabel extends StatelessWidget {
final String text;
const _SectionLabel(this.text);
@override
Widget build(BuildContext context) {
return Text(
text,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
}

25
views_v2/main.dart Normal file
View File

@@ -0,0 +1,25 @@
// lib/main.dart
// Punto de entrada. Inicializa Firebase, FCM, y monta el árbol Riverpod.
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'features/notifications/notification_service.dart';
import 'app.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Inicializar Firebase (requiere google-services.json en android/app/)
await Firebase.initializeApp();
// Inicializar FCM: permisos, canal Android, handlers foreground/background
await NotificationService.initialize();
runApp(
// ProviderScope es el contenedor global de Riverpod
const ProviderScope(
child: RecolectaApp(),
),
);
}

View File

@@ -0,0 +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 '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 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,51 @@
// lib/shared/widgets/prevention_banner.dart
// Banner de mensajería preventiva — obligatorio en la vista ETA.
// Regla de privacidad #5: textos que desalientan sacar basura fuera de horario
// o perseguir la unidad.
import 'package:flutter/material.dart';
class PreventionBanner extends StatelessWidget {
final String? customMessage;
const PreventionBanner({super.key, this.customMessage});
@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 Padding(
padding: EdgeInsets.only(top: 1),
child: Icon(
Icons.warning_amber_rounded,
size: 18,
color: Color(0xFFBA7517),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
customMessage ??
'No saques tu basura antes de recibir el aviso de proximidad '
'ni dejes bolsas en la calle por más de 30 min. '
'No persigas ni detengas la unidad recolectora.',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF633806),
height: 1.5,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,174 @@
// lib/shared/widgets/progress_steps.dart
// Barra de 4 pasos del servicio. Sin mapa ni coordenadas.
// Los pasos mapean a los eventos de positionId del backend:
// 0 = pendiente, 1 = ROUTE_START (pos 2), 2 = TRUCK_PROXIMITY (pos 4), 3 = ROUTE_COMPLETED (pos 8)
import 'package:flutter/material.dart';
class ProgressSteps extends StatelessWidget {
/// 0 = pendiente, 1 = en camino, 2 = cerca, 3 = completado
final int stepIndex;
const ProgressSteps({super.key, required this.stepIndex});
static const _steps = [
_StepData('Servicio pendiente', Icons.access_time_rounded),
_StepData('Salió al sector', Icons.arrow_forward_rounded),
_StepData('Cerca (~15 min)', Icons.local_shipping_rounded),
_StepData('Finalizado', Icons.check_circle_outline_rounded),
];
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
child: Row(
children: [
const Icon(Icons.route_rounded,
size: 16, color: Color(0xFF1D9E75)),
const SizedBox(width: 6),
Text(
'Progreso del servicio',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
const Divider(height: 1, thickness: 0.5),
...List.generate(_steps.length, (i) {
final status = _stepStatus(i);
return _StepRow(
data: _steps[i],
status: status,
isLast: i == _steps.length - 1,
);
}),
],
),
);
}
_Status _stepStatus(int i) {
if (i < stepIndex) return _Status.done;
if (i == stepIndex) return _Status.active;
return _Status.pending;
}
}
enum _Status { done, active, pending }
class _StepData {
final String label;
final IconData icon;
const _StepData(this.label, this.icon);
}
class _StepRow extends StatelessWidget {
final _StepData data;
final _Status status;
final bool isLast;
const _StepRow({
required this.data,
required this.status,
required this.isLast,
});
@override
Widget build(BuildContext context) {
Color iconBg;
Color iconColor;
IconData displayIcon;
switch (status) {
case _Status.done:
iconBg = const Color(0xFFE1F5EE);
iconColor = const Color(0xFF1D9E75);
displayIcon = Icons.check_rounded;
break;
case _Status.active:
iconBg = const Color(0xFFFAEEDA);
iconColor = const Color(0xFFBA7517);
displayIcon = data.icon;
break;
case _Status.pending:
iconBg = Theme.of(context).colorScheme.surfaceContainerLow;
iconColor = Theme.of(context).colorScheme.onSurfaceVariant;
displayIcon = Icons.radio_button_unchecked_rounded;
break;
}
return Container(
decoration: BoxDecoration(
border: isLast
? null
: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.5,
),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: iconBg,
shape: BoxShape.circle,
),
child: Icon(displayIcon, size: 15, color: iconColor),
),
const SizedBox(width: 12),
Expanded(
child: Text(
data.label,
style: TextStyle(
fontSize: 13,
color: status == _Status.pending
? Theme.of(context).colorScheme.onSurfaceVariant
: Theme.of(context).colorScheme.onSurface,
),
),
),
if (status == _Status.active)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFFAEEDA),
borderRadius: BorderRadius.circular(100),
),
child: const Text(
'Ahora',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Color(0xFF633806),
),
),
),
],
),
),
);
}
}