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

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

377
backend/app/api/admin.py Normal file
View File

@@ -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

View File

@@ -74,10 +74,10 @@ def register(body: RegisterRequest):
if not auth_user: if not auth_user:
raise HTTPException(status_code=400, detail="No se pudo crear el usuario en Supabase Auth") 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: try:
supabase_admin.table("users").upsert( 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() ).execute()
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Error al guardar el usuario: {e}") raise HTTPException(status_code=500, detail=f"Error al guardar el usuario: {e}")

View File

@@ -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

View File

@@ -3,6 +3,7 @@ from typing import Optional, Literal
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
name: str
email: Optional[str] = None email: Optional[str] = None
phone: Optional[str] = None phone: Optional[str] = None
password: str password: str

View File

@@ -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.addresses import router as addresses_router
from app.api.colonias import router as colonias_router from app.api.colonias import router as colonias_router
from app.api.users import router as users_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 from app.services import simulation, notifications
scheduler = AsyncIOScheduler() scheduler = AsyncIOScheduler()
@@ -59,3 +61,5 @@ app.include_router(addresses_router)
app.include_router(eta_router) app.include_router(eta_router)
app.include_router(colonias_router) app.include_router(colonias_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(admin_router)
app.include_router(simulation_router)

View File

Binary file not shown.

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:recolecta_app/features/admin/admin_shell.dart'; import 'package:recolecta_app/features/admin/admin_shell.dart';
import 'package:recolecta_app/features/auth/login_page.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/auth/register_page.dart';
import 'package:recolecta_app/features/driver/driver_shell.dart'; import 'package:recolecta_app/features/driver/driver_shell.dart';
import 'package:recolecta_app/features/driver/screens/driver_collections_screen.dart'; import 'package:recolecta_app/features/driver/screens/driver_collections_screen.dart';
@@ -43,20 +44,21 @@ final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authControllerProvider); final authState = ref.watch(authControllerProvider);
return GoRouter( return GoRouter(
initialLocation: '/login', initialLocation: '/splash',
redirect: (BuildContext context, GoRouterState state) { redirect: (BuildContext context, GoRouterState state) {
final isAuthenticated = authState.value?.isAuthenticated ?? false; final isAuthenticated = authState.value?.isAuthenticated ?? false;
final role = authState.value?.userRole; final role = authState.value?.userRole;
final isAuthRoute = final isPublicRoute =
state.matchedLocation == '/splash' ||
state.matchedLocation == '/login' || state.matchedLocation == '/login' ||
state.matchedLocation == '/register'; state.matchedLocation == '/register';
if (!isAuthenticated) { if (!isAuthenticated) {
return isAuthRoute ? null : '/login'; return isPublicRoute ? null : '/login';
} }
if (isAuthRoute) { if (isPublicRoute) {
switch (role) { switch (role) {
case 'admin': case 'admin':
return '/admin'; return '/admin';
@@ -72,6 +74,10 @@ final routerProvider = Provider<GoRouter>((ref) {
return null; return null;
}, },
routes: [ routes: [
GoRoute(
path: '/splash',
builder: (context, state) => const SplashScreen(),
),
GoRoute(path: '/login', builder: (context, state) => const LoginPage()), GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
GoRoute( GoRoute(
path: '/register', path: '/register',

View File

@@ -44,6 +44,7 @@ class AuthController extends AsyncNotifier<AuthState> {
} }
Future<void> register({ Future<void> register({
required String name,
required String email, required String email,
required String phone, required String phone,
required String password, required String password,
@@ -55,16 +56,19 @@ class AuthController extends AsyncNotifier<AuthState> {
}) async { }) async {
state = const AsyncLoading<AuthState>(); state = const AsyncLoading<AuthState>();
try { try {
final session = await ref.read(authServiceProvider).register( final session = await ref
email: email, .read(authServiceProvider)
phone: phone, .register(
password: password, name: name,
addressCalle: addressCalle, email: email,
addressColonia: addressColonia, phone: phone,
addressLabel: addressLabel, password: password,
addressLat: addressLat, addressCalle: addressCalle,
addressLng: addressLng, addressColonia: addressColonia,
); addressLabel: addressLabel,
addressLat: addressLat,
addressLng: addressLng,
);
final authState = AuthState.authenticated( final authState = AuthState.authenticated(
token: session.token, token: session.token,

View File

@@ -38,6 +38,7 @@ class AuthService {
} }
Future<AuthSession> register({ Future<AuthSession> register({
required String name,
required String email, required String email,
required String phone, required String phone,
required String password, required String password,
@@ -50,6 +51,7 @@ class AuthService {
return _authenticate( return _authenticate(
path: '/auth/register', path: '/auth/register',
payload: <String, dynamic>{ payload: <String, dynamic>{
'name': name,
'email': email, 'email': email,
'phone': phone, 'phone': phone,
'password': password, 'password': password,

View File

@@ -164,10 +164,6 @@ class _AddAddressPageState extends ConsumerState<AddAddressPage> {
'calle': _calleCtrl.text.trim(), 'calle': _calleCtrl.text.trim(),
'colonia': _selectedColonia!.nombre, 'colonia': _selectedColonia!.nombre,
}; };
if (_selectedLocation != null) {
body['lat'] = _selectedLocation!.latitude;
body['lng'] = _selectedLocation!.longitude;
}
await dio.post('/addresses', data: body); await dio.post('/addresses', data: body);
if (mounted) Navigator.pop(context, true); if (mounted) Navigator.pop(context, true);
@@ -396,19 +392,17 @@ class _AddAddressPageState extends ConsumerState<AddAddressPage> {
color: Colors.white, color: Colors.white,
), ),
) )
: const Row( : const FittedBox(
key: ValueKey('text'), key: ValueKey('text'),
mainAxisSize: MainAxisSize.min, fit: BoxFit.scaleDown,
children: [ child: Row(
Icon(Icons.check, size: 18), mainAxisAlignment: MainAxisAlignment.center,
SizedBox(width: 8), children: [
Flexible( Icon(Icons.check, size: 18),
child: Text( SizedBox(width: 8),
'Guardar dirección', Text('Guardar dirección'),
overflow: TextOverflow.ellipsis, ],
), ),
),
],
), ),
), ),
), ),

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
final _step1FormKey = GlobalKey<FormState>(); final _step1FormKey = GlobalKey<FormState>();
// Paso 1 // Paso 1
final _nameCtrl = TextEditingController();
final _emailCtrl = TextEditingController(); final _emailCtrl = TextEditingController();
final _telefonoCtrl = TextEditingController(); final _telefonoCtrl = TextEditingController();
final _passCtrl = TextEditingController(); final _passCtrl = TextEditingController();
@@ -91,6 +92,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
@override @override
void dispose() { void dispose() {
_pageController.dispose(); _pageController.dispose();
_nameCtrl.dispose();
_emailCtrl.dispose(); _emailCtrl.dispose();
_telefonoCtrl.dispose(); _telefonoCtrl.dispose();
_passCtrl.dispose(); _passCtrl.dispose();
@@ -201,77 +203,19 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
FocusScope.of(context).unfocus(); // Cierra el teclado FocusScope.of(context).unfocus(); // Cierra el teclado
} }
Future<void> _register() async { void _onRegister() {
if (_calleCtrl.text.trim().isEmpty || _selectedColonia == null) { final auth = ref.read(authControllerProvider.notifier);
ScaffoldMessenger.of(context).showSnackBar( auth.register(
const SnackBar( name: _nameCtrl.text,
content: Text('Ingresa tu calle y selecciona una colonia'), email: _emailCtrl.text,
behavior: SnackBarBehavior.floating, phone: _telefonoCtrl.text,
), password: _passCtrl.text,
); addressCalle: _calleCtrl.text,
return; addressColonia: _selectedColonia?.nombre,
} addressLabel: _tipoInmueble,
addressLat: _selectedLocation?.latitude,
final phoneDigits = _telefonoCtrl.text.replaceAll(RegExp(r'\D'), ''); addressLng: _selectedLocation?.longitude,
final phone = phoneDigits.isNotEmpty ? '+52$phoneDigits' : ''; );
final calle = _calleCtrl.text.trim();
final colonia = _selectedColonia!.nombre;
final lat = _selectedLocation?.latitude;
final lng = _selectedLocation?.longitude;
try {
await ref
.read(authControllerProvider.notifier)
.register(
email: _emailCtrl.text.trim(),
phone: phone,
password: _passCtrl.text,
addressCalle: calle,
addressColonia: colonia,
addressLabel: 'Mi Casa',
addressLat: lat,
addressLng: lng,
);
// Guardado silencioso de la dirección tras un registro exitoso
_postAddressInBackground(calle, colonia, lat, lng);
} catch (_) {
// El error ya es manejado por el listener y muestra el SnackBar
}
}
Future<void> _postAddressInBackground(
String calle,
String colonia,
double? lat,
double? lng,
) async {
try {
const storage = FlutterSecureStorage();
await Future.delayed(
const Duration(milliseconds: 800),
); // Esperar a que se guarde el JWT
final token = await storage.read(key: authTokenStorageKey) ?? '';
if (token.isNotEmpty) {
final dio = Dio(
BaseOptions(
baseUrl: const String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:8000',
),
headers: {'Authorization': 'Bearer $token'},
),
);
await dio.post(
'/addresses',
data: {'label': 'Mi Casa', 'calle': calle, 'colonia': colonia},
);
}
} catch (e) {
debugPrint('Aviso: No se pudo crear la dirección: $e');
}
} }
@override @override
@@ -299,35 +243,431 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
controller: _pageController, controller: _pageController,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
children: [ children: [
_Step1( _buildStep1(context),
formKey: _step1FormKey, _buildStep2(context, loading, coloniasList),
emailCtrl: _emailCtrl, ],
telefonoCtrl: _telefonoCtrl, ),
passCtrl: _passCtrl, bottomNavigationBar: _buildBottomControls(context, loading),
obscurePass: _obscurePass, );
onTogglePass: () => setState(() => _obscurePass = !_obscurePass), }
onNext: _nextPage,
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( const SizedBox(height: 8),
mapController: _mapController, const Text(
cpCtrl: _cpCtrl, 'Ingresa tus datos para registrarte.',
calleCtrl: _calleCtrl, style: TextStyle(fontSize: 15, color: AppTheme.textSecondary),
selectedColonia: _selectedColonia, ),
selectedLocation: _selectedLocation, const SizedBox(height: 28),
tipoInmueble: _tipoInmueble, AppFormField(
whatsappNotif: _whatsappNotif, controller: _nameCtrl,
loading: loading, label: 'Nombre completo',
onTipoChanged: (v) => setState(() => _tipoInmueble = v), validator: (val) =>
onCPChanged: (v) => _validarCP(v, coloniasList), val!.isEmpty ? 'Ingresa tu nombre completo' : null,
onLocationChanged: _fetchStreetName, ),
onWhatsappChanged: (v) => const SizedBox(height: 16),
setState(() => _whatsappNotif = v ?? false), AppFormField(
onRegister: _register, controller: _emailCtrl,
label: 'Correo electrónico',
hint: 'tu@correo.com',
keyboardType: TextInputType.emailAddress,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Ingresa tu correo';
final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+');
if (!emailRegex.hasMatch(v.trim()))
return 'Ingresa un correo válido';
return null;
},
),
const SizedBox(height: 14),
_PhoneField(controller: _telefonoCtrl),
const SizedBox(height: 14),
AppFormField(
label: 'Contraseña',
hint: '••••••••',
controller: _passCtrl,
obscureText: _obscurePass,
validator: (v) {
if (v == null || v.isEmpty) return 'Ingresa una contraseña';
if (v.length < 6) return 'Mínimo 6 caracteres';
return null;
},
suffix: IconButton(
icon: Icon(
_obscurePass
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
size: 18,
color: AppTheme.textSecondary,
),
onPressed: () => setState(() => _obscurePass = !_obscurePass),
),
), ),
], ],
), ),
); );
} }
Widget _buildStep2(
BuildContext context,
bool loading,
List<Colonia> coloniasList,
) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
AppFormCard(
icon: Icons.home_outlined,
title: 'Dirección de tu casa',
child: Column(
children: [
const Text(
'Tipo de inmueble',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
Row(
children: [
Expanded(
child: Material(
color: Colors.transparent,
child: RadioListTile<String>(
title: const Text(
'Casa',
style: TextStyle(fontSize: 14),
),
value: 'Casa',
groupValue: _tipoInmueble,
onChanged: (v) => setState(() => _tipoInmueble = v!),
),
),
),
Expanded(
child: Material(
color: Colors.transparent,
child: RadioListTile<String>(
title: const Text(
'Negocio',
style: TextStyle(fontSize: 14),
),
value: 'Negocio',
groupValue: _tipoInmueble,
onChanged: (v) => setState(() => _tipoInmueble = v!),
),
),
),
],
),
const SizedBox(height: 8),
AppFormField(
label: 'Código Postal',
hint: 'Ej. 38000',
controller: _cpCtrl,
keyboardType: TextInputType.number,
onChanged: (v) => _validarCP(v, coloniasList),
),
if (_selectedColonia != null) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.primaryLight.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: AppTheme.primaryMid),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.check_circle_outline,
color: AppTheme.primary,
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Colonia: ${_selectedColonia!.nombre}',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'Horario ${_selectedColonia!.turno?.toLowerCase() ?? 'asignado'}',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textPrimary,
),
),
Text(
_selectedColonia!.horarioEstimado ??
'Sin horario especificado',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
),
const SizedBox(height: 14),
AppFormField(
label: 'Calle y número',
hint: 'Av. Insurgentes 245',
controller: _calleCtrl,
),
const SizedBox(height: 16),
const Text(
'Toca el mapa para ubicar tu casa exacta:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Container(
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: AppTheme.border),
),
clipBehavior: Clip.hardEdge,
child: FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter:
_selectedLocation ??
const LatLng(20.5222, -100.8123),
initialZoom: 15.0,
onTap: (_, latlng) => _fetchStreetName(latlng),
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.onlineshack.recolecta',
),
if (_selectedLocation != null)
MarkerLayer(
markers: [
Marker(
point: _selectedLocation!,
width: 40,
height: 40,
child: const Icon(
Icons.location_on,
color: AppTheme.danger,
size: 40,
),
),
],
),
],
),
),
] else ...[
const SizedBox(height: 24),
const Center(
child: Text(
'Ingresa un código postal con servicio\npara asignar tu colonia.',
textAlign: TextAlign.center,
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 13,
),
),
),
],
],
),
),
const SizedBox(height: 16),
// ── Sección OCR (Privacidad por diseño) ──
AppFormCard(
icon: Icons.document_scanner_outlined,
title: 'Verificación de Domicilio',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Para prevenir abusos, requerimos validar tu dirección con un recibo (luz o agua). '
'Por privacidad, la imagen será borrada inmediatamente después de la lectura.',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
height: 1.4,
),
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
icon: const Icon(
Icons.upload_file,
color: AppTheme.primary,
),
label: const Text(
'Escanear recibo (OCR)',
style: TextStyle(color: AppTheme.primary),
),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Abriendo cámara... (Próximamente)'),
),
);
},
),
),
],
),
),
const SizedBox(height: 16),
// ── Sección WhatsApp ──
AppFormCard(
icon: Icons.chat_outlined,
title: 'Notificaciones Externas',
child: Column(
children: [
Material(
color: Colors.transparent,
child: CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
activeColor: AppTheme.primary,
value: _whatsappNotif,
onChanged: (v) =>
setState(() => _whatsappNotif = v ?? false),
title: const Text(
'Recibir alertas del camión vía WhatsApp (Próximamente)',
style: TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
),
),
),
),
],
),
),
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: loading ? null : _onRegister,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: loading
? const SizedBox(
key: ValueKey('loading'),
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Row(
key: ValueKey('text'),
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check, size: 18),
SizedBox(width: 8),
Flexible(
child: Text(
'Registrarme',
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),
const SizedBox(height: 16),
const Center(
child: Text(
'Al registrarte aceptas los Términos de Servicio\ny la Política de Privacidad.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
height: 1.5,
),
),
),
],
),
);
}
Widget _buildBottomControls(BuildContext context, bool isLoading) {
return Container(
padding: const EdgeInsets.all(
20,
).copyWith(bottom: MediaQuery.of(context).padding.bottom + 20),
decoration: const BoxDecoration(
color: AppTheme.background,
border: Border(top: BorderSide(color: AppTheme.border, width: 0.5)),
),
child: _currentPage == 0
? SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _nextPage,
child: const Text('Continuar'),
),
)
: SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: isLoading ? null : _onRegister,
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Crear mi cuenta'),
),
),
);
}
} }
// ── Indicador de pasos ──────────────────────────────────────────────────────── // ── Indicador de pasos ────────────────────────────────────────────────────────
@@ -794,17 +1134,17 @@ class _Step2 extends StatelessWidget {
color: Colors.white, color: Colors.white,
), ),
) )
: const Row( : const FittedBox(
key: ValueKey('text'), key: ValueKey('text'),
mainAxisSize: MainAxisSize.min, fit: BoxFit.scaleDown,
children: [ child: Row(
Icon(Icons.check, size: 18), mainAxisAlignment: MainAxisAlignment.center,
SizedBox(width: 8), children: [
Flexible( Icon(Icons.check, size: 18),
child: Text('Registrarme', SizedBox(width: 8),
overflow: TextOverflow.ellipsis), Text('Registrarme'),
), ],
], ),
), ),
), ),
), ),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
// lib/shared/widgets/prevention_banner.dart
// Banner de mensajería preventiva — obligatorio en la vista ETA.
// Regla de privacidad #5: textos que desalientan sacar basura fuera de horario
// o perseguir la unidad.
import 'package:flutter/material.dart';
class PreventionBanner extends StatelessWidget {
final String? customMessage;
const PreventionBanner({super.key, this.customMessage});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFAEEDA), // amber-50
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFFAC775)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 1),
child: Icon(
Icons.warning_amber_rounded,
size: 18,
color: Color(0xFFBA7517),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
customMessage ??
'No saques tu basura antes de recibir el aviso de proximidad '
'ni dejes bolsas en la calle por más de 30 min. '
'No persigas ni detengas la unidad recolectora.',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF633806),
height: 1.5,
),
),
),
],
),
);
}
}

View File

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

View File

@@ -7,12 +7,16 @@ import Foundation
import firebase_core import firebase_core
import firebase_messaging import firebase_messaging
import flutter_local_notifications
import flutter_secure_storage_macos import flutter_secure_storage_macos
import sqflite_darwin import sqflite_darwin
import video_player_avfoundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin"))
} }

View File

@@ -137,6 +137,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -145,6 +153,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.9" version: "1.0.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
url: "https://pub.dev"
source: hosted
version: "0.7.13"
dio: dio:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -270,6 +286,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" 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: flutter_map:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -376,6 +416,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http: http:
dependency: transitive dependency: transitive
description: description:
@@ -632,6 +680,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -877,6 +933,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.17" version: "0.6.17"
timezone:
dependency: transitive
description:
name: timezone
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -909,6 +973,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: vm_service:
dependency: transitive dependency: transitive
description: description:
@@ -981,6 +1085,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:

View File

@@ -40,10 +40,12 @@ dependencies:
go_router: ^14.6.2 go_router: ^14.6.2
firebase_core: ^3.8.0 firebase_core: ^3.8.0
firebase_messaging: ^15.1.5 firebase_messaging: ^15.1.5
flutter_local_notifications: ^17.1.2
flutter_secure_storage: ^9.2.4 flutter_secure_storage: ^9.2.4
cached_network_image: ^3.4.1 cached_network_image: ^3.4.1
flutter_map: ^6.1.0 flutter_map: ^6.1.0
latlong2: ^0.9.0 latlong2: ^0.9.0
video_player: ^2.9.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -65,6 +67,7 @@ flutter:
# - assets/images/ # - assets/images/
- assets/.env - assets/.env
- assets/data/separation_guide.json - assets/data/separation_guide.json
- assets/animations/
# The following line ensures that the Material Icons font is # The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in
# the material Icons class. # the material Icons class.

View File

@@ -67,6 +67,38 @@ class _AdminScreenState extends State<AdminScreen> {
), ),
]; ];
final List<String> _rutas = [
'Ruta Norte',
'Ruta Sur',
];
final List<ReportModel> _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) { void _selectSection(int index) {
setState(() => _selectedSection = index); setState(() => _selectedSection = index);
} }
@@ -82,6 +114,9 @@ class _AdminScreenState extends State<AdminScreen> {
case 2: case 2:
_showTruckForm(); _showTruckForm();
break; break;
case 3:
_showRouteForm();
break;
} }
} }
@@ -244,7 +279,29 @@ class _AdminScreenState extends State<AdminScreen> {
children: [ children: [
_buildTextField('Placa', placa), _buildTextField('Placa', placa),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildTextField('Ruta', ruta), if (_rutas.isNotEmpty)
DropdownButtonFormField<String>(
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), const SizedBox(height: 12),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: conductorId, initialValue: conductorId,
@@ -318,6 +375,293 @@ class _AdminScreenState extends State<AdminScreen> {
); );
} }
void _showRouteForm({String? ruta}) {
final nombreRuta = TextEditingController(text: ruta ?? '');
final formKey = GlobalKey<FormState>();
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<FormState>();
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<String>(
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<String, List<ReportModel>> reportesPorUnidad = {
for (var camion in _camiones) camion.id: [],
};
for (var reporte in _reportes) {
reportesPorUnidad[reporte.truckId]?.add(reporte);
}
final unidadesOrdenadas = List<TruckModel>.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, Widget _buildTextField(String label, TextEditingController controller,
{TextInputType keyboardType = TextInputType.text}) { {TextInputType keyboardType = TextInputType.text}) {
return TextFormField( return TextFormField(
@@ -352,7 +696,33 @@ class _AdminScreenState extends State<AdminScreen> {
), ),
TextButton( TextButton(
onPressed: () { 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); Navigator.pop(ctx);
}, },
style: TextButton.styleFrom(foregroundColor: AppTheme.danger), style: TextButton.styleFrom(foregroundColor: AppTheme.danger),
@@ -402,7 +772,13 @@ class _AdminScreenState extends State<AdminScreen> {
return Scaffold( return Scaffold(
backgroundColor: AppTheme.background, backgroundColor: AppTheme.background,
appBar: AppBar( 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 actions: _currentTab == 1
? [ ? [
IconButton( IconButton(
@@ -411,13 +787,22 @@ class _AdminScreenState extends State<AdminScreen> {
tooltip: 'Agregar', tooltip: 'Agregar',
), ),
] ]
: null, : _currentTab == 2
? [
IconButton(
icon: const Icon(Icons.add),
onPressed: _showReportForm,
tooltip: 'Agregar reporte',
),
]
: null,
), ),
body: IndexedStack( body: IndexedStack(
index: _currentTab, index: _currentTab,
children: [ children: [
_buildAdminProfile(), _buildAdminProfile(),
_buildAdminBody(), _buildAdminBody(),
_buildReportSection(),
], ],
), ),
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: BottomNavigationBar(
@@ -432,6 +817,10 @@ class _AdminScreenState extends State<AdminScreen> {
icon: Icon(Icons.admin_panel_settings_outlined), icon: Icon(Icons.admin_panel_settings_outlined),
label: 'Admin', label: 'Admin',
), ),
BottomNavigationBarItem(
icon: Icon(Icons.report_problem_outlined),
label: 'Reportes',
),
], ],
), ),
); );
@@ -448,6 +837,7 @@ class _AdminScreenState extends State<AdminScreen> {
_buildSectionButton('Usuarios', 0), _buildSectionButton('Usuarios', 0),
_buildSectionButton('Conductores', 1), _buildSectionButton('Conductores', 1),
_buildSectionButton('Camiones', 2), _buildSectionButton('Camiones', 2),
_buildSectionButton('Rutas', 3),
], ],
), ),
), ),
@@ -576,11 +966,74 @@ class _AdminScreenState extends State<AdminScreen> {
return _buildUsuarioSection(); return _buildUsuarioSection();
case 1: case 1:
return _buildDriverSection(); return _buildDriverSection();
default: case 2:
return _buildTruckSection(); 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() { Widget _buildUsuarioSection() {
if (_usuarios.isEmpty) { if (_usuarios.isEmpty) {
return const Center(child: Text('No hay usuarios registrados.')); return const Center(child: Text('No hay usuarios registrados.'));