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>

implementacion de login, vistas, correcion de errores en vista registro, domicilios
This commit is contained in:
shinra32
2026-05-22 23:07:24 -06:00
parent b4ee3e7b49
commit c91b6e2091
52 changed files with 3940 additions and 4368 deletions

View File

@@ -1,288 +1,19 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../core/network/api_client.dart';
import '../core/models/auth_state.dart';
import '../core/services/auth_controller.dart';
import '../core/storage/secure_storage.dart';
import 'bootstrap.dart' as bootstrap;
import '../features/auth/login_page.dart';
import '../features/auth/register_page.dart';
import '../features/addresses/new_address_page.dart';
final routerProvider = Provider<GoRouter>((ref) {
// ValueNotifier used as refreshListenable so GoRouter re-evaluates redirect
// without recreating the router (which would unmount widgets mid-request).
final notifier = ValueNotifier<int>(0);
ref.listen<AsyncValue<AuthState>>(authControllerProvider, (prev, next) {
notifier.value++;
});
ref.onDispose(notifier.dispose);
return GoRouter(
initialLocation: '/login',
refreshListenable: notifier,
redirect: (context, state) {
final authSnapshot = ref.read(authControllerProvider);
final isAuthenticated =
authSnapshot.asData?.value.isAuthenticated ?? false;
final location = state.matchedLocation;
final isAuthRoute =
location == '/login' || location == '/register';
if (authSnapshot.isLoading) return null;
if (!isAuthenticated && !isAuthRoute) return '/login';
if (isAuthenticated && isAuthRoute) return '/home';
return null;
},
routes: <RouteBase>[
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: '/register',
name: 'register',
builder: (context, state) => const RegisterPage(),
),
GoRoute(
path: '/home',
name: 'home',
builder: (context, state) => const HomePage(),
),
GoRoute(
path: '/addresses/new',
name: 'addresses-new',
builder: (context, state) => const NewAddressPage(),
),
GoRoute(
path: '/status',
name: 'status',
builder: (context, state) => const StatusPage(),
),
],
);
});
import 'package:recolecta_app/core/router/app_router.dart';
import 'package:recolecta_app/core/theme/app_theme.dart';
class RecolectaApp extends ConsumerWidget {
const RecolectaApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bootstrapState = ref.watch(bootstrap.bootstrapProvider);
return bootstrapState.when(
loading: () => const MaterialApp(
debugShowCheckedModeBanner: false,
home: BootstrapLoadingPage(),
),
error: (error, stackTrace) => MaterialApp(
debugShowCheckedModeBanner: false,
home: BootstrapErrorPage(error: error),
),
data: (_) => MaterialApp.router(
debugShowCheckedModeBanner: false,
title: 'Recolecta',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1F6F78)),
scaffoldBackgroundColor: const Color(0xFFF4F7F6),
useMaterial3: true,
),
routerConfig: ref.watch(routerProvider),
),
);
}
}
class BootstrapLoadingPage extends StatelessWidget {
const BootstrapLoadingPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
}
class BootstrapErrorPage extends StatelessWidget {
const BootstrapErrorPage({super.key, required this.error});
final Object error;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48),
const SizedBox(height: 16),
const Text(
'No se pudo cargar la configuración inicial.',
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
error.toString(),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
);
}
}
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final dio = ref.read(apiClientProvider);
final storage = ref.read(secureStorageProvider);
final baseUrl = dio.options.baseUrl;
final authState = ref.watch(authControllerProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Recolecta'),
actions: [
IconButton(
onPressed: authState.isLoading
? null
: () async {
await ref.read(authControllerProvider.notifier).logout();
if (context.mounted) {
context.go('/login');
}
},
icon: const Icon(Icons.logout),
tooltip: 'Salir',
),
IconButton(
onPressed: () => context.goNamed('status'),
icon: const Icon(Icons.route),
tooltip: 'Estado',
),
],
),
body: Padding(
padding: const EdgeInsets.all(24),
child: ListView(
children: [
const Text(
'Bootstrap listo',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
'La app ya carga .env, Riverpod y GoRouter para la base del MVP.',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
TextButton(
onPressed: () => context.go('/addresses/new'),
child: const Text('Agregar domicilio'),
),
const SizedBox(height: 24),
_InfoCard(title: 'API base URL', value: baseUrl, icon: Icons.cloud),
const SizedBox(height: 16),
_InfoCard(
title: 'Secure Storage',
value: storage.runtimeType.toString(),
icon: Icons.lock,
),
const SizedBox(height: 16),
_ImageCard(
title: 'Widget listo para caché de imágenes',
imageUrl:
'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=800&q=80',
),
],
),
),
);
}
}
class StatusPage extends StatelessWidget {
const StatusPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Estado')),
body: const Padding(
padding: EdgeInsets.all(24),
child: Text(
'Aquí después se conectarán ETA, notificaciones, ruta asignada y métricas de privacidad.',
),
),
);
}
}
class _InfoCard extends StatelessWidget {
const _InfoCard({
required this.title,
required this.value,
required this.icon,
});
final String title;
final String value;
final IconData icon;
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
child: ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(value),
),
);
}
}
class _ImageCard extends StatelessWidget {
const _ImageCard({required this.title, required this.imageUrl});
final String title;
final String imageUrl;
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CachedNetworkImage(
imageUrl: imageUrl,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
placeholder: (context, url) => const SizedBox(
height: 180,
child: Center(child: CircularProgressIndicator()),
),
errorWidget: (context, url, error) => const SizedBox(
height: 180,
child: Center(child: Icon(Icons.image_not_supported)),
),
),
Padding(padding: const EdgeInsets.all(16), child: Text(title)),
],
),
final router = ref.watch(routerProvider);
return MaterialApp.router(
title: 'Recolecta App',
theme: AppTheme.lightTheme,
debugShowCheckedModeBanner: false,
routerConfig: router,
);
}
}

View File

@@ -0,0 +1,154 @@
// ── Usuario (presentación UI) ─────────────────────────────────────────────────
class UIUserModel {
final String id;
final String nombre;
final String apellido;
final String email;
final String telefono;
final String role;
const UIUserModel({
required this.id,
required this.nombre,
required this.apellido,
required this.email,
required this.telefono,
this.role = 'citizen',
});
String get nombreCompleto => '$nombre $apellido'.trim();
String get iniciales {
final n = nombre.isNotEmpty ? nombre[0] : '';
final a = apellido.isNotEmpty ? apellido[0] : '';
return '$n$a'.toUpperCase();
}
bool get isAdmin => role == 'admin';
}
// ── Casa / Domicilio ──────────────────────────────────────────────────────────
class UIHouseModel {
final String id;
final String alias;
final String calle;
final String colonia;
final String? routeId;
final int radioAlertaMetros;
final bool alertaCercana;
final bool alertaMedia;
final bool recordatorioDiario;
final bool activa;
const UIHouseModel({
required this.id,
this.alias = 'Casa principal',
required this.calle,
required this.colonia,
this.routeId,
this.radioAlertaMetros = 200,
this.alertaCercana = true,
this.alertaMedia = false,
this.recordatorioDiario = true,
this.activa = true,
});
String get direccionCompleta => '$calle, Col. $colonia';
UIHouseModel copyWith({
String? alias,
String? calle,
String? colonia,
String? routeId,
int? radioAlertaMetros,
bool? alertaCercana,
bool? alertaMedia,
bool? recordatorioDiario,
bool? activa,
}) {
return UIHouseModel(
id: id,
alias: alias ?? this.alias,
calle: calle ?? this.calle,
colonia: colonia ?? this.colonia,
routeId: routeId ?? this.routeId,
radioAlertaMetros: radioAlertaMetros ?? this.radioAlertaMetros,
alertaCercana: alertaCercana ?? this.alertaCercana,
alertaMedia: alertaMedia ?? this.alertaMedia,
recordatorioDiario: recordatorioDiario ?? this.recordatorioDiario,
activa: activa ?? this.activa,
);
}
factory UIHouseModel.fromJson(Map<String, dynamic> json) {
return UIHouseModel(
id: json['id'] as String? ?? '',
alias: json['label'] as String? ?? 'Casa principal',
calle: json['calle'] as String? ?? '',
colonia: json['colonia'] as String? ?? '',
routeId: json['route_id'] as String?,
);
}
}
// ── Alerta ───────────────────────────────────────────────────────────────────
enum TipoAlerta { cercana, media, recordatorio }
class UIAlertaModel {
final String id;
final TipoAlerta tipo;
final double distanciaMetros;
final DateTime fecha;
final String direccionCasa;
final bool leida;
const UIAlertaModel({
required this.id,
required this.tipo,
required this.distanciaMetros,
required this.fecha,
required this.direccionCasa,
this.leida = false,
});
String get distanciaTexto {
if (distanciaMetros < 1000) {
return '${distanciaMetros.toStringAsFixed(0)} m';
}
return '${(distanciaMetros / 1000).toStringAsFixed(1)} km';
}
String get tiempoEstimadoTexto {
final segundos = (distanciaMetros / (5000 / 3600)).round();
if (segundos < 60) return 'Menos de 1 min';
final minutos = (segundos / 60).ceil();
return '~$minutos min';
}
String get fechaFormateada {
final ahora = DateTime.now();
final hoy = DateTime(ahora.year, ahora.month, ahora.day);
final fechaDia = DateTime(fecha.year, fecha.month, fecha.day);
if (fechaDia == hoy) return 'Hoy, ${_formatHora(fecha)}';
final ayer = hoy.subtract(const Duration(days: 1));
if (fechaDia == ayer) return 'Ayer, ${_formatHora(fecha)}';
const dias = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
const meses = ['ene','feb','mar','abr','may','jun','jul','ago','sep','oct','nov','dic'];
return '${dias[fecha.weekday - 1]} ${fecha.day} ${meses[fecha.month - 1]}, ${_formatHora(fecha)}';
}
String get etiquetaFecha {
final ahora = DateTime.now();
final hoy = DateTime(ahora.year, ahora.month, ahora.day);
final fechaDia = DateTime(fecha.year, fecha.month, fecha.day);
if (fechaDia == hoy) return 'Hoy';
const dias = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
return dias[fecha.weekday - 1];
}
String _formatHora(DateTime dt) {
final h = dt.hour > 12 ? dt.hour - 12 : dt.hour == 0 ? 12 : dt.hour;
final m = dt.minute.toString().padLeft(2, '0');
final ampm = dt.hour >= 12 ? 'p.m.' : 'a.m.';
return '$h:$m $ampm';
}
}

View File

@@ -7,10 +7,12 @@ import '../constants/auth_constants.dart';
import '../storage/secure_storage.dart';
final apiClientProvider = Provider<Dio>((ref) {
final defaultBaseUrl = kIsWeb
? 'http://localhost:8000'
: 'http://10.0.2.2:8000';
final baseUrl = dotenv.env['API_BASE_URL'] ?? defaultBaseUrl;
final envUrl = dotenv.env['API_BASE_URL'];
final baseUrl = kIsWeb
? (envUrl != null && envUrl.contains('localhost')
? envUrl
: 'http://localhost:8000')
: (envUrl ?? 'http://10.0.2.2:8000');
final secureStorage = ref.read(secureStorageProvider);
final dio = Dio(

View File

@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:recolecta_app/features/admin/admin_shell.dart';
import 'package:recolecta_app/features/auth/login_page.dart';
import 'package:recolecta_app/features/driver/driver_shell.dart';
import 'package:recolecta_app/features/driver/screens/driver_collections_screen.dart';
import 'package:recolecta_app/features/driver/screens/driver_home_screen.dart';
import 'package:recolecta_app/features/driver/screens/driver_incident_screen.dart';
import 'package:recolecta_app/features/feedback/feedback_screen.dart';
import 'package:recolecta_app/features/home/citizen_home_screen.dart';
import 'package:recolecta_app/features/home/citizen_shell.dart';
import 'package:recolecta_app/features/separation_guide/screens/category_detail_screen.dart';
import 'package:recolecta_app/features/separation_guide/screens/separation_guide_screen.dart';
import 'package:recolecta_app/core/services/auth_controller.dart';
// Mock Admin Screens
class AdminDashboardScreen extends StatelessWidget {
const AdminDashboardScreen({super.key});
@override
Widget build(BuildContext context) =>
const Scaffold(body: Center(child: Text('Admin Dashboard')));
}
class AdminRouteDetailScreen extends StatelessWidget {
const AdminRouteDetailScreen({super.key, required this.routeId});
final String routeId;
@override
Widget build(BuildContext context) =>
Scaffold(body: Center(child: Text('Admin Route Detail: $routeId')));
}
class AdminReassignScreen extends StatelessWidget {
const AdminReassignScreen({super.key, required this.routeId});
final String routeId;
@override
Widget build(BuildContext context) =>
Scaffold(body: Center(child: Text('Admin Reassign: $routeId')));
}
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authControllerProvider);
return GoRouter(
initialLocation: '/login',
redirect: (BuildContext context, GoRouterState state) {
final isAuthenticated = authState.value?.isAuthenticated ?? false;
final role = authState.value?.userRole;
final isLoggingIn = state.matchedLocation == '/login';
if (!isAuthenticated) {
return isLoggingIn ? null : '/login';
}
if (isLoggingIn) {
switch (role) {
case 'admin':
return '/admin';
case 'driver':
return '/driver';
case 'citizen':
return '/home';
default:
return '/login';
}
}
return null;
},
routes: [
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
ShellRoute(
builder: (context, state, child) => AdminShell(child: child),
routes: [
GoRoute(
path: '/admin',
builder: (context, state) => const AdminDashboardScreen(),
routes: [
GoRoute(
path: 'routes/:routeId',
builder: (context, state) => AdminRouteDetailScreen(
routeId: state.pathParameters['routeId']!,
),
),
GoRoute(
path: 'reassign/:routeId',
builder: (context, state) => AdminReassignScreen(
routeId: state.pathParameters['routeId']!,
),
),
],
),
],
),
ShellRoute(
builder: (context, state, child) => DriverShell(child: child),
routes: [
GoRoute(
path: '/driver',
builder: (context, state) => const DriverHomeScreen(),
),
GoRoute(
path: '/driver/collections',
builder: (context, state) => const DriverCollectionsScreen(),
),
GoRoute(
path: '/driver/incident',
builder: (context, state) => const DriverIncidentScreen(),
),
],
),
ShellRoute(
builder: (context, state, child) => CitizenShell(child: child),
routes: [
GoRoute(
path: '/home',
builder: (context, state) => const CitizenHomeScreen(),
),
GoRoute(
path: '/feedback',
builder: (context, state) => const FeedbackScreen(),
),
GoRoute(
path: '/guide',
builder: (context, state) => const SeparationGuideScreen(),
routes: [
GoRoute(
path: ':categoryId',
builder: (context, state) => CategoryDetailScreen(
categoryId: state.pathParameters['categoryId']!,
),
),
],
),
],
),
],
);
});

View File

@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
class AppTheme {
static const Color primary = Color(0xFF1D9E75);
static const Color primaryDark = Color(0xFF0F6E56);
static const Color primaryLight = Color(0xFFE1F5EE);
static const Color primaryMid = Color(0xFF9FE1CB);
static const Color blue = Color(0xFF185FA5);
static const Color blueLight = Color(0xFFE6F1FB);
static const Color amber = Color(0xFF854F0B);
static const Color amberLight = Color(0xFFFAEEDA);
static const Color danger = Color(0xFFE24B4A);
static const Color dangerLight = Color(0xFFFCEBEB);
static const Color textPrimary = Color(0xFF1A1A1A);
static const Color textSecondary = Color(0xFF6B7280);
static const Color textHint = Color(0xFFAAAAAA);
static const Color surface = Color(0xFFFFFFFF);
static const Color background = Color(0xFFF5F7F5);
static const Color border = Color(0xFFE5E7EB);
static const Color borderLight = Color(0xFFF0F2F0);
static const double radiusSm = 8.0;
static const double radiusMd = 12.0;
static const double radiusLg = 16.0;
static const double radiusXl = 24.0;
static const double radiusFull = 100.0;
static List<BoxShadow> get cardShadow => [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 12,
offset: const Offset(0, 4),
),
];
static List<BoxShadow> get softShadow => [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
];
static ThemeData get lightTheme => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primary,
primary: primary,
secondary: primaryDark,
surface: surface,
),
scaffoldBackgroundColor: background,
appBarTheme: const AppBarTheme(
backgroundColor: primary,
foregroundColor: Colors.white,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
inherit: false,
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
iconTheme: IconThemeData(color: Colors.white),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
elevation: 0,
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white54, width: 1.5),
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusSm),
borderSide: const BorderSide(color: border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusSm),
borderSide: const BorderSide(color: border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusSm),
borderSide: const BorderSide(color: primary, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusSm),
borderSide: const BorderSide(color: danger),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusSm),
borderSide: const BorderSide(color: danger, width: 1.5),
),
labelStyle: const TextStyle(color: textSecondary, fontSize: 13),
hintStyle: const TextStyle(color: textHint, fontSize: 13),
),
);
}

View File

@@ -0,0 +1,430 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
// ── Badge de estado ───────────────────────────────────────────────────────────
class AppStatusBadge extends StatelessWidget {
final String label;
final Color backgroundColor;
final Color textColor;
const AppStatusBadge({
super.key,
required this.label,
this.backgroundColor = AppTheme.primaryLight,
this.textColor = AppTheme.primaryDark,
});
factory AppStatusBadge.green(String label) => AppStatusBadge(
label: label,
backgroundColor: AppTheme.primaryLight,
textColor: AppTheme.primaryDark,
);
factory AppStatusBadge.amber(String label) => AppStatusBadge(
label: label,
backgroundColor: AppTheme.amberLight,
textColor: AppTheme.amber,
);
factory AppStatusBadge.gray(String label) => AppStatusBadge(
label: label,
backgroundColor: const Color(0xFFF1EFE8),
textColor: const Color(0xFF5F5E5A),
);
factory AppStatusBadge.danger(String label) => AppStatusBadge(
label: label,
backgroundColor: AppTheme.dangerLight,
textColor: AppTheme.danger,
);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: textColor,
),
),
);
}
}
// ── Tarjeta base ──────────────────────────────────────────────────────────────
class AppCard extends StatelessWidget {
final Widget child;
final EdgeInsets? padding;
final Color? borderColor;
final VoidCallback? onTap;
const AppCard({
super.key,
required this.child,
this.padding,
this.borderColor,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
padding: padding ?? const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: borderColor ?? AppTheme.border, width: 0.5),
boxShadow: AppTheme.softShadow,
),
child: child,
),
);
}
}
// ── Fila de información con ícono ─────────────────────────────────────────────
class AppInfoRow extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final Widget? trailing;
const AppInfoRow({
super.key,
required this.icon,
required this.label,
required this.value,
this.trailing,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.border, width: 0.5),
boxShadow: AppTheme.softShadow,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: AppTheme.primary, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary)),
const SizedBox(height: 2),
Text(label,
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary)),
],
),
),
?trailing,
],
),
);
}
}
// ── Campo de formulario ───────────────────────────────────────────────────────
class AppFormField extends StatelessWidget {
final String label;
final String? hint;
final TextEditingController? controller;
final bool obscureText;
final TextInputType? keyboardType;
final String? initialValue;
final Widget? suffix;
final int? maxLines;
final String? Function(String?)? validator;
const AppFormField({
super.key,
required this.label,
this.hint,
this.controller,
this.obscureText = false,
this.keyboardType,
this.initialValue,
this.suffix,
this.maxLines = 1,
this.validator,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary)),
const SizedBox(height: 6),
TextFormField(
controller: controller,
initialValue: initialValue,
obscureText: obscureText,
keyboardType: keyboardType,
maxLines: maxLines,
validator: validator,
style: const TextStyle(fontSize: 14, color: AppTheme.textPrimary),
decoration: InputDecoration(hintText: hint, suffixIcon: suffix),
),
],
);
}
}
// ── Sección con título ────────────────────────────────────────────────────────
class AppSectionTitle extends StatelessWidget {
final String title;
final Widget? action;
const AppSectionTitle({super.key, required this.title, this.action});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
Text(
title.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppTheme.textSecondary,
letterSpacing: 0.8,
),
),
const Spacer(),
if (action != null) action!,
],
),
);
}
}
// ── Toggle con label ──────────────────────────────────────────────────────────
class AppLabeledSwitch extends StatelessWidget {
final String label;
final bool value;
final ValueChanged<bool> onChanged;
const AppLabeledSwitch({
super.key,
required this.label,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: Text(label,
style: const TextStyle(
fontSize: 14, color: AppTheme.textPrimary)),
),
Switch.adaptive(
value: value,
onChanged: onChanged,
activeTrackColor: AppTheme.primary,
),
],
),
);
}
}
// ── Ítem de menú ──────────────────────────────────────────────────────────────
class AppMenuTile extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final VoidCallback? onTap;
final Color? iconColor;
final Color? titleColor;
final Widget? trailing;
const AppMenuTile({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.onTap,
this.iconColor,
this.titleColor,
this.trailing,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.border, width: 0.5),
boxShadow: AppTheme.softShadow,
),
child: Row(
children: [
Icon(icon, color: iconColor ?? AppTheme.primary, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: titleColor ?? AppTheme.textPrimary)),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(subtitle!,
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary)),
],
],
),
),
trailing ??
const Icon(Icons.chevron_right,
color: AppTheme.textSecondary, size: 18),
],
),
),
);
}
}
// ── Tarjeta de formulario con ícono ───────────────────────────────────────────
class AppFormCard extends StatelessWidget {
final IconData icon;
final String title;
final Widget child;
const AppFormCard({
super.key,
required this.icon,
required this.title,
required this.child,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.border, width: 0.5),
boxShadow: AppTheme.softShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: AppTheme.primary, size: 18),
const SizedBox(width: 8),
Text(title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary)),
],
),
const SizedBox(height: 16),
child,
],
),
);
}
}
// ── Bottom Nav Bar ────────────────────────────────────────────────────────────
class AppBottomNav extends StatelessWidget {
final int currentIndex;
final Function(int) onTap;
const AppBottomNav({
super.key,
required this.currentIndex,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return BottomNavigationBar(
currentIndex: currentIndex,
onTap: onTap,
type: BottomNavigationBarType.fixed,
backgroundColor: AppTheme.surface,
selectedItemColor: AppTheme.primary,
unselectedItemColor: AppTheme.textSecondary,
selectedFontSize: 11,
unselectedFontSize: 11,
elevation: 12,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.notifications_outlined),
activeIcon: Icon(Icons.notifications),
label: 'ETA',
),
BottomNavigationBarItem(
icon: Icon(Icons.history_outlined),
activeIcon: Icon(Icons.history),
label: 'Alertas',
),
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Mi casa',
),
BottomNavigationBarItem(
icon: Icon(Icons.person_outline),
activeIcon: Icon(Icons.person),
label: 'Perfil',
),
],
);
}
}

View File

@@ -0,0 +1,421 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_widgets.dart';
// ── Modelos locales ───────────────────────────────────────────────────────────
enum TruckStatus { disponible, enRuta, mantenimiento, detenido }
extension TruckStatusX on TruckStatus {
String get label => switch (this) {
TruckStatus.disponible => 'Disponible',
TruckStatus.enRuta => 'En ruta',
TruckStatus.mantenimiento => 'Mantenimiento',
TruckStatus.detenido => 'Detenido',
};
AppStatusBadge get badge => switch (this) {
TruckStatus.disponible => AppStatusBadge.green(label),
TruckStatus.enRuta => AppStatusBadge.amber(label),
TruckStatus.mantenimiento => AppStatusBadge.gray(label),
TruckStatus.detenido => AppStatusBadge.gray(label),
};
}
class _AdminUser {
final String id, nombre, apellido, email, telefono;
const _AdminUser({
required this.id,
required this.nombre,
required this.apellido,
required this.email,
required this.telefono,
});
String get nombreCompleto => '$nombre $apellido';
String get iniciales =>
'${nombre.isNotEmpty ? nombre[0] : ''}${apellido.isNotEmpty ? apellido[0] : ''}'
.toUpperCase();
_AdminUser copyWith({String? nombre, String? apellido, String? email, String? telefono}) =>
_AdminUser(id: id, nombre: nombre ?? this.nombre, apellido: apellido ?? this.apellido, email: email ?? this.email, telefono: telefono ?? this.telefono);
}
class _AdminRoute {
final String id, nombre, zona;
final bool activa;
const _AdminRoute({required this.id, required this.nombre, required this.zona, this.activa = true});
_AdminRoute copyWith({String? nombre, String? zona, bool? activa}) =>
_AdminRoute(id: id, nombre: nombre ?? this.nombre, zona: zona ?? this.zona, activa: activa ?? this.activa);
}
class _AdminTruck {
final String id, placas, modelo, conductor, rutaId;
final TruckStatus status;
const _AdminTruck({
required this.id, required this.placas, required this.modelo,
required this.conductor, required this.status, required this.rutaId,
});
_AdminTruck copyWith({String? placas, String? modelo, String? conductor, TruckStatus? status, String? rutaId}) =>
_AdminTruck(id: id, placas: placas ?? this.placas, modelo: modelo ?? this.modelo, conductor: conductor ?? this.conductor, status: status ?? this.status, rutaId: rutaId ?? this.rutaId);
}
// ── Pantalla ──────────────────────────────────────────────────────────────────
class AdminScreen extends StatefulWidget {
const AdminScreen({super.key});
@override
State<AdminScreen> createState() => _AdminScreenState();
}
class _AdminScreenState extends State<AdminScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
int _activeTab = 0;
final List<_AdminUser> _usuarios = [
const _AdminUser(id: 'u-01', nombre: 'Laura', apellido: 'Gómez', email: 'laura@recolecta.com', telefono: '+52 461 987 1234'),
const _AdminUser(id: 'u-02', nombre: 'Miguel', apellido: 'Sánchez', email: 'miguel@recolecta.com', telefono: '+52 461 123 7890'),
];
final List<_AdminRoute> _rutas = [
const _AdminRoute(id: 'RUTA-01', nombre: 'Ruta Norte', zona: 'Zona Norte'),
const _AdminRoute(id: 'RUTA-02', nombre: 'Ruta Sur', zona: 'Zona Sur', activa: false),
];
final List<_AdminTruck> _camiones = [
const _AdminTruck(id: 't-01', placas: 'GTO-101', modelo: 'Volvo FH', conductor: 'Javier Pérez', status: TruckStatus.enRuta, rutaId: 'RUTA-01'),
const _AdminTruck(id: 't-02', placas: 'GTO-103', modelo: 'Mercedes 1830', conductor: 'Ana Díaz', status: TruckStatus.disponible, rutaId: 'RUTA-02'),
];
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this)
..addListener(() {
if (!_tabController.indexIsChanging) {
setState(() => _activeTab = _tabController.index);
}
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
title: const Text('Panel de administración'),
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
labelColor: Colors.white,
unselectedLabelColor: Colors.white70,
tabs: const [
Tab(text: 'Usuarios'),
Tab(text: 'Rutas'),
Tab(text: 'Camiones'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildUsersTab(),
_buildRoutesTab(),
_buildTrucksTab(),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
if (_activeTab == 0) _showUserForm();
else if (_activeTab == 1) _showRouteForm();
else _showTruckForm();
},
backgroundColor: AppTheme.primary,
label: Text(_activeTab == 0 ? 'Nuevo usuario' : _activeTab == 1 ? 'Nueva ruta' : 'Nuevo camión'),
icon: const Icon(Icons.add),
),
);
}
// ── Tab usuarios ────────────────────────────────────────────────────────────
Widget _buildUsersTab() {
if (_usuarios.isEmpty) return _emptyState('No hay usuarios registrados.');
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _usuarios.length,
separatorBuilder: (_, i) => const SizedBox(height: 12),
itemBuilder: (context, i) {
final u = _usuarios[i];
return AppCard(
child: Row(
children: [
CircleAvatar(
backgroundColor: AppTheme.primaryLight,
foregroundColor: AppTheme.primary,
child: Text(u.iniciales),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(u.nombreCompleto, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(u.email, style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
Text(u.telefono, style: const TextStyle(fontSize: 13)),
],
),
),
IconButton(icon: const Icon(Icons.edit_outlined, color: AppTheme.primary), onPressed: () => _showUserForm(user: u)),
IconButton(icon: const Icon(Icons.delete_outline, color: AppTheme.danger), onPressed: () => _confirmDelete('usuario', () => setState(() => _usuarios.removeWhere((x) => x.id == u.id)))),
],
),
);
},
);
}
// ── Tab rutas ───────────────────────────────────────────────────────────────
Widget _buildRoutesTab() {
if (_rutas.isEmpty) return _emptyState('No hay rutas registradas.');
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _rutas.length,
separatorBuilder: (_, i) => const SizedBox(height: 12),
itemBuilder: (context, i) {
final r = _rutas[i];
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text(r.nombre, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600))),
r.activa ? AppStatusBadge.green('Activa') : AppStatusBadge.gray('Inactiva'),
],
),
const SizedBox(height: 6),
Text(r.zona, style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(onPressed: () => _showRouteForm(route: r), icon: const Icon(Icons.edit_outlined, size: 18), label: const Text('Editar')),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () => _confirmDelete('ruta', () => setState(() => _rutas.removeWhere((x) => x.id == r.id))),
icon: const Icon(Icons.delete_outline, size: 18),
label: const Text('Eliminar'),
style: TextButton.styleFrom(foregroundColor: AppTheme.danger),
),
],
),
],
),
);
},
);
}
// ── Tab camiones ────────────────────────────────────────────────────────────
Widget _buildTrucksTab() {
if (_camiones.isEmpty) return _emptyState('No hay camiones registrados.');
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _camiones.length,
separatorBuilder: (_, i) => const SizedBox(height: 12),
itemBuilder: (context, i) {
final t = _camiones[i];
final ruta = _rutas.firstWhere((r) => r.id == t.rutaId, orElse: () => const _AdminRoute(id: '', nombre: 'Sin ruta', zona: ''));
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text(t.placas, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600))),
t.status.badge,
],
),
const SizedBox(height: 6),
Text('${t.modelo} · ${t.conductor}', style: const TextStyle(fontSize: 13)),
Text('Ruta: ${ruta.nombre}', style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(onPressed: () => _showTruckForm(truck: t), icon: const Icon(Icons.edit_outlined, size: 18), label: const Text('Editar')),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () => _confirmDelete('camión', () => setState(() => _camiones.removeWhere((x) => x.id == t.id))),
icon: const Icon(Icons.delete_outline, size: 18),
label: const Text('Eliminar'),
style: TextButton.styleFrom(foregroundColor: AppTheme.danger),
),
],
),
],
),
);
},
);
}
Widget _emptyState(String msg) => Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(msg, textAlign: TextAlign.center, style: const TextStyle(fontSize: 15, color: AppTheme.textSecondary))));
// ── Confirmación de borrado ─────────────────────────────────────────────────
void _confirmDelete(String tipo, VoidCallback onConfirm) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusLg)),
title: Text('Eliminar $tipo'),
content: Text('¿Deseas eliminar este $tipo?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), child: const Text('Cancelar')),
TextButton(
onPressed: () { onConfirm(); Navigator.pop(ctx); },
style: TextButton.styleFrom(foregroundColor: AppTheme.danger),
child: const Text('Eliminar'),
),
],
),
);
}
// ── Formulario usuario ──────────────────────────────────────────────────────
void _showUserForm({_AdminUser? user}) {
final nombreCtrl = TextEditingController(text: user?.nombre);
final apellidoCtrl = TextEditingController(text: user?.apellido);
final emailCtrl = TextEditingController(text: user?.email);
final telefonoCtrl = TextEditingController(text: user?.telefono);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusLg)),
title: Text(user == null ? 'Nuevo usuario' : 'Editar usuario'),
content: SingleChildScrollView(
child: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(controller: nombreCtrl, decoration: const InputDecoration(labelText: 'Nombre')),
TextField(controller: apellidoCtrl, decoration: const InputDecoration(labelText: 'Apellido')),
TextField(controller: emailCtrl, decoration: const InputDecoration(labelText: 'Correo'), keyboardType: TextInputType.emailAddress),
TextField(controller: telefonoCtrl, decoration: const InputDecoration(labelText: 'Teléfono'), keyboardType: TextInputType.phone),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), child: const Text('Cancelar')),
TextButton(
onPressed: () {
final nuevo = _AdminUser(id: user?.id ?? 'u-${DateTime.now().millisecondsSinceEpoch}', nombre: nombreCtrl.text.trim(), apellido: apellidoCtrl.text.trim(), email: emailCtrl.text.trim(), telefono: telefonoCtrl.text.trim());
setState(() {
if (user == null) { _usuarios.add(nuevo); }
else { final idx = _usuarios.indexWhere((x) => x.id == user.id); if (idx >= 0) _usuarios[idx] = nuevo; }
});
Navigator.pop(ctx);
},
child: Text(user == null ? 'Crear' : 'Guardar'),
),
],
),
);
}
// ── Formulario ruta ─────────────────────────────────────────────────────────
void _showRouteForm({_AdminRoute? route}) {
final nombreCtrl = TextEditingController(text: route?.nombre);
final zonaCtrl = TextEditingController(text: route?.zona);
bool activa = route?.activa ?? true;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setInner) => AlertDialog(
backgroundColor: AppTheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusLg)),
title: Text(route == null ? 'Nueva ruta' : 'Editar ruta'),
content: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(controller: nombreCtrl, decoration: const InputDecoration(labelText: 'Nombre de ruta')),
TextField(controller: zonaCtrl, decoration: const InputDecoration(labelText: 'Zona')),
Row(children: [
const Expanded(child: Text('Ruta activa')),
Switch.adaptive(value: activa, onChanged: (v) => setInner(() => activa = v)),
]),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), child: const Text('Cancelar')),
TextButton(
onPressed: () {
final nueva = _AdminRoute(id: route?.id ?? 'r-${DateTime.now().millisecondsSinceEpoch}', nombre: nombreCtrl.text.trim(), zona: zonaCtrl.text.trim(), activa: activa);
setState(() {
if (route == null) { _rutas.add(nueva); }
else { final idx = _rutas.indexWhere((x) => x.id == route.id); if (idx >= 0) _rutas[idx] = nueva; }
});
Navigator.pop(ctx);
},
child: Text(route == null ? 'Crear' : 'Guardar'),
),
],
),
),
);
}
// ── Formulario camión ───────────────────────────────────────────────────────
void _showTruckForm({_AdminTruck? truck}) {
final placasCtrl = TextEditingController(text: truck?.placas);
final modeloCtrl = TextEditingController(text: truck?.modelo);
final conductorCtrl = TextEditingController(text: truck?.conductor);
TruckStatus status = truck?.status ?? TruckStatus.disponible;
String selectedRuta = truck?.rutaId ?? (_rutas.isNotEmpty ? _rutas.first.id : '');
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setInner) => AlertDialog(
backgroundColor: AppTheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusLg)),
title: Text(truck == null ? 'Nuevo camión' : 'Editar camión'),
content: SingleChildScrollView(
child: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(controller: placasCtrl, decoration: const InputDecoration(labelText: 'Placas')),
TextField(controller: modeloCtrl, decoration: const InputDecoration(labelText: 'Modelo')),
TextField(controller: conductorCtrl, decoration: const InputDecoration(labelText: 'Conductor')),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: selectedRuta.isEmpty ? null : selectedRuta,
decoration: const InputDecoration(labelText: 'Ruta'),
items: _rutas.map((r) => DropdownMenuItem(value: r.id, child: Text(r.nombre))).toList(),
onChanged: (v) { if (v != null) setInner(() => selectedRuta = v); },
),
const SizedBox(height: 12),
DropdownButtonFormField<TruckStatus>(
value: status,
decoration: const InputDecoration(labelText: 'Estatus'),
items: TruckStatus.values.map((s) => DropdownMenuItem(value: s, child: Text(s.label))).toList(),
onChanged: (v) { if (v != null) setInner(() => status = v); },
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), child: const Text('Cancelar')),
TextButton(
onPressed: () {
final nuevo = _AdminTruck(id: truck?.id ?? 't-${DateTime.now().millisecondsSinceEpoch}', placas: placasCtrl.text.trim(), modelo: modeloCtrl.text.trim(), conductor: conductorCtrl.text.trim(), status: status, rutaId: selectedRuta);
setState(() {
if (truck == null) { _camiones.add(nuevo); }
else { final idx = _camiones.indexWhere((x) => x.id == truck.id); if (idx >= 0) _camiones[idx] = nuevo; }
});
Navigator.pop(ctx);
},
child: Text(truck == null ? 'Crear' : 'Guardar'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class AdminShell extends StatefulWidget {
const AdminShell({super.key, required this.child});
final Widget child;
@override
State<AdminShell> createState() => _AdminShellState();
}
class _AdminShellState extends State<AdminShell> {
int _currentIndex = 0;
void _onTap(int index) {
setState(() {
_currentIndex = index;
});
switch (index) {
case 0:
context.go('/admin');
break;
case 1:
// Placeholder for future routes
break;
case 2:
// Placeholder for future routes
break;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: widget.child,
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: _onTap,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.dashboard),
label: 'Dashboard',
),
BottomNavigationBarItem(icon: Icon(Icons.route), label: 'Rutas'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Choferes'),
],
),
);
}
}

View File

@@ -0,0 +1,329 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_theme.dart';
import '../../core/models/ui_models.dart';
import '../../core/widgets/app_widgets.dart';
class AlertsScreen extends StatefulWidget {
const AlertsScreen({super.key});
@override
State<AlertsScreen> createState() => _AlertsScreenState();
}
class _AlertsScreenState extends State<AlertsScreen> {
final UIAlertaModel _alertaActiva = UIAlertaModel(
id: 'alerta-001',
tipo: TipoAlerta.cercana,
distanciaMetros: 180,
fecha: DateTime.now(),
direccionCasa: 'Av. Insurgentes 245',
leida: false,
);
final List<UIAlertaModel> _historial = [
UIAlertaModel(
id: 'h-001',
tipo: TipoAlerta.cercana,
distanciaMetros: 200,
fecha: DateTime.now().subtract(const Duration(hours: 1)),
direccionCasa: 'Av. Insurgentes 245',
leida: true,
),
UIAlertaModel(
id: 'h-002',
tipo: TipoAlerta.cercana,
distanciaMetros: 200,
fecha: DateTime.now().subtract(const Duration(days: 2, hours: 2)),
direccionCasa: 'Av. Insurgentes 245',
leida: true,
),
UIAlertaModel(
id: 'h-003',
tipo: TipoAlerta.cercana,
distanciaMetros: 200,
fecha: DateTime.now().subtract(
const Duration(days: 4, hours: 1, minutes: 30)),
direccionCasa: 'Av. Insurgentes 245',
leida: true,
),
UIAlertaModel(
id: 'h-004',
tipo: TipoAlerta.cercana,
distanciaMetros: 200,
fecha: DateTime.now().subtract(const Duration(days: 7, hours: 3)),
direccionCasa: 'Av. Insurgentes 245',
leida: true,
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
title: const Text('Alertas'),
actions: [
TextButton(
onPressed: () {},
child: const Text('Limpiar',
style: TextStyle(color: Colors.white, fontSize: 13)),
),
],
),
body: RefreshIndicator(
color: AppTheme.primary,
onRefresh: () async =>
Future.delayed(const Duration(milliseconds: 800)),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_AlertaActivaCard(alerta: _alertaActiva),
const SizedBox(height: 20),
if (_historial.isEmpty)
const _EmptyState()
else ...[
const AppSectionTitle(title: 'Historial de alertas'),
..._historial.map((a) => _AlertaHistorialItem(alerta: a)),
],
],
),
),
);
}
}
// ── Alerta activa ─────────────────────────────────────────────────────────────
class _AlertaActivaCard extends StatelessWidget {
final UIAlertaModel alerta;
const _AlertaActivaCard({required this.alerta});
@override
Widget build(BuildContext context) {
final progreso = (1 - (alerta.distanciaMetros / 400)).clamp(0.0, 1.0);
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.primaryMid),
boxShadow: AppTheme.softShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.notifications_active,
color: Colors.white, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('¡El camión está cerca!',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark)),
Text(alerta.fechaFormateada,
style: const TextStyle(
fontSize: 12, color: AppTheme.primary)),
],
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(20),
),
child: const Text('Ahora',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white)),
),
],
),
const SizedBox(height: 16),
const Text('El camión se encuentra a',
style: TextStyle(fontSize: 13, color: AppTheme.primaryDark)),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
alerta.distanciaTexto,
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.w700,
color: AppTheme.primary,
height: 1.1),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text('de tu casa en ${alerta.direccionCasa}',
style: const TextStyle(
fontSize: 13, color: AppTheme.primaryDark)),
),
],
),
const SizedBox(height: 14),
Row(
children: [
const Text('Llegada estimada:',
style:
TextStyle(fontSize: 12, color: AppTheme.primaryDark)),
const SizedBox(width: 6),
Text(alerta.tiempoEstimadoTexto,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppTheme.primary)),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progreso,
backgroundColor:
AppTheme.primaryMid.withValues(alpha: 0.4),
valueColor:
const AlwaysStoppedAnimation<Color>(AppTheme.primary),
minHeight: 7,
),
),
const SizedBox(height: 4),
const Row(
children: [
Text('Lejos',
style: TextStyle(fontSize: 10, color: AppTheme.primary)),
Spacer(),
Text('Tu casa',
style: TextStyle(fontSize: 10, color: AppTheme.primary)),
],
),
],
),
);
}
}
// ── Ítem de historial ─────────────────────────────────────────────────────────
class _AlertaHistorialItem extends StatelessWidget {
final UIAlertaModel alerta;
const _AlertaHistorialItem({required this.alerta});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.border, width: 0.5),
boxShadow: AppTheme.softShadow,
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppTheme.background,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.notifications_outlined,
color: AppTheme.textSecondary, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Camión a ${alerta.distanciaTexto}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary)),
const SizedBox(height: 2),
Text(alerta.fechaFormateada,
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary)),
],
),
),
_EtiquetaDia(texto: alerta.etiquetaFecha),
],
),
);
}
}
class _EtiquetaDia extends StatelessWidget {
final String texto;
const _EtiquetaDia({required this.texto});
@override
Widget build(BuildContext context) {
final esHoy = texto == 'Hoy';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: esHoy ? AppTheme.primaryLight : AppTheme.background,
borderRadius: BorderRadius.circular(20),
),
child: Text(
texto,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: esHoy ? AppTheme.primaryDark : AppTheme.textSecondary,
),
),
);
}
}
// ── Sin alertas ───────────────────────────────────────────────────────────────
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 60),
child: Column(
children: [
Icon(Icons.notifications_outlined,
color: AppTheme.primary, size: 48),
SizedBox(height: 16),
Text('Sin alertas por ahora',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary)),
SizedBox(height: 6),
Text(
'Te notificaremos cuando el camión\nesté cerca de tu casa.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13, color: AppTheme.textSecondary, height: 1.5),
),
],
),
);
}
}

View File

@@ -1,8 +1,12 @@
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/theme/app_theme.dart';
import '../../core/widgets/app_widgets.dart';
import '../../core/services/auth_controller.dart';
import '../../core/models/auth_state.dart';
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@@ -13,134 +17,226 @@ class LoginPage extends ConsumerStatefulWidget {
class _LoginPageState extends ConsumerState<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
final _emailCtrl = TextEditingController();
final _passCtrl = TextEditingController();
bool _obscurePass = true;
@override
void initState() {
super.initState();
ref.listenManual<AsyncValue<AuthState>>(authControllerProvider, (
prev,
next,
) {
if (!mounted) return;
if (next is AsyncError) {
String errorMessage = 'Ocurrió un error inesperado';
final error = next.error;
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';
}
} else {
errorMessage = error.toString();
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMessage),
backgroundColor: AppTheme.danger,
behavior: SnackBarBehavior.floating,
),
);
}
});
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_emailCtrl.dispose();
_passCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) {
return;
}
try {
await ref
.read(authControllerProvider.notifier)
.login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
if (!mounted) {
return;
}
context.go('/home');
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
}
if (!(_formKey.currentState?.validate() ?? false)) return;
await ref
.read(authControllerProvider.notifier)
.login(email: _emailCtrl.text.trim(), password: _passCtrl.text);
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authControllerProvider);
final loading = authState.isLoading;
final loading = ref.watch(authControllerProvider).isLoading;
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: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
// ── Encabezado ──────────────────────────────────────────
Row(
children: [
const SizedBox(height: 12),
const Icon(Icons.delete_outline_rounded, size: 54),
const SizedBox(height: 16),
Text(
'Recolecta',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
'Accede para ver solo tu ruta asignada.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 28),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Correo electrónico',
hintText: 'tu@correo.com',
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,
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Ingresa tu correo'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Contraseña',
hintText: '••••••••',
suffixIcon: IconButton(
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
const SizedBox(width: 14),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Recolecta',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
),
validator: (value) => (value == null || value.length < 6)
? 'La contraseña debe tener al menos 6 caracteres'
: null,
),
const SizedBox(height: 24),
SizedBox(
height: 52,
child: FilledButton(
onPressed: loading ? null : _submit,
child: loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('Entrar'),
),
),
const SizedBox(height: 16),
TextButton(
onPressed: () => context.go('/register'),
child: const Text('Crear cuenta'),
Text(
'Bienvenido de nuevo',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
],
),
],
),
),
const SizedBox(height: 32),
// ── 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),
),
),
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),
),
),
),
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: 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,
),
),
),
],
),
),
],
),
),
),

View File

@@ -1,8 +1,18 @@
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 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.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 '../addresses/colonias_selector.dart';
import '../../core/models/colonia.dart';
import '../home/colonias_data.dart';
class RegisterPage extends ConsumerStatefulWidget {
const RegisterPage({super.key});
@@ -12,173 +22,632 @@ class RegisterPage extends ConsumerStatefulWidget {
}
class _RegisterPageState extends ConsumerState<RegisterPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _phoneController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _obscurePassword = true;
final _pageController = PageController();
int _currentPage = 0;
final _step1FormKey = GlobalKey<FormState>();
// Paso 1
final _emailCtrl = TextEditingController();
final _telefonoCtrl = TextEditingController();
final _passCtrl = TextEditingController();
bool _obscurePass = true;
// Paso 2
final _cpCtrl = TextEditingController();
final _calleCtrl = TextEditingController();
Colonia? _selectedColonia;
LatLng? _selectedLocation;
int _radioAlerta = 200;
@override
void initState() {
super.initState();
ref.listenManual<AsyncValue<AuthState>>(authControllerProvider, (
prev,
next,
) {
if (!mounted) return;
if (next is AsyncError) {
String errorMessage = 'Ocurrió un error inesperado';
final error = next.error;
if (error is DioException) {
if (error.response?.data != null && error.response?.data is Map) {
errorMessage =
error.response!.data['detail'] ?? 'Error al registrarse';
} else {
errorMessage = 'Error de conexión con el servidor';
}
} else {
errorMessage = error.toString();
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMessage),
backgroundColor: AppTheme.danger,
behavior: SnackBarBehavior.floating,
),
);
}
});
}
@override
void dispose() {
_emailController.dispose();
_phoneController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_pageController.dispose();
_emailCtrl.dispose();
_telefonoCtrl.dispose();
_passCtrl.dispose();
_calleCtrl.dispose();
_cpCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) {
void _nextPage() {
if (!(_step1FormKey.currentState?.validate() ?? false)) return;
_pageController.nextPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
setState(() => _currentPage = 1);
}
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;
}
// 1. Registra al usuario
await ref
.read(authControllerProvider.notifier)
.register(
email: _emailCtrl.text.trim(),
phone: _telefonoCtrl.text.trim(),
password: _passCtrl.text,
);
// Detenernos si hubo algún error en el auth (ej. contraseña corta)
if (ref.read(authControllerProvider).hasError) return;
// 2. Guardar la dirección en el backend de forma silenciosa
try {
await ref
.read(authControllerProvider.notifier)
.register(
email: _emailController.text.trim(),
phone: _phoneController.text.trim(),
password: _passwordController.text,
);
if (!mounted) {
return;
const storage = FlutterSecureStorage();
final token = await storage.read(key: 'token') ?? '';
if (token.isNotEmpty) {
final dio = Dio(
BaseOptions(
baseUrl: const String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:8000',
),
headers: {'Authorization': 'Bearer $token'},
),
);
await dio.post(
'/addresses',
data: {
'label': 'Mi Casa',
'calle': _calleCtrl.text.trim(),
'colonia': _selectedColonia!.nombre,
},
);
}
context.go('/home');
} catch (error) {
if (!mounted) {
return;
} catch (e) {
debugPrint('Aviso: No se pudo guardar la dirección inicial: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Error al guardar tu dirección. Inténtalo más tarde.',
),
backgroundColor: AppTheme.danger,
),
);
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
return; // No navegar si falla el guardado de la dirección
}
// 3. Navegar a inicio de manera limpia
if (mounted) {
context.go(
'/home',
); // ¡Solución al GoException! Navega a la ruta correcta
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authControllerProvider);
final loading = authState.isLoading;
final loading = ref.watch(authControllerProvider).isLoading;
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
backgroundColor: AppTheme.background,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
iconTheme: const IconThemeData(color: AppTheme.textPrimary),
title: Text(
_currentPage == 0 ? 'Crear cuenta' : 'Mi dirección',
style: const TextStyle(color: AppTheme.textPrimary, fontSize: 16),
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(8),
child: _StepIndicator(current: _currentPage, total: 2),
),
),
body: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
children: [
_Step1(
formKey: _step1FormKey,
emailCtrl: _emailCtrl,
telefonoCtrl: _telefonoCtrl,
passCtrl: _passCtrl,
obscurePass: _obscurePass,
onTogglePass: () => setState(() => _obscurePass = !_obscurePass),
onNext: _nextPage,
),
_Step2(
cpCtrl: _cpCtrl,
calleCtrl: _calleCtrl,
selectedColonia: _selectedColonia,
selectedLocation: _selectedLocation,
radioAlerta: _radioAlerta,
loading: loading,
onColoniaChanged: (c) {
setState(() {
_selectedColonia = c;
if (c != null && kColoniasCoordinates.containsKey(c.nombre)) {
_selectedLocation = kColoniasCoordinates[c.nombre];
}
});
},
onLocationChanged: (l) => setState(() => _selectedLocation = l),
onRadioChanged: (v) => setState(() => _radioAlerta = v),
onRegister: _register,
),
],
),
);
}
}
// ── Indicador de pasos ────────────────────────────────────────────────────────
class _StepIndicator extends StatelessWidget {
final int current;
final int total;
const _StepIndicator({required this.current, required this.total});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
child: Row(
children: List.generate(total, (i) {
final active = i <= current;
return Expanded(
child: Container(
margin: EdgeInsets.only(right: i < total - 1 ? 6 : 0),
height: 4,
decoration: BoxDecoration(
color: active ? AppTheme.primary : AppTheme.border,
borderRadius: BorderRadius.circular(4),
),
),
);
}),
),
);
}
}
// ── Paso 1: Cuenta ────────────────────────────────────────────────────────────
class _Step1 extends StatelessWidget {
final GlobalKey<FormState> formKey;
final TextEditingController emailCtrl, telefonoCtrl, passCtrl;
final bool obscurePass;
final VoidCallback onTogglePass;
final VoidCallback onNext;
const _Step1({
required this.formKey,
required this.emailCtrl,
required this.telefonoCtrl,
required this.passCtrl,
required this.obscurePass,
required this.onTogglePass,
required this.onNext,
});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
AppFormCard(
icon: Icons.person_outline,
title: 'Información de cuenta',
child: Column(
children: [
AppFormField(
label: 'Correo electrónico',
hint: 'tu@correo.com',
controller: emailCtrl,
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),
AppFormField(
label: 'Teléfono',
hint: '+52 461 123 4567',
controller: telefonoCtrl,
keyboardType: TextInputType.phone,
),
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: onTogglePass,
),
),
],
),
),
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: onNext,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
const Icon(Icons.person_add_alt_1_outlined, size: 54),
const SizedBox(height: 16),
Text(
'Crear cuenta',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
'Registra tu correo, teléfono y contraseña para continuar.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 28),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Correo electrónico',
hintText: 'tu@correo.com',
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Ingresa tu correo'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Teléfono',
hintText: '+52 461 123 4567',
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Ingresa tu teléfono'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Contraseña',
hintText: '••••••••',
suffixIcon: IconButton(
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
validator: (value) => (value == null || value.length < 6)
? 'La contraseña debe tener al menos 6 caracteres'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _confirmPasswordController,
obscureText: _obscurePassword,
decoration: const InputDecoration(
labelText: 'Confirmar contraseña',
hintText: '••••••••',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Confirma tu contraseña';
}
if (value != _passwordController.text) {
return 'Las contraseñas no coinciden';
}
return null;
},
),
const SizedBox(height: 24),
SizedBox(
height: 52,
child: FilledButton(
onPressed: loading ? null : _submit,
child: loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('Registrarme'),
),
),
const SizedBox(height: 16),
TextButton(
onPressed: () => context.go('/login'),
child: const Text('Ya tengo cuenta'),
),
Text('Siguiente'),
SizedBox(width: 8),
Icon(Icons.arrow_forward, size: 18),
],
),
),
),
),
const SizedBox(height: 20),
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'¿Ya tienes cuenta? ',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
GestureDetector(
onTap: () => context.go('/login'),
child: const Text(
'Inicia sesión',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.primary,
),
),
),
],
),
),
],
),
),
);
}
}
// ── Paso 2: Dirección ─────────────────────────────────────────────────────────
class _Step2 extends StatelessWidget {
final TextEditingController cpCtrl;
final TextEditingController calleCtrl;
final Colonia? selectedColonia;
final LatLng? selectedLocation;
final int radioAlerta;
final bool loading;
final ValueChanged<Colonia?> onColoniaChanged;
final ValueChanged<LatLng> onLocationChanged;
final ValueChanged<int> onRadioChanged;
final VoidCallback onRegister;
const _Step2({
required this.cpCtrl,
required this.calleCtrl,
required this.selectedColonia,
required this.selectedLocation,
required this.radioAlerta,
required this.loading,
required this.onColoniaChanged,
required this.onLocationChanged,
required this.onRadioChanged,
required this.onRegister,
});
@override
Widget build(BuildContext context) {
final mapCenter = selectedLocation ?? const LatLng(20.5222, -100.8123);
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: [
AppFormField(
label: 'Código Postal',
hint: 'Ej. 38000',
controller: cpCtrl,
keyboardType: TextInputType.number,
),
const SizedBox(height: 14),
ColoniasSelector(
labelText: 'Colonia',
initialValue: selectedColonia,
onChanged: onColoniaChanged,
),
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(
key: ValueKey(selectedColonia?.nombre ?? 'default'),
options: MapOptions(
initialCenter: mapCenter,
initialZoom: 15.0,
onTap: (_, latlng) => onLocationChanged(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,
),
),
],
),
],
),
),
],
),
),
const SizedBox(height: 16),
AppFormCard(
icon: Icons.notifications_outlined,
title: 'Distancia de alerta',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Te avisamos cuando el camión esté a esta distancia de tu casa.',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
height: 1.4,
),
),
const SizedBox(height: 14),
...[200, 400, 600].map(
(dist) => _RadioOption(
value: dist,
groupValue: radioAlerta,
label: '$dist metros',
sublabel: dist == 200
? '~2-3 min de anticipación'
: dist == 400
? '~4-5 min de anticipación'
: '~6-8 min de anticipación',
onChanged: onRadioChanged,
),
),
],
),
),
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),
Text('Registrarme'),
],
),
),
),
),
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,
),
),
),
],
),
);
}
}
// ── Opción radio ──────────────────────────────────────────────────────────────
class _RadioOption extends StatelessWidget {
final int value, groupValue;
final String label, sublabel;
final ValueChanged<int> onChanged;
const _RadioOption({
required this.value,
required this.groupValue,
required this.label,
required this.sublabel,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final selected = value == groupValue;
return GestureDetector(
onTap: () => onChanged(value),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
decoration: BoxDecoration(
color: selected ? AppTheme.primaryLight : AppTheme.background,
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(
color: selected ? AppTheme.primary : AppTheme.border,
width: selected ? 1.5 : 0.5,
),
),
child: Row(
children: [
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: selected ? AppTheme.primary : AppTheme.border,
width: 2,
),
),
child: selected
? Center(
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppTheme.primary,
),
),
)
: null,
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: selected
? AppTheme.primaryDark
: AppTheme.textPrimary,
),
),
Text(
sublabel,
style: TextStyle(
fontSize: 11,
color: selected ? AppTheme.primary : AppTheme.textSecondary,
),
),
],
),
],
),
),
);

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class DriverShell extends StatefulWidget {
const DriverShell({super.key, required this.child});
final Widget child;
@override
State<DriverShell> createState() => _DriverShellState();
}
class _DriverShellState extends State<DriverShell> {
int _currentIndex = 0;
void _onTap(int index) {
setState(() {
_currentIndex = index;
});
switch (index) {
case 0:
context.go('/driver');
break;
case 1:
context.go('/driver/collections');
break;
case 2:
context.go('/driver/incident');
break;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: widget.child,
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: _onTap,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.route), label: 'Mi Ruta'),
BottomNavigationBarItem(
icon: Icon(Icons.location_on),
label: 'Recolecciones',
),
BottomNavigationBarItem(
icon: Icon(Icons.warning),
label: 'Reportar Falla',
),
],
),
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
class DriverCollectionsScreen extends StatelessWidget {
const DriverCollectionsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Recolecciones')),
body: const Center(
child: Text(
'TODO: Driver Collections Screen - Lista de domicilios para marcar recolección',
),
),
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
class DriverHomeScreen extends StatelessWidget {
const DriverHomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Mi Ruta')),
body: const Center(
child: Text(
'TODO: Driver Home Screen - Mostrar nombre de ruta, turno y estado',
),
),
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
class DriverIncidentScreen extends StatelessWidget {
const DriverIncidentScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Reportar Incidente')),
body: const Center(
child: Text(
'TODO: Driver Incident Screen - Formulario para reportar falla mecánica',
),
),
);
}
}

View File

@@ -0,0 +1,489 @@
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 '../../core/network/api_client.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,
);
});
class _EtaResult {
final String mensaje;
final String status;
final String direccion;
final String colonia;
final bool hasAddress;
const _EtaResult({
required this.mensaje,
required this.status,
required this.direccion,
required this.colonia,
required this.hasAddress,
});
const _EtaResult.noAddress()
: mensaje = '',
status = '',
direccion = '',
colonia = '',
hasAddress = false;
double get progreso {
if (mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo')) {
return 0.85;
}
if (mensaje.contains('finalizado')) return 1.0;
return 0.35;
}
String get etiquetaEstado {
if (status == 'completada') return 'Finalizado';
if (status == 'en_ruta') return 'En ruta';
return 'Pendiente';
}
}
// ── Pantalla ETA ──────────────────────────────────────────────────────────────
class EtaScreen extends ConsumerWidget {
const EtaScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final etaAsync = ref.watch(etaProvider);
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
title: const Text('Estado del camión'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Actualizar',
onPressed: () => ref.invalidate(etaProvider),
),
],
),
body: etaAsync.when(
loading: () => const _EtaLoading(),
error: (error, _) => _EtaError(
error: error.toString(),
onRetry: () => ref.invalidate(etaProvider),
),
data: (result) => result.hasAddress
? _EtaContent(result: result)
: _NoAddressState(
onAdd: () => context.go('/addresses/new'),
),
),
);
}
}
// ── Contenido ETA ─────────────────────────────────────────────────────────────
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,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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,
),
),
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),
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),
),
),
],
),
),
const SizedBox(height: 16),
// ── Horario estimado de la semana ──────────────────────────────
AppSectionTitle(title: 'Horario del camión'),
_HorarioCard(),
],
);
}
}
// ── Punto animado "en vivo" ───────────────────────────────────────────────────
class _LiveDot extends StatefulWidget {
final bool active;
const _LiveDot({required this.active});
@override
State<_LiveDot> createState() => _LiveDotState();
}
class _LiveDotState extends State<_LiveDot>
with SingleTickerProviderStateMixin {
late final AnimationController _anim;
@override
void initState() {
super.initState();
_anim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
}
@override
void dispose() {
_anim.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!widget.active) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: _anim,
builder: (_, child) => Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppTheme.primary
.withValues(alpha: 0.5 + _anim.value * 0.5),
),
),
);
}
}
// ── Horario ───────────────────────────────────────────────────────────────────
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),
];
@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.border, width: 0.5),
boxShadow: AppTheme.softShadow,
),
child: Column(
children: _dias.map((d) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
children: [
Text(
d.dia,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: d.activo
? AppTheme.textPrimary
: AppTheme.textSecondary,
),
),
const Spacer(),
Text(
d.hora,
style: TextStyle(
fontSize: 13,
color: d.activo ? AppTheme.primary : AppTheme.textSecondary,
),
),
],
),
);
}).toList(),
),
);
}
}
class _HorarioDia {
final String dia;
final String hora;
final bool activo;
const _HorarioDia(
{required this.dia, required this.hora, required this.activo});
}
// ── Sin domicilio ─────────────────────────────────────────────────────────────
class _NoAddressState extends StatelessWidget {
final VoidCallback onAdd;
const _NoAddressState({required this.onAdd});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
color: AppTheme.primaryLight,
shape: BoxShape.circle,
),
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),
),
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),
),
const SizedBox(height: 24),
SizedBox(
width: 200,
child: ElevatedButton(
onPressed: onAdd,
child: const Text('Agregar domicilio'),
),
),
],
),
),
);
}
}
// ── Cargando ──────────────────────────────────────────────────────────────────
class _EtaLoading extends StatelessWidget {
const _EtaLoading();
@override
Widget build(BuildContext context) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: AppTheme.primary),
SizedBox(height: 16),
Text('Consultando estado del camión…',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 14)),
],
),
);
}
}
// ── Error ─────────────────────────────────────────────────────────────────────
class _EtaError extends StatelessWidget {
final String error;
final VoidCallback onRetry;
const _EtaError({required this.error, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.wifi_off_outlined,
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 SizedBox(height: 8),
Text(error,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary)),
const SizedBox(height: 20),
SizedBox(
width: 160,
child: ElevatedButton(
onPressed: onRetry,
child: const Text('Reintentar'),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
class FeedbackScreen extends StatelessWidget {
const FeedbackScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Retroalimentación')),
body: const Center(
child: Text(
'TODO: Feedback Screen - Formulario de queja hacia la unidad',
),
),
);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
class CitizenHomeScreen extends StatelessWidget {
const CitizenHomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Inicio')),
body: const Center(
child: Text('TODO: Citizen Home Screen - Mostrar tarjeta ETA'),
),
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class CitizenShell extends StatefulWidget {
const CitizenShell({super.key, required this.child});
final Widget child;
@override
State<CitizenShell> createState() => _CitizenShellState();
}
class _CitizenShellState extends State<CitizenShell> {
int _currentIndex = 0;
void _onTap(int index) {
setState(() {
_currentIndex = index;
});
switch (index) {
case 0:
context.go('/home');
break;
case 1:
context.go('/guide');
break;
case 2:
context.go('/feedback');
break;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: widget.child,
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: _onTap,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Inicio'),
BottomNavigationBarItem(icon: Icon(Icons.menu_book), label: 'Guía'),
BottomNavigationBarItem(
icon: Icon(Icons.feedback),
label: 'Retroalimentación',
),
],
),
);
}
}

View File

@@ -0,0 +1,14 @@
import 'package:latlong2/latlong.dart';
// Coordenadas de referencia para el centro de cada colonia en Celaya, Gto.
// Para el MVP, estas coordenadas son fijas y coinciden con el JSON de `colonias-rutas`.
// En una versión futura, podrían venir de una API de geocodificación o de la BD.
const Map<String, LatLng> kColoniasCoordinates = {
'Zona Centro': LatLng(20.52254, -100.81153),
'Las Arboledas': LatLng(20.51422, -100.82793),
'San Juanico': LatLng(20.54066, -100.83831),
'Los Olivos': LatLng(20.54621, -100.77274),
'Rancho Seco': LatLng(20.49110, -100.81080),
'Las Insurgentes': LatLng(20.52427, -100.79548),
'Trojes': LatLng(20.50899, -100.77167),
};

View File

@@ -0,0 +1,580 @@
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../core/theme/app_theme.dart';
import '../../core/models/ui_models.dart';
import 'colonias_data.dart';
import '../../core/widgets/app_widgets.dart';
class MyHouseScreen extends StatefulWidget {
const MyHouseScreen({super.key});
@override
State<MyHouseScreen> createState() => _MyHouseScreenState();
}
class _MyHouseScreenState extends State<MyHouseScreen> {
bool _isLoading = true;
UIHouseModel? _casa;
@override
void initState() {
super.initState();
_cargarDomicilio();
}
Future<void> _cargarDomicilio() async {
try {
const storage = FlutterSecureStorage();
final token = await storage.read(key: 'token') ?? '';
if (token.isEmpty) {
setState(() => _isLoading = false);
return;
}
final dio = Dio(
BaseOptions(
baseUrl: const String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:8000',
),
headers: {'Authorization': 'Bearer $token'},
),
);
final res = await dio.get('/addresses');
if (res.data is List && (res.data as List).isNotEmpty) {
final addr = res.data[0];
setState(() {
_casa = UIHouseModel.fromJson(addr);
_isLoading = false;
});
} else {
setState(() => _isLoading = false);
}
} catch (e) {
setState(() => _isLoading = false);
debugPrint('Error al cargar domicilio: $e');
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(
backgroundColor: AppTheme.background,
body: Center(child: CircularProgressIndicator()),
);
}
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.')),
);
}
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: ListView(
padding: const EdgeInsets.all(16),
children: [
_CasaCard(casa: _casa!),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Mapa del Sector (Restringido)'),
_MapaColoniaRestringido(colonia: _casa!.colonia),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Radio de alerta'),
_RadioAlertaCard(
radioActual: _casa!.radioAlertaMetros,
onChanged: (v) =>
setState(() => _casa = _casa!.copyWith(radioAlertaMetros: v)),
),
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: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Funcionalidad próximamente disponible'),
behavior: SnackBarBehavior.floating,
backgroundColor: AppTheme.primary,
),
),
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,
),
),
],
),
),
),
const SizedBox(height: 24),
],
),
);
}
void _mostrarEditarDireccion(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: AppTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppTheme.radiusXl),
),
),
builder: (_) => _EditarDireccionSheet(casa: _casa!),
);
}
}
// ── Tarjeta de la casa ────────────────────────────────────────────────────────
class _CasaCard extends StatelessWidget {
final UIHouseModel casa;
const _CasaCard({required this.casa});
@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),
boxShadow: AppTheme.softShadow,
),
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: 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',
),
],
),
);
}
}
// ── Mapa de Colonia (Restringido para Privacidad) ──────────────────────────────
class _MapaColoniaRestringido extends StatelessWidget {
final String colonia;
const _MapaColoniaRestringido({required this.colonia});
@override
Widget build(BuildContext context) {
// Usa las coordenadas del archivo centralizado de datos de colonias.
final center =
kColoniasCoordinates[colonia] ?? const LatLng(20.5222, -100.8123);
// Creamos una "caja" o límite geográfico de aprox 1km a la redonda
final bounds = LatLngBounds(
LatLng(center.latitude - 0.01, center.longitude - 0.01),
LatLng(center.latitude + 0.01, center.longitude + 0.01),
);
return Container(
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.border, width: 1),
),
clipBehavior: Clip.hardEdge,
child: FlutterMap(
options: MapOptions(
initialCameraFit: CameraFit.bounds(bounds: bounds),
// ESTO ES LA MAGIA DE LA PRIVACIDAD: Bloquea el mapa a esta caja
cameraConstraint: CameraConstraint.contain(bounds: bounds),
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.drag | InteractiveFlag.pinchZoom,
),
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.onlineshack.recolecta',
),
CircleLayer(
circles: [
CircleMarker(
point: center,
color: AppTheme.primary.withValues(alpha: 0.2),
borderColor: AppTheme.primary,
borderStrokeWidth: 2,
radius: 350, // 350 metros a la redonda remarcados
useRadiusInMeter: true,
),
],
),
],
),
);
}
}
class _DetailRow extends StatelessWidget {
final IconData icon;
final String text;
const _DetailRow({required this.icon, required this.text});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 15, color: AppTheme.textSecondary),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
height: 1.4,
),
),
),
],
);
}
}
// ── Radio de alerta ───────────────────────────────────────────────────────────
class _RadioAlertaCard extends StatelessWidget {
final int radioActual;
final ValueChanged<int> onChanged;
const _RadioAlertaCard({required this.radioActual, required this.onChanged});
@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.border, width: 0.5),
boxShadow: AppTheme.softShadow,
),
child: Column(
children: [200, 400, 600].map((dist) {
final selected = dist == radioActual;
return GestureDetector(
onTap: () => onChanged(dist),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
decoration: BoxDecoration(
color: selected ? AppTheme.primaryLight : AppTheme.background,
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(
color: selected ? AppTheme.primary : AppTheme.border,
width: selected ? 1.5 : 0.5,
),
),
child: Row(
children: [
Icon(
selected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: selected ? AppTheme.primary : AppTheme.border,
size: 18,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'$dist metros',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: selected
? AppTheme.primaryDark
: AppTheme.textPrimary,
),
),
),
if (selected)
Text(
dist == 200
? '~2-3 min'
: dist == 400
? '~4-5 min'
: '~6-8 min',
style: const TextStyle(
fontSize: 12,
color: AppTheme.primary,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}).toList(),
),
);
}
}
// ── Notificaciones ────────────────────────────────────────────────────────────
class _NotificacionesCard extends StatelessWidget {
final UIHouseModel casa;
final ValueChanged<bool> onAlertaCercanaChanged;
final ValueChanged<bool> onAlertaMediaChanged;
final ValueChanged<bool> onRecordatorioChanged;
const _NotificacionesCard({
required this.casa,
required this.onAlertaCercanaChanged,
required this.onAlertaMediaChanged,
required this.onRecordatorioChanged,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.border, width: 0.5),
boxShadow: AppTheme.softShadow,
),
child: Column(
children: [
AppLabeledSwitch(
label: 'Alerta cuando el camión esté cerca',
value: casa.alertaCercana,
onChanged: onAlertaCercanaChanged,
),
const Divider(height: 1, color: AppTheme.borderLight),
AppLabeledSwitch(
label: 'Alerta a distancia media',
value: casa.alertaMedia,
onChanged: onAlertaMediaChanged,
),
const Divider(height: 1, color: AppTheme.borderLight),
AppLabeledSwitch(
label: 'Recordatorio diario del horario',
value: casa.recordatorioDiario,
onChanged: onRecordatorioChanged,
),
],
),
);
}
}
// ── Horario ───────────────────────────────────────────────────────────────────
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),
];
@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.border, width: 0.5),
boxShadow: AppTheme.softShadow,
),
child: Column(
children: _dias.map((d) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
children: [
Text(
d.dia,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: d.activo
? AppTheme.textPrimary
: AppTheme.textSecondary,
),
),
const Spacer(),
Text(
d.hora,
style: TextStyle(
fontSize: 13,
color: d.activo ? AppTheme.primary : AppTheme.textSecondary,
),
),
],
),
);
}).toList(),
),
);
}
}
class _HorarioDia {
final String dia;
final String hora;
final bool activo;
const _HorarioDia({
required this.dia,
required this.hora,
required this.activo,
});
}
// ── Sheet editar dirección ────────────────────────────────────────────────────
class _EditarDireccionSheet extends StatelessWidget {
final UIHouseModel casa;
const _EditarDireccionSheet({required this.casa});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 24,
right: 24,
top: 24,
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: AppTheme.border,
borderRadius: BorderRadius.circular(4),
),
),
),
const SizedBox(height: 20),
const Text(
'Editar dirección',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 20),
AppFormField(label: 'Calle y número', initialValue: casa.calle),
const SizedBox(height: 14),
AppFormField(label: 'Colonia', initialValue: casa.colonia),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text('Guardar cambios'),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import '../../core/widgets/app_widgets.dart';
import '../eta/eta_screen.dart';
import '../alerts/alerts_screen.dart';
import 'house_screen.dart';
import '../profile/profile_screen.dart';
class MainShell extends StatefulWidget {
const MainShell({super.key});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
int _currentIndex = 0;
static const List<Widget> _screens = [
EtaScreen(),
AlertsScreen(),
MyHouseScreen(),
ProfileScreen(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(index: _currentIndex, children: _screens),
bottomNavigationBar: AppBottomNav(
currentIndex: _currentIndex,
onTap: (i) => setState(() => _currentIndex = i),
),
);
}
}

View File

@@ -0,0 +1,243 @@
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 '../../core/services/auth_controller.dart';
import '../../core/storage/secure_storage.dart';
import '../../core/constants/auth_constants.dart';
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authControllerProvider).asData?.value;
final storage = ref.read(secureStorageProvider);
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(title: const Text('Mi perfil')),
body: FutureBuilder<_ProfileData>(
future: _loadProfile(storage),
builder: (context, snapshot) {
final profile = snapshot.data ??
_ProfileData(
email: authState?.token != null ? '' : '',
role: authState?.userRole ?? 'citizen',
);
return ListView(
padding: const EdgeInsets.all(16),
children: [
_ProfileHeader(profile: profile),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Mi cuenta'),
AppMenuTile(
icon: Icons.person_outline,
title: 'Editar perfil',
subtitle: profile.email,
onTap: () {},
),
AppMenuTile(
icon: Icons.lock_outline,
title: 'Cambiar contraseña',
onTap: () {},
),
AppMenuTile(
icon: Icons.email_outlined,
title: 'Correo',
subtitle: profile.email,
onTap: () {},
),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Configuración'),
AppMenuTile(
icon: Icons.calendar_month_outlined,
title: 'Horario del camión',
subtitle: 'Mi ruta asignada',
onTap: () {},
),
AppMenuTile(
icon: Icons.notifications_outlined,
title: 'Notificaciones',
subtitle: 'Gestiona tus alertas',
onTap: () {},
),
if (profile.role == 'admin')
AppMenuTile(
icon: Icons.admin_panel_settings_outlined,
title: 'Panel de administración',
subtitle: 'Gestiona usuarios, rutas y camiones',
onTap: () => context.go('/admin'),
),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Soporte'),
AppMenuTile(
icon: Icons.help_outline,
title: 'Ayuda y preguntas frecuentes',
onTap: () {},
),
AppMenuTile(
icon: Icons.bug_report_outlined,
title: 'Reportar un problema',
onTap: () {},
),
AppMenuTile(
icon: Icons.info_outline,
title: 'Acerca de la app',
subtitle: 'Versión 1.0.0',
onTap: () {},
),
const SizedBox(height: 16),
AppMenuTile(
icon: Icons.logout_rounded,
title: 'Cerrar sesión',
iconColor: AppTheme.danger,
titleColor: AppTheme.danger,
trailing: const SizedBox.shrink(),
onTap: () => _confirmarCerrarSesion(context, ref),
),
const SizedBox(height: 32),
const Center(
child: Text(
'Recolecta v1.0.0\nServicio de Limpia · Celaya, Gto.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12, color: AppTheme.textHint, height: 1.6),
),
),
const SizedBox(height: 24),
],
);
},
),
);
}
Future<_ProfileData> _loadProfile(dynamic storage) async {
final role =
await storage.read(key: authUserRoleStorageKey) as String? ?? 'citizen';
return _ProfileData(role: role);
}
void _confirmarCerrarSesion(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusLg)),
title: const Text('Cerrar sesión',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary)),
content: const Text('¿Estás seguro de que deseas cerrar sesión?',
style: TextStyle(fontSize: 14, color: AppTheme.textSecondary)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom(
foregroundColor: AppTheme.textSecondary),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () async {
Navigator.pop(ctx);
await ref.read(authControllerProvider.notifier).logout();
if (context.mounted) context.go('/login');
},
style: TextButton.styleFrom(foregroundColor: AppTheme.danger),
child: const Text('Cerrar sesión',
style: TextStyle(fontWeight: FontWeight.w600)),
),
],
),
);
}
}
// ── Datos de perfil ───────────────────────────────────────────────────────────
class _ProfileData {
final String email;
final String role;
const _ProfileData({
this.email = '',
this.role = 'citizen',
});
String get iniciales =>
email.isNotEmpty ? email[0].toUpperCase() : 'U';
String get displayName => email;
bool get isAdmin => role == 'admin';
}
// ── Encabezado ────────────────────────────────────────────────────────────────
class _ProfileHeader extends StatelessWidget {
final _ProfileData profile;
const _ProfileHeader({required this.profile});
@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.border, width: 0.5),
boxShadow: AppTheme.softShadow,
),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
shape: BoxShape.circle,
border: Border.all(color: AppTheme.primaryMid, width: 1.5),
),
child: Center(
child: Text(
profile.iniciales,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(profile.displayName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary)),
const SizedBox(height: 2),
Text(profile.email,
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
const SizedBox(height: 6),
AppStatusBadge.green(
profile.isAdmin ? 'Administrador' : 'Ciudadano'),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,87 @@
// Esta guía funciona offline. El chat con IA (mascota) es la capa extra cuando hay conexión — ver features/mascota/
import 'package:flutter/material.dart';
class SeparationGuide {
final String version;
final List<Category> categorias;
SeparationGuide({required this.version, required this.categorias});
factory SeparationGuide.fromJson(Map<String, dynamic> json) {
return SeparationGuide(
version: json['version'],
categorias: (json['categorias'] as List)
.map((category) => Category.fromJson(category))
.toList(),
);
}
}
class Category {
final String id;
final String nombre;
final String color;
final String icono;
final String descripcion;
final List<Example> ejemplos;
final String consejo;
Category({
required this.id,
required this.nombre,
required this.color,
required this.icono,
required this.descripcion,
required this.ejemplos,
required this.consejo,
});
factory Category.fromJson(Map<String, dynamic> json) {
return Category(
id: json['id'],
nombre: json['nombre'],
color: json['color'],
icono: json['icono'],
descripcion: json['descripcion'],
ejemplos: (json['ejemplos'] as List)
.map((example) => Example.fromJson(example))
.toList(),
consejo: json['consejo'],
);
}
IconData get iconData {
switch (icono) {
case 'eco':
return Icons.eco;
case 'recycling':
return Icons.recycling;
case 'delete':
return Icons.delete;
case 'warning':
return Icons.warning;
default:
return Icons.help;
}
}
Color get colorValue {
return Color(int.parse(color.substring(1, 7), radix: 16) + 0xFF000000);
}
}
class Example {
final String nombre;
final bool acepta;
final String? razon;
Example({required this.nombre, required this.acepta, this.razon});
factory Example.fromJson(Map<String, dynamic> json) {
return Example(
nombre: json['nombre'],
acepta: json['acepta'],
razon: json['razon'],
);
}
}

View File

@@ -0,0 +1,15 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recolecta_app/features/separation_guide/models/separation_guide_model.dart';
// Esta guía funciona offline. El chat con IA (mascota) es la capa extra cuando hay conexión — ver features/mascota/
final separationGuideProvider = FutureProvider<SeparationGuide>((ref) async {
// keepAlive: provider is non-autoDispose to avoid reloading the JSON on
// each navigation. This makes the guide work offline without repeated IO.
final jsonString = await rootBundle.loadString(
'assets/data/separation_guide.json',
);
final jsonResponse = json.decode(jsonString) as Map<String, dynamic>;
return SeparationGuide.fromJson(jsonResponse);
});

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recolecta_app/features/separation_guide/models/separation_guide_model.dart'
as guide;
import 'package:recolecta_app/features/separation_guide/providers/separation_guide_provider.dart';
class CategoryDetailScreen extends ConsumerWidget {
final String categoryId;
const CategoryDetailScreen({super.key, required this.categoryId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final guideData = ref.watch(separationGuideProvider);
return Scaffold(
body: guideData.when(
data: (data) {
final category = data.categorias.firstWhere(
(c) => c.id == categoryId,
);
return CustomScrollView(
slivers: [
SliverAppBar(
title: Text(category.nombre),
backgroundColor: category.colorValue,
expandedHeight: 120,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: Container(
color: category.colorValue,
alignment: Alignment.bottomLeft,
padding: const EdgeInsets.only(left: 16, bottom: 24),
child: Text(
category.descripcion,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: Colors.white),
),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final example = category.ejemplos[index];
return ListTile(
leading: Icon(
example.acepta ? Icons.check_circle : Icons.cancel,
color: example.acepta ? Colors.green : Colors.red,
),
title: Text(example.nombre),
subtitle: example.razon != null
? Chip(
label: Text(example.razon!),
backgroundColor: Colors.red.withOpacity(0.1),
)
: null,
);
}, childCount: category.ejemplos.length),
),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
),
bottomNavigationBar: guideData.maybeWhen(
data: (data) {
final category = data.categorias.firstWhere(
(c) => c.id == categoryId,
);
return BottomAppBar(
color: category.colorValue,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
category.consejo,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
);
},
orElse: () => null,
),
);
}
}

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:recolecta_app/features/separation_guide/providers/separation_guide_provider.dart';
import 'package:recolecta_app/features/separation_guide/models/separation_guide_model.dart'
as guide;
class SeparationGuideScreen extends ConsumerWidget {
const SeparationGuideScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final guideData = ref.watch(separationGuideProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Guía de Separación'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(20.0),
child: Text(
'Funciona sin internet',
style: Theme.of(context).textTheme.titleSmall,
),
),
),
body: guideData.when(
data: (data) => GridView.builder(
padding: const EdgeInsets.all(16.0),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16.0,
mainAxisSpacing: 16.0,
childAspectRatio: 1.2,
),
itemCount: data.categorias.length,
itemBuilder: (context, index) {
final category = data.categorias[index];
return CategoryCard(category: category);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
),
);
}
}
class CategoryCard extends StatelessWidget {
const CategoryCard({super.key, required this.category});
final guide.Category category;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => context.go('/guide/${category.id}'),
child: Card(
color: category.colorValue.withOpacity(0.15),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: category.colorValue, width: 1),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(category.iconData, size: 40, color: category.colorValue),
const SizedBox(height: 12),
Text(
category.nombre,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: category.colorValue,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
}

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'app/app.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await dotenv.load(fileName: '.env');
runApp(const ProviderScope(child: RecolectaApp()));
}