Compare commits
358 Commits
v1.0.0(7)
...
fusion-app
| Author | SHA1 | Date | |
|---|---|---|---|
| c0a429bc8a | |||
| ea554e2ec8 | |||
| bc56362d45 | |||
| f0ff6deffa | |||
| cbf40411a3 | |||
| fc194850b7 | |||
| 938a23a6a4 | |||
| cbb5ba4e65 | |||
| 4d1afd5a4a | |||
| 1f30558620 | |||
| 774559f854 | |||
| 82f05d8375 | |||
| 592bea9ead | |||
| 74fbf0084e | |||
| ccef0fd186 | |||
| 6ff2d77a32 | |||
| ecfaaec161 | |||
| 4f6e3684bf | |||
| abdbc2bf2e | |||
| 54b81818ec | |||
| 62b38acab4 | |||
| 924ecd83ee | |||
| 4510d2bb28 | |||
| f54bf24417 | |||
| b5d12f31a1 | |||
| 74f470219a | |||
| 24ddbf34e4 | |||
| 69297f826e | |||
| 390f2501b4 | |||
| 555a668481 | |||
| 5aa0c0acc7 | |||
| 57f0f64d08 | |||
| 9f23ecb42e | |||
| 8ff94a1e92 | |||
| a87c7e8732 | |||
| 4be46f71c7 | |||
| 105211e334 | |||
| 6d2d16d8cb | |||
| 424f2af0cc | |||
| 41f1797dcf | |||
| cf6a9dd6df | |||
| 0d677afd32 | |||
| 323d28bb17 | |||
| 92f6035b84 | |||
| c5c85c4fda | |||
| 4cfee8ced3 | |||
| 376e6bc13a | |||
| 4422f93903 | |||
| a9a34fbbc5 | |||
| 04b8d1609c | |||
| e901b22981 | |||
| 2d87cd5aee | |||
| bef87262b1 | |||
| 3f28aa95bc | |||
| dd8faa9f35 | |||
| bc946cfc39 | |||
| c4bdf707e1 | |||
| 17d931ffda | |||
| a21a8af9b1 | |||
| 05c96bc10e | |||
| bbdaa25e12 | |||
| 440adcdf8d | |||
| 6193c97802 | |||
| a985ced85c | |||
| 51901cc639 | |||
| c7fefe2a8b | |||
| cb897a3243 | |||
| 3325df94f7 | |||
| 0aefed0163 | |||
| 516197b819 | |||
| d9465996d7 | |||
| 309c7b1d87 | |||
| ab1a0a88f9 | |||
| 5be9136e06 | |||
| ac493725cf | |||
| 99da6e12fe | |||
| 383ea3d053 | |||
| 929ccadb0f | |||
| 144e48f370 | |||
| 53edf0b7e1 | |||
| 773312d5f9 | |||
| 003604444c | |||
| c7ed2f16c1 | |||
| 92c922a130 | |||
| f0666d9848 | |||
| 6e6225d6b6 | |||
| c23ae39b87 | |||
| cff71245ae | |||
| 3065b78779 | |||
| c1e498b1ab | |||
| 81c3eaec70 | |||
| 0f2d9ba601 | |||
| 82571e6035 | |||
| ba76348936 | |||
| a181ae4724 | |||
| 4d2cd62267 | |||
| fb281caf99 | |||
| a2ef28a1b5 | |||
| cbaee6d597 | |||
| e37adc1f78 | |||
| 3485e430f7 | |||
| 731787b002 | |||
| c9629c32e3 | |||
| 5c6eb97c1f | |||
| 1961be3805 | |||
| 6ff11b8c1e | |||
| 460fbffcad | |||
| b93fac4614 | |||
| 9622cc2d64 | |||
| 79e8c0fe74 | |||
| 8d5a2c8e56 | |||
| ad0b8d209a | |||
| 653ea9ab56 | |||
| 8c269e8c47 | |||
| a197d5bc28 | |||
| 3f9c298b6f | |||
| 3b1534d3b3 | |||
| eb2bde8d40 | |||
| c3dcc6febc | |||
| c4d328d92c | |||
| 2eee3489cd | |||
| 3b57d0e70d | |||
| fe9476d417 | |||
| 79d0f72f08 | |||
| 5925a97b01 | |||
| 41b22ad457 | |||
| 66a08c8016 | |||
| 3449ff9afd | |||
| d4fbbb8d4b | |||
| 3147566241 | |||
| f7e69b1184 | |||
| 41324c61bd | |||
| b8bf71fbe3 | |||
| 6d49e604be | |||
| 21dcafec26 | |||
| 8e8243345a | |||
| fe38e477e3 | |||
| db47543252 | |||
| caf77b1fd9 | |||
| dcc786d376 | |||
| 8cd01c6f3b | |||
| 1c98c0842d | |||
| 59cced7b17 | |||
| 4e14534b1b | |||
| 72d0c79c74 | |||
| 5b1826a10d | |||
| 039f2bb051 | |||
| 9d6953dbf5 | |||
| 90048ac159 | |||
| 982dee6c7a | |||
| 09897b7f69 | |||
| 09a625530e | |||
| c60761adab | |||
| 6694a4b0ce | |||
| 850796e1ca | |||
| 051424f58b | |||
| ac986ac360 | |||
| cf86570e4c | |||
| e30f5dabcc | |||
| 79039b99e2 | |||
| 14720b66bf | |||
| d7308229a0 | |||
| f7d3dbfd27 | |||
| bd7c47351f | |||
| 6cf994cd5d | |||
| fa3d7aa1fd | |||
| 5b4d31e2f1 | |||
| 2e6769f18f | |||
| 7d20b56583 | |||
| 40d55b0b43 | |||
| 5aa45b3d01 | |||
| ecfb4cc7d2 | |||
| 6b1e571341 | |||
| f6ff53bcd7 | |||
| d84edc11a9 | |||
| 45b2842e5a | |||
| a4f57f780b | |||
| e130f4a037 | |||
| 5c30dd9224 | |||
| 836aea707a | |||
| e0e7815ad8 | |||
| e0dde50eba | |||
| 54ddf68c22 | |||
| b5bba037f1 | |||
| 6ed36dba75 | |||
| 4deb263c7e | |||
| 82786b3577 | |||
| ff48b873e9 | |||
| 35948998f6 | |||
| c034d781af | |||
| 5bebe110fc | |||
| 107a4ec593 | |||
| 03effaed13 | |||
| 32eb4e0d52 | |||
| 6f5855e2fd | |||
| 412cb96888 | |||
| 853b6f20a3 | |||
| 01cb4c9427 | |||
| 82123a6d5f | |||
| 3956a87862 | |||
| 7251349e1d | |||
| cf2dbbeb63 | |||
| e9cceae485 | |||
| 6de01b62ae | |||
| 7c7ffb8f3d | |||
| 80f95bae5a | |||
| 63547b0f37 | |||
| 9ab78ac965 | |||
| 375e613caf | |||
| 9b253dd545 | |||
| 3f3fb3d5d0 | |||
| e48dec979c | |||
| 1c30318e06 | |||
| d5d38637a7 | |||
| ac5219f389 | |||
| 4fbdce3c8c | |||
| 5ad0a7acc5 | |||
| 065433ff61 | |||
| c06fb06d03 | |||
| 04c26e83cf | |||
| dc7325ea65 | |||
| 76782fbfd4 | |||
| c17e94ff7f | |||
| c84287e803 | |||
| 44c8949c07 | |||
| aaecc38461 | |||
| 3470e1bfef | |||
| 0530f892f2 | |||
| 734bd79af7 | |||
| 94e2fcbf7d | |||
| 35a943c066 | |||
| 5193e6ada2 | |||
| 2052fdcf85 | |||
| 4e50384dd9 | |||
| 9f5ec3f1da | |||
| db3197a93a | |||
| b90eed2a54 | |||
| 118be4c6c0 | |||
| 62de343dae | |||
| df92c51344 | |||
| 221d053d5f | |||
| e5cf5fcb61 | |||
| 3e427f44d7 | |||
| 746230a541 | |||
| 86642b9587 | |||
| 71ffc52993 | |||
| d355ee2442 | |||
| cc5159fc56 | |||
| d6d82d20c6 | |||
| f2d2385f24 | |||
| e6974c7be7 | |||
| 20cebc8bc7 | |||
| 2247833203 | |||
| 92e93a2b69 | |||
| 691dfc0472 | |||
| 2b9b6aa215 | |||
| 4cd4be24e6 | |||
| a547f7a786 | |||
| 42698631a3 | |||
| 69fdc2233f | |||
| 75b47e2c25 | |||
| 1c0a8b7bb7 | |||
| 417b6660fc | |||
| b8ac786146 | |||
| dd1617939b | |||
| 4c85af38aa | |||
| 309ff8b8b7 | |||
| e040944965 | |||
| b6526f20ee | |||
| 0418f16f87 | |||
| f36ad5e4a6 | |||
| 0a50941c2b | |||
| 7746d08759 | |||
| 72c88cc4b0 | |||
| b21b234b9a | |||
| f89bca99b3 | |||
| 1056895c31 | |||
| 424b8d9034 | |||
| 4aa91c355e | |||
| 7ea415cb6e | |||
| 21fd1e0197 | |||
| d618ed76d0 | |||
| dfd7ba9c41 | |||
| b8f5c5d6f8 | |||
| d470ed470a | |||
| a400fef77d | |||
| febc21a590 | |||
| 29fca859fc | |||
| d92fe887fd | |||
| 315e5b2908 | |||
| 244e5bbd03 | |||
| a86041885c | |||
| 12011ce525 | |||
| c92e2fb67f | |||
| 7e1ead9cae | |||
| e59ce36033 | |||
| aa3ffdb6a7 | |||
| 2eddb99c47 | |||
| c461519597 | |||
| 919ee55c45 | |||
| f5350f5e78 | |||
| b9b49f0b26 | |||
| afa916a30d | |||
| 4347cefaed | |||
| e7ebe7f403 | |||
| ed41b82076 | |||
| 9470f54867 | |||
| f82d222df3 | |||
| fd8ef27185 | |||
| 5b6ed5cf16 | |||
| bf1032245a | |||
| fad2c8792c | |||
| 73d9de45a2 | |||
| 56d89fcdc4 | |||
| eff6f01924 | |||
| 72d44b81df | |||
| 2942d7393e | |||
| 0b160758e2 | |||
| ecbb6d1e76 | |||
| e7a4653c01 | |||
| 05ffe572c8 | |||
| cbc40f7d95 | |||
| 27e26ca921 | |||
| e83adbfdbf | |||
| 973fc2490c | |||
| e148b9fdfa | |||
| 238c15888b | |||
| ddc5086b3b | |||
| 769e8fea27 | |||
| 297fa8241a | |||
| 984a87f200 | |||
| cda889a15b | |||
| 1230a27d94 | |||
| bc46f31434 | |||
| 514daf9c7c | |||
| 51a3979c03 | |||
| c7e32d1399 | |||
| 4e21e8d698 | |||
| 703b1e9fba | |||
| 2fe5a2399d | |||
| 9e41090712 | |||
| 648d0fc04b | |||
| 56e437ff13 | |||
| 88c1111bd5 | |||
| 85be483c4e | |||
| 08e099fc37 | |||
| 8a97304ff5 | |||
| 8c1ca94a08 | |||
| cbaff2e763 | |||
| f36bc9afc1 | |||
| 95a03434ca | |||
| 6b2034612a | |||
| ec14ad49e5 | |||
| 03998f9cf1 | |||
| 811e92defc | |||
| 1e60b38087 | |||
| 693f55369c | |||
| 506dd5a80f |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
# Dart / Flutter workspace caches (regenerated by `flutter pub get` / `dart pub get`)
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
**/.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
@@ -15,7 +15,18 @@
|
||||
**/macos/Flutter/ephemeral/
|
||||
**/windows/flutter/ephemeral/
|
||||
|
||||
# Flutter iOS build config (regenerated on pub get; contains machine-specific paths)
|
||||
**/ios/Flutter/Generated.xcconfig
|
||||
**/ios/Flutter/flutter_export_environment.sh
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
|
||||
# App config (contains API keys, passed via --dart-define-from-file)
|
||||
apps/mobile_app/config/*.json
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
38
.idea/modules.xml
generated
38
.idea/modules.xml
generated
@@ -1,38 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/legacy/modules/account/melos_account.iml" filepath="$PROJECT_DIR$/modules/legacy/modules/account/melos_account.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/payment/modules/activity/melos_activity.iml" filepath="$PROJECT_DIR$/modules/payment/modules/activity/melos_activity.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/payment/modules/auth/melos_auth.iml" filepath="$PROJECT_DIR$/modules/payment/modules/auth/melos_auth.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/legacy/modules/control_panel/melos_control_panel.iml" filepath="$PROJECT_DIR$/modules/legacy/modules/control_panel/melos_control_panel.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/legacy/modules/customer_service/melos_customer_service.iml" filepath="$PROJECT_DIR$/modules/legacy/modules/customer_service/melos_customer_service.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/payment/modules/dashboard_shell/melos_dashboard_shell.iml" filepath="$PROJECT_DIR$/modules/payment/modules/dashboard_shell/melos_dashboard_shell.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/packages/design_system/melos_design_system.iml" filepath="$PROJECT_DIR$/packages/design_system/melos_design_system.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/legacy/modules/device_management/melos_device_management.iml" filepath="$PROJECT_DIR$/modules/legacy/modules/device_management/melos_device_management.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/packages/flutter_treezor_entrust_sdk_bridge/melos_flutter_treezor_entrust_sdk_bridge.iml" filepath="$PROJECT_DIR$/packages/flutter_treezor_entrust_sdk_bridge/melos_flutter_treezor_entrust_sdk_bridge.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/packages/fonts/melos_fonts.iml" filepath="$PROJECT_DIR$/packages/fonts/melos_fonts.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/payment/modules/home/melos_home.iml" filepath="$PROJECT_DIR$/modules/payment/modules/home/melos_home.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/legacy/melos_legacy.iml" filepath="$PROJECT_DIR$/modules/legacy/melos_legacy.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/legacy/modules/legacy_auth/melos_legacy_auth.iml" filepath="$PROJECT_DIR$/modules/legacy/modules/legacy_auth/melos_legacy_auth.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/legacy/modules/legacy_dashboard_shell/melos_legacy_dashboard_shell.iml" filepath="$PROJECT_DIR$/modules/legacy/modules/legacy_dashboard_shell/melos_legacy_dashboard_shell.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/legacy/packages/legacy_shared/melos_legacy_shared.iml" filepath="$PROJECT_DIR$/modules/legacy/packages/legacy_shared/melos_legacy_shared.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/legacy/modules/location/melos_location.iml" filepath="$PROJECT_DIR$/modules/legacy/modules/location/melos_location.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/packages/navigation/melos_navigation.iml" filepath="$PROJECT_DIR$/packages/navigation/melos_navigation.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/payment/modules/notifications/melos_notifications.iml" filepath="$PROJECT_DIR$/modules/payment/modules/notifications/melos_notifications.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/packages/payments/melos_payments.iml" filepath="$PROJECT_DIR$/packages/payments/melos_payments.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/payment/modules/profile/melos_profile.iml" filepath="$PROJECT_DIR$/modules/payment/modules/profile/melos_profile.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/packages/sca_treezor/melos_sca_treezor.iml" filepath="$PROJECT_DIR$/packages/sca_treezor/melos_sca_treezor.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/legacy/modules/settings/melos_settings.iml" filepath="$PROJECT_DIR$/modules/legacy/modules/settings/melos_settings.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/apps/mobile_app/melos_sf_app_platform.iml" filepath="$PROJECT_DIR$/apps/mobile_app/melos_sf_app_platform.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/packages/sf_infrastructure/melos_sf_infrastructure.iml" filepath="$PROJECT_DIR$/packages/sf_infrastructure/melos_sf_infrastructure.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/packages/sf_localizations/melos_sf_localizations.iml" filepath="$PROJECT_DIR$/packages/sf_localizations/melos_sf_localizations.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/packages/sf_shared/melos_sf_shared.iml" filepath="$PROJECT_DIR$/packages/sf_shared/melos_sf_shared.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/modules/splash/melos_splash.iml" filepath="$PROJECT_DIR$/modules/splash/melos_splash.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/packages/utils/melos_utils.iml" filepath="$PROJECT_DIR$/packages/utils/melos_utils.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/melos_sf_app_platform_mono_repo.iml" filepath="$PROJECT_DIR$/melos_sf_app_platform_mono_repo.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/packages/flutter_treezor_entrust_sdk_bridge/example/melos_flutter_treezor_entrust_sdk_bridge_example.iml" filepath="$PROJECT_DIR$/packages/flutter_treezor_entrust_sdk_bridge/example/melos_flutter_treezor_entrust_sdk_bridge_example.iml"/>
|
||||
<module fileurl="file://$PROJECT_DIR$/melos_sf-app-platform.iml" filepath="$PROJECT_DIR$/melos_sf-app-platform.iml"/>
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
195
Resumen_Integracion_Juphoon.md
Normal file
195
Resumen_Integracion_Juphoon.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Integración Videollamadas Juphoon — Resumen del progreso
|
||||
|
||||
## Contexto
|
||||
|
||||
SaveFamily S.L (Bizkaia) está integrando videollamadas y chat entre su app móvil Flutter y sus smartwatches infantiles (RTOS/Android), usando el SDK de Juphoon (`jc_sdk`).
|
||||
|
||||
**Actores:**
|
||||
- Grupo SaveFamily S.L — Cliente, dueño de la app y backend
|
||||
- Shenzhen i365-Tech Co., Limited (Jane Zhang, Carmen) — Fabricante hardware, intermediario comercial
|
||||
- Juphoon/JUQU (Allen) — Proveedor del SDK de videollamadas
|
||||
- SeTracker — Proveedor del firmware del reloj y servidores auxiliares
|
||||
|
||||
**Cotización aprobada: $8,835** (Integración $2,200 + Chat $2,950 + Cloud Photo Album $735 + Encryption $2,950)
|
||||
|
||||
---
|
||||
|
||||
## Lo que se hizo
|
||||
|
||||
### 1. Análisis de documentación (3 rondas)
|
||||
- **Ronda 1** (31-03-2026): 50 preguntas técnicas → 27/50 respondidas (54%)
|
||||
- **Ronda 2** (01-04-2026): 17 preguntas generales → 17/17 respondidas (calidad desigual)
|
||||
- **Ronda 3** (09-04-2026): Documentación oficial SDK recibida — Quickstart V1.1 Flutter (13 páginas), sequence diagrams, protocolo TCP, connection/mutual dialing process
|
||||
- Documentos generados: análisis completo, conclusiones, preguntas bilingües ES/EN, análisis cruzado de respuestas
|
||||
|
||||
### 2. Cuenta Juphoon Cloud creada (16-04-2026)
|
||||
- Consola: juphoon.com (+34)
|
||||
- App creada: "SaveFamily" (tipo IoT, escenario Smartwatch)
|
||||
- **AppKey:** `9efcf2d889dc8a0320925096`
|
||||
- **AppSecret:** `ui7pr73ggl5rr0gf01np` (solo backend)
|
||||
- **AES_KEY IoT:** `8e3637pG7E9144E0` (solo backend)
|
||||
- Token auth activado en consola
|
||||
|
||||
### 3. Paquete `packages/videocall_sdk/` creado
|
||||
Wrapper 100% del `jc_sdk` v2.16.5 con arquitectura sólida (patrón `sca_treezor` del monorepo):
|
||||
|
||||
- **7 servicios** cubriendo toda la API pública del SDK:
|
||||
- `VideocallClient` → JCClient (auth, login, logout, messaging)
|
||||
- `VideocallCallService` → JCCall (llamadas 1-to-1)
|
||||
- `VideocallDeviceService` → JCMediaDevice (cámara, mic, speaker)
|
||||
- `VideocallChannelService` → JCMediaChannel (llamadas grupales)
|
||||
- `VideocallPushService` → JCPush (push notifications)
|
||||
- `VideocallNetService` → JCNet (estado de red)
|
||||
- `VideocallLogService` → JCLog (logging)
|
||||
- **Constructor injection** (no singletons estáticos)
|
||||
- **GetIt module** (`videocallSdkModule(config)`)
|
||||
- **`VideocallSdkManager`** orquestador de inicialización (Client → Device → Call/Channel/Push)
|
||||
- **`VideocallSdkConfig`** abstracto para config por entorno
|
||||
- **Riverpod providers** + StreamProviders para UI reactiva
|
||||
- **Callbacks del SDK → Dart Streams**
|
||||
|
||||
### 4. Permisos nativos configurados
|
||||
- **Android:** RECORD_AUDIO, ACCESS_WIFI_STATE, MODIFY_AUDIO_SETTINGS, BLUETOOTH + uses-feature (camera, bluetooth) + ProGuard rules (juphoon, justalk)
|
||||
- **iOS:** NSMicrophoneUsageDescription, NSPhotoLibraryUsageDescription, NSCameraUsageDescription actualizado + Podfile GCC_PREPROCESSOR_DEFINITIONS (PERMISSION_CAMERA, PHOTOS, MICROPHONE)
|
||||
|
||||
### 5. AppKey configurado por entorno
|
||||
- `juphoonAppKey` en development.json, staging.json, production.json
|
||||
- `Environment.juphoonAppKey` via `String.fromEnvironment()`
|
||||
- `SaveFamilyVideocallConfig` implementa `VideocallSdkConfig`
|
||||
- `videocallSdkModule(config)` integrado en `init_app.dart`
|
||||
|
||||
### 6. Feature `videocall/` creada en device_management
|
||||
Feature completa siguiendo el patrón del monorepo (builder + domain + data + presentation):
|
||||
|
||||
**Domain:**
|
||||
- `videocall_error.dart` — enums de error/success/screenMode
|
||||
- `videocall_participant.dart` — entidad Freezed para participantes grupales
|
||||
- `videocall_signaling_repository.dart` — interface señalización backend
|
||||
|
||||
**Data:**
|
||||
- `videocall_signaling_datasource.dart` — interface
|
||||
- `videocall_signaling_datasource_impl.dart` — placeholder (TODO cuando backend dé spec)
|
||||
- `videocall_signaling_repository_impl.dart` — impl
|
||||
|
||||
**State:**
|
||||
- `videocall_view_state.dart` — Freezed state 1-to-1 (screenMode, sdk ready, mic/speaker/camera, canvas, error/success events)
|
||||
- `videocall_view_model.dart` — Notifier 1-to-1 (init, login, call, answer, hangup, mute, speaker, camera, streams del SDK)
|
||||
- `group_call_view_state.dart` — Freezed state grupal
|
||||
- `group_call_view_model.dart` — Notifier grupal (join, leave, participants, streams)
|
||||
|
||||
**Widgets:**
|
||||
- `video_view_widget.dart` — renderiza JCMediaDeviceVideoCanvas (iOS/Android)
|
||||
- `call_controls_widget.dart` — mic, speaker, camera, hangup (botones circulares)
|
||||
- `call_status_indicator.dart` — "Llamando...", "Conectando..."
|
||||
- `incoming_call_overlay.dart` — aceptar/rechazar llamada entrante (fullscreen)
|
||||
- `participant_tile_widget.dart` — tile individual con video + nombre
|
||||
- `participant_grid_widget.dart` — grid responsivo de participantes
|
||||
|
||||
**Screen:**
|
||||
- `videocall_screen.dart` — 4 modos: idle (input userID + botón llamar), outgoing (llamando...), incoming (overlay aceptar/rechazar), inCall (video fullscreen + PIP + controles)
|
||||
|
||||
**Routing:**
|
||||
- `videocall_builder.dart` — GoRouter builder
|
||||
- Ruta: `/legacy/dashboard/device_management/videocall`
|
||||
|
||||
### 7. Code review realizado
|
||||
Score: **6/10 — Request changes**
|
||||
|
||||
**Issues identificados (pendientes de corregir):**
|
||||
1. Hardcoded test credentials (`p_test1/test123`) en UI de producción → guardar con `kDebugMode`
|
||||
2. `_onCallItemRemove` llama async sin await → race condition
|
||||
3. Todos los errores mapean a `I18n.errorGeneric` → sin diferenciación para el usuario
|
||||
4. `videocall_screen.dart` (310 líneas) demasiado grande → extraer `_IdleView` y `_InCallView` a ficheros separados como `ConsumerWidget`
|
||||
5. `group_call_view_model.dart` es dead code (no lo consume ninguna screen)
|
||||
6. Signaling placeholder con `throw UnimplementedError` → cambiar a no-op
|
||||
7. `VideocallParticipant` (domain) expone tipo SDK (`JCMediaDeviceVideoCanvas`) → mover al ViewModel
|
||||
|
||||
---
|
||||
|
||||
## Dónde quedamos
|
||||
|
||||
- **Rama:** `feature/videocall-sdk-integration`
|
||||
- Los cambios del paquete `videocall_sdk` están **commiteados y pusheados** (3 commits)
|
||||
- Los cambios de la feature están en disco pero **sin commitear** (necesitan correcciones del code review)
|
||||
- `fusion-app` avanzó y revirtió algunos cambios compartidos (permisos, rutas) → hay que re-sincronizar
|
||||
|
||||
---
|
||||
|
||||
## Pendiente
|
||||
|
||||
### Correcciones del code review
|
||||
- [ ] Guardar test credentials con `kDebugMode`
|
||||
- [ ] Fix async race en `_onCallItemRemove`
|
||||
- [ ] Implementar mensajes de error diferenciados
|
||||
- [ ] Extraer `_IdleView` y `_InCallView` a ficheros separados
|
||||
- [ ] Integrar o excluir group call ViewModel
|
||||
- [ ] Cambiar signaling placeholder de throw a no-op
|
||||
- [ ] Remover SDK type de domain entity
|
||||
|
||||
### Pruebas APP↔APP (primera llamada real)
|
||||
- [ ] Login con 2 userIDs de prueba (`p_test1`, `p_test2`)
|
||||
- [ ] Videollamada entre dos teléfonos físicos
|
||||
- [ ] Probar incoming call, reject, hangup, mute, camera switch
|
||||
- [ ] Probar app cerrada en iOS (riesgo #1 — push/background)
|
||||
|
||||
### Integración con backend
|
||||
- [ ] Obtener API REST del backend SaveFamily para señalización
|
||||
- [ ] Definir formato userID con backend (`p_<cuenta>` + sanitización emails)
|
||||
- [ ] Implementar datasource de señalización
|
||||
|
||||
### Pruebas APP↔Reloj
|
||||
- [ ] Llamada APP → Reloj
|
||||
- [ ] Llamada Reloj → APP
|
||||
- [ ] Llamadas grupales
|
||||
|
||||
### Producción
|
||||
- [ ] Token auth (backend genera tokens con AppSecret)
|
||||
- [ ] AppKeys separadas por entorno
|
||||
- [ ] Push/background iOS (PushKit + CallKit si necesario)
|
||||
|
||||
---
|
||||
|
||||
## 3 riesgos abiertos antes del pago ($8,835)
|
||||
|
||||
| # | Riesgo | Estado |
|
||||
|---|---|---|
|
||||
| 1 | **Push/background iOS** — la doc no menciona FCM/APNs, CallKit ni ConnectionService. App cerrada = no recibe llamadas. Posible deal-breaker | ❌ Sin respuesta |
|
||||
| 2 | **GDPR sin DPA** — servidores UE pero sin DPA, sin control routing, datos de menores | ❌ Email enviado 01-04, sin respuesta |
|
||||
| 3 | **Chat sin spec** — $2,950 sin lista de features, "mira SeTracker2" | ❌ Sin spec |
|
||||
| + | **Encryption** — $2,950 pagados, cero documentación del módulo | ❌ Sin spec |
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura confirmada
|
||||
|
||||
```
|
||||
APP (Flutter + jc_sdk) ←→ Juphoon Cloud (solo media)
|
||||
↕ API REST
|
||||
Backend SaveFamily ←→ Backend i365/SeTracker ←→ Smartwatch (firmware + jrtc_* C API)
|
||||
↕ TCP plano
|
||||
```
|
||||
|
||||
- El "Server" del protocolo TCP es **i365**, NO Juphoon
|
||||
- Juphoon Cloud **solo rutea audio/video** (media plane)
|
||||
- La señalización (quién llama a quién) va por el backend
|
||||
|
||||
## Naming conventions (protocolo TCP)
|
||||
|
||||
| Tipo | Formato | Ejemplo |
|
||||
|---|---|---|
|
||||
| Watch userID | `w_` + IMEI | `w_000078932675810` |
|
||||
| Mobile userID | `p_` + APP account | `p_abc10086` |
|
||||
| Group room | `did` + `_group` | `0245423235_group` |
|
||||
| Single room | `did` + `_` + APP account | `0245423235_abc10086` |
|
||||
|
||||
`@` y `.` se reemplazan por `_` en room numbers y userIDs.
|
||||
|
||||
## Documentación de referencia
|
||||
|
||||
- Quickstart V1.1: `~/Downloads/Video call API_ Juphoon Flutter SDK quickstart V1.1.pdf`
|
||||
- TCP Protocol: `~/Downloads/Juphoon Video Call TCP Protocol.docx`
|
||||
- Connection process: `~/Downloads/video call connection process Rev2.docx`
|
||||
- Mutual dialing: `~/Downloads/video call mutual dialing process.docx`
|
||||
- Schematics: `~/Downloads/schematics _2025.03.26 (2)/`
|
||||
- pub.dev: https://pub.dev/packages/jc_sdk
|
||||
- Consola Juphoon: https://developer.juphoon.com
|
||||
@@ -101,4 +101,10 @@ flutter {
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
// Required by AntelopAwareMessagingService to forward FCM pushes to the
|
||||
// Antelop SDK. The Antelop AAR (com.entrust.antelop:antelop) is brought
|
||||
// in transitively through the flutter_treezor_entrust_sdk_bridge plugin.
|
||||
implementation(platform("com.google.firebase:firebase-bom:33.4.0"))
|
||||
implementation("com.google.firebase:firebase-messaging")
|
||||
implementation("com.entrust.antelop:antelop:2.6.4")
|
||||
}
|
||||
|
||||
@@ -21,3 +21,11 @@
|
||||
-dontwarn com.huawei.hms.location.LocationServices
|
||||
-dontwarn com.huawei.hms.push.RemoteMessage
|
||||
-dontwarn com.huawei.hms.security.SecComponentInstallWizard
|
||||
|
||||
## Juphoon jc_sdk
|
||||
-dontwarn com.juphoon.*
|
||||
-keep class com.juphoon.**{*;}
|
||||
-dontwarn com.justalk.*
|
||||
-keep class com.justalk.**{*;}
|
||||
-keepattributes InnerClasses
|
||||
-keep class **.R$* {*;}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<application>
|
||||
<meta-data
|
||||
android:name="fr.antelop.application_id"
|
||||
android:value="3381448747424346509" />
|
||||
android:value="4713640103500149457" />
|
||||
<meta-data
|
||||
android:name="fr.antelop.issuer_id"
|
||||
android:value="treezor" />
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.bluetooth"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
@@ -34,6 +50,29 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="sf_default_channel" />
|
||||
|
||||
<!-- Wrap FCM with Antelop SDK forwarding (see AntelopAwareMessagingService). -->
|
||||
<service
|
||||
android:name=".AntelopAwareMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Disable Antelop's stock FCM service so AntelopAwareMessagingService is the only handler. -->
|
||||
<service
|
||||
android:name="fr.antelop.exposed.DefaultAntelopFirebaseMessagingService"
|
||||
tools:node="remove" />
|
||||
|
||||
<!-- Disable the firebase_messaging plugin's FCM service so AntelopAwareMessagingService is the only handler. -->
|
||||
<service
|
||||
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
|
||||
tools:node="remove" />
|
||||
</application>
|
||||
|
||||
<!-- Required to query activities that can process text, see:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.savefamily.app
|
||||
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import fr.antelop.sdk.firebase.AntelopFirebaseMessagingUtil
|
||||
import io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService
|
||||
|
||||
/**
|
||||
* FCM service that gives the Antelop SDK first dibs on every push, then
|
||||
* delegates the rest to the firebase_messaging Flutter plugin so Dart still
|
||||
* receives the notifications it expects.
|
||||
*
|
||||
* Without this, only one FirebaseMessagingService can win the
|
||||
* com.google.firebase.MESSAGING_EVENT intent — and once we added the
|
||||
* firebase_messaging plugin, its FlutterFirebaseMessagingService started
|
||||
* winning over Antelop's DefaultAntelopFirebaseMessagingService, leaving the
|
||||
* SDK forever waiting for activation pushes that never reached it.
|
||||
*/
|
||||
class AntelopAwareMessagingService : FlutterFirebaseMessagingService() {
|
||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||
val handled = AntelopFirebaseMessagingUtil.onMessageReceived(
|
||||
applicationContext,
|
||||
remoteMessage,
|
||||
)
|
||||
if (!handled) {
|
||||
super.onMessageReceived(remoteMessage)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
super.onNewToken(token)
|
||||
AntelopFirebaseMessagingUtil.onTokenRefresh(applicationContext)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<application>
|
||||
<meta-data
|
||||
android:name="fr.antelop.application_id"
|
||||
android:value="8632355012486459749" />
|
||||
android:value="4713640103500149457" />
|
||||
<meta-data
|
||||
android:name="fr.antelop.issuer_id"
|
||||
android:value="treezor" />
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "950566980029",
|
||||
"project_id": "sf-platform-pro",
|
||||
"storage_bucket": "sf-platform-pro.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:950566980029:android:75a7c10b6259d09681aad4",
|
||||
"android_client_info": {
|
||||
"package_name": "com.savefamily.app"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDkjNdOAK0ype7wgdgiC1BCKV_pP4s_mlA"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<application>
|
||||
<meta-data
|
||||
android:name="fr.antelop.application_id"
|
||||
android:value="3381448747424346509" />
|
||||
android:value="4713640103500149457" />
|
||||
<meta-data
|
||||
android:name="fr.antelop.issuer_id"
|
||||
android:value="treezor" />
|
||||
|
||||
BIN
apps/mobile_app/assets/shared/images/gps_location.png
Normal file
BIN
apps/mobile_app/assets/shared/images/gps_location.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"env": "development",
|
||||
"apiBaseUrl": "https://api-neki-b2b.neki.es/gateway/api/",
|
||||
"apiOrigin": "https://neki-b2b.neki.es"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"env": "production",
|
||||
"apiBaseUrl": "https://api-neki-b2b.neki.es/gateway/api/",
|
||||
"apiOrigin": "https://neki-b2b.neki.es"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"env": "staging",
|
||||
"apiBaseUrl": "https://api-platform.pre.savefamilygps.net/gateway/api/",
|
||||
"apiOrigin": "https://platform.pre.savefamilygps.net"
|
||||
}
|
||||
637
apps/mobile_app/docs/analytics-catalog-technical.md
Normal file
637
apps/mobile_app/docs/analytics-catalog-technical.md
Normal file
@@ -0,0 +1,637 @@
|
||||
# Catálogo de Analíticas — SaveFamily (módulo legacy)
|
||||
|
||||
> Documento para el equipo de Marketing. Describe cada evento de Firebase
|
||||
> Analytics que la app envía desde el módulo legacy: qué significa, cuándo se
|
||||
> dispara, qué parámetros trae, y qué insight ofrece.
|
||||
>
|
||||
> **Ambiente:** Eventos visibles en vivo en Firebase Console Analytics
|
||||
> **DebugView** (para builds debug/profile con el flag de debug activado).
|
||||
> Los reportes históricos están en **Realtime**, **Engagement Events** y
|
||||
> **Engagement Pages and screens**.
|
||||
>
|
||||
> **Parámetro común:** Cada evento incluye automáticamente un parámetro
|
||||
> `consent_status` (`true` / `false`) para permitir filtrado por
|
||||
> consentimiento GDPR cuando corresponda.
|
||||
|
||||
---
|
||||
|
||||
## Índice
|
||||
|
||||
1. [User Properties (propiedades del usuario)](#user-properties)
|
||||
2. [Screen Views (vistas de pantalla automáticas)](#screen-views)
|
||||
3. [Autenticación (`legacy_auth_*`)](#autenticación)
|
||||
4. [Cuenta (`legacy_account_*`)](#cuenta)
|
||||
5. [Dispositivo — Setup / alta (`legacy_device_setup_*`)](#dispositivo--setup)
|
||||
6. [Dispositivo — Funciones (`legacy_device_*`)](#dispositivo--funciones)
|
||||
7. [Contactos del dispositivo (`legacy_contacts_*`)](#contactos-del-dispositivo)
|
||||
8. [Ajustes (`legacy_settings_*`)](#ajustes)
|
||||
9. [Soporte (`legacy_support_*`)](#soporte)
|
||||
10. [Onboarding (`legacy_onboarding_*`)](#onboarding)
|
||||
11. [Panel principal (`legacy_control_panel_*`)](#panel-principal)
|
||||
12. [Ubicación y mapa (`legacy_location_*`)](#ubicación-y-mapa)
|
||||
|
||||
---
|
||||
|
||||
## User Properties
|
||||
|
||||
Son propiedades que se setean una sola vez por usuario (al hacer login) y
|
||||
sirven para **segmentar** a los usuarios en los reportes. Cualquier evento
|
||||
puede cruzarse por estas dimensiones en Firebase Analytics.
|
||||
|
||||
| Propiedad | Descripción | Valores ejemplo | Cuándo se setea |
|
||||
|---|---|---|---|
|
||||
| `env` | Ambiente de la app | `development`, `staging`, `production` | Al arrancar la app |
|
||||
| `user_id` (interna) | Identificador único del usuario | UUID del backend | Al confirmar el login (después del 2FA) |
|
||||
| `user_role` | Rol del usuario en el backend | `client`, `admin`, etc. | Al login |
|
||||
| `user_language` | Idioma preferido del usuario | `es`, `en`, `fr`, `de`, `it`, `pt` | Al login |
|
||||
| `user_signup_date` | Fecha de creación de la cuenta (ISO 8601 UTC) | `2024-04-07T10:34:42.000Z` | Al login |
|
||||
| `user_has_phone` | Si tiene teléfono registrado | `true` / `false` | Al login |
|
||||
| `user_has_api_key` | Si tiene una API key asignada (usuario técnico) | `true` / `false` | Al login |
|
||||
|
||||
> **Nota futura:** Cuando se lance el plan premium, se agregará
|
||||
> `user_plan` (`free` / `premium` / `family`) para segmentar la base por
|
||||
> plan.
|
||||
|
||||
---
|
||||
|
||||
## Screen Views
|
||||
|
||||
Cada vez que el usuario navega a una pantalla, Firebase recibe un evento
|
||||
automático `screen_view` con el parámetro `screen_name` igual al nombre
|
||||
lógico de la ruta (no el nombre de clase Flutter).
|
||||
|
||||
**Esto se captura automáticamente**, sin instrumentación manual en cada
|
||||
pantalla, mediante un listener del router. **También captura los cambios de
|
||||
tab del bottom navigation** (home device functions mapa chat).
|
||||
|
||||
### Pantallas del módulo legacy que se trackean
|
||||
|
||||
| Screen name | Pantalla |
|
||||
|---|---|
|
||||
| `splash` | Pantalla de carga inicial |
|
||||
| `legacy_onboarding` | Intro/onboarding |
|
||||
| `legacy_login` | Pantalla de login |
|
||||
| `legacy_signup` | Alta de cuenta |
|
||||
| `legacy_recover_password` | Recuperación de contraseña |
|
||||
| `legacy_device_setup` | Wizard de alta de reloj/dispositivo |
|
||||
| `legacy_request_link_phone` | Inicio de vinculación de teléfono |
|
||||
| `legacy_verify_link_phone_code` | Verificación del código OTP |
|
||||
| `control_panel` | Dashboard principal (home del legacy) |
|
||||
| `customer_service` | Pantalla de soporte |
|
||||
| `account_settings` | Menú de cuenta |
|
||||
| `personal_data` | Editar datos personales |
|
||||
| `change_password` | Cambiar contraseña |
|
||||
| `linked_devices` | Dispositivos vinculados a la cuenta |
|
||||
| `app_users` | Sub-usuarios de la app |
|
||||
| `delete_account` | Flujo de eliminación de cuenta |
|
||||
| `device_management` | Menú de gestión del dispositivo |
|
||||
| `scheduled_activities` | Actividades programadas |
|
||||
| `contacts` | Contactos del dispositivo |
|
||||
| `edit_contact` | Editar un contacto |
|
||||
| `health` | Salud (ritmo cardíaco, SpO2) |
|
||||
| `remote_connection` | Conexión remota (cámara, llamada) |
|
||||
| `locate_device` | Localizar dispositivo |
|
||||
| `rewards` | Recompensas |
|
||||
| `activity_meter` | Medidor de actividad (pasos) |
|
||||
| `apps_use` | Uso de apps |
|
||||
| `volume_control` | Control de volumen |
|
||||
| `call_history` | Historial de llamadas |
|
||||
| `background_image` | Imagen de fondo del dispositivo |
|
||||
| `legacy_location` | Mapa de ubicación |
|
||||
| `legacy_chat` | Chat (placeholder) |
|
||||
| `settings` | Menú de ajustes |
|
||||
| `alarm` | Alarmas |
|
||||
| `remote_management` | Gestión remota |
|
||||
| `sos_agenda` | Contactos SOS |
|
||||
| `sound` | Sonido del dispositivo |
|
||||
| `sync_clock` | Sincronización de reloj |
|
||||
| `app_store` | Gestión de apps instaladas |
|
||||
| `battery` | Batería / modo nocturno |
|
||||
| `block_phone` | Bloqueo de teléfono (whitelist) |
|
||||
| `disable_functions` | Desactivar funciones (teclado, GPS) |
|
||||
| `language` | Idioma del dispositivo |
|
||||
| `legacy_notifications` | Notificaciones del dispositivo |
|
||||
| `remote_on_off` | Encendido/apagado remoto |
|
||||
| `alerts` | Alertas |
|
||||
| `timezone` | Zona horaria |
|
||||
| `wifi_settings` | Configuración WiFi |
|
||||
|
||||
**Insight para marketing:** Con estas screen_view podés construir funnels
|
||||
(ej: `legacy_login control_panel device_management locate_device`) y
|
||||
medir tiempos entre pantallas, rebotes y pantallas más visitadas.
|
||||
|
||||
---
|
||||
|
||||
## Autenticación
|
||||
|
||||
Prefijo `legacy_auth_*` — cubre login, 2FA, signup, recuperación de
|
||||
contraseña, vinculación de teléfono y logout.
|
||||
|
||||
### Login
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_auth_login_attempt` | El usuario pulsa "Iniciar sesión" después de validar el formulario en el cliente. | — | Tope del funnel de login. Usar como base del "100 %" del funnel. |
|
||||
| `legacy_auth_login_success` | El backend aceptó email + contraseña. Aún falta el 2FA. | — | Credenciales válidas. Usar para medir la calidad de la contraseña/email. |
|
||||
| `legacy_auth_login_failure` | El backend rechazó las credenciales o hubo un error de red. | `reason` (mensaje de error) | Fricción. Analizar los `reason` más frecuentes para detectar problemas. |
|
||||
|
||||
### 2FA (doble factor)
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_auth_2fa_requested` | El backend envió el código 2FA al usuario. | — | Usuario pasó el primer paso del login. |
|
||||
| `legacy_auth_2fa_verified` | El código 2FA fue verificado y la sesión está activa. | — | Login exitoso. Fin del funnel de login. |
|
||||
| `legacy_auth_2fa_failure` | El código 2FA fue rechazado (incorrecto, expirado). | `reason` | Fricción en el 2FA. Si es muy alto, puede indicar problemas con la entrega del código. |
|
||||
| `legacy_auth_2fa_resend` | El usuario pidió reenviar el código. | — | Indica que no le llegó el primero. Útil para medir problemas de entrega. |
|
||||
|
||||
### Signup
|
||||
|
||||
El signup es un wizard de **2 pasos** (`step_index` 0, 1):
|
||||
|
||||
- **Paso 0 — Datos personales:** nombre, apellido, email, teléfono (con picker de país), aceptación de términos.
|
||||
- **Paso 1 — Contraseña:** password y repeat password con validación de reglas.
|
||||
|
||||
El `language` se infiere del locale del dispositivo al momento del submit.
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_auth_signup_started` | El usuario envió el formulario final (submit del paso 1). | — | Top del funnel de request al backend. |
|
||||
| `legacy_auth_signup_completed` | El backend creó la cuenta exitosamente. | — | Conversión de nueva cuenta. |
|
||||
| `legacy_auth_signup_failed` | Error en el alta (email ya existe, datos inválidos, error de red). | `reason` | Drop-off del signup. Analizar `reason` para ver si hay patrones. |
|
||||
| `legacy_auth_signup_step_completed` | El usuario avanzó a un paso siguiente (validación pasó). | `step_index` (0, 1) — el paso que JUSTO terminó | Funnel interno del signup. Permite ver cuántos completan paso 0 y llegan al 1. |
|
||||
| `legacy_auth_signup_step_back` | El usuario tocó "atrás" dentro del wizard. | `step_index` — el paso del que vuelve | Indica que el usuario quiere corregir algo — fricción. |
|
||||
| `legacy_auth_signup_step_validation_failed` | El usuario tocó "siguiente" pero la validación del formulario lo rechazó. | `step_index` | **Muy valioso:** dice en qué paso hay más problemas de validación. Si step 0 falla: nombre/apellido, email, teléfono o términos. Si step 1 falla: reglas de contraseña. |
|
||||
|
||||
### Recuperación de contraseña
|
||||
|
||||
Flujo **exclusivo por email** (se removió la opción de SMS/teléfono).
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_auth_password_reset_requested` | El usuario inició el flujo de recuperar contraseña tipeando su email. | — | Fricción: alguien no recuerda su contraseña. |
|
||||
| `legacy_auth_password_reset_email_sent` | El backend confirmó el envío del email de recuperación. | — | Confirma que el email salió. Cruzar con `reset_requested` para medir fallas. |
|
||||
| `legacy_auth_password_reset_completed` | El usuario guardó la nueva contraseña exitosamente. | — | **Conversión final** del funnel de recuperación. |
|
||||
| `legacy_auth_password_reset_failed` | Error al intentar guardar la nueva contraseña. | `reason` (`unequal_passwords`, `too_short`, `no_capitals`, `no_numbers`, `no_special_chars`, o mensaje del backend) | Permite ver qué reglas de validación molestan más a los usuarios. |
|
||||
|
||||
### Vinculación de teléfono
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_auth_link_phone_code_requested` | El usuario envió su número y pidió el código OTP. | — | Inicio del flujo de linking. |
|
||||
| `legacy_auth_link_phone_code_request_failed` | Falló el pedido del código al backend. | `reason` | Fricción inicial. |
|
||||
| `legacy_auth_link_phone_code_verified` | El código OTP fue verificado con éxito. | — | Número vinculado. |
|
||||
| `legacy_auth_link_phone_code_verification_failed` | Falló la verificación (código incorrecto o expirado). | `reason` | Fricción en el segundo paso. |
|
||||
|
||||
### Logout
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_auth_logout` | El usuario cerró sesión y la app limpió la sesión local. | — | Señal de fin de sesión. Cruzar con duración de sesión para ver patrones de uso. |
|
||||
|
||||
---
|
||||
|
||||
## Cuenta
|
||||
|
||||
Prefijo `legacy_account_*` — cubre edición de perfil, contraseña,
|
||||
dispositivos vinculados, usuarios de la app y **eliminación de cuenta
|
||||
(señal crítica de churn)**.
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_account_personal_data_edited` | El usuario guardó cambios en sus datos personales (nombre, apellido, teléfono). | — | Engagement con la cuenta. |
|
||||
| `legacy_account_password_changed` | Cambio de contraseña exitoso. | — | Señal de buen hábito de seguridad. |
|
||||
| `legacy_account_password_change_failed` | El cambio de contraseña falló. | `reason` | Fricción. |
|
||||
| `legacy_account_linked_device_unlinked` | El usuario quitó un dispositivo vinculado de su cuenta. | — | Posible señal temprana de desuso del dispositivo. |
|
||||
| `legacy_account_linked_device_renamed` | El usuario renombró un dispositivo vinculado (editó el carrier name). | — | Personalización / engagement con la gestión de dispositivos. |
|
||||
| `legacy_account_app_user_delete_triggered` | El usuario tocó "eliminar" en la pantalla de app users. | — | Nota técnica: la implementación actual borra al usuario logueado (parece ser placeholder). El evento se mantiene para medir demanda del feature. |
|
||||
| `legacy_account_deletion_initiated` | **CHURN SIGNAL** — El usuario entró al flujo "Eliminar cuenta". | — | Top del funnel de churn. |
|
||||
| `legacy_account_deletion_confirmed` | El usuario confirmó la eliminación y la API call está en progreso. | — | El usuario quiere realmente irse. |
|
||||
| `legacy_account_deletion_completed` | El backend confirmó la eliminación. | — | Usuario perdido. |
|
||||
| `legacy_account_deletion_cancelled` | El usuario canceló antes de confirmar la eliminación. | — | Save: el usuario se arrepintió. Útil para medir efectividad de pantallas de retención. |
|
||||
|
||||
---
|
||||
|
||||
## Dispositivo — Setup
|
||||
|
||||
Prefijo `legacy_device_setup_*` — **el momento aha del producto**: vincular
|
||||
un reloj/dispositivo del hijo a la cuenta del padre/madre.
|
||||
|
||||
### Funnel del wizard
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_device_setup_started` | El usuario entró al wizard de alta de dispositivo. | — | Top del funnel de activación. |
|
||||
| `legacy_device_setup_step_completed` | El usuario completó un paso del wizard. | `step` (`intro`, `link_info`, `scan_watch`, `profile`), `duration_seconds` (cuánto tardó en ese paso) | Permite ver dónde se abandona más el wizard **Y cuánto tiempo pasan los usuarios en cada paso** — fricción directa. |
|
||||
| `legacy_device_setup_completed` | El dispositivo se creó exitosamente y está vinculado. | `child_gender` (M/F/other), `relation_type` (mother/father/etc), `child_age_years` | **Conversión de activación + demográficos del usuario final**. Marketing puede construir **personas reales** con estos 3 params: género, edad y relación con el adulto que compró. |
|
||||
| `legacy_device_setup_failed` | Falló un paso del wizard. | `at_step` (en qué paso falló), `reason` (error) | Señal para el equipo técnico de dónde hay problemas. |
|
||||
| `legacy_device_setup_cancelled` | El usuario volvió atrás y abandonó el wizard. | `at_step` | Drop-off del wizard. |
|
||||
|
||||
### Entrada del código del reloj (QR vs. manual)
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_device_setup_qr_scanned` | El usuario escaneó exitosamente el código QR del reloj. | — | Método "rápido". Si su ratio baja, el QR scanner puede estar fallando. |
|
||||
| `legacy_device_setup_manual_code_entered` | El usuario avanzó con el código tipeado manualmente (no escaneó). | — | Fallback. Si crece mucho el ratio vs QR, invertir en mejorar la UX del scanner. |
|
||||
|
||||
### Familias con múltiples hijos
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_device_setup_reset_for_new_kid` | Después de terminar un alta, el usuario tocó "agregar otro hijo". | — | **Señal de familia con múltiples hijos**. Estos usuarios típicamente tienen mayor retention y LTV — son el mejor segmento. |
|
||||
|
||||
---
|
||||
|
||||
## Dispositivo — Funciones
|
||||
|
||||
Prefijo `legacy_device_*` — acciones sobre el dispositivo ya vinculado.
|
||||
Mide qué features del producto se usan más.
|
||||
|
||||
### Localización del dispositivo (comando "find")
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_device_locate_requested` | El usuario pulsó el botón de localizar (intento). | — | Uso del feature principal del producto. Top del mini-funnel de localización. |
|
||||
| `legacy_device_locate_success` | El comando de localizar fue enviado con éxito al backend. | — | El dispositivo va a sonar. Conversión del mini-funnel. |
|
||||
| `legacy_device_locate_failure` | El comando de localizar falló (error del backend o de red). | `reason` | Problema técnico al localizar. Drop-off del mini-funnel. |
|
||||
|
||||
### Conexión remota (cámara + llamadas)
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_device_remote_connection_started` | El usuario entró a la pantalla de conexión remota. | — | Intención de interactuar remotamente. |
|
||||
| `legacy_device_remote_connection_photo_taken` | El usuario pidió una foto de la cámara remota. | — | Feature avanzada. Permite medir uso de la cámara del reloj. |
|
||||
| `legacy_device_remote_connection_call_initiated` | El usuario inició una llamada bidireccional. | — | Feature crítica: llamar al niño. |
|
||||
| `legacy_device_remote_connection_picture_viewed` | El usuario navegó entre fotos de la cámara remota. | `direction` (`next`, `prev`, `direct`) | Engagement con la galería: cuántas fotos revisa el padre después de pedirlas. |
|
||||
|
||||
### Volumen del dispositivo
|
||||
|
||||
Cada envío del formulario dispara **un evento por tipo de volumen que
|
||||
efectivamente cambió** (media, ringtone, alarm) — si el usuario movió solo
|
||||
el media, solo se manda ese. Permite medir qué tipo de sonido configuran
|
||||
más los padres.
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_device_volume_control_changed` | El usuario guardó un cambio de volumen en el dispositivo, se emite 1 vez por cada tipo modificado. | `type` (`media`, `ringtone`, `alarm`), `level` (0-100) | Configuración. Cruzar `type` para ver cuál se ajusta más. |
|
||||
|
||||
### Imagen de fondo del reloj
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_device_background_image_changed` | El usuario seleccionó una imagen existente como fondo. | — | Personalización. |
|
||||
| `legacy_device_background_image_uploaded` | El usuario subió una foto personal como fondo. | — | Alta personalización — indicador de engagement. |
|
||||
|
||||
### Actividades programadas (alarmas personalizadas del dispositivo)
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_device_scheduled_activity_added` | El usuario agregó una actividad programada. | `week_day` (0-6, 0 = domingo), `period` (`HH:mm-HH:mm`) | **Dato muy útil:** permite ver qué horarios programan los padres (desayuno, colegio, deberes, etc) y qué días. |
|
||||
| `legacy_device_scheduled_activity_updated` | El usuario editó una actividad programada. | `week_day`, `period` | Refinamiento de configuración. |
|
||||
| `legacy_device_scheduled_activity_removed` | El usuario eliminó una actividad programada. | — | |
|
||||
|
||||
### Recompensas
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_device_rewards_granted` | El usuario asignó minutos de recompensa al dispositivo. | `amount` (cantidad de minutos) | Gamificación / recompensas de uso. |
|
||||
|
||||
### Podómetro (Activity Meter)
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_device_activity_pedometer_toggled` | El usuario activó/desactivó el contador de pasos. | `enabled` (`true` / `false`) | |
|
||||
| `legacy_device_activity_meter_time_range_changed` | El usuario cambió el rango de fechas en la pantalla de pasos. | `range` (`today`, `seven_days`, `thirty_days`, `custom`) | **Engagement profundo:** el padre no solo abre la pantalla, sino que investiga distintos períodos. |
|
||||
|
||||
### Salud (ritmo cardíaco / SpO2)
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_device_health_heart_rate_frequency_changed` | El usuario cambió la frecuencia de medición del ritmo cardíaco. | `frequency_seconds` | Personalización de monitoreo de salud. |
|
||||
| `legacy_device_health_measurement_started` | El usuario inició una medición manual de ritmo cardíaco. | — | Interés en datos de salud del niño. |
|
||||
| `legacy_device_health_time_range_changed` | El usuario cambió el rango de fechas en la pantalla de salud. | `range` | Engagement profundo: padres revisando el historial de salud. |
|
||||
|
||||
### Uso de aplicaciones (Apps Use)
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_device_apps_use_time_range_changed` | El usuario cambió el rango de fechas en la pantalla de uso de apps. | `range`, `total_duration_seconds` (total acumulado del período), `top_app_name` (app más usada en ese período) | **El evento más rico del módulo.** Permite a marketing segmentar directo: "padres cuyos hijos usan más TikTok que YouTube", "familias con uso > 4hs/día", etc. |
|
||||
|
||||
### Historial de llamadas
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_device_call_history_filter_changed` | El usuario cambió el filtro del historial. | `filter` (`all`, `incoming`, `outgoing`, `missed`) | **Cuando se filtra `missed` es señal de preocupación** del padre: busca llamadas perdidas del hijo. |
|
||||
|
||||
---
|
||||
|
||||
## Contactos del dispositivo
|
||||
|
||||
Prefijo `legacy_contacts_*` — contactos permitidos para llamadas desde el
|
||||
dispositivo del niño.
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_contacts_added` | El usuario agregó un contacto al dispositivo. | `total_count` (cantidad total de contactos DESPUÉS del add) | Configuración inicial o expansión de la agenda. El `total_count` permite segmentar "padres con agenda chica vs grande". |
|
||||
| `legacy_contacts_edited` | El usuario editó un contacto existente. | — | |
|
||||
| `legacy_contacts_deleted` | El usuario eliminó un contacto del dispositivo. | `total_count` (cantidad total DESPUÉS del delete) | |
|
||||
|
||||
---
|
||||
|
||||
## Ajustes
|
||||
|
||||
Prefijo `legacy_settings_*` — configuración general del dispositivo
|
||||
(alarmas, SOS, bloqueos, idioma, red, etc).
|
||||
|
||||
### Alarmas
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_settings_alarm_added` | Alarma nueva creada. | `time` (`HH:mm`) | Uso del feature de alarma. El `time` permite ver qué horarios son más populares (despertador matutino, hora del colegio, etc). |
|
||||
| `legacy_settings_alarm_updated` | Alarma existente editada. | `time` (el NUEVO `HH:mm`) | Refinamiento. |
|
||||
| `legacy_settings_alarm_removed` | Alarma eliminada. | — | |
|
||||
|
||||
### Contactos SOS
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_settings_sos_contact_added` | Contacto SOS agregado. | `total_count` | Configuración de seguridad. Muy importante. |
|
||||
| `legacy_settings_sos_contact_removed` | Contacto SOS removido. | `total_count` | |
|
||||
|
||||
### Whitelist del teléfono (bloqueo de llamadas)
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_settings_block_phone_contact_added` | Contacto agregado a la whitelist de llamadas permitidas. | `total_count` | Control parental. |
|
||||
| `legacy_settings_block_phone_contact_removed` | Contacto removido de la whitelist. | `total_count` | |
|
||||
|
||||
### Control parental (funciones desactivadas)
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_settings_disable_functions_changed` | El usuario guardó cambios en la pantalla de funciones desactivadas. | — | Engagement con control parental (evento agregado). |
|
||||
| `legacy_settings_disable_functions_keyboard_toggled` | Se guardó con el teclado habilitado/deshabilitado. | `enabled` | Control granular. |
|
||||
| `legacy_settings_disable_functions_gps_toggled` | Se guardó con el GPS habilitado/deshabilitado. | `enabled` | Control granular. |
|
||||
|
||||
### Otros ajustes del dispositivo
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_settings_language_changed` | Se cambió el idioma del dispositivo. | `language` (ej. `es`, `en`) | |
|
||||
| `legacy_settings_alerts_configured` | El usuario guardó cambios en las alertas. | `alert_count` (cuántas alertas activas), `alerts_enabled` (lista separada por comas truncada a 100 chars) | Permite ver qué alertas son más populares y cuántas alertas promedio configuran los padres. |
|
||||
| `legacy_settings_timezone_changed` | Se cambió la zona horaria. | `timezone` | |
|
||||
| `legacy_settings_wifi_added` | Se agregó una red WiFi permitida. | `total_count` | |
|
||||
| `legacy_settings_wifi_removed` | Se eliminó una red WiFi permitida. | `total_count` | |
|
||||
| `legacy_settings_sound_changed` | Se cambió el modo de sonido del dispositivo. | `mode` (`normal` / `silent` / `vibrate`) | Preferencia de perfil sonoro del niño. |
|
||||
| `legacy_settings_sync_clock_triggered` | El usuario disparó una sincronización manual del reloj del dispositivo. | — | |
|
||||
| `legacy_settings_battery_night_mode_toggled` | El usuario activó/desactivó el modo nocturno (ahorro de batería). | `enabled` | |
|
||||
|
||||
### Gestión remota del dispositivo (comandos destructivos)
|
||||
|
||||
Estos eventos son **muy importantes** para churn analysis. Un
|
||||
`factory_reset` típicamente precede a un desvinculado y potencial churn.
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_settings_remote_management_shutdown` | El usuario apagó el dispositivo remotamente. | — | Acción poco frecuente. |
|
||||
| `legacy_settings_remote_management_restart` | El usuario reinició el dispositivo remotamente. | — | Típicamente usado cuando hay problemas técnicos. |
|
||||
| `legacy_settings_remote_management_factory_reset` | **CHURN SIGNAL** — El usuario reseteó el dispositivo a fábrica. | — | Borra el dispositivo. Frecuentemente precede un `legacy_account_linked_device_unlinked` y luego `legacy_account_deletion_*`. Cruzar para medir correlación. |
|
||||
|
||||
---
|
||||
|
||||
## Soporte
|
||||
|
||||
Prefijo `legacy_support_*` — solo 1 evento hoy, medirá la demanda de
|
||||
soporte.
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_support_contact_initiated` | El usuario tocó el botón para contactar soporte (ej. abrir el cliente de email). | `channel` (`email` hoy; en el futuro también `phone`, `whatsapp`), `country` (país seleccionado en el formulario) | Demanda de soporte **por país**: permite ver dónde se originan más tickets. Nota: mide la **intención** de contactar, no confirma envío. |
|
||||
|
||||
---
|
||||
|
||||
## Onboarding
|
||||
|
||||
Prefijo `legacy_onboarding_*` — los slides de intro iniciales de la app.
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_onboarding_step_changed` | El usuario pasó a un nuevo slide del intro. | `step_index` (número de slide, empieza en 0) | Medir cuántos slides el usuario ve antes de empezar. |
|
||||
|
||||
---
|
||||
|
||||
## Panel principal
|
||||
|
||||
Prefijo `legacy_control_panel_*` — acciones en el home del legacy.
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_control_panel_device_selected` | El usuario cambió el dispositivo activo (útil cuando hay varios hijos). | `total_devices` (cuántos dispositivos tiene vinculados) | Qué dispositivo está monitoreando activamente. El `total_devices` permite **segmentar por tamaño de familia** (1 hijo, 2 hijos, 3+). |
|
||||
| `legacy_control_panel_positions_refreshed` | El usuario tiró del pull-to-refresh o tocó "actualizar" en el dashboard. | — | Preocupación activa del usuario. Indicador de engagement alto. |
|
||||
|
||||
---
|
||||
|
||||
## Ubicación y mapa
|
||||
|
||||
Prefijo `legacy_location_*` — **el feature más rico del producto**. Acá
|
||||
capturamos toda la interacción del usuario con el mapa: ver el trayecto,
|
||||
crear zonas seguras, ver lugares frecuentes, cambiar frecuencia de
|
||||
actualización, etc.
|
||||
|
||||
### Geofences (zonas seguras) — CRUD básico
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_location_geofence_created` | Se creó una geofence (API confirmó). | — | **Conversión final** del funnel de creación. |
|
||||
| `legacy_location_geofence_updated` | Se actualizó una geofence existente. | — | Refinamiento de configuración de zonas. |
|
||||
| `legacy_location_geofence_deleted` | Se eliminó una geofence. | — | |
|
||||
|
||||
### Lugares frecuentes — CRUD básico
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_location_frequent_place_created` | Se creó un lugar frecuente (API confirmó). | — | **Conversión final** del funnel. |
|
||||
| `legacy_location_frequent_place_updated` | Se actualizó un lugar frecuente. | — | |
|
||||
| `legacy_location_frequent_place_deleted` | Se eliminó un lugar frecuente. | — | |
|
||||
|
||||
### Funnel de creación de lugares (geofences y frequent places)
|
||||
|
||||
Este es el funnel más valioso del módulo de ubicación. Permite medir
|
||||
**cuánta gente empieza a crear una zona vs. cuánta termina**.
|
||||
|
||||
```
|
||||
legacy_location_place_creation_started (top: 100 %)
|
||||
|
||||
legacy_location_point_confirmed (paso 1 completado)
|
||||
|
||||
legacy_location_radius_confirmed (solo geofences — paso 2)
|
||||
|
||||
legacy_location_geofence_created (bottom: API OK)
|
||||
o legacy_location_frequent_place_created
|
||||
```
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_location_place_creation_started` | El usuario tocó "agregar zona" o "agregar lugar frecuente". | `mode` (`geofence` / `frequent_place`) | Top del funnel. |
|
||||
| `legacy_location_point_confirmed` | El usuario tocó el mapa para fijar el centro del lugar. | `mode` | Paso 1 del funnel completado. |
|
||||
| `legacy_location_radius_confirmed` | El usuario confirmó el radio de la geofence (solo aplica a geofences). | `radius` (metros), `is_editing` (`true` si estaba editando una existente, `false` si es nueva) | Paso 2 del funnel completado. Permite también **analizar qué tamaños de zonas eligen los usuarios** (radios más comunes casa, escuela, etc.). |
|
||||
| `legacy_location_place_creation_cancelled` | El usuario salió del flujo de creación/edición antes de terminar. | `mode`, `at_step` (`picking_point` o `adjusting_radius`) | **Drop-off del funnel**. El `at_step` dice exactamente dónde lo perdimos. |
|
||||
|
||||
### Exploración y edición
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_location_geofence_selected` | El usuario tocó una geofence del mapa para verla. | — | Engagement: el usuario está mirando sus zonas. |
|
||||
| `legacy_location_geofence_dismissed` | El usuario cerró el popup de la geofence sin hacer nada. | — | "Miró pero no editó". Indicador de exploración. |
|
||||
| `legacy_location_geofence_edit_started` | El usuario tocó "editar" en una geofence seleccionada. | — | Intención de editar. Mid-funnel de edición. |
|
||||
| `legacy_location_frequent_place_selected` | El usuario tocó un lugar frecuente para verlo. | — | Engagement. |
|
||||
| `legacy_location_frequent_place_dismissed` | El usuario cerró el popup del lugar frecuente. | — | |
|
||||
| `legacy_location_history_position_selected` | El usuario tocó un punto del historial de ubicaciones en el mapa. | — | Inspección detallada del trayecto. |
|
||||
| `legacy_location_history_position_dismissed` | El usuario cerró el detalle del punto de historial. | — | |
|
||||
|
||||
### Historial de ubicaciones
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_location_history_loaded` | El usuario cargó el historial para un rango de fechas. | — | Interés en el historial. |
|
||||
| `legacy_location_history_cleared` | El usuario limpió el trayecto del mapa. | — | |
|
||||
|
||||
### Frecuencia de ubicación (privacidad vs. precisión)
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_location_frequency_updated` | El usuario cambió cada cuánto el dispositivo manda su posición. | `frequency_seconds` (ej. `60`, `300`, `900`) | **Dato súper útil:** indica preferencia entre privacidad y precisión/batería. Correlacionar con retention. |
|
||||
|
||||
### Capas del mapa (toggles de visibilidad)
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_location_map_geofences_toggled` | El usuario mostró/ocultó las geofences en el mapa. | `visible` (`true` / `false`) | |
|
||||
| `legacy_location_map_frequent_places_toggled` | El usuario mostró/ocultó los lugares frecuentes. | `visible` | |
|
||||
| `legacy_location_map_route_trail_toggled` | El usuario mostró/ocultó la línea del trayecto histórico. | `visible` | |
|
||||
|
||||
### Modo "seguir en vivo"
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_location_following_toggled` | El usuario activó/desactivó el modo "seguir dispositivo" (el mapa se re-centra automáticamente). | `enabled` (`true` / `false`) | **Engagement alto:** el usuario está viendo al hijo en tiempo real. Correlacionar con horarios (ej. entrada/salida del cole). |
|
||||
|
||||
### UI del mapa (chrome)
|
||||
|
||||
| Evento | Cuándo se dispara | Parámetros | Qué significa para marketing |
|
||||
|---|---|---|---|
|
||||
| `legacy_location_map_actions_expanded` | El usuario abrió/cerró el drawer de acciones del mapa. | `expanded` (`true` / `false`) | Indica conocimiento de la UI. |
|
||||
| `legacy_location_map_zoomed` | El usuario hizo zoom in/out y se quedó en ese nivel (con debounce de 1 segundo para no spamear). | `zoom` (nivel de zoom final) | Nivel de detalle con el que los usuarios miran el mapa. Un zoom alto indica "me importa ver dónde exactamente está". |
|
||||
| `legacy_location_map_style_changed` | El usuario eligió otro estilo visual para el mapa desde el selector de capas. | `style` (`standard` / `voyager` / `light` / `dark` / `satellite`) | Personalización de la experiencia. **Satellite** es el más usado por padres que quieren ver edificios reales. |
|
||||
| `legacy_location_map_center_tapped` | El usuario tocó el botón "centrar en el dispositivo" del mapa. | — | Acción de re-centrado manual. Indica que el mapa se desplazó y el usuario quiere volver al hijo. |
|
||||
| `legacy_location_map_refresh_tapped` | El usuario tocó el botón de refresco dentro del mapa (distinto del pull-to-refresh del control panel). | — | **Engagement intenso:** el usuario quiere la posición más reciente AHORA. Suele dispararse en momentos de ansiedad. |
|
||||
| `legacy_location_shared` | El usuario tocó "compartir ubicación" — abre el share sheet nativo para mandar la posición del hijo a otra app. | — | **Acción viral del producto.** Es la más importante para crecimiento orgánico: indica que el usuario está mandando data del producto a contactos fuera de la app (familia, pareja, abuelos). |
|
||||
| `legacy_location_list_sheet_opened` | El usuario abrió el bottom sheet con la lista de geofences, lugares frecuentes e historial. | — | Quiere explorar todo lo que tiene configurado. Mid-funnel de gestión. |
|
||||
| `legacy_location_history_type_filter_changed` | El usuario filtró el historial por tipo de posición. | `type` (`gps` / `wifi` / `sos` / `all` cuando limpia el filtro) | Indica interés en una fuente de datos específica. **`sos`** filtrado es señal de un evento crítico que el usuario está investigando. |
|
||||
|
||||
---
|
||||
|
||||
## Cómo usar este catálogo
|
||||
|
||||
### Para construir funnels
|
||||
Tomá un evento "inicio" y uno "fin" en Firebase Analytics Engagement
|
||||
**Funnels** y comparalos:
|
||||
- **Signup:** `legacy_auth_signup_started _completed`
|
||||
- **Login:** `legacy_auth_login_attempt _2fa_verified`
|
||||
- **Activación (aha moment):** `legacy_device_setup_started _completed`
|
||||
- **Creación de zona segura:** `legacy_location_place_creation_started _geofence_created`
|
||||
- **Churn:** `legacy_account_deletion_initiated _deletion_completed`
|
||||
|
||||
### Para segmentar audiencias
|
||||
En **Audiences** podés filtrar por user properties (`user_language`,
|
||||
`user_has_phone`, etc.) y cruzarlo con cualquiera de estos eventos.
|
||||
|
||||
### Para detectar problemas
|
||||
Filtrar por los eventos con `_failed` o `_failure` y mirar los `reason`
|
||||
más frecuentes en la pestaña Events Parameter.
|
||||
|
||||
### Para medir engagement diario
|
||||
Los eventos `legacy_control_panel_positions_refreshed`,
|
||||
`legacy_location_following_toggled` y las screen_views del mapa son los
|
||||
indicadores más fuertes de usuarios activos y preocupados.
|
||||
|
||||
---
|
||||
|
||||
## Eventos propuestos para el futuro (NO implementados aún)
|
||||
|
||||
Esta sección es la **wishlist** para cuando existan los features o lleguen
|
||||
las decisiones pendientes.
|
||||
|
||||
### Cuando exista el plan premium/suscripción
|
||||
- `purchase` / `purchase_subscription` (con `value`, `currency`, `transaction_id`)
|
||||
- `action_click_gopremium` (botón de upgrade)
|
||||
- `subscription_error_payment` / `subscription_canceled_payment`
|
||||
- User property `user_plan` (`free` / `premium` / `family`)
|
||||
|
||||
### Limit popups / free-tier walls
|
||||
- `legacy_limit_hit` con `limit_type` (max_devices, max_contacts, etc.)
|
||||
- `legacy_limit_popup_shown`
|
||||
- `legacy_limit_popup_upgrade_clicked`
|
||||
|
||||
### Referral / invitación
|
||||
- `legacy_referral_screen_viewed`
|
||||
- `legacy_referral_code_shared` (con `channel`)
|
||||
- `legacy_referral_signup_completed`
|
||||
|
||||
### NPS / rating
|
||||
- `legacy_nps_prompt_shown`
|
||||
- `legacy_nps_score_submitted` (con `score` 0–10)
|
||||
- `legacy_app_rating_submitted`
|
||||
|
||||
### Push notification engagement
|
||||
- `legacy_notification_received` (background)
|
||||
- `legacy_notification_opened` (tap app abre)
|
||||
- `legacy_notification_dismissed`
|
||||
|
||||
### Aha moments
|
||||
- `legacy_first_device_connected` (primera vez que el usuario vincula un dispositivo — requiere persistencia de "primera vez")
|
||||
- `legacy_first_session_completed`
|
||||
|
||||
### A/B testing
|
||||
- `ab_test_<experiment_name>` (cuando empecemos experimentos con Remote Config)
|
||||
|
||||
### Errores de API / health técnica
|
||||
- `legacy_api_error` con `endpoint`, `status_code` (detectar endpoints flakey)
|
||||
- `legacy_session_expired`
|
||||
|
||||
---
|
||||
|
||||
## Referencias técnicas
|
||||
|
||||
- **Proyecto Firebase:** `sf-platform-pre` (para dev+staging) / `sf-platform-prod` (pendiente de crear)
|
||||
- **Package Dart:** `packages/sf_tracking/`
|
||||
- **Mixins:** Cada grupo de eventos vive en un mixin aparte dentro del package (`auth_tracking.dart`, `location_tracking.dart`, etc).
|
||||
- **GDPR:** Cada evento incluye automáticamente el parámetro `consent_status` para permitir filtrado post-hoc en BigQuery cuando se implemente el consent screen.
|
||||
- **Ambiente:** `env` se setea como user property (`development` / `staging` / `production`), por lo que **todos los reportes pueden filtrarse por ambiente** y producción no se va a mezclar con testing.
|
||||
|
||||
---
|
||||
|
||||
## Changelog del catálogo
|
||||
|
||||
- **2026-04-07** — Creación inicial. 61 eventos del módulo legacy implementados y validados en device físico (iPhone 14 Pro iOS 18 + Samsung Galaxy A55 Android 15).
|
||||
- **2026-04-07** — Se agregaron 16 eventos nuevos al módulo de ubicación (funnel de creación, exploración, edición, follow mode, map zoom debounced, history).
|
||||
- **2026-04-07** — Se expandió el tracking de `device_management` con 8 eventos nuevos y 3 enriquecimientos de parámetros:
|
||||
- NUEVOS: `legacy_device_locate_success/failure`, `legacy_device_remote_connection_picture_viewed`, `legacy_device_activity_meter_time_range_changed`, `legacy_device_health_time_range_changed`, `legacy_device_apps_use_time_range_changed` (con total_duration_seconds y top_app_name), `legacy_device_call_history_filter_changed`.
|
||||
- ENRIQUECIDOS: `legacy_device_volume_control_changed` ahora dispara un evento por cada tipo (media/ringtone/alarm) que efectivamente cambió; `legacy_device_scheduled_activity_added/updated` ahora incluyen `week_day` y `period`; `legacy_contacts_added/deleted` ahora incluyen `total_count`.
|
||||
- **2026-04-07** — Se expandió el tracking de `settings` con 3 eventos nuevos y 7 enriquecimientos de parámetros:
|
||||
- NUEVOS: `legacy_settings_remote_management_shutdown/restart/factory_reset` (churn signal crítico).
|
||||
- ENRIQUECIDOS: `legacy_settings_alarm_added/updated` ahora incluyen `time`; `legacy_settings_sos_contact_added/removed` incluyen `total_count`; `legacy_settings_block_phone_contact_added/removed` incluyen `total_count`; `legacy_settings_wifi_added/removed` incluyen `total_count`; `legacy_settings_sound_changed` incluye `mode`; `legacy_settings_alerts_configured` incluye `alert_count` y `alerts_enabled`.
|
||||
- **2026-04-07** — Se expandió el tracking de `device_setup` con 3 eventos nuevos y 2 enriquecimientos críticos:
|
||||
- NUEVOS: `legacy_device_setup_qr_scanned`, `legacy_device_setup_manual_code_entered`, `legacy_device_setup_reset_for_new_kid` (señal de familias con múltiples hijos).
|
||||
- ENRIQUECIDOS: `legacy_device_setup_step_completed` ahora incluye `duration_seconds` (tiempo por paso — fricción directa); `legacy_device_setup_completed` ahora incluye `child_gender`, `relation_type`, `child_age_years` ( demográficos del usuario final para personas de marketing).
|
||||
- **2026-04-07** — Se expandió el tracking de `legacy_auth` con 3 eventos nuevos para el funnel interno del signup:
|
||||
- NUEVOS: `legacy_auth_signup_step_completed`, `legacy_auth_signup_step_back`, `legacy_auth_signup_step_validation_failed` (originalmente con `step_index` 0-2; reducido a 0-1 en abril 2026 al simplificar el signup).
|
||||
- **2026-04-07** — Pasada final de cobertura en `legacy_auth`, `account`, `support`, `control_panel`: 6 eventos nuevos y 2 enriquecimientos.
|
||||
- NUEVOS AUTH: `legacy_auth_password_reset_completed`, `legacy_auth_password_reset_failed` (con `reason` granular), `legacy_auth_link_phone_code_request_failed`, `legacy_auth_link_phone_code_verification_failed`.
|
||||
- NUEVOS ACCOUNT: `legacy_account_linked_device_renamed`, `legacy_account_app_user_delete_triggered`.
|
||||
- ENRIQUECIDOS: `legacy_support_contact_initiated` ahora incluye `country` además de `channel`; `legacy_control_panel_device_selected` ahora incluye `total_devices` (proxy de tamaño de familia).
|
||||
- **2026-04-07** — Se expandió la cobertura de los widgets del módulo `location` con 6 eventos nuevos sobre acciones top-level del mapa:
|
||||
- NUEVOS: `legacy_location_map_style_changed` (selector de capas), `legacy_location_map_center_tapped`, `legacy_location_map_refresh_tapped`, `legacy_location_shared` ( acción viral del producto), `legacy_location_list_sheet_opened`, `legacy_location_history_type_filter_changed` (con `type` para detectar interés en posiciones SOS).
|
||||
- **2026-04-15** — Cambios de producto en `legacy_auth`:
|
||||
- **Signup reducido a 2 pasos** (antes 3). Se quitaron los campos de documento, fecha de nacimiento, lugar de nacimiento, país de nacimiento, relación con el niño y dirección completa. El request al backend ahora solo incluye `firstName`, `lastName`, `email`, `phone` (E.164), `language` (del locale del dispositivo) y `password`. `step_index` de los eventos `legacy_auth_signup_step_*` pasa de 0-2 a 0-1.
|
||||
- **Recover password solo por email**: se eliminó la UI de teléfono móvil en ambos screens del flujo (`request_recovery` y `new_password`). Los eventos del flujo se mantienen igual pero ahora siempre corresponden al canal email. Se eliminó del state `recoveryFormat` (ya no hay rama SMS).
|
||||
- **User properties (Firebase Analytics)** ahora se sincronizan solo en shells autenticados (dashboards legacy y payment), no en rutas públicas. Los eventos en sí no cambian — solo se movió el disparador de la sync para evitar llamadas espurias a `/auth/me` en login/signup/recover_password.
|
||||
353
apps/mobile_app/docs/analytics-catalog.md
Normal file
353
apps/mobile_app/docs/analytics-catalog.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# Catálogo de Eventos — SaveFamily
|
||||
|
||||
> Documento para el equipo de Marketing. Lista todos los eventos que la app registra y describe el momento exacto en que se dispara cada uno.
|
||||
|
||||
---
|
||||
|
||||
## Índice
|
||||
|
||||
1. [Pantallas de la app](#pantallas-de-la-app)
|
||||
2. [Autenticación](#autenticación)
|
||||
3. [Cuenta](#cuenta)
|
||||
4. [Alta de dispositivo (reloj/wearable del niño)](#alta-de-dispositivo)
|
||||
5. [Funciones del dispositivo](#funciones-del-dispositivo)
|
||||
6. [Contactos del dispositivo](#contactos-del-dispositivo)
|
||||
7. [Ajustes del dispositivo](#ajustes-del-dispositivo)
|
||||
8. [Soporte](#soporte)
|
||||
9. [Onboarding](#onboarding)
|
||||
10. [Panel principal (home)](#panel-principal)
|
||||
11. [Ubicación y mapa](#ubicación-y-mapa)
|
||||
|
||||
---
|
||||
|
||||
## Pantallas de la app
|
||||
|
||||
Cada vez que el usuario navega a una pantalla, queda registrada
|
||||
automáticamente. También se registran los cambios entre pestañas del menú
|
||||
inferior.
|
||||
|
||||
Pantallas registradas:
|
||||
|
||||
- Pantalla de carga inicial
|
||||
- Onboarding / intro
|
||||
- Login
|
||||
- Alta de cuenta (signup)
|
||||
- Recuperación de contraseña
|
||||
- Wizard de alta de reloj/dispositivo
|
||||
- Inicio de vinculación de teléfono
|
||||
- Verificación del código de vinculación
|
||||
- Dashboard principal (home)
|
||||
- Soporte / atención al cliente
|
||||
- Menú de cuenta
|
||||
- Editar datos personales
|
||||
- Cambiar contraseña
|
||||
- Dispositivos vinculados a la cuenta
|
||||
- Sub-usuarios de la app
|
||||
- Eliminación de cuenta
|
||||
- Menú de gestión del dispositivo
|
||||
- Actividades programadas
|
||||
- Contactos
|
||||
- Editar contacto
|
||||
- Salud (ritmo cardíaco, oxígeno en sangre)
|
||||
- Conexión remota (cámara y llamada)
|
||||
- Localizar dispositivo
|
||||
- Recompensas
|
||||
- Medidor de actividad / pasos
|
||||
- Uso de aplicaciones
|
||||
- Control de volumen
|
||||
- Historial de llamadas
|
||||
- Imagen de fondo del dispositivo
|
||||
- Mapa de ubicación
|
||||
- Chat
|
||||
- Menú de ajustes
|
||||
- Alarmas
|
||||
- Gestión remota
|
||||
- Contactos SOS
|
||||
- Sonido del dispositivo
|
||||
- Sincronización del reloj
|
||||
- Gestión de apps instaladas
|
||||
- Batería / modo nocturno
|
||||
- Bloqueo de teléfono (whitelist)
|
||||
- Desactivar funciones (teclado, GPS)
|
||||
- Idioma del dispositivo
|
||||
- Notificaciones del dispositivo
|
||||
- Encendido/apagado remoto
|
||||
- Alertas
|
||||
- Zona horaria
|
||||
- Configuración WiFi
|
||||
|
||||
---
|
||||
|
||||
## Autenticación
|
||||
|
||||
### Login
|
||||
|
||||
- **legacy_auth_login_attempt** — El usuario pulsa "Iniciar sesión" después de completar el formulario.
|
||||
- **legacy_auth_login_success** — Email y contraseña aceptados (todavía falta el segundo factor).
|
||||
- **legacy_auth_login_failure** — El intento de login fue rechazado.
|
||||
|
||||
### Doble factor (2FA)
|
||||
|
||||
- **legacy_auth_2fa_requested** — Se le envió el código de verificación al usuario.
|
||||
- **legacy_auth_2fa_verified** — El código fue aceptado y la sesión está activa (login completado).
|
||||
- **legacy_auth_2fa_failure** — El código fue rechazado (incorrecto o expirado).
|
||||
- **legacy_auth_2fa_resend** — El usuario pidió que le reenvíen el código.
|
||||
|
||||
### Alta de cuenta (signup)
|
||||
|
||||
El alta es un wizard de 2 pasos (datos personales → contraseña).
|
||||
|
||||
- **legacy_auth_signup_started** — El usuario envió el formulario final del alta.
|
||||
- **legacy_auth_signup_completed** — La cuenta se creó exitosamente.
|
||||
- **legacy_auth_signup_failed** — El alta falló.
|
||||
- **legacy_auth_signup_step_completed** — El usuario completó un paso del wizard y avanzó al siguiente.
|
||||
- **legacy_auth_signup_step_back** — El usuario volvió al paso anterior dentro del wizard.
|
||||
- **legacy_auth_signup_step_validation_failed** — El usuario intentó avanzar pero el formulario tenía errores.
|
||||
|
||||
### Recuperación de contraseña
|
||||
|
||||
Flujo exclusivo por email (no hay opción de SMS).
|
||||
|
||||
- **legacy_auth_password_reset_requested** — El usuario inició el flujo de "olvidé mi contraseña" tipeando su email.
|
||||
- **legacy_auth_password_reset_email_sent** — Se envió el email con el enlace de recuperación.
|
||||
- **legacy_auth_password_reset_completed** — El usuario guardó exitosamente su nueva contraseña.
|
||||
- **legacy_auth_password_reset_failed** — El intento de guardar la nueva contraseña falló.
|
||||
|
||||
### Vinculación de teléfono
|
||||
|
||||
- **legacy_auth_link_phone_code_requested** — El usuario envió su número y pidió el código.
|
||||
- **legacy_auth_link_phone_code_request_failed** — Falló el envío del código.
|
||||
- **legacy_auth_link_phone_code_verified** — El código fue verificado, número vinculado.
|
||||
- **legacy_auth_link_phone_code_verification_failed** — El código no fue aceptado.
|
||||
|
||||
### Cierre de sesión
|
||||
|
||||
- **legacy_auth_logout** — El usuario cerró sesión.
|
||||
|
||||
---
|
||||
|
||||
## Cuenta
|
||||
|
||||
- **legacy_account_personal_data_edited** — El usuario guardó cambios en sus datos personales (nombre, apellido, teléfono).
|
||||
- **legacy_account_password_changed** — El usuario cambió su contraseña exitosamente.
|
||||
- **legacy_account_password_change_failed** — El cambio de contraseña falló.
|
||||
- **legacy_account_linked_device_unlinked** — El usuario quitó un dispositivo vinculado de su cuenta.
|
||||
- **legacy_account_linked_device_renamed** — El usuario renombró un dispositivo vinculado.
|
||||
- **legacy_account_app_user_delete_triggered** — El usuario tocó "eliminar" en la pantalla de sub-usuarios.
|
||||
- **legacy_account_deletion_initiated** — El usuario entró al flujo de "eliminar cuenta". Señal temprana de churn.
|
||||
- **legacy_account_deletion_confirmed** — El usuario confirmó la eliminación.
|
||||
- **legacy_account_deletion_completed** — La cuenta se eliminó.
|
||||
- **legacy_account_deletion_cancelled** — El usuario canceló antes de confirmar la eliminación.
|
||||
|
||||
---
|
||||
|
||||
## Alta de dispositivo
|
||||
|
||||
Vincular el reloj/dispositivo del niño a la cuenta del adulto.
|
||||
|
||||
### Wizard de alta
|
||||
|
||||
- **legacy_device_setup_started** — El usuario entró al wizard de alta de dispositivo.
|
||||
- **legacy_device_setup_step_completed** — El usuario completó un paso del wizard. Se registra cuánto tiempo tardó en ese paso.
|
||||
- **legacy_device_setup_completed** — El dispositivo quedó vinculado. Se registra género y edad del niño y la relación con el adulto.
|
||||
- **legacy_device_setup_failed** — Falló el alta del dispositivo.
|
||||
- **legacy_device_setup_cancelled** — El usuario abandonó el wizard.
|
||||
|
||||
### Cómo se introdujo el código del reloj
|
||||
|
||||
- **legacy_device_setup_qr_scanned** — El usuario escaneó el código QR del reloj.
|
||||
- **legacy_device_setup_manual_code_entered** — El usuario tipeó el código manualmente.
|
||||
|
||||
### Familias con varios hijos
|
||||
|
||||
- **legacy_device_setup_reset_for_new_kid** — Después de terminar un alta, el usuario eligió "agregar otro hijo".
|
||||
|
||||
---
|
||||
|
||||
## Funciones del dispositivo
|
||||
|
||||
### Localizar dispositivo
|
||||
|
||||
- **legacy_device_locate_requested** — El usuario pulsó el botón de localizar.
|
||||
- **legacy_device_locate_success** — La orden de localizar se envió al dispositivo.
|
||||
- **legacy_device_locate_failure** — La orden de localizar falló.
|
||||
|
||||
### Conexión remota (cámara y llamada)
|
||||
|
||||
- **legacy_device_remote_connection_started** — El usuario abrió la pantalla de conexión remota.
|
||||
- **legacy_device_remote_connection_photo_taken** — El usuario pidió una foto desde la cámara remota.
|
||||
- **legacy_device_remote_connection_call_initiated** — El usuario inició una llamada con el dispositivo.
|
||||
- **legacy_device_remote_connection_picture_viewed** — El usuario navegó entre las fotos tomadas remotamente.
|
||||
|
||||
### Volumen del dispositivo
|
||||
|
||||
- **legacy_device_volume_control_changed** — El usuario guardó un cambio de volumen. Se dispara una vez por cada tipo modificado (multimedia, tono de llamada, alarma).
|
||||
|
||||
### Imagen de fondo del reloj
|
||||
|
||||
- **legacy_device_background_image_changed** — El usuario eligió una imagen existente como fondo.
|
||||
- **legacy_device_background_image_uploaded** — El usuario subió una foto personal como fondo.
|
||||
|
||||
### Actividades programadas (rutinas en el dispositivo)
|
||||
|
||||
- **legacy_device_scheduled_activity_added** — El usuario creó una nueva actividad programada. Se registra el día de la semana y el horario.
|
||||
- **legacy_device_scheduled_activity_updated** — El usuario editó una actividad programada.
|
||||
- **legacy_device_scheduled_activity_removed** — El usuario eliminó una actividad programada.
|
||||
|
||||
### Recompensas
|
||||
|
||||
- **legacy_device_rewards_granted** — El usuario otorgó minutos de recompensa al dispositivo. Se registra la cantidad de minutos.
|
||||
|
||||
### Podómetro / pasos
|
||||
|
||||
- **legacy_device_activity_pedometer_toggled** — El usuario activó o desactivó el contador de pasos.
|
||||
- **legacy_device_activity_meter_time_range_changed** — El usuario cambió el rango de fechas en la pantalla de pasos (hoy, 7 días, 30 días, personalizado).
|
||||
|
||||
### Salud (ritmo cardíaco / oxígeno en sangre)
|
||||
|
||||
- **legacy_device_health_heart_rate_frequency_changed** — El usuario cambió la frecuencia con la que se mide el ritmo cardíaco.
|
||||
- **legacy_device_health_measurement_started** — El usuario inició una medición manual.
|
||||
- **legacy_device_health_time_range_changed** — El usuario cambió el rango de fechas en la pantalla de salud.
|
||||
|
||||
### Uso de aplicaciones del dispositivo
|
||||
|
||||
- **legacy_device_apps_use_time_range_changed** — El usuario cambió el rango de fechas en la pantalla de uso de apps. Se registra el tiempo total acumulado y la app más usada del período.
|
||||
|
||||
### Historial de llamadas
|
||||
|
||||
- **legacy_device_call_history_filter_changed** — El usuario filtró el historial (todas, entrantes, salientes, perdidas). Filtrar perdidas suele ser señal de preocupación del adulto.
|
||||
|
||||
---
|
||||
|
||||
## Contactos del dispositivo
|
||||
|
||||
Contactos permitidos para llamar al/desde el dispositivo del niño.
|
||||
|
||||
- **legacy_contacts_added** — El usuario agregó un contacto. Se registra cuántos contactos tiene en total.
|
||||
- **legacy_contacts_edited** — El usuario editó un contacto existente.
|
||||
- **legacy_contacts_deleted** — El usuario eliminó un contacto. Se registra el total restante.
|
||||
|
||||
---
|
||||
|
||||
## Ajustes del dispositivo
|
||||
|
||||
### Alarmas
|
||||
|
||||
- **legacy_settings_alarm_added** — El usuario creó una alarma. Se registra la hora.
|
||||
- **legacy_settings_alarm_updated** — El usuario editó una alarma.
|
||||
- **legacy_settings_alarm_removed** — El usuario eliminó una alarma.
|
||||
|
||||
### Contactos SOS
|
||||
|
||||
- **legacy_settings_sos_contact_added** — El usuario agregó un contacto SOS.
|
||||
- **legacy_settings_sos_contact_removed** — El usuario eliminó un contacto SOS.
|
||||
|
||||
### Whitelist de llamadas (bloqueo de teléfono)
|
||||
|
||||
- **legacy_settings_block_phone_contact_added** — El usuario agregó un contacto a la lista de llamadas permitidas.
|
||||
- **legacy_settings_block_phone_contact_removed** — El usuario quitó un contacto de la lista de llamadas permitidas.
|
||||
|
||||
### Control parental (funciones desactivadas)
|
||||
|
||||
- **legacy_settings_disable_functions_changed** — El usuario guardó cambios en la pantalla de funciones desactivadas.
|
||||
- **legacy_settings_disable_functions_keyboard_toggled** — El usuario activó o desactivó el teclado.
|
||||
- **legacy_settings_disable_functions_gps_toggled** — El usuario activó o desactivó el GPS.
|
||||
|
||||
### Otros ajustes
|
||||
|
||||
- **legacy_settings_language_changed** — El usuario cambió el idioma del dispositivo.
|
||||
- **legacy_settings_alerts_configured** — El usuario guardó cambios en las alertas. Se registra cuántas alertas activas tiene y cuáles están encendidas.
|
||||
- **legacy_settings_timezone_changed** — El usuario cambió la zona horaria.
|
||||
- **legacy_settings_wifi_added** — El usuario agregó una red WiFi permitida.
|
||||
- **legacy_settings_wifi_removed** — El usuario eliminó una red WiFi permitida.
|
||||
- **legacy_settings_sound_changed** — El usuario cambió el modo de sonido del dispositivo (normal / silencio / vibración).
|
||||
- **legacy_settings_sync_clock_triggered** — El usuario disparó una sincronización manual del reloj.
|
||||
- **legacy_settings_battery_night_mode_toggled** — El usuario activó o desactivó el modo nocturno (ahorro de batería).
|
||||
|
||||
### Gestión remota del dispositivo
|
||||
|
||||
- **legacy_settings_remote_management_shutdown** — El usuario apagó el dispositivo a distancia.
|
||||
- **legacy_settings_remote_management_restart** — El usuario reinició el dispositivo a distancia.
|
||||
- **legacy_settings_remote_management_factory_reset** — El usuario restauró el dispositivo a fábrica. Suele preceder al desvinculado y al churn.
|
||||
|
||||
---
|
||||
|
||||
## Soporte
|
||||
|
||||
- **legacy_support_contact_initiated** — El usuario tocó el botón para contactar a soporte. Se registra el canal (email) y el país seleccionado en el formulario.
|
||||
|
||||
---
|
||||
|
||||
## Onboarding
|
||||
|
||||
- **legacy_onboarding_step_changed** — El usuario pasó a un nuevo slide del intro inicial.
|
||||
|
||||
---
|
||||
|
||||
## Panel principal
|
||||
|
||||
- **legacy_control_panel_device_selected** — El usuario cambió el dispositivo activo (útil cuando hay varios hijos). Se registra cuántos dispositivos tiene vinculados.
|
||||
- **legacy_control_panel_positions_refreshed** — El usuario refrescó manualmente el dashboard (pull-to-refresh o botón de actualizar).
|
||||
|
||||
---
|
||||
|
||||
## Ubicación y mapa
|
||||
|
||||
### Zonas seguras (geofences)
|
||||
|
||||
- **legacy_location_geofence_created** — Se creó una nueva zona segura.
|
||||
- **legacy_location_geofence_updated** — Se editó una zona segura existente.
|
||||
- **legacy_location_geofence_deleted** — Se eliminó una zona segura.
|
||||
|
||||
### Lugares frecuentes
|
||||
|
||||
- **legacy_location_frequent_place_created** — Se creó un nuevo lugar frecuente.
|
||||
- **legacy_location_frequent_place_updated** — Se editó un lugar frecuente existente.
|
||||
- **legacy_location_frequent_place_deleted** — Se eliminó un lugar frecuente.
|
||||
|
||||
### Funnel de creación de lugares (zonas y frecuentes)
|
||||
|
||||
- **legacy_location_place_creation_started** — El usuario tocó "agregar zona" o "agregar lugar frecuente".
|
||||
- **legacy_location_point_confirmed** — El usuario fijó el centro del lugar tocando el mapa.
|
||||
- **legacy_location_radius_confirmed** — El usuario confirmó el radio de la zona segura. Se registra el tamaño del radio.
|
||||
- **legacy_location_place_creation_cancelled** — El usuario abandonó el flujo de creación o edición. Se registra en qué paso lo dejó.
|
||||
|
||||
### Exploración y edición
|
||||
|
||||
- **legacy_location_geofence_selected** — El usuario tocó una zona segura del mapa para verla.
|
||||
- **legacy_location_geofence_dismissed** — El usuario cerró el detalle de la zona sin hacer cambios.
|
||||
- **legacy_location_geofence_edit_started** — El usuario tocó "editar" en una zona seleccionada.
|
||||
- **legacy_location_frequent_place_selected** — El usuario tocó un lugar frecuente para verlo.
|
||||
- **legacy_location_frequent_place_dismissed** — El usuario cerró el detalle del lugar frecuente.
|
||||
- **legacy_location_history_position_selected** — El usuario tocó un punto del historial de ubicaciones en el mapa.
|
||||
- **legacy_location_history_position_dismissed** — El usuario cerró el detalle del punto del historial.
|
||||
|
||||
### Historial de ubicaciones
|
||||
|
||||
- **legacy_location_history_loaded** — El usuario cargó el historial de ubicaciones para un rango de fechas.
|
||||
- **legacy_location_history_cleared** — El usuario limpió el trayecto del mapa.
|
||||
|
||||
### Frecuencia de ubicación
|
||||
|
||||
- **legacy_location_frequency_updated** — El usuario cambió cada cuánto el dispositivo manda su posición.
|
||||
|
||||
### Capas del mapa
|
||||
|
||||
- **legacy_location_map_geofences_toggled** — El usuario mostró u ocultó las zonas seguras en el mapa.
|
||||
- **legacy_location_map_frequent_places_toggled** — El usuario mostró u ocultó los lugares frecuentes en el mapa.
|
||||
- **legacy_location_map_route_trail_toggled** — El usuario mostró u ocultó la línea del trayecto histórico.
|
||||
|
||||
### Modo "seguir en vivo"
|
||||
|
||||
- **legacy_location_following_toggled** — El usuario activó o desactivó el modo "seguir dispositivo" (el mapa se re-centra automáticamente sobre el niño).
|
||||
|
||||
### Acciones del mapa
|
||||
|
||||
- **legacy_location_map_actions_expanded** — El usuario abrió o cerró el menú de acciones del mapa.
|
||||
- **legacy_location_map_zoomed** — El usuario hizo zoom y se quedó en ese nivel.
|
||||
- **legacy_location_map_style_changed** — El usuario eligió otro estilo de mapa (estándar, claro, oscuro, satélite, etc.).
|
||||
- **legacy_location_map_center_tapped** — El usuario tocó "centrar en el dispositivo" para volver el mapa sobre el niño.
|
||||
- **legacy_location_map_refresh_tapped** — El usuario tocó refrescar dentro del mapa para pedir la posición más reciente.
|
||||
- **legacy_location_shared** — El usuario compartió la ubicación del niño hacia otra app (familia, pareja, abuelos). Acción viral del producto.
|
||||
- **legacy_location_list_sheet_opened** — El usuario abrió la lista con todas sus zonas, lugares frecuentes e historial.
|
||||
- **legacy_location_history_type_filter_changed** — El usuario filtró el historial por tipo de posición (GPS, WiFi, SOS). Filtrar SOS suele indicar que está investigando un evento crítico.
|
||||
282
apps/mobile_app/docs/videocall-integration.md
Normal file
282
apps/mobile_app/docs/videocall-integration.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# Integración Videollamadas — Juphoon jc_sdk
|
||||
|
||||
## Estado general
|
||||
|
||||
| Fase | Estado |
|
||||
|---|---|
|
||||
| 1. SDK wrapper (`videocall_sdk`) | ✅ Completado |
|
||||
| 2. Configuración nativa (permisos) | ✅ Completado |
|
||||
| 3. Configuración por entorno (AppKey) | ✅ Completado |
|
||||
| 4. Feature videocall (UI + lógica) | ⏳ Pendiente |
|
||||
| 5. Pruebas APP↔APP | ⏳ Pendiente |
|
||||
| 6. Integración con backend (señalización) | ⏳ Pendiente |
|
||||
| 7. Pruebas APP↔Reloj | ⏳ Pendiente |
|
||||
| 8. Token auth (producción) | ⏳ Pendiente |
|
||||
| 9. Push/background iOS | ⏳ Pendiente (sin doc del proveedor) |
|
||||
| 10. Chat | ⏳ Pendiente (sin spec del proveedor) |
|
||||
|
||||
---
|
||||
|
||||
## Fase 1: SDK wrapper — ✅
|
||||
|
||||
Paquete `packages/videocall_sdk/` con wrap 100% de `jc_sdk` v2.16.5.
|
||||
|
||||
### Arquitectura (patrón sca_treezor)
|
||||
- Constructor injection (no singletons estáticos)
|
||||
- GetIt module (`videocallSdkModule(config)`)
|
||||
- `VideocallSdkManager` orquestador de inicialización
|
||||
- `VideocallSdkConfig` abstracto para config por entorno
|
||||
- Riverpod providers + StreamProviders para UI reactiva
|
||||
- Callbacks del SDK → Dart Streams
|
||||
|
||||
### Servicios (7 total, cobertura 100%)
|
||||
- `VideocallClient` → JCClient (auth, login, logout, messaging)
|
||||
- `VideocallCallService` → JCCall (llamadas 1-to-1)
|
||||
- `VideocallDeviceService` → JCMediaDevice (cámara, mic, speaker)
|
||||
- `VideocallChannelService` → JCMediaChannel (llamadas grupales)
|
||||
- `VideocallPushService` → JCPush (push notifications)
|
||||
- `VideocallNetService` → JCNet (estado de red)
|
||||
- `VideocallLogService` → JCLog (logging)
|
||||
|
||||
### Estructura
|
||||
```
|
||||
packages/videocall_sdk/lib/src/
|
||||
├── config/videocall_sdk_config.dart
|
||||
├── di/videocall_sdk_module.dart
|
||||
├── manager/videocall_sdk_manager.dart
|
||||
├── models/ (call_state, call_direction, videocall_item, etc.)
|
||||
├── services/ (7 servicios)
|
||||
└── providers/videocall_providers.dart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fase 2: Permisos nativos — ✅
|
||||
|
||||
### Android (`AndroidManifest.xml`)
|
||||
- [x] INTERNET (ya existía)
|
||||
- [x] ACCESS_NETWORK_STATE (ya existía)
|
||||
- [x] ACCESS_WIFI_STATE
|
||||
- [x] CAMERA (ya existía)
|
||||
- [x] RECORD_AUDIO
|
||||
- [x] MODIFY_AUDIO_SETTINGS
|
||||
- [x] BLUETOOTH
|
||||
- [x] uses-feature: hardware.camera
|
||||
- [x] uses-feature: hardware.camera.autofocus
|
||||
- [x] uses-feature: hardware.bluetooth (optional)
|
||||
|
||||
### Android (`proguard-rules.pro`)
|
||||
- [x] -keep com.juphoon.**
|
||||
- [x] -keep com.justalk.**
|
||||
- [x] -keepattributes InnerClasses
|
||||
|
||||
### iOS (`Info.plist`)
|
||||
- [x] NSCameraUsageDescription (actualizado: QR + videollamadas)
|
||||
- [x] NSMicrophoneUsageDescription
|
||||
- [x] NSPhotoLibraryUsageDescription
|
||||
|
||||
### iOS (`Podfile`)
|
||||
- [x] PERMISSION_CAMERA=1
|
||||
- [x] PERMISSION_PHOTOS=1
|
||||
- [x] PERMISSION_MICROPHONE=1
|
||||
|
||||
---
|
||||
|
||||
## Fase 3: Config por entorno — ✅
|
||||
|
||||
- [x] `juphoonAppKey` en development.json, staging.json, production.json
|
||||
- [x] `Environment.juphoonAppKey` via `String.fromEnvironment()`
|
||||
- [x] `SaveFamilyVideocallConfig` implementa `VideocallSdkConfig`
|
||||
- [x] `videocallSdkModule(config)` llamado en `init_app.dart`
|
||||
- [ ] AppKeys separadas por entorno (por ahora las 3 usan la misma clave de dev)
|
||||
|
||||
---
|
||||
|
||||
## Fase 4: Feature videocall — ⏳ SIGUIENTE
|
||||
|
||||
Feature en `modules/legacy/modules/device_management/lib/src/features/videocall/`
|
||||
|
||||
### Por hacer
|
||||
- [ ] `videocall_builder.dart` — GoRouter builder
|
||||
- [ ] `domain/entities/videocall_entity.dart` — Freezed entity
|
||||
- [ ] `domain/entities/videocall_error.dart` — Error enum con i18n
|
||||
- [ ] `presentation/state/videocall_view_model.dart` — Notifier
|
||||
- [ ] `presentation/state/videocall_view_state.dart` — Freezed state
|
||||
- [ ] `presentation/videocall_screen.dart` — Pantalla de llamada
|
||||
- [ ] `presentation/widgets/local_video_view.dart` — Video local
|
||||
- [ ] `presentation/widgets/remote_video_view.dart` — Video remoto
|
||||
- [ ] `presentation/widgets/call_controls.dart` — Botones (colgar, mute, cámara)
|
||||
- [ ] `presentation/widgets/incoming_call_dialog.dart` — Dialog llamada entrante
|
||||
- [ ] Providers en `core/providers/`
|
||||
- [ ] Ruta en GoRouter (`app_router.dart`)
|
||||
- [ ] Runtime permissions (pedir cámara/mic en runtime)
|
||||
|
||||
---
|
||||
|
||||
## Fase 5: Pruebas APP↔APP — ⏳
|
||||
|
||||
- [ ] Login con 2 userIDs de prueba (`p_test1`, `p_test2`)
|
||||
- [ ] Llamada de voz APP→APP
|
||||
- [ ] Videollamada APP→APP
|
||||
- [ ] Responder llamada entrante
|
||||
- [ ] Rechazar llamada
|
||||
- [ ] Colgar durante llamada
|
||||
- [ ] Mute/unmute micrófono
|
||||
- [ ] Cambiar cámara frontal/trasera
|
||||
- [ ] Speaker on/off
|
||||
- [ ] Llamada perdida (onMissedCallItem)
|
||||
- [ ] Verificar desfase versión SDK (quickstart 1.0.2 vs pub.dev 2.16.5)
|
||||
|
||||
---
|
||||
|
||||
## Fase 6: Integración backend — ⏳
|
||||
|
||||
- [ ] Obtener documentación API REST del backend SaveFamily para señalización
|
||||
- [ ] Endpoint para iniciar llamada → notificar al reloj
|
||||
- [ ] Endpoint para recibir notificación de llamada entrante del reloj
|
||||
- [ ] Formato userID definido con backend (`p_<cuenta>` vs `w_<IMEI>`)
|
||||
- [ ] Sanitización de emails (@ → _ en userIDs)
|
||||
|
||||
---
|
||||
|
||||
## Fase 7: Pruebas APP↔Reloj — ⏳
|
||||
|
||||
- [ ] Llamada APP → Reloj
|
||||
- [ ] Llamada Reloj → APP
|
||||
- [ ] Videollamada grupal (JCMediaChannel)
|
||||
- [ ] Límite 5 min de llamada
|
||||
- [ ] Registro IMEI (protocolo RYIMEI) — lo hace el backend
|
||||
|
||||
---
|
||||
|
||||
## Fase 8: Token auth — ⏳
|
||||
|
||||
- [ ] Backend implementa generación de tokens Juphoon (usa AppSecret)
|
||||
- [ ] App pide token al backend antes del login
|
||||
- [ ] Token se pasa como `password` en `VideocallClient.login()`
|
||||
- [ ] Activar Token鉴权 en consola Juphoon (ya está activo)
|
||||
|
||||
Nota: para dev/testing no es necesario — `autoCreateAccount = true` en LoginParam permite login con cualquier password.
|
||||
|
||||
---
|
||||
|
||||
## Fase 9: Push/Background iOS — ⏳ RIESGO
|
||||
|
||||
**Problema:** No hay documentación de cómo recibir llamadas con la app cerrada en iOS.
|
||||
|
||||
- [ ] Probar qué pasa cuando la app está cerrada y llega una llamada (fase 5)
|
||||
- [ ] Si no funciona: investigar PushKit + CallKit
|
||||
- [ ] Verificar si `JCPush` del SDK resuelve esto
|
||||
- [ ] Consultar pestaña "消息通知服务" en la consola Juphoon
|
||||
- [ ] Si es deal-breaker: escalar antes del pago ($8,835)
|
||||
|
||||
---
|
||||
|
||||
## Fase 10: Chat — ⏳ SIN SPEC
|
||||
|
||||
- [ ] Obtener especificación del módulo de chat del proveedor
|
||||
- [ ] Determinar si usa JCMediaChannel (SDK) o API propia
|
||||
- [ ] $2,950 pagados sin lista de features
|
||||
|
||||
---
|
||||
|
||||
## Credenciales Juphoon Cloud
|
||||
|
||||
| Campo | Valor | Quién lo usa |
|
||||
|---|---|---|
|
||||
| AppKey | `9efcf2d889dc8a0320925096` | App Flutter + Backend |
|
||||
| AppSecret | `ui7pr73ggl5rr0gf01np` | Solo Backend |
|
||||
| AES_KEY (IoT) | `8e3637pG7E9144E0` | Solo Backend |
|
||||
| Consola | juphoon.com (+34 603675786) | Julián |
|
||||
|
||||
---
|
||||
|
||||
## Naming conventions (protocolo TCP)
|
||||
|
||||
| Tipo | Formato | Ejemplo |
|
||||
|---|---|---|
|
||||
| Watch userID | `w_` + IMEI | `w_000078932675810` |
|
||||
| Mobile userID | `p_` + APP account | `p_abc10086` |
|
||||
| Group room | `did` + `_group` | `0245423235_group` |
|
||||
| Single room | `did` + `_` + APP account | `0245423235_abc10086` |
|
||||
|
||||
Nota: `@` y `.` se reemplazan por `_` en room numbers y userIDs.
|
||||
|
||||
---
|
||||
|
||||
## Documentación de referencia
|
||||
|
||||
- Quickstart V1.1: `~/Downloads/Video call API_ Juphoon Flutter SDK quickstart V1.1.pdf`
|
||||
- TCP Protocol: `~/Downloads/Juphoon Video Call TCP Protocol.docx`
|
||||
- Connection process: `~/Downloads/video call connection process Rev2.docx`
|
||||
- Mutual dialing: `~/Downloads/video call mutual dialing process.docx`
|
||||
- Schematics: `~/Downloads/schematics _2025.03.26 (2)/`
|
||||
- pub.dev: https://pub.dev/packages/jc_sdk
|
||||
- Consola: https://developer.juphoon.com
|
||||
|
||||
---
|
||||
|
||||
## Flujos de llamada (protocolo TCP + Juphoon SDK)
|
||||
|
||||
### APP → Reloj (outgoing)
|
||||
|
||||
1. App envía `VIDEO_CALL_REQUEST` al backend con `chatType`, `appAccount`, `roomNumber`, `sessionId`
|
||||
2. Backend reenvía la notificación al reloj via TCP
|
||||
3. App inicia audio/cámara y llama al watch account via SDK (`startCall(userId: "w_<IMEI>")`)
|
||||
4. Reloj contesta → SDK notifica via `callItemUpdateStream` (estado `isTalking`)
|
||||
5. App envía `VIDEO_CALL_ROOM_COUNT_REQUEST` con `type` (0/1), `count: 2`, `room_num`
|
||||
|
||||
### Reloj → APP (incoming)
|
||||
|
||||
1. Reloj envía notificación de llamada al backend
|
||||
2. Backend notifica a la app (requiere app abierta con SDK inicializado, ver Fase 9)
|
||||
3. SDK detecta llamada entrante via `callItemAddStream` con `CallDirection.incoming`
|
||||
4. Usuario acepta → `answerCall()` → SDK conecta
|
||||
5. Reloj reporta participantes al backend
|
||||
|
||||
### Colgar / Rechazar
|
||||
|
||||
- **Colgar (en llamada):** `hangUp()` en SDK + `VIDEO_CALL_CANCEL` al backend
|
||||
- **Rechazar (incoming):** `hangUp()` en SDK + `VIDEO_CALL_REFUSE` al backend con `appAccount`, `roomNumber`
|
||||
|
||||
### Convenciones de nombres
|
||||
|
||||
| Campo | Formato | Ejemplo |
|
||||
|---|---|---|
|
||||
| Watch userID | `w_` + IMEI | `w_000078932675810` |
|
||||
| Mobile userID | `p_` + email sanitizado | `p_user_example_com` |
|
||||
| Room (single) | `deviceId` + `_` + appAccount | `0245423235_p_user_example_com` |
|
||||
| Room (group) | `deviceId` + `_group` | `0245423235_group` |
|
||||
| Session ID | `deviceId` + `_` + epoch en segundos | `0245423235_1714150800` |
|
||||
|
||||
Sanitización: `@` y `.` se reemplazan por `_` en userIDs y roomNumbers.
|
||||
|
||||
### Configuración del SDK por tipo de dispositivo
|
||||
|
||||
- RTOS watches: `MediaConfig.MODE_RTOS`
|
||||
- Android watches: `MediaConfig.MODE_INTELLIGENT_HARDWARE`
|
||||
- Se determina con `device.capabilities.system` (`isRtos` / `isAndroid`)
|
||||
|
||||
### Auto-login
|
||||
|
||||
- userId: `p_` + email sanitizado (ej: `p_julian_test_com`)
|
||||
- password: `user.id` (UUID del usuario padre)
|
||||
- En dev/testing `autoCreateAccount = true` permite login con cualquier password
|
||||
|
||||
---
|
||||
|
||||
## Limitaciones actuales
|
||||
|
||||
### Recepción de llamadas requiere app abierta
|
||||
|
||||
La app debe estar en primer plano con el SDK inicializado y el client logueado para recibir llamadas entrantes. Si el app está en background o cerrada, las llamadas no llegan. Esto se resuelve en Fase 9 (Push/Background).
|
||||
|
||||
### Sin timeout de llamada
|
||||
|
||||
El protocolo menciona un límite de 5 min por llamada, pero no está implementado en la app. El reloj podría manejar el corte por su lado.
|
||||
|
||||
---
|
||||
|
||||
## Pendientes por verificar
|
||||
|
||||
- **chatType**: El protocolo TCP usa `0` (single) y `1` (multi) como enteros. Nuestra app envía `"single"`/`"multi"` como strings en el JSON del comando. Verificar que el backend hace la conversión correctamente antes de enviar al reloj via TCP.
|
||||
@@ -1 +1 @@
|
||||
{"flutter":{"platforms":{"android":{"default":{"projectId":"sf-platform-pre","appId":"1:535646668726:android:b87245b807258e3e5e6317","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"sf-platform-pre","appId":"1:535646668726:ios:5172d626d02dfe215e6317","uploadDebugSymbols":true,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options_dev.dart":{"projectId":"sf-platform-pre","configurations":{"android":"1:535646668726:android:c3a09d6c26f0cdf95e6317","ios":"1:535646668726:ios:524afa641f61d7cb5e6317"}},"lib/firebase_options_staging.dart":{"projectId":"sf-platform-pre","configurations":{"android":"1:535646668726:android:b87245b807258e3e5e6317","ios":"1:535646668726:ios:5172d626d02dfe215e6317"}}}}}}
|
||||
{"flutter":{"platforms":{"android":{"default":{"projectId":"sf-platform-pre","appId":"1:535646668726:android:b87245b807258e3e5e6317","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"sf-platform-pre","appId":"1:535646668726:ios:5172d626d02dfe215e6317","uploadDebugSymbols":true,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options_dev.dart":{"projectId":"sf-platform-pre","configurations":{"android":"1:535646668726:android:c3a09d6c26f0cdf95e6317","ios":"1:535646668726:ios:524afa641f61d7cb5e6317"}},"lib/firebase_options_staging.dart":{"projectId":"sf-platform-pre","configurations":{"android":"1:535646668726:android:b87245b807258e3e5e6317","ios":"1:535646668726:ios:5172d626d02dfe215e6317"}},"lib/firebase_options_prod.dart":{"projectId":"sf-platform-pro","configurations":{"android":"1:950566980029:android:75a7c10b6259d09681aad4","ios":"1:950566980029:ios:987b4f0b9e9b897481aad4"}}}}}}
|
||||
@@ -45,5 +45,14 @@ end
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
## Juphoon jc_sdk: enable camera, photos and microphone permissions
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
|
||||
'$(inherited)',
|
||||
'PERMISSION_CAMERA=1',
|
||||
'PERMISSION_PHOTOS=1',
|
||||
'PERMISSION_MICROPHONE=1'
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
PODS:
|
||||
- audioplayers_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- Firebase/CoreOnly (12.9.0):
|
||||
- FirebaseCore (~> 12.9.0)
|
||||
- Firebase/Crashlytics (12.9.0):
|
||||
@@ -119,10 +122,17 @@ PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_contacts (0.0.1):
|
||||
- Flutter
|
||||
- flutter_image_compress_common (1.0.0):
|
||||
- Flutter
|
||||
- Mantle
|
||||
- SDWebImage
|
||||
- SDWebImageWebPCoder
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- flutter_native_splash (2.4.3):
|
||||
- Flutter
|
||||
- flutter_ringtone_player (0.0.1):
|
||||
- Flutter
|
||||
- flutter_treezor_entrust_sdk_bridge (0.0.1):
|
||||
- Flutter
|
||||
- GoogleAdsOnDeviceConversion (3.2.0):
|
||||
@@ -184,6 +194,23 @@ PODS:
|
||||
- GoogleUtilities/Privacy
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- jc_sdk (0.0.1):
|
||||
- Flutter
|
||||
- libwebp (1.5.0):
|
||||
- libwebp/demux (= 1.5.0)
|
||||
- libwebp/mux (= 1.5.0)
|
||||
- libwebp/sharpyuv (= 1.5.0)
|
||||
- libwebp/webp (= 1.5.0)
|
||||
- libwebp/demux (1.5.0):
|
||||
- libwebp/webp
|
||||
- libwebp/mux (1.5.0):
|
||||
- libwebp/demux
|
||||
- libwebp/sharpyuv (1.5.0)
|
||||
- libwebp/webp (1.5.0):
|
||||
- libwebp/sharpyuv
|
||||
- Mantle (2.2.0):
|
||||
- Mantle/extobjc (= 2.2.0)
|
||||
- Mantle/extobjc (2.2.0)
|
||||
- mobile_scanner (7.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -192,6 +219,8 @@ PODS:
|
||||
- nanopb/encode (= 3.30910.0)
|
||||
- nanopb/decode (3.30910.0)
|
||||
- nanopb/encode (3.30910.0)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -200,11 +229,22 @@ PODS:
|
||||
- PromisesObjC (2.4.0)
|
||||
- PromisesSwift (2.4.0):
|
||||
- PromisesObjC (= 2.4.0)
|
||||
- record_ios (1.2.0):
|
||||
- Flutter
|
||||
- SDWebImage (5.21.7):
|
||||
- SDWebImage/Core (= 5.21.7)
|
||||
- SDWebImage/Core (5.21.7)
|
||||
- SDWebImageWebPCoder (0.15.0):
|
||||
- libwebp (~> 1.0)
|
||||
- SDWebImage/Core (~> 5.17)
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
@@ -212,6 +252,7 @@ PODS:
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
|
||||
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
|
||||
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
||||
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
|
||||
@@ -220,15 +261,21 @@ DEPENDENCIES:
|
||||
- firebase_remote_config (from `.symlinks/plugins/firebase_remote_config/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_contacts (from `.symlinks/plugins/flutter_contacts/ios`)
|
||||
- flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- flutter_ringtone_player (from `.symlinks/plugins/flutter_ringtone_player/ios`)
|
||||
- flutter_treezor_entrust_sdk_bridge (from `.symlinks/plugins/flutter_treezor_entrust_sdk_bridge/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- jc_sdk (from `.symlinks/plugins/jc_sdk/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||
|
||||
@@ -252,11 +299,17 @@ SPEC REPOS:
|
||||
- GoogleAppMeasurement
|
||||
- GoogleDataTransport
|
||||
- GoogleUtilities
|
||||
- libwebp
|
||||
- Mantle
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
- PromisesSwift
|
||||
- SDWebImage
|
||||
- SDWebImageWebPCoder
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audioplayers_darwin:
|
||||
:path: ".symlinks/plugins/audioplayers_darwin/darwin"
|
||||
firebase_analytics:
|
||||
:path: ".symlinks/plugins/firebase_analytics/ios"
|
||||
firebase_core:
|
||||
@@ -273,30 +326,43 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter
|
||||
flutter_contacts:
|
||||
:path: ".symlinks/plugins/flutter_contacts/ios"
|
||||
flutter_image_compress_common:
|
||||
:path: ".symlinks/plugins/flutter_image_compress_common/ios"
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
flutter_native_splash:
|
||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||
flutter_ringtone_player:
|
||||
:path: ".symlinks/plugins/flutter_ringtone_player/ios"
|
||||
flutter_treezor_entrust_sdk_bridge:
|
||||
:path: ".symlinks/plugins/flutter_treezor_entrust_sdk_bridge/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
jc_sdk:
|
||||
:path: ".symlinks/plugins/jc_sdk/ios"
|
||||
mobile_scanner:
|
||||
:path: ".symlinks/plugins/mobile_scanner/darwin"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
record_ios:
|
||||
:path: ".symlinks/plugins/record_ios/ios"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite_darwin:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
webview_flutter_wkwebview:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audioplayers_darwin: f15e209a3e856d1a7edcf98dc029f484fead2242
|
||||
Firebase: 065f2bb395062046623036d8e6dc857bc2521d56
|
||||
firebase_analytics: 42693ebf35c4d330b74abcb46ca80351703644e0
|
||||
firebase_core: 98bcc1bd1a097bcb8b1ed6e091de3039802527c4
|
||||
@@ -319,25 +385,35 @@ SPEC CHECKSUMS:
|
||||
FirebaseSharedSwift: 9d2fa84a46676302b89dbd5e6e62bce2fe376909
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_contacts: edb1c5ce76aa433e20e6cb14c615f4c0b66e0983
|
||||
flutter_image_compress_common: ec1d45c362c9d30a3f6a0426c297f47c52007e3e
|
||||
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
|
||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
||||
flutter_ringtone_player: 15eba85187230b87b2512f0e1b92225618bc03e7
|
||||
flutter_treezor_entrust_sdk_bridge: 4c2c94fb74ab57576e8d49f5f2a4b214e41141fe
|
||||
GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f
|
||||
GoogleAppMeasurement: fce7c1c90640d2f9f5c56771f71deacb2ba3f98c
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b
|
||||
jc_sdk: 3c77f6d7e5e052e2960c47629f612127585779cf
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
|
||||
mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
|
||||
record_ios: 26294aaa39e4bb7665b0fef78bdc23d723b432f2
|
||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
|
||||
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
|
||||
|
||||
PODFILE CHECKSUM: 2ff48235bd696a83f30729eab21272c929e12684
|
||||
PODFILE CHECKSUM: 88fd88ec59f7f53cf74c06ffd99479aec395968a
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -256,6 +256,7 @@
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
F0758EB530B1A8787EB3F30B /* Copy GoogleService-Info */,
|
||||
AA0000022345678900000001 /* Copy AntelopRelease */,
|
||||
437F5EA1E5D92D7C421FD996 /* [CP] Embed Pods Frameworks */,
|
||||
791C3CA41F1AAEE1267769C8 /* [CP] Copy Pods Resources */,
|
||||
0F0F4E82D9AA0B3E11014E72 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */,
|
||||
@@ -479,6 +480,24 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${SRCROOT}/scripts/copy-google-service-plist.sh\"";
|
||||
};
|
||||
AA0000022345678900000001 /* Copy AntelopRelease */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Copy AntelopRelease";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${SRCROOT}/scripts/copy-antelop-release-plist.sh\"";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<key>fr.antelop.initialConnectionTimeout</key>
|
||||
<integer>60</integer>
|
||||
<key>fr.antelop.application_id</key>
|
||||
<integer>2940147927882003152</integer>
|
||||
<integer>4713640103500149457</integer>
|
||||
<key>fr.antelop.issuer_id</key>
|
||||
<string>treezor</string>
|
||||
<key>fr.antelop.teamIdentifier</key>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<key>fr.antelop.initialConnectionTimeout</key>
|
||||
<integer>60</integer>
|
||||
<key>fr.antelop.application_id</key>
|
||||
<integer>2940147927882003152</integer>
|
||||
<integer>4713640103500149457</integer>
|
||||
<key>fr.antelop.issuer_id</key>
|
||||
<string>treezor</string>
|
||||
<key>fr.antelop.teamIdentifier</key>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<key>fr.antelop.initialConnectionTimeout</key>
|
||||
<integer>60</integer>
|
||||
<key>fr.antelop.application_id</key>
|
||||
<integer>5850886184402974206</integer>
|
||||
<integer>4713640103500149457</integer>
|
||||
<key>fr.antelop.issuer_id</key>
|
||||
<string>treezor</string>
|
||||
<key>fr.antelop.teamIdentifier</key>
|
||||
|
||||
@@ -50,7 +50,11 @@
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>Necesitamos acceso a tus contactos para seleccionar números de teléfono.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Necesitamos la cámara para escanear códigos QR</string>
|
||||
<string>Necesitamos la cámara para escanear códigos QR y realizar videollamadas</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Necesitamos el micrófono para realizar videollamadas</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Necesitamos acceso a la galería de fotos para compartir imágenes</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Usamos tu ubicación para verificar la seguridad de las transacciones.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyC0_d7Z6uVHHKhaf7JHRROaY6g2mvvpOXU</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>950566980029</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.savefamily.app</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>sf-platform-pro</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>sf-platform-pro.firebasestorage.app</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:950566980029:ios:987b4f0b9e9b897481aad4</string>
|
||||
</dict>
|
||||
</plist>
|
||||
46
apps/mobile_app/ios/scripts/copy-antelop-release-plist.sh
Executable file
46
apps/mobile_app/ios/scripts/copy-antelop-release-plist.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Copies the correct AntelopRelease.plist into the .app bundle based on the
|
||||
# active build CONFIGURATION (Debug-development, Release-staging, etc.). The
|
||||
# Antelop SDK reads `AntelopRelease.plist` by a fixed name at runtime, so the
|
||||
# per-flavor variants (AntelopRelease-development.plist,
|
||||
# AntelopRelease-staging.plist) must be copied over that fixed name.
|
||||
#
|
||||
# Source layout: ios/Runner/AntelopRelease.plist (production),
|
||||
# ios/Runner/AntelopRelease-development.plist,
|
||||
# ios/Runner/AntelopRelease-staging.plist.
|
||||
|
||||
set -e
|
||||
|
||||
echo "Configuration: ${CONFIGURATION}"
|
||||
|
||||
if [[ $CONFIGURATION =~ \-([^-]*)$ ]]; then
|
||||
flavor=${BASH_REMATCH[1]}
|
||||
else
|
||||
echo "warning: Could not extract flavor from CONFIGURATION='${CONFIGURATION}', defaulting to 'production'"
|
||||
flavor="production"
|
||||
fi
|
||||
|
||||
echo "Flavor: $flavor"
|
||||
|
||||
case "$flavor" in
|
||||
development|staging)
|
||||
SRC="${PROJECT_DIR}/Runner/AntelopRelease-${flavor}.plist"
|
||||
;;
|
||||
production)
|
||||
SRC="${PROJECT_DIR}/Runner/AntelopRelease.plist"
|
||||
;;
|
||||
*)
|
||||
echo "warning: Unknown flavor '${flavor}', falling back to AntelopRelease.plist"
|
||||
SRC="${PROJECT_DIR}/Runner/AntelopRelease.plist"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -f "$SRC" ]; then
|
||||
echo "error: ${SRC} not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEST="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/AntelopRelease.plist"
|
||||
echo "Copying ${SRC} -> ${DEST}"
|
||||
cp "${SRC}" "${DEST}"
|
||||
@@ -2,6 +2,8 @@ abstract class Environment {
|
||||
static const env = String.fromEnvironment('env', defaultValue: 'development');
|
||||
static const apiBaseUrl = String.fromEnvironment('apiBaseUrl');
|
||||
static const apiOrigin = String.fromEnvironment('apiOrigin');
|
||||
static const wsUrl = String.fromEnvironment('wsUrl');
|
||||
static const juphoonAppKey = String.fromEnvironment('juphoonAppKey');
|
||||
|
||||
// --- Fase 2: Firebase & Sentry ---
|
||||
// static const sentryDsn = String.fromEnvironment('sentryDsn');
|
||||
|
||||
@@ -6,4 +6,6 @@ class SaveFamilyEnvConfig implements EnvConfig {
|
||||
String get apiBaseUrl => Environment.apiBaseUrl;
|
||||
@override
|
||||
String get apiOrigin => Environment.apiOrigin;
|
||||
@override
|
||||
String get wsUrl => Environment.wsUrl;
|
||||
}
|
||||
|
||||
14
apps/mobile_app/lib/config/env/save_family_videocall_config.dart
vendored
Normal file
14
apps/mobile_app/lib/config/env/save_family_videocall_config.dart
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:videocall_sdk/videocall_sdk.dart';
|
||||
|
||||
import 'environment.dart';
|
||||
|
||||
class SaveFamilyVideocallConfig implements VideocallSdkConfig {
|
||||
@override
|
||||
String get appKey => Environment.juphoonAppKey;
|
||||
|
||||
@override
|
||||
String get serverAddress => '';
|
||||
|
||||
@override
|
||||
CreateParam? get createParam => null;
|
||||
}
|
||||
3
apps/mobile_app/lib/core/app_provider_container.dart
Normal file
3
apps/mobile_app/lib/core/app_provider_container.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
late ProviderContainer appProviderContainer;
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'app_version_check.dart';
|
||||
|
||||
Future<void> showAppUpdateDialog(
|
||||
BuildContext context, {
|
||||
required AvailableUpdate result,
|
||||
VoidCallback? onDismiss,
|
||||
VoidCallback? onUpdateTapped,
|
||||
}) {
|
||||
final isForce = result is ForceUpdate;
|
||||
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: !isForce,
|
||||
builder: (dialogContext) {
|
||||
return PopScope(
|
||||
canPop: !isForce,
|
||||
child: AlertDialog(
|
||||
title: Text(
|
||||
dialogContext.translate(
|
||||
isForce
|
||||
? I18n.appUpdateRequiredTitle
|
||||
: I18n.appUpdateAvailableTitle,
|
||||
),
|
||||
),
|
||||
content: Text(
|
||||
result.message.isNotEmpty
|
||||
? result.message
|
||||
: dialogContext.translate(
|
||||
isForce
|
||||
? I18n.appUpdateRequiredMessage
|
||||
: I18n.appUpdateAvailableMessage,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (!isForce)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
onDismiss?.call();
|
||||
},
|
||||
child: Text(dialogContext.translate(I18n.appUpdateLater)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _launchStore(result.storeUrl, onUpdateTapped),
|
||||
child: Text(dialogContext.translate(I18n.appUpdateNow)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launchStore(String storeUrl, VoidCallback? onTapped) async {
|
||||
onTapped?.call();
|
||||
try {
|
||||
final uri = Uri.tryParse(storeUrl);
|
||||
if (uri == null) {
|
||||
debugPrint('[AppUpdateDialog] invalid store URL: $storeUrl');
|
||||
return;
|
||||
}
|
||||
final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!launched) {
|
||||
debugPrint('[AppUpdateDialog] launchUrl returned false for $storeUrl');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[AppUpdateDialog] launchUrl failed: $e');
|
||||
}
|
||||
}
|
||||
123
apps/mobile_app/lib/core/app_version_check/app_update_gate.dart
Normal file
123
apps/mobile_app/lib/core/app_version_check/app_update_gate.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:navigation/navigation.dart';
|
||||
import 'package:sf_app_platform/navigation/app_router.dart';
|
||||
import 'package:sf_tracking/sf_tracking.dart';
|
||||
|
||||
import 'app_update_dialog.dart';
|
||||
import 'app_version_check.dart';
|
||||
|
||||
class AppUpdateGate extends ConsumerStatefulWidget {
|
||||
const AppUpdateGate({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
ConsumerState<AppUpdateGate> createState() => _AppUpdateGateState();
|
||||
}
|
||||
|
||||
class _AppUpdateGateState extends ConsumerState<AppUpdateGate> {
|
||||
bool _dialogVisible = false;
|
||||
VoidCallback? _pendingRouterListener;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_detachPendingRouterListener();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _detachPendingRouterListener() {
|
||||
final listener = _pendingRouterListener;
|
||||
if (listener != null) {
|
||||
appRouter.routerDelegate.removeListener(listener);
|
||||
_pendingRouterListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isStableRoute() {
|
||||
final path = appRouter.routerDelegate.currentConfiguration.uri.path;
|
||||
return path.startsWith(AppRoutes.dashboard) ||
|
||||
path.startsWith(AppRoutes.legacyDashboard);
|
||||
}
|
||||
|
||||
void _onResultEmitted(AppVersionCheckResult result) {
|
||||
if (result is! AvailableUpdate) {
|
||||
_detachPendingRouterListener();
|
||||
return;
|
||||
}
|
||||
if (_dialogVisible) return;
|
||||
|
||||
if (_isStableRoute()) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_show(result);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_detachPendingRouterListener();
|
||||
void onChange() {
|
||||
if (!_isStableRoute()) return;
|
||||
_detachPendingRouterListener();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_show(result);
|
||||
});
|
||||
}
|
||||
|
||||
_pendingRouterListener = onChange;
|
||||
appRouter.routerDelegate.addListener(onChange);
|
||||
}
|
||||
|
||||
void _show(AvailableUpdate result) {
|
||||
if (!mounted) return;
|
||||
if (_dialogVisible) return;
|
||||
final ctx = appRouter.routerDelegate.navigatorKey.currentContext;
|
||||
if (ctx == null) return;
|
||||
|
||||
final tracking = ref.read(sfTrackingProvider);
|
||||
final kind = _kindLabel(result);
|
||||
|
||||
tracking.appUpdateDialogShown(
|
||||
kind: kind,
|
||||
latestBuild: result.latestBuild,
|
||||
currentBuild: result.currentBuild,
|
||||
);
|
||||
|
||||
_dialogVisible = true;
|
||||
showAppUpdateDialog(
|
||||
ctx,
|
||||
result: result,
|
||||
onDismiss: () {
|
||||
if (result is SoftUpdate) {
|
||||
tracking.appUpdateDialogDismissed(latestBuild: result.latestBuild);
|
||||
ref.read(appVersionCheckProvider.notifier).markSoftDismissed(result);
|
||||
}
|
||||
},
|
||||
onUpdateTapped: () => tracking.appUpdateCtaTapped(
|
||||
kind: kind,
|
||||
latestBuild: result.latestBuild,
|
||||
),
|
||||
).whenComplete(() {
|
||||
_dialogVisible = false;
|
||||
});
|
||||
}
|
||||
|
||||
String _kindLabel(AvailableUpdate result) {
|
||||
return switch (result) {
|
||||
SoftUpdate() => 'soft',
|
||||
ForceUpdate() => 'force',
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen<AsyncValue<AppVersionCheckResult>>(
|
||||
appVersionCheckProvider,
|
||||
(previous, next) {
|
||||
next.whenData(_onResultEmitted);
|
||||
},
|
||||
);
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app_version_check_result.dart';
|
||||
import 'app_version_check_service.dart';
|
||||
|
||||
export 'app_version_check_result.dart';
|
||||
export 'app_version_check_service.dart';
|
||||
export 'dismissed_build_store.dart';
|
||||
export 'remote_config_reader.dart';
|
||||
|
||||
final appVersionCheckServiceProvider = Provider<AppVersionCheckService>((ref) {
|
||||
return AppVersionCheckService();
|
||||
});
|
||||
|
||||
class AppVersionCheck extends AsyncNotifier<AppVersionCheckResult> {
|
||||
Future<void>? _inflight;
|
||||
|
||||
AppVersionCheckService get _service =>
|
||||
ref.read(appVersionCheckServiceProvider);
|
||||
|
||||
@override
|
||||
Future<AppVersionCheckResult> build() {
|
||||
return _service.check();
|
||||
}
|
||||
|
||||
Future<void> refresh() => _runSerialized(() async {
|
||||
state = AsyncData(await _service.check());
|
||||
});
|
||||
|
||||
Future<void> markSoftDismissed(SoftUpdate result) =>
|
||||
_runSerialized(() async {
|
||||
await _service.markSoftDismissed(result.latestBuild);
|
||||
state = const AsyncData(NoUpdate());
|
||||
});
|
||||
|
||||
Future<void> _runSerialized(Future<void> Function() op) async {
|
||||
final previous = _inflight;
|
||||
final completer = Completer<void>();
|
||||
_inflight = completer.future;
|
||||
try {
|
||||
if (previous != null) {
|
||||
try {
|
||||
await previous;
|
||||
} catch (_) {}
|
||||
}
|
||||
await op();
|
||||
} finally {
|
||||
completer.complete();
|
||||
if (identical(_inflight, completer.future)) {
|
||||
_inflight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final appVersionCheckProvider =
|
||||
AsyncNotifierProvider<AppVersionCheck, AppVersionCheckResult>(
|
||||
AppVersionCheck.new,
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
sealed class AppVersionCheckResult {
|
||||
const AppVersionCheckResult();
|
||||
}
|
||||
|
||||
class NoUpdate extends AppVersionCheckResult {
|
||||
const NoUpdate();
|
||||
}
|
||||
|
||||
sealed class AvailableUpdate extends AppVersionCheckResult {
|
||||
const AvailableUpdate({
|
||||
required this.message,
|
||||
required this.storeUrl,
|
||||
required this.latestBuild,
|
||||
required this.currentBuild,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final String storeUrl;
|
||||
final int latestBuild;
|
||||
final int currentBuild;
|
||||
}
|
||||
|
||||
class SoftUpdate extends AvailableUpdate {
|
||||
const SoftUpdate({
|
||||
required super.message,
|
||||
required super.storeUrl,
|
||||
required super.latestBuild,
|
||||
required super.currentBuild,
|
||||
});
|
||||
}
|
||||
|
||||
class ForceUpdate extends AvailableUpdate {
|
||||
const ForceUpdate({
|
||||
required super.message,
|
||||
required super.storeUrl,
|
||||
required super.latestBuild,
|
||||
required super.currentBuild,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'app_version_check_result.dart';
|
||||
import 'dismissed_build_store.dart';
|
||||
import 'remote_config_keys.dart';
|
||||
import 'remote_config_reader.dart';
|
||||
|
||||
typedef CurrentBuildLoader = Future<int> Function();
|
||||
|
||||
Future<int> defaultCurrentBuildLoader() async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
return int.tryParse(info.buildNumber) ?? 0;
|
||||
}
|
||||
|
||||
class AppVersionCheckService {
|
||||
AppVersionCheckService({
|
||||
RemoteConfigReader? remoteConfig,
|
||||
DismissedBuildStore? dismissedStore,
|
||||
CurrentBuildLoader? currentBuildLoader,
|
||||
bool? isIos,
|
||||
}) : _remoteConfig = remoteConfig ?? FirebaseRemoteConfigReader(),
|
||||
_dismissedStore = dismissedStore ?? SharedPrefsDismissedBuildStore(),
|
||||
_currentBuildLoader = currentBuildLoader ?? defaultCurrentBuildLoader,
|
||||
_isIos = isIos ?? Platform.isIOS;
|
||||
|
||||
final RemoteConfigReader _remoteConfig;
|
||||
final DismissedBuildStore _dismissedStore;
|
||||
final CurrentBuildLoader _currentBuildLoader;
|
||||
final bool _isIos;
|
||||
|
||||
Future<AppVersionCheckResult> check() async {
|
||||
try {
|
||||
final currentBuild = await _currentBuildLoader();
|
||||
|
||||
try {
|
||||
await _remoteConfig.fetchAndActivate();
|
||||
} catch (e) {
|
||||
debugPrint('[AppVersionCheck] RC fetch failed: $e');
|
||||
}
|
||||
|
||||
final minRequired = _remoteConfig.getInt(RemoteConfigKeys.minRequiredBuild);
|
||||
final latest = _remoteConfig.getInt(RemoteConfigKeys.latestBuild);
|
||||
final forceFlag = _remoteConfig.getBool(RemoteConfigKeys.updateForce);
|
||||
final message = _remoteConfig.getString(RemoteConfigKeys.updateMessage);
|
||||
final storeUrl = _isIos
|
||||
? _remoteConfig.getString(RemoteConfigKeys.updateUrlIos)
|
||||
: _remoteConfig.getString(RemoteConfigKeys.updateUrlAndroid);
|
||||
|
||||
if (forceFlag || currentBuild < minRequired) {
|
||||
return ForceUpdate(
|
||||
message: message,
|
||||
storeUrl: storeUrl,
|
||||
latestBuild: latest,
|
||||
currentBuild: currentBuild,
|
||||
);
|
||||
}
|
||||
|
||||
if (currentBuild < latest) {
|
||||
final dismissedFor = await _dismissedStore.read();
|
||||
if (latest <= dismissedFor) {
|
||||
return const NoUpdate();
|
||||
}
|
||||
return SoftUpdate(
|
||||
message: message,
|
||||
storeUrl: storeUrl,
|
||||
latestBuild: latest,
|
||||
currentBuild: currentBuild,
|
||||
);
|
||||
}
|
||||
|
||||
return const NoUpdate();
|
||||
} catch (e) {
|
||||
debugPrint('[AppVersionCheck] check failed: $e');
|
||||
return const NoUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> markSoftDismissed(int latestBuild) async {
|
||||
try {
|
||||
await _dismissedStore.write(latestBuild);
|
||||
} catch (e) {
|
||||
debugPrint('[AppVersionCheck] markSoftDismissed failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
abstract class DismissedBuildStore {
|
||||
Future<int> read();
|
||||
Future<void> write(int latestBuild);
|
||||
}
|
||||
|
||||
class SharedPrefsDismissedBuildStore implements DismissedBuildStore {
|
||||
static const _key = 'app_update_dismissed_for_latest_build';
|
||||
|
||||
@override
|
||||
Future<int> read() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getInt(_key) ?? 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(int latestBuild) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_key, latestBuild);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
class RemoteConfigKeys {
|
||||
const RemoteConfigKeys._();
|
||||
|
||||
static const minRequiredBuild = 'min_required_build';
|
||||
static const latestBuild = 'latest_build';
|
||||
static const updateForce = 'update_force';
|
||||
static const updateMessage = 'update_message';
|
||||
static const updateUrlIos = 'update_url_ios';
|
||||
static const updateUrlAndroid = 'update_url_android';
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:firebase_remote_config/firebase_remote_config.dart';
|
||||
|
||||
abstract class RemoteConfigReader {
|
||||
Future<void> fetchAndActivate();
|
||||
int getInt(String key);
|
||||
bool getBool(String key);
|
||||
String getString(String key);
|
||||
}
|
||||
|
||||
class FirebaseRemoteConfigReader implements RemoteConfigReader {
|
||||
FirebaseRemoteConfigReader([FirebaseRemoteConfig? rc])
|
||||
: _rc = rc ?? FirebaseRemoteConfig.instance;
|
||||
|
||||
final FirebaseRemoteConfig _rc;
|
||||
|
||||
@override
|
||||
Future<void> fetchAndActivate() => _rc.fetchAndActivate();
|
||||
|
||||
@override
|
||||
int getInt(String key) => _rc.getInt(key);
|
||||
|
||||
@override
|
||||
bool getBool(String key) => _rc.getBool(key);
|
||||
|
||||
@override
|
||||
String getString(String key) => _rc.getString(key);
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:firebase_performance/firebase_performance.dart';
|
||||
import 'package:firebase_remote_config/firebase_remote_config.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
import 'package:sf_tracking/sf_tracking.dart';
|
||||
|
||||
import '../config/env/environment_enum.dart';
|
||||
import '../firebase_options_dev.dart' as dev_options;
|
||||
import '../firebase_options_prod.dart' as prod_options;
|
||||
import '../firebase_options_staging.dart' as staging_options;
|
||||
import 'app_version_check/remote_config_keys.dart';
|
||||
|
||||
Future<void> setupFirebase(EnvironmentEnum env) async {
|
||||
final FirebaseOptions options;
|
||||
@@ -17,20 +20,18 @@ Future<void> setupFirebase(EnvironmentEnum env) async {
|
||||
case EnvironmentEnum.staging:
|
||||
options = staging_options.DefaultFirebaseOptions.currentPlatform;
|
||||
case EnvironmentEnum.production:
|
||||
// TODO: replace with prod_options.DefaultFirebaseOptions.currentPlatform
|
||||
// once the production Firebase project is created.
|
||||
throw UnsupportedError(
|
||||
'Production Firebase project is not configured yet. '
|
||||
'Run `flutterfire configure --project=<prod-project-id>` and import it here.',
|
||||
);
|
||||
options = prod_options.DefaultFirebaseOptions.currentPlatform;
|
||||
}
|
||||
|
||||
await Firebase.initializeApp(options: options);
|
||||
|
||||
// Report crashes in ALL builds (debug + release) so we catch issues during testing too.
|
||||
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
|
||||
// TODO(gdpr): wire `enabled` to real consent once the fix in backlog lands.
|
||||
final crashlytics = FirebaseCrashlyticsService(enabled: true);
|
||||
FlutterError.onError = (details) =>
|
||||
crashlytics.recordFlutterError(details, fatal: false);
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
|
||||
crashlytics.recordError(error, stack, fatal: false);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -48,6 +49,20 @@ Future<void> setupFirebase(EnvironmentEnum env) async {
|
||||
: const Duration(hours: 12),
|
||||
),
|
||||
);
|
||||
await remoteConfig.setDefaults(<String, Object>{
|
||||
RemoteConfigKeys.minRequiredBuild: 0,
|
||||
RemoteConfigKeys.latestBuild: 0,
|
||||
RemoteConfigKeys.updateForce: false,
|
||||
RemoteConfigKeys.updateMessage: '',
|
||||
RemoteConfigKeys.updateUrlIos: 'https://apps.apple.com/app/id6759875039',
|
||||
RemoteConfigKeys.updateUrlAndroid:
|
||||
'https://play.google.com/store/apps/details?id=com.savefamily.app',
|
||||
BrandLinksKeys.privacyPolicyUrl:
|
||||
'https://savefamilygps.com/pages/politica-de-privacidad-reloj-gps-infantil-localizador-savefamily',
|
||||
BrandLinksKeys.corporateWebsiteUrl: 'https://www.savefamilygps.com/',
|
||||
BrandLinksKeys.helpCenterUrl: 'https://savefamilygpshelp.zendesk.com/hc/es',
|
||||
BrandLinksKeys.supportEmail: 'info@savefamilygps.com',
|
||||
});
|
||||
try {
|
||||
await remoteConfig.fetchAndActivate();
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class IncomingCallNotificationConfig {
|
||||
const IncomingCallNotificationConfig._();
|
||||
|
||||
static const String channelId = 'sf_incoming_call_channel_v2';
|
||||
static const String legacyChannelId = 'sf_incoming_call_channel';
|
||||
static const String channelName = 'Videollamadas entrantes';
|
||||
static const String channelDescription =
|
||||
'Notificaciones tipo llamada para videollamadas entrantes desde el reloj.';
|
||||
static const int notificationId = 1001;
|
||||
static const String actionAccept = 'accept';
|
||||
static const String actionReject = 'reject';
|
||||
static const String systemRingtoneUri = 'content://settings/system/ringtone';
|
||||
}
|
||||
63
apps/mobile_app/lib/core/incoming_call_strings_cache.dart
Normal file
63
apps/mobile_app/lib/core/incoming_call_strings_cache.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class IncomingCallStrings {
|
||||
const IncomingCallStrings({
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.acceptLabel,
|
||||
required this.rejectLabel,
|
||||
required this.channelName,
|
||||
required this.channelDescription,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String body;
|
||||
final String acceptLabel;
|
||||
final String rejectLabel;
|
||||
final String channelName;
|
||||
final String channelDescription;
|
||||
}
|
||||
|
||||
class IncomingCallStringsCache {
|
||||
const IncomingCallStringsCache._();
|
||||
|
||||
static const _keyTitle = 'incoming_call.title';
|
||||
static const _keyBody = 'incoming_call.body';
|
||||
static const _keyAccept = 'incoming_call.accept';
|
||||
static const _keyReject = 'incoming_call.reject';
|
||||
static const _keyChannelName = 'incoming_call.channel_name';
|
||||
static const _keyChannelDescription = 'incoming_call.channel_description';
|
||||
|
||||
static const _fallback = IncomingCallStrings(
|
||||
title: 'Videollamada entrante',
|
||||
body: 'El reloj te está llamando',
|
||||
acceptLabel: 'Aceptar',
|
||||
rejectLabel: 'Rechazar',
|
||||
channelName: 'Videollamadas entrantes',
|
||||
channelDescription:
|
||||
'Notificaciones tipo llamada para videollamadas entrantes desde el reloj.',
|
||||
);
|
||||
|
||||
static Future<void> save(IncomingCallStrings strings) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyTitle, strings.title);
|
||||
await prefs.setString(_keyBody, strings.body);
|
||||
await prefs.setString(_keyAccept, strings.acceptLabel);
|
||||
await prefs.setString(_keyReject, strings.rejectLabel);
|
||||
await prefs.setString(_keyChannelName, strings.channelName);
|
||||
await prefs.setString(_keyChannelDescription, strings.channelDescription);
|
||||
}
|
||||
|
||||
static Future<IncomingCallStrings> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return IncomingCallStrings(
|
||||
title: prefs.getString(_keyTitle) ?? _fallback.title,
|
||||
body: prefs.getString(_keyBody) ?? _fallback.body,
|
||||
acceptLabel: prefs.getString(_keyAccept) ?? _fallback.acceptLabel,
|
||||
rejectLabel: prefs.getString(_keyReject) ?? _fallback.rejectLabel,
|
||||
channelName: prefs.getString(_keyChannelName) ?? _fallback.channelName,
|
||||
channelDescription: prefs.getString(_keyChannelDescription) ??
|
||||
_fallback.channelDescription,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,54 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:legacy_theme/legacy_theme.dart';
|
||||
import 'package:navigation/navigation.dart';
|
||||
import 'package:sca_treezor/sca_treezor.dart';
|
||||
import 'package:sf_app_platform/config/env/environment_enum.dart';
|
||||
import 'package:sf_app_platform/config/env/save_family_env_config.dart';
|
||||
import 'package:sf_app_platform/config/env/save_family_videocall_config.dart';
|
||||
import 'package:sf_app_platform/core/app_provider_container.dart';
|
||||
import 'package:sf_app_platform/core/config/app_mode.dart';
|
||||
import 'package:sf_app_platform/core/firebase_init.dart';
|
||||
import 'package:sf_app_platform/core/notifications_init.dart';
|
||||
import 'package:sf_app_platform/navigation/app_router.dart';
|
||||
import 'package:sf_app_platform/save_family_app.dart';
|
||||
import 'package:navigation/navigation.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
import 'package:sf_tracking/sf_tracking.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import 'package:videocall_sdk/videocall_sdk.dart';
|
||||
|
||||
Future<void> initApp(EnvironmentEnum env) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
await initializeDateFormatting();
|
||||
tz.initializeTimeZones();
|
||||
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
|
||||
navigationModule();
|
||||
scaTreezorModule();
|
||||
videocallSdkModule(SaveFamilyVideocallConfig());
|
||||
themePackages();
|
||||
|
||||
// Order matters: Firebase → sfTracking (FirebaseTrackingClient touches
|
||||
// FirebaseAnalytics.instance) → router (SaveFamilyApp wires sfTracking
|
||||
// into SfRouterListener at construction time).
|
||||
await setupFirebase(env);
|
||||
await setupNotifications();
|
||||
initSfTracking();
|
||||
|
||||
configureAppRouter();
|
||||
|
||||
// TODO Fase 2: await initSentry(env);
|
||||
|
||||
configureAppRouter();
|
||||
onRouterReady();
|
||||
|
||||
await configureDependencies(
|
||||
SaveFamilyEnvConfig(),
|
||||
log: env.isDevelopment || kDebugMode,
|
||||
onTokenExpired: isPaymentMode
|
||||
? () => appRouter.go(AppRoutes.scaTreezor)
|
||||
: () => appRouter.go(AppRoutes.legacyLogin),
|
||||
: null,
|
||||
onUnauthorized: () async {
|
||||
final currentLocation =
|
||||
appRouter.routerDelegate.currentConfiguration.uri.path;
|
||||
@@ -49,9 +57,25 @@ Future<void> initApp(EnvironmentEnum env) async {
|
||||
await GetIt.I<TreezorWalletConnectionService>().logout();
|
||||
} catch (_) {}
|
||||
await clearSessionData();
|
||||
appRouter.go(isPaymentMode ? AppRoutes.login : AppRoutes.legacyLogin);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
appRouter.go(isPaymentMode ? AppRoutes.login : AppRoutes.legacyLogin);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
runApp(const ProviderScope(child: SaveFamilyApp()));
|
||||
appProviderContainer = ProviderContainer(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(sharedPreferences),
|
||||
],
|
||||
);
|
||||
|
||||
await setupNotifications();
|
||||
initSfTracking();
|
||||
|
||||
runApp(
|
||||
UncontrolledProviderScope(
|
||||
container: appProviderContainer,
|
||||
child: const SaveFamilyApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:chat/chat.dart';
|
||||
import 'package:device_management/device_management.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:navigation/navigation.dart';
|
||||
import 'package:sf_app_platform/core/app_provider_container.dart';
|
||||
import 'package:sf_app_platform/core/incoming_call_notification_config.dart';
|
||||
import 'package:sf_app_platform/core/incoming_call_strings_cache.dart';
|
||||
import 'package:sf_app_platform/navigation/app_router.dart';
|
||||
|
||||
/// Background message handler. MUST be a top-level function annotated with
|
||||
/// `@pragma('vm:entry-point')` so the Flutter engine can dispatch it from a
|
||||
/// background isolate when the app is terminated or backgrounded.
|
||||
///
|
||||
/// This runs in a separate isolate: it CANNOT access main-isolate state
|
||||
/// (providers, GetIt, navigation). Keep it side-effect free or schedule work
|
||||
/// via shared_preferences. Do not call `Firebase.initializeApp` here —
|
||||
/// firebase_messaging 14+ auto-initializes the default app for the background
|
||||
/// isolate.
|
||||
// iOS limitation: incoming-call UX requires PushKit + CallKit + a VoIP cert,
|
||||
// which we don't have yet. On iOS the full-screen call UI and ring-while-killed
|
||||
// behaviour will not work — only the standard notification banner.
|
||||
// See TODO(videocall-ios-callkit) for the migration path.
|
||||
//
|
||||
// TODO(push-data-only): backend sends hybrid pushes (notification + data).
|
||||
// In background/killed the SDK auto-shows the `notification` payload using
|
||||
// `sf_default_channel`. For VIDEO_CALL_FROM that produces a duplicate notif
|
||||
// alongside our custom incoming-call notif (ringtone + full-screen). Backend
|
||||
// must drop the `notification` field at minimum for VIDEO_CALL_FROM pushes,
|
||||
// ideally for all commands. When that happens, this handler should construct
|
||||
// title/body locally with i18n for every command (CHAT_MESSAGE, ALERT, etc.)
|
||||
// and call _localNotifications.show(...) directly.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
debugPrint(
|
||||
'[FCM-bg] message received: ${message.messageId} - ${message.notification?.title}',
|
||||
);
|
||||
debugPrint('[FCM-bg] messageId=${message.messageId}');
|
||||
debugPrint('[FCM-bg] notification=${message.notification?.title} | ${message.notification?.body}');
|
||||
debugPrint('[FCM-bg] data=${message.data}');
|
||||
if (message.data['command'] == 'VIDEO_CALL_FROM') {
|
||||
await _showIncomingCallNotification(message.data);
|
||||
}
|
||||
}
|
||||
|
||||
const String _localChannelId = 'sf_default_channel';
|
||||
@@ -28,17 +45,16 @@ const String _localChannelDescription =
|
||||
final FlutterLocalNotificationsPlugin _localNotifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
Map<String, dynamic>? _pendingNotificationData;
|
||||
bool _routerReady = false;
|
||||
ProviderSubscription<VideocallIncomingArgs?>? _incomingProviderSub;
|
||||
|
||||
Future<void> setupNotifications() async {
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
|
||||
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
|
||||
|
||||
final settings = await messaging.requestPermission(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
debugPrint('[FCM] permission: ${settings.authorizationStatus.name}');
|
||||
await messaging.requestPermission(alert: true, badge: true, sound: true);
|
||||
|
||||
await messaging.setForegroundNotificationPresentationOptions(
|
||||
alert: true,
|
||||
@@ -47,6 +63,7 @@ Future<void> setupNotifications() async {
|
||||
);
|
||||
|
||||
await _initLocalNotifications();
|
||||
_subscribeToIncomingProvider();
|
||||
|
||||
FirebaseMessaging.onMessage.listen(_onForegroundMessage);
|
||||
FirebaseMessaging.onMessageOpenedApp.listen(_onMessageOpenedApp);
|
||||
@@ -55,24 +72,21 @@ Future<void> setupNotifications() async {
|
||||
if (initialMessage != null) {
|
||||
_onMessageOpenedApp(initialMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: integrate with backend (Treezor/SaveFamily api).
|
||||
messaging.onTokenRefresh.listen((newToken) {
|
||||
debugPrint('[FCM] token refreshed: $newToken');
|
||||
});
|
||||
|
||||
try {
|
||||
final token = await messaging.getToken();
|
||||
debugPrint('[FCM] initial token: $token');
|
||||
} catch (e) {
|
||||
debugPrint('[FCM] getToken failed: $e');
|
||||
void onRouterReady() {
|
||||
_routerReady = true;
|
||||
final pending = _pendingNotificationData;
|
||||
if (pending != null) {
|
||||
_pendingNotificationData = null;
|
||||
_handleNotificationNavigation(pending);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initLocalNotifications() async {
|
||||
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const iosInit = DarwinInitializationSettings(
|
||||
requestAlertPermission: false, // already requested via FirebaseMessaging
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
@@ -86,24 +100,129 @@ Future<void> _initLocalNotifications() async {
|
||||
onDidReceiveNotificationResponse: _onLocalNotificationTapped,
|
||||
);
|
||||
|
||||
// Android 8+ requires every notification to belong to a channel.
|
||||
const channel = AndroidNotificationChannel(
|
||||
_localChannelId,
|
||||
_localChannelName,
|
||||
description: _localChannelDescription,
|
||||
importance: Importance.high,
|
||||
);
|
||||
await _localNotifications
|
||||
final strings = await IncomingCallStringsCache.load();
|
||||
final callChannel = AndroidNotificationChannel(
|
||||
IncomingCallNotificationConfig.channelId,
|
||||
strings.channelName,
|
||||
description: strings.channelDescription,
|
||||
importance: Importance.max,
|
||||
playSound: true,
|
||||
sound: const UriAndroidNotificationSound(
|
||||
IncomingCallNotificationConfig.systemRingtoneUri,
|
||||
),
|
||||
enableVibration: true,
|
||||
enableLights: true,
|
||||
);
|
||||
final androidPlugin = _localNotifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(channel);
|
||||
>();
|
||||
await androidPlugin?.deleteNotificationChannel(
|
||||
IncomingCallNotificationConfig.legacyChannelId,
|
||||
);
|
||||
await androidPlugin?.createNotificationChannel(channel);
|
||||
await androidPlugin?.createNotificationChannel(callChannel);
|
||||
}
|
||||
|
||||
void _onForegroundMessage(RemoteMessage message) {
|
||||
debugPrint(
|
||||
'[FCM-fg] message received: ${message.messageId} - ${message.notification?.title}',
|
||||
void _subscribeToIncomingProvider() {
|
||||
_incomingProviderSub?.close();
|
||||
_incomingProviderSub = appProviderContainer.listen<VideocallIncomingArgs?>(
|
||||
videocallIncomingProvider,
|
||||
(_, next) {
|
||||
if (next == null) {
|
||||
_dismissIncomingCallNotification();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showIncomingCallNotification(Map<String, dynamic> data) async {
|
||||
final roomNumber = data['roomNumber'] as String?;
|
||||
final appAccount = data['appAccount'] as String?;
|
||||
final sessionId = data['sessionId'] as String?;
|
||||
if (roomNumber == null || appAccount == null || sessionId == null) return;
|
||||
|
||||
final strings = await IncomingCallStringsCache.load();
|
||||
final payload = jsonEncode(data);
|
||||
|
||||
await _localNotifications.show(
|
||||
IncomingCallNotificationConfig.notificationId,
|
||||
strings.title,
|
||||
strings.body,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
IncomingCallNotificationConfig.channelId,
|
||||
strings.channelName,
|
||||
channelDescription: strings.channelDescription,
|
||||
importance: Importance.max,
|
||||
priority: Priority.max,
|
||||
category: AndroidNotificationCategory.call,
|
||||
fullScreenIntent: true,
|
||||
ongoing: true,
|
||||
autoCancel: false,
|
||||
playSound: true,
|
||||
sound: const UriAndroidNotificationSound(
|
||||
IncomingCallNotificationConfig.systemRingtoneUri,
|
||||
),
|
||||
enableVibration: true,
|
||||
visibility: NotificationVisibility.public,
|
||||
actions: <AndroidNotificationAction>[
|
||||
AndroidNotificationAction(
|
||||
IncomingCallNotificationConfig.actionAccept,
|
||||
strings.acceptLabel,
|
||||
showsUserInterface: true,
|
||||
cancelNotification: true,
|
||||
),
|
||||
AndroidNotificationAction(
|
||||
IncomingCallNotificationConfig.actionReject,
|
||||
strings.rejectLabel,
|
||||
showsUserInterface: false,
|
||||
cancelNotification: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
interruptionLevel: InterruptionLevel.timeSensitive,
|
||||
),
|
||||
),
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _dismissIncomingCallNotification() async {
|
||||
await _localNotifications.cancel(
|
||||
IncomingCallNotificationConfig.notificationId,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(push-data-only): when backend stops sending the `notification` field,
|
||||
// stop reading message.notification.title/body below. Instead, build title/body
|
||||
// from message.data (e.g., data['carrierName']) plus i18n strings — same way
|
||||
// _showIncomingCallNotification already does for VIDEO_CALL_FROM. Then we can
|
||||
// drop the early-return when notification is null.
|
||||
void _onForegroundMessage(RemoteMessage message) {
|
||||
debugPrint('[FCM-fg] messageId=${message.messageId}');
|
||||
debugPrint('[FCM-fg] notification=${message.notification?.title} | ${message.notification?.body}');
|
||||
debugPrint('[FCM-fg] data=${message.data}');
|
||||
if (message.data['command'] == 'VIDEO_CALL_FROM') {
|
||||
_showIncomingCallNotification(message.data);
|
||||
_handleNotificationNavigation(message.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_shouldSuppressChatNotification(message.data)) {
|
||||
debugPrint('[FCM-fg] chat notification suppressed by active context');
|
||||
return;
|
||||
}
|
||||
|
||||
final notification = message.notification;
|
||||
if (notification == null) return;
|
||||
@@ -132,16 +251,164 @@ void _onForegroundMessage(RemoteMessage message) {
|
||||
);
|
||||
}
|
||||
|
||||
void _onMessageOpenedApp(RemoteMessage message) {
|
||||
debugPrint(
|
||||
'[FCM-tap] user tapped notification: ${message.messageId} - data: ${message.data}',
|
||||
);
|
||||
// TODO: handle deep linking based on message.data.
|
||||
/// Suppresses chat notifications while the user is actively reading the chat
|
||||
/// surface — same UX as WhatsApp/Telegram.
|
||||
///
|
||||
/// - Chat list → suppress (the list updates reactively via the WebSocket).
|
||||
/// - Conversation matching the incoming chatId → suppress (the message arrives
|
||||
/// through the reactive stream).
|
||||
/// - Conversation viewing a different chat → show (cross-chat notification).
|
||||
/// - Outside the chat surface → show.
|
||||
bool _shouldSuppressChatNotification(Map<String, dynamic> data) {
|
||||
if (data['command'] != 'CHAT_MESSAGE') return false;
|
||||
|
||||
final context = appProviderContainer.read(chatContextProvider);
|
||||
if (context is ChatContextOutsideChat) return false;
|
||||
if (context is ChatContextList) return true;
|
||||
|
||||
final deviceIdentificator = data['deviceIdentificator'] as String?;
|
||||
if (deviceIdentificator == null || deviceIdentificator.isEmpty) return false;
|
||||
|
||||
final incomingChatId = appProviderContainer
|
||||
.read(chatDeeplinkServiceProvider)
|
||||
.resolveClientChatId(
|
||||
chatId: data['chatId'] as String?,
|
||||
deviceIdentificator: deviceIdentificator,
|
||||
);
|
||||
if (incomingChatId == null) return false;
|
||||
|
||||
return context is ChatContextConversation && context.chatId == incomingChatId;
|
||||
}
|
||||
|
||||
void _onLocalNotificationTapped(NotificationResponse response) {
|
||||
debugPrint(
|
||||
'[FCM-localtap] user tapped local notification: id=${response.id} payload=${response.payload}',
|
||||
);
|
||||
// TODO: handle deep linking. Payload contains JSON-encoded message.data.
|
||||
void _onMessageOpenedApp(RemoteMessage message) {
|
||||
debugPrint('[FCM-tap] messageId=${message.messageId}');
|
||||
debugPrint('[FCM-tap] notification=${message.notification?.title} | ${message.notification?.body}');
|
||||
debugPrint('[FCM-tap] data=${message.data}');
|
||||
_handleNotificationNavigation(message.data);
|
||||
}
|
||||
|
||||
// TODO(videocall-callkit-migration): tap "Accept" hoy abre el app y muestra
|
||||
// IncomingView (doble-tap para entrar a la llamada). WhatsApp/Telegram entran
|
||||
// directo porque usan CallKit/CallStyle nativos. Migración completa a
|
||||
// flutter_callkit_incoming bloqueada por VoIP cert de Apple. Plan y opciones
|
||||
// en docs/videocall-callkit-migration.md.
|
||||
void _onLocalNotificationTapped(NotificationResponse response) {
|
||||
final payload = response.payload;
|
||||
if (payload == null || payload.isEmpty) return;
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(payload) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data['command'] == 'VIDEO_CALL_FROM' &&
|
||||
response.actionId == IncomingCallNotificationConfig.actionReject) {
|
||||
_rejectIncomingCallFromNotification(data);
|
||||
return;
|
||||
}
|
||||
|
||||
_handleNotificationNavigation(data);
|
||||
}
|
||||
|
||||
Future<void> _rejectIncomingCallFromNotification(
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
final roomNumber = data['roomNumber'] as String?;
|
||||
final appAccount = data['appAccount'] as String?;
|
||||
if (roomNumber == null || appAccount == null) {
|
||||
appProviderContainer.read(videocallIncomingProvider.notifier).clear();
|
||||
return;
|
||||
}
|
||||
final chatType = data['chatType'] == 'multi'
|
||||
? VideocallChatType.multi
|
||||
: VideocallChatType.single;
|
||||
final deviceId = parseDeviceIdFromRoom(roomNumber);
|
||||
try {
|
||||
await appProviderContainer
|
||||
.read(videocallSignalingRepositoryProvider)
|
||||
.refuseCall(
|
||||
deviceIdentificator: deviceId,
|
||||
chatType: chatType,
|
||||
appAccount: appAccount,
|
||||
roomNumber: roomNumber,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint('[Notification] refuseCall from notif failed: $error');
|
||||
}
|
||||
appProviderContainer.read(videocallIncomingProvider.notifier).clear();
|
||||
}
|
||||
|
||||
void _handleNotificationNavigation(Map<String, dynamic> data) {
|
||||
if (!_routerReady) {
|
||||
_pendingNotificationData = data;
|
||||
return;
|
||||
}
|
||||
|
||||
final currentLocation =
|
||||
appRouter.routerDelegate.currentConfiguration.uri.path;
|
||||
if (!currentLocation.startsWith(AppRoutes.legacyDashboard)) return;
|
||||
|
||||
Map<String, dynamic> resolved = data;
|
||||
final pushData = data['pushData'];
|
||||
if (pushData is String && pushData.isNotEmpty) {
|
||||
try {
|
||||
resolved = jsonDecode(pushData) as Map<String, dynamic>;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final command = resolved['command'] as String? ?? data['command'] as String?;
|
||||
|
||||
FirebaseCrashlytics.instance.log(
|
||||
'FCM-nav: command=$command from=$currentLocation',
|
||||
);
|
||||
|
||||
switch (command) {
|
||||
case 'ALERT':
|
||||
appRouter.go(AppRoutes.deviceNotifications);
|
||||
case 'CHAT_MESSAGE':
|
||||
_openChatFromIncoming(resolved);
|
||||
case 'VIDEO_CALL_FROM':
|
||||
final chatType = resolved['chatType'] as String?;
|
||||
final appAccount = resolved['appAccount'] as String?;
|
||||
final roomNumber = resolved['roomNumber'] as String?;
|
||||
final sessionId = resolved['sessionId'] as String?;
|
||||
if (roomNumber == null || appAccount == null || sessionId == null) {
|
||||
return;
|
||||
}
|
||||
appProviderContainer.read(videocallIncomingProvider.notifier).set(
|
||||
VideocallIncomingArgs(
|
||||
roomNumber: roomNumber,
|
||||
appAccount: appAccount,
|
||||
sessionId: sessionId,
|
||||
chatType: chatType == 'multi'
|
||||
? VideocallChatType.multi
|
||||
: VideocallChatType.single,
|
||||
),
|
||||
);
|
||||
appRouter.go(AppRoutes.videocall);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openChatFromIncoming(Map<String, dynamic> data) async {
|
||||
final deviceIdentificator = data['deviceIdentificator'] as String?;
|
||||
if (deviceIdentificator == null || deviceIdentificator.isEmpty) {
|
||||
appRouter.go(AppRoutes.legacyChat);
|
||||
return;
|
||||
}
|
||||
|
||||
final outcome = await appProviderContainer
|
||||
.read(chatDeeplinkServiceProvider)
|
||||
.prepareIncomingChat(
|
||||
chatId: data['chatId'] as String?,
|
||||
deviceIdentificator: deviceIdentificator,
|
||||
);
|
||||
|
||||
if (outcome == null) {
|
||||
appRouter.go(AppRoutes.legacyChat);
|
||||
return;
|
||||
}
|
||||
|
||||
appRouter.go(AppRoutes.legacyChatConversationFor(outcome.chatId));
|
||||
}
|
||||
|
||||
68
apps/mobile_app/lib/firebase_options_prod.dart
Normal file
68
apps/mobile_app/lib/firebase_options_prod.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
// File generated by FlutterFire CLI.
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||
|
||||
/// Default [FirebaseOptions] for use with your Firebase apps.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// import 'firebase_options_prod.dart';
|
||||
/// // ...
|
||||
/// await Firebase.initializeApp(
|
||||
/// options: DefaultFirebaseOptions.currentPlatform,
|
||||
/// );
|
||||
/// ```
|
||||
class DefaultFirebaseOptions {
|
||||
static FirebaseOptions get currentPlatform {
|
||||
if (kIsWeb) {
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for web - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
}
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
return android;
|
||||
case TargetPlatform.iOS:
|
||||
return ios;
|
||||
case TargetPlatform.macOS:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for macos - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
case TargetPlatform.windows:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for windows - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
case TargetPlatform.linux:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for linux - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
default:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions are not supported for this platform.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static const FirebaseOptions android = FirebaseOptions(
|
||||
apiKey: 'AIzaSyDkjNdOAK0ype7wgdgiC1BCKV_pP4s_mlA',
|
||||
appId: '1:950566980029:android:75a7c10b6259d09681aad4',
|
||||
messagingSenderId: '950566980029',
|
||||
projectId: 'sf-platform-pro',
|
||||
storageBucket: 'sf-platform-pro.firebasestorage.app',
|
||||
);
|
||||
|
||||
static const FirebaseOptions ios = FirebaseOptions(
|
||||
apiKey: 'AIzaSyC0_d7Z6uVHHKhaf7JHRROaY6g2mvvpOXU',
|
||||
appId: '1:950566980029:ios:987b4f0b9e9b897481aad4',
|
||||
messagingSenderId: '950566980029',
|
||||
projectId: 'sf-platform-pro',
|
||||
storageBucket: 'sf-platform-pro.firebasestorage.app',
|
||||
iosBundleId: 'com.savefamily.app',
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:dashboard_shell/dashboard_builder.dart';
|
||||
import 'package:device_management/device_management.dart';
|
||||
import 'package:control_panel/control_panel.dart';
|
||||
import 'package:legacy_dashboard_shell/legacy_dashboard_builder.dart';
|
||||
import 'package:chat/chat.dart';
|
||||
import 'package:location/location.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
@@ -18,10 +19,30 @@ import 'package:payments/payments.dart';
|
||||
import 'package:profile/profile.dart';
|
||||
import 'package:settings/settings.dart';
|
||||
import 'package:sf_app_platform/core/config/app_mode.dart';
|
||||
import 'package:sf_app_platform/widgets/user_identity_listener.dart';
|
||||
import 'package:splash/splash.dart';
|
||||
|
||||
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
final _legacyControlPanelNavKey =
|
||||
GlobalKey<NavigatorState>(debugLabel: 'legacyControlPanel');
|
||||
final _legacyDeviceMgmtNavKey =
|
||||
GlobalKey<NavigatorState>(debugLabel: 'legacyDeviceMgmt');
|
||||
final _legacyLocationNavKey =
|
||||
GlobalKey<NavigatorState>(debugLabel: 'legacyLocation');
|
||||
final _legacyChatNavKey = GlobalKey<NavigatorState>(debugLabel: 'legacyChat');
|
||||
final _legacySettingsNavKey =
|
||||
GlobalKey<NavigatorState>(debugLabel: 'legacySettings');
|
||||
|
||||
final _dashboardHomeNavKey =
|
||||
GlobalKey<NavigatorState>(debugLabel: 'dashboardHome');
|
||||
final _dashboardActivityNavKey =
|
||||
GlobalKey<NavigatorState>(debugLabel: 'dashboardActivity');
|
||||
final _dashboardNotificationsNavKey =
|
||||
GlobalKey<NavigatorState>(debugLabel: 'dashboardNotifications');
|
||||
final _dashboardProfileNavKey =
|
||||
GlobalKey<NavigatorState>(debugLabel: 'dashboardProfile');
|
||||
|
||||
late final GoRouter appRouter;
|
||||
|
||||
/// Maps the splash's session check result to the destination route based
|
||||
@@ -31,12 +52,14 @@ const _legacySplashRouteMap = <InitialRoute, String>{
|
||||
InitialRoute.onboarding: AppRoutes.legacyOnboarding,
|
||||
InitialRoute.login: AppRoutes.legacyLogin,
|
||||
InitialRoute.home: AppRoutes.controlPanel,
|
||||
InitialRoute.deviceSetup: AppRoutes.legacyDeviceSetup,
|
||||
};
|
||||
|
||||
const _paymentSplashRouteMap = <InitialRoute, String>{
|
||||
InitialRoute.onboarding: AppRoutes.onboarding,
|
||||
InitialRoute.login: AppRoutes.login,
|
||||
InitialRoute.home: AppRoutes.dashboardHome,
|
||||
InitialRoute.deviceSetup: AppRoutes.deviceSetup,
|
||||
};
|
||||
|
||||
void configureAppRouter() {
|
||||
@@ -56,10 +79,13 @@ void configureAppRouter() {
|
||||
),
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, navShell) {
|
||||
return LegacyDashboardBuilder().build(context, navShell);
|
||||
return UserIdentityListener(
|
||||
child: LegacyDashboardBuilder().build(context, navShell),
|
||||
);
|
||||
},
|
||||
branches: [
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _legacyControlPanelNavKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.controlPanel,
|
||||
@@ -71,6 +97,19 @@ void configureAppRouter() {
|
||||
name: 'customer_service',
|
||||
pageBuilder: CustomerServiceBuilder().buildPage,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'notifications',
|
||||
name: 'legacy_notifications',
|
||||
pageBuilder: const DeviceNotificationsBuilder().buildPage,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':type',
|
||||
name: 'legacy_notifications_by_type',
|
||||
pageBuilder:
|
||||
const FilteredNotificationsBuilder().buildPage,
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'account_settings',
|
||||
name: 'account_settings',
|
||||
@@ -108,6 +147,7 @@ void configureAppRouter() {
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _legacyDeviceMgmtNavKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.deviceManagement,
|
||||
@@ -166,6 +206,11 @@ void configureAppRouter() {
|
||||
name: 'volume_control',
|
||||
pageBuilder: const VolumeControlBuilder().buildPage,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'do_not_disturb',
|
||||
name: 'do_not_disturb',
|
||||
pageBuilder: const DoNotDisturbBuilder().buildPage,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'call_history',
|
||||
name: 'call_history',
|
||||
@@ -176,11 +221,35 @@ void configureAppRouter() {
|
||||
name: 'background_image',
|
||||
pageBuilder: const BackgroundImageBuilder().buildPage,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'installed_apps',
|
||||
name: 'installed_apps',
|
||||
pageBuilder: const InstalledAppsBuilder().buildPage,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'schedules',
|
||||
name: 'app_usage_schedules',
|
||||
pageBuilder:
|
||||
const AppUsageSchedulesBuilder().buildPage,
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'videocall',
|
||||
name: 'videocall',
|
||||
pageBuilder: const VideocallBuilder().buildPage,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'friends',
|
||||
name: 'friends',
|
||||
pageBuilder: const FriendsBuilder().buildPage,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _legacyLocationNavKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.legacyLocation,
|
||||
@@ -189,28 +258,36 @@ void configureAppRouter() {
|
||||
),
|
||||
],
|
||||
),
|
||||
// TODO: Añadir branch para Chat (tab 4)
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _legacyChatNavKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '${AppRoutes.legacyDashboard}/chat',
|
||||
path: AppRoutes.legacyChat,
|
||||
name: 'legacy_chat',
|
||||
pageBuilder: (context, state) => MaterialPage<void>(
|
||||
key: state.pageKey,
|
||||
child: const Scaffold(
|
||||
body: Center(child: Text('Chat - Coming soon')),
|
||||
pageBuilder: const ChatListBuilder().buildPage,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'conversation/:chatId',
|
||||
name: 'legacy_chat_conversation',
|
||||
pageBuilder: const ChatConversationBuilder().buildPage,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _legacySettingsNavKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.settings,
|
||||
name: 'settings',
|
||||
pageBuilder: SettingsBuilder().buildPage,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'appearance',
|
||||
name: 'appearance',
|
||||
pageBuilder: AppearanceBuilder().buildPage,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'alarm',
|
||||
name: 'alarm',
|
||||
@@ -250,6 +327,13 @@ void configureAppRouter() {
|
||||
path: 'block_phone',
|
||||
name: 'block_phone',
|
||||
pageBuilder: BlockPhoneBuilder().buildPage,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'blocked_calls',
|
||||
name: 'blocked_calls',
|
||||
pageBuilder: BlockedCallsBuilder().buildPage,
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'disable_functions',
|
||||
@@ -261,11 +345,6 @@ void configureAppRouter() {
|
||||
name: 'language',
|
||||
pageBuilder: LanguageBuilder().buildPage,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'legacy_notifications',
|
||||
name: 'legacy_notifications',
|
||||
pageBuilder: LegacyNotificationsBuilder().buildPage,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'remote_on_off',
|
||||
name: 'remote_on_off',
|
||||
@@ -376,10 +455,13 @@ void configureAppRouter() {
|
||||
),
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, navShell) {
|
||||
return DashboardBuilder().build(context, navShell);
|
||||
return UserIdentityListener(
|
||||
child: DashboardBuilder().build(context, navShell),
|
||||
);
|
||||
},
|
||||
branches: [
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _dashboardHomeNavKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.dashboardHome,
|
||||
@@ -448,6 +530,7 @@ void configureAppRouter() {
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _dashboardActivityNavKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.dashboardActivity,
|
||||
@@ -457,6 +540,7 @@ void configureAppRouter() {
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _dashboardNotificationsNavKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.dashboardNotifications,
|
||||
@@ -466,6 +550,7 @@ void configureAppRouter() {
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _dashboardProfileNavKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.dashboardProfile,
|
||||
|
||||
@@ -2,20 +2,21 @@ import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart' show WidgetRef;
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
|
||||
class LegacyHeartbeatService {
|
||||
LegacyHeartbeatService({
|
||||
required SaveFamilyRepository repository,
|
||||
required WidgetRef ref,
|
||||
required void Function() onUnauthorized,
|
||||
}) : _repository = repository,
|
||||
}) : _ref = ref,
|
||||
_onUnauthorized = onUnauthorized;
|
||||
|
||||
final SaveFamilyRepository _repository;
|
||||
final WidgetRef _ref;
|
||||
final void Function() _onUnauthorized;
|
||||
Timer? _timer;
|
||||
|
||||
static const _interval = Duration(minutes: 3);
|
||||
static const _interval = Duration(minutes: 2);
|
||||
|
||||
void start() {
|
||||
if (_timer != null) return;
|
||||
@@ -32,8 +33,8 @@ class LegacyHeartbeatService {
|
||||
|
||||
Future<void> _beat() async {
|
||||
try {
|
||||
await _repository.get<dynamic>('/auth/me');
|
||||
debugPrint('[LegacyHeartbeat] /auth/me => OK');
|
||||
await _ref.read(legacyDevicesProvider.notifier).refresh();
|
||||
debugPrint('[LegacyHeartbeat] devices refreshed');
|
||||
} catch (e) {
|
||||
debugPrint('[LegacyHeartbeat] error: $e');
|
||||
if (e is DioException && e.response?.statusCode == 401) {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import 'package:auth/auth.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:legacy_theme/legacy_theme.dart';
|
||||
import 'package:sf_app_platform/core/app_version_check/app_update_gate.dart';
|
||||
import 'package:sf_app_platform/core/app_version_check/app_version_check.dart';
|
||||
import 'package:sf_app_platform/core/config/app_mode.dart';
|
||||
import 'package:sf_app_platform/core/incoming_call_strings_cache.dart';
|
||||
import 'package:sf_app_platform/navigation/app_router.dart';
|
||||
import 'package:navigation/navigation.dart';
|
||||
import 'package:sf_app_platform/providers/app_state_provider.dart';
|
||||
@@ -16,6 +21,7 @@ import 'package:sf_tracking/sf_tracking.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
import 'package:utils/utils.dart';
|
||||
import 'package:fonts/fonts.dart';
|
||||
import 'package:videocall_sdk/videocall_sdk.dart';
|
||||
|
||||
class SaveFamilyApp extends ConsumerStatefulWidget {
|
||||
const SaveFamilyApp({super.key});
|
||||
@@ -29,6 +35,7 @@ class SaveFamilyAppState extends ConsumerState<SaveFamilyApp>
|
||||
WalletHeartbeatService? _walletHeartbeat;
|
||||
LegacyHeartbeatService? _legacyHeartbeat;
|
||||
SfRouterListener? _trackingRouterListener;
|
||||
WebSocketService? _webSocket;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -55,18 +62,26 @@ class SaveFamilyAppState extends ConsumerState<SaveFamilyApp>
|
||||
|
||||
if (isLegacyMode) {
|
||||
_legacyHeartbeat = LegacyHeartbeatService(
|
||||
repository: GetIt.I<SaveFamilyRepository>(),
|
||||
ref: ref,
|
||||
onUnauthorized: () {
|
||||
clearSessionData();
|
||||
appRouter.go(AppRoutes.legacyLogin);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
appRouter.go(AppRoutes.legacyLogin);
|
||||
});
|
||||
},
|
||||
);
|
||||
_webSocket = GetIt.I<WebSocketService>();
|
||||
appRouter.routerDelegate.addListener(_onRouteChanged);
|
||||
}
|
||||
|
||||
onBeforeSessionCleared = () {
|
||||
_walletHeartbeat?.stop();
|
||||
_legacyHeartbeat?.stop();
|
||||
_webSocket?.disconnect();
|
||||
GetIt.I<VideocallClient>().logout();
|
||||
FirebaseMessaging.instance.deleteToken().catchError((Object e) {
|
||||
debugPrint('[FCM] deleteToken on logout failed: $e');
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,8 +92,10 @@ class SaveFamilyAppState extends ConsumerState<SaveFamilyApp>
|
||||
final location = appRouter.routerDelegate.currentConfiguration.uri.path;
|
||||
if (location.startsWith(AppRoutes.legacyDashboard)) {
|
||||
heartbeat.start();
|
||||
_webSocket?.connect();
|
||||
} else {
|
||||
heartbeat.stop();
|
||||
_webSocket?.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +107,7 @@ class SaveFamilyAppState extends ConsumerState<SaveFamilyApp>
|
||||
_trackingRouterListener?.dispose();
|
||||
_walletHeartbeat?.stop();
|
||||
_legacyHeartbeat?.stop();
|
||||
_webSocket?.disconnect();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
@@ -104,9 +122,11 @@ class SaveFamilyAppState extends ConsumerState<SaveFamilyApp>
|
||||
_onRouteChanged();
|
||||
}
|
||||
ref.read(permissionsProvider.notifier).checkPermissions();
|
||||
ref.read(appVersionCheckProvider.notifier).refresh();
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
_walletHeartbeat?.stop();
|
||||
_legacyHeartbeat?.stop();
|
||||
_webSocket?.disconnect();
|
||||
}
|
||||
super.didChangeAppLifecycleState(state);
|
||||
}
|
||||
@@ -114,45 +134,76 @@ class SaveFamilyAppState extends ConsumerState<SaveFamilyApp>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SizeUtils.init(context: context);
|
||||
ref.watch(pushTokenRefreshListenerProvider);
|
||||
|
||||
ref.listen<AsyncValue<UserEntity>>(userInfoProvider, (previous, next) {
|
||||
next.whenData((user) {
|
||||
UserInfoTrackingListener(ref.read(sfTrackingProvider)).onUserChanged(
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
language: user.language,
|
||||
createdAtMillis: user.createdAt,
|
||||
hasPhone: user.phone.isNotEmpty,
|
||||
hasApiKey: user.hasApiKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'SaveFamily',
|
||||
theme: ThemeData(
|
||||
// Theme wiring:
|
||||
// - Legacy mode: new `legacy_theme` package (Material 3 + light/dark/system).
|
||||
// - Payment mode: unchanged behaviour (seed-based ColorScheme, light only).
|
||||
final ThemeData lightTheme;
|
||||
final ThemeData? darkTheme;
|
||||
final ThemeMode themeMode;
|
||||
if (isLegacyMode) {
|
||||
final legacyThemeState = ref.watch(legacyThemeNotifierProvider);
|
||||
lightTheme = LegacyAppTheme.light;
|
||||
darkTheme = LegacyAppTheme.dark;
|
||||
themeMode = legacyThemeState.themeMode;
|
||||
} else {
|
||||
lightTheme = ThemeData(
|
||||
fontFamily: AppFonts.stolzl,
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF329E95)),
|
||||
),
|
||||
routerConfig: appRouter,
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: [
|
||||
// CountryLocalizations.delegate,
|
||||
SFLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: [for (final lang in supportedLanguages) Locale(lang)],
|
||||
localeResolutionCallback: (locale, supportedLocales) {
|
||||
if (locale == null) return supportedLocales.first;
|
||||
for (var supportedLocale in supportedLocales) {
|
||||
if (supportedLocale.languageCode == locale.languageCode) {
|
||||
return supportedLocale;
|
||||
);
|
||||
darkTheme = null;
|
||||
themeMode = ThemeMode.light;
|
||||
}
|
||||
|
||||
return AppUpdateGate(
|
||||
child: MaterialApp.router(
|
||||
title: 'SaveFamily',
|
||||
theme: lightTheme,
|
||||
darkTheme: darkTheme,
|
||||
themeMode: themeMode,
|
||||
routerConfig: appRouter,
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: [
|
||||
SFLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: [for (final lang in supportedLanguages) Locale(lang)],
|
||||
localeResolutionCallback: (locale, supportedLocales) {
|
||||
if (locale == null) return supportedLocales.first;
|
||||
for (var supportedLocale in supportedLocales) {
|
||||
if (supportedLocale.languageCode == locale.languageCode) {
|
||||
return supportedLocale;
|
||||
}
|
||||
}
|
||||
}
|
||||
return supportedLocales.first;
|
||||
},
|
||||
return supportedLocales.first;
|
||||
},
|
||||
builder: (context, child) {
|
||||
_cacheIncomingCallStrings(context);
|
||||
return child ?? const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _cachedIncomingCallLocale;
|
||||
|
||||
void _cacheIncomingCallStrings(BuildContext context) {
|
||||
final localeCode = context.locale.languageCode;
|
||||
if (_cachedIncomingCallLocale == localeCode) return;
|
||||
_cachedIncomingCallLocale = localeCode;
|
||||
IncomingCallStringsCache.save(
|
||||
IncomingCallStrings(
|
||||
title: context.translate(I18n.videocallIncomingVideo),
|
||||
body: context.translate(I18n.videocallIncomingPushBody),
|
||||
acceptLabel: context.translate(I18n.videocallAccept),
|
||||
rejectLabel: context.translate(I18n.videocallReject),
|
||||
channelName: context.translate(I18n.videocallNotificationChannelName),
|
||||
channelDescription:
|
||||
context.translate(I18n.videocallNotificationChannelDescription),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
28
apps/mobile_app/lib/widgets/user_identity_listener.dart
Normal file
28
apps/mobile_app/lib/widgets/user_identity_listener.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
import 'package:sf_tracking/sf_tracking.dart';
|
||||
|
||||
class UserIdentityListener extends ConsumerWidget {
|
||||
final Widget child;
|
||||
|
||||
const UserIdentityListener({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.listen<AsyncValue<UserEntity>>(userInfoProvider, (_, next) {
|
||||
next.whenData((user) {
|
||||
UserInfoTrackingListener(ref.read(sfTrackingProvider)).onUserChanged(
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
language: user.language,
|
||||
createdAtMillis: user.createdAt,
|
||||
hasPhone: user.phone.isNotEmpty,
|
||||
hasApiKey: user.hasApiKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,21 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <record_linux/record_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
|
||||
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) record_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
||||
record_linux_plugin_register_with_registrar(record_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
file_selector_linux
|
||||
record_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ resolution: workspace
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+7
|
||||
version: 1.0.0+10
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
@@ -64,10 +64,14 @@ dependencies:
|
||||
path: ../../modules/legacy/modules/device_management
|
||||
location:
|
||||
path: ../../modules/legacy/modules/location
|
||||
chat:
|
||||
path: ../../modules/legacy/modules/chat
|
||||
legacy_auth:
|
||||
path: ../../modules/legacy/modules/legacy_auth
|
||||
settings:
|
||||
path: ../../modules/legacy/modules/settings
|
||||
legacy_theme:
|
||||
path: ../../modules/legacy/packages/legacy_theme
|
||||
#packages dependencies go here
|
||||
navigation:
|
||||
path: ../../packages/navigation
|
||||
@@ -91,9 +95,13 @@ dependencies:
|
||||
path: ../../packages/sca_treezor
|
||||
payments:
|
||||
path: ../../packages/payments
|
||||
videocall_sdk:
|
||||
path: ../../packages/videocall_sdk
|
||||
#dependencies go here
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_svg: ^2.2.2
|
||||
intl: ^0.20.2
|
||||
timezone: ^0.10.1
|
||||
go_router_builder: ^4.1.1
|
||||
build_runner: ^2.7.1
|
||||
|
||||
@@ -106,6 +114,9 @@ dependencies:
|
||||
firebase_crashlytics: ^5.1.0
|
||||
firebase_analytics: ^12.2.0
|
||||
firebase_remote_config: ^6.3.0
|
||||
package_info_plus: ^8.3.1
|
||||
url_launcher: ^6.3.2
|
||||
shared_preferences: ^2.5.5
|
||||
firebase_messaging: ^16.1.3
|
||||
firebase_performance: ^0.11.2
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sf_app_platform/core/app_version_check/app_version_check.dart';
|
||||
import 'package:sf_app_platform/core/app_version_check/remote_config_keys.dart';
|
||||
|
||||
class _FakeRemoteConfig implements RemoteConfigReader {
|
||||
_FakeRemoteConfig({
|
||||
this.minRequired = 0,
|
||||
this.latest = 0,
|
||||
this.force = false,
|
||||
this.message = '',
|
||||
this.iosUrl = 'ios-url',
|
||||
this.androidUrl = 'android-url',
|
||||
this.fetchThrows = false,
|
||||
});
|
||||
|
||||
int minRequired;
|
||||
int latest;
|
||||
bool force;
|
||||
String message;
|
||||
String iosUrl;
|
||||
String androidUrl;
|
||||
bool fetchThrows;
|
||||
int fetchCallCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> fetchAndActivate() async {
|
||||
fetchCallCount++;
|
||||
if (fetchThrows) throw Exception('boom');
|
||||
}
|
||||
|
||||
@override
|
||||
int getInt(String key) => switch (key) {
|
||||
RemoteConfigKeys.minRequiredBuild => minRequired,
|
||||
RemoteConfigKeys.latestBuild => latest,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
@override
|
||||
bool getBool(String key) =>
|
||||
key == RemoteConfigKeys.updateForce ? force : false;
|
||||
|
||||
@override
|
||||
String getString(String key) => switch (key) {
|
||||
RemoteConfigKeys.updateMessage => message,
|
||||
RemoteConfigKeys.updateUrlIos => iosUrl,
|
||||
RemoteConfigKeys.updateUrlAndroid => androidUrl,
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
class _FakeDismissedStore implements DismissedBuildStore {
|
||||
_FakeDismissedStore({this.value = 0, this.writeThrows = false});
|
||||
|
||||
int value;
|
||||
bool writeThrows;
|
||||
int writeCallCount = 0;
|
||||
|
||||
@override
|
||||
Future<int> read() async => value;
|
||||
|
||||
@override
|
||||
Future<void> write(int latestBuild) async {
|
||||
writeCallCount++;
|
||||
if (writeThrows) throw Exception('write boom');
|
||||
value = latestBuild;
|
||||
}
|
||||
}
|
||||
|
||||
AppVersionCheckService _buildService({
|
||||
required _FakeRemoteConfig rc,
|
||||
required _FakeDismissedStore store,
|
||||
required int currentBuild,
|
||||
bool isIos = false,
|
||||
}) {
|
||||
return AppVersionCheckService(
|
||||
remoteConfig: rc,
|
||||
dismissedStore: store,
|
||||
currentBuildLoader: () async => currentBuild,
|
||||
isIos: isIos,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('AppVersionCheckService.check', () {
|
||||
test('returns NoUpdate when current build matches latest and no force',
|
||||
() async {
|
||||
final service = _buildService(
|
||||
rc: _FakeRemoteConfig(latest: 7),
|
||||
store: _FakeDismissedStore(),
|
||||
currentBuild: 7,
|
||||
);
|
||||
|
||||
final result = await service.check();
|
||||
|
||||
expect(result, isA<NoUpdate>());
|
||||
});
|
||||
|
||||
test('returns NoUpdate when nothing requires attention', () async {
|
||||
final service = _buildService(
|
||||
rc: _FakeRemoteConfig(),
|
||||
store: _FakeDismissedStore(),
|
||||
currentBuild: 7,
|
||||
);
|
||||
|
||||
final result = await service.check();
|
||||
|
||||
expect(result, isA<NoUpdate>());
|
||||
});
|
||||
|
||||
test('returns SoftUpdate when current build is behind latest', () async {
|
||||
final service = _buildService(
|
||||
rc: _FakeRemoteConfig(latest: 8, message: 'New stuff'),
|
||||
store: _FakeDismissedStore(),
|
||||
currentBuild: 7,
|
||||
);
|
||||
|
||||
final result = await service.check();
|
||||
|
||||
expect(result, isA<SoftUpdate>());
|
||||
final soft = result as SoftUpdate;
|
||||
expect(soft.latestBuild, 8);
|
||||
expect(soft.currentBuild, 7);
|
||||
expect(soft.message, 'New stuff');
|
||||
});
|
||||
|
||||
test('returns ForceUpdate when current build is below min required',
|
||||
() async {
|
||||
final service = _buildService(
|
||||
rc: _FakeRemoteConfig(minRequired: 8, latest: 8),
|
||||
store: _FakeDismissedStore(),
|
||||
currentBuild: 7,
|
||||
);
|
||||
|
||||
final result = await service.check();
|
||||
|
||||
expect(result, isA<ForceUpdate>());
|
||||
final force = result as ForceUpdate;
|
||||
expect(force.latestBuild, 8);
|
||||
expect(force.currentBuild, 7);
|
||||
});
|
||||
|
||||
test('returns ForceUpdate when force flag is true even with current build matching',
|
||||
() async {
|
||||
final service = _buildService(
|
||||
rc: _FakeRemoteConfig(force: true, latest: 7),
|
||||
store: _FakeDismissedStore(),
|
||||
currentBuild: 7,
|
||||
);
|
||||
|
||||
final result = await service.check();
|
||||
|
||||
expect(result, isA<ForceUpdate>());
|
||||
});
|
||||
|
||||
test('force flag wins over soft when both conditions are met', () async {
|
||||
final service = _buildService(
|
||||
rc: _FakeRemoteConfig(force: true, latest: 8),
|
||||
store: _FakeDismissedStore(),
|
||||
currentBuild: 7,
|
||||
);
|
||||
|
||||
final result = await service.check();
|
||||
|
||||
expect(result, isA<ForceUpdate>());
|
||||
});
|
||||
|
||||
test('returns NoUpdate when latest is dismissed', () async {
|
||||
final service = _buildService(
|
||||
rc: _FakeRemoteConfig(latest: 8),
|
||||
store: _FakeDismissedStore(value: 8),
|
||||
currentBuild: 7,
|
||||
);
|
||||
|
||||
final result = await service.check();
|
||||
|
||||
expect(result, isA<NoUpdate>());
|
||||
});
|
||||
|
||||
test('returns NoUpdate when dismissed value is greater than latest',
|
||||
() async {
|
||||
final service = _buildService(
|
||||
rc: _FakeRemoteConfig(latest: 8),
|
||||
store: _FakeDismissedStore(value: 10),
|
||||
currentBuild: 7,
|
||||
);
|
||||
|
||||
final result = await service.check();
|
||||
|
||||
expect(result, isA<NoUpdate>());
|
||||
});
|
||||
|
||||
test('returns SoftUpdate when latest is greater than dismissed', () async {
|
||||
final service = _buildService(
|
||||
rc: _FakeRemoteConfig(latest: 9),
|
||||
store: _FakeDismissedStore(value: 8),
|
||||
currentBuild: 7,
|
||||
);
|
||||
|
||||
final result = await service.check();
|
||||
|
||||
expect(result, isA<SoftUpdate>());
|
||||
});
|
||||
|
||||
test('dismiss persistence does NOT block force update', () async {
|
||||
final service = _buildService(
|
||||
rc: _FakeRemoteConfig(force: true),
|
||||
store: _FakeDismissedStore(value: 9999),
|
||||
currentBuild: 7,
|
||||
);
|
||||
|
||||
final result = await service.check();
|
||||
|
||||
expect(result, isA<ForceUpdate>());
|
||||
});
|
||||
|
||||
test('uses iOS store URL when isIos is true', () async {
|
||||
final service = _buildService(
|
||||
rc: _FakeRemoteConfig(latest: 8, iosUrl: 'apple', androidUrl: 'play'),
|
||||
store: _FakeDismissedStore(),
|
||||
currentBuild: 7,
|
||||
isIos: true,
|
||||
);
|
||||
|
||||
final result = await service.check() as SoftUpdate;
|
||||
|
||||
expect(result.storeUrl, 'apple');
|
||||
});
|
||||
|
||||
test('uses Android store URL when isIos is false', () async {
|
||||
final service = _buildService(
|
||||
rc: _FakeRemoteConfig(latest: 8, iosUrl: 'apple', androidUrl: 'play'),
|
||||
store: _FakeDismissedStore(),
|
||||
currentBuild: 7,
|
||||
isIos: false,
|
||||
);
|
||||
|
||||
final result = await service.check() as SoftUpdate;
|
||||
|
||||
expect(result.storeUrl, 'play');
|
||||
});
|
||||
|
||||
test('returns NoUpdate when fetchAndActivate throws', () async {
|
||||
final service = _buildService(
|
||||
rc: _FakeRemoteConfig(fetchThrows: true, latest: 8),
|
||||
store: _FakeDismissedStore(),
|
||||
currentBuild: 7,
|
||||
);
|
||||
|
||||
final result = await service.check();
|
||||
|
||||
expect(result, isA<SoftUpdate>(),
|
||||
reason:
|
||||
'fetch failure should fall back to cached values, not return none');
|
||||
});
|
||||
|
||||
test('check still calls fetchAndActivate', () async {
|
||||
final rc = _FakeRemoteConfig();
|
||||
final service = _buildService(
|
||||
rc: rc,
|
||||
store: _FakeDismissedStore(),
|
||||
currentBuild: 7,
|
||||
);
|
||||
|
||||
await service.check();
|
||||
|
||||
expect(rc.fetchCallCount, 1);
|
||||
});
|
||||
|
||||
test('returns NoUpdate when current build loader throws', () async {
|
||||
final service = AppVersionCheckService(
|
||||
remoteConfig: _FakeRemoteConfig(),
|
||||
dismissedStore: _FakeDismissedStore(),
|
||||
currentBuildLoader: () async => throw Exception('package_info crash'),
|
||||
);
|
||||
|
||||
final result = await service.check();
|
||||
|
||||
expect(result, isA<NoUpdate>());
|
||||
});
|
||||
});
|
||||
|
||||
group('AppVersionCheckService.markSoftDismissed', () {
|
||||
test('writes the latest build to the dismiss store', () async {
|
||||
final store = _FakeDismissedStore();
|
||||
final service = _buildService(
|
||||
rc: _FakeRemoteConfig(),
|
||||
store: store,
|
||||
currentBuild: 7,
|
||||
);
|
||||
|
||||
await service.markSoftDismissed(8);
|
||||
|
||||
expect(store.value, 8);
|
||||
expect(store.writeCallCount, 1);
|
||||
});
|
||||
|
||||
test('swallows write errors silently', () async {
|
||||
final store = _FakeDismissedStore(writeThrows: true);
|
||||
final service = _buildService(
|
||||
rc: _FakeRemoteConfig(),
|
||||
store: store,
|
||||
currentBuild: 7,
|
||||
);
|
||||
|
||||
await expectLater(service.markSoftDismissed(8), completes);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -71,10 +71,12 @@ dependencies:
|
||||
l10n_countries: ^1.3.1
|
||||
sealed_countries: ^2.8.0
|
||||
country_code_picker: ^3.4.1
|
||||
phone_numbers_parser: ^9.0.3
|
||||
|
||||
# ---------------- Utilities ----------------
|
||||
uuid: ^4.5.3
|
||||
plugin_platform_interface: ^2.0.2
|
||||
package_info_plus: ^8.3.1
|
||||
|
||||
dev_dependencies:
|
||||
# ---------------- Linting ----------------
|
||||
|
||||
201
docs/amplitude-integration-plan.md
Normal file
201
docs/amplitude-integration-plan.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Amplitude — Plan de Integración para SaveFamily
|
||||
|
||||
## Por qué Amplitude
|
||||
|
||||
- **Analytics a nivel de usuario individual** — ver qué hizo "Juan Pérez" y cuándo
|
||||
- **Free tier de 50M eventos/mes** — más que suficiente para la escala actual
|
||||
- **Complementa Firebase Analytics** — Firebase para métricas agregadas, Amplitude para análisis de usuarios individuales
|
||||
- **Integración mínima** — la arquitectura multi-client de `sf_tracking` permite añadir Amplitude como un segundo client sin tocar ningún módulo
|
||||
|
||||
---
|
||||
|
||||
## 10 Ejemplos de lo que podríamos obtener
|
||||
|
||||
### 1. Timeline de usuario individual
|
||||
> "El usuario testapps.savefamily@gmail.com hoy a las 10:32 hizo login, fue a ubicación, cambió de dispositivo al SENIOR SF, abrió configuración de alarmas, creó una alarma, volvió al panel, y cerró la app a las 10:45"
|
||||
|
||||
Ver cronológicamente cada acción de un usuario específico. Útil para soporte: cuando un usuario reporta un problema, ves exactamente qué hizo.
|
||||
|
||||
### 2. Segmentación por tipo de dispositivo
|
||||
> "Los usuarios con relojes Android (sistema=android) usan la app 3x más que los de RTOS. Los de Android visitan 'Uso de apps' y 'Videollamada', los de RTOS ni los ven"
|
||||
|
||||
Segmentar usuarios por las capabilities de sus dispositivos para priorizar features.
|
||||
|
||||
### 3. Feature adoption por usuario
|
||||
> "De los 500 usuarios activos, solo 23 han usado 'Hacer amigos', 180 usan 'Historial de llamadas', y 45 han configurado geofences"
|
||||
|
||||
Saber qué features se usan realmente y cuáles no, a nivel de usuario individual — puedes contactar a los que no usan cierta feature para entender por qué.
|
||||
|
||||
### 4. Funnel de onboarding con nombres
|
||||
> "De 50 registros esta semana, 48 completaron el paso de datos personales, 40 escanearon la montre, 35 crearon el perfil del niño, pero solo 12 hicieron el primer depósito. Estos son los 23 que abandonaron en el depósito: [lista de emails]"
|
||||
|
||||
Identificar exactamente quién abandona en cada paso y poder contactarles.
|
||||
|
||||
### 5. Usuarios con problemas recurrentes
|
||||
> "El usuario maria.garcia@gmail.com ha visto la pantalla de error de conexión 15 veces esta semana. Su dispositivo 9024387350 está siempre desconectado"
|
||||
|
||||
Detectar patrones de error por usuario para soporte proactivo.
|
||||
|
||||
### 6. Frecuencia de uso por padre
|
||||
> "Los padres que configuran 'argent de poche automatique' abren la app 2 veces/semana. Los que no lo configuran abren 5 veces/semana (para hacer depósitos manuales). Estos son los 200 usuarios sin auto-allowance configurada"
|
||||
|
||||
Entender cómo las features afectan el engagement y segmentar para comunicaciones.
|
||||
|
||||
### 7. Retención por cohorte de signup
|
||||
> "Los usuarios que se registraron en marzo retienen un 60% al mes 2. Los de abril un 45%. ¿Qué cambió? Los de marzo pasaron por el onboarding con el nuevo flujo de 3 pasos"
|
||||
|
||||
Medir impacto de cambios en la app por cohorte temporal.
|
||||
|
||||
### 8. Uso del mapa de localización
|
||||
> "El 70% de los usuarios activos visita el mapa diariamente. El 30% usa historial de posiciones. Solo el 5% crea geofences. Los que crean geofences tienen una retención del 85% vs 50% de los que no"
|
||||
|
||||
Correlacionar uso de features con retención.
|
||||
|
||||
### 9. Dispositivos múltiples por usuario
|
||||
> "Los usuarios con 3+ dispositivos representan el 15% de la base pero generan el 40% de las sesiones. Su patrón: revisan ubicación de cada dispositivo secuencialmente cada mañana"
|
||||
|
||||
Entender el comportamiento de power users.
|
||||
|
||||
### 10. Impacto de actualizaciones
|
||||
> "Después del update 1.0.0+5 (fix de posición), los usuarios que antes tenían errores de 'posición desactualizada' dejaron de reportar. Confirmado: 0 usuarios ven la posición vieja vs 34 antes del fix"
|
||||
|
||||
Medir el impacto real de cada release en la experiencia del usuario.
|
||||
|
||||
---
|
||||
|
||||
## Plan de Integración Técnica
|
||||
|
||||
### Fase 1: Cuenta y SDK (15 min)
|
||||
|
||||
1. Crear cuenta en [amplitude.com](https://amplitude.com) (plan Starter gratuito)
|
||||
2. Crear proyecto "SaveFamily" → obtener **API Key**
|
||||
3. Añadir dependencia al package `sf_tracking`:
|
||||
|
||||
```yaml
|
||||
# packages/sf_tracking/pubspec.yaml
|
||||
dependencies:
|
||||
amplitude_flutter: ^4.x.x
|
||||
```
|
||||
|
||||
### Fase 2: Crear AmplitudeTrackingClient (30 min)
|
||||
|
||||
Crear `packages/sf_tracking/lib/src/clients/amplitude_tracking_client.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:amplitude_flutter/amplitude.dart';
|
||||
import 'package:sf_tracking/src/tracking_client.dart';
|
||||
|
||||
class AmplitudeTrackingClient implements TrackingClient {
|
||||
AmplitudeTrackingClient({required String apiKey}) {
|
||||
_amplitude = Amplitude.getInstance();
|
||||
_amplitude.init(apiKey);
|
||||
_amplitude.trackingSessionEvents(true);
|
||||
}
|
||||
|
||||
late final Amplitude _amplitude;
|
||||
|
||||
@override
|
||||
Future<void> setAnalyticsStatus({bool enabled = true}) async {
|
||||
_amplitude.setOptOut(!enabled);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setConsentStatus(bool hasConsent) async {
|
||||
// Amplitude respects opt-out via setOptOut
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setUserId(String? userId) async {
|
||||
if (userId != null) {
|
||||
_amplitude.setUserId(userId);
|
||||
} else {
|
||||
_amplitude.setUserId(null);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setUserProperty(String name, String value) async {
|
||||
final identify = Identify()..set(name, value);
|
||||
_amplitude.identify(identify);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> track(String name, [Map<String, Object>? parameters]) async {
|
||||
_amplitude.logEvent(name, eventProperties: parameters);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> trackScreenView(
|
||||
String screenName, [
|
||||
Map<String, Object>? parameters,
|
||||
]) async {
|
||||
_amplitude.logEvent(
|
||||
'screen_view',
|
||||
eventProperties: {'screen_name': screenName, ...?parameters},
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fase 3: Registrar el client (5 min)
|
||||
|
||||
En `apps/mobile_app/lib/core/init_app.dart`, añadir Amplitude junto a Firebase:
|
||||
|
||||
```dart
|
||||
final trackingClients = <TrackingClient>[
|
||||
FirebaseTrackingClient(consentAnalytics: true),
|
||||
AmplitudeTrackingClient(apiKey: 'TU_API_KEY'),
|
||||
if (kDebugMode) DebugTrackingClient(),
|
||||
];
|
||||
|
||||
final sfTracking = SfTrackingRepository(clients: trackingClients);
|
||||
```
|
||||
|
||||
**Eso es todo.** No hay que tocar ningún módulo, ningún mixin, ningún evento. Todos los eventos existentes (100+) se enviarán automáticamente a Amplitude gracias al patrón `_broadcast` del `SfTrackingRepository`.
|
||||
|
||||
### Fase 4: User Properties adicionales (opcional, 15 min)
|
||||
|
||||
Enriquecer el perfil de usuario en Amplitude con propiedades extra que ya tenemos:
|
||||
|
||||
```dart
|
||||
// En UserInfoTrackingListener o en el login flow
|
||||
await tracking.setUserProperty('device_count', devices.length.toString());
|
||||
await tracking.setUserProperty('primary_device_type', device.type);
|
||||
await tracking.setUserProperty('primary_device_system', device.capabilities?.system ?? 'unknown');
|
||||
await tracking.setUserProperty('app_version', packageInfo.version);
|
||||
await tracking.setUserProperty('app_build', packageInfo.buildNumber);
|
||||
```
|
||||
|
||||
### Fase 5: Verificar en dashboard (10 min)
|
||||
|
||||
1. Abrir la app, hacer login, navegar
|
||||
2. Ir a Amplitude dashboard → User Look-Up
|
||||
3. Buscar por userId → ver el timeline del usuario
|
||||
4. Verificar que los eventos aparecen con sus parámetros
|
||||
|
||||
---
|
||||
|
||||
## Resumen de cambios en código
|
||||
|
||||
| Archivo | Cambio |
|
||||
|---------|--------|
|
||||
| `packages/sf_tracking/pubspec.yaml` | Añadir `amplitude_flutter` |
|
||||
| `packages/sf_tracking/lib/src/clients/amplitude_tracking_client.dart` | **Nuevo** — implementación de TrackingClient |
|
||||
| `packages/sf_tracking/lib/sf_tracking.dart` | Export del nuevo client |
|
||||
| `apps/mobile_app/lib/core/init_app.dart` | Registrar AmplitudeTrackingClient en la lista de clients |
|
||||
|
||||
**4 archivos tocados, 0 módulos afectados.**
|
||||
|
||||
---
|
||||
|
||||
## Consideraciones
|
||||
|
||||
1. **API Key** — no hardcodear en código. Añadir al config JSON (`development.json`, `staging.json`, `production.json`) como `amplitudeApiKey` y leerlo via `String.fromEnvironment()`
|
||||
|
||||
2. **GDPR** — Amplitude respeta `setOptOut()`. El flujo de consent existente (`setConsentStatus`) ya se propaga a todos los clients
|
||||
|
||||
3. **Costes** — Plan Starter gratuito: 50M eventos/mes, retención de datos 1 año. Más que suficiente
|
||||
|
||||
4. **Datos existentes** — Solo se capturan datos desde la integración en adelante. No hay datos históricos
|
||||
|
||||
5. **Proyectos separados** — Crear un proyecto Amplitude para staging y otro para production (igual que Firebase)
|
||||
163
docs/chat-known-issues.md
Normal file
163
docs/chat-known-issues.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Chat — Known issues y deuda
|
||||
|
||||
Resumen actualizado al 2026-05-08 tras el refactor production-ready.
|
||||
|
||||
## Backend — bloqueantes coordinados
|
||||
|
||||
### 1. Push routing por chatId
|
||||
|
||||
**Estado:** pendiente coordinar con backend.
|
||||
|
||||
El backend usa la heurística `phone.endsWith(chatId)` para enrutar pushes. Eso
|
||||
no funciona con los chatIds del cliente, que son:
|
||||
|
||||
- `1to1_<userId>_<deviceIdentificator>` para conversación 1:1.
|
||||
- `family_<delegationId>` para grupo familiar.
|
||||
|
||||
**Síntoma:** los pushes de mensajes propios reconciliados por el otro extremo
|
||||
no llegan al teléfono.
|
||||
|
||||
**Acción:** pedir al backend que (a) acepte chatIds tipados, o (b) extraiga el
|
||||
`deviceIdentificator` del chatId 1:1 para el match.
|
||||
|
||||
### 2. `pushData.chatId` ausente
|
||||
|
||||
**Estado:** pendiente coordinar con backend.
|
||||
|
||||
El push de chat trae `pushData` sin `chatId` explícito. El handler del cliente
|
||||
(`apps/mobile_app/lib/core/notifications_init.dart`) ya tiene
|
||||
`TODO(push-chatid)` apuntando a esto: hoy si el deep-link no encuentra
|
||||
`chatId`, cae a la lista de chats en lugar de la conversación.
|
||||
|
||||
**Acción:** backend debe incluir `chatId` en el `pushData` de
|
||||
`CHAT_MESSAGE`.
|
||||
|
||||
### 3. Tope de tamaño y mime types
|
||||
|
||||
**Estado:** documentación pendiente.
|
||||
|
||||
El cliente reacciona a `413 Payload Too Large` y `415 Unsupported Media Type`
|
||||
mapeándolos a `ChatConversationErrorEvent.fileTooLarge` /
|
||||
`fileUnsupported`. Pero **no validamos antes de subir** porque no conocemos
|
||||
los límites del backend.
|
||||
|
||||
**Acción:** confirmar tamaño máximo (imagen/audio) y mime types soportados.
|
||||
Una vez confirmado, validar client-side antes del upload para evitar el round
|
||||
trip al servidor.
|
||||
|
||||
## Cliente — deuda conocida
|
||||
|
||||
### 4. Group fan-out: IDs por device
|
||||
|
||||
**Estado:** workaround productivo, deuda lógica.
|
||||
|
||||
Hoy `ChatSendService` envía un mensaje por device en el grupo familiar. El
|
||||
**primer device** usa el `messageId` del optimistic; los **demás** generan
|
||||
UUIDs nuevos. En backend quedan N mensajes con IDs distintos para un solo
|
||||
"send" lógico del usuario.
|
||||
|
||||
**Riesgo:** en retry, los UUIDs 2..N se regeneran, creando duplicados en
|
||||
backend. Si el backend no dedupe por contenido, el grupo familiar verá
|
||||
mensajes duplicados.
|
||||
|
||||
**Mitigación actual:** retry desde el optimistic mismo conserva el mensaje
|
||||
primario (que ya tenía un ID estable), pero los secundarios sí se duplican
|
||||
si el primer intento dejó algunos OK y otros KO.
|
||||
|
||||
**Solución correcta:** backend debe aceptar un `bulkSend` que toma un solo
|
||||
`messageId` lógico + lista de devices target. O el cliente persiste los IDs
|
||||
secundarios en el optimistic state para no regenerarlos.
|
||||
|
||||
### 5. `selectedDevice` snapshot al abrir el chat
|
||||
|
||||
**Estado:** mitigado, comportamiento aceptable.
|
||||
|
||||
`build()` del controller toma snapshot de `selectedDeviceProvider` en
|
||||
`ChatConversationKind.oneToOne`. Si el usuario cambia de dispositivo después
|
||||
de abrir el chat, los **mensajes ya cargados** quedan congelados (correcto:
|
||||
son la conversación con el device anterior).
|
||||
|
||||
**Ya arreglado:** los **envíos** sí re-leen `selectedDeviceProvider` justo
|
||||
antes de mandar, vía `ChatParticipantsService.resolveForSend`. Polling y
|
||||
WebSocket sub se mantienen contra el device snapshot.
|
||||
|
||||
**Caveat:** si cambia el device durante el chat, el polling no refleja
|
||||
mensajes del nuevo device hasta que el chat se reabra. Esperado.
|
||||
|
||||
### 6. Audio recording mínimo de 1 segundo
|
||||
|
||||
`ChatConversationConfig.minAudioRecordingMs = 1000`. Audios más cortos se
|
||||
descartan con un dialog "audio muy corto". Si querés cambiar el umbral,
|
||||
modificar el config — está centralizado.
|
||||
|
||||
### 7. WebSocket + polling híbrido
|
||||
|
||||
El controller mantiene **ambos**: WebSocket para reconciliación inmediata
|
||||
cuando llega evento, y polling cada 4s **solo cuando hay mensajes pendientes**
|
||||
(no `delivered`). Polling se apaga automáticamente al confirmar entrega o
|
||||
tras 3 errores consecutivos.
|
||||
|
||||
Esto es defensa en profundidad: si el WebSocket cae mientras hay un mensaje
|
||||
pendiente, el polling lo captura. Trade-off: ligeramente más tráfico cuando
|
||||
hay pendientes.
|
||||
|
||||
### 8. `ref.listen` en build de `ChatAudioPlayerController`
|
||||
|
||||
`chat_audio_player_controller.dart:31` usa `ref.listen<String?>` dentro de
|
||||
`build()`. Va contra la regla "no `ref.listen` en build de notifier", pero es
|
||||
caso justificado: necesita callback de transición (cuando otro audio empieza,
|
||||
pausar el actual). Con `ref.watch` puro no se puede observar transiciones.
|
||||
|
||||
Si la regla se vuelve más estricta, mover el listen a un método `start()` que
|
||||
la screen invoca explícitamente.
|
||||
|
||||
## Estructura post-refactor
|
||||
|
||||
```
|
||||
chat/
|
||||
├── core/
|
||||
│ ├── data/
|
||||
│ │ ├── datasource/
|
||||
│ │ │ └── chat_remote_datasource_impl.dart # Dio inyectado, no GetIt
|
||||
│ │ ├── models/
|
||||
│ │ ├── repositories/
|
||||
│ │ └── utils/chat_image_compressor.dart
|
||||
│ ├── domain/
|
||||
│ │ ├── entities/, enums/, repositories/
|
||||
│ │ └── services/ # ChatIdResolver, ChatPermissions, WatchEmojiCatalog
|
||||
│ └── providers/
|
||||
└── features/
|
||||
├── chat_conversation/
|
||||
│ ├── application/ # NUEVO — service layer
|
||||
│ │ ├── chat_send_service.dart # fan-out, retry, aggregate
|
||||
│ │ ├── chat_sync_service.dart # fetch, polling, WS
|
||||
│ │ ├── chat_participants_service.dart # 1to1 vs family
|
||||
│ │ ├── chat_permission_flow_service.dart # tracking + decision
|
||||
│ │ └── chat_media_cleanup_service.dart # orphan files
|
||||
│ ├── config/
|
||||
│ │ └── chat_conversation_config.dart # 8 magic numbers centralizados
|
||||
│ ├── presentation/
|
||||
│ │ ├── chat_conversation_screen.dart
|
||||
│ │ ├── providers/
|
||||
│ │ │ ├── chat_conversation_controller.dart # 622 LOC (-19%)
|
||||
│ │ │ ├── chat_audio_player_controller.dart
|
||||
│ │ │ └── chat_recorder_controller.dart
|
||||
│ │ └── widgets/
|
||||
│ │ ├── chat_bubble_shell.dart # NUEVO — shared shell+footer
|
||||
│ │ ├── chat_input_bar.dart # split en _Composer + _SendOrRecordAction
|
||||
│ │ ├── chat_message_bubble.dart # _TextBubble usa shell
|
||||
│ │ ├── chat_audio_bubble.dart # usa shell
|
||||
│ │ └── chat_image_bubble.dart # usa shell + cache existsSync
|
||||
│ └── chat_conversation_builder.dart
|
||||
└── chat_list/
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
- `test/core/data/repositories/chat_repository_impl_test.dart`
|
||||
- `test/core/domain/services/chat_id_resolver_test.dart`
|
||||
|
||||
**Pendiente añadir:**
|
||||
- Tests del controller (send happy path, send error, retry, polling lifecycle).
|
||||
- Tests de `ChatSendService` (fan-out aggregate, retry semantics).
|
||||
- Tests de `ChatPermissionFlowService` (tracking + decision).
|
||||
209
docs/chat-notifications-backend-coordination.md
Normal file
209
docs/chat-notifications-backend-coordination.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# Chat notifications — Backend coordination
|
||||
|
||||
Coordinación pendiente entre cliente Flutter y backend para que las
|
||||
notificaciones de chat (`CHAT_MESSAGE`) lleguen production-ready.
|
||||
|
||||
Última actualización: 2026-05-08.
|
||||
|
||||
## Contexto
|
||||
|
||||
El cliente está implementando una experiencia de notificaciones tipo
|
||||
WhatsApp/Telegram que decide en runtime si suprime, baja, o muestra una
|
||||
notificación según el contexto activo del usuario:
|
||||
|
||||
- App killed / background → notif sistema completa
|
||||
- App foreground en otra pantalla → snackbar in-app + badge
|
||||
- App foreground en lista de chats → solo badge
|
||||
- App foreground en la conversación del mensaje → silencio total
|
||||
|
||||
Para que esa experiencia funcione, el cliente necesita **control completo**
|
||||
sobre cuándo y cómo se muestra la notificación. Hoy no lo tiene porque el
|
||||
backend manda push hybrid y el SDK de FCM auto-dispara notif del sistema
|
||||
sin pedirnos permiso.
|
||||
|
||||
## Pedido 1 — `CHAT_MESSAGE` debe ser **data-only**
|
||||
|
||||
### Estado actual (problema)
|
||||
|
||||
El backend hoy manda push hybrid:
|
||||
|
||||
```json
|
||||
{
|
||||
"notification": {
|
||||
"title": "Nuevo mensaje",
|
||||
"body": "Nuevo mensaje de SW2 PLUS"
|
||||
},
|
||||
"data": {
|
||||
"command": "CHAT_MESSAGE",
|
||||
"chatId": "...",
|
||||
"messageId": "...",
|
||||
"deviceIdentificator": "8005953685",
|
||||
"senderId": "",
|
||||
"type": "legacy"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Cuando el SDK de FCM ve `notification` en el payload, dispara la notif del
|
||||
sistema **automáticamente** y sin pasar por nuestro handler. Resultado: el
|
||||
usuario ve la notif aunque esté mirando justo ese chat.
|
||||
|
||||
### Cambio pedido
|
||||
|
||||
Mandar **solo `data`** (sin `notification`):
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"command": "CHAT_MESSAGE",
|
||||
"chatId": "...",
|
||||
"messageId": "...",
|
||||
"deviceIdentificator": "8005953685",
|
||||
"senderId": "",
|
||||
"type": "legacy",
|
||||
"senderName": "SW2 PLUS"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Adicional pedido en el `data`:
|
||||
- `senderName` — string display del autor del mensaje. Para device es el
|
||||
`carrierName` del reloj (`SW2 PLUS`). Para humano es el nombre del usuario
|
||||
(`Ana García`). El cliente lo usa para construir el título/body de la
|
||||
notificación local con i18n correcta. Si no se incluye, el cliente
|
||||
fallbackea a `senderId` o `deviceIdentificator`.
|
||||
- `messageType` (opcional) — `text | image | audio | emoji`. Permite al
|
||||
cliente armar previews tipo "Ana: 📷 Imagen" sin tener que hacer fetch
|
||||
del mensaje completo. Si no se incluye, el cliente solo dice "nuevo
|
||||
mensaje".
|
||||
|
||||
### Configuración FCM HTTP v1 API
|
||||
|
||||
Al construir el message para FCM, mandar:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"token": "...",
|
||||
"data": { ... },
|
||||
"android": {
|
||||
"priority": "HIGH"
|
||||
},
|
||||
"apns": {
|
||||
"headers": {
|
||||
"apns-priority": "5",
|
||||
"apns-push-type": "alert"
|
||||
},
|
||||
"payload": {
|
||||
"aps": {
|
||||
"content-available": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`content-available: 1` en iOS dispara el background handler aunque la app
|
||||
esté killed. Verificar que no haya `alert` ni `sound` en `aps` (eso lo
|
||||
convierte en hybrid).
|
||||
|
||||
### Por qué es importante
|
||||
|
||||
- Sin esto, **no se puede suprimir la notif** cuando el usuario ya está
|
||||
viendo la conversación. El SDK la dispara antes de que el cliente tenga
|
||||
oportunidad de decidir.
|
||||
- El title/body se arman client-side con i18n del idioma del usuario.
|
||||
Hoy backend manda solo "Nuevo mensaje de X" en español, los usuarios en
|
||||
otros idiomas verán la notif en español aunque el resto del app esté en
|
||||
otro idioma.
|
||||
|
||||
### Riesgo si no se cambia
|
||||
|
||||
Mantener hybrid es shippable pero la UX queda comprometida — el usuario
|
||||
siempre ve la notif del sistema, también cuando está leyendo el chat. Es
|
||||
ruidoso e inconsistente con apps comparables.
|
||||
|
||||
## Pedido 2 — WebSocket no debe duplicar `chat-message-received`
|
||||
|
||||
### Estado actual (problema)
|
||||
|
||||
En logs del cliente, cuando llega un mensaje nuevo del reloj, el evento
|
||||
WebSocket `chat-message-received` llega **dos veces** seguidas con el mismo
|
||||
`messageId`:
|
||||
|
||||
```
|
||||
[WebSocket] Message: {"type":"chat-message-received","deviceIdentificator":"8005953685","messageId":"95f0cbc3-...","senderId":"","chatId":"p_testapps_savefamily_gmail_com"}
|
||||
[WebSocket] Message: {"type":"chat-message-received","deviceIdentificator":"8005953685","messageId":"95f0cbc3-...","senderId":"","chatId":"p_testapps_savefamily_gmail_com"}
|
||||
```
|
||||
|
||||
Mismo timestamp aproximado, mismo payload exacto. Indistinguibles.
|
||||
|
||||
### Investigación pedida
|
||||
|
||||
¿El servidor está emitiendo el evento dos veces, o el cliente está
|
||||
suscribiéndose dos veces al mismo stream?
|
||||
|
||||
El cliente va a hacer su propia investigación (multi-listener en
|
||||
`WebSocketService` o subscripción duplicada en el chat controller). Pero si
|
||||
el server lo emite dos veces, hay que arreglar de tu lado.
|
||||
|
||||
### Cambio pedido (si server-side)
|
||||
|
||||
Emitir un solo evento `chat-message-received` por mensaje. Si hay un caso
|
||||
legítimo donde se emite varias veces (ej: re-broadcast tras reconexión),
|
||||
deduplicar por `messageId` antes de emitir.
|
||||
|
||||
### Mitigación cliente independiente
|
||||
|
||||
El cliente va a deduplicar por `messageId` de todas formas (porque también
|
||||
puede llegar el mismo mensaje por FCM + WS + polling concurrentemente).
|
||||
Esto es fix de robustez, no reemplaza el ask al server.
|
||||
|
||||
## Convenciones del cliente — `(senderId, chatId)`
|
||||
|
||||
El cliente identifica el chat al que pertenece un mensaje según la matriz:
|
||||
|
||||
| `senderId` | `chatId` | Tipo | Origen | Cliente arma chat ID |
|
||||
|---|---|---|---|---|
|
||||
| null/`""` | null/`""` | grupal | reloj | `family_<delegationId>` |
|
||||
| null/`""` | con valor | 1:1 | reloj | `1to1_<userId>_<deviceIdentificator>` |
|
||||
| con valor | null/`""` | grupal | humano | `family_<delegationId>` |
|
||||
| con valor | con valor | 1:1 | humano | `1to1_<userId>_<deviceIdentificator>` |
|
||||
|
||||
**Reglas:**
|
||||
- `chatId` discrimina 1:1 vs grupal (presente vs ausente).
|
||||
- `senderId` discrimina device vs humano (ausente = device, presente = humano).
|
||||
- El `chatId` del payload (cuando viene) NO es usado directamente como
|
||||
identificador — es solo señal de que el mensaje es 1:1.
|
||||
|
||||
Esto está documentado en `chat/lib/src/core/domain/incoming_chat_resolver.dart`
|
||||
y aplicado en `ChatDeeplinkService`. Backend no necesita cambiar nada acá,
|
||||
es convención client-side.
|
||||
|
||||
## Convención adicional — `deviceIdentificator` siempre presente
|
||||
|
||||
El cliente depende de `deviceIdentificator` en el payload (push y WS) para:
|
||||
- Construir el `1to1_<userId>_<deviceIdentificator>` cuando aplica.
|
||||
- Cambiar el `selectedDeviceProvider` automáticamente cuando el usuario
|
||||
tiene varios relojes asociados y el push viene de uno distinto al
|
||||
actualmente seleccionado.
|
||||
|
||||
**Garantía pedida:** `deviceIdentificator` debe venir siempre, en push y en
|
||||
WS, para todos los `command: CHAT_MESSAGE`. Si no viene, el cliente cae a
|
||||
fallback (chat list) y la UX se degrada.
|
||||
|
||||
## Tabla resumen de pedidos
|
||||
|
||||
| # | Pedido | Severidad | Esfuerzo BE estimado |
|
||||
|---|---|---|---|
|
||||
| 1 | `CHAT_MESSAGE` data-only | Alta — UX final depende | bajo (cambio de payload) |
|
||||
| 1.a | `senderName` en `data` | Alta — i18n y display | bajo |
|
||||
| 1.b | `messageType` en `data` | Media — preview en notif | bajo |
|
||||
| 2 | Investigar/dedup WS duplicado | Media — robustez | medio (depende del flow del server) |
|
||||
| 3 | Garantizar `deviceIdentificator` siempre | Alta — multi-device routing | bajo (validar) |
|
||||
|
||||
## Contacto
|
||||
|
||||
Cliente: equipo Flutter SaveFamily.
|
||||
Documento mantenido por: equipo de chat (refactor en `feature/videocall-sdk-integration`).
|
||||
164
docs/location-module-review.md
Normal file
164
docs/location-module-review.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Location Module — Code Review
|
||||
|
||||
## Resumen Ejecutivo
|
||||
|
||||
~6000 líneas de Dart. Arquitectura limpia pero con **3 bugs críticos** y varias oportunidades de mejora.
|
||||
|
||||
---
|
||||
|
||||
## Bugs Críticos (HIGH)
|
||||
|
||||
### 1. Crash por array out of bounds en el playback del historial
|
||||
**Archivo:** `location_map.dart:753`
|
||||
```dart
|
||||
currentPosition: widget.positionHistory[mapState.historyNavigationIndex]
|
||||
```
|
||||
Si el historial se limpia mientras el playback está activo, `historyNavigationIndex` puede apuntar fuera del array → crash.
|
||||
|
||||
### 2. El historial NO se limpia al cambiar de dispositivo
|
||||
**Archivo:** `location_map.dart:126-133`
|
||||
Cuando el usuario cambia de dispositivo, la ruta histórica del dispositivo anterior sigue visible en el mapa. Confusión garantizada.
|
||||
|
||||
### 3. Rebuilds excesivos de marcadores con historial grande
|
||||
**Archivo:** `location_map.dart:796-882`
|
||||
`_buildMarkers()` se llama en cada build. Con 1000+ posiciones de historial, recrea la lista completa de markers cada vez → UI laggy/janky.
|
||||
|
||||
---
|
||||
|
||||
## Bugs Medios (MEDIUM)
|
||||
|
||||
### 4. Race condition en animación de historial
|
||||
**Archivo:** `location_map.dart:141-152`
|
||||
Si se carga nuevo historial mientras la animación sigue, pueden haber conflictos de estado. No hay guard contra animaciones concurrentes.
|
||||
|
||||
### 5. Memory leak con timers
|
||||
**Archivo:** `location_map.dart:99-109`
|
||||
`_followTimer` se recrea en `didUpdateWidget` sin garantías de que el callback anterior terminó su ejecución.
|
||||
|
||||
### 6. Errores silenciosos en carga inicial
|
||||
**Archivo:** `location_controller.dart:31-43`
|
||||
Si geofences Y frequent places fallan al cargar, el catch-all devuelve estado vacío sin notificar al usuario.
|
||||
|
||||
### 7. Paginación de historial sin límite de memoria
|
||||
**Archivo:** `location_remote_datasource_impl.dart:128-150`
|
||||
`pageSize=1000` con loop hasta `totalPages`. Datasets enormes pueden causar OOM al acumular todas las posiciones en memoria.
|
||||
|
||||
### 8. Cast inseguro en markers
|
||||
**Archivo:** `location_map.dart:812`
|
||||
```dart
|
||||
(historyLayer.build(context) as MarkerLayer).markers
|
||||
```
|
||||
Puede crashear si cambia el tipo de retorno de `RouteHistoryLayer.build()`.
|
||||
|
||||
### 9. Clustering O(n²)
|
||||
**Archivo:** `route_history_layer.dart:160-182`
|
||||
Algoritmo de clustering compara cada punto con todos los demás. Lento para datasets grandes.
|
||||
|
||||
---
|
||||
|
||||
## Bugs Menores (LOW)
|
||||
|
||||
### 10. Filtro de coordenadas (0,0)
|
||||
**Archivo:** `location_remote_datasource_impl.dart:152-154`
|
||||
`latitude != 0 || longitude != 0` podría filtrar posiciones válidas cerca del ecuador/meridiano de Greenwich.
|
||||
|
||||
### 11. Sin backoff en polling
|
||||
**Archivo:** `location_map.dart:103-108`
|
||||
Timer sigue disparando cada N segundos aunque el dispositivo esté desconectado. Solo hace `return` temprano, sin reducir frecuencia.
|
||||
|
||||
### 12. Tracking unawaited sin error handling
|
||||
Las llamadas de analytics (`unawaited(tracking.event())`) no manejan errores. Difícil de debuggear si el tracking falla silenciosamente.
|
||||
|
||||
---
|
||||
|
||||
## Lo que funciona bien
|
||||
|
||||
- **CRUD completo** de geofences y frequent places con buen manejo de 404
|
||||
- **Tests sólidos**: 577 + 312 líneas cubriendo todos los CRUD y transiciones de estado
|
||||
- **Animaciones** del mapa (reveal, playback, move) bien implementadas
|
||||
- **Separación de controllers**: LocationController (datos) vs LocationMapController (UI del mapa)
|
||||
- **Error propagation** con enums tipados y keys i18n para cada tipo de error
|
||||
- **Tracking analytics** exhaustivo en todas las acciones del usuario
|
||||
- **Paginación** en historial de posiciones (loop hasta totalPages)
|
||||
- **orderBy correcto** en historial (positionDate ASC) y posiciones actuales (positionDate DESC)
|
||||
|
||||
---
|
||||
|
||||
## Lo que falta
|
||||
|
||||
- **Sin WebSocket** para posiciones en tiempo real (solo HTTP polling cada 60s+)
|
||||
- **Sin widget tests** ni integration tests
|
||||
- **Sin tests de performance** con datasets grandes
|
||||
- **Sin tests de concurrencia** (CRUD simultáneo, device switch durante playback)
|
||||
- **LocationMap tiene 1063 líneas** — debería dividirse en subwidgets
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
API (SaveFamilyRepository)
|
||||
→ LocationRemoteDatasource (HTTP calls)
|
||||
→ LocationRepository (error mapping, 404 handling)
|
||||
→ LocationController (state management, business logic)
|
||||
→ LocationScreen → LocationMap → Widgets
|
||||
```
|
||||
|
||||
### State Management
|
||||
- **LocationController** (keepAlive: true) — geofences, frequent places, position history, CRUD operations
|
||||
- **LocationMapController** — UI state: placing mode, selections, animations, playback, reveal
|
||||
- **LocationListFilterController** — simple enum filter for position history type
|
||||
|
||||
### Map Layers (bottom to top)
|
||||
1. TileLayer (estilo del mapa)
|
||||
2. CircleLayer (geofences activos)
|
||||
3. PolylineLayer (ruta del historial)
|
||||
4. CircleLayer (preview de geofence durante creación)
|
||||
5. MarkerLayer (dispositivo, geofences, frequent places, posiciones del historial)
|
||||
|
||||
---
|
||||
|
||||
## Fixes recomendados por prioridad
|
||||
|
||||
### Prioridad 1 — Prevenir crashes
|
||||
1. **Bounds check en playback** — Verificar `historyNavigationIndex < positionHistory.length` antes de acceder
|
||||
2. **Limpiar historial al cambiar dispositivo** — Llamar `clearPositionHistory()` en device switch
|
||||
|
||||
### Prioridad 2 — Mejorar rendimiento
|
||||
3. **Memoizar markers** — Cache del resultado de `_buildMarkers()`, solo recalcular cuando cambian los datos
|
||||
4. **Limitar posiciones en memoria** — Cap de posiciones o streaming en lugar de acumular todo
|
||||
|
||||
### Prioridad 3 — Mejorar robustez
|
||||
5. **Guard de animación concurrente** — Flag `_isAnimating` para prevenir inicio de nueva animación mientras otra corre
|
||||
6. **Mostrar error si carga inicial falla** — Propagar error al usuario en lugar de silenciar
|
||||
|
||||
### Prioridad 4 — Mejorar UX
|
||||
7. **WebSocket para posiciones** — Reemplazar polling HTTP por push via WebSocket
|
||||
8. **Dividir LocationMap** — Extraer subwidgets para controles, markers, info cards
|
||||
|
||||
---
|
||||
|
||||
## Archivos del módulo
|
||||
|
||||
### Core (Data Layer)
|
||||
- `src/core/data/datasource/location_remote_datasource.dart` — interfaz abstracta
|
||||
- `src/core/data/datasource/location_remote_datasource_impl.dart` — 157 líneas, HTTP calls
|
||||
- `src/core/data/repositories/location_repository_impl.dart` — 102 líneas, error mapping
|
||||
- `src/core/domain/entities/geofence_entity.dart` — Freezed entity
|
||||
- `src/core/domain/entities/frequent_place_entity.dart` — Freezed entity
|
||||
- `src/core/data/models/` — 14 DTOs con json_serializable
|
||||
- `src/core/providers/` — 2 providers de DI
|
||||
|
||||
### Presentation (Feature Layer)
|
||||
- `location_controller.dart` — 431 líneas, business logic principal
|
||||
- `location_map_controller.dart` — 328 líneas, state machine del mapa
|
||||
- `location_state.dart` — 66 líneas, Freezed state
|
||||
- `location_map_state.dart` — 37 líneas, Freezed UI state
|
||||
- `location_screen.dart` — 82 líneas, entry point
|
||||
- `location_map.dart` — **1063 líneas**, widget principal del mapa
|
||||
- `widgets/` — 8 controles de mapa + 4 info cards + 4 widgets de soporte
|
||||
|
||||
### Tests
|
||||
- `location_controller_test.dart` — 577 líneas, CRUD completo
|
||||
- `location_map_controller_test.dart` — 312 líneas, transiciones de estado
|
||||
204
docs/location-ui-endpoints-map.md
Normal file
204
docs/location-ui-endpoints-map.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Location — Mapa de UI, Botones y Endpoints
|
||||
|
||||
## Qué ve el usuario al entrar al tab de Localización
|
||||
|
||||
### Carga inicial (automática al entrar)
|
||||
|
||||
Al navegar al tab, se ejecutan **3 llamadas HTTP en paralelo**:
|
||||
|
||||
| Endpoint | Método | Descripción |
|
||||
|----------|--------|-------------|
|
||||
| `GET /devices/{deviceUUID}/geofences` | GET | Carga las zonas de seguridad del dispositivo |
|
||||
| `GET /devices/{deviceUUID}/frequent-places` | GET | Carga los lugares frecuentes del dispositivo |
|
||||
| `GET /devices/identificator/{did}/positions?page=1&pageSize=5&orderBy=positionDate DESC` | GET | Carga las últimas 5 posiciones para mostrar el marcador actual |
|
||||
|
||||
Además se inicia un **timer de polling** que cada N segundos (frecuencia del dispositivo, mínimo 60s) vuelve a llamar al endpoint de posiciones.
|
||||
|
||||
---
|
||||
|
||||
## Controles sobre el mapa
|
||||
|
||||
### Esquina superior izquierda
|
||||
|
||||
#### 1. Selector de estilo de mapa (icono de capas)
|
||||
- **Acción:** Cambia el tile layer del mapa (Standard, Voyager, Light, Dark, Satellite)
|
||||
- **Endpoint:** Ninguno — solo cambia el URL del tile server localmente
|
||||
|
||||
#### 2. Selector de frecuencia GPS (icono de timer)
|
||||
- **Acción:** Al expandir, muestra las opciones de frecuencia (ej: 5min, 10min, 30min, 1h). Al seleccionar una:
|
||||
- **Endpoint:** Se ejecuta a través del `deviceSettingsUpdateProvider` que llama:
|
||||
- `PUT /devices/{deviceUUID}` — actualiza `settings.frequency` del dispositivo
|
||||
- Internamente envía un comando al dispositivo para cambiar su frecuencia de reporte
|
||||
|
||||
---
|
||||
|
||||
### Esquina superior derecha
|
||||
|
||||
#### 3. Botón de refrescar (icono ↻)
|
||||
- **Acción:** Fuerza una actualización de la posición del dispositivo
|
||||
- **Endpoint:**
|
||||
- `GET /devices/identificator/{did}/positions?page=1&pageSize=5&orderBy=positionDate DESC`
|
||||
|
||||
#### 4. Botón de ajustes GPS (icono ⚙️)
|
||||
Abre un diálogo modal con las siguientes opciones:
|
||||
|
||||
##### 4.1 "Lista" (Ver listas)
|
||||
- **Acción:** Abre un bottom sheet con 3 tabs: Geofences, Lugares frecuentes, Historial
|
||||
- **Endpoint:** Ninguno — usa los datos ya cargados en memoria
|
||||
|
||||
##### 4.2 "Añadir zona de seguridad"
|
||||
- **Acción:** Entra en modo de colocación. El usuario mueve el mapa y confirma. Luego ajusta el radio con un slider. Finalmente introduce nombre y descripción.
|
||||
- **Endpoint al confirmar:**
|
||||
- `POST /geofences` — body: `{ deviceId, name, description, latitude, longitude, radius, isActive: true }`
|
||||
- **Endpoint al editar (desde info card):**
|
||||
- `PUT /geofences/{geofenceId}` — body: `{ name, description, latitude, longitude, radius }`
|
||||
- **Endpoint al eliminar (desde info card o lista):**
|
||||
- `DELETE /geofences/{geofenceId}`
|
||||
|
||||
##### 4.3 "Añadir lugar frecuente"
|
||||
- **Acción:** Entra en modo de colocación. El usuario mueve el mapa y confirma. Introduce nombre.
|
||||
- **Endpoint al confirmar:**
|
||||
- `POST /frequent-places` — body: `{ deviceId, name, lat, lng }`
|
||||
- **Endpoint al editar:**
|
||||
- `PUT /frequent-places/{frequentPlaceId}` — body: `{ name, lat, lng, wifiList }`
|
||||
- **Endpoint al eliminar:**
|
||||
- `DELETE /frequent-places/{frequentPlaceId}`
|
||||
|
||||
##### 4.4 "Compartir ubicación"
|
||||
- **Acción:** Abre el share sheet nativo con la ubicación actual (nombre del dispositivo + dirección + link de Google Maps)
|
||||
- **Endpoint:** Ninguno — usa la posición ya cargada
|
||||
|
||||
##### 4.5 "Centrar en dispositivo"
|
||||
- **Acción:** Anima el mapa hacia la última posición conocida del dispositivo
|
||||
- **Endpoint:** Ninguno — usa la posición ya cargada
|
||||
|
||||
##### 4.6 "Seguir dispositivo" (toggle)
|
||||
- **Acción:** Activa/desactiva el modo "seguir" — cuando llega una nueva posición, el mapa se centra automáticamente
|
||||
- **Endpoint:** Ninguno — solo cambia el comportamiento del timer de polling
|
||||
|
||||
##### 4.7 "Mostrar/ocultar zonas de seguridad" (toggle)
|
||||
- **Acción:** Muestra u oculta los círculos y marcadores de geofences en el mapa
|
||||
- **Endpoint:** Ninguno — solo cambia visibilidad en UI
|
||||
|
||||
##### 4.8 "Mostrar/ocultar lugares frecuentes" (toggle)
|
||||
- **Acción:** Muestra u oculta los marcadores de lugares frecuentes
|
||||
- **Endpoint:** Ninguno — solo cambia visibilidad en UI
|
||||
|
||||
##### 4.9 "Historial de posiciones"
|
||||
- **Acción:** Abre un date range picker. Al seleccionar rango de fechas:
|
||||
- **Endpoint:**
|
||||
- `GET /devices/identificator/{did}/positions?page=1&pageSize=1000&filters=[positionDate gte/lte]&orderBy=positionDate ASC`
|
||||
- Pagina automáticamente hasta obtener todas las posiciones del rango (loop hasta `totalPages`)
|
||||
|
||||
##### 4.10 "Mostrar/ocultar ruta del historial" (toggle)
|
||||
- **Acción:** Muestra u oculta la línea de ruta del historial cargado
|
||||
- **Endpoint:** Ninguno — solo cambia visibilidad en UI
|
||||
|
||||
---
|
||||
|
||||
### Panel de acciones expandible (esquina derecha, debajo del GPS)
|
||||
|
||||
Cuando el usuario pulsa el botón "⋮" (tres puntos) se expande un panel vertical con:
|
||||
|
||||
#### 5. Botón lista (icono 📋)
|
||||
- Mismo que 4.1 — abre bottom sheet con listas
|
||||
|
||||
#### 6. Botón añadir geofence (icono 📍+)
|
||||
- Mismo que 4.2 — entra en modo colocación de geofence
|
||||
|
||||
#### 7. Botón añadir lugar frecuente (icono 🏠+)
|
||||
- Mismo que 4.3 — entra en modo colocación de lugar frecuente
|
||||
|
||||
#### 8. Botón compartir (icono ↗)
|
||||
- Mismo que 4.4 — share sheet con ubicación
|
||||
|
||||
#### 9. Botón refrescar (icono ↻)
|
||||
- Mismo que 3 — fuerza refresh de posiciones
|
||||
|
||||
#### 10. Botón centrar (icono ⊕)
|
||||
- Mismo que 4.5 — centra en dispositivo
|
||||
|
||||
#### 11. Botón seguir (icono GPS)
|
||||
- Mismo que 4.6 — toggle de seguimiento
|
||||
|
||||
---
|
||||
|
||||
### Parte inferior del mapa
|
||||
|
||||
#### 12. Banner de dispositivo (swipeable)
|
||||
- **Acción:** Muestra el dispositivo seleccionado con nombre, batería, última posición. Se puede swipear para cambiar de dispositivo.
|
||||
- **Al cambiar de dispositivo:**
|
||||
- `GET /devices/identificator/{newDid}/positions?page=1&pageSize=5&orderBy=positionDate DESC` — carga posiciones del nuevo dispositivo
|
||||
- `GET /devices/{newDeviceUUID}/geofences` — carga geofences del nuevo dispositivo
|
||||
- `GET /devices/{newDeviceUUID}/frequent-places` — carga lugares frecuentes del nuevo dispositivo
|
||||
- Se reinicia el timer de polling con la frecuencia del nuevo dispositivo
|
||||
|
||||
#### 13. Botón centrar en dispositivo (icono ⊕, sobre el banner)
|
||||
- Solo aparece si no hay historial activo
|
||||
- Mismo que 4.5
|
||||
|
||||
---
|
||||
|
||||
### Controles especiales (aparecen según el modo)
|
||||
|
||||
#### Modo colocación (geofence o lugar frecuente)
|
||||
- **Banner superior:** "Mueve el mapa para elegir la ubicación" + botón Cancelar + botón Confirmar
|
||||
- **Centro de la pantalla:** Cruz/icono de marcador
|
||||
|
||||
#### Modo ajuste de radio (solo geofence)
|
||||
- **Slider inferior:** Ajusta el radio del círculo (se ve el preview en el mapa)
|
||||
- **Botones:** Cancelar / Confirmar
|
||||
|
||||
#### Modo historial — Animación reveal
|
||||
- **Barra de progreso inferior:** Muestra el progreso de la animación de revelado de posiciones
|
||||
- **Botón Skip:** Salta al final de la animación
|
||||
|
||||
#### Modo historial — Player de navegación
|
||||
- **Controles inferior:** ◀ Anterior | ▶/⏸ Play/Pause | Siguiente ▶
|
||||
- **Botón cerrar:** Sale del modo historial
|
||||
- **Play/Pause:** Auto-avanza cada 3 segundos por las posiciones del historial
|
||||
|
||||
---
|
||||
|
||||
### Info cards (aparecen al tocar un marcador)
|
||||
|
||||
#### Al tocar un marcador de geofence
|
||||
- Muestra: nombre, descripción, radio, coordenadas, estado (activa/inactiva)
|
||||
- **Botón Editar:** Entra en modo edición (reposicionar + ajustar radio + renombrar)
|
||||
- `PUT /geofences/{id}`
|
||||
- **Botón Eliminar:** Confirmación → elimina
|
||||
- `DELETE /geofences/{id}`
|
||||
|
||||
#### Al tocar un marcador de lugar frecuente
|
||||
- Muestra: nombre, coordenadas, redes WiFi
|
||||
- **Botón Editar:** Entra en modo edición (reposicionar + renombrar)
|
||||
- `PUT /frequent-places/{id}`
|
||||
- **Botón Eliminar:** Confirmación → elimina
|
||||
- `DELETE /frequent-places/{id}`
|
||||
|
||||
#### Al tocar un punto del historial
|
||||
- Muestra: fecha/hora, tipo de posición, dirección, coordenadas
|
||||
|
||||
---
|
||||
|
||||
## Resumen de endpoints
|
||||
|
||||
| Endpoint | Método | Cuándo se llama |
|
||||
|----------|--------|-----------------|
|
||||
| `GET /devices/{uuid}/geofences` | GET | Carga inicial + cambio de dispositivo |
|
||||
| `POST /geofences` | POST | Crear zona de seguridad |
|
||||
| `PUT /geofences/{id}` | PUT | Editar zona de seguridad |
|
||||
| `DELETE /geofences/{id}` | DELETE | Eliminar zona de seguridad |
|
||||
| `GET /devices/{uuid}/frequent-places` | GET | Carga inicial + cambio de dispositivo |
|
||||
| `POST /frequent-places` | POST | Crear lugar frecuente |
|
||||
| `PUT /frequent-places/{id}` | PUT | Editar lugar frecuente |
|
||||
| `DELETE /frequent-places/{id}` | DELETE | Eliminar lugar frecuente |
|
||||
| `GET /devices/identificator/{did}/positions` | GET | Posición actual (pageSize=5, DESC) + historial (pageSize=1000, ASC, con filtros de fecha) + polling periódico |
|
||||
| `PUT /devices/{uuid}` | PUT | Cambiar frecuencia GPS (via deviceSettingsUpdateProvider) |
|
||||
|
||||
## Polling automático
|
||||
|
||||
- **Frecuencia:** La que tenga configurada el dispositivo (mínimo 60 segundos)
|
||||
- **Endpoint:** `GET /devices/identificator/{did}/positions?page=1&pageSize=5&orderBy=positionDate DESC`
|
||||
- **Condición:** No se ejecuta si el dispositivo está desconectado
|
||||
- **Se reinicia:** Al cambiar de dispositivo o al cambiar la frecuencia manualmente
|
||||
130
docs/videocall-callkit-migration.md
Normal file
130
docs/videocall-callkit-migration.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Videocall — Migración a CallKit / Notification.CallStyle nativo
|
||||
|
||||
## Contexto
|
||||
|
||||
Hoy (2026-05-08) las videollamadas entrantes con la app killed se manejan via
|
||||
`flutter_local_notifications` con `fullScreenIntent: true` y
|
||||
`AndroidNotificationCategory.call`. El usuario ve dos botones en la
|
||||
notificación (Aceptar / Rechazar) construidos como
|
||||
`AndroidNotificationAction`. Ver `apps/mobile_app/lib/core/notifications_init.dart`.
|
||||
|
||||
## Problema conocido
|
||||
|
||||
**Doble tap para aceptar.**
|
||||
|
||||
Cuando el usuario toca "Aceptar" en la notificación, el flujo es:
|
||||
|
||||
```
|
||||
notif "Aceptar" → app abre → IncomingView (otro Aceptar) → llamada
|
||||
tap 1 tap 2
|
||||
```
|
||||
|
||||
WhatsApp / Telegram / Zoom entran **directo** a la llamada activa con un solo
|
||||
tap. Eso es lo esperado por el usuario.
|
||||
|
||||
La causa estructural: `flutter_local_notifications` solo nos pasa el
|
||||
`actionId` al handler. No hay integración con la API de telefonía del sistema
|
||||
operativo. Para tener tap único nativo necesitamos:
|
||||
|
||||
- **iOS:** CallKit + PushKit + VoIP cert (cert es el bloqueador hoy).
|
||||
- **Android:** `Notification.CallStyle` (API 31+) + `Telecom`/`ConnectionService`.
|
||||
|
||||
## Opciones evaluadas
|
||||
|
||||
| Solución | iOS | Android | Costo | Doble-tap |
|
||||
|---|---|---|---|---|
|
||||
| `flutter_local_notifications` (actual) | banner básico | full-screen intent OK | hecho | sí |
|
||||
| `flutter_local_notifications` + flag `autoAnswer` | banner + auto answer | full-screen + auto answer | ~20 LOC | no |
|
||||
| `flutter_callkit_incoming` plugin | CallKit nativo | `CallStyle` nativo | ~3–4 días + VoIP cert | no |
|
||||
| Bare metal (Swift+CallKit / Kotlin+ConnectionService) | máximo control | máximo control | varios sprints | no |
|
||||
|
||||
## Bloqueador actual
|
||||
|
||||
**VoIP cert de Apple no obtenido.** Sin él, no hay PushKit / CallKit en iOS,
|
||||
y por lo tanto la migración a `flutter_callkit_incoming` no entrega valor en
|
||||
iOS. Movernos solo en Android dejaría una asimetría en la UX entre plataformas.
|
||||
|
||||
Account Holder Apple Developer: Jorge Alvarez (Team `KQ73NP6L4Q`). El cert se
|
||||
gestiona desde su cuenta.
|
||||
|
||||
## Plan recomendado
|
||||
|
||||
### Fase 1 — mientras no haya VoIP cert (hoy)
|
||||
|
||||
**No implementar ahora.** Conscientemente decidimos vivir con el doble-tap
|
||||
hasta tener el cert. Si la presión de UX aumenta antes de obtener el cert,
|
||||
implementar el flag `autoAnswer` como mitigación temporal:
|
||||
|
||||
- Añadir campo `autoAnswer: bool` a `VideocallIncomingArgs`.
|
||||
- En `notifications_init._onLocalNotificationTapped`, distinguir
|
||||
`actionId == actionAccept` (autoAnswer=true) vs body tap (autoAnswer=false).
|
||||
- En `videocall_controller._consumePendingIncoming`, si `autoAnswer`,
|
||||
llamar `answerCall()` automáticamente después de
|
||||
`presentIncomingChannelCall(...)`.
|
||||
|
||||
Caveat: durante el SDK login (~1–3s) el usuario verá brevemente la
|
||||
`IncomingView` con el ringtone antes de entrar a la llamada activa. Para una
|
||||
v1 está OK. Reemplazable por un loading "Conectando…".
|
||||
|
||||
### Fase 2 — cuando llegue VoIP cert
|
||||
|
||||
Migrar a [`flutter_callkit_incoming`](https://pub.dev/packages/flutter_callkit_incoming).
|
||||
|
||||
**iOS:**
|
||||
|
||||
1. Generar VoIP cert en Apple Developer (Account Holder).
|
||||
2. Subir cert al backend (FCM no envía VoIP pushes; necesitamos APNs VoIP
|
||||
directo o un proxy en el backend).
|
||||
3. Configurar PushKit en `AppDelegate.swift`.
|
||||
4. Implementar `CXProviderDelegate` callbacks que disparan
|
||||
`joinChannel` (Juphoon) cuando CallKit reporta accept.
|
||||
|
||||
**Android:**
|
||||
|
||||
1. Permiso `MANAGE_OWN_CALLS` en `AndroidManifest.xml`.
|
||||
2. Foreground service para mantener la llamada en background.
|
||||
3. `flutter_callkit_incoming` ya wrappea `Notification.CallStyle`
|
||||
internamente — solo configurar.
|
||||
|
||||
**Flutter:**
|
||||
|
||||
1. Reemplazar `_showIncomingCallNotification(data)` en
|
||||
`notifications_init.dart` por
|
||||
`FlutterCallkitIncoming.showCallkitIncoming(params)`.
|
||||
2. Suscribirse a `FlutterCallkitIncoming.onEvent` para manejar
|
||||
`actionCallAccept` / `actionCallDecline` / `actionCallEnded`.
|
||||
3. En `actionCallAccept` callback, leer roomNumber/sessionId del payload y
|
||||
llamar al `videocallController.presentIncomingChannelCall(...)` +
|
||||
`answerCall()` directo.
|
||||
4. Eliminar el flag `autoAnswer` si se hubiera implementado en Fase 1.
|
||||
|
||||
**Backend:**
|
||||
|
||||
- La push payload de VIDEO_CALL_FROM puede mantenerse igual (los campos
|
||||
`roomNumber`, `appAccount`, `sessionId`, `chatType` siguen siendo los que
|
||||
usa el handler).
|
||||
- Para iOS: el backend necesita enviar push VoIP via APNs (canal aparte de
|
||||
FCM). Esto es trabajo coordinado con backend.
|
||||
|
||||
## Estimación
|
||||
|
||||
| Item | Esfuerzo |
|
||||
|---|---|
|
||||
| Obtener VoIP cert (gestión Apple) | semanas (no nuestro) |
|
||||
| iOS CallKit + PushKit nativo | 1–2 días |
|
||||
| Android `CallStyle` (via plugin) | 1 día |
|
||||
| Backend APNs VoIP push | depende backend |
|
||||
| Flutter wiring + cleanup | 1 día |
|
||||
| QA cross-platform | 1–2 días |
|
||||
| **Total dev** | **~4–6 días** una vez desbloqueado el cert |
|
||||
|
||||
## Referencias
|
||||
|
||||
- [flutter_callkit_incoming pub.dev](https://pub.dev/packages/flutter_callkit_incoming)
|
||||
- [Apple — Reporting Incoming Calls with CallKit](https://developer.apple.com/documentation/callkit)
|
||||
- [Android Notification.CallStyle](https://developer.android.com/reference/android/app/Notification.CallStyle)
|
||||
- TODO en código: `apps/mobile_app/lib/core/notifications_init.dart` —
|
||||
buscar `TODO(videocall-callkit-migration)`.
|
||||
- Limitación iOS ya documentada en
|
||||
`apps/mobile_app/lib/core/notifications_init.dart` con
|
||||
`TODO(videocall-ios-callkit)`.
|
||||
46
docs/videocall-juphoon-android-fix.md
Normal file
46
docs/videocall-juphoon-android-fix.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Videocall — Implemented `JCMediaChannel.join` for Android Watches
|
||||
|
||||
Following your confirmation that Android watches require `JCMediaChannel.join` (not `JCCall.call`), we have refactored the mobile side. Summary of changes:
|
||||
|
||||
## 1. We now detect the watch type before starting a call
|
||||
|
||||
We use `device.capabilities.system` from our self-hosted server, which returns `"android"` or `"rtos"`. Based on that flag, the app picks one of two flows.
|
||||
|
||||
## 2. RTOS watches — flow unchanged
|
||||
|
||||
For RTOS watches we keep the original 1:1 P2P flow: `JCCall.call(userID: "w_<imei>", isVideo: true, callParam)`. This continues to work as before.
|
||||
|
||||
## 3. Android watches — new room-based flow
|
||||
|
||||
For Android watches, when the user taps the device card on the idle screen, the app now does the following sequence:
|
||||
|
||||
- Builds the room number using your TCP protocol convention: `<deviceIdentificator>_<sanitizedAppAccount>` (the same format the watch firmware uses when reporting `UPRYROOMCOUNT`).
|
||||
- Sends the `PRYVCALL` signaling to the watch via our backend (`VIDEO_CALL_REQUEST` with `chatType=0` for single chat, `chatType=1` for group).
|
||||
- Calls `JCMediaChannel.enableUploadAudioStream(true)` and `JCMediaChannel.enableUploadVideoStream(true)`.
|
||||
- Calls `JCMediaChannel.join(roomNumber, joinParam)`. The `joinParam` we use: `capacity=6`, `heartbeatTime=20`, `heartbeatTimeout=60`, `framerate=24`, `videoRatio=1.78`, `smooth=true`, `maxResolution=0`.
|
||||
- Listens for the `onJoin` callback for the local side and `onParticipantJoin` for the remote (watch) side.
|
||||
- When `onParticipantJoin` fires, calls `requestVideo(participant, PICTURESIZE_LARGE)` to request the watch's video stream and renders it.
|
||||
|
||||
## 4. Group calls — same room flow as Android 1:1, just with a different room number
|
||||
|
||||
For the group/family chat we use room number `<deviceIdentificator>_group`, also via `JCMediaChannel.join`. This was already working but now uses the same shared private method as the Android 1:1 flow, so behavior is consistent.
|
||||
|
||||
## 5. SDK initialization is unchanged
|
||||
|
||||
We still call `JCClient.create(appKey, ...)`, `login("p_<sanitizedEmail>", password)`, and on `_configureDevice` we still apply `MediaConfig.MODE_RTOS` for RTOS watches and `MediaConfig.MODE_INTELLIGENT_HARDWARE` for Android watches.
|
||||
|
||||
## 6. UI behavior
|
||||
|
||||
For both Android 1:1 and group calls, the app shows the channel/conference UI (`screenMode = groupCall`) which displays the local preview plus a grid of remote participants. For RTOS 1:1 we keep the previous P2P UI driven by `onCallItemUpdate(STATE_TALKING)`.
|
||||
|
||||
## Expected effect
|
||||
|
||||
The previous bug was that the mobile waited forever in `STATE_PENDING` because the watch was joining a room while the mobile was waiting for a P2P answer. With the new flow, when the user accepts on the watch and the watch joins room `<deviceIdentificator>_<appAccount>`, the mobile (already in the same room) receives `onParticipantJoin` with the watch as participant, which is the correct "call connected" event for room mode.
|
||||
|
||||
## Could you please confirm
|
||||
|
||||
- That the room number format `<deviceIdentificator>_<sanitizedAppAccount>` (e.g. `1106971865_p_testapps_savefamily_gmail_com`) is the right one for Android single-chat rooms.
|
||||
- That the `JoinParam` values above are reasonable for a 1-to-1 watch call (we copied them from our group-call flow).
|
||||
- That nothing else is needed on the mobile side besides `JCMediaChannel.join` + `enableUploadAudioStream` + `enableUploadVideoStream` + `requestVideo` after `onParticipantJoin`.
|
||||
|
||||
We'll test on a real Android watch (`1106971865`) and share logs once we have results. Thanks for the clarification — the `RTC1.0 Flutter quickstart` PDF you shared made it very clear once we cross-referenced with the `connection process Rev2` document.
|
||||
256
docs/videocall-juphoon-talking-issue.md
Normal file
256
docs/videocall-juphoon-talking-issue.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# Videocall — Missing `isTalking` State Transition
|
||||
|
||||
## Issue Summary
|
||||
|
||||
When the **mobile app initiates a video call to a watch** (RTOS or Android firmware), the call is answered on the watch and audio/video can be exchanged, but **the Juphoon SDK on the mobile side never emits a `callItemUpdateStream` event with `isTalking == true`**.
|
||||
|
||||
As a consequence the client-side state machine stays in `outgoing` (the UI keeps showing "Connecting…" / "Device ringing…") and never transitions to `inCall`. The `_reportRoomCount` notification (`VIDEO_CALL_ROOM_COUNT_REQUEST`) is therefore never sent, the local UI controls (mute, hang up, switch camera) stay in their pre-call state, and the call cannot be properly hung up by the user.
|
||||
|
||||
We need help from Juphoon to identify which side is failing to publish the "talking" state and what we need to send/configure for the SDK on the mobile to recognise the watch as connected.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The mobile side of the videocall feature is implemented in Flutter and wraps the Juphoon Android/iOS SDK through a dedicated `videocall_sdk` package.
|
||||
|
||||
```
|
||||
modules/legacy/modules/device_management/lib/src/features/videocall/
|
||||
├── domain/ (entities, repositories interfaces)
|
||||
├── data/ (signaling datasource + repository impl)
|
||||
├── providers/ (Riverpod providers for signaling)
|
||||
└── presentation/
|
||||
├── videocall_screen.dart (idle / outgoing / incoming / inCall / groupCall UI)
|
||||
├── providers/
|
||||
│ ├── videocall_controller.dart ← orchestrates SDK + signaling
|
||||
│ ├── videocall_state.dart
|
||||
│ ├── group_call_controller.dart (group/conference mode)
|
||||
│ └── group_call_state.dart
|
||||
└── widgets/ (UI for each call state)
|
||||
|
||||
packages/videocall_sdk/lib/src/
|
||||
├── manager/videocall_sdk_manager.dart
|
||||
├── services/
|
||||
│ ├── videocall_client.dart (login)
|
||||
│ ├── videocall_call_service.dart (start/answer/hangup, callItem*Stream)
|
||||
│ ├── videocall_device_service.dart (camera/mic/speaker)
|
||||
│ ├── videocall_channel_service.dart (group room)
|
||||
│ ├── videocall_net_service.dart
|
||||
│ ├── videocall_log_service.dart
|
||||
│ └── videocall_push_service.dart
|
||||
└── models/ (CallParam, VideocallItem, CallDirection, MediaConfig, …)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Outgoing 1:1 Call — Expected Sequence
|
||||
|
||||
User taps the device card on the idle screen of the videocall feature → `vm.startCall('')` is invoked on the controller.
|
||||
|
||||
`videocall_controller.dart:184` (`startCall`):
|
||||
|
||||
| Step | Action | Code reference |
|
||||
|------|--------|----------------|
|
||||
| 1 | Request runtime permissions for camera + microphone via `permission_handler`. | `_requestMediaPermissions` (line 167) |
|
||||
| 2 | Resolve target Juphoon user ID. Defaults to `w_${device.imei}` if no explicit `remoteUserId` is provided. | line 191 |
|
||||
| 3 | Build `chatType`, `roomNumber`, `sessionId`. `roomNumber = ${deviceId}_${sanitizedAppAccount}` for 1:1, `${deviceId}_group` for multi. | `_buildRoomNumber` (line 152), `_buildSessionId` (line 163) |
|
||||
| 4 | Set `screenMode = outgoing` so the UI swaps to the "calling" view. | line 199 |
|
||||
| 5 | Send signaling to the watch via the backend: `POST /commands` with `command: VIDEO_CALL_REQUEST`, payload `{ chatType, appAccount, roomNumber, sessionId }`. The backend forwards this as `PRYVCALL` to the watch firmware. | `_signaling.initiateCall` (line 206), see datasource `videocall_signaling_datasource_impl.dart:11` |
|
||||
| 6 | Start local audio + camera on the SDK. | `_deviceService.startAudio()` / `startCamera()` (line 224-225) |
|
||||
| 7 | Call `_callService.startCall(userId, isVideo: true, callParam)` to start the SIP/RTC session via the Juphoon SDK. | line 228 |
|
||||
|
||||
After step 7 the controller waits for two asynchronous signals:
|
||||
|
||||
* **`VideoCallRequestResponseEvent` over WebSocket** — the backend forwards a watch acknowledgement (`OK` or busy/refuse). When `isOk == true` the controller sets `state.isDeviceRinging = true` so the UI can show "Device ringing".
|
||||
Code: `_onWebSocketEvent` (line 525).
|
||||
|
||||
* **`callItemAddStream` from the SDK** — the SDK reports the outgoing call as a `VideocallItem` with `direction == outgoing`. The controller stores it in `state.currentCall`.
|
||||
Code: `_onCallItemAdd` (line 450).
|
||||
|
||||
* **`callItemUpdateStream` from the SDK** — once the remote party (watch) joins the call, the SDK should update the `VideocallItem` with `isTalking == true`, optionally with `uploadVideoStreamSelf` / `uploadVideoStreamOther`. The controller transitions to `inCall` and sends a `VIDEO_CALL_ROOM_COUNT_REQUEST` to inform the backend that the room now has 2 participants.
|
||||
Code: `_onCallItemUpdate` (line 466).
|
||||
|
||||
**This last event is the one that never arrives in the buggy flow.**
|
||||
|
||||
---
|
||||
|
||||
## Watch Firmware Logs (Real Capture)
|
||||
|
||||
```
|
||||
[3G*1106971865*0037*PRYVCALL,0,18606224072,1106971865_18606224072,123456789]
|
||||
[3G*1106971865*000a*PRYVCALL,0]
|
||||
[3G*1106971865*0028*UPRYROOMCOUNT,0,0,1106971865_18606224072]
|
||||
```
|
||||
|
||||
* `PRYVCALL,0,...` — the watch received the request from the backend. The first arg is `chatType` (`0 = single`, `1 = multi`), then `appAccount`, then `roomNumber`, then `sessionId`.
|
||||
* `PRYVCALL,0` (ack) — the watch replies acknowledging the request.
|
||||
* `UPRYROOMCOUNT,0,0,...` — the watch updates the room count with `count=0` (the watch is **not** in the room yet, or is reporting that nobody is connected).
|
||||
|
||||
Despite the watch beeping/ringing and the user pressing the answer button on the watch, the mobile-side SDK callback `callItemUpdateStream` never receives an event with `isTalking == true`.
|
||||
|
||||
---
|
||||
|
||||
## Mobile SDK Configuration
|
||||
|
||||
`videocall_controller.dart:118` (`_configureDevice`):
|
||||
|
||||
```dart
|
||||
_deviceService.setDefaultSpeakerOn(true);
|
||||
_deviceService.setCameraProperty(640, 360, 30); // _cameraWidth, _cameraHeight, _cameraFps
|
||||
_deviceService.setVideoAngle(0);
|
||||
_callService.setMaxCallNum(1);
|
||||
_callService.setTermWhenNetDisconnected(true);
|
||||
|
||||
final isRtos = device?.capabilities?.isRtos ?? false;
|
||||
final mediaConfig = await VideocallCallService.generateMediaConfigByMode(
|
||||
isRtos
|
||||
? MediaConfig.MODE_RTOS
|
||||
: MediaConfig.MODE_INTELLIGENT_HARDWARE,
|
||||
);
|
||||
_callService.updateMediaConfig(mediaConfig);
|
||||
```
|
||||
|
||||
Login is performed in `_autoLogin` (line 93):
|
||||
|
||||
```dart
|
||||
final userId = 'p_$sanitizedEmail'; // mobile user ID
|
||||
await _client.login(userId: userId, password: user.id);
|
||||
```
|
||||
|
||||
The watch is registered with Juphoon under another account (the watch firmware logs in by itself with its own credentials).
|
||||
|
||||
`callParam` for the outgoing call:
|
||||
|
||||
```dart
|
||||
final callParam = CallParam(ticket: state.deviceId);
|
||||
await _callService.startCall(
|
||||
userId: targetUserId, // 'w_<imei>'
|
||||
isVideo: true,
|
||||
callParam: callParam,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands Sent From the Mobile to the Watch (via backend)
|
||||
|
||||
Defined in `videocall_signaling_datasource_impl.dart`. They are sent as `POST /commands` to the SaveFamily backend, which forwards them to the watch as raw firmware commands.
|
||||
|
||||
| Mobile event | Backend command | Payload | Watch firmware command |
|
||||
|--------------|-----------------|---------|------------------------|
|
||||
| `startCall` | `VIDEO_CALL_REQUEST` | `{chatType, appAccount, roomNumber, sessionId}` | `PRYVCALL,<chatType>,<appAccount>,<roomNumber>,<sessionId>` |
|
||||
| `cancelCall`, `hangUp` | `VIDEO_CALL_CANCEL` | `{chatType}` | (cancel) |
|
||||
| `rejectCall` | `VIDEO_CALL_REFUSE` | `{chatType, appAccount, roomNumber}` | (refuse) |
|
||||
| `_reportRoomCount` (after `inCall`) | `VIDEO_CALL_ROOM_COUNT_REQUEST` | `{type, count, room_num}` | `UPRYROOMCOUNT,<type>,<count>,<roomNumber>` |
|
||||
|
||||
`type` in `VIDEO_CALL_ROOM_COUNT_REQUEST` is `0` for `single` and `1` for `multi`.
|
||||
|
||||
---
|
||||
|
||||
## What We Have Verified
|
||||
|
||||
1. `_callService.startCall(...)` returns `true` (the SDK accepts the outgoing call).
|
||||
2. `callItemAddStream` fires once with a `VideocallItem` whose `direction == outgoing`. We set it as `state.currentCall`.
|
||||
3. The watch logs `PRYVCALL,...` immediately after we send `VIDEO_CALL_REQUEST`, so the watch receives the request.
|
||||
4. The user can hear the watch ringing.
|
||||
5. We confirm receiving `VideoCallRequestResponseEvent` via WebSocket with `isOk: true` — the backend says the watch acknowledged.
|
||||
6. After the user answers on the watch, the SDK on the mobile **does not** emit any `callItemUpdateStream` event with `isTalking == true`.
|
||||
7. No error is logged on the SDK side. Network is stable, login is `loggedIn`.
|
||||
|
||||
---
|
||||
|
||||
## What We Need Help With (Asks for Juphoon)
|
||||
|
||||
1. **Under which conditions is `VideocallItem.isTalking == true` published over `callItemUpdateStream`?**
|
||||
We assume it is when both peers have joined the media session. Is there an additional handshake or media event that has to fire on the watch firmware before the mobile SDK considers the call as "talking"?
|
||||
|
||||
2. **Does the watch firmware (RTOS and Android) need to call any specific SDK API after `PRYVCALL` is accepted by the user, in order for the mobile SDK to receive the talking event?**
|
||||
The watch logs show `PRYVCALL` ack and `UPRYROOMCOUNT,0,0,...`. Is `UPRYROOMCOUNT` enough or are we missing a `JCCallJoin` / `acceptCall` equivalent on the watch side?
|
||||
|
||||
3. **Should the mobile and watch use matching `chatType`?**
|
||||
Today the mobile uses `0 = single` for 1:1 calls regardless of the watch firmware. If the watch is Android (which according to the firmware vendor always operates in "room mode" / `meeting_page`), should the mobile send `chatType = 1` (multi) and let both peers join the same room? Currently the watch logs `PRYVCALL,0,...` with a `roomNumber` of the form `<deviceId>_<userPhone>` even though the watch is Android — would that break the talking state propagation?
|
||||
|
||||
4. **`MediaConfig.MODE_RTOS` vs `MODE_INTELLIGENT_HARDWARE`** — we select the mode based on `device.capabilities.system`. Are these the correct mode mappings for the talking event to fire? Could the mode mismatch silently disable the update events?
|
||||
|
||||
5. **`UPRYROOMCOUNT` returning `count = 0`** after the watch acknowledges `PRYVCALL` — is this expected before the watch joins the room, or does it mean the watch never actually joined the room from the SDK's perspective?
|
||||
|
||||
6. **Reference to `meeting_page`** — the firmware vendor mentioned that Android watches use `meeting_page` for any call. Is there a Juphoon-side equivalent we should be invoking from the mobile (e.g., `joinChannel` instead of `startCall`) when the peer is an Android watch?
|
||||
|
||||
---
|
||||
|
||||
## Reproduction
|
||||
|
||||
* App build: `apps/mobile_app` flavour `staging`.
|
||||
* Mobile: Android phone (Samsung, API 34+) signed in as `testapps.savefamily@gmail.com`.
|
||||
* Watch: Android firmware, identificator `1106971865`. The watch is logged into Juphoon as a separate account.
|
||||
* The user opens the app → `Device Management` → `Video call` → taps the device card on the idle screen.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Mobile sends `VIDEO_CALL_REQUEST` (visible in app logs).
|
||||
2. Watch fires `PRYVCALL,...` and starts ringing.
|
||||
3. User accepts on the watch.
|
||||
4. Audio / video clearly flows (the watch shows the user's video preview, the user can hear the watch carrier speaking).
|
||||
5. Mobile UI stays in `outgoing` ("Device ringing…") forever. `_onCallItemUpdate` is never triggered with `isTalking == true`.
|
||||
|
||||
---
|
||||
|
||||
## Relevant Files
|
||||
|
||||
### Feature
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `modules/legacy/modules/device_management/lib/src/features/videocall/presentation/providers/videocall_controller.dart` | Main controller. Holds the SDK lifecycle + signaling orchestration. The talking state transition is expected to happen in `_onCallItemUpdate` (line 466). |
|
||||
| `modules/legacy/modules/device_management/lib/src/features/videocall/presentation/providers/videocall_state.dart` | Freezed state (`screenMode`, `currentCall`, `isDeviceRinging`, `chatType`, `localUserId`, `remoteUserId`, etc.). |
|
||||
| `modules/legacy/modules/device_management/lib/src/features/videocall/data/datasources/videocall_signaling_datasource_impl.dart` | Sends the `VIDEO_CALL_REQUEST`, `VIDEO_CALL_CANCEL`, `VIDEO_CALL_REFUSE`, `VIDEO_CALL_ROOM_COUNT_REQUEST` commands to the backend. |
|
||||
| `modules/legacy/modules/device_management/lib/src/features/videocall/presentation/videocall_screen.dart` | UI; reflects `screenMode` from the controller. |
|
||||
|
||||
### SDK Wrapper
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `packages/videocall_sdk/lib/src/manager/videocall_sdk_manager.dart` | Singleton orchestrator over the Juphoon SDK. |
|
||||
| `packages/videocall_sdk/lib/src/services/videocall_call_service.dart` | Owns `callItemAddStream`, `callItemUpdateStream`, `callItemRemoveStream`, `missedCallStream`. The talking state should arrive via `callItemUpdateStream`. |
|
||||
| `packages/videocall_sdk/lib/src/services/videocall_client.dart` | Login lifecycle. |
|
||||
| `packages/videocall_sdk/lib/src/services/videocall_device_service.dart` | Camera/mic/speaker control. |
|
||||
| `packages/videocall_sdk/lib/src/services/videocall_channel_service.dart` | Group/room mode (used in `groupCall` flow). |
|
||||
| `packages/videocall_sdk/lib/src/models/videocall_item.dart` | Carries `state`, `isTalking`, `uploadVideoStreamSelf`, `uploadVideoStreamOther`. |
|
||||
|
||||
### Backend Side
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `packages/sf_infrastructure/lib/src/websocket/websocket_event.dart` | `VideoCallEvent`, `VideoCallRefusedEvent`, `VideoCallRoomCountEvent`, `VideoCallRequestResponseEvent`. |
|
||||
| `packages/sf_infrastructure/lib/src/websocket/websocket_event_parser.dart` | Parses incoming WebSocket frames into the events above. |
|
||||
|
||||
---
|
||||
|
||||
## Sample Mobile Logs During the Bug
|
||||
|
||||
```
|
||||
[Videocall] build() called, scheduling _initSdk
|
||||
[Videocall] _initSdk: isInitialized=true
|
||||
[Videocall] _autoLogin: userId=p_testapps_savefamily_gmail_com, clientState=loggedIn
|
||||
[Videocall] _autoLogin: already logged in, skipping login
|
||||
… user taps device card …
|
||||
[Videocall] _requestMediaPermissions: granted
|
||||
… signaling.initiateCall succeeds (HTTP 201) …
|
||||
[Videocall] WS video_call_request_response: ok
|
||||
… (watch starts ringing, user accepts on watch) …
|
||||
… (no further callItemUpdateStream events) …
|
||||
```
|
||||
|
||||
Expected (but missing):
|
||||
|
||||
```
|
||||
[Videocall] _onCallItemUpdate: state=<X>, isTalking=true, uploadSelf=true, uploadOther=true
|
||||
[Videocall] screenMode -> inCall
|
||||
[Videocall] _reportRoomCount → VIDEO_CALL_ROOM_COUNT_REQUEST {type:0, count:2, room_num:<…>}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contact
|
||||
|
||||
Please reach out to the SaveFamily mobile team with any clarifications. We can provide additional logs from the Juphoon SDK if needed (the wrapper currently exposes `videocall_log_service.dart` but verbose logging is off by default; we can re-enable it on demand).
|
||||
@@ -1,6 +1,4 @@
|
||||
import 'package:account/src/core/data/models/change_password_request_model.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:legacy_shared/legacy_shared.dart';
|
||||
import 'package:account/src/core/data/models/change_password_request_dto.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
|
||||
import '../../../features/change_password/domain/models/entities/change_password_request_entity.dart';
|
||||
@@ -17,11 +15,7 @@ class ChangePasswordRemoteDatasourceImpl
|
||||
required String userId,
|
||||
required ChangePasswordRequestEntity request,
|
||||
}) async {
|
||||
try {
|
||||
final body = request.toModel().toJson();
|
||||
await _repository.put<void>('/auth/change-password', body: body);
|
||||
} on DioException catch (error) {
|
||||
throw mapDioError(error, defaultMessage: 'Error to change password');
|
||||
}
|
||||
final body = request.toDto().toJson();
|
||||
await _repository.put<void>('/auth/change-password', body: body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:account/src/features/linked_devices/domain/entities/update_device_request_entity.dart';
|
||||
import 'package:sf_shared/sf_shared.dart' show DeviceEntity;
|
||||
|
||||
abstract class DevicesRemoteDatasource {
|
||||
Future<void> deleteDevice({required String deviceId});
|
||||
|
||||
Future<void> updateDevice({required UpdateDeviceRequestEntity request});
|
||||
Future<void> updateDevice({required DeviceEntity device});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:account/src/core/data/models/update_device_request_model.dart';
|
||||
import 'package:account/src/features/linked_devices/domain/entities/update_device_request_entity.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:legacy_shared/legacy_shared.dart';
|
||||
import 'package:legacy_device_state/legacy_device_state.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
import 'package:sf_shared/sf_shared.dart' show DeviceEntity;
|
||||
|
||||
import 'devices_remote_datasource.dart';
|
||||
|
||||
@@ -13,22 +11,15 @@ class DevicesRemoteDatasourceImpl implements DevicesRemoteDatasource {
|
||||
|
||||
@override
|
||||
Future<void> deleteDevice({required String deviceId}) async {
|
||||
try {
|
||||
await _repository.delete<void>('/devices/$deviceId');
|
||||
} on DioException catch (error) {
|
||||
throw mapDioError(error, defaultMessage: 'Error to delete device');
|
||||
}
|
||||
await _repository.delete<void>('/devices/$deviceId');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateDevice({
|
||||
required UpdateDeviceRequestEntity request,
|
||||
}) async {
|
||||
try {
|
||||
final body = request.toModel().toJson();
|
||||
await _repository.put<void>('/devices', body: body);
|
||||
} on DioException catch (error) {
|
||||
throw mapDioError(error, defaultMessage: 'Error to update device');
|
||||
}
|
||||
Future<void> updateDevice({required DeviceEntity device}) async {
|
||||
final csvBase64 = DeviceCsvBuilder.buildBase64Csv(
|
||||
device: device,
|
||||
settings: device.settings,
|
||||
);
|
||||
await _repository.put<void>('/devices', body: {'csv': csvBase64});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:account/src/core/data/datasource/users_remote_datasource.dart';
|
||||
import 'package:account/src/core/data/models/get_app_users_response_model.dart';
|
||||
import 'package:account/src/core/data/models/update_user_request_model.dart';
|
||||
import 'package:account/src/core/data/models/get_app_users_response_dto.dart';
|
||||
import 'package:account/src/core/data/models/update_user_request_dto.dart';
|
||||
import 'package:account/src/features/personal_data/domain/entities/update_user_request_entity.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:legacy_shared/legacy_shared.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
|
||||
@@ -14,23 +12,16 @@ class UsersRemoteDatasourceImpl implements UsersRemoteDatasource {
|
||||
|
||||
@override
|
||||
Future<List<UserEntity>> getUsers({required String userId}) async {
|
||||
try {
|
||||
final response = await _repository.get<Map<String, dynamic>>(
|
||||
'/users/$userId/user-devices',
|
||||
);
|
||||
final data = response.data!['items'];
|
||||
if (data == null || data.isEmpty) {
|
||||
throw Exception('Empty response from /users/$userId/user-devices');
|
||||
}
|
||||
|
||||
final model = GetAppUsersResponseModel.fromJson(data);
|
||||
return model.toEntity();
|
||||
} on DioException catch (error) {
|
||||
throw mapDioError(
|
||||
error,
|
||||
defaultMessage: error.message ?? 'Error getting devices',
|
||||
);
|
||||
final response = await _repository.get<Map<String, dynamic>>(
|
||||
'/users/$userId/user-devices',
|
||||
);
|
||||
final data = response.data!['items'];
|
||||
if (data == null || data.isEmpty) {
|
||||
throw Exception('Empty response from /users/$userId/user-devices');
|
||||
}
|
||||
|
||||
final model = GetAppUsersResponseDto.fromJson(data);
|
||||
return model.toEntity();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -38,20 +29,12 @@ class UsersRemoteDatasourceImpl implements UsersRemoteDatasource {
|
||||
required String userId,
|
||||
required UpdateUserRequestEntity request,
|
||||
}) async {
|
||||
try {
|
||||
final body = request.toModel().toJson();
|
||||
await _repository.put<void>('/users/$userId', body: body);
|
||||
} on DioException catch (error) {
|
||||
throw mapDioError(error, defaultMessage: 'Error to update user');
|
||||
}
|
||||
final body = request.toDto().toJson();
|
||||
await _repository.put<void>('/users/$userId', body: body);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteUser({required String userId}) async {
|
||||
try {
|
||||
await _repository.delete<void>('/users/$userId');
|
||||
} on DioException catch (error) {
|
||||
throw mapDioError(error, defaultMessage: 'Error to delete device');
|
||||
}
|
||||
await _repository.delete<void>('/users/$userId');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:account/src/features/change_password/domain/models/entities/change_password_request_entity.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'change_password_request_dto.freezed.dart';
|
||||
part 'change_password_request_dto.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class ChangePasswordRequestDto with _$ChangePasswordRequestDto {
|
||||
const factory ChangePasswordRequestDto({required String password}) =
|
||||
_ChangePasswordRequestDto;
|
||||
|
||||
factory ChangePasswordRequestDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$ChangePasswordRequestDtoFromJson(json);
|
||||
}
|
||||
|
||||
extension ChangePasswordRequestDtoMapper on ChangePasswordRequestEntity {
|
||||
ChangePasswordRequestDto toDto() =>
|
||||
ChangePasswordRequestDto(password: password);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'change_password_request_model.dart';
|
||||
part of 'change_password_request_dto.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
@@ -13,22 +13,22 @@ part of 'change_password_request_model.dart';
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$ChangePasswordRequestModel {
|
||||
mixin _$ChangePasswordRequestDto {
|
||||
|
||||
String get password;
|
||||
/// Create a copy of ChangePasswordRequestModel
|
||||
/// Create a copy of ChangePasswordRequestDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ChangePasswordRequestModelCopyWith<ChangePasswordRequestModel> get copyWith => _$ChangePasswordRequestModelCopyWithImpl<ChangePasswordRequestModel>(this as ChangePasswordRequestModel, _$identity);
|
||||
$ChangePasswordRequestDtoCopyWith<ChangePasswordRequestDto> get copyWith => _$ChangePasswordRequestDtoCopyWithImpl<ChangePasswordRequestDto>(this as ChangePasswordRequestDto, _$identity);
|
||||
|
||||
/// Serializes this ChangePasswordRequestModel to a JSON map.
|
||||
/// Serializes this ChangePasswordRequestDto to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChangePasswordRequestModel&&(identical(other.password, password) || other.password == password));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChangePasswordRequestDto&&(identical(other.password, password) || other.password == password));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -37,15 +37,15 @@ int get hashCode => Object.hash(runtimeType,password);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChangePasswordRequestModel(password: $password)';
|
||||
return 'ChangePasswordRequestDto(password: $password)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ChangePasswordRequestModelCopyWith<$Res> {
|
||||
factory $ChangePasswordRequestModelCopyWith(ChangePasswordRequestModel value, $Res Function(ChangePasswordRequestModel) _then) = _$ChangePasswordRequestModelCopyWithImpl;
|
||||
abstract mixin class $ChangePasswordRequestDtoCopyWith<$Res> {
|
||||
factory $ChangePasswordRequestDtoCopyWith(ChangePasswordRequestDto value, $Res Function(ChangePasswordRequestDto) _then) = _$ChangePasswordRequestDtoCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String password
|
||||
@@ -56,14 +56,14 @@ $Res call({
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ChangePasswordRequestModelCopyWithImpl<$Res>
|
||||
implements $ChangePasswordRequestModelCopyWith<$Res> {
|
||||
_$ChangePasswordRequestModelCopyWithImpl(this._self, this._then);
|
||||
class _$ChangePasswordRequestDtoCopyWithImpl<$Res>
|
||||
implements $ChangePasswordRequestDtoCopyWith<$Res> {
|
||||
_$ChangePasswordRequestDtoCopyWithImpl(this._self, this._then);
|
||||
|
||||
final ChangePasswordRequestModel _self;
|
||||
final $Res Function(ChangePasswordRequestModel) _then;
|
||||
final ChangePasswordRequestDto _self;
|
||||
final $Res Function(ChangePasswordRequestDto) _then;
|
||||
|
||||
/// Create a copy of ChangePasswordRequestModel
|
||||
/// Create a copy of ChangePasswordRequestDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? password = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
@@ -75,8 +75,8 @@ as String,
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [ChangePasswordRequestModel].
|
||||
extension ChangePasswordRequestModelPatterns on ChangePasswordRequestModel {
|
||||
/// Adds pattern-matching-related methods to [ChangePasswordRequestDto].
|
||||
extension ChangePasswordRequestDtoPatterns on ChangePasswordRequestDto {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
@@ -89,10 +89,10 @@ extension ChangePasswordRequestModelPatterns on ChangePasswordRequestModel {
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ChangePasswordRequestModel value)? $default,{required TResult orElse(),}){
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ChangePasswordRequestDto value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChangePasswordRequestModel() when $default != null:
|
||||
case _ChangePasswordRequestDto() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
@@ -111,10 +111,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ChangePasswordRequestModel value) $default,){
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ChangePasswordRequestDto value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChangePasswordRequestModel():
|
||||
case _ChangePasswordRequestDto():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
@@ -132,10 +132,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ChangePasswordRequestModel value)? $default,){
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ChangePasswordRequestDto value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChangePasswordRequestModel() when $default != null:
|
||||
case _ChangePasswordRequestDto() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
@@ -155,7 +155,7 @@ return $default(_that);case _:
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String password)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChangePasswordRequestModel() when $default != null:
|
||||
case _ChangePasswordRequestDto() when $default != null:
|
||||
return $default(_that.password);case _:
|
||||
return orElse();
|
||||
|
||||
@@ -176,7 +176,7 @@ return $default(_that.password);case _:
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String password) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChangePasswordRequestModel():
|
||||
case _ChangePasswordRequestDto():
|
||||
return $default(_that.password);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
@@ -196,7 +196,7 @@ return $default(_that.password);case _:
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String password)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChangePasswordRequestModel() when $default != null:
|
||||
case _ChangePasswordRequestDto() when $default != null:
|
||||
return $default(_that.password);case _:
|
||||
return null;
|
||||
|
||||
@@ -208,26 +208,26 @@ return $default(_that.password);case _:
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _ChangePasswordRequestModel implements ChangePasswordRequestModel {
|
||||
const _ChangePasswordRequestModel({required this.password});
|
||||
factory _ChangePasswordRequestModel.fromJson(Map<String, dynamic> json) => _$ChangePasswordRequestModelFromJson(json);
|
||||
class _ChangePasswordRequestDto implements ChangePasswordRequestDto {
|
||||
const _ChangePasswordRequestDto({required this.password});
|
||||
factory _ChangePasswordRequestDto.fromJson(Map<String, dynamic> json) => _$ChangePasswordRequestDtoFromJson(json);
|
||||
|
||||
@override final String password;
|
||||
|
||||
/// Create a copy of ChangePasswordRequestModel
|
||||
/// Create a copy of ChangePasswordRequestDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$ChangePasswordRequestModelCopyWith<_ChangePasswordRequestModel> get copyWith => __$ChangePasswordRequestModelCopyWithImpl<_ChangePasswordRequestModel>(this, _$identity);
|
||||
_$ChangePasswordRequestDtoCopyWith<_ChangePasswordRequestDto> get copyWith => __$ChangePasswordRequestDtoCopyWithImpl<_ChangePasswordRequestDto>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$ChangePasswordRequestModelToJson(this, );
|
||||
return _$ChangePasswordRequestDtoToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChangePasswordRequestModel&&(identical(other.password, password) || other.password == password));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChangePasswordRequestDto&&(identical(other.password, password) || other.password == password));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -236,15 +236,15 @@ int get hashCode => Object.hash(runtimeType,password);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChangePasswordRequestModel(password: $password)';
|
||||
return 'ChangePasswordRequestDto(password: $password)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$ChangePasswordRequestModelCopyWith<$Res> implements $ChangePasswordRequestModelCopyWith<$Res> {
|
||||
factory _$ChangePasswordRequestModelCopyWith(_ChangePasswordRequestModel value, $Res Function(_ChangePasswordRequestModel) _then) = __$ChangePasswordRequestModelCopyWithImpl;
|
||||
abstract mixin class _$ChangePasswordRequestDtoCopyWith<$Res> implements $ChangePasswordRequestDtoCopyWith<$Res> {
|
||||
factory _$ChangePasswordRequestDtoCopyWith(_ChangePasswordRequestDto value, $Res Function(_ChangePasswordRequestDto) _then) = __$ChangePasswordRequestDtoCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String password
|
||||
@@ -255,17 +255,17 @@ $Res call({
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$ChangePasswordRequestModelCopyWithImpl<$Res>
|
||||
implements _$ChangePasswordRequestModelCopyWith<$Res> {
|
||||
__$ChangePasswordRequestModelCopyWithImpl(this._self, this._then);
|
||||
class __$ChangePasswordRequestDtoCopyWithImpl<$Res>
|
||||
implements _$ChangePasswordRequestDtoCopyWith<$Res> {
|
||||
__$ChangePasswordRequestDtoCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _ChangePasswordRequestModel _self;
|
||||
final $Res Function(_ChangePasswordRequestModel) _then;
|
||||
final _ChangePasswordRequestDto _self;
|
||||
final $Res Function(_ChangePasswordRequestDto) _then;
|
||||
|
||||
/// Create a copy of ChangePasswordRequestModel
|
||||
/// Create a copy of ChangePasswordRequestDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? password = null,}) {
|
||||
return _then(_ChangePasswordRequestModel(
|
||||
return _then(_ChangePasswordRequestDto(
|
||||
password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
@@ -1,15 +1,15 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'change_password_request_model.dart';
|
||||
part of 'change_password_request_dto.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_ChangePasswordRequestModel _$ChangePasswordRequestModelFromJson(
|
||||
_ChangePasswordRequestDto _$ChangePasswordRequestDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _ChangePasswordRequestModel(password: json['password'] as String);
|
||||
) => _ChangePasswordRequestDto(password: json['password'] as String);
|
||||
|
||||
Map<String, dynamic> _$ChangePasswordRequestModelToJson(
|
||||
_ChangePasswordRequestModel instance,
|
||||
Map<String, dynamic> _$ChangePasswordRequestDtoToJson(
|
||||
_ChangePasswordRequestDto instance,
|
||||
) => <String, dynamic>{'password': instance.password};
|
||||
@@ -1,19 +0,0 @@
|
||||
import 'package:account/src/features/change_password/domain/models/entities/change_password_request_entity.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'change_password_request_model.freezed.dart';
|
||||
part 'change_password_request_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class ChangePasswordRequestModel with _$ChangePasswordRequestModel {
|
||||
const factory ChangePasswordRequestModel({required String password}) =
|
||||
_ChangePasswordRequestModel;
|
||||
|
||||
factory ChangePasswordRequestModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$ChangePasswordRequestModelFromJson(json);
|
||||
}
|
||||
|
||||
extension ChangePasswordRequestModelMapper on ChangePasswordRequestEntity {
|
||||
ChangePasswordRequestModel toModel() =>
|
||||
ChangePasswordRequestModel(password: password);
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
|
||||
part 'get_app_users_response_model.freezed.dart';
|
||||
part 'get_app_users_response_model.g.dart';
|
||||
part 'get_app_users_response_dto.freezed.dart';
|
||||
part 'get_app_users_response_dto.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class GetAppUsersResponseModel with _$GetAppUsersResponseModel {
|
||||
const factory GetAppUsersResponseModel({
|
||||
required List<GetAppUsersItemResponseModel> items,
|
||||
}) = _GetAppUsersResponseModel;
|
||||
abstract class GetAppUsersResponseDto with _$GetAppUsersResponseDto {
|
||||
const factory GetAppUsersResponseDto({
|
||||
required List<GetAppUsersItemResponseDto> items,
|
||||
}) = _GetAppUsersResponseDto;
|
||||
|
||||
factory GetAppUsersResponseModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$GetAppUsersResponseModelFromJson(json);
|
||||
factory GetAppUsersResponseDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$GetAppUsersResponseDtoFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class GetAppUsersItemResponseModel
|
||||
with _$GetAppUsersItemResponseModel {
|
||||
const factory GetAppUsersItemResponseModel({
|
||||
abstract class GetAppUsersItemResponseDto
|
||||
with _$GetAppUsersItemResponseDto {
|
||||
const factory GetAppUsersItemResponseDto({
|
||||
required String id,
|
||||
required String delegationId,
|
||||
required String email,
|
||||
@@ -32,17 +32,17 @@ abstract class GetAppUsersItemResponseModel
|
||||
required String lastName,
|
||||
required bool hasApiKey,
|
||||
required String phone,
|
||||
}) = _GetAppUsersItemResponseModel;
|
||||
}) = _GetAppUsersItemResponseDto;
|
||||
|
||||
factory GetAppUsersItemResponseModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$GetAppUsersItemResponseModelFromJson(json);
|
||||
factory GetAppUsersItemResponseDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$GetAppUsersItemResponseDtoFromJson(json);
|
||||
}
|
||||
|
||||
extension GetUsersResponseModelMapper on GetAppUsersResponseModel {
|
||||
extension GetAppUsersResponseDtoMapper on GetAppUsersResponseDto {
|
||||
List<UserEntity> toEntity() {
|
||||
return items
|
||||
.map(
|
||||
(GetAppUsersItemResponseModel item) => UserEntity(
|
||||
(GetAppUsersItemResponseDto item) => UserEntity(
|
||||
id: item.id,
|
||||
delegationId: item.delegationId,
|
||||
email: item.email,
|
||||
@@ -3,7 +3,7 @@
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'get_app_users_response_model.dart';
|
||||
part of 'get_app_users_response_dto.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
@@ -13,22 +13,22 @@ part of 'get_app_users_response_model.dart';
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$GetAppUsersResponseModel {
|
||||
mixin _$GetAppUsersResponseDto {
|
||||
|
||||
List<GetAppUsersItemResponseModel> get items;
|
||||
/// Create a copy of GetAppUsersResponseModel
|
||||
List<GetAppUsersItemResponseDto> get items;
|
||||
/// Create a copy of GetAppUsersResponseDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$GetAppUsersResponseModelCopyWith<GetAppUsersResponseModel> get copyWith => _$GetAppUsersResponseModelCopyWithImpl<GetAppUsersResponseModel>(this as GetAppUsersResponseModel, _$identity);
|
||||
$GetAppUsersResponseDtoCopyWith<GetAppUsersResponseDto> get copyWith => _$GetAppUsersResponseDtoCopyWithImpl<GetAppUsersResponseDto>(this as GetAppUsersResponseDto, _$identity);
|
||||
|
||||
/// Serializes this GetAppUsersResponseModel to a JSON map.
|
||||
/// Serializes this GetAppUsersResponseDto to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is GetAppUsersResponseModel&&const DeepCollectionEquality().equals(other.items, items));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is GetAppUsersResponseDto&&const DeepCollectionEquality().equals(other.items, items));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -37,18 +37,18 @@ int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'GetAppUsersResponseModel(items: $items)';
|
||||
return 'GetAppUsersResponseDto(items: $items)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $GetAppUsersResponseModelCopyWith<$Res> {
|
||||
factory $GetAppUsersResponseModelCopyWith(GetAppUsersResponseModel value, $Res Function(GetAppUsersResponseModel) _then) = _$GetAppUsersResponseModelCopyWithImpl;
|
||||
abstract mixin class $GetAppUsersResponseDtoCopyWith<$Res> {
|
||||
factory $GetAppUsersResponseDtoCopyWith(GetAppUsersResponseDto value, $Res Function(GetAppUsersResponseDto) _then) = _$GetAppUsersResponseDtoCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
List<GetAppUsersItemResponseModel> items
|
||||
List<GetAppUsersItemResponseDto> items
|
||||
});
|
||||
|
||||
|
||||
@@ -56,27 +56,27 @@ $Res call({
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$GetAppUsersResponseModelCopyWithImpl<$Res>
|
||||
implements $GetAppUsersResponseModelCopyWith<$Res> {
|
||||
_$GetAppUsersResponseModelCopyWithImpl(this._self, this._then);
|
||||
class _$GetAppUsersResponseDtoCopyWithImpl<$Res>
|
||||
implements $GetAppUsersResponseDtoCopyWith<$Res> {
|
||||
_$GetAppUsersResponseDtoCopyWithImpl(this._self, this._then);
|
||||
|
||||
final GetAppUsersResponseModel _self;
|
||||
final $Res Function(GetAppUsersResponseModel) _then;
|
||||
final GetAppUsersResponseDto _self;
|
||||
final $Res Function(GetAppUsersResponseDto) _then;
|
||||
|
||||
/// Create a copy of GetAppUsersResponseModel
|
||||
/// Create a copy of GetAppUsersResponseDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? items = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable
|
||||
as List<GetAppUsersItemResponseModel>,
|
||||
as List<GetAppUsersItemResponseDto>,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [GetAppUsersResponseModel].
|
||||
extension GetAppUsersResponseModelPatterns on GetAppUsersResponseModel {
|
||||
/// Adds pattern-matching-related methods to [GetAppUsersResponseDto].
|
||||
extension GetAppUsersResponseDtoPatterns on GetAppUsersResponseDto {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
@@ -89,10 +89,10 @@ extension GetAppUsersResponseModelPatterns on GetAppUsersResponseModel {
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _GetAppUsersResponseModel value)? $default,{required TResult orElse(),}){
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _GetAppUsersResponseDto value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _GetAppUsersResponseModel() when $default != null:
|
||||
case _GetAppUsersResponseDto() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
@@ -111,10 +111,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _GetAppUsersResponseModel value) $default,){
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _GetAppUsersResponseDto value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _GetAppUsersResponseModel():
|
||||
case _GetAppUsersResponseDto():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
@@ -132,10 +132,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _GetAppUsersResponseModel value)? $default,){
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _GetAppUsersResponseDto value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _GetAppUsersResponseModel() when $default != null:
|
||||
case _GetAppUsersResponseDto() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
@@ -153,9 +153,9 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<GetAppUsersItemResponseModel> items)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<GetAppUsersItemResponseDto> items)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _GetAppUsersResponseModel() when $default != null:
|
||||
case _GetAppUsersResponseDto() when $default != null:
|
||||
return $default(_that.items);case _:
|
||||
return orElse();
|
||||
|
||||
@@ -174,9 +174,9 @@ return $default(_that.items);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<GetAppUsersItemResponseModel> items) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<GetAppUsersItemResponseDto> items) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _GetAppUsersResponseModel():
|
||||
case _GetAppUsersResponseDto():
|
||||
return $default(_that.items);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
@@ -194,9 +194,9 @@ return $default(_that.items);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<GetAppUsersItemResponseModel> items)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<GetAppUsersItemResponseDto> items)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _GetAppUsersResponseModel() when $default != null:
|
||||
case _GetAppUsersResponseDto() when $default != null:
|
||||
return $default(_that.items);case _:
|
||||
return null;
|
||||
|
||||
@@ -208,32 +208,32 @@ return $default(_that.items);case _:
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _GetAppUsersResponseModel implements GetAppUsersResponseModel {
|
||||
const _GetAppUsersResponseModel({required final List<GetAppUsersItemResponseModel> items}): _items = items;
|
||||
factory _GetAppUsersResponseModel.fromJson(Map<String, dynamic> json) => _$GetAppUsersResponseModelFromJson(json);
|
||||
class _GetAppUsersResponseDto implements GetAppUsersResponseDto {
|
||||
const _GetAppUsersResponseDto({required final List<GetAppUsersItemResponseDto> items}): _items = items;
|
||||
factory _GetAppUsersResponseDto.fromJson(Map<String, dynamic> json) => _$GetAppUsersResponseDtoFromJson(json);
|
||||
|
||||
final List<GetAppUsersItemResponseModel> _items;
|
||||
@override List<GetAppUsersItemResponseModel> get items {
|
||||
final List<GetAppUsersItemResponseDto> _items;
|
||||
@override List<GetAppUsersItemResponseDto> get items {
|
||||
if (_items is EqualUnmodifiableListView) return _items;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_items);
|
||||
}
|
||||
|
||||
|
||||
/// Create a copy of GetAppUsersResponseModel
|
||||
/// Create a copy of GetAppUsersResponseDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$GetAppUsersResponseModelCopyWith<_GetAppUsersResponseModel> get copyWith => __$GetAppUsersResponseModelCopyWithImpl<_GetAppUsersResponseModel>(this, _$identity);
|
||||
_$GetAppUsersResponseDtoCopyWith<_GetAppUsersResponseDto> get copyWith => __$GetAppUsersResponseDtoCopyWithImpl<_GetAppUsersResponseDto>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$GetAppUsersResponseModelToJson(this, );
|
||||
return _$GetAppUsersResponseDtoToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GetAppUsersResponseModel&&const DeepCollectionEquality().equals(other._items, _items));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GetAppUsersResponseDto&&const DeepCollectionEquality().equals(other._items, _items));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -242,18 +242,18 @@ int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'GetAppUsersResponseModel(items: $items)';
|
||||
return 'GetAppUsersResponseDto(items: $items)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$GetAppUsersResponseModelCopyWith<$Res> implements $GetAppUsersResponseModelCopyWith<$Res> {
|
||||
factory _$GetAppUsersResponseModelCopyWith(_GetAppUsersResponseModel value, $Res Function(_GetAppUsersResponseModel) _then) = __$GetAppUsersResponseModelCopyWithImpl;
|
||||
abstract mixin class _$GetAppUsersResponseDtoCopyWith<$Res> implements $GetAppUsersResponseDtoCopyWith<$Res> {
|
||||
factory _$GetAppUsersResponseDtoCopyWith(_GetAppUsersResponseDto value, $Res Function(_GetAppUsersResponseDto) _then) = __$GetAppUsersResponseDtoCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
List<GetAppUsersItemResponseModel> items
|
||||
List<GetAppUsersItemResponseDto> items
|
||||
});
|
||||
|
||||
|
||||
@@ -261,19 +261,19 @@ $Res call({
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$GetAppUsersResponseModelCopyWithImpl<$Res>
|
||||
implements _$GetAppUsersResponseModelCopyWith<$Res> {
|
||||
__$GetAppUsersResponseModelCopyWithImpl(this._self, this._then);
|
||||
class __$GetAppUsersResponseDtoCopyWithImpl<$Res>
|
||||
implements _$GetAppUsersResponseDtoCopyWith<$Res> {
|
||||
__$GetAppUsersResponseDtoCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _GetAppUsersResponseModel _self;
|
||||
final $Res Function(_GetAppUsersResponseModel) _then;
|
||||
final _GetAppUsersResponseDto _self;
|
||||
final $Res Function(_GetAppUsersResponseDto) _then;
|
||||
|
||||
/// Create a copy of GetAppUsersResponseModel
|
||||
/// Create a copy of GetAppUsersResponseDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? items = null,}) {
|
||||
return _then(_GetAppUsersResponseModel(
|
||||
return _then(_GetAppUsersResponseDto(
|
||||
items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable
|
||||
as List<GetAppUsersItemResponseModel>,
|
||||
as List<GetAppUsersItemResponseDto>,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -282,22 +282,22 @@ as List<GetAppUsersItemResponseModel>,
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$GetAppUsersItemResponseModel {
|
||||
mixin _$GetAppUsersItemResponseDto {
|
||||
|
||||
String get id; String get delegationId; String get email; int get createdAt; int? get updatedAt; String get status; String get role; int get lastLogin; int get currentLogin; String get language; String get firstName; String get lastName; bool get hasApiKey; String get phone;
|
||||
/// Create a copy of GetAppUsersItemResponseModel
|
||||
/// Create a copy of GetAppUsersItemResponseDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$GetAppUsersItemResponseModelCopyWith<GetAppUsersItemResponseModel> get copyWith => _$GetAppUsersItemResponseModelCopyWithImpl<GetAppUsersItemResponseModel>(this as GetAppUsersItemResponseModel, _$identity);
|
||||
$GetAppUsersItemResponseDtoCopyWith<GetAppUsersItemResponseDto> get copyWith => _$GetAppUsersItemResponseDtoCopyWithImpl<GetAppUsersItemResponseDto>(this as GetAppUsersItemResponseDto, _$identity);
|
||||
|
||||
/// Serializes this GetAppUsersItemResponseModel to a JSON map.
|
||||
/// Serializes this GetAppUsersItemResponseDto to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is GetAppUsersItemResponseModel&&(identical(other.id, id) || other.id == id)&&(identical(other.delegationId, delegationId) || other.delegationId == delegationId)&&(identical(other.email, email) || other.email == email)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.status, status) || other.status == status)&&(identical(other.role, role) || other.role == role)&&(identical(other.lastLogin, lastLogin) || other.lastLogin == lastLogin)&&(identical(other.currentLogin, currentLogin) || other.currentLogin == currentLogin)&&(identical(other.language, language) || other.language == language)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.hasApiKey, hasApiKey) || other.hasApiKey == hasApiKey)&&(identical(other.phone, phone) || other.phone == phone));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is GetAppUsersItemResponseDto&&(identical(other.id, id) || other.id == id)&&(identical(other.delegationId, delegationId) || other.delegationId == delegationId)&&(identical(other.email, email) || other.email == email)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.status, status) || other.status == status)&&(identical(other.role, role) || other.role == role)&&(identical(other.lastLogin, lastLogin) || other.lastLogin == lastLogin)&&(identical(other.currentLogin, currentLogin) || other.currentLogin == currentLogin)&&(identical(other.language, language) || other.language == language)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.hasApiKey, hasApiKey) || other.hasApiKey == hasApiKey)&&(identical(other.phone, phone) || other.phone == phone));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -306,15 +306,15 @@ int get hashCode => Object.hash(runtimeType,id,delegationId,email,createdAt,upda
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'GetAppUsersItemResponseModel(id: $id, delegationId: $delegationId, email: $email, createdAt: $createdAt, updatedAt: $updatedAt, status: $status, role: $role, lastLogin: $lastLogin, currentLogin: $currentLogin, language: $language, firstName: $firstName, lastName: $lastName, hasApiKey: $hasApiKey, phone: $phone)';
|
||||
return 'GetAppUsersItemResponseDto(id: $id, delegationId: $delegationId, email: $email, createdAt: $createdAt, updatedAt: $updatedAt, status: $status, role: $role, lastLogin: $lastLogin, currentLogin: $currentLogin, language: $language, firstName: $firstName, lastName: $lastName, hasApiKey: $hasApiKey, phone: $phone)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $GetAppUsersItemResponseModelCopyWith<$Res> {
|
||||
factory $GetAppUsersItemResponseModelCopyWith(GetAppUsersItemResponseModel value, $Res Function(GetAppUsersItemResponseModel) _then) = _$GetAppUsersItemResponseModelCopyWithImpl;
|
||||
abstract mixin class $GetAppUsersItemResponseDtoCopyWith<$Res> {
|
||||
factory $GetAppUsersItemResponseDtoCopyWith(GetAppUsersItemResponseDto value, $Res Function(GetAppUsersItemResponseDto) _then) = _$GetAppUsersItemResponseDtoCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id, String delegationId, String email, int createdAt, int? updatedAt, String status, String role, int lastLogin, int currentLogin, String language, String firstName, String lastName, bool hasApiKey, String phone
|
||||
@@ -325,14 +325,14 @@ $Res call({
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$GetAppUsersItemResponseModelCopyWithImpl<$Res>
|
||||
implements $GetAppUsersItemResponseModelCopyWith<$Res> {
|
||||
_$GetAppUsersItemResponseModelCopyWithImpl(this._self, this._then);
|
||||
class _$GetAppUsersItemResponseDtoCopyWithImpl<$Res>
|
||||
implements $GetAppUsersItemResponseDtoCopyWith<$Res> {
|
||||
_$GetAppUsersItemResponseDtoCopyWithImpl(this._self, this._then);
|
||||
|
||||
final GetAppUsersItemResponseModel _self;
|
||||
final $Res Function(GetAppUsersItemResponseModel) _then;
|
||||
final GetAppUsersItemResponseDto _self;
|
||||
final $Res Function(GetAppUsersItemResponseDto) _then;
|
||||
|
||||
/// Create a copy of GetAppUsersItemResponseModel
|
||||
/// Create a copy of GetAppUsersItemResponseDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? delegationId = null,Object? email = null,Object? createdAt = null,Object? updatedAt = freezed,Object? status = null,Object? role = null,Object? lastLogin = null,Object? currentLogin = null,Object? language = null,Object? firstName = null,Object? lastName = null,Object? hasApiKey = null,Object? phone = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
@@ -357,8 +357,8 @@ as String,
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [GetAppUsersItemResponseModel].
|
||||
extension GetAppUsersItemResponseModelPatterns on GetAppUsersItemResponseModel {
|
||||
/// Adds pattern-matching-related methods to [GetAppUsersItemResponseDto].
|
||||
extension GetAppUsersItemResponseDtoPatterns on GetAppUsersItemResponseDto {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
@@ -371,10 +371,10 @@ extension GetAppUsersItemResponseModelPatterns on GetAppUsersItemResponseModel {
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _GetAppUsersItemResponseModel value)? $default,{required TResult orElse(),}){
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _GetAppUsersItemResponseDto value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _GetAppUsersItemResponseModel() when $default != null:
|
||||
case _GetAppUsersItemResponseDto() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
@@ -393,10 +393,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _GetAppUsersItemResponseModel value) $default,){
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _GetAppUsersItemResponseDto value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _GetAppUsersItemResponseModel():
|
||||
case _GetAppUsersItemResponseDto():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
@@ -414,10 +414,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _GetAppUsersItemResponseModel value)? $default,){
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _GetAppUsersItemResponseDto value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _GetAppUsersItemResponseModel() when $default != null:
|
||||
case _GetAppUsersItemResponseDto() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
@@ -437,7 +437,7 @@ return $default(_that);case _:
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String delegationId, String email, int createdAt, int? updatedAt, String status, String role, int lastLogin, int currentLogin, String language, String firstName, String lastName, bool hasApiKey, String phone)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _GetAppUsersItemResponseModel() when $default != null:
|
||||
case _GetAppUsersItemResponseDto() when $default != null:
|
||||
return $default(_that.id,_that.delegationId,_that.email,_that.createdAt,_that.updatedAt,_that.status,_that.role,_that.lastLogin,_that.currentLogin,_that.language,_that.firstName,_that.lastName,_that.hasApiKey,_that.phone);case _:
|
||||
return orElse();
|
||||
|
||||
@@ -458,7 +458,7 @@ return $default(_that.id,_that.delegationId,_that.email,_that.createdAt,_that.up
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String delegationId, String email, int createdAt, int? updatedAt, String status, String role, int lastLogin, int currentLogin, String language, String firstName, String lastName, bool hasApiKey, String phone) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _GetAppUsersItemResponseModel():
|
||||
case _GetAppUsersItemResponseDto():
|
||||
return $default(_that.id,_that.delegationId,_that.email,_that.createdAt,_that.updatedAt,_that.status,_that.role,_that.lastLogin,_that.currentLogin,_that.language,_that.firstName,_that.lastName,_that.hasApiKey,_that.phone);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
@@ -478,7 +478,7 @@ return $default(_that.id,_that.delegationId,_that.email,_that.createdAt,_that.up
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String delegationId, String email, int createdAt, int? updatedAt, String status, String role, int lastLogin, int currentLogin, String language, String firstName, String lastName, bool hasApiKey, String phone)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _GetAppUsersItemResponseModel() when $default != null:
|
||||
case _GetAppUsersItemResponseDto() when $default != null:
|
||||
return $default(_that.id,_that.delegationId,_that.email,_that.createdAt,_that.updatedAt,_that.status,_that.role,_that.lastLogin,_that.currentLogin,_that.language,_that.firstName,_that.lastName,_that.hasApiKey,_that.phone);case _:
|
||||
return null;
|
||||
|
||||
@@ -490,9 +490,9 @@ return $default(_that.id,_that.delegationId,_that.email,_that.createdAt,_that.up
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _GetAppUsersItemResponseModel implements GetAppUsersItemResponseModel {
|
||||
const _GetAppUsersItemResponseModel({required this.id, required this.delegationId, required this.email, required this.createdAt, required this.updatedAt, required this.status, required this.role, required this.lastLogin, required this.currentLogin, required this.language, required this.firstName, required this.lastName, required this.hasApiKey, required this.phone});
|
||||
factory _GetAppUsersItemResponseModel.fromJson(Map<String, dynamic> json) => _$GetAppUsersItemResponseModelFromJson(json);
|
||||
class _GetAppUsersItemResponseDto implements GetAppUsersItemResponseDto {
|
||||
const _GetAppUsersItemResponseDto({required this.id, required this.delegationId, required this.email, required this.createdAt, required this.updatedAt, required this.status, required this.role, required this.lastLogin, required this.currentLogin, required this.language, required this.firstName, required this.lastName, required this.hasApiKey, required this.phone});
|
||||
factory _GetAppUsersItemResponseDto.fromJson(Map<String, dynamic> json) => _$GetAppUsersItemResponseDtoFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
@override final String delegationId;
|
||||
@@ -509,20 +509,20 @@ class _GetAppUsersItemResponseModel implements GetAppUsersItemResponseModel {
|
||||
@override final bool hasApiKey;
|
||||
@override final String phone;
|
||||
|
||||
/// Create a copy of GetAppUsersItemResponseModel
|
||||
/// Create a copy of GetAppUsersItemResponseDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$GetAppUsersItemResponseModelCopyWith<_GetAppUsersItemResponseModel> get copyWith => __$GetAppUsersItemResponseModelCopyWithImpl<_GetAppUsersItemResponseModel>(this, _$identity);
|
||||
_$GetAppUsersItemResponseDtoCopyWith<_GetAppUsersItemResponseDto> get copyWith => __$GetAppUsersItemResponseDtoCopyWithImpl<_GetAppUsersItemResponseDto>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$GetAppUsersItemResponseModelToJson(this, );
|
||||
return _$GetAppUsersItemResponseDtoToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GetAppUsersItemResponseModel&&(identical(other.id, id) || other.id == id)&&(identical(other.delegationId, delegationId) || other.delegationId == delegationId)&&(identical(other.email, email) || other.email == email)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.status, status) || other.status == status)&&(identical(other.role, role) || other.role == role)&&(identical(other.lastLogin, lastLogin) || other.lastLogin == lastLogin)&&(identical(other.currentLogin, currentLogin) || other.currentLogin == currentLogin)&&(identical(other.language, language) || other.language == language)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.hasApiKey, hasApiKey) || other.hasApiKey == hasApiKey)&&(identical(other.phone, phone) || other.phone == phone));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GetAppUsersItemResponseDto&&(identical(other.id, id) || other.id == id)&&(identical(other.delegationId, delegationId) || other.delegationId == delegationId)&&(identical(other.email, email) || other.email == email)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.status, status) || other.status == status)&&(identical(other.role, role) || other.role == role)&&(identical(other.lastLogin, lastLogin) || other.lastLogin == lastLogin)&&(identical(other.currentLogin, currentLogin) || other.currentLogin == currentLogin)&&(identical(other.language, language) || other.language == language)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.hasApiKey, hasApiKey) || other.hasApiKey == hasApiKey)&&(identical(other.phone, phone) || other.phone == phone));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -531,15 +531,15 @@ int get hashCode => Object.hash(runtimeType,id,delegationId,email,createdAt,upda
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'GetAppUsersItemResponseModel(id: $id, delegationId: $delegationId, email: $email, createdAt: $createdAt, updatedAt: $updatedAt, status: $status, role: $role, lastLogin: $lastLogin, currentLogin: $currentLogin, language: $language, firstName: $firstName, lastName: $lastName, hasApiKey: $hasApiKey, phone: $phone)';
|
||||
return 'GetAppUsersItemResponseDto(id: $id, delegationId: $delegationId, email: $email, createdAt: $createdAt, updatedAt: $updatedAt, status: $status, role: $role, lastLogin: $lastLogin, currentLogin: $currentLogin, language: $language, firstName: $firstName, lastName: $lastName, hasApiKey: $hasApiKey, phone: $phone)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$GetAppUsersItemResponseModelCopyWith<$Res> implements $GetAppUsersItemResponseModelCopyWith<$Res> {
|
||||
factory _$GetAppUsersItemResponseModelCopyWith(_GetAppUsersItemResponseModel value, $Res Function(_GetAppUsersItemResponseModel) _then) = __$GetAppUsersItemResponseModelCopyWithImpl;
|
||||
abstract mixin class _$GetAppUsersItemResponseDtoCopyWith<$Res> implements $GetAppUsersItemResponseDtoCopyWith<$Res> {
|
||||
factory _$GetAppUsersItemResponseDtoCopyWith(_GetAppUsersItemResponseDto value, $Res Function(_GetAppUsersItemResponseDto) _then) = __$GetAppUsersItemResponseDtoCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id, String delegationId, String email, int createdAt, int? updatedAt, String status, String role, int lastLogin, int currentLogin, String language, String firstName, String lastName, bool hasApiKey, String phone
|
||||
@@ -550,17 +550,17 @@ $Res call({
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$GetAppUsersItemResponseModelCopyWithImpl<$Res>
|
||||
implements _$GetAppUsersItemResponseModelCopyWith<$Res> {
|
||||
__$GetAppUsersItemResponseModelCopyWithImpl(this._self, this._then);
|
||||
class __$GetAppUsersItemResponseDtoCopyWithImpl<$Res>
|
||||
implements _$GetAppUsersItemResponseDtoCopyWith<$Res> {
|
||||
__$GetAppUsersItemResponseDtoCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _GetAppUsersItemResponseModel _self;
|
||||
final $Res Function(_GetAppUsersItemResponseModel) _then;
|
||||
final _GetAppUsersItemResponseDto _self;
|
||||
final $Res Function(_GetAppUsersItemResponseDto) _then;
|
||||
|
||||
/// Create a copy of GetAppUsersItemResponseModel
|
||||
/// Create a copy of GetAppUsersItemResponseDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? delegationId = null,Object? email = null,Object? createdAt = null,Object? updatedAt = freezed,Object? status = null,Object? role = null,Object? lastLogin = null,Object? currentLogin = null,Object? language = null,Object? firstName = null,Object? lastName = null,Object? hasApiKey = null,Object? phone = null,}) {
|
||||
return _then(_GetAppUsersItemResponseModel(
|
||||
return _then(_GetAppUsersItemResponseDto(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,delegationId: null == delegationId ? _self.delegationId : delegationId // ignore: cast_nullable_to_non_nullable
|
||||
as String,email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
|
||||
@@ -1,28 +1,28 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'get_app_users_response_model.dart';
|
||||
part of 'get_app_users_response_dto.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_GetAppUsersResponseModel _$GetAppUsersResponseModelFromJson(
|
||||
_GetAppUsersResponseDto _$GetAppUsersResponseDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _GetAppUsersResponseModel(
|
||||
) => _GetAppUsersResponseDto(
|
||||
items: (json['items'] as List<dynamic>)
|
||||
.map(
|
||||
(e) => GetAppUsersItemResponseModel.fromJson(e as Map<String, dynamic>),
|
||||
(e) => GetAppUsersItemResponseDto.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$GetAppUsersResponseModelToJson(
|
||||
_GetAppUsersResponseModel instance,
|
||||
Map<String, dynamic> _$GetAppUsersResponseDtoToJson(
|
||||
_GetAppUsersResponseDto instance,
|
||||
) => <String, dynamic>{'items': instance.items};
|
||||
|
||||
_GetAppUsersItemResponseModel _$GetAppUsersItemResponseModelFromJson(
|
||||
_GetAppUsersItemResponseDto _$GetAppUsersItemResponseDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _GetAppUsersItemResponseModel(
|
||||
) => _GetAppUsersItemResponseDto(
|
||||
id: json['id'] as String,
|
||||
delegationId: json['delegationId'] as String,
|
||||
email: json['email'] as String,
|
||||
@@ -39,8 +39,8 @@ _GetAppUsersItemResponseModel _$GetAppUsersItemResponseModelFromJson(
|
||||
phone: json['phone'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$GetAppUsersItemResponseModelToJson(
|
||||
_GetAppUsersItemResponseModel instance,
|
||||
Map<String, dynamic> _$GetAppUsersItemResponseDtoToJson(
|
||||
_GetAppUsersItemResponseDto instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'delegationId': instance.delegationId,
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'package:account/src/features/linked_devices/domain/entities/update_device_request_entity.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'update_device_request_model.freezed.dart';
|
||||
part 'update_device_request_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class UpdateDeviceRequestModel with _$UpdateDeviceRequestModel {
|
||||
const factory UpdateDeviceRequestModel({
|
||||
required String identificator,
|
||||
required String carrierName,
|
||||
}) = _UpdateDeviceRequestModel;
|
||||
|
||||
factory UpdateDeviceRequestModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$UpdateDeviceRequestModelFromJson(json);
|
||||
}
|
||||
|
||||
extension UpdateDeviceRequestModelMapper on UpdateDeviceRequestEntity {
|
||||
UpdateDeviceRequestModel toModel() => UpdateDeviceRequestModel(
|
||||
identificator: identificator,
|
||||
carrierName: carrierName,
|
||||
);
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'update_device_request_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$UpdateDeviceRequestModel {
|
||||
|
||||
String get identificator; String get carrierName;
|
||||
/// Create a copy of UpdateDeviceRequestModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$UpdateDeviceRequestModelCopyWith<UpdateDeviceRequestModel> get copyWith => _$UpdateDeviceRequestModelCopyWithImpl<UpdateDeviceRequestModel>(this as UpdateDeviceRequestModel, _$identity);
|
||||
|
||||
/// Serializes this UpdateDeviceRequestModel to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is UpdateDeviceRequestModel&&(identical(other.identificator, identificator) || other.identificator == identificator)&&(identical(other.carrierName, carrierName) || other.carrierName == carrierName));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,identificator,carrierName);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UpdateDeviceRequestModel(identificator: $identificator, carrierName: $carrierName)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $UpdateDeviceRequestModelCopyWith<$Res> {
|
||||
factory $UpdateDeviceRequestModelCopyWith(UpdateDeviceRequestModel value, $Res Function(UpdateDeviceRequestModel) _then) = _$UpdateDeviceRequestModelCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String identificator, String carrierName
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$UpdateDeviceRequestModelCopyWithImpl<$Res>
|
||||
implements $UpdateDeviceRequestModelCopyWith<$Res> {
|
||||
_$UpdateDeviceRequestModelCopyWithImpl(this._self, this._then);
|
||||
|
||||
final UpdateDeviceRequestModel _self;
|
||||
final $Res Function(UpdateDeviceRequestModel) _then;
|
||||
|
||||
/// Create a copy of UpdateDeviceRequestModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? identificator = null,Object? carrierName = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
identificator: null == identificator ? _self.identificator : identificator // ignore: cast_nullable_to_non_nullable
|
||||
as String,carrierName: null == carrierName ? _self.carrierName : carrierName // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [UpdateDeviceRequestModel].
|
||||
extension UpdateDeviceRequestModelPatterns on UpdateDeviceRequestModel {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _UpdateDeviceRequestModel value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateDeviceRequestModel() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _UpdateDeviceRequestModel value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateDeviceRequestModel():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _UpdateDeviceRequestModel value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateDeviceRequestModel() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String identificator, String carrierName)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateDeviceRequestModel() when $default != null:
|
||||
return $default(_that.identificator,_that.carrierName);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String identificator, String carrierName) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateDeviceRequestModel():
|
||||
return $default(_that.identificator,_that.carrierName);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String identificator, String carrierName)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateDeviceRequestModel() when $default != null:
|
||||
return $default(_that.identificator,_that.carrierName);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _UpdateDeviceRequestModel implements UpdateDeviceRequestModel {
|
||||
const _UpdateDeviceRequestModel({required this.identificator, required this.carrierName});
|
||||
factory _UpdateDeviceRequestModel.fromJson(Map<String, dynamic> json) => _$UpdateDeviceRequestModelFromJson(json);
|
||||
|
||||
@override final String identificator;
|
||||
@override final String carrierName;
|
||||
|
||||
/// Create a copy of UpdateDeviceRequestModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$UpdateDeviceRequestModelCopyWith<_UpdateDeviceRequestModel> get copyWith => __$UpdateDeviceRequestModelCopyWithImpl<_UpdateDeviceRequestModel>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$UpdateDeviceRequestModelToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UpdateDeviceRequestModel&&(identical(other.identificator, identificator) || other.identificator == identificator)&&(identical(other.carrierName, carrierName) || other.carrierName == carrierName));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,identificator,carrierName);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UpdateDeviceRequestModel(identificator: $identificator, carrierName: $carrierName)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$UpdateDeviceRequestModelCopyWith<$Res> implements $UpdateDeviceRequestModelCopyWith<$Res> {
|
||||
factory _$UpdateDeviceRequestModelCopyWith(_UpdateDeviceRequestModel value, $Res Function(_UpdateDeviceRequestModel) _then) = __$UpdateDeviceRequestModelCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String identificator, String carrierName
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$UpdateDeviceRequestModelCopyWithImpl<$Res>
|
||||
implements _$UpdateDeviceRequestModelCopyWith<$Res> {
|
||||
__$UpdateDeviceRequestModelCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _UpdateDeviceRequestModel _self;
|
||||
final $Res Function(_UpdateDeviceRequestModel) _then;
|
||||
|
||||
/// Create a copy of UpdateDeviceRequestModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? identificator = null,Object? carrierName = null,}) {
|
||||
return _then(_UpdateDeviceRequestModel(
|
||||
identificator: null == identificator ? _self.identificator : identificator // ignore: cast_nullable_to_non_nullable
|
||||
as String,carrierName: null == carrierName ? _self.carrierName : carrierName // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -1,21 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'update_device_request_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_UpdateDeviceRequestModel _$UpdateDeviceRequestModelFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _UpdateDeviceRequestModel(
|
||||
identificator: json['identificator'] as String,
|
||||
carrierName: json['carrierName'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UpdateDeviceRequestModelToJson(
|
||||
_UpdateDeviceRequestModel instance,
|
||||
) => <String, dynamic>{
|
||||
'identificator': instance.identificator,
|
||||
'carrierName': instance.carrierName,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:account/src/features/personal_data/domain/entities/update_user_request_entity.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'update_user_request_dto.freezed.dart';
|
||||
part 'update_user_request_dto.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class UpdateUserRequestDto with _$UpdateUserRequestDto {
|
||||
const factory UpdateUserRequestDto({
|
||||
required String id,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required String phone,
|
||||
required String language,
|
||||
}) = _UpdateUserRequestDto;
|
||||
|
||||
factory UpdateUserRequestDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$UpdateUserRequestDtoFromJson(json);
|
||||
}
|
||||
|
||||
extension UpdateUserRequestDtoMapper on UpdateUserRequestEntity {
|
||||
UpdateUserRequestDto toDto() => UpdateUserRequestDto(
|
||||
id: id,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
phone: phone,
|
||||
language: language,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'update_user_request_dto.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$UpdateUserRequestDto {
|
||||
|
||||
String get id; String get firstName; String get lastName; String get phone; String get language;
|
||||
/// Create a copy of UpdateUserRequestDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$UpdateUserRequestDtoCopyWith<UpdateUserRequestDto> get copyWith => _$UpdateUserRequestDtoCopyWithImpl<UpdateUserRequestDto>(this as UpdateUserRequestDto, _$identity);
|
||||
|
||||
/// Serializes this UpdateUserRequestDto to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is UpdateUserRequestDto&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.phone, phone) || other.phone == phone)&&(identical(other.language, language) || other.language == language));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,firstName,lastName,phone,language);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UpdateUserRequestDto(id: $id, firstName: $firstName, lastName: $lastName, phone: $phone, language: $language)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $UpdateUserRequestDtoCopyWith<$Res> {
|
||||
factory $UpdateUserRequestDtoCopyWith(UpdateUserRequestDto value, $Res Function(UpdateUserRequestDto) _then) = _$UpdateUserRequestDtoCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id, String firstName, String lastName, String phone, String language
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$UpdateUserRequestDtoCopyWithImpl<$Res>
|
||||
implements $UpdateUserRequestDtoCopyWith<$Res> {
|
||||
_$UpdateUserRequestDtoCopyWithImpl(this._self, this._then);
|
||||
|
||||
final UpdateUserRequestDto _self;
|
||||
final $Res Function(UpdateUserRequestDto) _then;
|
||||
|
||||
/// Create a copy of UpdateUserRequestDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? phone = null,Object? language = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
|
||||
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
|
||||
as String,phone: null == phone ? _self.phone : phone // ignore: cast_nullable_to_non_nullable
|
||||
as String,language: null == language ? _self.language : language // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [UpdateUserRequestDto].
|
||||
extension UpdateUserRequestDtoPatterns on UpdateUserRequestDto {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _UpdateUserRequestDto value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateUserRequestDto() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _UpdateUserRequestDto value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateUserRequestDto():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _UpdateUserRequestDto value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateUserRequestDto() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String phone, String language)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateUserRequestDto() when $default != null:
|
||||
return $default(_that.id,_that.firstName,_that.lastName,_that.phone,_that.language);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String phone, String language) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateUserRequestDto():
|
||||
return $default(_that.id,_that.firstName,_that.lastName,_that.phone,_that.language);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String firstName, String lastName, String phone, String language)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateUserRequestDto() when $default != null:
|
||||
return $default(_that.id,_that.firstName,_that.lastName,_that.phone,_that.language);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _UpdateUserRequestDto implements UpdateUserRequestDto {
|
||||
const _UpdateUserRequestDto({required this.id, required this.firstName, required this.lastName, required this.phone, required this.language});
|
||||
factory _UpdateUserRequestDto.fromJson(Map<String, dynamic> json) => _$UpdateUserRequestDtoFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
@override final String firstName;
|
||||
@override final String lastName;
|
||||
@override final String phone;
|
||||
@override final String language;
|
||||
|
||||
/// Create a copy of UpdateUserRequestDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$UpdateUserRequestDtoCopyWith<_UpdateUserRequestDto> get copyWith => __$UpdateUserRequestDtoCopyWithImpl<_UpdateUserRequestDto>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$UpdateUserRequestDtoToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UpdateUserRequestDto&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.phone, phone) || other.phone == phone)&&(identical(other.language, language) || other.language == language));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,firstName,lastName,phone,language);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UpdateUserRequestDto(id: $id, firstName: $firstName, lastName: $lastName, phone: $phone, language: $language)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$UpdateUserRequestDtoCopyWith<$Res> implements $UpdateUserRequestDtoCopyWith<$Res> {
|
||||
factory _$UpdateUserRequestDtoCopyWith(_UpdateUserRequestDto value, $Res Function(_UpdateUserRequestDto) _then) = __$UpdateUserRequestDtoCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id, String firstName, String lastName, String phone, String language
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$UpdateUserRequestDtoCopyWithImpl<$Res>
|
||||
implements _$UpdateUserRequestDtoCopyWith<$Res> {
|
||||
__$UpdateUserRequestDtoCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _UpdateUserRequestDto _self;
|
||||
final $Res Function(_UpdateUserRequestDto) _then;
|
||||
|
||||
/// Create a copy of UpdateUserRequestDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? phone = null,Object? language = null,}) {
|
||||
return _then(_UpdateUserRequestDto(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
|
||||
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
|
||||
as String,phone: null == phone ? _self.phone : phone // ignore: cast_nullable_to_non_nullable
|
||||
as String,language: null == language ? _self.language : language // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1,27 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'update_user_request_dto.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_UpdateUserRequestDto _$UpdateUserRequestDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _UpdateUserRequestDto(
|
||||
id: json['id'] as String,
|
||||
firstName: json['firstName'] as String,
|
||||
lastName: json['lastName'] as String,
|
||||
phone: json['phone'] as String,
|
||||
language: json['language'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UpdateUserRequestDtoToJson(
|
||||
_UpdateUserRequestDto instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'firstName': instance.firstName,
|
||||
'lastName': instance.lastName,
|
||||
'phone': instance.phone,
|
||||
'language': instance.language,
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import 'package:account/src/features/personal_data/domain/entities/update_user_request_entity.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'update_user_request_model.freezed.dart';
|
||||
part 'update_user_request_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class UpdateUserRequestModel with _$UpdateUserRequestModel {
|
||||
const factory UpdateUserRequestModel({
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? phone,
|
||||
}) = _UpdateUserRequestModel;
|
||||
|
||||
factory UpdateUserRequestModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$UpdateUserRequestModelFromJson(json);
|
||||
}
|
||||
|
||||
extension UpdateUserRequestModelMapper on UpdateUserRequestEntity {
|
||||
UpdateUserRequestModel toModel() => UpdateUserRequestModel(
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
phone: phone,
|
||||
);
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'update_user_request_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$UpdateUserRequestModel {
|
||||
|
||||
String? get firstName; String? get lastName; String? get phone;
|
||||
/// Create a copy of UpdateUserRequestModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$UpdateUserRequestModelCopyWith<UpdateUserRequestModel> get copyWith => _$UpdateUserRequestModelCopyWithImpl<UpdateUserRequestModel>(this as UpdateUserRequestModel, _$identity);
|
||||
|
||||
/// Serializes this UpdateUserRequestModel to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is UpdateUserRequestModel&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.phone, phone) || other.phone == phone));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,firstName,lastName,phone);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UpdateUserRequestModel(firstName: $firstName, lastName: $lastName, phone: $phone)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $UpdateUserRequestModelCopyWith<$Res> {
|
||||
factory $UpdateUserRequestModelCopyWith(UpdateUserRequestModel value, $Res Function(UpdateUserRequestModel) _then) = _$UpdateUserRequestModelCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String? firstName, String? lastName, String? phone
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$UpdateUserRequestModelCopyWithImpl<$Res>
|
||||
implements $UpdateUserRequestModelCopyWith<$Res> {
|
||||
_$UpdateUserRequestModelCopyWithImpl(this._self, this._then);
|
||||
|
||||
final UpdateUserRequestModel _self;
|
||||
final $Res Function(UpdateUserRequestModel) _then;
|
||||
|
||||
/// Create a copy of UpdateUserRequestModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? firstName = freezed,Object? lastName = freezed,Object? phone = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
firstName: freezed == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,lastName: freezed == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,phone: freezed == phone ? _self.phone : phone // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [UpdateUserRequestModel].
|
||||
extension UpdateUserRequestModelPatterns on UpdateUserRequestModel {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _UpdateUserRequestModel value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateUserRequestModel() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _UpdateUserRequestModel value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateUserRequestModel():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _UpdateUserRequestModel value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateUserRequestModel() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String? firstName, String? lastName, String? phone)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateUserRequestModel() when $default != null:
|
||||
return $default(_that.firstName,_that.lastName,_that.phone);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String? firstName, String? lastName, String? phone) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateUserRequestModel():
|
||||
return $default(_that.firstName,_that.lastName,_that.phone);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String? firstName, String? lastName, String? phone)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UpdateUserRequestModel() when $default != null:
|
||||
return $default(_that.firstName,_that.lastName,_that.phone);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _UpdateUserRequestModel implements UpdateUserRequestModel {
|
||||
const _UpdateUserRequestModel({this.firstName, this.lastName, this.phone});
|
||||
factory _UpdateUserRequestModel.fromJson(Map<String, dynamic> json) => _$UpdateUserRequestModelFromJson(json);
|
||||
|
||||
@override final String? firstName;
|
||||
@override final String? lastName;
|
||||
@override final String? phone;
|
||||
|
||||
/// Create a copy of UpdateUserRequestModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$UpdateUserRequestModelCopyWith<_UpdateUserRequestModel> get copyWith => __$UpdateUserRequestModelCopyWithImpl<_UpdateUserRequestModel>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$UpdateUserRequestModelToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UpdateUserRequestModel&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.phone, phone) || other.phone == phone));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,firstName,lastName,phone);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UpdateUserRequestModel(firstName: $firstName, lastName: $lastName, phone: $phone)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$UpdateUserRequestModelCopyWith<$Res> implements $UpdateUserRequestModelCopyWith<$Res> {
|
||||
factory _$UpdateUserRequestModelCopyWith(_UpdateUserRequestModel value, $Res Function(_UpdateUserRequestModel) _then) = __$UpdateUserRequestModelCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String? firstName, String? lastName, String? phone
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$UpdateUserRequestModelCopyWithImpl<$Res>
|
||||
implements _$UpdateUserRequestModelCopyWith<$Res> {
|
||||
__$UpdateUserRequestModelCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _UpdateUserRequestModel _self;
|
||||
final $Res Function(_UpdateUserRequestModel) _then;
|
||||
|
||||
/// Create a copy of UpdateUserRequestModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? firstName = freezed,Object? lastName = freezed,Object? phone = freezed,}) {
|
||||
return _then(_UpdateUserRequestModel(
|
||||
firstName: freezed == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,lastName: freezed == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,phone: freezed == phone ? _self.phone : phone // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -1,23 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'update_user_request_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_UpdateUserRequestModel _$UpdateUserRequestModelFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _UpdateUserRequestModel(
|
||||
firstName: json['firstName'] as String?,
|
||||
lastName: json['lastName'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UpdateUserRequestModelToJson(
|
||||
_UpdateUserRequestModel instance,
|
||||
) => <String, dynamic>{
|
||||
'firstName': instance.firstName,
|
||||
'lastName': instance.lastName,
|
||||
'phone': instance.phone,
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:account/src/features/change_password/domain/models/entities/change_password_request_entity.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
|
||||
import '../../domain/repositories/change_password_repository.dart';
|
||||
import '../datasource/change_password_remote_datasource.dart';
|
||||
@@ -12,7 +14,11 @@ class ChangePasswordRepositoryImpl implements ChangePasswordRepository {
|
||||
Future<void> changePassword({
|
||||
required String userId,
|
||||
required ChangePasswordRequestEntity request,
|
||||
}) {
|
||||
return _remote.changePassword(userId: userId, request: request);
|
||||
}) async {
|
||||
try {
|
||||
await _remote.changePassword(userId: userId, request: request);
|
||||
} on DioException catch (error) {
|
||||
throw mapDioError(error, defaultMessage: 'Error to change password');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:account/src/core/data/datasource/devices_remote_datasource.dart';
|
||||
import 'package:account/src/features/linked_devices/domain/entities/update_device_request_entity.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
import 'package:sf_shared/sf_shared.dart' show DeviceEntity;
|
||||
|
||||
import '../../domain/repositories/devices_repository.dart';
|
||||
|
||||
@@ -9,12 +11,20 @@ class DevicesRepositoryImpl implements DevicesRepository {
|
||||
final DevicesRemoteDatasource _remote;
|
||||
|
||||
@override
|
||||
Future<void> deleteDevice({required String deviceId}) {
|
||||
return _remote.deleteDevice(deviceId: deviceId);
|
||||
Future<void> deleteDevice({required String deviceId}) async {
|
||||
try {
|
||||
await _remote.deleteDevice(deviceId: deviceId);
|
||||
} on DioException catch (error) {
|
||||
throw mapDioError(error, defaultMessage: 'Error to delete device');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateDevice({required UpdateDeviceRequestEntity request}) {
|
||||
return _remote.updateDevice(request: request);
|
||||
Future<void> updateDevice({required DeviceEntity device}) async {
|
||||
try {
|
||||
await _remote.updateDevice(device: device);
|
||||
} on DioException catch (error) {
|
||||
throw mapDioError(error, defaultMessage: 'Error to update device');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:account/src/features/personal_data/domain/entities/update_user_request_entity.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
|
||||
import '../../domain/repositories/users_repository.dart';
|
||||
@@ -13,17 +15,32 @@ class UsersRepositoryImpl implements UsersRepository {
|
||||
Future<void> updateUser({
|
||||
required String userId,
|
||||
required UpdateUserRequestEntity request,
|
||||
}) {
|
||||
return _remote.updateUser(userId: userId, request: request);
|
||||
}) async {
|
||||
try {
|
||||
await _remote.updateUser(userId: userId, request: request);
|
||||
} on DioException catch (error) {
|
||||
throw mapDioError(error, defaultMessage: 'Error to update user');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<UserEntity>> getUsers({required String userId}) {
|
||||
return _remote.getUsers(userId: userId);
|
||||
Future<List<UserEntity>> getUsers({required String userId}) async {
|
||||
try {
|
||||
return await _remote.getUsers(userId: userId);
|
||||
} on DioException catch (error) {
|
||||
throw mapDioError(
|
||||
error,
|
||||
defaultMessage: error.message ?? 'Error getting devices',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteUser({required String userId}) {
|
||||
return _remote.deleteUser(userId: userId);
|
||||
Future<void> deleteUser({required String userId}) async {
|
||||
try {
|
||||
await _remote.deleteUser(userId: userId);
|
||||
} on DioException catch (error) {
|
||||
throw mapDioError(error, defaultMessage: 'Error to delete device');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:account/src/features/linked_devices/domain/entities/update_device_request_entity.dart';
|
||||
import 'package:sf_shared/sf_shared.dart' show DeviceEntity;
|
||||
|
||||
abstract class DevicesRepository {
|
||||
Future<void> deleteDevice({required String deviceId});
|
||||
|
||||
Future<void> updateDevice({required UpdateDeviceRequestEntity request});
|
||||
Future<void> updateDevice({required DeviceEntity device});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:account/src/features/account_settings/presentation/state/account_settings_view_model.dart';
|
||||
import 'package:account/src/features/account_settings/presentation/providers/account_settings_controller.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:legacy_shared/legacy_shared.dart';
|
||||
import 'package:legacy_theme/legacy_theme.dart';
|
||||
import 'package:legacy_ui/legacy_ui.dart';
|
||||
import 'package:navigation/navigation.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:utils/utils.dart';
|
||||
|
||||
@@ -12,37 +15,33 @@ import 'widgets/reg_code_dialog.dart';
|
||||
|
||||
class AccountSettingsScreen extends ConsumerWidget {
|
||||
final NavigationContract navigationContract;
|
||||
static final _privacyUrl =
|
||||
'https://savefamilygps.com/pages/politica-de-privacidad-reloj-gps-infantil-localizador-savefamily';
|
||||
|
||||
const AccountSettingsScreen({super.key, required this.navigationContract});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = ref.watch(themePortProvider);
|
||||
final color = theme.getColorFor(ThemeCode.legacyPrimary);
|
||||
final color = context.sfColors.legacyPrimary;
|
||||
final selectedDevice = ref.watch(selectedDeviceProvider).value;
|
||||
final isLoggingOut = ref.watch(
|
||||
accountSettingsViewModelProvider.select((s) => s.isLoggingOut),
|
||||
accountSettingsControllerProvider.select((s) => s.isLoading),
|
||||
);
|
||||
|
||||
ref.listen(accountSettingsViewModelProvider.select((s) => s.isLoggingOut), (
|
||||
prev,
|
||||
isLoggingOut,
|
||||
) {
|
||||
if (prev == true && !isLoggingOut) {
|
||||
navigationContract.goTo(AppRoutes.legacyLogin);
|
||||
ref.listen(accountSettingsControllerProvider, (prev, next) async {
|
||||
if (prev != null && prev.isLoading && !next.isLoading) {
|
||||
await clearSessionData();
|
||||
ref.invalidate(legacyDevicesProvider);
|
||||
ref.invalidate(selectedDeviceProvider);
|
||||
if (context.mounted) navigationContract.goTo(AppRoutes.legacyLogin);
|
||||
}
|
||||
});
|
||||
|
||||
return LegacyPageLayout(
|
||||
theme: theme,
|
||||
title: context.translate(I18n.accountSettings),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: SizeUtils.getByScreen(
|
||||
small: EdgeInsets.symmetric(horizontal: 22, vertical: 10),
|
||||
big: EdgeInsets.symmetric(horizontal: 21, vertical: 8),
|
||||
small: const EdgeInsets.symmetric(horizontal: 22, vertical: 10),
|
||||
big: const EdgeInsets.symmetric(horizontal: 21, vertical: 8),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -78,11 +77,18 @@ class AccountSettingsScreen extends ConsumerWidget {
|
||||
text: I18n.linkedDevices,
|
||||
color: color,
|
||||
),
|
||||
// _item(context, onPressed: () => navigationContract.pushTo(AppRoutes.appUsers), icon: Icons.groups_outlined, text: I18n.appUsers, color: color),
|
||||
_item(
|
||||
context,
|
||||
onPressed: () =>
|
||||
navigationContract.pushTo(AppRoutes.appUsers),
|
||||
icon: Icons.groups_outlined,
|
||||
text: I18n.appUsers,
|
||||
color: color,
|
||||
),
|
||||
_item(
|
||||
context,
|
||||
onPressed: () async {
|
||||
final Uri url = Uri.parse(_privacyUrl);
|
||||
final Uri url = Uri.parse(BrandLinks.privacyPolicy);
|
||||
if (!await launchUrl(url)) {
|
||||
throw Exception('Could not launch $url');
|
||||
}
|
||||
@@ -94,9 +100,9 @@ class AccountSettingsScreen extends ConsumerWidget {
|
||||
_item(
|
||||
context,
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
showLegacyDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
builder: (_) => Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: RegCodeDialog(
|
||||
regCode: selectedDevice?.id ?? '',
|
||||
@@ -124,12 +130,12 @@ class AccountSettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
footer: Container(
|
||||
padding: SizeUtils.getByScreen(
|
||||
small: EdgeInsets.symmetric(vertical: 12, horizontal: 30),
|
||||
big: EdgeInsets.symmetric(vertical: 10, horizontal: 28),
|
||||
small: const EdgeInsets.symmetric(vertical: 12, horizontal: 30),
|
||||
big: const EdgeInsets.symmetric(vertical: 10, horizontal: 28),
|
||||
),
|
||||
child: PrimaryButton(
|
||||
text: context.translate(I18n.logOut),
|
||||
color: theme.getColorFor(ThemeCode.legacyPrimary),
|
||||
color: context.sfColors.legacyPrimary,
|
||||
leading: isLoggingOut
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
@@ -142,7 +148,7 @@ class AccountSettingsScreen extends ConsumerWidget {
|
||||
: null,
|
||||
onPressed: isLoggingOut
|
||||
? () {}
|
||||
: ref.read(accountSettingsViewModelProvider.notifier).logout,
|
||||
: ref.read(accountSettingsControllerProvider.notifier).logout,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:legacy_auth/legacy_auth.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:sf_tracking/sf_tracking.dart';
|
||||
|
||||
part 'account_settings_controller.g.dart';
|
||||
|
||||
@riverpod
|
||||
class AccountSettingsController extends _$AccountSettingsController {
|
||||
@override
|
||||
FutureOr<void> build() {}
|
||||
|
||||
Future<void> logout() async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
await ref.read(legacyAuthRepositoryProvider).logout();
|
||||
} catch (_) {}
|
||||
unawaited(ref.read(sfTrackingProvider).legacyAuthLogout());
|
||||
state = const AsyncData(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account_settings_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(AccountSettingsController)
|
||||
const accountSettingsControllerProvider = AccountSettingsControllerProvider._();
|
||||
|
||||
final class AccountSettingsControllerProvider
|
||||
extends $AsyncNotifierProvider<AccountSettingsController, void> {
|
||||
const AccountSettingsControllerProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'accountSettingsControllerProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$accountSettingsControllerHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AccountSettingsController create() => AccountSettingsController();
|
||||
}
|
||||
|
||||
String _$accountSettingsControllerHash() =>
|
||||
r'8ca0c05ca6f2d5696126f5ab7ade83419c544afe';
|
||||
|
||||
abstract class _$AccountSettingsController extends $AsyncNotifier<void> {
|
||||
FutureOr<void> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
build();
|
||||
final ref = this.ref as $Ref<AsyncValue<void>, void>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<void>, void>,
|
||||
AsyncValue<void>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, null);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:account/src/features/account_settings/presentation/state/account_settings_view_state.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:legacy_auth/legacy_auth.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
import 'package:sf_tracking/sf_tracking.dart';
|
||||
|
||||
final accountSettingsViewModelProvider =
|
||||
NotifierProvider.autoDispose<
|
||||
AccountSettingsViewModel,
|
||||
AccountSettingsViewState
|
||||
>(AccountSettingsViewModel.new);
|
||||
|
||||
class AccountSettingsViewModel extends Notifier<AccountSettingsViewState> {
|
||||
late final SfTrackingRepository _tracking;
|
||||
|
||||
@override
|
||||
AccountSettingsViewState build() {
|
||||
_tracking = ref.read(sfTrackingProvider);
|
||||
return const AccountSettingsViewState();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
if (state.isLoggingOut) return;
|
||||
|
||||
state = state.copyWith(isLoggingOut: true, errorMessage: '');
|
||||
|
||||
try {
|
||||
await ref.read(legacyAuthRepositoryProvider).logout();
|
||||
} catch (_) {}
|
||||
|
||||
await clearSessionData();
|
||||
|
||||
unawaited(_tracking.legacyAuthLogout());
|
||||
|
||||
state = state.copyWith(isLoggingOut: false);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'account_settings_view_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
abstract class AccountSettingsViewState with _$AccountSettingsViewState {
|
||||
const factory AccountSettingsViewState({
|
||||
@Default(false) bool isLoggingOut,
|
||||
@Default('') String errorMessage,
|
||||
}) = _AccountSettingsViewState;
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'account_settings_view_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$AccountSettingsViewState {
|
||||
|
||||
bool get isLoggingOut; String get errorMessage;
|
||||
/// Create a copy of AccountSettingsViewState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$AccountSettingsViewStateCopyWith<AccountSettingsViewState> get copyWith => _$AccountSettingsViewStateCopyWithImpl<AccountSettingsViewState>(this as AccountSettingsViewState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is AccountSettingsViewState&&(identical(other.isLoggingOut, isLoggingOut) || other.isLoggingOut == isLoggingOut)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,isLoggingOut,errorMessage);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AccountSettingsViewState(isLoggingOut: $isLoggingOut, errorMessage: $errorMessage)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $AccountSettingsViewStateCopyWith<$Res> {
|
||||
factory $AccountSettingsViewStateCopyWith(AccountSettingsViewState value, $Res Function(AccountSettingsViewState) _then) = _$AccountSettingsViewStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
bool isLoggingOut, String errorMessage
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$AccountSettingsViewStateCopyWithImpl<$Res>
|
||||
implements $AccountSettingsViewStateCopyWith<$Res> {
|
||||
_$AccountSettingsViewStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final AccountSettingsViewState _self;
|
||||
final $Res Function(AccountSettingsViewState) _then;
|
||||
|
||||
/// Create a copy of AccountSettingsViewState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? isLoggingOut = null,Object? errorMessage = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
isLoggingOut: null == isLoggingOut ? _self.isLoggingOut : isLoggingOut // ignore: cast_nullable_to_non_nullable
|
||||
as bool,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [AccountSettingsViewState].
|
||||
extension AccountSettingsViewStatePatterns on AccountSettingsViewState {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _AccountSettingsViewState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _AccountSettingsViewState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _AccountSettingsViewState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _AccountSettingsViewState():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _AccountSettingsViewState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _AccountSettingsViewState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isLoggingOut, String errorMessage)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _AccountSettingsViewState() when $default != null:
|
||||
return $default(_that.isLoggingOut,_that.errorMessage);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isLoggingOut, String errorMessage) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _AccountSettingsViewState():
|
||||
return $default(_that.isLoggingOut,_that.errorMessage);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isLoggingOut, String errorMessage)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _AccountSettingsViewState() when $default != null:
|
||||
return $default(_that.isLoggingOut,_that.errorMessage);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _AccountSettingsViewState implements AccountSettingsViewState {
|
||||
const _AccountSettingsViewState({this.isLoggingOut = false, this.errorMessage = ''});
|
||||
|
||||
|
||||
@override@JsonKey() final bool isLoggingOut;
|
||||
@override@JsonKey() final String errorMessage;
|
||||
|
||||
/// Create a copy of AccountSettingsViewState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$AccountSettingsViewStateCopyWith<_AccountSettingsViewState> get copyWith => __$AccountSettingsViewStateCopyWithImpl<_AccountSettingsViewState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AccountSettingsViewState&&(identical(other.isLoggingOut, isLoggingOut) || other.isLoggingOut == isLoggingOut)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,isLoggingOut,errorMessage);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AccountSettingsViewState(isLoggingOut: $isLoggingOut, errorMessage: $errorMessage)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$AccountSettingsViewStateCopyWith<$Res> implements $AccountSettingsViewStateCopyWith<$Res> {
|
||||
factory _$AccountSettingsViewStateCopyWith(_AccountSettingsViewState value, $Res Function(_AccountSettingsViewState) _then) = __$AccountSettingsViewStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
bool isLoggingOut, String errorMessage
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$AccountSettingsViewStateCopyWithImpl<$Res>
|
||||
implements _$AccountSettingsViewStateCopyWith<$Res> {
|
||||
__$AccountSettingsViewStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _AccountSettingsViewState _self;
|
||||
final $Res Function(_AccountSettingsViewState) _then;
|
||||
|
||||
/// Create a copy of AccountSettingsViewState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? isLoggingOut = null,Object? errorMessage = null,}) {
|
||||
return _then(_AccountSettingsViewState(
|
||||
isLoggingOut: null == isLoggingOut ? _self.isLoggingOut : isLoggingOut // ignore: cast_nullable_to_non_nullable
|
||||
as bool,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:account/src/features/app_users/presentation/state/app_users_view_model.dart';
|
||||
import 'package:account/src/features/app_users/presentation/providers/app_users_edit_mode_provider.dart';
|
||||
import 'package:account/src/features/app_users/presentation/providers/app_users_provider.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:legacy_shared/legacy_shared.dart';
|
||||
import 'package:legacy_theme/legacy_theme.dart';
|
||||
import 'package:legacy_ui/legacy_ui.dart';
|
||||
import 'package:navigation/navigation.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
@@ -15,36 +17,38 @@ class AppUsersScreen extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final vm = ref.read(appUsersViewModelProvider.notifier);
|
||||
final state = ref.watch(appUsersViewModelProvider);
|
||||
final usersAsync = ref.watch(appUsersProvider);
|
||||
final isEditing = ref.watch(appUsersEditModeProvider);
|
||||
final toggleEditing = ref.read(appUsersEditModeProvider.notifier).toggle;
|
||||
|
||||
final theme = ref.watch(themePortProvider);
|
||||
ref.listen(appUsersProvider, (_, next) => next.showErrorOn(context));
|
||||
|
||||
return LegacyPageLayout(
|
||||
theme: theme,
|
||||
showEdit: true,
|
||||
onEditChange: vm.toggleIsEditing,
|
||||
onEditChange: toggleEditing,
|
||||
title: context.translate(I18n.appUsers),
|
||||
body: Container(
|
||||
padding: SizeUtils.getByScreen(
|
||||
small: EdgeInsets.symmetric(horizontal: 22, vertical: 10),
|
||||
big: EdgeInsets.symmetric(horizontal: 21, vertical: 8),
|
||||
),
|
||||
child: ListView.separated(
|
||||
itemBuilder: (BuildContext context, int index) => AppUserCard(
|
||||
user: state.appUsers[index],
|
||||
isEditing: state.isEditing,
|
||||
child: usersAsync.when(
|
||||
data: (users) => ListView.separated(
|
||||
itemBuilder: (_, index) =>
|
||||
AppUserCard(user: users[index], isEditing: isEditing),
|
||||
separatorBuilder: (_, __) =>
|
||||
SizedBox(height: SizeUtils.getByScreen(small: 18, big: 17)),
|
||||
itemCount: users.length,
|
||||
),
|
||||
separatorBuilder: (BuildContext context, int index) =>
|
||||
SizedBox(height: SizeUtils.getByScreen(small: 18, big: 17)),
|
||||
itemCount: state.appUsers.length,
|
||||
loading: () => const LegacyLoadingIndicator(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
footer: state.isEditing
|
||||
footer: isEditing
|
||||
? PrimaryButton(
|
||||
onPressed: vm.toggleIsEditing,
|
||||
onPressed: toggleEditing,
|
||||
text: context.translate(I18n.save),
|
||||
color: theme.getColorFor(ThemeCode.legacyPrimary),
|
||||
color: context.sfColors.legacyPrimary,
|
||||
height: SizeUtils.getByScreen(small: 44, big: 42),
|
||||
)
|
||||
: null,
|
||||
@@ -60,8 +64,6 @@ class AppUserCard extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = ref.watch(themePortProvider);
|
||||
|
||||
return Container(
|
||||
padding: SizeUtils.getByScreen(
|
||||
small: EdgeInsets.symmetric(horizontal: 22, vertical: 10),
|
||||
@@ -71,20 +73,20 @@ class AppUserCard extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(SizeUtils.getByScreen(small: 12, big: 18)),
|
||||
),
|
||||
color: theme.getColorFor(ThemeCode.backgroundSecondary),
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: theme.getColorFor(ThemeCode.backgroundPrimary),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
padding: EdgeInsets.all(SizeUtils.getByScreen(small: 4, big: 12)),
|
||||
child: Icon(
|
||||
SFIcons.account,
|
||||
size: SizeUtils.getByScreen(small: 40, big: 44),
|
||||
color: theme.getColorFor(ThemeCode.legacyPrimary),
|
||||
color: context.sfColors.legacyPrimary,
|
||||
weight: 30,
|
||||
),
|
||||
),
|
||||
@@ -121,12 +123,12 @@ class AppUserCard extends ConsumerWidget {
|
||||
if (isEditing) ...[
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFFF5D52),
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
showLegacyDialog(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
child: Container(
|
||||
@@ -166,9 +168,7 @@ class AppUserCard extends ConsumerWidget {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
text: context.translate(I18n.cancel),
|
||||
color: theme.getColorFor(
|
||||
ThemeCode.legacyPrimary,
|
||||
),
|
||||
color: context.sfColors.legacyPrimary,
|
||||
height: SizeUtils.getByScreen(
|
||||
small: 38,
|
||||
big: 36,
|
||||
@@ -191,9 +191,7 @@ class AppUserCard extends ConsumerWidget {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
text: context.translate(I18n.delete),
|
||||
color: theme.getColorFor(
|
||||
ThemeCode.legacyPrimary,
|
||||
),
|
||||
color: context.sfColors.legacyPrimary,
|
||||
height: SizeUtils.getByScreen(
|
||||
small: 38,
|
||||
big: 36,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'app_users_edit_mode_provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
class AppUsersEditMode extends _$AppUsersEditMode {
|
||||
@override
|
||||
bool build() => false;
|
||||
|
||||
void toggle() => state = !state;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app_users_edit_mode_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(AppUsersEditMode)
|
||||
const appUsersEditModeProvider = AppUsersEditModeProvider._();
|
||||
|
||||
final class AppUsersEditModeProvider
|
||||
extends $NotifierProvider<AppUsersEditMode, bool> {
|
||||
const AppUsersEditModeProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'appUsersEditModeProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$appUsersEditModeHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AppUsersEditMode create() => AppUsersEditMode();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$appUsersEditModeHash() => r'd4c3717c5dca1dc16bc5846842877967e360e081';
|
||||
|
||||
abstract class _$AppUsersEditMode extends $Notifier<bool> {
|
||||
bool build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<bool, bool>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<bool, bool>,
|
||||
bool,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:account/src/core/providers/users_repository_provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
|
||||
part 'app_users_provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<UserEntity>> appUsers(Ref ref) async {
|
||||
final user = await ref.watch(userInfoProvider.future);
|
||||
return ref.watch(usersRepositoryProvider).getUsers(userId: user.id);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app_users_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(appUsers)
|
||||
const appUsersProvider = AppUsersProvider._();
|
||||
|
||||
final class AppUsersProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<UserEntity>>,
|
||||
List<UserEntity>,
|
||||
FutureOr<List<UserEntity>>
|
||||
>
|
||||
with $FutureModifier<List<UserEntity>>, $FutureProvider<List<UserEntity>> {
|
||||
const AppUsersProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'appUsersProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$appUsersHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<UserEntity>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<UserEntity>> create(Ref ref) {
|
||||
return appUsers(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$appUsersHash() => r'341fe226d4a606a33e023ee3d93b36100a82be0c';
|
||||
@@ -1,53 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:account/src/core/domain/repositories/users_repository.dart';
|
||||
import 'package:account/src/core/providers/users_repository_provider.dart';
|
||||
import 'package:account/src/features/app_users/presentation/state/app_users_view_state.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
import 'package:sf_tracking/sf_tracking.dart';
|
||||
|
||||
final appUsersViewModelProvider =
|
||||
NotifierProvider.autoDispose<AppUsersViewModel, AppUsersViewState>(
|
||||
AppUsersViewModel.new,
|
||||
);
|
||||
|
||||
class AppUsersViewModel extends Notifier<AppUsersViewState> {
|
||||
late final UsersRepository _usersRepository;
|
||||
late final SfTrackingRepository _tracking;
|
||||
|
||||
@override
|
||||
AppUsersViewState build() {
|
||||
_usersRepository = ref.read(usersRepositoryProvider);
|
||||
_tracking = ref.read(sfTrackingProvider);
|
||||
|
||||
_init();
|
||||
|
||||
return const AppUsersViewState();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final user = await ref.read(userInfoProvider.future);
|
||||
|
||||
setUser(user);
|
||||
final appUsers = await _usersRepository.getUsers(userId: user.id);
|
||||
setAppUsers(appUsers);
|
||||
}
|
||||
|
||||
void setUser(UserEntity user) {
|
||||
state = state.copyWith(loggedUser: user);
|
||||
}
|
||||
|
||||
void setAppUsers(List<UserEntity> appUsers) {
|
||||
state = state.copyWith(appUsers: appUsers, isLoading: false);
|
||||
}
|
||||
|
||||
void toggleIsEditing() {
|
||||
state = state.copyWith(isEditing: !state.isEditing);
|
||||
}
|
||||
|
||||
void deleteUser() {
|
||||
unawaited(_tracking.legacyAccountAppUserDeleteTriggered());
|
||||
_usersRepository.deleteUser(userId: state.loggedUser!.id);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
|
||||
part 'app_users_view_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
abstract class AppUsersViewState with _$AppUsersViewState {
|
||||
const factory AppUsersViewState({
|
||||
@Default(false) bool isLoading,
|
||||
@Default(null) UserEntity? loggedUser,
|
||||
@Default([]) List<UserEntity> appUsers,
|
||||
@Default(false) bool isEditing,
|
||||
@Default('') String errorMessage,
|
||||
}) = _AppUsersViewState;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user