📦 Features¶
Documentación detallada de cada feature de la aplicación AltruPets Mobile.
Mapa de Features¶
flowchart TB
Auth[Auth<br/>Login/Register]
Home[Home<br/>Dashboard]
Profile[Profile<br/>User Profile]
Orgs[Organizations<br/>Orgs]
Rescues[Rescues<br/>Rescues]
Settings[Settings<br/>Config]
Onboarding[Onboarding<br/>Initial Reg]
Auth --> Home
Onboarding --> Home
Home --> Profile
Home --> Orgs
Home --> Rescues
Home --> Settings
Profile -.->|uses| Auth
Orgs -.->|uses| Auth
Rescues -.->|uses| Auth
style Auth fill:#e1f5ff
style Home fill:#fff4e1
style Profile fill:#f0e1ff
style Orgs fill:#e1ffe1
style Rescues fill:#ffe1e1
style Settings fill:#f5f5f5
style Onboarding fill:#ffe1f5
Estructura de un Feature¶
Cada feature sigue la estructura de Clean Architecture:
flowchart TB
subgraph Feature[Feature Module]
subgraph Domain[domain/]
Entities[entities<br/>Freezed models]
RepoInt[repositories<br/>Interfaces]
end
subgraph Data[data/]
Models[models<br/>DTOs]
RepoImpl[repositories<br/>Implementations]
end
subgraph Presentation[presentation/]
Pages[pages<br/>Screens]
Providers[providers<br/>StateNotifiers]
Widgets[widgets<br/>UI Components]
end
end
Pages --> Providers
Providers --> RepoInt
RepoImpl -.->|implements| RepoInt
RepoImpl --> Models
Models -.->|toEntity| Entities
style Domain fill:#e1f5ff
style Data fill:#fff4e1
style Presentation fill:#f0e1ff
Estructura de carpetas:
features/<feature_name>/
├── domain/ # Capa de dominio
│ ├── entities/ # Entidades de negocio (Freezed)
│ └── repositories/ # Interfaces de repositorio
├── data/ # Capa de datos
│ ├── models/ # DTOs y modelos de datos
│ └── repositories/ # Implementaciones de repositorio
└── presentation/ # Capa de presentación
├── pages/ # Páginas/Screens
├── providers/ # Riverpod StateNotifiers
└── widgets/ # Widgets específicos del feature
Auth¶
Gestiona la autenticación de usuarios con JWT.
Funcionalidades¶
- Login: Autenticación con username/password
- Registro: Creación de nuevos usuarios
- Sesión persistente: Token almacenado en secure storage
- Auto-logout: Detección de token expirado con redirección automática
- Refresh token: Renovación automática de sesión (cuando el backend lo soporte)
Estructura¶
features/auth/
├── domain/
│ ├── entities/
│ │ ├── user.dart # Entidad User con todos los campos
│ │ ├── user.freezed.dart
│ │ └── user.g.dart
│ └── repositories/
│ └── auth_repository_interface.dart
├── data/
│ ├── models/
│ │ ├── auth_payload.dart # Token + User response
│ │ └── register_input.dart # Input para registro
│ └── repositories/
│ └── auth_repository.dart
└── presentation/
├── pages/
│ ├── login_page.dart
│ └── register_page.dart
└── providers/
├── auth_provider.dart
└── auth_provider.freezed.dart
Providers¶
// Estado de autenticación principal
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
final repository = ref.watch(authRepositoryProvider);
return AuthNotifier(repository);
});
// Verificación de sesión activa
final isAuthenticatedProvider = FutureProvider<bool>((ref) async {
final hasActiveSession = await GraphQLClientService.hasActiveSession();
if (!hasActiveSession) return false;
final repository = ref.read(authRepositoryProvider);
final currentUserResult = await repository.getCurrentUser();
return currentUserResult.isRight();
});
// Stream para sesión expirada (401/403)
final sessionExpiredProvider = StreamProvider<void>((ref) {
return GraphQLClientService.sessionExpiredStream;
});
Uso en la App¶
// En main.dart - Escuchar expiración de sesión
ref.listen<AsyncValue<void>>(sessionExpiredProvider, (previous, next) {
next.whenData((_) async {
await ref.read(authProvider.notifier).logout();
navigation.navigateAndRemoveAllGlobal(const LoginPage());
});
});
// Decidir pantalla inicial
home: isAuthenticatedAsync.when(
data: (isAuthenticated) =>
isAuthenticated ? const HomePage() : const LoginPage(),
loading: () =>
const Scaffold(body: Center(child: CircularProgressIndicator())),
error: (_, __) => const LoginPage(),
),
Entidad User¶
@freezed
class User with _$User {
const factory User({
required String id,
required String username,
@JsonKey(fromJson: _rolesFromJson) List<String>? roles,
String? email,
String? firstName,
String? lastName,
String? phone,
String? identification,
String? country,
String? province,
String? canton,
String? district,
String? occupation,
String? incomeSource,
bool? isActive,
bool? isVerified,
String? avatarBase64,
@JsonKey(fromJson: _dateTimeFromJson) DateTime? createdAt,
@JsonKey(fromJson: _dateTimeFromJson) DateTime? updatedAt,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
Home¶
Dashboard principal que sirve como hub de navegación.
Funcionalidades¶
- Welcome header: Saludo personalizado con nombre del usuario
- Quick actions: Accesos directos a funciones principales
- Navigation: Navegación a Organizations, Profile, Settings, Rescues
- Sync status: Indicador de cambios pendientes por sincronizar
Estructura¶
features/home/
└── presentation/
├── pages/
│ └── home_page.dart
└── widgets/
├── home_welcome_header.dart
└── quick_action_card.dart
Organizations¶
Gestión completa de organizaciones de protección animal.
Funcionalidades¶
- Búsqueda: Filtrado por nombre, ubicación, tipo
- Detalle: Vista completa de organización con información de contacto
- Registro: Formulario para crear nueva organización
- Membresías: Gestión de miembros y roles dentro de organizaciones
Estructura¶
features/organizations/
├── data/
│ ├── models/
│ │ ├── organization.dart
│ │ ├── organization_membership.dart
│ │ ├── register_organization_input.dart
│ │ └── search_organizations_input.dart
│ └── repositories/
│ └── organizations_repository.dart
└── presentation/
├── pages/
│ ├── search_organizations_page.dart
│ ├── organization_detail_page.dart
│ ├── register_organization_page.dart
│ └── manage_memberships_page.dart
└── providers/
└── organizations_provider.dart
Modelo Organization¶
@freezed
class Organization with _$Organization {
const factory Organization({
required String id,
required String name,
String? description,
String? legalId, // Cédula jurídica
String? email,
String? phone,
String? website,
String? address,
String? province,
String? canton,
String? district,
String? logoBase64,
bool? isVerified,
DateTime? createdAt,
}) = _Organization;
factory Organization.fromJson(Map<String, dynamic> json) =>
_$OrganizationFromJson(json);
}
Profile¶
Gestión del perfil de usuario con soporte offline.
Funcionalidades¶
- Ver/Editar: Información personal completa
- Avatar: Subida y visualización de foto de perfil
- Ubicación: Selector cascada de Costa Rica (provincia → cantón → distrito)
- Hogares de acogida: Gestión de foster homes
- Offline: Cache local con sincronización en segundo plano
Estructura¶
features/profile/
├── data/
│ ├── models/
│ │ ├── update_profile_input.dart
│ │ └── foster_home.dart
│ └── repositories/
│ └── profile_repository.dart
└── presentation/
├── pages/
│ ├── profile_page.dart
│ └── edit_profile_page.dart
├── providers/
│ └── profile_provider.dart
└── widgets/
├── profile_header.dart
└── profile_menu_option.dart
Sincronización Offline¶
El sistema de sincronización offline permite que los cambios se guarden localmente y se sincronicen cuando hay conexión.
sequenceDiagram
participant UI as Pagina Editar Perfil
participant P as ProfileProvider
participant R as ProfileRepository
participant Q as ColaSincronizacion
participant C as Cache
participant API as API GraphQL
UI->>P: updateProfile(datos)
P->>C: Guardar en cache
C-->>P: Guardado
alt En linea
P->>API: Mutacion
API-->>P: Exito
P->>C: Actualizar cache
else Sin conexion
P->>Q: Agregar a cola
Q-->>P: Encolado
Note over Q: Espera conexion
end
P-->>UI: Actualizar UI
Note over Q,API: Cuando hay conexion
Q->>API: Sincronizar cambios
API-->>Q: Exito
Q->>C: Actualizar cache
Uso en código:
// Ver estado de sincronización
final syncStatus = ref.watch(syncStatusProvider);
if (syncStatus.pendingCount > 0) {
// Mostrar indicador de cambios pendientes
SyncStatusIndicator(status: syncStatus);
}
// Texto descriptivo
syncStatus.statusText; // "Sincronizando...", "2 cambio(s) pendiente(s)", "Sincronizado"
syncStatus.timeSinceLastSync; // "Hace 5 min", "Hace 1 h"
Rescues¶
Coordinación de rescates de animales con geolocalización.
Funcionalidades¶
- Crear rescate: Reportar animal en situación de emergencia
- Geolocalización: Ubicación exacta del animal
- Asignación: Match con rescatistas cercanos
- Seguimiento: Estado del rescate en tiempo real
- Historial: Rescates completados
Estructura¶
features/rescues/
├── domain/
│ ├── entities/
│ │ └── rescue.dart
│ └── repositories/
│ └── rescue_repository_interface.dart
├── data/
│ └── repositories/
│ └── rescue_repository.dart
└── presentation/
├── pages/
│ ├── create_rescue_page.dart
│ ├── rescue_detail_page.dart
│ └── rescue_history_page.dart
└── providers/
└── rescue_provider.dart
Geolocalización¶
El sistema de geolocalización permite ubicar rescates con precisión.
sequenceDiagram
participant UI as Pagina Crear Rescate
participant GP as GeolocationProvider
participant GS as GeolocationService
participant Perm as Permisos
participant GPS as Dispositivo GPS
UI->>GP: getCurrentPosition()
GP->>Perm: Verificar permisos
alt Permisos concedidos
Perm-->>GP: Concedidos
GP->>GS: getPosition()
GS->>GPS: Solicitar ubicacion
GPS-->>GS: Posicion(lat, lng)
GS-->>GP: Posicion
GP-->>UI: Posicion
else Permisos denegados
Perm-->>GP: Denegados
GP->>UI: Solicitar permisos
UI->>Perm: requestPermissions()
Perm-->>UI: Resultado
end
UI->>UI: Mostrar mapa con marcador
Uso en código:
// Obtener ubicación actual
final position = await ref.read(geolocationProvider.notifier).getCurrentPosition();
// Usar en formulario de rescate
CreateRescueInput(
latitude: position.latitude,
longitude: position.longitude,
description: 'Perro abandonado en...',
);
Settings¶
Configuración de preferencias de la aplicación.
Funcionalidades¶
- Tema: Modo claro, oscuro, o seguir sistema
- Idioma: Español, Inglés
- Notificaciones: Preferencias de push notifications
- Cuenta: Cerrar sesión, eliminar cuenta
Estructura¶
features/settings/
└── presentation/
├── pages/
│ └── settings_page.dart
└── widgets/
├── theme_selector.dart
└── language_selector.dart
Cambiar Tema¶
El sistema de temas permite cambiar entre modo claro, oscuro o seguir el sistema.
sequenceDiagram
participant UI as Pagina Configuracion
participant TP as ThemeProvider
participant TN as ThemeNotifier
participant Prefs as SharedPreferences
participant App as MaterialApp
UI->>TP: Leer tema actual
TP-->>UI: ThemeMode.dark
UI->>TN: setThemeMode(light)
TN->>Prefs: Guardar preferencia
Prefs-->>TN: Guardado
TN->>TP: Actualizar estado
TP->>App: Rebuild con nuevo tema
App-->>UI: UI actualiza
Uso en código:
// Leer tema actual
final themeMode = ref.watch(themeModeProvider);
// Cambiar tema
final notifier = await ref.read(themeNotifierInstanceProvider.future);
await notifier.setThemeMode(AppThemeMode.dark);
Onboarding¶
Flujo de registro paso a paso para nuevos usuarios.
Funcionalidades¶
- Selección de rol: Adoptante, Rescatista, Organización
- Información básica: Nombre, email, teléfono
- Ubicación: País, provincia, cantón, distrito
- Verificación: Email o teléfono
Flujo de Onboarding¶
state-v2
[*] --> RoleSelection: Iniciar
RoleSelection --> PersonalInfo: Seleccionar rol
PersonalInfo --> Location: Completar info
Location --> Verification: Seleccionar ubicacion
Verification --> Complete: Verificar
Complete --> [*]: Registro exitoso
RoleSelection --> RoleSelection: Cambiar rol
PersonalInfo --> RoleSelection: Volver
Location --> PersonalInfo: Volver
Verification --> Location: Volver
Estructura¶
features/onboarding/
└── presentation/
├── pages/
│ ├── onboarding_page.dart
│ ├── role_selection_page.dart
│ ├── personal_info_page.dart
│ └── verification_page.dart
└── providers/
└── onboarding_provider.dart
Crear Nueva Feature¶
1. Generar Estructura de Carpetas¶
mkdir -p lib/features/nueva_feature/{domain/{entities,repositories},data/{models,repositories},presentation/{pages,providers,widgets}}
2. Crear Entidad¶
// lib/features/nueva_feature/domain/entities/mi_entidad.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'mi_entidad.freezed.dart';
part 'mi_entidad.g.dart';
@freezed
class MiEntidad with _$MiEntidad {
const factory MiEntidad({
required String id,
required String nombre,
}) = _MiEntidad;
factory MiEntidad.fromJson(Map<String, dynamic> json) =>
_$MiEntidadFromJson(json);
}
3. Crear Interface de Repositorio¶
// lib/features/nueva_feature/domain/repositories/mi_repositorio_interface.dart
import 'package:dartz/dartz.dart';
import 'package:altrupets/core/error/failures.dart';
abstract class MiRepositorioInterface {
Future<Either<Failure, List<MiEntidad>>> getAll();
Future<Either<Failure, MiEntidad>> getById(String id);
Future<Either<Failure, MiEntidad>> create(CreateInput input);
}
4. Implementar Repositorio¶
// lib/features/nueva_feature/data/repositories/mi_repositorio.dart
class MiRepositorio implements MiRepositorioInterface {
final GraphQLClient _client = GraphQLClientService.getClient();
@override
Future<Either<Failure, List<MiEntidad>>> getAll() async {
try {
final result = await _client.query(...);
if (result.hasException) {
return Left(ServerFailure(result.exception.toString()));
}
return Right(...);
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
}
5. Crear Provider¶
// lib/features/nueva_feature/presentation/providers/mi_provider.dart
@freezed
class MiState with _$MiState {
const factory MiState({
@Default(false) bool isLoading,
@Default([]) List<MiEntidad> items,
String? error,
}) = _MiState;
}
class MiNotifier extends StateNotifier<MiState> {
MiNotifier(this._repository) : super(const MiState());
final MiRepositorioInterface _repository;
Future<void> loadItems() async {
state = state.copyWith(isLoading: true);
final result = await _repository.getAll();
result.fold(
(failure) => state = state.copyWith(
isLoading: false,
error: failure.message,
),
(items) => state = state.copyWith(
isLoading: false,
items: items,
),
);
}
}
final miProvider = StateNotifierProvider<MiNotifier, MiState>((ref) {
return MiNotifier(ref.watch(miRepositorioProvider));
});
6. Generar Código¶
7. Crear Páginas¶
// lib/features/nueva_feature/presentation/pages/mi_page.dart
class MiPage extends ConsumerWidget {
const MiPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(miProvider);
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state.error != null) {
return Center(child: Text(state.error!));
}
return ListView.builder(
itemCount: state.items.length,
itemBuilder: (context, index) => ListTile(
title: Text(state.items[index].nombre),
),
);
}
}