diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..a937e96 --- /dev/null +++ b/backend/app/api/admin.py @@ -0,0 +1,377 @@ +""" +Endpoints de administración — Solo accesibles para usuarios con role='admin'. + +Operan directamente contra Supabase (RLS bypaseado por service_role). +""" +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException + +from app.core.deps import require_role +from app.core.supabase_client import supabase_admin +from app.schemas.admin import ( + AdminUser, + AdminUserCreate, + AdminUserUpdate, + AdminRoute, + AdminRouteCreate, + AdminRouteUpdate, + AdminUnit, + AdminUnitCreate, + AdminUnitUpdate, + AdminDriver, + AdminDriverCreate, + AdminDriverUpdate, +) + +router = APIRouter( + prefix="/admin", + tags=["admin"], + dependencies=[Depends(require_role("admin"))], +) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── +def _auth_user_map() -> dict[str, dict]: + """Devuelve {user_id: {email, phone}} desde Supabase Auth (paginado).""" + mapping: dict[str, dict] = {} + try: + page = 1 + while True: + resp = supabase_admin.auth.admin.list_users(page=page, per_page=200) + users = getattr(resp, "users", None) or ( + resp if isinstance(resp, list) else [] + ) + if not users: + break + for u in users: + mapping[str(u.id)] = { + "email": getattr(u, "email", None), + "phone": getattr(u, "phone", None), + } + if len(users) < 200: + break + page += 1 + except Exception as e: + print(f"[admin] list_users falló: {e}") + return mapping + + +def _auth_user(user_id: str) -> dict: + try: + resp = supabase_admin.auth.admin.get_user_by_id(user_id) + u = getattr(resp, "user", None) or resp + return { + "email": getattr(u, "email", None), + "phone": getattr(u, "phone", None), + } + except Exception: + return {"email": None, "phone": None} + + +# ── Users ───────────────────────────────────────────────────────────────────── +@router.get("/users", response_model=list[AdminUser]) +def list_users(): + res = supabase_admin.table("users").select("id, name, role").execute() + rows = res.data or [] + auth_map = _auth_user_map() + return [ + AdminUser( + id=str(r["id"]), + name=r.get("name"), + role=r.get("role", "citizen"), + email=auth_map.get(str(r["id"]), {}).get("email"), + phone=auth_map.get(str(r["id"]), {}).get("phone"), + ) + for r in rows + ] + + +@router.post("/users", response_model=AdminUser, status_code=201) +def create_user(body: AdminUserCreate): + if not body.email and not body.phone: + raise HTTPException(400, "Se requiere email o teléfono") + if len(body.password) < 6: + raise HTTPException(400, "La contraseña debe tener al menos 6 caracteres") + + create_attrs: dict = {"password": body.password} + if body.email: + create_attrs["email"] = body.email + create_attrs["email_confirm"] = True + else: + create_attrs["phone"] = body.phone + create_attrs["phone_confirm"] = True + + try: + resp = supabase_admin.auth.admin.create_user(create_attrs) + except Exception as e: + raise HTTPException(400, f"Error al crear el usuario en Supabase Auth: {e}") + + auth_user = getattr(resp, "user", None) + if not auth_user: + raise HTTPException(500, "Supabase Auth no devolvió un usuario válido") + + try: + supabase_admin.table("users").upsert( + {"id": str(auth_user.id), "name": body.name, "role": body.role} + ).execute() + except Exception as e: + raise HTTPException(500, f"Error al guardar el usuario en public.users: {e}") + + return AdminUser( + id=str(auth_user.id), + name=body.name, + email=body.email, + phone=body.phone, + role=body.role, + ) + + +@router.patch("/users/{user_id}", response_model=AdminUser) +def update_user(user_id: str, body: AdminUserUpdate): + public_payload: dict = {} + if body.name is not None: + public_payload["name"] = body.name + if body.role is not None: + public_payload["role"] = body.role + if public_payload: + try: + supabase_admin.table("users").update(public_payload).eq( + "id", user_id + ).execute() + except Exception as e: + raise HTTPException(500, f"Error al actualizar public.users: {e}") + + if body.email is not None: + try: + supabase_admin.auth.admin.update_user_by_id( + user_id, {"email": body.email} + ) + except Exception as e: + raise HTTPException(500, f"Error al actualizar email: {e}") + + res = ( + supabase_admin.table("users") + .select("id, name, role") + .eq("id", user_id) + .maybe_single() + .execute() + ) + if not res.data: + raise HTTPException(404, "Usuario no encontrado") + auth = _auth_user(user_id) + return AdminUser( + id=str(res.data["id"]), + name=res.data.get("name"), + role=res.data.get("role", "citizen"), + email=auth.get("email"), + phone=auth.get("phone"), + ) + + +@router.delete("/users/{user_id}", status_code=204) +def delete_user(user_id: str): + try: + supabase_admin.auth.admin.delete_user(user_id) + except Exception as e: + raise HTTPException(500, f"Error al borrar el usuario: {e}") + try: + supabase_admin.table("users").delete().eq("id", user_id).execute() + except Exception: + pass + return None + + +# ── Routes ──────────────────────────────────────────────────────────────────── +_ROUTE_COLS = "id, name, truck_id, turno, status, current_position_id" + + +@router.get("/routes", response_model=list[AdminRoute]) +def list_routes(): + res = supabase_admin.table("routes").select(_ROUTE_COLS).execute() + return [AdminRoute(**r) for r in (res.data or [])] + + +@router.post("/routes", response_model=AdminRoute, status_code=201) +def create_route(body: AdminRouteCreate): + payload = body.model_dump(exclude_none=True) + try: + res = ( + supabase_admin.table("routes") + .insert(payload) + .execute() + ) + except Exception as e: + raise HTTPException(400, f"Error al crear la ruta: {e}") + row = (res.data or [None])[0] + if not row: + raise HTTPException(500, "Supabase no devolvió la fila creada") + return AdminRoute(**row) + + +@router.patch("/routes/{route_id}", response_model=AdminRoute) +def update_route(route_id: str, body: AdminRouteUpdate): + payload = body.model_dump(exclude_none=True) + if not payload: + raise HTTPException(400, "Sin cambios") + try: + supabase_admin.table("routes").update(payload).eq("id", route_id).execute() + except Exception as e: + raise HTTPException(400, f"Error al actualizar la ruta: {e}") + res = ( + supabase_admin.table("routes") + .select(_ROUTE_COLS) + .eq("id", route_id) + .maybe_single() + .execute() + ) + if not res.data: + raise HTTPException(404, "Ruta no encontrada") + return AdminRoute(**res.data) + + +@router.delete("/routes/{route_id}", status_code=204) +def delete_route(route_id: str): + try: + supabase_admin.table("routes").delete().eq("id", route_id).execute() + except Exception as e: + raise HTTPException(400, f"Error al borrar la ruta: {e}") + return None + + +# ── Units ───────────────────────────────────────────────────────────────────── +@router.get("/units", response_model=list[AdminUnit]) +def list_units(): + res = supabase_admin.table("units").select("id, plate, status").execute() + return [AdminUnit(**r) for r in (res.data or [])] + + +@router.post("/units", response_model=AdminUnit, status_code=201) +def create_unit(body: AdminUnitCreate): + try: + res = ( + supabase_admin.table("units") + .insert(body.model_dump(exclude_none=True)) + .execute() + ) + except Exception as e: + raise HTTPException(400, f"Error al crear la unidad: {e}") + row = (res.data or [None])[0] + if not row: + raise HTTPException(500, "Supabase no devolvió la fila creada") + return AdminUnit(**row) + + +@router.patch("/units/{unit_id}", response_model=AdminUnit) +def update_unit(unit_id: int, body: AdminUnitUpdate): + payload = body.model_dump(exclude_none=True) + if not payload: + raise HTTPException(400, "Sin cambios") + try: + supabase_admin.table("units").update(payload).eq("id", unit_id).execute() + except Exception as e: + raise HTTPException(400, f"Error al actualizar la unidad: {e}") + res = ( + supabase_admin.table("units") + .select("id, plate, status") + .eq("id", unit_id) + .maybe_single() + .execute() + ) + if not res.data: + raise HTTPException(404, "Unidad no encontrada") + return AdminUnit(**res.data) + + +@router.delete("/units/{unit_id}", status_code=204) +def delete_unit(unit_id: int): + try: + supabase_admin.table("units").delete().eq("id", unit_id).execute() + except Exception as e: + raise HTTPException(400, f"Error al borrar la unidad: {e}") + return None + + +# ── Drivers ─────────────────────────────────────────────────────────────────── +def _hydrate_driver(row: dict, users_map: dict[str, dict], units_map: dict[int, dict]) -> AdminDriver: + user_id = str(row.get("user_id")) + unit_id: Optional[int] = row.get("unit_id") + u = users_map.get(user_id, {}) + unit = units_map.get(unit_id, {}) if unit_id is not None else {} + return AdminDriver( + id=str(row["id"]), + user_id=user_id, + user_name=u.get("name"), + user_email=u.get("email"), + unit_id=unit_id, + plate=unit.get("plate"), + ) + + +def _drivers_context() -> tuple[dict[str, dict], dict[int, dict]]: + users_res = supabase_admin.table("users").select("id, name").execute() + auth_map = _auth_user_map() + users_map: dict[str, dict] = { + str(u["id"]): { + "name": u.get("name"), + "email": auth_map.get(str(u["id"]), {}).get("email"), + } + for u in (users_res.data or []) + } + units_res = supabase_admin.table("units").select("id, plate").execute() + units_map: dict[int, dict] = {u["id"]: u for u in (units_res.data or [])} + return users_map, units_map + + +@router.get("/drivers", response_model=list[AdminDriver]) +def list_drivers(): + res = supabase_admin.table("drivers").select("id, user_id, unit_id").execute() + users_map, units_map = _drivers_context() + return [_hydrate_driver(r, users_map, units_map) for r in (res.data or [])] + + +@router.post("/drivers", response_model=AdminDriver, status_code=201) +def create_driver(body: AdminDriverCreate): + try: + res = ( + supabase_admin.table("drivers") + .insert(body.model_dump(exclude_none=True)) + .execute() + ) + except Exception as e: + raise HTTPException(400, f"Error al crear el conductor: {e}") + row = (res.data or [None])[0] + if not row: + raise HTTPException(500, "Supabase no devolvió la fila creada") + users_map, units_map = _drivers_context() + return _hydrate_driver(row, users_map, units_map) + + +@router.patch("/drivers/{driver_id}", response_model=AdminDriver) +def update_driver(driver_id: str, body: AdminDriverUpdate): + payload = body.model_dump(exclude_none=True) + if not payload: + raise HTTPException(400, "Sin cambios") + try: + supabase_admin.table("drivers").update(payload).eq("id", driver_id).execute() + except Exception as e: + raise HTTPException(400, f"Error al actualizar el conductor: {e}") + res = ( + supabase_admin.table("drivers") + .select("id, user_id, unit_id") + .eq("id", driver_id) + .maybe_single() + .execute() + ) + if not res.data: + raise HTTPException(404, "Conductor no encontrado") + users_map, units_map = _drivers_context() + return _hydrate_driver(res.data, users_map, units_map) + + +@router.delete("/drivers/{driver_id}", status_code=204) +def delete_driver(driver_id: str): + try: + supabase_admin.table("drivers").delete().eq("id", driver_id).execute() + except Exception as e: + raise HTTPException(400, f"Error al borrar el conductor: {e}") + return None diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 1f03712..e08f378 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -74,10 +74,10 @@ def register(body: RegisterRequest): if not auth_user: raise HTTPException(status_code=400, detail="No se pudo crear el usuario en Supabase Auth") - # Crear entrada en public.users con el rol elegido + # Crear entrada en public.users con el rol y nombre elegidos try: supabase_admin.table("users").upsert( - {"id": str(auth_user.id), "role": body.role} + {"id": str(auth_user.id), "role": body.role, "name": body.name} ).execute() except Exception as e: raise HTTPException(status_code=500, detail=f"Error al guardar el usuario: {e}") diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..3bcaf51 --- /dev/null +++ b/backend/app/schemas/admin.py @@ -0,0 +1,91 @@ +from typing import Optional, Literal +from pydantic import BaseModel, EmailStr + + +# ── Users ───────────────────────────────────────────────────────────────────── +class AdminUser(BaseModel): + id: str + name: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + role: str = "citizen" + + +class AdminUserCreate(BaseModel): + name: str + password: str + email: Optional[EmailStr] = None + phone: Optional[str] = None + role: Literal["citizen", "driver", "admin"] = "citizen" + + +class AdminUserUpdate(BaseModel): + name: Optional[str] = None + email: Optional[EmailStr] = None + role: Optional[Literal["citizen", "driver", "admin"]] = None + + +# ── Routes ──────────────────────────────────────────────────────────────────── +class AdminRoute(BaseModel): + id: str + name: Optional[str] = None + truck_id: Optional[int] = None + turno: Optional[str] = None + status: str = "pendiente" + current_position_id: int = 1 + + +class AdminRouteCreate(BaseModel): + id: str + name: Optional[str] = None + truck_id: Optional[int] = None + turno: Optional[Literal["matutino", "vespertino", "Matutino", "Vespertino"]] = None + status: Optional[ + Literal["pendiente", "en_ruta", "completada", "diferida", "reasignada"] + ] = "pendiente" + + +class AdminRouteUpdate(BaseModel): + name: Optional[str] = None + truck_id: Optional[int] = None + turno: Optional[Literal["matutino", "vespertino", "Matutino", "Vespertino"]] = None + status: Optional[ + Literal["pendiente", "en_ruta", "completada", "diferida", "reasignada"] + ] = None + + +# ── Units (camiones) ────────────────────────────────────────────────────────── +class AdminUnit(BaseModel): + id: int + plate: Optional[str] = None + status: str = "active" + + +class AdminUnitCreate(BaseModel): + id: int + plate: Optional[str] = None + status: Literal["active", "inactive", "maintenance"] = "active" + + +class AdminUnitUpdate(BaseModel): + plate: Optional[str] = None + status: Optional[Literal["active", "inactive", "maintenance"]] = None + + +# ── Drivers ─────────────────────────────────────────────────────────────────── +class AdminDriver(BaseModel): + id: str + user_id: str + user_name: Optional[str] = None + user_email: Optional[str] = None + unit_id: Optional[int] = None + plate: Optional[str] = None + + +class AdminDriverCreate(BaseModel): + user_id: str + unit_id: Optional[int] = None + + +class AdminDriverUpdate(BaseModel): + unit_id: Optional[int] = None diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 70c532e..7bedd48 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -3,6 +3,7 @@ from typing import Optional, Literal class RegisterRequest(BaseModel): + name: str email: Optional[str] = None phone: Optional[str] = None password: str diff --git a/backend/main.py b/backend/main.py index dd60b14..de093ce 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,6 +9,8 @@ from app.api.auth import router as auth_router from app.api.addresses import router as addresses_router from app.api.colonias import router as colonias_router from app.api.users import router as users_router +from app.api.admin import router as admin_router +from app.api.simulation import router as simulation_router from app.services import simulation, notifications scheduler = AsyncIOScheduler() @@ -59,3 +61,5 @@ app.include_router(addresses_router) app.include_router(eta_router) app.include_router(colonias_router) app.include_router(users_router) +app.include_router(admin_router) +app.include_router(simulation_router) diff --git a/recolecta_app/assets/animations/.gitkeep b/recolecta_app/assets/animations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/recolecta_app/assets/animations/saludo.mp4 b/recolecta_app/assets/animations/saludo.mp4 new file mode 100644 index 0000000..a70625e Binary files /dev/null and b/recolecta_app/assets/animations/saludo.mp4 differ diff --git a/recolecta_app/lib/core/router/app_router.dart b/recolecta_app/lib/core/router/app_router.dart index 31b39ba..ee9fc85 100644 --- a/recolecta_app/lib/core/router/app_router.dart +++ b/recolecta_app/lib/core/router/app_router.dart @@ -3,6 +3,7 @@ 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/splash/splash_screen.dart'; import 'package:recolecta_app/features/auth/register_page.dart'; import 'package:recolecta_app/features/driver/driver_shell.dart'; import 'package:recolecta_app/features/driver/screens/driver_collections_screen.dart'; @@ -43,20 +44,21 @@ final routerProvider = Provider((ref) { final authState = ref.watch(authControllerProvider); return GoRouter( - initialLocation: '/login', + initialLocation: '/splash', redirect: (BuildContext context, GoRouterState state) { final isAuthenticated = authState.value?.isAuthenticated ?? false; final role = authState.value?.userRole; - final isAuthRoute = + final isPublicRoute = + state.matchedLocation == '/splash' || state.matchedLocation == '/login' || state.matchedLocation == '/register'; if (!isAuthenticated) { - return isAuthRoute ? null : '/login'; + return isPublicRoute ? null : '/login'; } - if (isAuthRoute) { + if (isPublicRoute) { switch (role) { case 'admin': return '/admin'; @@ -72,6 +74,10 @@ final routerProvider = Provider((ref) { return null; }, routes: [ + GoRoute( + path: '/splash', + builder: (context, state) => const SplashScreen(), + ), GoRoute(path: '/login', builder: (context, state) => const LoginPage()), GoRoute( path: '/register', diff --git a/recolecta_app/lib/core/services/auth_controller.dart b/recolecta_app/lib/core/services/auth_controller.dart index 8902f6e..9cadace 100644 --- a/recolecta_app/lib/core/services/auth_controller.dart +++ b/recolecta_app/lib/core/services/auth_controller.dart @@ -44,6 +44,7 @@ class AuthController extends AsyncNotifier { } Future register({ + required String name, required String email, required String phone, required String password, @@ -55,16 +56,19 @@ class AuthController extends AsyncNotifier { }) async { state = const AsyncLoading(); try { - final session = await ref.read(authServiceProvider).register( - email: email, - phone: phone, - password: password, - addressCalle: addressCalle, - addressColonia: addressColonia, - addressLabel: addressLabel, - addressLat: addressLat, - addressLng: addressLng, - ); + final session = await ref + .read(authServiceProvider) + .register( + name: name, + email: email, + phone: phone, + password: password, + addressCalle: addressCalle, + addressColonia: addressColonia, + addressLabel: addressLabel, + addressLat: addressLat, + addressLng: addressLng, + ); final authState = AuthState.authenticated( token: session.token, diff --git a/recolecta_app/lib/core/services/auth_service.dart b/recolecta_app/lib/core/services/auth_service.dart index 5d48b41..01cd9ff 100644 --- a/recolecta_app/lib/core/services/auth_service.dart +++ b/recolecta_app/lib/core/services/auth_service.dart @@ -38,6 +38,7 @@ class AuthService { } Future register({ + required String name, required String email, required String phone, required String password, @@ -50,6 +51,7 @@ class AuthService { return _authenticate( path: '/auth/register', payload: { + 'name': name, 'email': email, 'phone': phone, 'password': password, diff --git a/recolecta_app/lib/features/addresses/add_address_page.dart b/recolecta_app/lib/features/addresses/add_address_page.dart index 3e5b8c8..9c1ba32 100644 --- a/recolecta_app/lib/features/addresses/add_address_page.dart +++ b/recolecta_app/lib/features/addresses/add_address_page.dart @@ -164,10 +164,6 @@ class _AddAddressPageState extends ConsumerState { 'calle': _calleCtrl.text.trim(), 'colonia': _selectedColonia!.nombre, }; - if (_selectedLocation != null) { - body['lat'] = _selectedLocation!.latitude; - body['lng'] = _selectedLocation!.longitude; - } await dio.post('/addresses', data: body); if (mounted) Navigator.pop(context, true); @@ -396,19 +392,17 @@ class _AddAddressPageState extends ConsumerState { color: Colors.white, ), ) - : const Row( + : const FittedBox( key: ValueKey('text'), - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.check, size: 18), - SizedBox(width: 8), - Flexible( - child: Text( - 'Guardar dirección', - overflow: TextOverflow.ellipsis, - ), - ), - ], + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check, size: 18), + SizedBox(width: 8), + Text('Guardar dirección'), + ], + ), ), ), ), diff --git a/recolecta_app/lib/features/admin/admin_screen.dart b/recolecta_app/lib/features/admin/admin_screen.dart index a3298a2..8824d56 100644 --- a/recolecta_app/lib/features/admin/admin_screen.dart +++ b/recolecta_app/lib/features/admin/admin_screen.dart @@ -1,24 +1,1100 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/services/auth_controller.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_widgets.dart'; +import 'data/admin_service.dart'; +import 'models/admin_driver.dart'; +import 'models/admin_route.dart'; +import 'models/admin_unit.dart'; +import 'models/admin_user.dart'; +import 'providers/admin_providers.dart'; -// ── Modelos locales ─────────────────────────────────────────────────────────── -enum TruckStatus { disponible, enRuta, mantenimiento, detenido } +class AdminScreen extends ConsumerStatefulWidget { + const AdminScreen({super.key}); + + @override + ConsumerState createState() => _AdminScreenState(); +} + +class _AdminScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabController; + int _activeTab = 0; + + @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(); + } + + AdminService get _service => ref.read(adminServiceProvider); + + void _snack(String msg, {bool error = false}) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + backgroundColor: error ? AppTheme.danger : AppTheme.primary, + ), + ); + } + + Future _handleAdd() async { + switch (_activeTab) { + case 0: + await _showUserForm(); + break; + case 1: + await _showRouteForm(); + break; + case 2: + await _showUnitForm(); + break; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + title: const Text('Panel de administración'), + actions: [ + IconButton( + tooltip: 'Refrescar', + icon: const Icon(Icons.refresh), + onPressed: () { + ref.invalidate(adminUsersProvider); + ref.invalidate(adminRoutesProvider); + ref.invalidate(adminUnitsProvider); + ref.invalidate(adminDriversProvider); + }, + ), + IconButton( + tooltip: 'Cerrar sesión', + icon: const Icon(Icons.logout), + onPressed: () async { + await ref.read(authControllerProvider.notifier).logout(); + if (mounted) context.go('/login'); + }, + ), + ], + 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: const [_UsersTab(), _RoutesTab(), _TrucksTab()], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _handleAdd, + backgroundColor: AppTheme.primary, + icon: const Icon(Icons.add), + label: Text( + _activeTab == 0 + ? 'Nuevo usuario' + : _activeTab == 1 + ? 'Nueva ruta' + : 'Nuevo camión', + ), + ), + ); + } + + // ── Formulario usuario ────────────────────────────────────────────────────── + Future _showUserForm({AdminUserModel? user}) async { + final isEdit = user != null; + final nombre = TextEditingController(text: user?.name ?? ''); + final email = TextEditingController(text: user?.email ?? ''); + final telefono = TextEditingController(text: user?.phone ?? ''); + final password = TextEditingController(); + String role = user?.role ?? 'citizen'; + final formKey = GlobalKey(); + + final saved = await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setStateDialog) { + return AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), + title: Text(isEdit ? 'Editar usuario' : 'Nuevo usuario'), + content: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _textField(nombre, 'Nombre', required: true), + const SizedBox(height: 10), + _textField( + email, + 'Email', + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 10), + if (!isEdit) ...[ + _textField( + telefono, + 'Teléfono', + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 10), + _textField( + password, + 'Contraseña (mín. 6)', + obscure: true, + required: true, + validator: (v) => (v == null || v.length < 6) + ? 'Mínimo 6 caracteres' + : null, + ), + const SizedBox(height: 10), + ], + DropdownButtonFormField( + initialValue: role, + decoration: InputDecoration( + labelText: 'Rol', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppTheme.radiusMd, + ), + ), + ), + items: const [ + DropdownMenuItem( + value: 'citizen', + child: Text('Ciudadano'), + ), + DropdownMenuItem( + value: 'driver', + child: Text('Conductor'), + ), + DropdownMenuItem( + value: 'admin', + child: Text('Administrador'), + ), + ], + onChanged: (v) { + if (v != null) setStateDialog(() => role = v); + }, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancelar'), + ), + ElevatedButton( + onPressed: () async { + if (!formKey.currentState!.validate()) return; + try { + if (isEdit) { + await _service.updateUser( + user.id, + name: nombre.text.trim(), + email: email.text.trim().isEmpty + ? null + : email.text.trim(), + role: role, + ); + } else { + if (email.text.trim().isEmpty && + telefono.text.trim().isEmpty) { + _snack('Email o teléfono es requerido', error: true); + return; + } + await _service.createUser( + name: nombre.text.trim(), + password: password.text, + email: email.text.trim().isEmpty + ? null + : email.text.trim(), + phone: telefono.text.trim().isEmpty + ? null + : telefono.text.trim(), + role: role, + ); + } + if (ctx.mounted) Navigator.pop(ctx, true); + } catch (e) { + _snack('Error: ${_errMsg(e)}', error: true); + } + }, + child: const Text('Guardar'), + ), + ], + ); + }, + ); + }, + ); + + if (saved == true) { + ref.invalidate(adminUsersProvider); + ref.invalidate(adminDriversProvider); + _snack(isEdit ? 'Usuario actualizado' : 'Usuario creado'); + } + } + + // ── Formulario ruta ───────────────────────────────────────────────────────── + Future _showRouteForm({AdminRouteModel? route}) async { + final isEdit = route != null; + final id = TextEditingController(text: route?.id ?? ''); + final nombre = TextEditingController(text: route?.name ?? ''); + String? turno = route?.turno; + String status = route?.status ?? 'pendiente'; + int? truckId = route?.truckId; + final formKey = GlobalKey(); + final units = ref + .read(adminUnitsProvider) + .maybeWhen(data: (u) => u, orElse: () => []); + + final saved = await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setStateDialog) { + return AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), + title: Text(isEdit ? 'Editar ruta' : 'Nueva ruta'), + content: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _textField( + id, + 'ID (ej. RUTA-01)', + required: true, + enabled: !isEdit, + ), + const SizedBox(height: 10), + _textField(nombre, 'Nombre'), + const SizedBox(height: 10), + DropdownButtonFormField( + initialValue: turno, + decoration: InputDecoration( + labelText: 'Turno', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppTheme.radiusMd, + ), + ), + ), + items: const [ + DropdownMenuItem( + value: null, + child: Text('—'), + ), + DropdownMenuItem( + value: 'matutino', + child: Text('Matutino'), + ), + DropdownMenuItem( + value: 'vespertino', + child: Text('Vespertino'), + ), + ], + onChanged: (v) => setStateDialog(() => turno = v), + ), + const SizedBox(height: 10), + DropdownButtonFormField( + initialValue: status, + decoration: InputDecoration( + labelText: 'Status', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppTheme.radiusMd, + ), + ), + ), + items: const [ + DropdownMenuItem( + value: 'pendiente', + child: Text('Pendiente'), + ), + DropdownMenuItem( + value: 'en_ruta', + child: Text('En ruta'), + ), + DropdownMenuItem( + value: 'completada', + child: Text('Completada'), + ), + DropdownMenuItem( + value: 'diferida', + child: Text('Diferida'), + ), + DropdownMenuItem( + value: 'reasignada', + child: Text('Reasignada'), + ), + ], + onChanged: (v) { + if (v != null) setStateDialog(() => status = v); + }, + ), + const SizedBox(height: 10), + DropdownButtonFormField( + initialValue: truckId, + decoration: InputDecoration( + labelText: 'Camión asignado', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppTheme.radiusMd, + ), + ), + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Sin asignar'), + ), + ...units.map( + (u) => DropdownMenuItem( + value: u.id, + child: Text('${u.displayPlate} (#${u.id})'), + ), + ), + ], + onChanged: (v) => setStateDialog(() => truckId = v), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancelar'), + ), + ElevatedButton( + onPressed: () async { + if (!formKey.currentState!.validate()) return; + try { + if (isEdit) { + await _service.updateRoute( + route.id, + name: nombre.text.trim(), + truckId: truckId, + turno: turno, + status: status, + ); + } else { + await _service.createRoute( + id: id.text.trim(), + name: nombre.text.trim().isEmpty + ? null + : nombre.text.trim(), + truckId: truckId, + turno: turno, + status: status, + ); + } + if (ctx.mounted) Navigator.pop(ctx, true); + } catch (e) { + _snack('Error: ${_errMsg(e)}', error: true); + } + }, + child: const Text('Guardar'), + ), + ], + ); + }, + ); + }, + ); + + if (saved == true) { + ref.invalidate(adminRoutesProvider); + _snack(isEdit ? 'Ruta actualizada' : 'Ruta creada'); + } + } + + // ── Formulario camión (unit) ──────────────────────────────────────────────── + Future _showUnitForm({AdminUnitModel? unit}) async { + final isEdit = unit != null; + final idCtrl = TextEditingController(text: unit?.id.toString() ?? ''); + final plate = TextEditingController(text: unit?.plate ?? ''); + String status = unit?.status ?? 'active'; + final formKey = GlobalKey(); + + final saved = await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setStateDialog) { + return AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), + title: Text(isEdit ? 'Editar camión' : 'Nuevo camión'), + content: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _textField( + idCtrl, + 'ID numérico (ej. 101)', + keyboardType: TextInputType.number, + required: true, + enabled: !isEdit, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Requerido'; + if (int.tryParse(v) == null) + return 'Debe ser numérico'; + return null; + }, + ), + const SizedBox(height: 10), + _textField(plate, 'Placa'), + const SizedBox(height: 10), + DropdownButtonFormField( + initialValue: status, + decoration: InputDecoration( + labelText: 'Estado', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppTheme.radiusMd, + ), + ), + ), + items: const [ + DropdownMenuItem( + value: 'active', + child: Text('Activo'), + ), + DropdownMenuItem( + value: 'inactive', + child: Text('Inactivo'), + ), + DropdownMenuItem( + value: 'maintenance', + child: Text('Mantenimiento'), + ), + ], + onChanged: (v) { + if (v != null) setStateDialog(() => status = v); + }, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancelar'), + ), + ElevatedButton( + onPressed: () async { + if (!formKey.currentState!.validate()) return; + try { + if (isEdit) { + await _service.updateUnit( + unit.id, + plate: plate.text.trim().isEmpty + ? null + : plate.text.trim(), + status: status, + ); + } else { + await _service.createUnit( + id: int.parse(idCtrl.text.trim()), + plate: plate.text.trim().isEmpty + ? null + : plate.text.trim(), + status: status, + ); + } + if (ctx.mounted) Navigator.pop(ctx, true); + } catch (e) { + _snack('Error: ${_errMsg(e)}', error: true); + } + }, + child: const Text('Guardar'), + ), + ], + ); + }, + ); + }, + ); + + if (saved == true) { + ref.invalidate(adminUnitsProvider); + ref.invalidate(adminRoutesProvider); + _snack(isEdit ? 'Camión actualizado' : 'Camión creado'); + } + } + + // ── Helpers ───────────────────────────────────────────────────────────────── + Widget _textField( + TextEditingController controller, + String label, { + TextInputType keyboardType = TextInputType.text, + bool obscure = false, + bool required = false, + bool enabled = true, + String? Function(String?)? validator, + }) { + return TextFormField( + controller: controller, + keyboardType: keyboardType, + obscureText: obscure, + enabled: enabled, + validator: + validator ?? + (required + ? (v) => + (v == null || v.trim().isEmpty) ? 'Campo requerido' : null + : null), + decoration: InputDecoration( + labelText: label, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + ), + ), + ); + } + + String _errMsg(Object e) { + final s = e.toString(); + return s.length > 220 ? '${s.substring(0, 220)}…' : s; + } +} + +// ── Tabs ────────────────────────────────────────────────────────────────────── +class _UsersTab extends ConsumerWidget { + const _UsersTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(adminUsersProvider); + return async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => _ErrorView( + message: e.toString(), + onRetry: () => ref.invalidate(adminUsersProvider), + ), + data: (users) { + if (users.isEmpty) { + return const _EmptyView('No hay usuarios registrados.'); + } + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + itemCount: users.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final u = users[i]; + return AppCard( + child: Row( + children: [ + CircleAvatar( + backgroundColor: AppTheme.primaryLight, + foregroundColor: AppTheme.primary, + child: Text(u.initials), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + u.displayName, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + if (u.email != null && u.email!.isNotEmpty) + Text( + u.email!, + style: const TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + if (u.phone != null && u.phone!.isNotEmpty) + Text(u.phone!, style: const TextStyle(fontSize: 13)), + const SizedBox(height: 4), + _roleBadge(u.role), + ], + ), + ), + IconButton( + icon: const Icon( + Icons.edit_outlined, + color: AppTheme.primary, + ), + onPressed: () { + final state = context + .findAncestorStateOfType<_AdminScreenState>(); + state?._showUserForm(user: u); + }, + ), + IconButton( + icon: const Icon( + Icons.delete_outline, + color: AppTheme.danger, + ), + onPressed: () => _confirmAndDelete( + context, + tipo: 'usuario', + onConfirm: () async { + await ref.read(adminServiceProvider).deleteUser(u.id); + ref.invalidate(adminUsersProvider); + ref.invalidate(adminDriversProvider); + }, + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + + Widget _roleBadge(String role) { + switch (role) { + case 'admin': + return AppStatusBadge.amber('Administrador'); + case 'driver': + return AppStatusBadge.green('Conductor'); + default: + return AppStatusBadge.gray('Ciudadano'); + } + } +} + +class _RoutesTab extends ConsumerWidget { + const _RoutesTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(adminRoutesProvider); + final units = ref + .watch(adminUnitsProvider) + .maybeWhen(data: (u) => u, orElse: () => []); + + return async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => _ErrorView( + message: e.toString(), + onRetry: () => ref.invalidate(adminRoutesProvider), + ), + data: (routes) { + if (routes.isEmpty) { + return const _EmptyView('No hay rutas registradas.'); + } + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + itemCount: routes.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final r = routes[i]; + AdminUnitModel? unit; + if (r.truckId != null) { + for (final u in units) { + if (u.id == r.truckId) { + unit = u; + break; + } + } + } + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + r.displayName, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + _routeStatusBadge(r.status), + ], + ), + const SizedBox(height: 6), + Text( + 'ID: ${r.id}', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + if (r.turno != null) + Text( + 'Turno: ${r.turno}', + style: const TextStyle(fontSize: 13), + ), + Text( + 'Camión: ${unit?.displayPlate ?? (r.truckId == null ? 'Sin asignar' : '#${r.truckId}')}', + style: const TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + Text( + 'Posición actual: ${r.currentPositionId}/8', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () { + final state = context + .findAncestorStateOfType<_AdminScreenState>(); + state?._showRouteForm(route: r); + }, + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Editar'), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () => _confirmAndDelete( + context, + tipo: 'ruta', + onConfirm: () async { + await ref + .read(adminServiceProvider) + .deleteRoute(r.id); + ref.invalidate(adminRoutesProvider); + }, + ), + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Eliminar'), + style: TextButton.styleFrom( + foregroundColor: AppTheme.danger, + ), + ), + ], + ), + ], + ), + ); + }, + ); + }, + ); + } + + Widget _routeStatusBadge(String status) { + switch (status) { + case 'en_ruta': + return AppStatusBadge.amber('En ruta'); + case 'completada': + return AppStatusBadge.green('Completada'); + case 'diferida': + return AppStatusBadge.danger('Diferida'); + case 'reasignada': + return AppStatusBadge.amber('Reasignada'); + default: + return AppStatusBadge.gray('Pendiente'); + } + } +} + +class _TrucksTab extends ConsumerWidget { + const _TrucksTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(adminUnitsProvider); + final routes = ref + .watch(adminRoutesProvider) + .maybeWhen(data: (r) => r, orElse: () => []); + final drivers = ref + .watch(adminDriversProvider) + .maybeWhen(data: (d) => d, orElse: () => []); + + return async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => _ErrorView( + message: e.toString(), + onRetry: () => ref.invalidate(adminUnitsProvider), + ), + data: (units) { + if (units.isEmpty) { + return const _EmptyView('No hay camiones registrados.'); + } + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + itemCount: units.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final t = units[i]; + AdminRouteModel? assignedRoute; + for (final r in routes) { + if (r.truckId == t.id) { + assignedRoute = r; + break; + } + } + AdminDriverModel? assignedDriver; + for (final d in drivers) { + if (d.unitId == t.id) { + assignedDriver = d; + break; + } + } + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + t.displayPlate, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + _unitStatusBadge(t.status), + ], + ), + const SizedBox(height: 6), + Text( + 'ID: #${t.id}', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + Text( + 'Conductor: ${assignedDriver?.displayName ?? 'Sin asignar'}', + style: const TextStyle(fontSize: 13), + ), + Text( + 'Ruta: ${assignedRoute?.displayName ?? 'Sin asignar'}', + style: const TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () { + final state = context + .findAncestorStateOfType<_AdminScreenState>(); + state?._showUnitForm(unit: t); + }, + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Editar'), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () => _confirmAndDelete( + context, + tipo: 'camión', + onConfirm: () async { + await ref + .read(adminServiceProvider) + .deleteUnit(t.id); + ref.invalidate(adminUnitsProvider); + ref.invalidate(adminRoutesProvider); + }, + ), + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Eliminar'), + style: TextButton.styleFrom( + foregroundColor: AppTheme.danger, + ), + ), + ], + ), + ], + ), + ); + }, + ); + }, + ); + } + + Widget _unitStatusBadge(String status) { + switch (status) { + case 'inactive': + return AppStatusBadge.gray('Inactivo'); + case 'maintenance': + return AppStatusBadge.amber('Mantenimiento'); + default: + return AppStatusBadge.green('Activo'); + } + } +} + +// ── Shared widgets ──────────────────────────────────────────────────────────── +class _EmptyView extends StatelessWidget { + const _EmptyView(this.message); + final String message; + + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 15, color: AppTheme.textSecondary), + ), + ), + ); +} + +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.message, required this.onRetry}); + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: AppTheme.danger, size: 48), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Reintentar'), + ), + ], + ), + ), + ); +} + +void _confirmAndDelete( + BuildContext context, { + required String tipo, + required Future Function() 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: () async { + Navigator.pop(ctx); + try { + await onConfirm(); + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$tipo eliminado'))); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: AppTheme.danger, + ), + ); + } + } + }, + style: TextButton.styleFrom(foregroundColor: AppTheme.danger), + child: const Text('Eliminar'), + ), + ], + ), + ); +} + +// ── Legacy stubs (no longer used; kept enum to avoid breaking imports) ──────── +enum _LegacyTruckStatus { 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', - }; + 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), - }; + TruckStatus.disponible => AppStatusBadge.green(label), + TruckStatus.enRuta => AppStatusBadge.amber(label), + TruckStatus.mantenimiento => AppStatusBadge.gray(label), + TruckStatus.detenido => AppStatusBadge.gray(label), + }; } class _AdminUser { @@ -34,27 +1110,63 @@ class _AdminUser { 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); + _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}); + 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); + _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, + 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); + _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 ────────────────────────────────────────────────────────────────── @@ -71,18 +1183,49 @@ class _AdminScreenState extends State 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'), + 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), + 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'), + 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 @@ -122,20 +1265,25 @@ class _AdminScreenState extends State ), body: TabBarView( controller: _tabController, - children: [ - _buildUsersTab(), - _buildRoutesTab(), - _buildTrucksTab(), - ], + children: [_buildUsersTab(), _buildRoutesTab(), _buildTrucksTab()], ), floatingActionButton: FloatingActionButton.extended( onPressed: () { - if (_activeTab == 0) _showUserForm(); - else if (_activeTab == 1) _showRouteForm(); - else _showTruckForm(); + 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'), + label: Text( + _activeTab == 0 + ? 'Nuevo usuario' + : _activeTab == 1 + ? 'Nueva ruta' + : 'Nuevo camión', + ), icon: const Icon(Icons.add), ), ); @@ -163,15 +1311,38 @@ class _AdminScreenState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(u.nombreCompleto, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + 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.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)))), + 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), + ), + ), + ), ], ), ); @@ -194,23 +1365,50 @@ class _AdminScreenState extends State children: [ Row( children: [ - Expanded(child: Text(r.nombre, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600))), - r.activa ? AppStatusBadge.green('Activa') : AppStatusBadge.gray('Inactiva'), + 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)), + 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')), + 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))), + 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), + style: TextButton.styleFrom( + foregroundColor: AppTheme.danger, + ), ), ], ), @@ -230,31 +1428,62 @@ class _AdminScreenState extends State 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: '')); + 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))), + 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)), + 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')), + 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))), + 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), + style: TextButton.styleFrom( + foregroundColor: AppTheme.danger, + ), ), ], ), @@ -265,7 +1494,16 @@ class _AdminScreenState extends State ); } - 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)))); + 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) { @@ -273,13 +1511,24 @@ class _AdminScreenState extends State context: context, builder: (ctx) => AlertDialog( backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + 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); }, + 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'), ), @@ -298,24 +1547,59 @@ class _AdminScreenState extends State context: context, builder: (ctx) => AlertDialog( backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + 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), - ]), + 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: () => 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()); + 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; } + 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); }, @@ -336,24 +1620,55 @@ class _AdminScreenState extends State builder: (ctx) => StatefulBuilder( builder: (ctx, setInner) => AlertDialog( backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + 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)), - ]), - ]), + 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: () => 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); + 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; } + 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); }, @@ -371,43 +1686,90 @@ class _AdminScreenState extends State 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 : ''); + 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)), + 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( - 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( - 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); }, - ), - ]), + 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( + 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( + 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: () => 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); + 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; } + 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); }, diff --git a/recolecta_app/lib/features/admin/data/admin_service.dart b/recolecta_app/lib/features/admin/data/admin_service.dart new file mode 100644 index 0000000..23936de --- /dev/null +++ b/recolecta_app/lib/features/admin/data/admin_service.dart @@ -0,0 +1,191 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/network/api_client.dart'; +import '../models/admin_driver.dart'; +import '../models/admin_route.dart'; +import '../models/admin_unit.dart'; +import '../models/admin_user.dart'; + +final adminServiceProvider = Provider((ref) { + return AdminService(ref.read(apiClientProvider)); +}); + +class AdminService { + AdminService(this._dio); + final Dio _dio; + + // ── Users ─────────────────────────────────────────────────────────────────── + Future> listUsers() async { + final res = await _dio.get>('/admin/users'); + return (res.data ?? []) + .whereType() + .map((e) => AdminUserModel.fromJson(Map.from(e))) + .toList(); + } + + Future createUser({ + required String name, + required String password, + String? email, + String? phone, + String role = 'citizen', + }) async { + final res = await _dio.post>( + '/admin/users', + data: { + 'name': name, + 'password': password, + if (email != null && email.isNotEmpty) 'email': email, + if (phone != null && phone.isNotEmpty) 'phone': phone, + 'role': role, + }, + ); + return AdminUserModel.fromJson(res.data!); + } + + Future updateUser( + String id, { + String? name, + String? email, + String? role, + }) async { + final res = await _dio.patch>( + '/admin/users/$id', + data: { + if (name != null) 'name': name, + if (email != null) 'email': email, + if (role != null) 'role': role, + }, + ); + return AdminUserModel.fromJson(res.data!); + } + + Future deleteUser(String id) async { + await _dio.delete('/admin/users/$id'); + } + + // ── Routes ────────────────────────────────────────────────────────────────── + Future> listRoutes() async { + final res = await _dio.get>('/admin/routes'); + return (res.data ?? []) + .whereType() + .map((e) => AdminRouteModel.fromJson(Map.from(e))) + .toList(); + } + + Future createRoute({ + required String id, + String? name, + int? truckId, + String? turno, + String? status, + }) async { + final res = await _dio.post>( + '/admin/routes', + data: { + 'id': id, + if (name != null) 'name': name, + if (truckId != null) 'truck_id': truckId, + if (turno != null) 'turno': turno, + if (status != null) 'status': status, + }, + ); + return AdminRouteModel.fromJson(res.data!); + } + + Future updateRoute( + String id, { + String? name, + int? truckId, + String? turno, + String? status, + }) async { + final res = await _dio.patch>( + '/admin/routes/$id', + data: { + if (name != null) 'name': name, + if (truckId != null) 'truck_id': truckId, + if (turno != null) 'turno': turno, + if (status != null) 'status': status, + }, + ); + return AdminRouteModel.fromJson(res.data!); + } + + Future deleteRoute(String id) async { + await _dio.delete('/admin/routes/$id'); + } + + // ── Units ─────────────────────────────────────────────────────────────────── + Future> listUnits() async { + final res = await _dio.get>('/admin/units'); + return (res.data ?? []) + .whereType() + .map((e) => AdminUnitModel.fromJson(Map.from(e))) + .toList(); + } + + Future createUnit({ + required int id, + String? plate, + String status = 'active', + }) async { + final res = await _dio.post>( + '/admin/units', + data: {'id': id, if (plate != null) 'plate': plate, 'status': status}, + ); + return AdminUnitModel.fromJson(res.data!); + } + + Future updateUnit( + int id, { + String? plate, + String? status, + }) async { + final res = await _dio.patch>( + '/admin/units/$id', + data: { + if (plate != null) 'plate': plate, + if (status != null) 'status': status, + }, + ); + return AdminUnitModel.fromJson(res.data!); + } + + Future deleteUnit(int id) async { + await _dio.delete('/admin/units/$id'); + } + + // ── Drivers ───────────────────────────────────────────────────────────────── + Future> listDrivers() async { + final res = await _dio.get>('/admin/drivers'); + return (res.data ?? []) + .whereType() + .map((e) => AdminDriverModel.fromJson(Map.from(e))) + .toList(); + } + + Future createDriver({ + required String userId, + int? unitId, + }) async { + final res = await _dio.post>( + '/admin/drivers', + data: {'user_id': userId, if (unitId != null) 'unit_id': unitId}, + ); + return AdminDriverModel.fromJson(res.data!); + } + + Future updateDriver(String id, {int? unitId}) async { + final res = await _dio.patch>( + '/admin/drivers/$id', + data: {if (unitId != null) 'unit_id': unitId}, + ); + return AdminDriverModel.fromJson(res.data!); + } + + Future deleteDriver(String id) async { + await _dio.delete('/admin/drivers/$id'); + } +} diff --git a/recolecta_app/lib/features/admin/models/admin_driver.dart b/recolecta_app/lib/features/admin/models/admin_driver.dart new file mode 100644 index 0000000..5732df1 --- /dev/null +++ b/recolecta_app/lib/features/admin/models/admin_driver.dart @@ -0,0 +1,31 @@ +class AdminDriverModel { + final String id; + final String userId; + final String? userName; + final String? userEmail; + final int? unitId; + final String? plate; + + const AdminDriverModel({ + required this.id, + required this.userId, + this.userName, + this.userEmail, + this.unitId, + this.plate, + }); + + factory AdminDriverModel.fromJson(Map json) => + AdminDriverModel( + id: json['id'].toString(), + userId: json['user_id'].toString(), + userName: json['user_name'] as String?, + userEmail: json['user_email'] as String?, + unitId: (json['unit_id'] as num?)?.toInt(), + plate: json['plate'] as String?, + ); + + String get displayName => userName == null || userName!.trim().isEmpty + ? (userEmail ?? userId) + : userName!; +} diff --git a/recolecta_app/lib/features/admin/models/admin_route.dart b/recolecta_app/lib/features/admin/models/admin_route.dart new file mode 100644 index 0000000..ed597c8 --- /dev/null +++ b/recolecta_app/lib/features/admin/models/admin_route.dart @@ -0,0 +1,29 @@ +class AdminRouteModel { + final String id; + final String? name; + final int? truckId; + final String? turno; + final String status; + final int currentPositionId; + + const AdminRouteModel({ + required this.id, + this.name, + this.truckId, + this.turno, + this.status = 'pendiente', + this.currentPositionId = 1, + }); + + factory AdminRouteModel.fromJson(Map json) => + AdminRouteModel( + id: json['id'].toString(), + name: json['name'] as String?, + truckId: (json['truck_id'] as num?)?.toInt(), + turno: json['turno'] as String?, + status: (json['status'] as String?) ?? 'pendiente', + currentPositionId: (json['current_position_id'] as num?)?.toInt() ?? 1, + ); + + String get displayName => name == null || name!.trim().isEmpty ? id : name!; +} diff --git a/recolecta_app/lib/features/admin/models/admin_unit.dart b/recolecta_app/lib/features/admin/models/admin_unit.dart new file mode 100644 index 0000000..8fc4cee --- /dev/null +++ b/recolecta_app/lib/features/admin/models/admin_unit.dart @@ -0,0 +1,16 @@ +class AdminUnitModel { + final int id; + final String? plate; + final String status; + + const AdminUnitModel({required this.id, this.plate, this.status = 'active'}); + + factory AdminUnitModel.fromJson(Map json) => AdminUnitModel( + id: (json['id'] as num).toInt(), + plate: json['plate'] as String?, + status: (json['status'] as String?) ?? 'active', + ); + + String get displayPlate => + plate == null || plate!.trim().isEmpty ? '#$id' : plate!; +} diff --git a/recolecta_app/lib/features/admin/models/admin_user.dart b/recolecta_app/lib/features/admin/models/admin_user.dart new file mode 100644 index 0000000..d5a0084 --- /dev/null +++ b/recolecta_app/lib/features/admin/models/admin_user.dart @@ -0,0 +1,34 @@ +class AdminUserModel { + final String id; + final String? name; + final String? email; + final String? phone; + final String role; + + const AdminUserModel({ + required this.id, + this.name, + this.email, + this.phone, + this.role = 'citizen', + }); + + String get displayName => + (name == null || name!.trim().isEmpty) ? (email ?? phone ?? id) : name!; + + String get initials { + final source = displayName.trim(); + if (source.isEmpty) return '?'; + final parts = source.split(RegExp(r'\s+')); + if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); + return (parts[0].substring(0, 1) + parts[1].substring(0, 1)).toUpperCase(); + } + + factory AdminUserModel.fromJson(Map json) => AdminUserModel( + id: json['id'].toString(), + name: json['name'] as String?, + email: json['email'] as String?, + phone: json['phone'] as String?, + role: (json['role'] as String?) ?? 'citizen', + ); +} diff --git a/recolecta_app/lib/features/admin/providers/admin_providers.dart b/recolecta_app/lib/features/admin/providers/admin_providers.dart new file mode 100644 index 0000000..50be3c1 --- /dev/null +++ b/recolecta_app/lib/features/admin/providers/admin_providers.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/admin_service.dart'; +import '../models/admin_driver.dart'; +import '../models/admin_route.dart'; +import '../models/admin_unit.dart'; +import '../models/admin_user.dart'; + +final adminUsersProvider = FutureProvider>((ref) { + return ref.read(adminServiceProvider).listUsers(); +}); + +final adminRoutesProvider = FutureProvider>((ref) { + return ref.read(adminServiceProvider).listRoutes(); +}); + +final adminUnitsProvider = FutureProvider>((ref) { + return ref.read(adminServiceProvider).listUnits(); +}); + +final adminDriversProvider = FutureProvider>((ref) { + return ref.read(adminServiceProvider).listDrivers(); +}); diff --git a/recolecta_app/lib/features/auth/login_page.dart b/recolecta_app/lib/features/auth/login_page.dart index 603afb0..408b8fd 100644 --- a/recolecta_app/lib/features/auth/login_page.dart +++ b/recolecta_app/lib/features/auth/login_page.dart @@ -1,12 +1,13 @@ +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:dio/dio.dart'; +import '../../core/models/auth_state.dart'; +import '../../core/services/auth_controller.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_widgets.dart'; -import '../../core/services/auth_controller.dart'; -import '../../core/models/auth_state.dart'; +import 'widgets/video_mascot.dart'; class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); @@ -30,23 +31,18 @@ class _LoginPageState extends ConsumerState { ) { if (!mounted) return; if (next is AsyncError) { - String errorMessage = 'Ocurrió un error inesperado'; final error = next.error; - + String msg = 'Ocurrió un error inesperado'; if (error is DioException) { - if (error.response?.data != null && error.response?.data is Map) { - errorMessage = - error.response!.data['detail'] ?? 'Credenciales inválidas'; - } else { - errorMessage = 'Error de conexión con el servidor'; - } + msg = (error.response?.data is Map) + ? error.response!.data['detail'] ?? 'Credenciales inválidas' + : 'Error de conexión con el servidor'; } else { - errorMessage = error.toString(); + msg = error.toString(); } - ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(errorMessage), + content: Text(msg), backgroundColor: AppTheme.danger, behavior: SnackBarBehavior.floating, ), @@ -72,171 +68,189 @@ class _LoginPageState extends ConsumerState { @override Widget build(BuildContext context) { final loading = ref.watch(authControllerProvider).isLoading; + final screenH = MediaQuery.of(context).size.height; return Scaffold( backgroundColor: AppTheme.background, - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - iconTheme: const IconThemeData(color: AppTheme.textPrimary), - title: const Text( - 'Iniciar sesión', - style: TextStyle(color: AppTheme.textPrimary, fontSize: 16), - ), - ), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), + body: Column( + children: [ + // ── Cabecera verde con mascota ───────────────────────────── + _GreenHeader(height: screenH * 0.38), - // ── Encabezado ────────────────────────────────────────── - Row( + // ── Formulario ───────────────────────────────────────────── + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: AppTheme.primaryLight, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - ), - child: const Icon( - Icons.delete_outline_rounded, - color: AppTheme.primary, - size: 26, + AppFormField( + label: 'Correo electrónico', + hint: 'tu@correo.com', + controller: _emailCtrl, + keyboardType: TextInputType.emailAddress, + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Ingresa tu correo' + : null, + ), + const SizedBox(height: 16), + AppFormField( + label: 'Contraseña', + hint: '••••••••', + controller: _passCtrl, + obscureText: _obscurePass, + validator: (v) => (v == null || v.length < 6) + ? 'Mínimo 6 caracteres' + : null, + suffix: IconButton( + icon: Icon( + _obscurePass + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 18, + color: AppTheme.textSecondary, + ), + onPressed: () => + setState(() => _obscurePass = !_obscurePass), ), ), - const SizedBox(width: 14), - const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Recolecta', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary, - ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + foregroundColor: AppTheme.primary, + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - Text( - 'Bienvenido de nuevo', - style: TextStyle( - fontSize: 13, - color: AppTheme.textSecondary, - ), + child: const Text( + '¿Olvidaste tu contraseña?', + style: TextStyle(fontSize: 13), ), - ], + ), + ), + const SizedBox(height: 24), + SizedBox( + height: 52, + child: ElevatedButton( + onPressed: loading ? null : _submit, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: loading + ? const SizedBox( + key: ValueKey('loading'), + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + 'Ingresar', + key: ValueKey('text'), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + const SizedBox(height: 32), + Center( + child: Wrap( + alignment: WrapAlignment.center, + children: [ + const Text( + '¿No tienes cuenta? ', + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + GestureDetector( + onTap: () => context.go('/register'), + child: const Text( + 'Regístrate', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primary, + ), + ), + ), + ], + ), ), ], ), + ), + ), + ), + ], + ), + ); + } +} - const SizedBox(height: 32), +// ── Cabecera con gradiente verde y mascota ─────────────────────────────────── - // ── Formulario ────────────────────────────────────────── - AppFormField( - label: 'Correo electrónico', - hint: 'tu@correo.com', - controller: _emailCtrl, - keyboardType: TextInputType.emailAddress, - validator: (v) => (v == null || v.trim().isEmpty) - ? 'Ingresa tu correo' - : null, - ), - const SizedBox(height: 16), - AppFormField( - label: 'Contraseña', - hint: '••••••••', - controller: _passCtrl, - obscureText: _obscurePass, - validator: (v) => (v == null || v.length < 6) - ? 'Mínimo 6 caracteres' - : null, - suffix: IconButton( - icon: Icon( - _obscurePass - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - size: 18, - color: AppTheme.textSecondary, - ), - onPressed: () => - setState(() => _obscurePass = !_obscurePass), - ), - ), +class _GreenHeader extends StatelessWidget { + final double height; + const _GreenHeader({required this.height}); - const SizedBox(height: 10), - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () {}, - style: TextButton.styleFrom( - foregroundColor: AppTheme.primary, - ), - child: const Text( - '¿Olvidaste tu contraseña?', - style: TextStyle(fontSize: 13), + @override + Widget build(BuildContext context) { + return ClipPath( + clipper: _WaveClipper(), + child: Container( + height: height, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + stops: [0.0, 0.6, 1.0], + colors: [Color(0xFF0A4A38), Color(0xFF0F6E56), Color(0xFF1D9E75)], + ), + ), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 8), + const VideoMascot(size: 108), + const SizedBox(height: 16), + const Text( + 'RecolectApp', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.8, ), ), - ), - - const SizedBox(height: 24), - - // ── Botón ─────────────────────────────────────────────── - SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton( - onPressed: loading ? null : _submit, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: loading - ? const SizedBox( - key: ValueKey('loading'), - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Text('Ingresar', key: ValueKey('text')), + const SizedBox(height: 4), + Text( + 'Bienvenido de nuevo', + style: TextStyle( + fontSize: 14, + color: Colors.white.withValues(alpha: 0.82), + fontWeight: FontWeight.w400, ), ), - ), - - const SizedBox(height: 36), - - // ── Crear cuenta ──────────────────────────────────────── - Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - '¿No tienes cuenta? ', - style: TextStyle( - fontSize: 13, - color: AppTheme.textSecondary, - ), - ), - GestureDetector( - onTap: () => context.go('/register'), - child: const Text( - 'Regístrate', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppTheme.primary, - ), - ), - ), - ], - ), - ), - ], + const SizedBox(height: 28), + ], + ), ), ), ), @@ -244,3 +258,29 @@ class _LoginPageState extends ConsumerState { ); } } + +class _WaveClipper extends CustomClipper { + @override + Path getClip(Size size) { + final path = Path(); + path.lineTo(0, size.height - 36); + path.quadraticBezierTo( + size.width * 0.25, + size.height, + size.width * 0.5, + size.height - 18, + ); + path.quadraticBezierTo( + size.width * 0.75, + size.height - 36, + size.width, + size.height - 10, + ); + path.lineTo(size.width, 0); + path.close(); + return path; + } + + @override + bool shouldReclip(_WaveClipper old) => false; +} diff --git a/recolecta_app/lib/features/auth/register_page.dart b/recolecta_app/lib/features/auth/register_page.dart index 0f2545b..465dbeb 100644 --- a/recolecta_app/lib/features/auth/register_page.dart +++ b/recolecta_app/lib/features/auth/register_page.dart @@ -40,6 +40,7 @@ class _RegisterPageState extends ConsumerState { final _step1FormKey = GlobalKey(); // Paso 1 + final _nameCtrl = TextEditingController(); final _emailCtrl = TextEditingController(); final _telefonoCtrl = TextEditingController(); final _passCtrl = TextEditingController(); @@ -91,6 +92,7 @@ class _RegisterPageState extends ConsumerState { @override void dispose() { _pageController.dispose(); + _nameCtrl.dispose(); _emailCtrl.dispose(); _telefonoCtrl.dispose(); _passCtrl.dispose(); @@ -201,77 +203,19 @@ class _RegisterPageState extends ConsumerState { FocusScope.of(context).unfocus(); // Cierra el teclado } - Future _register() async { - if (_calleCtrl.text.trim().isEmpty || _selectedColonia == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Ingresa tu calle y selecciona una colonia'), - behavior: SnackBarBehavior.floating, - ), - ); - return; - } - - final phoneDigits = _telefonoCtrl.text.replaceAll(RegExp(r'\D'), ''); - final phone = phoneDigits.isNotEmpty ? '+52$phoneDigits' : ''; - - final calle = _calleCtrl.text.trim(); - final colonia = _selectedColonia!.nombre; - final lat = _selectedLocation?.latitude; - final lng = _selectedLocation?.longitude; - - try { - await ref - .read(authControllerProvider.notifier) - .register( - email: _emailCtrl.text.trim(), - phone: phone, - password: _passCtrl.text, - addressCalle: calle, - addressColonia: colonia, - addressLabel: 'Mi Casa', - addressLat: lat, - addressLng: lng, - ); - - // Guardado silencioso de la dirección tras un registro exitoso - _postAddressInBackground(calle, colonia, lat, lng); - } catch (_) { - // El error ya es manejado por el listener y muestra el SnackBar - } - } - - Future _postAddressInBackground( - String calle, - String colonia, - double? lat, - double? lng, - ) async { - try { - const storage = FlutterSecureStorage(); - await Future.delayed( - const Duration(milliseconds: 800), - ); // Esperar a que se guarde el JWT - final token = await storage.read(key: authTokenStorageKey) ?? ''; - - if (token.isNotEmpty) { - final dio = Dio( - BaseOptions( - baseUrl: const String.fromEnvironment( - 'API_BASE_URL', - defaultValue: 'http://localhost:8000', - ), - headers: {'Authorization': 'Bearer $token'}, - ), - ); - await dio.post( - '/addresses', - data: {'label': 'Mi Casa', 'calle': calle, 'colonia': colonia}, - ); - } - } catch (e) { - debugPrint('Aviso: No se pudo crear la dirección: $e'); - } + void _onRegister() { + final auth = ref.read(authControllerProvider.notifier); + auth.register( + name: _nameCtrl.text, + email: _emailCtrl.text, + phone: _telefonoCtrl.text, + password: _passCtrl.text, + addressCalle: _calleCtrl.text, + addressColonia: _selectedColonia?.nombre, + addressLabel: _tipoInmueble, + addressLat: _selectedLocation?.latitude, + addressLng: _selectedLocation?.longitude, + ); } @override @@ -299,35 +243,431 @@ class _RegisterPageState extends ConsumerState { controller: _pageController, physics: const NeverScrollableScrollPhysics(), children: [ - _Step1( - formKey: _step1FormKey, - emailCtrl: _emailCtrl, - telefonoCtrl: _telefonoCtrl, - passCtrl: _passCtrl, - obscurePass: _obscurePass, - onTogglePass: () => setState(() => _obscurePass = !_obscurePass), - onNext: _nextPage, + _buildStep1(context), + _buildStep2(context, loading, coloniasList), + ], + ), + bottomNavigationBar: _buildBottomControls(context, loading), + ); + } + + Widget _buildStep1(BuildContext context) { + return Form( + key: _step1FormKey, + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 40), + children: [ + const Text( + 'Crea tu cuenta', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), ), - _Step2( - mapController: _mapController, - cpCtrl: _cpCtrl, - calleCtrl: _calleCtrl, - selectedColonia: _selectedColonia, - selectedLocation: _selectedLocation, - tipoInmueble: _tipoInmueble, - whatsappNotif: _whatsappNotif, - loading: loading, - onTipoChanged: (v) => setState(() => _tipoInmueble = v), - onCPChanged: (v) => _validarCP(v, coloniasList), - onLocationChanged: _fetchStreetName, - onWhatsappChanged: (v) => - setState(() => _whatsappNotif = v ?? false), - onRegister: _register, + const SizedBox(height: 8), + const Text( + 'Ingresa tus datos para registrarte.', + style: TextStyle(fontSize: 15, color: AppTheme.textSecondary), + ), + const SizedBox(height: 28), + AppFormField( + controller: _nameCtrl, + label: 'Nombre completo', + validator: (val) => + val!.isEmpty ? 'Ingresa tu nombre completo' : null, + ), + const SizedBox(height: 16), + AppFormField( + controller: _emailCtrl, + label: 'Correo electrónico', + hint: 'tu@correo.com', + keyboardType: TextInputType.emailAddress, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Ingresa tu correo'; + final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+'); + if (!emailRegex.hasMatch(v.trim())) + return 'Ingresa un correo válido'; + return null; + }, + ), + const SizedBox(height: 14), + _PhoneField(controller: _telefonoCtrl), + const SizedBox(height: 14), + AppFormField( + label: 'Contraseña', + hint: '••••••••', + controller: _passCtrl, + obscureText: _obscurePass, + validator: (v) { + if (v == null || v.isEmpty) return 'Ingresa una contraseña'; + if (v.length < 6) return 'Mínimo 6 caracteres'; + return null; + }, + suffix: IconButton( + icon: Icon( + _obscurePass + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 18, + color: AppTheme.textSecondary, + ), + onPressed: () => setState(() => _obscurePass = !_obscurePass), + ), ), ], ), ); } + + Widget _buildStep2( + BuildContext context, + bool loading, + List coloniasList, + ) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + AppFormCard( + icon: Icons.home_outlined, + title: 'Dirección de tu casa', + child: Column( + children: [ + const Text( + 'Tipo de inmueble', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + ), + ), + Row( + children: [ + Expanded( + child: Material( + color: Colors.transparent, + child: RadioListTile( + title: const Text( + 'Casa', + style: TextStyle(fontSize: 14), + ), + value: 'Casa', + groupValue: _tipoInmueble, + onChanged: (v) => setState(() => _tipoInmueble = v!), + ), + ), + ), + Expanded( + child: Material( + color: Colors.transparent, + child: RadioListTile( + title: const Text( + 'Negocio', + style: TextStyle(fontSize: 14), + ), + value: 'Negocio', + groupValue: _tipoInmueble, + onChanged: (v) => setState(() => _tipoInmueble = v!), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + AppFormField( + label: 'Código Postal', + hint: 'Ej. 38000', + controller: _cpCtrl, + keyboardType: TextInputType.number, + onChanged: (v) => _validarCP(v, coloniasList), + ), + + if (_selectedColonia != null) ...[ + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.primaryLight.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all(color: AppTheme.primaryMid), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.check_circle_outline, + color: AppTheme.primary, + size: 18, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Colonia: ${_selectedColonia!.nombre}', + style: const TextStyle( + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Horario ${_selectedColonia!.turno?.toLowerCase() ?? 'asignado'}', + style: const TextStyle( + fontSize: 13, + color: AppTheme.textPrimary, + ), + ), + Text( + _selectedColonia!.horarioEstimado ?? + 'Sin horario especificado', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + ], + ), + ), + const SizedBox(height: 14), + AppFormField( + label: 'Calle y número', + hint: 'Av. Insurgentes 245', + controller: _calleCtrl, + ), + const SizedBox(height: 16), + const Text( + 'Toca el mapa para ubicar tu casa exacta:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + Container( + height: 200, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all(color: AppTheme.border), + ), + clipBehavior: Clip.hardEdge, + child: FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: + _selectedLocation ?? + const LatLng(20.5222, -100.8123), + initialZoom: 15.0, + onTap: (_, latlng) => _fetchStreetName(latlng), + ), + children: [ + TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.onlineshack.recolecta', + ), + if (_selectedLocation != null) + MarkerLayer( + markers: [ + Marker( + point: _selectedLocation!, + width: 40, + height: 40, + child: const Icon( + Icons.location_on, + color: AppTheme.danger, + size: 40, + ), + ), + ], + ), + ], + ), + ), + ] else ...[ + const SizedBox(height: 24), + const Center( + child: Text( + 'Ingresa un código postal con servicio\npara asignar tu colonia.', + textAlign: TextAlign.center, + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 13, + ), + ), + ), + ], + ], + ), + ), + const SizedBox(height: 16), + + // ── Sección OCR (Privacidad por diseño) ── + AppFormCard( + icon: Icons.document_scanner_outlined, + title: 'Verificación de Domicilio', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Para prevenir abusos, requerimos validar tu dirección con un recibo (luz o agua). ' + 'Por privacidad, la imagen será borrada inmediatamente después de la lectura.', + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + icon: const Icon( + Icons.upload_file, + color: AppTheme.primary, + ), + label: const Text( + 'Escanear recibo (OCR)', + style: TextStyle(color: AppTheme.primary), + ), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Abriendo cámara... (Próximamente)'), + ), + ); + }, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // ── Sección WhatsApp ── + AppFormCard( + icon: Icons.chat_outlined, + title: 'Notificaciones Externas', + child: Column( + children: [ + Material( + color: Colors.transparent, + child: CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: AppTheme.primary, + value: _whatsappNotif, + onChanged: (v) => + setState(() => _whatsappNotif = v ?? false), + title: const Text( + 'Recibir alertas del camión vía WhatsApp (Próximamente)', + style: TextStyle( + fontSize: 14, + color: AppTheme.textPrimary, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 28), + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: loading ? null : _onRegister, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: loading + ? const SizedBox( + key: ValueKey('loading'), + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Row( + key: ValueKey('text'), + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check, size: 18), + SizedBox(width: 8), + Flexible( + child: Text( + 'Registrarme', + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 16), + const Center( + child: Text( + 'Al registrarte aceptas los Términos de Servicio\ny la Política de Privacidad.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + color: AppTheme.textSecondary, + height: 1.5, + ), + ), + ), + ], + ), + ); + } + + Widget _buildBottomControls(BuildContext context, bool isLoading) { + return Container( + padding: const EdgeInsets.all( + 20, + ).copyWith(bottom: MediaQuery.of(context).padding.bottom + 20), + decoration: const BoxDecoration( + color: AppTheme.background, + border: Border(top: BorderSide(color: AppTheme.border, width: 0.5)), + ), + child: _currentPage == 0 + ? SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _nextPage, + child: const Text('Continuar'), + ), + ) + : SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: isLoading ? null : _onRegister, + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Crear mi cuenta'), + ), + ), + ); + } } // ── Indicador de pasos ──────────────────────────────────────────────────────── @@ -794,17 +1134,17 @@ class _Step2 extends StatelessWidget { color: Colors.white, ), ) - : const Row( + : const FittedBox( key: ValueKey('text'), - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.check, size: 18), - SizedBox(width: 8), - Flexible( - child: Text('Registrarme', - overflow: TextOverflow.ellipsis), - ), - ], + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check, size: 18), + SizedBox(width: 8), + Text('Registrarme'), + ], + ), ), ), ), diff --git a/recolecta_app/lib/features/auth/widgets/video_mascot.dart b/recolecta_app/lib/features/auth/widgets/video_mascot.dart new file mode 100644 index 0000000..bd43d42 --- /dev/null +++ b/recolecta_app/lib/features/auth/widgets/video_mascot.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class VideoMascot extends StatelessWidget { + final double size; + + const VideoMascot({super.key, this.size = 108}); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.1), + ), + clipBehavior: Clip.hardEdge, + // Cargamos el archivo como GIF + child: Image.asset( + 'assets/animations/blink_saludo.gif', + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + // Plan B: si el archivo no existe o hay error, mostramos la huellita + return const Center( + child: Icon(Icons.pets, color: Colors.white, size: 48), + ); + }, + ), + ); + } +} diff --git a/recolecta_app/lib/features/eta/eta_provider.dart b/recolecta_app/lib/features/eta/eta_provider.dart new file mode 100644 index 0000000..46d54bc --- /dev/null +++ b/recolecta_app/lib/features/eta/eta_provider.dart @@ -0,0 +1,49 @@ +// lib/features/eta/eta_provider.dart +// Riverpod AsyncNotifier: carga ETA al abrir la app y al recibir push FCM. +// No hace polling continuo. + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:recolecta_app/features/eta/eta_model.dart'; +import 'package:recolecta_app/features/eta/eta_service.dart'; + +// ────────────────────────────────────────── +// Provider del addressId activo del ciudadano +// (se puebla en el provider de auth/session) +// ────────────────────────────────────────── +class ActiveAddressIdNotifier extends Notifier { + @override + String? build() => null; +} + +final activeAddressIdProvider = + NotifierProvider( + ActiveAddressIdNotifier.new, + ); + +// ────────────────────────────────────────── +// AsyncNotifier principal de ETA +// ────────────────────────────────────────── +class EtaNotifier extends AsyncNotifier { + @override + Future build() async { + final addressId = ref.watch(activeAddressIdProvider); + if (addressId == null) { + throw Exception('No hay domicilio verificado'); + } + return ref.read(etaServiceProvider).fetchEta(addressId); + } + + /// Llamar desde la UI (botón refrescar) o desde el handler de FCM. + Future refresh() async { + state = const AsyncLoading(); + final addressId = ref.read(activeAddressIdProvider); + if (addressId == null) return; + state = await AsyncValue.guard( + () => ref.read(etaServiceProvider).fetchEta(addressId), + ); + } +} + +final etaProvider = AsyncNotifierProvider( + EtaNotifier.new, +); diff --git a/recolecta_app/lib/features/eta/eta_screen.dart b/recolecta_app/lib/features/eta/eta_screen.dart index f8faace..6e5e3a4 100644 --- a/recolecta_app/lib/features/eta/eta_screen.dart +++ b/recolecta_app/lib/features/eta/eta_screen.dart @@ -1,3 +1,14 @@ +// lib/features/eta/eta_screen.dart +// Vista principal del ciudadano: ETA con mapa de domicilio y progreso de ruta. +// Fusiona eta_screen.dart (doc-1) + eta_screen_v2.dart (doc-2). +// Orden visual: +// 1. Hero card (estado + ventana horaria) +// 2. Domicilio registrado +// 3. ProgressSteps ← nuevo: justo debajo del mapa/dirección +// 4. PreventionBanner +// 5. FCM badge +// 6. Horario semanal + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -5,43 +16,13 @@ import 'package:go_router/go_router.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_widgets.dart'; import '../../core/network/api_client.dart'; +import '../notifications/notification_service.dart'; +import '../../shared/widgets/prevention_banner.dart'; +import '../../shared/widgets/progress_steps.dart'; -// ── Provider de ETA ─────────────────────────────────────────────────────────── -final etaProvider = FutureProvider.autoDispose<_EtaResult>((ref) async { - final dio = ref.read(apiClientProvider); - - final addressesResp = await dio.get('/addresses'); - final raw = addressesResp.data; - - List 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( - '/eta', - queryParameters: {'address_id': addressId}, - ); - - final data = etaResp.data as Map; - return _EtaResult( - mensaje: data['mensaje'] as String? ?? '', - status: data['status'] as String? ?? '', - direccion: items.first['calle'] as String? ?? '', - colonia: items.first['colonia'] as String? ?? '', - hasAddress: true, - ); -}); - +// ───────────────────────────────────────────────────────────────────────────── +// Modelo de resultado ETA +// ───────────────────────────────────────────────────────────────────────────── class _EtaResult { final String mensaje; final String status; @@ -58,211 +39,446 @@ class _EtaResult { }); const _EtaResult.noAddress() - : mensaje = '', - status = '', - direccion = '', - colonia = '', - hasAddress = false; + : mensaje = '', + status = '', + direccion = '', + colonia = '', + hasAddress = false; + + // ── Utilidades derivadas ─────────────────────────────────────────────────── + + bool get isCompleted => status == 'completada'; + bool get isNearby => + mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo'); double get progreso { - if (mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo')) { - return 0.85; - } - if (mensaje.contains('finalizado')) return 1.0; + if (isNearby) return 0.85; + if (isCompleted) return 1.0; return 0.35; } + /// Índice para el widget ProgressSteps (0 = inicio, 1 = en ruta, 2 = cerca, + /// 3 = atendiendo, 4 = completado). Ajusta los valores según tu enum real. + int get stepIndex { + if (isCompleted) return 4; + if (isNearby) return 3; + if (status == 'en_ruta') return 2; + return 1; + } + String get etiquetaEstado { - if (status == 'completada') return 'Finalizado'; + if (isCompleted) return 'Finalizado'; if (status == 'en_ruta') return 'En ruta'; return 'Pendiente'; } } -// ── Pantalla ETA ────────────────────────────────────────────────────────────── -class EtaScreen extends ConsumerWidget { +// ───────────────────────────────────────────────────────────────────────────── +// Provider de ETA +// ───────────────────────────────────────────────────────────────────────────── +class _EtaNotifier extends AsyncNotifier<_EtaResult> { + @override + Future<_EtaResult> build() => _fetch(); + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(_fetch); + } + + Future<_EtaResult> _fetch() async { + final dio = ref.read(apiClientProvider); + + final addressesResp = await dio.get('/addresses'); + final raw = addressesResp.data; + + List 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( + '/eta', + queryParameters: {'address_id': addressId}, + ); + + final data = etaResp.data as Map; + return _EtaResult( + mensaje: data['mensaje'] as String? ?? '', + status: data['status'] as String? ?? '', + direccion: items.first['calle'] as String? ?? '', + colonia: items.first['colonia'] as String? ?? '', + hasAddress: true, + ); + } +} + +final etaProvider = AsyncNotifierProvider.autoDispose<_EtaNotifier, _EtaResult>( + _EtaNotifier.new, +); + +// Expone el routeId activo (se puebla desde el provider de sesión/domicilio) +class ActiveRouteIdNotifier extends Notifier { + @override + String? build() => null; +} + +final activeRouteIdProvider = NotifierProvider( + ActiveRouteIdNotifier.new, +); + +// ───────────────────────────────────────────────────────────────────────────── +// Pantalla principal +// ───────────────────────────────────────────────────────────────────────────── +class EtaScreen extends ConsumerStatefulWidget { const EtaScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _EtaScreenState(); +} + +class _EtaScreenState extends ConsumerState + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + // Refresca al recibir push FCM (RUTA_PROXIMITY, ROUTE_START, etc.) + NotificationService.onFcmMessage.addListener(_onPush); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + NotificationService.onFcmMessage.removeListener(_onPush); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + ref.read(etaProvider.notifier).refresh(); + } + } + + void _onPush() => ref.read(etaProvider.notifier).refresh(); + + @override + Widget build(BuildContext context) { final etaAsync = ref.watch(etaProvider); return Scaffold( backgroundColor: AppTheme.background, appBar: AppBar( - title: const Text('Estado del camión'), + title: const Text('Mi recolección'), actions: [ IconButton( - icon: const Icon(Icons.refresh), + icon: const Icon(Icons.refresh_rounded), tooltip: 'Actualizar', - onPressed: () => ref.invalidate(etaProvider), + onPressed: () => ref.read(etaProvider.notifier).refresh(), ), ], ), body: etaAsync.when( loading: () => const _EtaLoading(), - error: (error, _) => _EtaError( - error: error.toString(), - onRetry: () => ref.invalidate(etaProvider), + error: (e, _) => _EtaError( + error: e.toString(), + onRetry: () => ref.read(etaProvider.notifier).refresh(), ), data: (result) => result.hasAddress ? _EtaContent(result: result) - : _NoAddressState( - onAdd: () => context.go('/addresses/new'), - ), + : _NoAddressState(onAdd: () => context.go('/addresses/new')), ), ); } } -// ── Contenido ETA ───────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Contenido principal +// ───────────────────────────────────────────────────────────────────────────── class _EtaContent extends StatelessWidget { final _EtaResult result; const _EtaContent({required this.result}); @override Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - // ── Tarjeta de estado principal ──────────────────────────────── - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: AppTheme.primaryLight, - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - border: Border.all(color: AppTheme.primaryMid), - boxShadow: AppTheme.softShadow, + return RefreshIndicator( + onRefresh: () => ProviderScope.containerOf( + context, + ).read(etaProvider.notifier).refresh(), + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + children: [ + // ── 1. Hero card ──────────────────────────────────────────────── + _EtaHeroCard(result: result), + const SizedBox(height: 16), + + // ── 2. Domicilio registrado ───────────────────────────────────── + AppInfoRow( + icon: Icons.home_outlined, + label: 'Col. ${result.colonia}', + value: result.direccion.isEmpty ? 'Mi domicilio' : result.direccion, + trailing: AppStatusBadge.green('Activo'), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 12), + + // ── 3. Pasos de progreso (justo debajo del domicilio) ─────────── + ProgressSteps(stepIndex: result.stepIndex), + const SizedBox(height: 12), + + // ── 4. Banner de prevención ───────────────────────────────────── + const PreventionBanner(), + const SizedBox(height: 12), + + // ── 5. Badge de suscripción FCM ───────────────────────────────── + const _FcmStatusBadge(), + const SizedBox(height: 16), + + // ── 6. Horario semanal ────────────────────────────────────────── + AppSectionTitle(title: 'Horario del camión'), + _HorarioCard(), + const SizedBox(height: 24), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Hero card: estado + ventana horaria + barra de progreso +// ───────────────────────────────────────────────────────────────────────────── +class _EtaHeroCard extends StatelessWidget { + final _EtaResult result; + const _EtaHeroCard({required this.result}); + + Color _bgColor(BuildContext context) { + final cs = Theme.of(context).colorScheme; + if (result.isCompleted) return cs.surfaceContainerHighest; + if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50 + return const Color(0xFFE1F5EE); // teal-50 + } + + Color _accentColor(BuildContext context) { + if (result.isCompleted) return Theme.of(context).colorScheme.outline; + if (result.isNearby) return const Color(0xFFBA7517); // amber-400 + return const Color(0xFF1D9E75); // teal-400 + } + + @override + Widget build(BuildContext context) { + final accent = _accentColor(context); + final textTheme = Theme.of(context).textTheme; + + return AnimatedContainer( + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: _bgColor(context), + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: accent.withOpacity(0.3)), + boxShadow: AppTheme.softShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Cabecera: icono + etiqueta + punto vivo + Row( children: [ - Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: AppTheme.primary, - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.delete_outline_rounded, - color: Colors.white, - size: 24, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Camión recolector', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: AppTheme.primaryDark, - ), - ), - const SizedBox(height: 2), - AppStatusBadge.green(result.etiquetaEstado), - ], - ), - ), - _LiveDot(active: result.status == 'en_ruta'), - ], - ), - - const SizedBox(height: 20), - - // Mensaje ETA - Text( - result.mensaje, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - color: AppTheme.primaryDark, - height: 1.3, + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.delete_outline_rounded, + color: Colors.white, + size: 24, ), ), - - const SizedBox(height: 16), - - // Barra de progreso - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: result.progreso, - backgroundColor: - AppTheme.primaryMid.withValues(alpha: 0.35), - valueColor: - const AlwaysStoppedAnimation(AppTheme.primary), - minHeight: 8, - ), - ), - const SizedBox(height: 6), - const Row( - children: [ - Text('Inicio de ruta', - style: TextStyle( - fontSize: 10, color: AppTheme.primaryDark)), - Spacer(), - Text('Tu casa', - style: TextStyle( - fontSize: 10, color: AppTheme.primaryDark)), - ], - ), - ], - ), - ), - - const SizedBox(height: 16), - - // ── Domicilio registrado ─────────────────────────────────────── - AppInfoRow( - icon: Icons.home_outlined, - label: 'Col. ${result.colonia}', - value: result.direccion.isEmpty ? 'Mi domicilio' : result.direccion, - trailing: AppStatusBadge.green('Activo'), - ), - - const SizedBox(height: 16), - - // ── Aviso de privacidad ──────────────────────────────────────── - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppTheme.blueLight, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - ), - child: const Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.shield_outlined, color: AppTheme.blue, size: 18), - SizedBox(width: 10), + const SizedBox(width: 12), Expanded( - child: Text( - 'Tu ubicación exacta y la del camión no se comparten. Solo ves el estado de tu ruta.', - style: TextStyle( - fontSize: 12, color: AppTheme.blue, height: 1.5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Camión recolector', + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: accent, + ), + ), + const SizedBox(height: 2), + _StatusPill(result: result, accent: accent), + ], ), ), + _LiveDot(active: result.status == 'en_ruta'), + ], + ), + + const SizedBox(height: 16), + + // Ventana horaria o mensaje de estado + Text( + result.mensaje.isNotEmpty + ? result.mensaje + : _windowLabel(result.status), + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + color: accent, + height: 1.2, + ), + ), + + const SizedBox(height: 16), + + // Barra de progreso + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: result.progreso, + backgroundColor: accent.withOpacity(0.2), + valueColor: AlwaysStoppedAnimation(accent), + minHeight: 8, + ), + ), + const SizedBox(height: 6), + Row( + children: [ + Text( + 'Inicio de ruta', + style: TextStyle(fontSize: 10, color: accent.withOpacity(0.7)), + ), + const Spacer(), + Text( + 'Tu casa', + style: TextStyle(fontSize: 10, color: accent.withOpacity(0.7)), + ), ], ), + ], + ), + ); + } + + String _windowLabel(String s) { + switch (s) { + case 'completada': + return 'Servicio finalizado'; + case 'diferida': + return 'Servicio diferido'; + case 'reasignada': + return 'Ruta reasignada'; + default: + return 'En camino'; + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pill de estado con punto pulsante +// ───────────────────────────────────────────────────────────────────────────── +class _StatusPill extends StatelessWidget { + final _EtaResult result; + final Color accent; + const _StatusPill({required this.result, required this.accent}); + + @override + Widget build(BuildContext context) { + final label = result.isNearby + ? 'Cerca de tu domicilio' + : result.isCompleted + ? 'Servicio completado' + : 'En camino a tu sector'; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!result.isCompleted) _PulsingDot(color: accent), + if (!result.isCompleted) const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: accent.withOpacity(0.15), + borderRadius: BorderRadius.circular(100), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: accent, + ), + ), ), - - const SizedBox(height: 16), - - // ── Horario estimado de la semana ────────────────────────────── - AppSectionTitle(title: 'Horario del camión'), - _HorarioCard(), ], ); } } -// ── Punto animado "en vivo" ─────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Punto pulsante (animación de opacidad) +// ───────────────────────────────────────────────────────────────────────────── +class _PulsingDot extends StatefulWidget { + final Color color; + const _PulsingDot({required this.color}); + + @override + State<_PulsingDot> createState() => _PulsingDotState(); +} + +class _PulsingDotState extends State<_PulsingDot> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _anim; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + _anim = Tween(begin: 1.0, end: 0.3).animate(_ctrl); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _anim, + builder: (_, __) => Opacity( + opacity: _anim.value, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: widget.color, + shape: BoxShape.circle, + ), + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Punto vivo "EN VIVO" (escala + opacidad) +// ───────────────────────────────────────────────────────────────────────────── class _LiveDot extends StatefulWidget { final bool active; const _LiveDot({required this.active}); @@ -292,34 +508,87 @@ class _LiveDotState extends State<_LiveDot> @override Widget build(BuildContext context) { - if (!widget.active) { - return const SizedBox.shrink(); - } + if (!widget.active) return const SizedBox.shrink(); return AnimatedBuilder( animation: _anim, - builder: (_, child) => Container( + builder: (_, __) => Container( width: 10, height: 10, decoration: BoxDecoration( shape: BoxShape.circle, - color: AppTheme.primary - .withValues(alpha: 0.5 + _anim.value * 0.5), + color: AppTheme.primary.withValues(alpha: 0.5 + _anim.value * 0.5), ), ), ); } } -// ── Horario ─────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Badge de suscripción FCM +// ───────────────────────────────────────────────────────────────────────────── +class _FcmStatusBadge extends ConsumerWidget { + const _FcmStatusBadge(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final routeId = ref.watch(activeRouteIdProvider); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFF1D9E75), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text.rich( + TextSpan( + children: [ + const TextSpan( + text: 'Notificaciones activas ', + style: TextStyle(fontWeight: FontWeight.w500), + ), + TextSpan( + text: routeId != null + ? 'para topic_$routeId' + : '— suscribiendo...', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Horario semanal +// ───────────────────────────────────────────────────────────────────────────── class _HorarioCard extends StatelessWidget { - final List<_HorarioDia> _dias = const [ - _HorarioDia(dia: 'Lunes', hora: '8:00 – 10:00 a.m.', activo: true), - _HorarioDia(dia: 'Martes', hora: '8:00 – 10:00 a.m.', activo: true), - _HorarioDia(dia: 'Miércoles',hora: 'Sin servicio', activo: false), - _HorarioDia(dia: 'Jueves', hora: '8:00 – 10:00 a.m.', activo: true), - _HorarioDia(dia: 'Viernes', hora: '8:00 – 10:00 a.m.', activo: true), - _HorarioDia(dia: 'Sábado', hora: '9:00 – 11:00 a.m.', activo: true), - _HorarioDia(dia: 'Domingo', hora: 'Sin servicio', activo: false), + static const _dias = [ + _HorarioDia(dia: 'Lunes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Martes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Miércoles', hora: 'Sin servicio', activo: false), + _HorarioDia(dia: 'Jueves', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Viernes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Sábado', hora: '9:00 – 11:00 a.m.', activo: true), + _HorarioDia(dia: 'Domingo', hora: 'Sin servicio', activo: false), ]; @override @@ -369,11 +638,16 @@ class _HorarioDia { final String dia; final String hora; final bool activo; - const _HorarioDia( - {required this.dia, required this.hora, required this.activo}); + const _HorarioDia({ + required this.dia, + required this.hora, + required this.activo, + }); } -// ── Sin domicilio ───────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Sin domicilio registrado +// ───────────────────────────────────────────────────────────────────────────── class _NoAddressState extends StatelessWidget { final VoidCallback onAdd; const _NoAddressState({required this.onAdd}); @@ -393,23 +667,30 @@ class _NoAddressState extends StatelessWidget { color: AppTheme.primaryLight, shape: BoxShape.circle, ), - child: const Icon(Icons.home_outlined, - color: AppTheme.primary, size: 40), + child: const Icon( + Icons.home_outlined, + color: AppTheme.primary, + size: 40, + ), ), const SizedBox(height: 20), const Text( 'Sin domicilio registrado', style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary), + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), ), const SizedBox(height: 8), const Text( 'Registra tu domicilio para\nrecibir el ETA de tu ruta.', textAlign: TextAlign.center, style: TextStyle( - fontSize: 13, color: AppTheme.textSecondary, height: 1.5), + fontSize: 13, + color: AppTheme.textSecondary, + height: 1.5, + ), ), const SizedBox(height: 24), SizedBox( @@ -426,7 +707,9 @@ class _NoAddressState extends StatelessWidget { } } -// ── Cargando ────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Cargando +// ───────────────────────────────────────────────────────────────────────────── class _EtaLoading extends StatelessWidget { const _EtaLoading(); @@ -434,19 +717,23 @@ class _EtaLoading extends StatelessWidget { Widget build(BuildContext context) { return const Center( child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(color: AppTheme.primary), SizedBox(height: 16), - Text('Consultando estado del camión…', - style: TextStyle(color: AppTheme.textSecondary, fontSize: 14)), + Text( + 'Consultando estado del servicio...', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 14), + ), ], ), ); } } -// ── Error ───────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Error +// ───────────────────────────────────────────────────────────────────────────── class _EtaError extends StatelessWidget { final String error; final VoidCallback onRetry; @@ -460,25 +747,36 @@ class _EtaError extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.wifi_off_outlined, - color: AppTheme.textSecondary, size: 48), + const Icon( + Icons.wifi_off_rounded, + color: AppTheme.textSecondary, + size: 48, + ), const SizedBox(height: 16), - const Text('No se pudo obtener el estado', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), + const Text( + 'No se pudo obtener el estado', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), const SizedBox(height: 8), - Text(error, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), + Text( + error, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), const SizedBox(height: 20), SizedBox( width: 160, - child: ElevatedButton( + child: FilledButton.icon( onPressed: onRetry, - child: const Text('Reintentar'), + icon: const Icon(Icons.refresh_rounded), + label: const Text('Reintentar'), ), ), ], diff --git a/recolecta_app/lib/features/eta/eta_service.dart b/recolecta_app/lib/features/eta/eta_service.dart new file mode 100644 index 0000000..2ddb758 --- /dev/null +++ b/recolecta_app/lib/features/eta/eta_service.dart @@ -0,0 +1,25 @@ +// lib/features/eta/eta_service.dart +// Llama a GET /eta?address_id=X via dio. +// La respuesta NUNCA contiene coordenadas (validado en backend + RLS). + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:recolecta_app/core/network/api_client.dart'; +import 'package:recolecta_app/features/eta/eta_model.dart'; + +class EtaService { + final Dio _dio; + EtaService(this._dio); + + Future fetchEta(String addressId) async { + final response = await _dio.get>( + '/eta', + queryParameters: {'address_id': addressId}, + ); + return EtaResponse.fromJson(response.data!); + } +} + +final etaServiceProvider = Provider( + (ref) => EtaService(ref.read(apiClientProvider)), +); diff --git a/recolecta_app/lib/features/notifications/notification_service.dart b/recolecta_app/lib/features/notifications/notification_service.dart new file mode 100644 index 0000000..587c9ef --- /dev/null +++ b/recolecta_app/lib/features/notifications/notification_service.dart @@ -0,0 +1,130 @@ +// lib/features/notifications/notification_service.dart +// Gestiona FCM: suscripción a topic, handlers foreground/background. +// +// Regla de privacidad: los payloads de push NUNCA contienen lat/lng. +// El backend solo manda title/body desde notificaciones.json. + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +// Canal Android de alta prioridad para alertas de proximidad +const _kChannelId = 'recolecta_alerts'; +const _kChannelName = 'Alertas de recolección'; +const _kChannelDesc = 'Notificaciones de llegada del camión recolector'; + +/// Notifier simple: la EtaScreen lo escucha para refrescar sin polling. +class _FcmMessageNotifier extends ChangeNotifier { + RemoteMessage? lastMessage; + void notify(RemoteMessage msg) { + lastMessage = msg; + notifyListeners(); + } +} + +// Handler de background/terminated (top-level, fuera de clase) +@pragma('vm:entry-point') +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + // Solo loguear; la EtaScreen se refrescará cuando la app vuelva a foreground. + debugPrint('[FCM background] ${message.notification?.title}'); +} + +class NotificationService { + NotificationService._(); + + static final _messaging = FirebaseMessaging.instance; + static final _localNotifications = FlutterLocalNotificationsPlugin(); + static final onFcmMessage = _FcmMessageNotifier(); + + /// Inicializar una sola vez en main.dart + static Future initialize() async { + // Registrar handler de background + FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); + + // Solicitar permisos (iOS + Android 13+) + final settings = await _messaging.requestPermission( + alert: true, + badge: true, + sound: true, + ); + debugPrint('[FCM] Permission: ${settings.authorizationStatus}'); + + // Canal Android + const androidChannel = AndroidNotificationChannel( + _kChannelId, + _kChannelName, + description: _kChannelDesc, + importance: Importance.high, + ); + await _localNotifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.createNotificationChannel(androidChannel); + + // Inicializar flutter_local_notifications + const initSettings = InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + iOS: DarwinInitializationSettings(), + ); + await _localNotifications.initialize(initSettings); + + // Foreground: mostrar notificación local + notificar EtaScreen + FirebaseMessaging.onMessage.listen((message) { + _showLocalNotification(message); + onFcmMessage.notify(message); + }); + + // Tap en notificación cuando la app estaba en background + FirebaseMessaging.onMessageOpenedApp.listen((message) { + onFcmMessage.notify(message); + }); + + // Verificar si la app abrió desde una notificación (terminated) + final initial = await _messaging.getInitialMessage(); + if (initial != null) { + onFcmMessage.notify(initial); + } + } + + /// Suscribir al topic de la ruta del ciudadano. + /// Llamar justo después de que verified = true en el domicilio. + static Future subscribeToRoute(String routeId) async { + final topic = 'topic_$routeId'; + await _messaging.subscribeToTopic(topic); + debugPrint('[FCM] Suscrito a $topic'); + } + + /// Desuscribir (al cambiar de domicilio / colonia) + static Future unsubscribeFromRoute(String routeId) async { + final topic = 'topic_$routeId'; + await _messaging.unsubscribeFromTopic(topic); + debugPrint('[FCM] Desuscrito de $topic'); + } + + static Future _showLocalNotification(RemoteMessage message) async { + final notification = message.notification; + if (notification == null) return; + + // El payload del backend es solo title+body; NUNCA contiene coordenadas. + await _localNotifications.show( + notification.hashCode, + notification.title, + notification.body, + NotificationDetails( + android: AndroidNotificationDetails( + _kChannelId, + _kChannelName, + channelDescription: _kChannelDesc, + importance: Importance.high, + priority: Priority.high, + // Sin ningún campo de mapa o ubicación + ), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ), + ), + ); + } +} \ No newline at end of file diff --git a/recolecta_app/lib/features/notifications/notifications_screen.dart b/recolecta_app/lib/features/notifications/notifications_screen.dart index a1cb270..ef7a4b3 100644 --- a/recolecta_app/lib/features/notifications/notifications_screen.dart +++ b/recolecta_app/lib/features/notifications/notifications_screen.dart @@ -1,20 +1,378 @@ +// lib/features/notifications/notifications_screen.dart +// Historial de notificaciones FCM recibidas. +// Los items se almacenan en memoria (no en BD) — solo mensajes del topic propio. + +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; -import '../../core/theme/app_theme.dart'; - -class NotificationsScreen extends StatelessWidget { +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'notification_service.dart'; +import '../eta/eta_screen.dart'; // activeRouteIdProvider + +// ────────────────────────────────────────── +// Modelo local de item de notificación +// ────────────────────────────────────────── +enum FcmEventType { routeStart, truckProximity, routeCompleted, reassignment, unknown } + +FcmEventType _eventTypeFromMessage(RemoteMessage msg) { + final type = msg.data['event'] as String?; + switch (type) { + case 'ROUTE_START': + return FcmEventType.routeStart; + case 'TRUCK_PROXIMITY': + return FcmEventType.truckProximity; + case 'ROUTE_COMPLETED': + return FcmEventType.routeCompleted; + case 'reasignacion': + case 'retraso': + return FcmEventType.reassignment; + default: + return FcmEventType.unknown; + } +} + +class NotificationItem { + final String title; + final String body; + final FcmEventType type; + final DateTime receivedAt; + + const NotificationItem({ + required this.title, + required this.body, + required this.type, + required this.receivedAt, + }); +} + +// ────────────────────────────────────────── +// Provider: lista de notificaciones en memoria +// ────────────────────────────────────────── +final notificationsListProvider = + NotifierProvider>( + NotificationsNotifier.new, +); + +class NotificationsNotifier extends Notifier> { + @override + List build() { + // Escuchar mensajes FCM en foreground + NotificationService.onFcmMessage.addListener(_onMessage); + ref.onDispose( + () => NotificationService.onFcmMessage.removeListener(_onMessage), + ); + return []; + } + + void _onMessage() { + final msg = NotificationService.onFcmMessage.lastMessage; + if (msg == null) return; + final item = NotificationItem( + title: msg.notification?.title ?? 'Recolección', + body: msg.notification?.body ?? '', + type: _eventTypeFromMessage(msg), + receivedAt: DateTime.now(), + ); + state = [item, ...state]; + } + + void clearAll() => state = []; +} + +// ────────────────────────────────────────── +// Pantalla de notificaciones +// ────────────────────────────────────────── +class NotificationsScreen extends ConsumerWidget { const NotificationsScreen({super.key}); - + + @override + Widget build(BuildContext context, WidgetRef ref) { + final items = ref.watch(notificationsListProvider); + final routeId = ref.watch(activeRouteIdProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Notificaciones'), + actions: [ + if (items.isNotEmpty) + TextButton( + onPressed: () => + ref.read(notificationsListProvider.notifier).clearAll(), + child: const Text('Limpiar'), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + // Badge de suscripción FCM + _FcmTopicBadge(routeId: routeId), + const SizedBox(height: 12), + + // Aviso de privacidad + _PrivacyNote(), + const SizedBox(height: 16), + + if (items.isEmpty) + const _EmptyState() + else ...[ + const _SectionLabel(label: 'Recientes'), + ...items.map((item) => _NotificationCard(item: item)), + ], + ], + ), + ); + } +} + +// ────────────────────────────────────────── +// Widgets auxiliares +// ────────────────────────────────────────── +class _FcmTopicBadge extends StatelessWidget { + final String? routeId; + const _FcmTopicBadge({required this.routeId}); + @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar(title: const Text('Avisos y Alertas')), - body: const Center( - child: Text( - 'Bandeja de entrada de FCM', - style: TextStyle(color: AppTheme.textSecondary), + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFF1D9E75), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text.rich( + TextSpan(children: [ + const TextSpan( + text: 'Suscrito a ', + style: TextStyle(fontSize: 12), + ), + TextSpan( + text: routeId != null + ? 'topic_$routeId' + : 'topic pendiente', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + const TextSpan( + text: ' · Solo recibes eventos de tu ruta', + style: TextStyle(fontSize: 12), + ), + ]), + ), + ), + ], + ), + ); + } +} + +class _PrivacyNote extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFAEEDA), // amber-50 + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFFAC775)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline_rounded, + size: 18, color: Color(0xFFBA7517)), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Los mensajes no revelan la ubicación del camión. Solo se muestra el tiempo estimado de llegada.', + style: const TextStyle(fontSize: 12, color: Color(0xFF633806)), + maxLines: 3, + ), + ), + ], + ), + ); + } +} + +class _SectionLabel extends StatelessWidget { + final String label; + const _SectionLabel({required this.label}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + label.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.8, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ); } } + +class _NotificationCard extends StatelessWidget { + final NotificationItem item; + const _NotificationCard({required this.item}); + + IconData get _icon { + switch (item.type) { + case FcmEventType.routeStart: + return Icons.arrow_forward_rounded; + case FcmEventType.truckProximity: + return Icons.local_shipping_rounded; + case FcmEventType.routeCompleted: + return Icons.check_circle_outline_rounded; + case FcmEventType.reassignment: + return Icons.swap_horiz_rounded; + default: + return Icons.notifications_outlined; + } + } + + Color _accentColor() { + switch (item.type) { + case FcmEventType.routeStart: + return const Color(0xFF1D9E75); + case FcmEventType.truckProximity: + return const Color(0xFFBA7517); + case FcmEventType.routeCompleted: + return Colors.grey; + case FcmEventType.reassignment: + return const Color(0xFF378ADD); + default: + return Colors.grey; + } + } + + String _relativeTime() { + final diff = DateTime.now().difference(item.receivedAt); + if (diff.inMinutes < 1) return 'Ahora mismo'; + if (diff.inMinutes < 60) return 'Hace ${diff.inMinutes} min'; + if (diff.inHours < 24) return 'Hace ${diff.inHours} h'; + return 'Ayer'; + } + + @override + Widget build(BuildContext context) { + final accent = _accentColor(); + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(10), + border: Border( + left: BorderSide(color: accent, width: 3), + top: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5), + right: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5), + bottom: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: accent.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(_icon, size: 16, color: accent), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + item.body, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + const SizedBox(height: 4), + Text( + _relativeTime(), + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.notifications_none_rounded, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'Sin notificaciones aún', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + 'Recibirás un aviso cuando el camión esté cerca.', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/recolecta_app/lib/features/splash/splash_screen.dart b/recolecta_app/lib/features/splash/splash_screen.dart new file mode 100644 index 0000000..cfb6a44 --- /dev/null +++ b/recolecta_app/lib/features/splash/splash_screen.dart @@ -0,0 +1,312 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class SplashScreen extends StatefulWidget { + const SplashScreen({super.key}); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State + with TickerProviderStateMixin { + late final AnimationController _logoCtrl; + late final AnimationController _textCtrl; + late final AnimationController _bubblesCtrl; + + late final Animation _logoScale; + late final Animation _logoOpacity; + late final Animation _textSlide; + late final Animation _textOpacity; + late final Animation _subtitleOpacity; + + @override + void initState() { + super.initState(); + + _logoCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + _textCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + _bubblesCtrl = AnimationController( + vsync: this, + duration: const Duration(seconds: 6), + )..repeat(); + + _logoScale = Tween(begin: 0.2, end: 1.0).animate( + CurvedAnimation(parent: _logoCtrl, curve: Curves.elasticOut), + ); + _logoOpacity = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _logoCtrl, + curve: const Interval(0.0, 0.4, curve: Curves.easeIn), + ), + ); + _textSlide = Tween( + begin: const Offset(0, 0.4), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _textCtrl, curve: Curves.easeOut)); + _textOpacity = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _textCtrl, + curve: const Interval(0.0, 0.6, curve: Curves.easeIn), + ), + ); + _subtitleOpacity = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _textCtrl, + curve: const Interval(0.4, 1.0, curve: Curves.easeIn), + ), + ); + + _runSequence(); + } + + Future _runSequence() async { + await Future.delayed(const Duration(milliseconds: 400)); + if (!mounted) return; + _logoCtrl.forward(); + await Future.delayed(const Duration(milliseconds: 600)); + if (!mounted) return; + _textCtrl.forward(); + await Future.delayed(const Duration(milliseconds: 2200)); + if (mounted) context.go('/login'); + } + + @override + void dispose() { + _logoCtrl.dispose(); + _textCtrl.dispose(); + _bubblesCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + stops: [0.0, 0.55, 1.0], + colors: [ + Color(0xFF0A4A38), + Color(0xFF0F6E56), + Color(0xFF1D9E75), + ], + ), + ), + child: Stack( + children: [ + // Burbujas decorativas animadas + AnimatedBuilder( + animation: _bubblesCtrl, + builder: (_, _) => CustomPaint( + painter: _BubblesPainter(_bubblesCtrl.value), + size: Size.infinite, + ), + ), + + SafeArea( + child: Column( + children: [ + const Spacer(flex: 3), + + // Logo central + ScaleTransition( + scale: _logoScale, + child: FadeTransition( + opacity: _logoOpacity, + child: Container( + width: 118, + height: 118, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(34), + border: Border.all( + color: Colors.white.withValues(alpha: 0.35), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: const Icon( + Icons.recycling_rounded, + size: 64, + color: Colors.white, + ), + ), + ), + ), + + const SizedBox(height: 36), + + // Nombre de la app + SlideTransition( + position: _textSlide, + child: FadeTransition( + opacity: _textOpacity, + child: const Text( + 'RecolectApp', + style: TextStyle( + fontSize: 38, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -1.0, + height: 1.1, + ), + ), + ), + ), + + const SizedBox(height: 10), + + // Subtítulo + FadeTransition( + opacity: _subtitleOpacity, + child: Text( + 'Sistema de Recolección Inteligente', + style: TextStyle( + fontSize: 14, + color: Colors.white.withValues(alpha: 0.8), + letterSpacing: 0.4, + fontWeight: FontWeight.w400, + ), + ), + ), + + const Spacer(flex: 3), + + // Indicador de carga + FadeTransition( + opacity: _subtitleOpacity, + child: const Padding( + padding: EdgeInsets.only(bottom: 52), + child: _DotsLoader(), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ── Loader de tres puntos ──────────────────────────────────────────────────── + +class _DotsLoader extends StatefulWidget { + const _DotsLoader(); + + @override + State<_DotsLoader> createState() => _DotsLoaderState(); +} + +class _DotsLoaderState extends State<_DotsLoader> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + )..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (_, _) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + final phase = (_ctrl.value - i * 0.2).clamp(0.0, 1.0); + final wave = (sin(phase * pi)).clamp(0.0, 1.0); + return AnimatedContainer( + duration: Duration.zero, + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.35 + 0.65 * wave), + ), + ); + }), + ); + }, + ); + } +} + +// ── Burbujas decorativas de fondo ──────────────────────────────────────────── + +class _BubblesPainter extends CustomPainter { + final double t; + + _BubblesPainter(this.t); + + static const _bubbles = [ + _Bubble(0.08, 0.15, 60, 0.0), + _Bubble(0.85, 0.08, 90, 0.2), + _Bubble(0.72, 0.78, 50, 0.5), + _Bubble(0.15, 0.85, 70, 0.7), + _Bubble(0.50, 0.05, 40, 0.35), + _Bubble(0.92, 0.55, 35, 0.9), + ]; + + @override + void paint(Canvas canvas, Size size) { + for (final b in _bubbles) { + final phase = (t + b.phase) % 1.0; + final floatY = sin(phase * 2 * pi) * 12; + final paint = Paint() + ..color = Colors.white.withValues(alpha: 0.04 + 0.03 * sin(phase * pi)) + ..style = PaintingStyle.fill; + canvas.drawCircle( + Offset(b.xFrac * size.width, b.yFrac * size.height + floatY), + b.radius, + paint, + ); + final strokePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.07) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + canvas.drawCircle( + Offset(b.xFrac * size.width, b.yFrac * size.height + floatY), + b.radius, + strokePaint, + ); + } + } + + @override + bool shouldRepaint(_BubblesPainter old) => old.t != t; +} + +class _Bubble { + final double xFrac, yFrac, radius, phase; + const _Bubble(this.xFrac, this.yFrac, this.radius, this.phase); +} diff --git a/recolecta_app/lib/shared/widgets/prevention_banner.dart b/recolecta_app/lib/shared/widgets/prevention_banner.dart new file mode 100644 index 0000000..4edb065 --- /dev/null +++ b/recolecta_app/lib/shared/widgets/prevention_banner.dart @@ -0,0 +1,51 @@ +// lib/shared/widgets/prevention_banner.dart +// Banner de mensajería preventiva — obligatorio en la vista ETA. +// Regla de privacidad #5: textos que desalientan sacar basura fuera de horario +// o perseguir la unidad. + +import 'package:flutter/material.dart'; + +class PreventionBanner extends StatelessWidget { + final String? customMessage; + + const PreventionBanner({super.key, this.customMessage}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFAEEDA), // amber-50 + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFFAC775)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 1), + child: Icon( + Icons.warning_amber_rounded, + size: 18, + color: Color(0xFFBA7517), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + customMessage ?? + 'No saques tu basura antes de recibir el aviso de proximidad ' + 'ni dejes bolsas en la calle por más de 30 min. ' + 'No persigas ni detengas la unidad recolectora.', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF633806), + height: 1.5, + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/recolecta_app/lib/shared/widgets/progress_steps.dart b/recolecta_app/lib/shared/widgets/progress_steps.dart new file mode 100644 index 0000000..0a95e31 --- /dev/null +++ b/recolecta_app/lib/shared/widgets/progress_steps.dart @@ -0,0 +1,174 @@ +// lib/shared/widgets/progress_steps.dart +// Barra de 4 pasos del servicio. Sin mapa ni coordenadas. +// Los pasos mapean a los eventos de positionId del backend: +// 0 = pendiente, 1 = ROUTE_START (pos 2), 2 = TRUCK_PROXIMITY (pos 4), 3 = ROUTE_COMPLETED (pos 8) + +import 'package:flutter/material.dart'; + +class ProgressSteps extends StatelessWidget { + /// 0 = pendiente, 1 = en camino, 2 = cerca, 3 = completado + final int stepIndex; + + const ProgressSteps({super.key, required this.stepIndex}); + + static const _steps = [ + _StepData('Servicio pendiente', Icons.access_time_rounded), + _StepData('Salió al sector', Icons.arrow_forward_rounded), + _StepData('Cerca (~15 min)', Icons.local_shipping_rounded), + _StepData('Finalizado', Icons.check_circle_outline_rounded), + ]; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 8), + child: Row( + children: [ + const Icon(Icons.route_rounded, + size: 16, color: Color(0xFF1D9E75)), + const SizedBox(width: 6), + Text( + 'Progreso del servicio', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + ), + const Divider(height: 1, thickness: 0.5), + ...List.generate(_steps.length, (i) { + final status = _stepStatus(i); + return _StepRow( + data: _steps[i], + status: status, + isLast: i == _steps.length - 1, + ); + }), + ], + ), + ); + } + + _Status _stepStatus(int i) { + if (i < stepIndex) return _Status.done; + if (i == stepIndex) return _Status.active; + return _Status.pending; + } +} + +enum _Status { done, active, pending } + +class _StepData { + final String label; + final IconData icon; + const _StepData(this.label, this.icon); +} + +class _StepRow extends StatelessWidget { + final _StepData data; + final _Status status; + final bool isLast; + + const _StepRow({ + required this.data, + required this.status, + required this.isLast, + }); + + @override + Widget build(BuildContext context) { + Color iconBg; + Color iconColor; + IconData displayIcon; + + switch (status) { + case _Status.done: + iconBg = const Color(0xFFE1F5EE); + iconColor = const Color(0xFF1D9E75); + displayIcon = Icons.check_rounded; + break; + case _Status.active: + iconBg = const Color(0xFFFAEEDA); + iconColor = const Color(0xFFBA7517); + displayIcon = data.icon; + break; + case _Status.pending: + iconBg = Theme.of(context).colorScheme.surfaceContainerLow; + iconColor = Theme.of(context).colorScheme.onSurfaceVariant; + displayIcon = Icons.radio_button_unchecked_rounded; + break; + } + + return Container( + decoration: BoxDecoration( + border: isLast + ? null + : Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.5, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: iconBg, + shape: BoxShape.circle, + ), + child: Icon(displayIcon, size: 15, color: iconColor), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + data.label, + style: TextStyle( + fontSize: 13, + color: status == _Status.pending + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.onSurface, + ), + ), + ), + if (status == _Status.active) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFAEEDA), + borderRadius: BorderRadius.circular(100), + ), + child: const Text( + 'Ahora', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: Color(0xFF633806), + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/recolecta_app/macos/Flutter/GeneratedPluginRegistrant.swift b/recolecta_app/macos/Flutter/GeneratedPluginRegistrant.swift index 7397616..677cdb0 100644 --- a/recolecta_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/recolecta_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,12 +7,16 @@ import Foundation import firebase_core import firebase_messaging +import flutter_local_notifications import flutter_secure_storage_macos import sqflite_darwin +import video_player_avfoundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) } diff --git a/recolecta_app/pubspec.lock b/recolecta_app/pubspec.lock index 3bf8624..f3aa26f 100644 --- a/recolecta_app/pubspec.lock +++ b/recolecta_app/pubspec.lock @@ -137,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -145,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" + url: "https://pub.dev" + source: hosted + version: "0.7.13" dio: dependency: "direct main" description: @@ -270,6 +286,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + url: "https://pub.dev" + source: hosted + version: "17.2.4" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + url: "https://pub.dev" + source: hosted + version: "4.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + url: "https://pub.dev" + source: hosted + version: "7.2.0" flutter_map: dependency: "direct main" description: @@ -376,6 +416,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -632,6 +680,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -877,6 +933,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.17" + timezone: + dependency: transitive + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" typed_data: dependency: transitive description: @@ -909,6 +973,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0" + url: "https://pub.dev" + source: hosted + version: "2.9.5" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58" + url: "https://pub.dev" + source: hosted + version: "2.9.7" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0" + url: "https://pub.dev" + source: hosted + version: "6.7.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" vm_service: dependency: transitive description: @@ -981,6 +1085,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" yaml: dependency: transitive description: diff --git a/recolecta_app/pubspec.yaml b/recolecta_app/pubspec.yaml index c295d60..746dd32 100644 --- a/recolecta_app/pubspec.yaml +++ b/recolecta_app/pubspec.yaml @@ -40,10 +40,12 @@ dependencies: go_router: ^14.6.2 firebase_core: ^3.8.0 firebase_messaging: ^15.1.5 + flutter_local_notifications: ^17.1.2 flutter_secure_storage: ^9.2.4 cached_network_image: ^3.4.1 flutter_map: ^6.1.0 latlong2: ^0.9.0 + video_player: ^2.9.2 dev_dependencies: flutter_test: @@ -65,6 +67,7 @@ flutter: # - assets/images/ - assets/.env - assets/data/separation_guide.json + - assets/animations/ # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. diff --git a/views_v3/admin_screen.dart b/views_v3/admin_screen.dart index 5fad745..64546e7 100644 --- a/views_v3/admin_screen.dart +++ b/views_v3/admin_screen.dart @@ -67,6 +67,38 @@ class _AdminScreenState extends State { ), ]; + final List _rutas = [ + 'Ruta Norte', + 'Ruta Sur', + ]; + + final List _reportes = [ + ReportModel( + id: 'report-01', + truckId: 'truck-01', + titulo: 'Reporte de ruta bloqueada', + descripcion: 'El camión no puede continuar porque la ruta está obstruida.', + fecha: DateTime.now().subtract(Duration(hours: 2)), + estado: 'Pendiente', + ), + ReportModel( + id: 'report-02', + truckId: 'truck-01', + titulo: 'Revisión mecánica urgente', + descripcion: 'Se detectó una fuga de aceite en el motor.', + fecha: DateTime.now().subtract(Duration(days: 1, hours: 3)), + estado: 'En revisión', + ), + ReportModel( + id: 'report-03', + truckId: 'truck-02', + titulo: 'Retraso en servicio', + descripcion: 'El camión llegó tarde por un problema en la carretera.', + fecha: DateTime.now().subtract(Duration(hours: 5)), + estado: 'Resuelto', + ), + ]; + void _selectSection(int index) { setState(() => _selectedSection = index); } @@ -82,6 +114,9 @@ class _AdminScreenState extends State { case 2: _showTruckForm(); break; + case 3: + _showRouteForm(); + break; } } @@ -244,7 +279,29 @@ class _AdminScreenState extends State { children: [ _buildTextField('Placa', placa), const SizedBox(height: 12), - _buildTextField('Ruta', ruta), + if (_rutas.isNotEmpty) + DropdownButtonFormField( + initialValue: _rutas.contains(ruta.text) ? ruta.text : _rutas.first, + decoration: InputDecoration( + labelText: 'Ruta', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + ), + ), + items: _rutas + .map((rutaName) => DropdownMenuItem( + value: rutaName, + child: Text(rutaName), + )) + .toList(), + onChanged: (value) { + if (value != null) { + ruta.text = value; + } + }, + ) + else + _buildTextField('Ruta', ruta), const SizedBox(height: 12), DropdownButtonFormField( initialValue: conductorId, @@ -318,6 +375,293 @@ class _AdminScreenState extends State { ); } + void _showRouteForm({String? ruta}) { + final nombreRuta = TextEditingController(text: ruta ?? ''); + final formKey = GlobalKey(); + + showDialog( + context: context, + builder: (ctx) { + return AlertDialog( + title: Text(ruta == null ? 'Agregar ruta' : 'Editar ruta'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), + content: Form( + key: formKey, + child: _buildTextField('Nombre de la ruta', nombreRuta), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancelar'), + ), + ElevatedButton( + onPressed: () { + if (!formKey.currentState!.validate()) return; + final nuevoNombre = nombreRuta.text.trim(); + setState(() { + if (ruta == null) { + _rutas.add(nuevoNombre); + } else { + final index = _rutas.indexOf(ruta); + if (index != -1) { + _rutas[index] = nuevoNombre; + for (var i = 0; i < _camiones.length; i++) { + if (_camiones[i].ruta == ruta) { + _camiones[i] = TruckModel( + id: _camiones[i].id, + placa: _camiones[i].placa, + ruta: nuevoNombre, + conductorId: _camiones[i].conductorId, + activo: _camiones[i].activo, + ); + } + } + for (var i = 0; i < _conductores.length; i++) { + if (_conductores[i].rutaAsignada == ruta) { + _conductores[i] = DriverModel( + id: _conductores[i].id, + nombre: _conductores[i].nombre, + telefono: _conductores[i].telefono, + placa: _conductores[i].placa, + rutaAsignada: nuevoNombre, + ); + } + } + } + } + }); + Navigator.pop(ctx); + }, + child: const Text('Guardar'), + ), + ], + ); + }, + ); + } + + void _showReportForm({ReportModel? reporte}) { + final titulo = TextEditingController(text: reporte?.titulo ?? ''); + final descripcion = TextEditingController(text: reporte?.descripcion ?? ''); + String selectedTruckId = reporte?.truckId ?? _camiones.first.id; + final formKey = GlobalKey(); + + showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setStateDialog) { + return AlertDialog( + title: Text(reporte == null ? 'Agregar reporte' : 'Editar reporte'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + ), + content: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: selectedTruckId, + decoration: InputDecoration( + labelText: 'Unidad', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + ), + ), + items: _camiones + .map((truck) => DropdownMenuItem( + value: truck.id, + child: Text('${truck.placa} · ${truck.ruta}'), + )) + .toList(), + onChanged: (value) { + if (value != null) { + setStateDialog(() => selectedTruckId = value); + } + }, + ), + const SizedBox(height: 12), + _buildTextField('Título', titulo), + const SizedBox(height: 12), + _buildTextField('Descripción', descripcion), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancelar'), + ), + ElevatedButton( + onPressed: () { + if (!formKey.currentState!.validate()) return; + final nuevo = ReportModel( + id: reporte?.id ?? 'report-${DateTime.now().millisecondsSinceEpoch}', + truckId: selectedTruckId, + titulo: titulo.text.trim(), + descripcion: descripcion.text.trim(), + fecha: DateTime.now(), + ); + setState(() { + if (reporte == null) { + _reportes.add(nuevo); + } else { + final index = _reportes.indexWhere((r) => r.id == reporte.id); + if (index != -1) { + _reportes[index] = nuevo; + } + } + }); + Navigator.pop(ctx); + }, + child: const Text('Guardar'), + ), + ], + ); + }, + ); + }, + ); + } + + Widget _buildReportSection() { + final header = Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Reportes de unidades', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + TextButton.icon( + icon: const Icon(Icons.add), + label: const Text('Agregar reporte'), + onPressed: _showReportForm, + ), + ], + ), + ); + + if (_camiones.isEmpty) { + return Column( + children: [ + header, + const SizedBox(height: 10), + const Expanded(child: Center(child: Text('No hay unidades registradas.'))), + ], + ); + } + + final Map> reportesPorUnidad = { + for (var camion in _camiones) camion.id: [], + }; + for (var reporte in _reportes) { + reportesPorUnidad[reporte.truckId]?.add(reporte); + } + + final unidadesOrdenadas = List.from(_camiones) + ..sort((a, b) => a.placa.compareTo(b.placa)); + + return Column( + children: [ + header, + const SizedBox(height: 10), + Expanded( + child: ListView( + padding: const EdgeInsets.only(bottom: 16), + children: unidadesOrdenadas.map((camion) { + final reportes = reportesPorUnidad[camion.id] ?? []; + reportes.sort((a, b) => b.fecha.compareTo(a.fecha)); + + return w.AppCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(camion.placa, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + )), + const SizedBox(height: 6), + Text(camion.ruta, + style: const TextStyle(color: AppTheme.textSecondary)), + ], + ), + ), + w.StatusBadge.gray('${reportes.length} reporte(s)'), + ], + ), + const SizedBox(height: 12), + if (reportes.isEmpty) + const Text('No hay reportes para esta unidad.', + style: TextStyle(color: AppTheme.textSecondary)) + else + Column( + children: reportes + .map((reporte) => Padding( + padding: const EdgeInsets.only(top: 12), + child: Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + border: Border.all(color: AppTheme.border), + ), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(reporte.titulo, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + )), + ), + Text(reporte.fechaFormateada, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary)), + ], + ), + const SizedBox(height: 6), + Text(reporte.descripcion, + style: const TextStyle(color: AppTheme.textSecondary)), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: w.StatusBadge.amber(reporte.estado), + ), + ], + ), + ), + )) + .toList(), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ); + } + Widget _buildTextField(String label, TextEditingController controller, {TextInputType keyboardType = TextInputType.text}) { return TextFormField( @@ -352,7 +696,33 @@ class _AdminScreenState extends State { ), TextButton( onPressed: () { - setState(() => lista.remove(item)); + setState(() { + lista.remove(item); + if (item is String && identical(lista, _rutas)) { + for (var i = 0; i < _camiones.length; i++) { + if (_camiones[i].ruta == item) { + _camiones[i] = TruckModel( + id: _camiones[i].id, + placa: _camiones[i].placa, + ruta: '', + conductorId: _camiones[i].conductorId, + activo: _camiones[i].activo, + ); + } + } + for (var i = 0; i < _conductores.length; i++) { + if (_conductores[i].rutaAsignada == item) { + _conductores[i] = DriverModel( + id: _conductores[i].id, + nombre: _conductores[i].nombre, + telefono: _conductores[i].telefono, + placa: _conductores[i].placa, + rutaAsignada: '', + ); + } + } + } + }); Navigator.pop(ctx); }, style: TextButton.styleFrom(foregroundColor: AppTheme.danger), @@ -402,7 +772,13 @@ class _AdminScreenState extends State { return Scaffold( backgroundColor: AppTheme.background, appBar: AppBar( - title: Text(_currentTab == 0 ? 'Mi perfil' : 'Panel administrador'), + title: Text( + _currentTab == 0 + ? 'Mi perfil' + : _currentTab == 1 + ? 'Panel administrador' + : 'Reportes por unidad', + ), actions: _currentTab == 1 ? [ IconButton( @@ -411,13 +787,22 @@ class _AdminScreenState extends State { tooltip: 'Agregar', ), ] - : null, + : _currentTab == 2 + ? [ + IconButton( + icon: const Icon(Icons.add), + onPressed: _showReportForm, + tooltip: 'Agregar reporte', + ), + ] + : null, ), body: IndexedStack( index: _currentTab, children: [ _buildAdminProfile(), _buildAdminBody(), + _buildReportSection(), ], ), bottomNavigationBar: BottomNavigationBar( @@ -432,6 +817,10 @@ class _AdminScreenState extends State { icon: Icon(Icons.admin_panel_settings_outlined), label: 'Admin', ), + BottomNavigationBarItem( + icon: Icon(Icons.report_problem_outlined), + label: 'Reportes', + ), ], ), ); @@ -448,6 +837,7 @@ class _AdminScreenState extends State { _buildSectionButton('Usuarios', 0), _buildSectionButton('Conductores', 1), _buildSectionButton('Camiones', 2), + _buildSectionButton('Rutas', 3), ], ), ), @@ -576,11 +966,74 @@ class _AdminScreenState extends State { return _buildUsuarioSection(); case 1: return _buildDriverSection(); - default: + case 2: return _buildTruckSection(); + case 3: + return _buildRouteSection(); + default: + return _buildReportSection(); } } + Widget _buildRouteSection() { + if (_rutas.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('No hay rutas registradas.'), + const SizedBox(height: 12), + ElevatedButton( + onPressed: _showRouteForm, + child: const Text('Agregar ruta'), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.only(bottom: 16), + itemCount: _rutas.length, + itemBuilder: (context, index) { + final ruta = _rutas[index]; + final asignados = _camiones.where((camion) => camion.ruta == ruta).length; + return w.AppCard( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(ruta, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + )), + const SizedBox(height: 6), + Text('$asignados camión(es) asignado(s)', + style: const TextStyle(color: AppTheme.textSecondary)), + ], + ), + ), + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () => _showRouteForm(ruta: ruta), + ), + IconButton( + icon: const Icon(Icons.delete_outline, color: AppTheme.danger), + onPressed: () { + _confirmDelete(ruta, _rutas, 'ruta'); + }, + ), + ], + ), + ); + }, + ); + } + Widget _buildUsuarioSection() { if (_usuarios.isEmpty) { return const Center(child: Text('No hay usuarios registrados.'));