Compare commits
161 Commits
6b1e571341
...
feature/vi
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| b9b49f0b26 | |||
| afa916a30d | |||
| 4347cefaed |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,6 +24,9 @@
|
||||
*.iml
|
||||
.vscode/
|
||||
|
||||
# App config (contains API keys, passed via --dart-define-from-file)
|
||||
apps/mobile_app/config/*.json
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
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
|
||||
@@ -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,10 +2,25 @@
|
||||
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"
|
||||
@@ -36,6 +51,10 @@
|
||||
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"
|
||||
|
||||
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,6 +0,0 @@
|
||||
{
|
||||
"env": "development",
|
||||
"apiBaseUrl": "https://api-neki-b2b.neki.es/gateway/api/",
|
||||
"apiOrigin": "https://neki-b2b.neki.es",
|
||||
"wsUrl": "wss://api-neki-b2b.neki.es/websocket"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"env": "production",
|
||||
"apiBaseUrl": "https://api-platform.savefamily.app/gateway/api/",
|
||||
"apiOrigin": "https://platform.savefamily.app",
|
||||
"wsUrl": "wss://api-platform.savefamily.app/websocket"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"env": "staging",
|
||||
"apiBaseUrl": "https://api-platform.pre.savefamilygps.net/gateway/api/",
|
||||
"apiOrigin": "https://platform.pre.savefamilygps.net",
|
||||
"wsUrl": "wss://api-platform.pre.savefamilygps.net/websocket"
|
||||
}
|
||||
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.
|
||||
@@ -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
|
||||
@@ -202,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):
|
||||
@@ -214,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`)
|
||||
@@ -222,16 +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`)
|
||||
|
||||
@@ -255,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:
|
||||
@@ -276,14 +326,20 @@ 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:
|
||||
@@ -292,16 +348,21 @@ EXTERNAL SOURCES:
|
||||
: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
|
||||
@@ -324,14 +385,19 @@ 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
|
||||
@@ -339,11 +405,15 @@ SPEC CHECKSUMS:
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,6 +3,7 @@ abstract class Environment {
|
||||
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');
|
||||
|
||||
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,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,24 +1,27 @@
|
||||
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:get_it/get_it.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:design_system/design_system.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();
|
||||
@@ -30,17 +33,16 @@ Future<void> initApp(EnvironmentEnum env) async {
|
||||
|
||||
navigationModule();
|
||||
scaTreezorModule();
|
||||
videocallSdkModule(SaveFamilyVideocallConfig());
|
||||
themePackages();
|
||||
|
||||
await setupFirebase(env);
|
||||
await setupNotifications();
|
||||
initSfTracking();
|
||||
|
||||
// TODO Fase 2: await initSentry(env);
|
||||
|
||||
configureAppRouter();
|
||||
onRouterReady();
|
||||
|
||||
// TODO Fase 2: await initSentry(env);
|
||||
|
||||
await configureDependencies(
|
||||
SaveFamilyEnvConfig(),
|
||||
log: env.isDevelopment || kDebugMode,
|
||||
@@ -55,15 +57,24 @@ 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);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
appProviderContainer = ProviderContainer(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(sharedPreferences),
|
||||
],
|
||||
);
|
||||
|
||||
await setupNotifications();
|
||||
initSfTracking();
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(sharedPreferences),
|
||||
],
|
||||
UncontrolledProviderScope(
|
||||
container: appProviderContainer,
|
||||
child: const SaveFamilyApp(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,16 +1,39 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:chat/chat.dart';
|
||||
import 'package:device_management/device_management.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';
|
||||
|
||||
// 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] messageId: ${message.messageId}');
|
||||
debugPrint('[FCM-bg] notification: title=${message.notification?.title}, body=${message.notification?.body}');
|
||||
debugPrint('[FCM-bg] data: ${message.data}');
|
||||
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';
|
||||
@@ -23,18 +46,14 @@ final FlutterLocalNotificationsPlugin _localNotifications =
|
||||
|
||||
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,
|
||||
@@ -43,6 +62,7 @@ Future<void> setupNotifications() async {
|
||||
);
|
||||
|
||||
await _initLocalNotifications();
|
||||
_subscribeToIncomingProvider();
|
||||
|
||||
FirebaseMessaging.onMessage.listen(_onForegroundMessage);
|
||||
FirebaseMessaging.onMessageOpenedApp.listen(_onMessageOpenedApp);
|
||||
@@ -51,13 +71,6 @@ Future<void> setupNotifications() async {
|
||||
if (initialMessage != null) {
|
||||
_onMessageOpenedApp(initialMessage);
|
||||
}
|
||||
|
||||
try {
|
||||
final token = await messaging.getToken();
|
||||
debugPrint('[FCM] initial token: $token');
|
||||
} catch (e) {
|
||||
debugPrint('[FCM] getToken failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void onRouterReady() {
|
||||
@@ -92,17 +105,123 @@ Future<void> _initLocalNotifications() async {
|
||||
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 _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: title=${message.notification?.title}, body=${message.notification?.body}');
|
||||
debugPrint('[FCM-fg] data: ${message.data}');
|
||||
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;
|
||||
@@ -131,33 +250,98 @@ void _onForegroundMessage(RemoteMessage message) {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 _onMessageOpenedApp(RemoteMessage message) {
|
||||
debugPrint('[FCM-tap] messageId: ${message.messageId}');
|
||||
debugPrint('[FCM-tap] notification: title=${message.notification?.title}, body=${message.notification?.body}');
|
||||
debugPrint('[FCM-tap] data: ${message.data}');
|
||||
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) {
|
||||
debugPrint('[LocalNotif-tap] id=${response.id}');
|
||||
debugPrint('[LocalNotif-tap] payload=${response.payload}');
|
||||
debugPrint('[LocalNotif-tap] actionId=${response.actionId}');
|
||||
|
||||
final payload = response.payload;
|
||||
if (payload == null || payload.isEmpty) return;
|
||||
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
final data = jsonDecode(payload) as Map<String, dynamic>;
|
||||
_handleNotificationNavigation(data);
|
||||
} catch (e) {
|
||||
debugPrint('[LocalNotif-tap] failed to parse payload: $e');
|
||||
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;
|
||||
debugPrint('[Notification] router not ready, queued for later');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -165,16 +349,61 @@ void _handleNotificationNavigation(Map<String, dynamic> data) {
|
||||
appRouter.routerDelegate.currentConfiguration.uri.path;
|
||||
if (!currentLocation.startsWith(AppRoutes.legacyDashboard)) return;
|
||||
|
||||
final command = data['command'] as String?;
|
||||
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?;
|
||||
|
||||
switch (command) {
|
||||
case 'ALERT':
|
||||
// TODO(backend): include `alertType` in the push payload so we can deep-link
|
||||
// to the filtered feed via `${AppRoutes.deviceNotifications}?type=<alertType>`
|
||||
// and have NotificationsScreen pre-select `notificationsFilterProvider`.
|
||||
// Until then, land on the category picker ("all").
|
||||
appRouter.go(AppRoutes.deviceNotifications);
|
||||
default:
|
||||
debugPrint('[Notification] unhandled command: $command');
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -225,6 +226,16 @@ void configureAppRouter() {
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'videocall',
|
||||
name: 'videocall',
|
||||
pageBuilder: const VideocallBuilder().buildPage,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'friends',
|
||||
name: 'friends',
|
||||
pageBuilder: const FriendsBuilder().buildPage,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -239,19 +250,20 @@ 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -20,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});
|
||||
@@ -60,10 +62,12 @@ 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>();
|
||||
@@ -74,6 +78,7 @@ class SaveFamilyAppState extends ConsumerState<SaveFamilyApp>
|
||||
_walletHeartbeat?.stop();
|
||||
_legacyHeartbeat?.stop();
|
||||
_webSocket?.disconnect();
|
||||
GetIt.I<VideocallClient>().logout();
|
||||
FirebaseMessaging.instance.deleteToken().catchError((Object e) {
|
||||
debugPrint('[FCM] deleteToken on logout failed: $e');
|
||||
});
|
||||
@@ -175,6 +180,29 @@ class SaveFamilyAppState extends ConsumerState<SaveFamilyApp>
|
||||
}
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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+9
|
||||
version: 1.0.0+10
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
@@ -64,6 +64,8 @@ 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:
|
||||
@@ -93,6 +95,8 @@ 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
|
||||
|
||||
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).
|
||||
4
modules/legacy/modules/chat/lib/chat.dart
Normal file
4
modules/legacy/modules/chat/lib/chat.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
export 'src/core/application/services/chat_deeplink_service.dart';
|
||||
export 'src/core/providers/chat_context_provider.dart';
|
||||
export 'src/features/chat_list/chat_list_builder.dart';
|
||||
export 'src/features/chat_conversation/chat_conversation_builder.dart';
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:chat/src/core/domain/services/incoming_chat_resolver.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
|
||||
/// Outcome of preparing a deeplink for an incoming chat payload.
|
||||
///
|
||||
/// `chatId` is the **client-side** chat ID to navigate to, already resolved
|
||||
/// against the user's identity and the device referenced in the payload.
|
||||
/// `deviceSwitched` reports whether the selected device changed as part of the
|
||||
/// preparation — useful for callers that want to log or surface the switch.
|
||||
class ChatDeeplinkOutcome {
|
||||
const ChatDeeplinkOutcome({
|
||||
required this.chatId,
|
||||
required this.deviceSwitched,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final bool deviceSwitched;
|
||||
}
|
||||
|
||||
/// Coordinates the side-effects required to deeplink into a chat conversation
|
||||
/// from an incoming payload (FCM push, WebSocket event, local notification tap).
|
||||
///
|
||||
/// Responsibilities:
|
||||
/// 1. Resolve the client-side chat ID via [IncomingChatResolver].
|
||||
/// 2. Switch [selectedDeviceProvider] to the device referenced in the payload
|
||||
/// when the user has more than one watch and the active selection differs.
|
||||
///
|
||||
/// Navigation is intentionally left to the caller so that route decisions stay
|
||||
/// centralized in the notifications layer.
|
||||
class ChatDeeplinkService {
|
||||
ChatDeeplinkService({
|
||||
required Ref ref,
|
||||
IncomingChatResolver resolver = const IncomingChatResolver(),
|
||||
}) : _ref = ref,
|
||||
_resolver = resolver;
|
||||
|
||||
final Ref _ref;
|
||||
final IncomingChatResolver _resolver;
|
||||
|
||||
/// Prepares an incoming chat for deeplink.
|
||||
///
|
||||
/// Returns `null` when the payload cannot be resolved — typically because the
|
||||
/// user is not yet loaded, or because the referenced device is not associated
|
||||
/// with this account. In that case the caller should fall back to the chat
|
||||
/// list route.
|
||||
Future<ChatDeeplinkOutcome?> prepareIncomingChat({
|
||||
required String? chatId,
|
||||
required String deviceIdentificator,
|
||||
}) async {
|
||||
final clientChatId = resolveClientChatId(
|
||||
chatId: chatId,
|
||||
deviceIdentificator: deviceIdentificator,
|
||||
);
|
||||
if (clientChatId == null) return null;
|
||||
|
||||
final deviceSwitched = await _switchDeviceIfNeeded(deviceIdentificator);
|
||||
|
||||
return ChatDeeplinkOutcome(
|
||||
chatId: clientChatId,
|
||||
deviceSwitched: deviceSwitched,
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves the client-side chat ID for an incoming payload **without** any
|
||||
/// side effects (no device switch). Returns `null` when the user is not yet
|
||||
/// loaded.
|
||||
///
|
||||
/// Used by callers that need to make routing decisions before deeplinking —
|
||||
/// e.g. notification suppression based on the active chat context.
|
||||
String? resolveClientChatId({
|
||||
required String? chatId,
|
||||
required String deviceIdentificator,
|
||||
}) {
|
||||
final user = _ref.read(userInfoProvider).value;
|
||||
if (user == null) {
|
||||
debugPrint('[ChatDeeplink] resolve aborted: user not loaded');
|
||||
return null;
|
||||
}
|
||||
return _resolver.resolveClientChatId(
|
||||
chatId: chatId,
|
||||
deviceIdentificator: deviceIdentificator,
|
||||
userId: user.id,
|
||||
delegationId: user.delegationId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _switchDeviceIfNeeded(String deviceIdentificator) async {
|
||||
final selected = _ref.read(selectedDeviceProvider).value;
|
||||
if (selected != null && selected.identificator == deviceIdentificator) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final devices = _ref.read(legacyDevicesProvider).value ?? const [];
|
||||
final target = devices
|
||||
.where((d) => d.identificator == deviceIdentificator)
|
||||
.firstOrNull;
|
||||
if (target == null) {
|
||||
debugPrint(
|
||||
'[ChatDeeplink] device $deviceIdentificator not in user devices',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
await _ref.read(selectedDeviceProvider.notifier).setSelectedDevice(target);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
final chatDeeplinkServiceProvider = Provider<ChatDeeplinkService>(
|
||||
(ref) => ChatDeeplinkService(ref: ref),
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
|
||||
abstract class ChatOfflineQueueDatasource {
|
||||
Future<List<ChatMessageEntity>> load(String chatId);
|
||||
Future<void> enqueue(String chatId, ChatMessageEntity message);
|
||||
Future<void> remove(String chatId, String messageId);
|
||||
Future<void> clear(String chatId);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:chat/src/core/data/datasource/chat_offline_queue_datasource.dart';
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:chat/src/core/domain/enums/chat_message_status.dart';
|
||||
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _prefix = 'legacy_chat_offline_queue_';
|
||||
|
||||
class ChatOfflineQueueDatasourceImpl implements ChatOfflineQueueDatasource {
|
||||
String _key(String chatId) => '$_prefix$chatId';
|
||||
|
||||
@override
|
||||
Future<List<ChatMessageEntity>> load(String chatId) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(chatId));
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
final list = jsonDecode(raw) as List<dynamic>;
|
||||
return list
|
||||
.map((e) => _entityFromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('[ChatOfflineQueue] failed to load: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> enqueue(String chatId, ChatMessageEntity message) async {
|
||||
final current = await load(chatId);
|
||||
final filtered = current.where((m) => m.id != message.id).toList()
|
||||
..add(message);
|
||||
await _save(chatId, filtered);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String chatId, String messageId) async {
|
||||
final current = await load(chatId);
|
||||
final filtered = current.where((m) => m.id != messageId).toList();
|
||||
if (filtered.length == current.length) return;
|
||||
await _save(chatId, filtered);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear(String chatId) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_key(chatId));
|
||||
} catch (e) {
|
||||
debugPrint('[ChatOfflineQueue] failed to clear: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save(String chatId, List<ChatMessageEntity> messages) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (messages.isEmpty) {
|
||||
await prefs.remove(_key(chatId));
|
||||
return;
|
||||
}
|
||||
final raw = jsonEncode(messages.map(_entityToJson).toList());
|
||||
await prefs.setString(_key(chatId), raw);
|
||||
} catch (e) {
|
||||
debugPrint('[ChatOfflineQueue] failed to save: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _entityToJson(ChatMessageEntity m) => {
|
||||
'id': m.id,
|
||||
'chatId': m.chatId,
|
||||
'userId': m.userId,
|
||||
'userName': m.userName,
|
||||
'deviceIdentificator': m.deviceIdentificator,
|
||||
'type': m.type.wireValue,
|
||||
'content': m.content,
|
||||
'createdAt': m.createdAt.millisecondsSinceEpoch,
|
||||
'localFilePath': m.localFilePath,
|
||||
'fileDurationMs': m.fileDurationMs,
|
||||
'fileSizeBytes': m.fileSizeBytes,
|
||||
};
|
||||
|
||||
ChatMessageEntity _entityFromJson(Map<String, dynamic> json) =>
|
||||
ChatMessageEntity(
|
||||
id: json['id'] as String,
|
||||
chatId: json['chatId'] as String?,
|
||||
userId: json['userId'] as String?,
|
||||
userName: json['userName'] as String?,
|
||||
deviceIdentificator: json['deviceIdentificator'] as String,
|
||||
type: ChatMessageType.fromWire(json['type'] as String),
|
||||
content: json['content'] as String? ?? '',
|
||||
status: ChatMessageStatus.wait,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
json['createdAt'] as int,
|
||||
),
|
||||
isLocalOptimistic: true,
|
||||
failed: true,
|
||||
localFilePath: json['localFilePath'] as String?,
|
||||
fileDurationMs: json['fileDurationMs'] as int?,
|
||||
fileSizeBytes: json['fileSizeBytes'] as int?,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
|
||||
import 'package:chat/src/core/domain/repositories/chat_repository.dart';
|
||||
|
||||
class ChatMessageFile {
|
||||
final Uint8List bytes;
|
||||
final String? contentType;
|
||||
const ChatMessageFile({required this.bytes, this.contentType});
|
||||
}
|
||||
|
||||
abstract class ChatRemoteDatasource {
|
||||
Future<List<ChatMessageEntity>> listMessages({
|
||||
required String deviceIdentificator,
|
||||
String? chatId,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
});
|
||||
|
||||
Future<ChatMessageEntity> getMessage({required String id});
|
||||
|
||||
Future<ChatMessageFile> getMessageFile({required String messageId});
|
||||
|
||||
Future<ChatMessageEntity> sendTextMessage({
|
||||
required String id,
|
||||
required String deviceIdentificator,
|
||||
required String chatId,
|
||||
required ChatMessageType type,
|
||||
required String content,
|
||||
required String userId,
|
||||
required String userName,
|
||||
});
|
||||
|
||||
Future<ChatMessageEntity> sendImageMessage({
|
||||
required String id,
|
||||
required String deviceIdentificator,
|
||||
required String chatId,
|
||||
required String filePath,
|
||||
required String userId,
|
||||
required String userName,
|
||||
ChatUploadProgress? onProgress,
|
||||
});
|
||||
|
||||
Future<ChatMessageEntity> sendAudioMessage({
|
||||
required String id,
|
||||
required String deviceIdentificator,
|
||||
required String chatId,
|
||||
required String filePath,
|
||||
required String userId,
|
||||
required String userName,
|
||||
ChatUploadProgress? onProgress,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import 'package:chat/src/core/data/datasource/chat_remote_datasource.dart';
|
||||
import 'package:chat/src/core/data/models/chat_message_dto.dart';
|
||||
import 'package:chat/src/core/data/models/chat_messages_response_dto.dart';
|
||||
import 'package:chat/src/core/data/models/send_chat_message_form_data.dart';
|
||||
import 'package:chat/src/core/data/utils/chat_image_compressor.dart';
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
|
||||
import 'package:chat/src/core/domain/repositories/chat_repository.dart';
|
||||
import 'package:chat/src/core/domain/services/chat_id_resolver.dart';
|
||||
import 'package:chat/src/core/domain/services/watch_emoji_catalog.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
import 'package:utils/utils.dart';
|
||||
|
||||
class ChatRemoteDatasourceImpl implements ChatRemoteDatasource {
|
||||
ChatRemoteDatasourceImpl(this._repository, this._dio);
|
||||
|
||||
final SaveFamilyRepository _repository;
|
||||
final Dio _dio;
|
||||
final ChatIdResolver _chatIdResolver = const ChatIdResolver();
|
||||
final WatchEmojiCatalog _emojiCatalog = const WatchEmojiCatalog();
|
||||
// The shared Dio singleton has `content-type: application/json` as default.
|
||||
// Multipart uploads need to swap that header so Dio adds the multipart
|
||||
// boundary. Mutating the singleton is unsafe under concurrency, so the
|
||||
// lock serialises uploads inside this datasource. Inter-isolate is not a
|
||||
// concern here.
|
||||
final Lock _multipartLock = Lock();
|
||||
|
||||
ChatMessageEntity _decodeEmoji(ChatMessageEntity entity) {
|
||||
if (entity.type != ChatMessageType.text &&
|
||||
entity.type != ChatMessageType.emoji) {
|
||||
return entity;
|
||||
}
|
||||
return entity.copyWith(content: _emojiCatalog.decode(entity.content));
|
||||
}
|
||||
|
||||
// TODO(backend): el parser de /chat-messages trata el query param `chatId`
|
||||
// como base64-JSON y revienta con SyntaxError. Mientras tanto pedimos por
|
||||
// deviceIdentificator y filtramos chatId en cliente.
|
||||
@override
|
||||
Future<List<ChatMessageEntity>> listMessages({
|
||||
required String deviceIdentificator,
|
||||
String? chatId,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
}) async {
|
||||
final response = await _repository.get<Map<String, dynamic>>(
|
||||
'/chat-messages',
|
||||
queryParameters: {
|
||||
'deviceIdentificator': deviceIdentificator,
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
'filters': encodeFilters(const []),
|
||||
'orderBy': encodeOrderBy('createdAt', direction: 'DESC'),
|
||||
},
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null || data.isEmpty) return [];
|
||||
|
||||
final messages = ChatMessagesResponseDto.fromJson(data)
|
||||
.toEntities()
|
||||
.map(_decodeEmoji)
|
||||
.toList();
|
||||
if (chatId == null) return messages;
|
||||
return messages
|
||||
.where(
|
||||
(m) => _chatIdResolver.matchesWireChatId(
|
||||
chatId: chatId,
|
||||
wireChatId: m.chatId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ChatMessageEntity> getMessage({required String id}) async {
|
||||
final response = await _repository.get<Map<String, dynamic>>(
|
||||
'/chat-messages/$id',
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw Exception('Empty response from server');
|
||||
}
|
||||
final item = data['item'] as Map<String, dynamic>? ?? data;
|
||||
return _decodeEmoji(ChatMessageDto.fromJson(item).toEntity());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ChatMessageFile> getMessageFile({required String messageId}) async {
|
||||
final response = await _dio.get<List<int>>(
|
||||
'/chat-messages/$messageId/file',
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null || data.isEmpty) {
|
||||
throw Exception('Empty file response from server');
|
||||
}
|
||||
return ChatMessageFile(
|
||||
bytes: Uint8List.fromList(data),
|
||||
contentType: response.headers.value('content-type'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ChatMessageEntity> sendTextMessage({
|
||||
required String id,
|
||||
required String deviceIdentificator,
|
||||
required String chatId,
|
||||
required ChatMessageType type,
|
||||
required String content,
|
||||
required String userId,
|
||||
required String userName,
|
||||
}) async {
|
||||
final formData = buildSendChatMessageFormData(
|
||||
id: id,
|
||||
deviceIdentificator: deviceIdentificator,
|
||||
type: type,
|
||||
content: content,
|
||||
chatId: _chatIdResolver.toWireChatId(chatId: chatId, userId: userId),
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
);
|
||||
return _postMultipart(formData);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ChatMessageEntity> sendImageMessage({
|
||||
required String id,
|
||||
required String deviceIdentificator,
|
||||
required String chatId,
|
||||
required String filePath,
|
||||
required String userId,
|
||||
required String userName,
|
||||
ChatUploadProgress? onProgress,
|
||||
}) async {
|
||||
final compressed = await compressChatImage(filePath);
|
||||
final multipartFile = await MultipartFile.fromFile(
|
||||
compressed.path,
|
||||
filename: 'chat_image_$id.jpg',
|
||||
contentType: MediaType('image', 'jpeg'),
|
||||
);
|
||||
|
||||
final formData = buildSendChatMessageFormData(
|
||||
id: id,
|
||||
deviceIdentificator: deviceIdentificator,
|
||||
type: ChatMessageType.image,
|
||||
content: '',
|
||||
chatId: _chatIdResolver.toWireChatId(chatId: chatId, userId: userId),
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
file: multipartFile,
|
||||
);
|
||||
return _postMultipart(formData, onProgress: onProgress);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ChatMessageEntity> sendAudioMessage({
|
||||
required String id,
|
||||
required String deviceIdentificator,
|
||||
required String chatId,
|
||||
required String filePath,
|
||||
required String userId,
|
||||
required String userName,
|
||||
ChatUploadProgress? onProgress,
|
||||
}) async {
|
||||
final multipartFile = await MultipartFile.fromFile(
|
||||
filePath,
|
||||
filename: 'chat_audio_$id.m4a',
|
||||
contentType: MediaType('audio', 'mp4'),
|
||||
);
|
||||
|
||||
final formData = buildSendChatMessageFormData(
|
||||
id: id,
|
||||
deviceIdentificator: deviceIdentificator,
|
||||
type: ChatMessageType.audio,
|
||||
content: '',
|
||||
chatId: _chatIdResolver.toWireChatId(chatId: chatId, userId: userId),
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
file: multipartFile,
|
||||
);
|
||||
return _postMultipart(formData, onProgress: onProgress);
|
||||
}
|
||||
|
||||
Future<ChatMessageEntity> _postMultipart(
|
||||
FormData formData, {
|
||||
ChatUploadProgress? onProgress,
|
||||
}) {
|
||||
return _multipartLock.synchronized(() async {
|
||||
final originalContentType =
|
||||
_dio.options.headers.remove(Headers.contentTypeHeader);
|
||||
try {
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
'/chat-messages',
|
||||
data: formData,
|
||||
onSendProgress: onProgress,
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw Exception('Empty response from server');
|
||||
}
|
||||
final item = data['item'] as Map<String, dynamic>? ?? data;
|
||||
return _decodeEmoji(ChatMessageDto.fromJson(item).toEntity());
|
||||
} finally {
|
||||
if (originalContentType != null) {
|
||||
_dio.options.headers[Headers.contentTypeHeader] =
|
||||
originalContentType;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:chat/src/core/domain/enums/chat_message_status.dart';
|
||||
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'chat_message_dto.freezed.dart';
|
||||
part 'chat_message_dto.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class ChatMessageDto with _$ChatMessageDto {
|
||||
const factory ChatMessageDto({
|
||||
required String id,
|
||||
String? chatId,
|
||||
String? userId,
|
||||
String? userName,
|
||||
required String deviceIdentificator,
|
||||
required String type,
|
||||
required String content,
|
||||
required String status,
|
||||
required int createdAt,
|
||||
}) = _ChatMessageDto;
|
||||
|
||||
factory ChatMessageDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$ChatMessageDtoFromJson(json);
|
||||
}
|
||||
|
||||
extension ChatMessageDtoMapper on ChatMessageDto {
|
||||
ChatMessageEntity toEntity() => ChatMessageEntity(
|
||||
id: id,
|
||||
chatId: chatId,
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
deviceIdentificator: deviceIdentificator,
|
||||
type: ChatMessageType.fromWire(type),
|
||||
content: content,
|
||||
status: ChatMessageStatus.fromWire(status),
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(createdAt),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// 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 'chat_message_dto.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$ChatMessageDto {
|
||||
|
||||
String get id; String? get chatId; String? get userId; String? get userName; String get deviceIdentificator; String get type; String get content; String get status; int get createdAt;
|
||||
/// Create a copy of ChatMessageDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ChatMessageDtoCopyWith<ChatMessageDto> get copyWith => _$ChatMessageDtoCopyWithImpl<ChatMessageDto>(this as ChatMessageDto, _$identity);
|
||||
|
||||
/// Serializes this ChatMessageDto to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatMessageDto&&(identical(other.id, id) || other.id == id)&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.deviceIdentificator, deviceIdentificator) || other.deviceIdentificator == deviceIdentificator)&&(identical(other.type, type) || other.type == type)&&(identical(other.content, content) || other.content == content)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,chatId,userId,userName,deviceIdentificator,type,content,status,createdAt);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatMessageDto(id: $id, chatId: $chatId, userId: $userId, userName: $userName, deviceIdentificator: $deviceIdentificator, type: $type, content: $content, status: $status, createdAt: $createdAt)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ChatMessageDtoCopyWith<$Res> {
|
||||
factory $ChatMessageDtoCopyWith(ChatMessageDto value, $Res Function(ChatMessageDto) _then) = _$ChatMessageDtoCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id, String? chatId, String? userId, String? userName, String deviceIdentificator, String type, String content, String status, int createdAt
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ChatMessageDtoCopyWithImpl<$Res>
|
||||
implements $ChatMessageDtoCopyWith<$Res> {
|
||||
_$ChatMessageDtoCopyWithImpl(this._self, this._then);
|
||||
|
||||
final ChatMessageDto _self;
|
||||
final $Res Function(ChatMessageDto) _then;
|
||||
|
||||
/// Create a copy of ChatMessageDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? chatId = freezed,Object? userId = freezed,Object? userName = freezed,Object? deviceIdentificator = null,Object? type = null,Object? content = null,Object? status = null,Object? createdAt = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,chatId: freezed == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,userName: freezed == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,deviceIdentificator: null == deviceIdentificator ? _self.deviceIdentificator : deviceIdentificator // ignore: cast_nullable_to_non_nullable
|
||||
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
|
||||
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||
as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [ChatMessageDto].
|
||||
extension ChatMessageDtoPatterns on ChatMessageDto {
|
||||
/// 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( _ChatMessageDto value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessageDto() 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( _ChatMessageDto value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessageDto():
|
||||
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( _ChatMessageDto value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessageDto() 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? chatId, String? userId, String? userName, String deviceIdentificator, String type, String content, String status, int createdAt)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessageDto() when $default != null:
|
||||
return $default(_that.id,_that.chatId,_that.userId,_that.userName,_that.deviceIdentificator,_that.type,_that.content,_that.status,_that.createdAt);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? chatId, String? userId, String? userName, String deviceIdentificator, String type, String content, String status, int createdAt) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessageDto():
|
||||
return $default(_that.id,_that.chatId,_that.userId,_that.userName,_that.deviceIdentificator,_that.type,_that.content,_that.status,_that.createdAt);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? chatId, String? userId, String? userName, String deviceIdentificator, String type, String content, String status, int createdAt)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessageDto() when $default != null:
|
||||
return $default(_that.id,_that.chatId,_that.userId,_that.userName,_that.deviceIdentificator,_that.type,_that.content,_that.status,_that.createdAt);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _ChatMessageDto implements ChatMessageDto {
|
||||
const _ChatMessageDto({required this.id, this.chatId, this.userId, this.userName, required this.deviceIdentificator, required this.type, required this.content, required this.status, required this.createdAt});
|
||||
factory _ChatMessageDto.fromJson(Map<String, dynamic> json) => _$ChatMessageDtoFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
@override final String? chatId;
|
||||
@override final String? userId;
|
||||
@override final String? userName;
|
||||
@override final String deviceIdentificator;
|
||||
@override final String type;
|
||||
@override final String content;
|
||||
@override final String status;
|
||||
@override final int createdAt;
|
||||
|
||||
/// Create a copy of ChatMessageDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$ChatMessageDtoCopyWith<_ChatMessageDto> get copyWith => __$ChatMessageDtoCopyWithImpl<_ChatMessageDto>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$ChatMessageDtoToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatMessageDto&&(identical(other.id, id) || other.id == id)&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.deviceIdentificator, deviceIdentificator) || other.deviceIdentificator == deviceIdentificator)&&(identical(other.type, type) || other.type == type)&&(identical(other.content, content) || other.content == content)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,chatId,userId,userName,deviceIdentificator,type,content,status,createdAt);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatMessageDto(id: $id, chatId: $chatId, userId: $userId, userName: $userName, deviceIdentificator: $deviceIdentificator, type: $type, content: $content, status: $status, createdAt: $createdAt)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$ChatMessageDtoCopyWith<$Res> implements $ChatMessageDtoCopyWith<$Res> {
|
||||
factory _$ChatMessageDtoCopyWith(_ChatMessageDto value, $Res Function(_ChatMessageDto) _then) = __$ChatMessageDtoCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id, String? chatId, String? userId, String? userName, String deviceIdentificator, String type, String content, String status, int createdAt
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$ChatMessageDtoCopyWithImpl<$Res>
|
||||
implements _$ChatMessageDtoCopyWith<$Res> {
|
||||
__$ChatMessageDtoCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _ChatMessageDto _self;
|
||||
final $Res Function(_ChatMessageDto) _then;
|
||||
|
||||
/// Create a copy of ChatMessageDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? chatId = freezed,Object? userId = freezed,Object? userName = freezed,Object? deviceIdentificator = null,Object? type = null,Object? content = null,Object? status = null,Object? createdAt = null,}) {
|
||||
return _then(_ChatMessageDto(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,chatId: freezed == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,userName: freezed == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,deviceIdentificator: null == deviceIdentificator ? _self.deviceIdentificator : deviceIdentificator // ignore: cast_nullable_to_non_nullable
|
||||
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
|
||||
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||
as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1,33 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'chat_message_dto.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_ChatMessageDto _$ChatMessageDtoFromJson(Map<String, dynamic> json) =>
|
||||
_ChatMessageDto(
|
||||
id: json['id'] as String,
|
||||
chatId: json['chatId'] as String?,
|
||||
userId: json['userId'] as String?,
|
||||
userName: json['userName'] as String?,
|
||||
deviceIdentificator: json['deviceIdentificator'] as String,
|
||||
type: json['type'] as String,
|
||||
content: json['content'] as String,
|
||||
status: json['status'] as String,
|
||||
createdAt: (json['createdAt'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ChatMessageDtoToJson(_ChatMessageDto instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'chatId': instance.chatId,
|
||||
'userId': instance.userId,
|
||||
'userName': instance.userName,
|
||||
'deviceIdentificator': instance.deviceIdentificator,
|
||||
'type': instance.type,
|
||||
'content': instance.content,
|
||||
'status': instance.status,
|
||||
'createdAt': instance.createdAt,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:chat/src/core/data/models/chat_message_dto.dart';
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'chat_messages_response_dto.freezed.dart';
|
||||
part 'chat_messages_response_dto.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class ChatMessagesResponseDto with _$ChatMessagesResponseDto {
|
||||
const factory ChatMessagesResponseDto({
|
||||
required List<ChatMessageDto> items,
|
||||
int? total,
|
||||
int? page,
|
||||
int? pages,
|
||||
}) = _ChatMessagesResponseDto;
|
||||
|
||||
factory ChatMessagesResponseDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$ChatMessagesResponseDtoFromJson(json);
|
||||
}
|
||||
|
||||
extension ChatMessagesResponseDtoMapper on ChatMessagesResponseDto {
|
||||
List<ChatMessageEntity> toEntities() =>
|
||||
items.map((dto) => dto.toEntity()).toList();
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// 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 'chat_messages_response_dto.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$ChatMessagesResponseDto {
|
||||
|
||||
List<ChatMessageDto> get items; int? get total; int? get page; int? get pages;
|
||||
/// Create a copy of ChatMessagesResponseDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ChatMessagesResponseDtoCopyWith<ChatMessagesResponseDto> get copyWith => _$ChatMessagesResponseDtoCopyWithImpl<ChatMessagesResponseDto>(this as ChatMessagesResponseDto, _$identity);
|
||||
|
||||
/// Serializes this ChatMessagesResponseDto to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatMessagesResponseDto&&const DeepCollectionEquality().equals(other.items, items)&&(identical(other.total, total) || other.total == total)&&(identical(other.page, page) || other.page == page)&&(identical(other.pages, pages) || other.pages == pages));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(items),total,page,pages);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatMessagesResponseDto(items: $items, total: $total, page: $page, pages: $pages)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ChatMessagesResponseDtoCopyWith<$Res> {
|
||||
factory $ChatMessagesResponseDtoCopyWith(ChatMessagesResponseDto value, $Res Function(ChatMessagesResponseDto) _then) = _$ChatMessagesResponseDtoCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
List<ChatMessageDto> items, int? total, int? page, int? pages
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ChatMessagesResponseDtoCopyWithImpl<$Res>
|
||||
implements $ChatMessagesResponseDtoCopyWith<$Res> {
|
||||
_$ChatMessagesResponseDtoCopyWithImpl(this._self, this._then);
|
||||
|
||||
final ChatMessagesResponseDto _self;
|
||||
final $Res Function(ChatMessagesResponseDto) _then;
|
||||
|
||||
/// Create a copy of ChatMessagesResponseDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? items = null,Object? total = freezed,Object? page = freezed,Object? pages = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable
|
||||
as List<ChatMessageDto>,total: freezed == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
|
||||
as int?,page: freezed == page ? _self.page : page // ignore: cast_nullable_to_non_nullable
|
||||
as int?,pages: freezed == pages ? _self.pages : pages // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [ChatMessagesResponseDto].
|
||||
extension ChatMessagesResponseDtoPatterns on ChatMessagesResponseDto {
|
||||
/// 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( _ChatMessagesResponseDto value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessagesResponseDto() 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( _ChatMessagesResponseDto value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessagesResponseDto():
|
||||
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( _ChatMessagesResponseDto value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessagesResponseDto() 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( List<ChatMessageDto> items, int? total, int? page, int? pages)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessagesResponseDto() when $default != null:
|
||||
return $default(_that.items,_that.total,_that.page,_that.pages);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( List<ChatMessageDto> items, int? total, int? page, int? pages) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessagesResponseDto():
|
||||
return $default(_that.items,_that.total,_that.page,_that.pages);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( List<ChatMessageDto> items, int? total, int? page, int? pages)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessagesResponseDto() when $default != null:
|
||||
return $default(_that.items,_that.total,_that.page,_that.pages);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _ChatMessagesResponseDto implements ChatMessagesResponseDto {
|
||||
const _ChatMessagesResponseDto({required final List<ChatMessageDto> items, this.total, this.page, this.pages}): _items = items;
|
||||
factory _ChatMessagesResponseDto.fromJson(Map<String, dynamic> json) => _$ChatMessagesResponseDtoFromJson(json);
|
||||
|
||||
final List<ChatMessageDto> _items;
|
||||
@override List<ChatMessageDto> get items {
|
||||
if (_items is EqualUnmodifiableListView) return _items;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_items);
|
||||
}
|
||||
|
||||
@override final int? total;
|
||||
@override final int? page;
|
||||
@override final int? pages;
|
||||
|
||||
/// Create a copy of ChatMessagesResponseDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$ChatMessagesResponseDtoCopyWith<_ChatMessagesResponseDto> get copyWith => __$ChatMessagesResponseDtoCopyWithImpl<_ChatMessagesResponseDto>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$ChatMessagesResponseDtoToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatMessagesResponseDto&&const DeepCollectionEquality().equals(other._items, _items)&&(identical(other.total, total) || other.total == total)&&(identical(other.page, page) || other.page == page)&&(identical(other.pages, pages) || other.pages == pages));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_items),total,page,pages);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatMessagesResponseDto(items: $items, total: $total, page: $page, pages: $pages)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$ChatMessagesResponseDtoCopyWith<$Res> implements $ChatMessagesResponseDtoCopyWith<$Res> {
|
||||
factory _$ChatMessagesResponseDtoCopyWith(_ChatMessagesResponseDto value, $Res Function(_ChatMessagesResponseDto) _then) = __$ChatMessagesResponseDtoCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
List<ChatMessageDto> items, int? total, int? page, int? pages
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$ChatMessagesResponseDtoCopyWithImpl<$Res>
|
||||
implements _$ChatMessagesResponseDtoCopyWith<$Res> {
|
||||
__$ChatMessagesResponseDtoCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _ChatMessagesResponseDto _self;
|
||||
final $Res Function(_ChatMessagesResponseDto) _then;
|
||||
|
||||
/// Create a copy of ChatMessagesResponseDto
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? items = null,Object? total = freezed,Object? page = freezed,Object? pages = freezed,}) {
|
||||
return _then(_ChatMessagesResponseDto(
|
||||
items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable
|
||||
as List<ChatMessageDto>,total: freezed == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
|
||||
as int?,page: freezed == page ? _self.page : page // ignore: cast_nullable_to_non_nullable
|
||||
as int?,pages: freezed == pages ? _self.pages : pages // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1,27 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'chat_messages_response_dto.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_ChatMessagesResponseDto _$ChatMessagesResponseDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _ChatMessagesResponseDto(
|
||||
items: (json['items'] as List<dynamic>)
|
||||
.map((e) => ChatMessageDto.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
total: (json['total'] as num?)?.toInt(),
|
||||
page: (json['page'] as num?)?.toInt(),
|
||||
pages: (json['pages'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ChatMessagesResponseDtoToJson(
|
||||
_ChatMessagesResponseDto instance,
|
||||
) => <String, dynamic>{
|
||||
'items': instance.items,
|
||||
'total': instance.total,
|
||||
'page': instance.page,
|
||||
'pages': instance.pages,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
FormData buildSendChatMessageFormData({
|
||||
required String id,
|
||||
required String deviceIdentificator,
|
||||
required ChatMessageType type,
|
||||
required String content,
|
||||
required String? chatId,
|
||||
required String userId,
|
||||
required String userName,
|
||||
MultipartFile? file,
|
||||
}) {
|
||||
final isTextual =
|
||||
type == ChatMessageType.text || type == ChatMessageType.emoji;
|
||||
|
||||
return FormData.fromMap({
|
||||
'id': id,
|
||||
'deviceIdentificator': deviceIdentificator,
|
||||
'type': type.wireValue,
|
||||
if (isTextual) 'content': content,
|
||||
if (file != null) 'file': file,
|
||||
if (chatId != null) 'chatId': chatId,
|
||||
'userId': userId,
|
||||
'userName': userName,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:chat/src/core/data/datasource/chat_remote_datasource.dart';
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
|
||||
import 'package:chat/src/core/domain/repositories/chat_repository.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
|
||||
class ChatRepositoryImpl implements ChatRepository {
|
||||
ChatRepositoryImpl(this._remote, {BaseCacheManager? cacheManager})
|
||||
: _cacheOverride = cacheManager;
|
||||
|
||||
final ChatRemoteDatasource _remote;
|
||||
final BaseCacheManager? _cacheOverride;
|
||||
BaseCacheManager? _defaultCache;
|
||||
|
||||
BaseCacheManager get _cache =>
|
||||
_cacheOverride ?? (_defaultCache ??= DefaultCacheManager());
|
||||
|
||||
static const _cacheKeyPrefix = 'chat_msg_file_';
|
||||
|
||||
@override
|
||||
Future<List<ChatMessageEntity>> listMessages({
|
||||
required String deviceIdentificator,
|
||||
String? chatId,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
}) async {
|
||||
try {
|
||||
return await _remote.listMessages(
|
||||
deviceIdentificator: deviceIdentificator,
|
||||
chatId: chatId,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) return [];
|
||||
throw mapDioError(e, defaultMessage: 'Error loading chat messages');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ChatMessageEntity> getMessage({required String id}) =>
|
||||
safeCall(() => _remote.getMessage(id: id), 'Error loading chat message');
|
||||
|
||||
@override
|
||||
Future<ChatMessageEntity> sendTextMessage({
|
||||
required String id,
|
||||
required String deviceIdentificator,
|
||||
required String chatId,
|
||||
required ChatMessageType type,
|
||||
required String content,
|
||||
required String userId,
|
||||
required String userName,
|
||||
}) => safeCall(
|
||||
() => _remote.sendTextMessage(
|
||||
id: id,
|
||||
deviceIdentificator: deviceIdentificator,
|
||||
chatId: chatId,
|
||||
type: type,
|
||||
content: content,
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
),
|
||||
'Error sending chat message',
|
||||
);
|
||||
|
||||
@override
|
||||
Future<ChatMessageEntity> sendImageMessage({
|
||||
required String id,
|
||||
required String deviceIdentificator,
|
||||
required String chatId,
|
||||
required String filePath,
|
||||
required String userId,
|
||||
required String userName,
|
||||
ChatUploadProgress? onProgress,
|
||||
}) => safeCall(
|
||||
() => _remote.sendImageMessage(
|
||||
id: id,
|
||||
deviceIdentificator: deviceIdentificator,
|
||||
chatId: chatId,
|
||||
filePath: filePath,
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
onProgress: onProgress,
|
||||
),
|
||||
'Error sending image message',
|
||||
);
|
||||
|
||||
@override
|
||||
Future<ChatMessageEntity> sendAudioMessage({
|
||||
required String id,
|
||||
required String deviceIdentificator,
|
||||
required String chatId,
|
||||
required String filePath,
|
||||
required int durationMs,
|
||||
required String userId,
|
||||
required String userName,
|
||||
ChatUploadProgress? onProgress,
|
||||
}) async {
|
||||
final entity = await safeCall(
|
||||
() => _remote.sendAudioMessage(
|
||||
id: id,
|
||||
deviceIdentificator: deviceIdentificator,
|
||||
chatId: chatId,
|
||||
filePath: filePath,
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
onProgress: onProgress,
|
||||
),
|
||||
'Error sending audio message',
|
||||
);
|
||||
return entity.copyWith(fileDurationMs: durationMs);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<File> resolveMessageFile({
|
||||
required String messageId,
|
||||
String? localFilePath,
|
||||
}) async {
|
||||
if (localFilePath != null) {
|
||||
final local = File(localFilePath);
|
||||
if (await local.exists()) return local;
|
||||
}
|
||||
|
||||
final cacheKey = '$_cacheKeyPrefix$messageId';
|
||||
final cached = await _cache.getFileFromCache(cacheKey);
|
||||
if (cached != null) return cached.file;
|
||||
|
||||
try {
|
||||
final remote = await _remote.getMessageFile(messageId: messageId);
|
||||
final stored = await _cache.putFile(
|
||||
cacheKey,
|
||||
remote.bytes,
|
||||
fileExtension: _extensionFor(remote.contentType),
|
||||
maxAge: const Duration(days: 365),
|
||||
);
|
||||
return stored;
|
||||
} on DioException catch (e) {
|
||||
throw _mapFileError(e);
|
||||
}
|
||||
}
|
||||
|
||||
ChatFileException _mapFileError(DioException error) {
|
||||
final api = mapDioError(error, defaultMessage: 'Error loading chat file');
|
||||
final kind = switch (api.statusCode) {
|
||||
400 => ChatFileError.unsupported,
|
||||
403 => ChatFileError.forbidden,
|
||||
404 => ChatFileError.notFound,
|
||||
_ when api.isNetworkError => ChatFileError.network,
|
||||
_ => ChatFileError.unknown,
|
||||
};
|
||||
return ChatFileException(kind: kind, message: api.message);
|
||||
}
|
||||
|
||||
String _extensionFor(String? contentType) {
|
||||
if (contentType == null) return 'bin';
|
||||
final mime = contentType.split(';').first.trim().toLowerCase();
|
||||
return switch (mime) {
|
||||
'image/jpeg' || 'image/jpg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
'image/heic' || 'image/heif' => 'heic',
|
||||
'audio/mpeg' || 'audio/mp3' => 'mp3',
|
||||
'audio/mp4' || 'audio/m4a' || 'audio/x-m4a' || 'audio/aac' => 'm4a',
|
||||
'audio/ogg' => 'ogg',
|
||||
'audio/wav' || 'audio/x-wav' => 'wav',
|
||||
_ => 'bin',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
const int _maxDimension = 1024;
|
||||
const int _quality = 85;
|
||||
|
||||
Future<File> compressChatImage(String sourcePath) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final targetPath =
|
||||
'${tempDir.path}/chat_image_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
|
||||
final compressed = await FlutterImageCompress.compressAndGetFile(
|
||||
sourcePath,
|
||||
targetPath,
|
||||
minWidth: _maxDimension,
|
||||
minHeight: _maxDimension,
|
||||
quality: _quality,
|
||||
format: CompressFormat.jpeg,
|
||||
);
|
||||
|
||||
if (compressed == null) return File(sourcePath);
|
||||
return File(compressed.path);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'chat_conversation_entity.freezed.dart';
|
||||
|
||||
@freezed
|
||||
sealed class ChatConversationEntity with _$ChatConversationEntity {
|
||||
const factory ChatConversationEntity.oneToOne({
|
||||
required String chatId,
|
||||
required String deviceId,
|
||||
required String deviceIdentificator,
|
||||
required String displayName,
|
||||
String? avatarBackgroundImageId,
|
||||
ChatMessageEntity? lastMessage,
|
||||
}) = OneToOneConversation;
|
||||
|
||||
const factory ChatConversationEntity.familyGroup({
|
||||
required String chatId,
|
||||
required String displayName,
|
||||
required List<String> memberIdentificators,
|
||||
ChatMessageEntity? lastMessage,
|
||||
}) = FamilyGroupConversation;
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
// 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 'chat_conversation_entity.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$ChatConversationEntity {
|
||||
|
||||
String get chatId; String get displayName; ChatMessageEntity? get lastMessage;
|
||||
/// Create a copy of ChatConversationEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ChatConversationEntityCopyWith<ChatConversationEntity> get copyWith => _$ChatConversationEntityCopyWithImpl<ChatConversationEntity>(this as ChatConversationEntity, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatConversationEntity&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.lastMessage, lastMessage) || other.lastMessage == lastMessage));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,chatId,displayName,lastMessage);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatConversationEntity(chatId: $chatId, displayName: $displayName, lastMessage: $lastMessage)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ChatConversationEntityCopyWith<$Res> {
|
||||
factory $ChatConversationEntityCopyWith(ChatConversationEntity value, $Res Function(ChatConversationEntity) _then) = _$ChatConversationEntityCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String chatId, String displayName, ChatMessageEntity? lastMessage
|
||||
});
|
||||
|
||||
|
||||
$ChatMessageEntityCopyWith<$Res>? get lastMessage;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ChatConversationEntityCopyWithImpl<$Res>
|
||||
implements $ChatConversationEntityCopyWith<$Res> {
|
||||
_$ChatConversationEntityCopyWithImpl(this._self, this._then);
|
||||
|
||||
final ChatConversationEntity _self;
|
||||
final $Res Function(ChatConversationEntity) _then;
|
||||
|
||||
/// Create a copy of ChatConversationEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? chatId = null,Object? displayName = null,Object? lastMessage = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
chatId: null == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
|
||||
as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
|
||||
as String,lastMessage: freezed == lastMessage ? _self.lastMessage : lastMessage // ignore: cast_nullable_to_non_nullable
|
||||
as ChatMessageEntity?,
|
||||
));
|
||||
}
|
||||
/// Create a copy of ChatConversationEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$ChatMessageEntityCopyWith<$Res>? get lastMessage {
|
||||
if (_self.lastMessage == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ChatMessageEntityCopyWith<$Res>(_self.lastMessage!, (value) {
|
||||
return _then(_self.copyWith(lastMessage: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [ChatConversationEntity].
|
||||
extension ChatConversationEntityPatterns on ChatConversationEntity {
|
||||
/// 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( OneToOneConversation value)? oneToOne,TResult Function( FamilyGroupConversation value)? familyGroup,required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case OneToOneConversation() when oneToOne != null:
|
||||
return oneToOne(_that);case FamilyGroupConversation() when familyGroup != null:
|
||||
return familyGroup(_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?>({required TResult Function( OneToOneConversation value) oneToOne,required TResult Function( FamilyGroupConversation value) familyGroup,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case OneToOneConversation():
|
||||
return oneToOne(_that);case FamilyGroupConversation():
|
||||
return familyGroup(_that);}
|
||||
}
|
||||
/// 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( OneToOneConversation value)? oneToOne,TResult? Function( FamilyGroupConversation value)? familyGroup,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case OneToOneConversation() when oneToOne != null:
|
||||
return oneToOne(_that);case FamilyGroupConversation() when familyGroup != null:
|
||||
return familyGroup(_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 chatId, String deviceId, String deviceIdentificator, String displayName, String? avatarBackgroundImageId, ChatMessageEntity? lastMessage)? oneToOne,TResult Function( String chatId, String displayName, List<String> memberIdentificators, ChatMessageEntity? lastMessage)? familyGroup,required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case OneToOneConversation() when oneToOne != null:
|
||||
return oneToOne(_that.chatId,_that.deviceId,_that.deviceIdentificator,_that.displayName,_that.avatarBackgroundImageId,_that.lastMessage);case FamilyGroupConversation() when familyGroup != null:
|
||||
return familyGroup(_that.chatId,_that.displayName,_that.memberIdentificators,_that.lastMessage);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?>({required TResult Function( String chatId, String deviceId, String deviceIdentificator, String displayName, String? avatarBackgroundImageId, ChatMessageEntity? lastMessage) oneToOne,required TResult Function( String chatId, String displayName, List<String> memberIdentificators, ChatMessageEntity? lastMessage) familyGroup,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case OneToOneConversation():
|
||||
return oneToOne(_that.chatId,_that.deviceId,_that.deviceIdentificator,_that.displayName,_that.avatarBackgroundImageId,_that.lastMessage);case FamilyGroupConversation():
|
||||
return familyGroup(_that.chatId,_that.displayName,_that.memberIdentificators,_that.lastMessage);}
|
||||
}
|
||||
/// 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 chatId, String deviceId, String deviceIdentificator, String displayName, String? avatarBackgroundImageId, ChatMessageEntity? lastMessage)? oneToOne,TResult? Function( String chatId, String displayName, List<String> memberIdentificators, ChatMessageEntity? lastMessage)? familyGroup,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case OneToOneConversation() when oneToOne != null:
|
||||
return oneToOne(_that.chatId,_that.deviceId,_that.deviceIdentificator,_that.displayName,_that.avatarBackgroundImageId,_that.lastMessage);case FamilyGroupConversation() when familyGroup != null:
|
||||
return familyGroup(_that.chatId,_that.displayName,_that.memberIdentificators,_that.lastMessage);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class OneToOneConversation implements ChatConversationEntity {
|
||||
const OneToOneConversation({required this.chatId, required this.deviceId, required this.deviceIdentificator, required this.displayName, this.avatarBackgroundImageId, this.lastMessage});
|
||||
|
||||
|
||||
@override final String chatId;
|
||||
final String deviceId;
|
||||
final String deviceIdentificator;
|
||||
@override final String displayName;
|
||||
final String? avatarBackgroundImageId;
|
||||
@override final ChatMessageEntity? lastMessage;
|
||||
|
||||
/// Create a copy of ChatConversationEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$OneToOneConversationCopyWith<OneToOneConversation> get copyWith => _$OneToOneConversationCopyWithImpl<OneToOneConversation>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is OneToOneConversation&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.deviceIdentificator, deviceIdentificator) || other.deviceIdentificator == deviceIdentificator)&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.avatarBackgroundImageId, avatarBackgroundImageId) || other.avatarBackgroundImageId == avatarBackgroundImageId)&&(identical(other.lastMessage, lastMessage) || other.lastMessage == lastMessage));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,chatId,deviceId,deviceIdentificator,displayName,avatarBackgroundImageId,lastMessage);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatConversationEntity.oneToOne(chatId: $chatId, deviceId: $deviceId, deviceIdentificator: $deviceIdentificator, displayName: $displayName, avatarBackgroundImageId: $avatarBackgroundImageId, lastMessage: $lastMessage)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $OneToOneConversationCopyWith<$Res> implements $ChatConversationEntityCopyWith<$Res> {
|
||||
factory $OneToOneConversationCopyWith(OneToOneConversation value, $Res Function(OneToOneConversation) _then) = _$OneToOneConversationCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String chatId, String deviceId, String deviceIdentificator, String displayName, String? avatarBackgroundImageId, ChatMessageEntity? lastMessage
|
||||
});
|
||||
|
||||
|
||||
@override $ChatMessageEntityCopyWith<$Res>? get lastMessage;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$OneToOneConversationCopyWithImpl<$Res>
|
||||
implements $OneToOneConversationCopyWith<$Res> {
|
||||
_$OneToOneConversationCopyWithImpl(this._self, this._then);
|
||||
|
||||
final OneToOneConversation _self;
|
||||
final $Res Function(OneToOneConversation) _then;
|
||||
|
||||
/// Create a copy of ChatConversationEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? chatId = null,Object? deviceId = null,Object? deviceIdentificator = null,Object? displayName = null,Object? avatarBackgroundImageId = freezed,Object? lastMessage = freezed,}) {
|
||||
return _then(OneToOneConversation(
|
||||
chatId: null == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
|
||||
as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable
|
||||
as String,deviceIdentificator: null == deviceIdentificator ? _self.deviceIdentificator : deviceIdentificator // ignore: cast_nullable_to_non_nullable
|
||||
as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
|
||||
as String,avatarBackgroundImageId: freezed == avatarBackgroundImageId ? _self.avatarBackgroundImageId : avatarBackgroundImageId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,lastMessage: freezed == lastMessage ? _self.lastMessage : lastMessage // ignore: cast_nullable_to_non_nullable
|
||||
as ChatMessageEntity?,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of ChatConversationEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$ChatMessageEntityCopyWith<$Res>? get lastMessage {
|
||||
if (_self.lastMessage == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ChatMessageEntityCopyWith<$Res>(_self.lastMessage!, (value) {
|
||||
return _then(_self.copyWith(lastMessage: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class FamilyGroupConversation implements ChatConversationEntity {
|
||||
const FamilyGroupConversation({required this.chatId, required this.displayName, required final List<String> memberIdentificators, this.lastMessage}): _memberIdentificators = memberIdentificators;
|
||||
|
||||
|
||||
@override final String chatId;
|
||||
@override final String displayName;
|
||||
final List<String> _memberIdentificators;
|
||||
List<String> get memberIdentificators {
|
||||
if (_memberIdentificators is EqualUnmodifiableListView) return _memberIdentificators;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_memberIdentificators);
|
||||
}
|
||||
|
||||
@override final ChatMessageEntity? lastMessage;
|
||||
|
||||
/// Create a copy of ChatConversationEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$FamilyGroupConversationCopyWith<FamilyGroupConversation> get copyWith => _$FamilyGroupConversationCopyWithImpl<FamilyGroupConversation>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is FamilyGroupConversation&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.displayName, displayName) || other.displayName == displayName)&&const DeepCollectionEquality().equals(other._memberIdentificators, _memberIdentificators)&&(identical(other.lastMessage, lastMessage) || other.lastMessage == lastMessage));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,chatId,displayName,const DeepCollectionEquality().hash(_memberIdentificators),lastMessage);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatConversationEntity.familyGroup(chatId: $chatId, displayName: $displayName, memberIdentificators: $memberIdentificators, lastMessage: $lastMessage)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $FamilyGroupConversationCopyWith<$Res> implements $ChatConversationEntityCopyWith<$Res> {
|
||||
factory $FamilyGroupConversationCopyWith(FamilyGroupConversation value, $Res Function(FamilyGroupConversation) _then) = _$FamilyGroupConversationCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String chatId, String displayName, List<String> memberIdentificators, ChatMessageEntity? lastMessage
|
||||
});
|
||||
|
||||
|
||||
@override $ChatMessageEntityCopyWith<$Res>? get lastMessage;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$FamilyGroupConversationCopyWithImpl<$Res>
|
||||
implements $FamilyGroupConversationCopyWith<$Res> {
|
||||
_$FamilyGroupConversationCopyWithImpl(this._self, this._then);
|
||||
|
||||
final FamilyGroupConversation _self;
|
||||
final $Res Function(FamilyGroupConversation) _then;
|
||||
|
||||
/// Create a copy of ChatConversationEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? chatId = null,Object? displayName = null,Object? memberIdentificators = null,Object? lastMessage = freezed,}) {
|
||||
return _then(FamilyGroupConversation(
|
||||
chatId: null == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
|
||||
as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
|
||||
as String,memberIdentificators: null == memberIdentificators ? _self._memberIdentificators : memberIdentificators // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,lastMessage: freezed == lastMessage ? _self.lastMessage : lastMessage // ignore: cast_nullable_to_non_nullable
|
||||
as ChatMessageEntity?,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of ChatConversationEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$ChatMessageEntityCopyWith<$Res>? get lastMessage {
|
||||
if (_self.lastMessage == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ChatMessageEntityCopyWith<$Res>(_self.lastMessage!, (value) {
|
||||
return _then(_self.copyWith(lastMessage: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:chat/src/core/domain/enums/chat_message_status.dart';
|
||||
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'chat_message_entity.freezed.dart';
|
||||
|
||||
@freezed
|
||||
abstract class ChatMessageEntity with _$ChatMessageEntity {
|
||||
const ChatMessageEntity._();
|
||||
|
||||
const factory ChatMessageEntity({
|
||||
required String id,
|
||||
String? chatId,
|
||||
String? userId,
|
||||
String? userName,
|
||||
required String deviceIdentificator,
|
||||
required ChatMessageType type,
|
||||
required String content,
|
||||
required ChatMessageStatus status,
|
||||
required DateTime createdAt,
|
||||
@Default(false) bool isLocalOptimistic,
|
||||
@Default(false) bool failed,
|
||||
String? localFilePath,
|
||||
@Default(0.0) double uploadProgress,
|
||||
int? fileDurationMs,
|
||||
int? fileSizeBytes,
|
||||
}) = _ChatMessageEntity;
|
||||
|
||||
bool get isOutgoing => userId != null;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// 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 'chat_message_entity.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$ChatMessageEntity {
|
||||
|
||||
String get id; String? get chatId; String? get userId; String? get userName; String get deviceIdentificator; ChatMessageType get type; String get content; ChatMessageStatus get status; DateTime get createdAt; bool get isLocalOptimistic; bool get failed; String? get localFilePath; double get uploadProgress; int? get fileDurationMs; int? get fileSizeBytes;
|
||||
/// Create a copy of ChatMessageEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ChatMessageEntityCopyWith<ChatMessageEntity> get copyWith => _$ChatMessageEntityCopyWithImpl<ChatMessageEntity>(this as ChatMessageEntity, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatMessageEntity&&(identical(other.id, id) || other.id == id)&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.deviceIdentificator, deviceIdentificator) || other.deviceIdentificator == deviceIdentificator)&&(identical(other.type, type) || other.type == type)&&(identical(other.content, content) || other.content == content)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isLocalOptimistic, isLocalOptimistic) || other.isLocalOptimistic == isLocalOptimistic)&&(identical(other.failed, failed) || other.failed == failed)&&(identical(other.localFilePath, localFilePath) || other.localFilePath == localFilePath)&&(identical(other.uploadProgress, uploadProgress) || other.uploadProgress == uploadProgress)&&(identical(other.fileDurationMs, fileDurationMs) || other.fileDurationMs == fileDurationMs)&&(identical(other.fileSizeBytes, fileSizeBytes) || other.fileSizeBytes == fileSizeBytes));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,chatId,userId,userName,deviceIdentificator,type,content,status,createdAt,isLocalOptimistic,failed,localFilePath,uploadProgress,fileDurationMs,fileSizeBytes);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatMessageEntity(id: $id, chatId: $chatId, userId: $userId, userName: $userName, deviceIdentificator: $deviceIdentificator, type: $type, content: $content, status: $status, createdAt: $createdAt, isLocalOptimistic: $isLocalOptimistic, failed: $failed, localFilePath: $localFilePath, uploadProgress: $uploadProgress, fileDurationMs: $fileDurationMs, fileSizeBytes: $fileSizeBytes)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ChatMessageEntityCopyWith<$Res> {
|
||||
factory $ChatMessageEntityCopyWith(ChatMessageEntity value, $Res Function(ChatMessageEntity) _then) = _$ChatMessageEntityCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id, String? chatId, String? userId, String? userName, String deviceIdentificator, ChatMessageType type, String content, ChatMessageStatus status, DateTime createdAt, bool isLocalOptimistic, bool failed, String? localFilePath, double uploadProgress, int? fileDurationMs, int? fileSizeBytes
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ChatMessageEntityCopyWithImpl<$Res>
|
||||
implements $ChatMessageEntityCopyWith<$Res> {
|
||||
_$ChatMessageEntityCopyWithImpl(this._self, this._then);
|
||||
|
||||
final ChatMessageEntity _self;
|
||||
final $Res Function(ChatMessageEntity) _then;
|
||||
|
||||
/// Create a copy of ChatMessageEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? chatId = freezed,Object? userId = freezed,Object? userName = freezed,Object? deviceIdentificator = null,Object? type = null,Object? content = null,Object? status = null,Object? createdAt = null,Object? isLocalOptimistic = null,Object? failed = null,Object? localFilePath = freezed,Object? uploadProgress = null,Object? fileDurationMs = freezed,Object? fileSizeBytes = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,chatId: freezed == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,userName: freezed == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,deviceIdentificator: null == deviceIdentificator ? _self.deviceIdentificator : deviceIdentificator // ignore: cast_nullable_to_non_nullable
|
||||
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as ChatMessageType,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
|
||||
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||
as ChatMessageStatus,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,isLocalOptimistic: null == isLocalOptimistic ? _self.isLocalOptimistic : isLocalOptimistic // ignore: cast_nullable_to_non_nullable
|
||||
as bool,failed: null == failed ? _self.failed : failed // ignore: cast_nullable_to_non_nullable
|
||||
as bool,localFilePath: freezed == localFilePath ? _self.localFilePath : localFilePath // ignore: cast_nullable_to_non_nullable
|
||||
as String?,uploadProgress: null == uploadProgress ? _self.uploadProgress : uploadProgress // ignore: cast_nullable_to_non_nullable
|
||||
as double,fileDurationMs: freezed == fileDurationMs ? _self.fileDurationMs : fileDurationMs // ignore: cast_nullable_to_non_nullable
|
||||
as int?,fileSizeBytes: freezed == fileSizeBytes ? _self.fileSizeBytes : fileSizeBytes // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [ChatMessageEntity].
|
||||
extension ChatMessageEntityPatterns on ChatMessageEntity {
|
||||
/// 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( _ChatMessageEntity value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessageEntity() 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( _ChatMessageEntity value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessageEntity():
|
||||
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( _ChatMessageEntity value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessageEntity() 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? chatId, String? userId, String? userName, String deviceIdentificator, ChatMessageType type, String content, ChatMessageStatus status, DateTime createdAt, bool isLocalOptimistic, bool failed, String? localFilePath, double uploadProgress, int? fileDurationMs, int? fileSizeBytes)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessageEntity() when $default != null:
|
||||
return $default(_that.id,_that.chatId,_that.userId,_that.userName,_that.deviceIdentificator,_that.type,_that.content,_that.status,_that.createdAt,_that.isLocalOptimistic,_that.failed,_that.localFilePath,_that.uploadProgress,_that.fileDurationMs,_that.fileSizeBytes);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? chatId, String? userId, String? userName, String deviceIdentificator, ChatMessageType type, String content, ChatMessageStatus status, DateTime createdAt, bool isLocalOptimistic, bool failed, String? localFilePath, double uploadProgress, int? fileDurationMs, int? fileSizeBytes) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessageEntity():
|
||||
return $default(_that.id,_that.chatId,_that.userId,_that.userName,_that.deviceIdentificator,_that.type,_that.content,_that.status,_that.createdAt,_that.isLocalOptimistic,_that.failed,_that.localFilePath,_that.uploadProgress,_that.fileDurationMs,_that.fileSizeBytes);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? chatId, String? userId, String? userName, String deviceIdentificator, ChatMessageType type, String content, ChatMessageStatus status, DateTime createdAt, bool isLocalOptimistic, bool failed, String? localFilePath, double uploadProgress, int? fileDurationMs, int? fileSizeBytes)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatMessageEntity() when $default != null:
|
||||
return $default(_that.id,_that.chatId,_that.userId,_that.userName,_that.deviceIdentificator,_that.type,_that.content,_that.status,_that.createdAt,_that.isLocalOptimistic,_that.failed,_that.localFilePath,_that.uploadProgress,_that.fileDurationMs,_that.fileSizeBytes);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _ChatMessageEntity extends ChatMessageEntity {
|
||||
const _ChatMessageEntity({required this.id, this.chatId, this.userId, this.userName, required this.deviceIdentificator, required this.type, required this.content, required this.status, required this.createdAt, this.isLocalOptimistic = false, this.failed = false, this.localFilePath, this.uploadProgress = 0.0, this.fileDurationMs, this.fileSizeBytes}): super._();
|
||||
|
||||
|
||||
@override final String id;
|
||||
@override final String? chatId;
|
||||
@override final String? userId;
|
||||
@override final String? userName;
|
||||
@override final String deviceIdentificator;
|
||||
@override final ChatMessageType type;
|
||||
@override final String content;
|
||||
@override final ChatMessageStatus status;
|
||||
@override final DateTime createdAt;
|
||||
@override@JsonKey() final bool isLocalOptimistic;
|
||||
@override@JsonKey() final bool failed;
|
||||
@override final String? localFilePath;
|
||||
@override@JsonKey() final double uploadProgress;
|
||||
@override final int? fileDurationMs;
|
||||
@override final int? fileSizeBytes;
|
||||
|
||||
/// Create a copy of ChatMessageEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$ChatMessageEntityCopyWith<_ChatMessageEntity> get copyWith => __$ChatMessageEntityCopyWithImpl<_ChatMessageEntity>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatMessageEntity&&(identical(other.id, id) || other.id == id)&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.deviceIdentificator, deviceIdentificator) || other.deviceIdentificator == deviceIdentificator)&&(identical(other.type, type) || other.type == type)&&(identical(other.content, content) || other.content == content)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isLocalOptimistic, isLocalOptimistic) || other.isLocalOptimistic == isLocalOptimistic)&&(identical(other.failed, failed) || other.failed == failed)&&(identical(other.localFilePath, localFilePath) || other.localFilePath == localFilePath)&&(identical(other.uploadProgress, uploadProgress) || other.uploadProgress == uploadProgress)&&(identical(other.fileDurationMs, fileDurationMs) || other.fileDurationMs == fileDurationMs)&&(identical(other.fileSizeBytes, fileSizeBytes) || other.fileSizeBytes == fileSizeBytes));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,chatId,userId,userName,deviceIdentificator,type,content,status,createdAt,isLocalOptimistic,failed,localFilePath,uploadProgress,fileDurationMs,fileSizeBytes);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatMessageEntity(id: $id, chatId: $chatId, userId: $userId, userName: $userName, deviceIdentificator: $deviceIdentificator, type: $type, content: $content, status: $status, createdAt: $createdAt, isLocalOptimistic: $isLocalOptimistic, failed: $failed, localFilePath: $localFilePath, uploadProgress: $uploadProgress, fileDurationMs: $fileDurationMs, fileSizeBytes: $fileSizeBytes)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$ChatMessageEntityCopyWith<$Res> implements $ChatMessageEntityCopyWith<$Res> {
|
||||
factory _$ChatMessageEntityCopyWith(_ChatMessageEntity value, $Res Function(_ChatMessageEntity) _then) = __$ChatMessageEntityCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id, String? chatId, String? userId, String? userName, String deviceIdentificator, ChatMessageType type, String content, ChatMessageStatus status, DateTime createdAt, bool isLocalOptimistic, bool failed, String? localFilePath, double uploadProgress, int? fileDurationMs, int? fileSizeBytes
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$ChatMessageEntityCopyWithImpl<$Res>
|
||||
implements _$ChatMessageEntityCopyWith<$Res> {
|
||||
__$ChatMessageEntityCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _ChatMessageEntity _self;
|
||||
final $Res Function(_ChatMessageEntity) _then;
|
||||
|
||||
/// Create a copy of ChatMessageEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? chatId = freezed,Object? userId = freezed,Object? userName = freezed,Object? deviceIdentificator = null,Object? type = null,Object? content = null,Object? status = null,Object? createdAt = null,Object? isLocalOptimistic = null,Object? failed = null,Object? localFilePath = freezed,Object? uploadProgress = null,Object? fileDurationMs = freezed,Object? fileSizeBytes = freezed,}) {
|
||||
return _then(_ChatMessageEntity(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,chatId: freezed == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,userName: freezed == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,deviceIdentificator: null == deviceIdentificator ? _self.deviceIdentificator : deviceIdentificator // ignore: cast_nullable_to_non_nullable
|
||||
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as ChatMessageType,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
|
||||
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||
as ChatMessageStatus,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,isLocalOptimistic: null == isLocalOptimistic ? _self.isLocalOptimistic : isLocalOptimistic // ignore: cast_nullable_to_non_nullable
|
||||
as bool,failed: null == failed ? _self.failed : failed // ignore: cast_nullable_to_non_nullable
|
||||
as bool,localFilePath: freezed == localFilePath ? _self.localFilePath : localFilePath // ignore: cast_nullable_to_non_nullable
|
||||
as String?,uploadProgress: null == uploadProgress ? _self.uploadProgress : uploadProgress // ignore: cast_nullable_to_non_nullable
|
||||
as double,fileDurationMs: freezed == fileDurationMs ? _self.fileDurationMs : fileDurationMs // ignore: cast_nullable_to_non_nullable
|
||||
as int?,fileSizeBytes: freezed == fileSizeBytes ? _self.fileSizeBytes : fileSizeBytes // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1 @@
|
||||
enum ChatMediaSource { camera, gallery }
|
||||
@@ -0,0 +1,13 @@
|
||||
enum ChatMessageStatus {
|
||||
wait,
|
||||
send,
|
||||
delivered;
|
||||
|
||||
static ChatMessageStatus fromWire(String value) =>
|
||||
ChatMessageStatus.values.firstWhere(
|
||||
(e) => e.name == value,
|
||||
orElse: () => ChatMessageStatus.wait,
|
||||
);
|
||||
|
||||
bool get isPending => this != ChatMessageStatus.delivered;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
enum ChatMessageType {
|
||||
text,
|
||||
emoji,
|
||||
image,
|
||||
audio;
|
||||
|
||||
String get wireValue => name;
|
||||
|
||||
static ChatMessageType fromWire(String value) =>
|
||||
ChatMessageType.values.firstWhere(
|
||||
(e) => e.wireValue == value,
|
||||
orElse: () => ChatMessageType.text,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
|
||||
|
||||
typedef ChatUploadProgress = void Function(int sent, int total);
|
||||
|
||||
enum ChatFileError { unsupported, forbidden, notFound, network, unknown }
|
||||
|
||||
class ChatFileException implements Exception {
|
||||
final ChatFileError kind;
|
||||
final String message;
|
||||
const ChatFileException({required this.kind, required this.message});
|
||||
|
||||
@override
|
||||
String toString() => 'ChatFileException($kind): $message';
|
||||
}
|
||||
|
||||
abstract class ChatRepository {
|
||||
Future<List<ChatMessageEntity>> listMessages({
|
||||
required String deviceIdentificator,
|
||||
String? chatId,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
});
|
||||
|
||||
Future<ChatMessageEntity> getMessage({required String id});
|
||||
|
||||
Future<ChatMessageEntity> sendTextMessage({
|
||||
required String id,
|
||||
required String deviceIdentificator,
|
||||
required String chatId,
|
||||
required ChatMessageType type,
|
||||
required String content,
|
||||
required String userId,
|
||||
required String userName,
|
||||
});
|
||||
|
||||
Future<ChatMessageEntity> sendImageMessage({
|
||||
required String id,
|
||||
required String deviceIdentificator,
|
||||
required String chatId,
|
||||
required String filePath,
|
||||
required String userId,
|
||||
required String userName,
|
||||
ChatUploadProgress? onProgress,
|
||||
});
|
||||
|
||||
Future<ChatMessageEntity> sendAudioMessage({
|
||||
required String id,
|
||||
required String deviceIdentificator,
|
||||
required String chatId,
|
||||
required String filePath,
|
||||
required int durationMs,
|
||||
required String userId,
|
||||
required String userName,
|
||||
ChatUploadProgress? onProgress,
|
||||
});
|
||||
|
||||
Future<File> resolveMessageFile({
|
||||
required String messageId,
|
||||
String? localFilePath,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class ChatIdResolver {
|
||||
const ChatIdResolver();
|
||||
|
||||
static const _oneToOnePrefix = '1to1_';
|
||||
static const _familyPrefix = 'family_';
|
||||
|
||||
String oneToOneChatId({
|
||||
required String userId,
|
||||
required String deviceIdentificator,
|
||||
}) => '$_oneToOnePrefix${userId}_$deviceIdentificator';
|
||||
|
||||
String familyChatId({required String userId, String? delegationId}) =>
|
||||
'$_familyPrefix${delegationId ?? userId}';
|
||||
|
||||
String newChatMessageId() => const Uuid().v4();
|
||||
|
||||
bool isOneToOne(String chatId) => chatId.startsWith(_oneToOnePrefix);
|
||||
|
||||
bool isFamilyGroup(String chatId) => chatId.startsWith(_familyPrefix);
|
||||
|
||||
String? toWireChatId({required String chatId, required String userId}) {
|
||||
if (isOneToOne(chatId)) return userId;
|
||||
if (isFamilyGroup(chatId)) return null;
|
||||
return chatId;
|
||||
}
|
||||
|
||||
bool matchesWireChatId({
|
||||
required String chatId,
|
||||
required String? wireChatId,
|
||||
}) {
|
||||
if (isOneToOne(chatId)) return wireChatId != null;
|
||||
if (isFamilyGroup(chatId)) return wireChatId == null;
|
||||
return wireChatId == chatId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
enum ChatPermissionResult { granted, denied, permanentlyDenied }
|
||||
|
||||
ChatPermissionResult _mapStatus(PermissionStatus status) {
|
||||
if (status.isGranted || status.isLimited) return ChatPermissionResult.granted;
|
||||
if (status.isPermanentlyDenied) {
|
||||
return ChatPermissionResult.permanentlyDenied;
|
||||
}
|
||||
return ChatPermissionResult.denied;
|
||||
}
|
||||
|
||||
class ChatPermissions {
|
||||
const ChatPermissions();
|
||||
|
||||
Future<ChatPermissionResult> ensureCamera() async {
|
||||
final status = await Permission.camera.request();
|
||||
return _mapStatus(status);
|
||||
}
|
||||
|
||||
Future<ChatPermissionResult> ensureMicrophone() async {
|
||||
final status = await Permission.microphone.request();
|
||||
return _mapStatus(status);
|
||||
}
|
||||
|
||||
Future<ChatPermissionResult> ensurePhotos() async {
|
||||
final status = await Permission.photos.request();
|
||||
return _mapStatus(status);
|
||||
}
|
||||
|
||||
Future<bool> openSettings() => openAppSettings();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'chat_id_resolver.dart';
|
||||
|
||||
/// Resolves the **client-side** chat ID from a raw incoming payload (push or
|
||||
/// WebSocket).
|
||||
///
|
||||
/// Backend convention (matrix on `senderId` × `chatId`):
|
||||
///
|
||||
/// | senderId | chatId | Type | Source | Client chat ID |
|
||||
/// |---------------|---------------|---------|--------|-----------------------------------------|
|
||||
/// | null/empty | null/empty | family | watch | `family_<delegationId or userId>` |
|
||||
/// | null/empty | non-empty | 1to1 | watch | `1to1_<userId>_<deviceIdentificator>` |
|
||||
/// | non-empty | null/empty | family | human | `family_<delegationId or userId>` |
|
||||
/// | non-empty | non-empty | 1to1 | human | `1to1_<userId>_<deviceIdentificator>` |
|
||||
///
|
||||
/// `chatId` discriminates 1:1 vs family. `senderId` discriminates device vs
|
||||
/// human (used elsewhere for display purposes — author label in bubbles, push
|
||||
/// title — but not for routing).
|
||||
///
|
||||
/// The raw `chatId` from the payload is **not** used as the client chat ID
|
||||
/// directly; it is only a signal that the message is 1:1.
|
||||
class IncomingChatResolver {
|
||||
const IncomingChatResolver({ChatIdResolver chatIdResolver = const ChatIdResolver()})
|
||||
: _chatIdResolver = chatIdResolver;
|
||||
|
||||
final ChatIdResolver _chatIdResolver;
|
||||
|
||||
/// Builds the client-side chat ID for an incoming message.
|
||||
///
|
||||
/// Returns either `1to1_<userId>_<deviceIdentificator>` or
|
||||
/// `family_<delegationId ?? userId>` depending on the matrix above.
|
||||
String resolveClientChatId({
|
||||
required String? chatId,
|
||||
required String deviceIdentificator,
|
||||
required String userId,
|
||||
required String? delegationId,
|
||||
}) {
|
||||
if (_isOneToOne(chatId)) {
|
||||
return _chatIdResolver.oneToOneChatId(
|
||||
userId: userId,
|
||||
deviceIdentificator: deviceIdentificator,
|
||||
);
|
||||
}
|
||||
return _chatIdResolver.familyChatId(
|
||||
userId: userId,
|
||||
delegationId: delegationId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether the incoming payload represents a 1:1 conversation.
|
||||
bool isOneToOne({required String? chatId}) => _isOneToOne(chatId);
|
||||
|
||||
/// Whether the message originated from a watch (device) rather than a human.
|
||||
///
|
||||
/// Used to decide the author label (carrier name vs human name) for
|
||||
/// notifications and chat bubbles in family group conversations.
|
||||
bool isFromWatch({required String? senderId}) =>
|
||||
senderId == null || senderId.isEmpty;
|
||||
|
||||
bool _isOneToOne(String? chatId) => chatId != null && chatId.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
class WatchEmojiCatalog {
|
||||
const WatchEmojiCatalog();
|
||||
|
||||
static const Map<String, String> _codeToUnicode = {
|
||||
'WA': '😴',
|
||||
'DK': '😭',
|
||||
'S': '😍',
|
||||
'GM': '😷',
|
||||
'OT': '🤮',
|
||||
'KX': '😃',
|
||||
'K': '😎',
|
||||
'TST': '😜',
|
||||
'NG': '☹️',
|
||||
'WL': '😶',
|
||||
'WX': '🙂',
|
||||
'FH': '😡',
|
||||
'WY': '😑',
|
||||
'ZY': '😉',
|
||||
'MMD': '😘',
|
||||
'SQ': '😖',
|
||||
'Y': '😇',
|
||||
'MH': '😅',
|
||||
};
|
||||
|
||||
static final Map<String, String> _unicodeToCode = {
|
||||
for (final entry in _codeToUnicode.entries) entry.value: entry.key,
|
||||
};
|
||||
|
||||
static final RegExp _codePattern = RegExp(r'\{([A-Za-z]+)\}');
|
||||
|
||||
Iterable<String> get supportedUnicode => _codeToUnicode.values;
|
||||
|
||||
bool isSupportedUnicode(String content) =>
|
||||
_unicodeToCode.containsKey(content);
|
||||
|
||||
String decode(String content) {
|
||||
return content.replaceAllMapped(_codePattern, (match) {
|
||||
final code = match.group(1)!.toUpperCase();
|
||||
return _codeToUnicode[code] ?? match.group(0)!;
|
||||
});
|
||||
}
|
||||
|
||||
String? encodeOne(String unicodeEmoji) {
|
||||
final code = _unicodeToCode[unicodeEmoji];
|
||||
return code == null ? null : '{$code}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// Active chat context as observed by the user.
|
||||
///
|
||||
/// Used by the notification handler to decide whether to suppress, downgrade,
|
||||
/// or show notifications for incoming chat messages.
|
||||
sealed class ChatContext {
|
||||
const ChatContext();
|
||||
|
||||
const factory ChatContext.outsideChat() = ChatContextOutsideChat;
|
||||
const factory ChatContext.list() = ChatContextList;
|
||||
const factory ChatContext.conversation(String chatId) =
|
||||
ChatContextConversation;
|
||||
}
|
||||
|
||||
class ChatContextOutsideChat extends ChatContext {
|
||||
const ChatContextOutsideChat();
|
||||
}
|
||||
|
||||
class ChatContextList extends ChatContext {
|
||||
const ChatContextList();
|
||||
}
|
||||
|
||||
class ChatContextConversation extends ChatContext {
|
||||
const ChatContextConversation(this.chatId);
|
||||
final String chatId;
|
||||
}
|
||||
|
||||
class ChatContextNotifier extends Notifier<ChatContext> {
|
||||
@override
|
||||
ChatContext build() => const ChatContext.outsideChat();
|
||||
|
||||
void setOutside() => state = const ChatContext.outsideChat();
|
||||
void setList() => state = const ChatContext.list();
|
||||
void setConversation(String chatId) =>
|
||||
state = ChatContext.conversation(chatId);
|
||||
|
||||
void releaseList() {
|
||||
if (state is ChatContextList) {
|
||||
state = const ChatContext.outsideChat();
|
||||
}
|
||||
}
|
||||
|
||||
void releaseConversation(String chatId) {
|
||||
final current = state;
|
||||
if (current is ChatContextConversation && current.chatId == chatId) {
|
||||
state = const ChatContext.outsideChat();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final chatContextProvider =
|
||||
NotifierProvider<ChatContextNotifier, ChatContext>(
|
||||
ChatContextNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:chat/src/core/domain/services/chat_id_resolver.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final chatIdResolverProvider = Provider<ChatIdResolver>(
|
||||
(ref) => const ChatIdResolver(),
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:chat/src/core/data/datasource/chat_offline_queue_datasource.dart';
|
||||
import 'package:chat/src/core/data/datasource/chat_offline_queue_datasource_impl.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final chatOfflineQueueProvider = Provider<ChatOfflineQueueDatasource>(
|
||||
(ref) => ChatOfflineQueueDatasourceImpl(),
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:chat/src/core/domain/services/chat_permissions.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final chatPermissionsProvider = Provider<ChatPermissions>(
|
||||
(ref) => const ChatPermissions(),
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:chat/src/core/data/datasource/chat_remote_datasource.dart';
|
||||
import 'package:chat/src/core/data/datasource/chat_remote_datasource_impl.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
|
||||
final chatRemoteDatasourceProvider = Provider<ChatRemoteDatasource>((ref) {
|
||||
return ChatRemoteDatasourceImpl(
|
||||
getIt<SaveFamilyRepository>(),
|
||||
getIt<Dio>(),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:chat/src/core/data/repositories/chat_repository_impl.dart';
|
||||
import 'package:chat/src/core/domain/repositories/chat_repository.dart';
|
||||
import 'package:chat/src/core/providers/chat_remote_datasource_provider.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
||||
return ChatRepositoryImpl(ref.read(chatRemoteDatasourceProvider));
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:chat/src/core/providers/chat_id_resolver_provider.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
|
||||
final familyChatIdProvider = Provider<String?>((ref) {
|
||||
final user = ref.watch(userInfoProvider).value;
|
||||
if (user == null) return null;
|
||||
return ref
|
||||
.read(chatIdResolverProvider)
|
||||
.familyChatId(userId: user.id, delegationId: user.delegationId);
|
||||
});
|
||||
|
||||
final familyChatMembersProvider = Provider<List<DeviceEntity>>((ref) {
|
||||
final devices = ref.watch(legacyDevicesProvider).value ?? const [];
|
||||
final user = ref.watch(userInfoProvider).value;
|
||||
if (user == null) return const [];
|
||||
|
||||
final delegationId = user.delegationId;
|
||||
if (delegationId == null) return devices;
|
||||
|
||||
return devices.where((d) => d.delegationId == delegationId).toList();
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:chat/src/core/domain/services/watch_emoji_catalog.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final watchEmojiCatalogProvider = Provider<WatchEmojiCatalog>(
|
||||
(ref) => const WatchEmojiCatalog(),
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:chat/src/features/chat_conversation/config/chat_conversation_config.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class ChatMediaCleanupService {
|
||||
const ChatMediaCleanupService();
|
||||
|
||||
Future<void> cleanupOrphanFiles() async {
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final cutoff =
|
||||
DateTime.now().subtract(ChatConversationConfig.orphanThreshold);
|
||||
await for (final entity in tempDir.list()) {
|
||||
if (entity is! File) continue;
|
||||
final name = entity.path.split('/').last;
|
||||
if (!name.startsWith('chat_audio_') &&
|
||||
!name.startsWith('chat_image_')) {
|
||||
continue;
|
||||
}
|
||||
final stat = await entity.stat();
|
||||
if (stat.modified.isBefore(cutoff)) {
|
||||
await entity.delete().catchError((_) => entity);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
final chatMediaCleanupServiceProvider = Provider<ChatMediaCleanupService>(
|
||||
(_) => const ChatMediaCleanupService(),
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:chat/src/core/providers/family_chat_provider.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
|
||||
class ChatParticipantsSnapshot {
|
||||
const ChatParticipantsSnapshot({
|
||||
required this.identificators,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
final List<String> identificators;
|
||||
final String? title;
|
||||
|
||||
static const empty =
|
||||
ChatParticipantsSnapshot(identificators: [], title: null);
|
||||
}
|
||||
|
||||
class ChatParticipantsService {
|
||||
ChatParticipantsService(this._ref);
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
Future<ChatParticipantsSnapshot> resolveForChat({
|
||||
required bool isOneToOne,
|
||||
}) async {
|
||||
if (isOneToOne) {
|
||||
final selected = await _ref.read(selectedDeviceProvider.future);
|
||||
if (selected == null) return ChatParticipantsSnapshot.empty;
|
||||
return ChatParticipantsSnapshot(
|
||||
identificators: [selected.identificator],
|
||||
title: selected.carrierName ?? selected.identificator,
|
||||
);
|
||||
}
|
||||
final members = _ref.read(familyChatMembersProvider);
|
||||
return ChatParticipantsSnapshot(
|
||||
identificators: members.map((d) => d.identificator).toList(),
|
||||
title: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<String>> resolveForSend({
|
||||
required bool isOneToOne,
|
||||
required List<String> fallback,
|
||||
}) async {
|
||||
if (isOneToOne) {
|
||||
final selected = await _ref.read(selectedDeviceProvider.future);
|
||||
if (selected == null) return const [];
|
||||
return [selected.identificator];
|
||||
}
|
||||
final members = _ref.read(familyChatMembersProvider);
|
||||
if (members.isEmpty) return fallback;
|
||||
return members.map((d) => d.identificator).toList();
|
||||
}
|
||||
}
|
||||
|
||||
final chatParticipantsServiceProvider =
|
||||
Provider<ChatParticipantsService>(ChatParticipantsService.new);
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:chat/src/core/domain/services/chat_permissions.dart';
|
||||
import 'package:chat/src/core/providers/chat_permissions_provider.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sf_tracking/sf_tracking.dart';
|
||||
|
||||
enum ChatPermissionKind { camera, microphone, photos }
|
||||
|
||||
enum ChatPermissionDecision {
|
||||
granted,
|
||||
denied,
|
||||
permanentlyDeniedNeedsSettings,
|
||||
}
|
||||
|
||||
class ChatPermissionOutcome {
|
||||
const ChatPermissionOutcome({
|
||||
required this.decision,
|
||||
required this.kind,
|
||||
});
|
||||
|
||||
final ChatPermissionDecision decision;
|
||||
final ChatPermissionKind kind;
|
||||
|
||||
bool get isGranted => decision == ChatPermissionDecision.granted;
|
||||
}
|
||||
|
||||
class ChatPermissionFlowService {
|
||||
ChatPermissionFlowService(this._ref);
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
ChatPermissions get _permissions => _ref.read(chatPermissionsProvider);
|
||||
|
||||
Future<ChatPermissionOutcome> request(ChatPermissionKind kind) async {
|
||||
final result = switch (kind) {
|
||||
ChatPermissionKind.camera => await _permissions.ensureCamera(),
|
||||
ChatPermissionKind.microphone => await _permissions.ensureMicrophone(),
|
||||
ChatPermissionKind.photos => await _permissions.ensurePhotos(),
|
||||
};
|
||||
|
||||
final decision = switch (result) {
|
||||
ChatPermissionResult.granted => ChatPermissionDecision.granted,
|
||||
ChatPermissionResult.permanentlyDenied =>
|
||||
ChatPermissionDecision.permanentlyDeniedNeedsSettings,
|
||||
ChatPermissionResult.denied => ChatPermissionDecision.denied,
|
||||
};
|
||||
|
||||
if (decision != ChatPermissionDecision.granted) {
|
||||
_ref.read(sfTrackingProvider).legacyChatPermissionDenied(
|
||||
permission: switch (kind) {
|
||||
ChatPermissionKind.camera => 'camera',
|
||||
ChatPermissionKind.microphone => 'microphone',
|
||||
ChatPermissionKind.photos => 'photos',
|
||||
},
|
||||
permanently:
|
||||
decision == ChatPermissionDecision.permanentlyDeniedNeedsSettings,
|
||||
);
|
||||
}
|
||||
|
||||
return ChatPermissionOutcome(decision: decision, kind: kind);
|
||||
}
|
||||
|
||||
Future<bool> openSettings() => _permissions.openSettings();
|
||||
}
|
||||
|
||||
final chatPermissionFlowServiceProvider =
|
||||
Provider<ChatPermissionFlowService>(ChatPermissionFlowService.new);
|
||||
@@ -0,0 +1,264 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:chat/src/core/domain/enums/chat_message_status.dart';
|
||||
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
|
||||
import 'package:chat/src/core/domain/services/chat_id_resolver.dart';
|
||||
import 'package:chat/src/core/providers/chat_id_resolver_provider.dart';
|
||||
import 'package:chat/src/core/providers/chat_repository_provider.dart';
|
||||
import 'package:chat/src/features/chat_conversation/config/chat_conversation_config.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_conversation_state.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
|
||||
class ChatSendOutcome {
|
||||
const ChatSendOutcome({
|
||||
required this.success,
|
||||
this.primaryMessage,
|
||||
this.statusCode,
|
||||
});
|
||||
|
||||
final bool success;
|
||||
final ChatMessageEntity? primaryMessage;
|
||||
final int? statusCode;
|
||||
|
||||
ChatConversationErrorEvent? get errorEvent {
|
||||
if (success) return null;
|
||||
return switch (statusCode) {
|
||||
403 => ChatConversationErrorEvent.sendForbidden,
|
||||
413 => ChatConversationErrorEvent.fileTooLarge,
|
||||
415 => ChatConversationErrorEvent.fileUnsupported,
|
||||
_ => ChatConversationErrorEvent.sendFailed,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ChatSendService {
|
||||
ChatSendService(this._ref);
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
ChatIdResolver get _resolver => _ref.read(chatIdResolverProvider);
|
||||
|
||||
ChatMessageEntity buildOptimisticTextual({
|
||||
required String messageId,
|
||||
required String chatId,
|
||||
required String userId,
|
||||
required String userName,
|
||||
required String firstParticipant,
|
||||
required ChatMessageType type,
|
||||
required String displayContent,
|
||||
required DateTime now,
|
||||
}) =>
|
||||
ChatMessageEntity(
|
||||
id: messageId,
|
||||
chatId: chatId,
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
deviceIdentificator: firstParticipant,
|
||||
type: type,
|
||||
content: displayContent,
|
||||
status: ChatMessageStatus.wait,
|
||||
createdAt: now,
|
||||
isLocalOptimistic: true,
|
||||
);
|
||||
|
||||
Future<ChatMessageEntity> buildOptimisticMedia({
|
||||
required String messageId,
|
||||
required String chatId,
|
||||
required String userId,
|
||||
required String userName,
|
||||
required String firstParticipant,
|
||||
required ChatMessageType type,
|
||||
required String filePath,
|
||||
required DateTime now,
|
||||
int? durationMs,
|
||||
}) async {
|
||||
final fileSize = await _safeFileSize(filePath);
|
||||
return ChatMessageEntity(
|
||||
id: messageId,
|
||||
chatId: chatId,
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
deviceIdentificator: firstParticipant,
|
||||
type: type,
|
||||
content: '',
|
||||
status: ChatMessageStatus.wait,
|
||||
createdAt: now,
|
||||
isLocalOptimistic: true,
|
||||
localFilePath: filePath,
|
||||
uploadProgress: 0,
|
||||
fileDurationMs: durationMs,
|
||||
fileSizeBytes: fileSize,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ChatSendOutcome> sendTextual({
|
||||
required String primaryMessageId,
|
||||
required List<String> participants,
|
||||
required String chatId,
|
||||
required ChatMessageType type,
|
||||
required String wireContent,
|
||||
required String userId,
|
||||
required String userName,
|
||||
}) async {
|
||||
final repo = _ref.read(chatRepositoryProvider);
|
||||
final ids = _idsForFanOut(primaryMessageId, participants.length);
|
||||
|
||||
return _fanOut(
|
||||
participantCount: participants.length,
|
||||
sendOne: (index) => repo.sendTextMessage(
|
||||
id: ids[index],
|
||||
deviceIdentificator: participants[index],
|
||||
chatId: chatId,
|
||||
type: type,
|
||||
content: wireContent,
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ChatSendOutcome> sendMedia({
|
||||
required String primaryMessageId,
|
||||
required List<String> participants,
|
||||
required String chatId,
|
||||
required ChatMessageType type,
|
||||
required String filePath,
|
||||
required String userId,
|
||||
required String userName,
|
||||
int? durationMs,
|
||||
void Function(double aggregatedProgress)? onProgress,
|
||||
}) async {
|
||||
final repo = _ref.read(chatRepositoryProvider);
|
||||
final ids = _idsForFanOut(primaryMessageId, participants.length);
|
||||
final progresses = List<double>.filled(participants.length, 0.0);
|
||||
|
||||
void Function(int sent, int total) progressFor(int index) =>
|
||||
(sent, total) {
|
||||
if (total <= 0) return;
|
||||
progresses[index] = sent / total;
|
||||
final avg =
|
||||
progresses.reduce((a, b) => a + b) / progresses.length;
|
||||
onProgress?.call(avg);
|
||||
};
|
||||
|
||||
final outcome = await _fanOut(
|
||||
participantCount: participants.length,
|
||||
sendOne: (index) => type == ChatMessageType.image
|
||||
? repo.sendImageMessage(
|
||||
id: ids[index],
|
||||
deviceIdentificator: participants[index],
|
||||
chatId: chatId,
|
||||
filePath: filePath,
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
onProgress: progressFor(index),
|
||||
)
|
||||
: repo.sendAudioMessage(
|
||||
id: ids[index],
|
||||
deviceIdentificator: participants[index],
|
||||
chatId: chatId,
|
||||
filePath: filePath,
|
||||
durationMs: durationMs ?? 0,
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
onProgress: progressFor(index),
|
||||
),
|
||||
);
|
||||
|
||||
final primary = outcome.primaryMessage;
|
||||
if (primary == null) return outcome;
|
||||
final fileSize = await _safeFileSize(filePath);
|
||||
return ChatSendOutcome(
|
||||
success: outcome.success,
|
||||
primaryMessage: primary.copyWith(
|
||||
localFilePath: filePath,
|
||||
fileDurationMs: durationMs ?? primary.fileDurationMs,
|
||||
fileSizeBytes: fileSize ?? primary.fileSizeBytes,
|
||||
),
|
||||
statusCode: outcome.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _idsForFanOut(String primaryId, int count) => [
|
||||
primaryId,
|
||||
for (var i = 1; i < count; i++) _resolver.newChatMessageId(),
|
||||
];
|
||||
|
||||
Future<ChatSendOutcome> _fanOut({
|
||||
required int participantCount,
|
||||
required Future<ChatMessageEntity> Function(int index) sendOne,
|
||||
}) async {
|
||||
final results = await Future.wait([
|
||||
for (var i = 0; i < participantCount; i++)
|
||||
_SendAttempt.run(() => sendOne(i)),
|
||||
]);
|
||||
final allSucceeded = results.every((r) => r.success);
|
||||
final primary = results.firstWhere(
|
||||
(r) => r.success,
|
||||
orElse: () => results.first,
|
||||
);
|
||||
return ChatSendOutcome(
|
||||
success: allSucceeded,
|
||||
primaryMessage: primary.value,
|
||||
statusCode: allSucceeded ? null : primary.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
Future<int?> _safeFileSize(String path) async {
|
||||
try {
|
||||
return await File(path).length();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SendAttempt {
|
||||
const _SendAttempt._({
|
||||
required this.success,
|
||||
this.value,
|
||||
this.statusCode,
|
||||
});
|
||||
|
||||
final bool success;
|
||||
final ChatMessageEntity? value;
|
||||
final int? statusCode;
|
||||
|
||||
static Future<_SendAttempt> run(
|
||||
Future<ChatMessageEntity> Function() action,
|
||||
) async {
|
||||
int? lastStatusCode;
|
||||
for (var attempt = 0;
|
||||
attempt < ChatConversationConfig.retryAttempts;
|
||||
attempt++) {
|
||||
try {
|
||||
final value = await action();
|
||||
return _SendAttempt._(success: true, value: value);
|
||||
} on ApiException catch (e) {
|
||||
lastStatusCode = e.statusCode;
|
||||
if (!e.isNetworkError) {
|
||||
return _SendAttempt._(success: false, statusCode: e.statusCode);
|
||||
}
|
||||
debugPrint(
|
||||
'[Chat][send.network] attempt=$attempt status=${e.statusCode} message=${e.message}',
|
||||
);
|
||||
if (attempt < ChatConversationConfig.retryAttempts - 1) {
|
||||
await Future<void>.delayed(ChatConversationConfig.retryBackoff);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[Chat][send.error] attempt=$attempt type=${e.runtimeType} $e',
|
||||
);
|
||||
if (attempt < ChatConversationConfig.retryAttempts - 1) {
|
||||
await Future<void>.delayed(ChatConversationConfig.retryBackoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
return _SendAttempt._(success: false, statusCode: lastStatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
final chatSendServiceProvider = Provider<ChatSendService>(ChatSendService.new);
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:chat/src/core/domain/services/chat_id_resolver.dart';
|
||||
import 'package:chat/src/core/providers/chat_id_resolver_provider.dart';
|
||||
import 'package:chat/src/core/providers/chat_repository_provider.dart';
|
||||
import 'package:chat/src/features/chat_conversation/config/chat_conversation_config.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:legacy_device_state/legacy_device_state.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
|
||||
class ChatPageResult {
|
||||
const ChatPageResult({
|
||||
required this.messages,
|
||||
required this.hasMore,
|
||||
this.hasError = false,
|
||||
});
|
||||
|
||||
final List<ChatMessageEntity> messages;
|
||||
final bool hasMore;
|
||||
final bool hasError;
|
||||
}
|
||||
|
||||
class ChatSyncService {
|
||||
ChatSyncService(this._ref);
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
Future<ChatPageResult> fetchPage({
|
||||
required String chatId,
|
||||
required List<String> participants,
|
||||
required int page,
|
||||
}) async {
|
||||
final repo = _ref.read(chatRepositoryProvider);
|
||||
try {
|
||||
final perDevice = await Future.wait(
|
||||
participants.map(
|
||||
(deviceIdentificator) => repo.listMessages(
|
||||
deviceIdentificator: deviceIdentificator,
|
||||
chatId: chatId,
|
||||
page: page,
|
||||
pageSize: ChatConversationConfig.pageSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
final flattened = perDevice.expand((messages) => messages).toList();
|
||||
final hasMore = perDevice.any(
|
||||
(messages) => messages.length >= ChatConversationConfig.pageSize,
|
||||
);
|
||||
return ChatPageResult(messages: flattened, hasMore: hasMore);
|
||||
} catch (_) {
|
||||
return const ChatPageResult(
|
||||
messages: [],
|
||||
hasMore: false,
|
||||
hasError: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
StreamSubscription<WebSocketEvent> subscribeToWebSocket({
|
||||
required String chatId,
|
||||
required List<String> participants,
|
||||
required void Function() onMatchingEvent,
|
||||
}) {
|
||||
final ws = _ref.read(webSocketServiceProvider);
|
||||
final ChatIdResolver resolver = _ref.read(chatIdResolverProvider);
|
||||
final recentMessageIds = <String, DateTime>{};
|
||||
return ws.events.listen((event) {
|
||||
if (event is! ChatMessageEvent) return;
|
||||
final matchesChat = resolver.matchesWireChatId(
|
||||
chatId: chatId,
|
||||
wireChatId: event.chatId,
|
||||
);
|
||||
final matchesDevice = participants.contains(event.deviceIdentificator);
|
||||
if (!matchesChat || !matchesDevice) return;
|
||||
if (_isDuplicateWithinWindow(event.messageId, recentMessageIds)) return;
|
||||
onMatchingEvent();
|
||||
});
|
||||
}
|
||||
|
||||
/// Listens for WebSocket reconnections.
|
||||
///
|
||||
/// While the app is suspended (or the network drops) the WS misses any
|
||||
/// `chat-message-received` events the backend emits — they are not replayed
|
||||
/// on reconnect. The caller should reconcile its state from the REST API
|
||||
/// when this fires to recover lost messages.
|
||||
StreamSubscription<bool> subscribeToReconnect({
|
||||
required void Function() onReconnect,
|
||||
}) {
|
||||
final ws = _ref.read(webSocketServiceProvider);
|
||||
var wasConnected = ws.isConnected;
|
||||
return ws.connectionState.listen((connected) {
|
||||
if (connected && !wasConnected) {
|
||||
onReconnect();
|
||||
}
|
||||
wasConnected = connected;
|
||||
});
|
||||
}
|
||||
|
||||
bool _isDuplicateWithinWindow(
|
||||
String messageId,
|
||||
Map<String, DateTime> recent,
|
||||
) {
|
||||
final now = DateTime.now();
|
||||
recent.removeWhere(
|
||||
(_, seenAt) => now.difference(seenAt) > _wsDedupWindow,
|
||||
);
|
||||
if (recent.containsKey(messageId)) return true;
|
||||
recent[messageId] = now;
|
||||
return false;
|
||||
}
|
||||
|
||||
static const _wsDedupWindow = Duration(seconds: 5);
|
||||
|
||||
Timer createPollTimer({
|
||||
required Future<void> Function() onTick,
|
||||
required void Function() onTooManyErrors,
|
||||
required bool Function() isErrorTick,
|
||||
}) {
|
||||
var consecutiveErrors = 0;
|
||||
return Timer.periodic(ChatConversationConfig.pollInterval, (_) async {
|
||||
await onTick();
|
||||
if (isErrorTick()) {
|
||||
consecutiveErrors++;
|
||||
if (consecutiveErrors >=
|
||||
ChatConversationConfig.maxConsecutivePollErrors) {
|
||||
onTooManyErrors();
|
||||
}
|
||||
} else {
|
||||
consecutiveErrors = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
List<ChatMessageEntity> mergeMessages(List<ChatMessageEntity> all) {
|
||||
final byId = <String, ChatMessageEntity>{};
|
||||
for (final message in all) {
|
||||
final existing = byId[message.id];
|
||||
if (existing == null || existing.status.isPending) {
|
||||
byId[message.id] = message;
|
||||
}
|
||||
}
|
||||
final merged = byId.values.toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
final chatSyncServiceProvider = Provider<ChatSyncService>(ChatSyncService.new);
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:chat/src/features/chat_conversation/presentation/chat_conversation_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class ChatConversationBuilder {
|
||||
const ChatConversationBuilder();
|
||||
|
||||
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||
final chatId = state.pathParameters['chatId'] ?? '';
|
||||
return MaterialPage<void>(
|
||||
key: state.pageKey,
|
||||
child: ChatConversationScreen(chatId: chatId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
class ChatConversationConfig {
|
||||
const ChatConversationConfig._();
|
||||
|
||||
static const Duration pollInterval = Duration(seconds: 4);
|
||||
static const int pageSize = 50;
|
||||
static const Duration orphanThreshold = Duration(hours: 1);
|
||||
static const Duration retryBackoff = Duration(seconds: 3);
|
||||
static const int retryAttempts = 2;
|
||||
static const int maxConsecutivePollErrors = 3;
|
||||
static const int minAudioRecordingMs = 1000;
|
||||
static const int imagePickerMaxWidth = 4096;
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:chat/src/core/providers/chat_context_provider.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_conversation_controller.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_conversation_state.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_app_bar.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_date_separator.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_input_bar.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_message_bubble.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
|
||||
class ChatConversationScreen extends ConsumerStatefulWidget {
|
||||
final String chatId;
|
||||
|
||||
const ChatConversationScreen({super.key, required this.chatId});
|
||||
|
||||
@override
|
||||
ConsumerState<ChatConversationScreen> createState() =>
|
||||
_ChatConversationScreenState();
|
||||
}
|
||||
|
||||
class _ChatConversationScreenState
|
||||
extends ConsumerState<ChatConversationScreen> {
|
||||
final _scrollController = ScrollController();
|
||||
late final ChatContextNotifier _chatContextNotifier;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_chatContextNotifier = ref.read(chatContextProvider.notifier);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_chatContextNotifier.setConversation(widget.chatId);
|
||||
ref
|
||||
.read(chatConversationControllerProvider(widget.chatId).notifier)
|
||||
.reconcileFromRemote();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final notifier = _chatContextNotifier;
|
||||
final chatId = widget.chatId;
|
||||
Future.microtask(() => notifier.releaseConversation(chatId));
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
ref.read(chatConversationControllerProvider(widget.chatId).notifier).loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asyncState = ref.watch(chatConversationControllerProvider(widget.chatId));
|
||||
|
||||
ref.listen(chatConversationControllerProvider(widget.chatId), (
|
||||
previous,
|
||||
next,
|
||||
) {
|
||||
final state = next.value;
|
||||
if (state == null) return;
|
||||
if (previous?.value == null) {
|
||||
ref
|
||||
.read(chatConversationControllerProvider(widget.chatId).notifier)
|
||||
.initialize();
|
||||
}
|
||||
final errorKey = state.displayErrorKey;
|
||||
if (errorKey != null) {
|
||||
showErrorDialog(context, errorKey);
|
||||
ref
|
||||
.read(chatConversationControllerProvider(widget.chatId).notifier)
|
||||
.clearErrorEvent();
|
||||
}
|
||||
});
|
||||
|
||||
return asyncState.when(
|
||||
loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||
error: (_, __) => Scaffold(
|
||||
body: Center(
|
||||
child: Text(context.translate(I18n.errorChatLoadConversation)),
|
||||
),
|
||||
),
|
||||
data: (state) {
|
||||
final title = state.title ??
|
||||
(state.kind == ChatConversationKind.familyGroup
|
||||
? context.translate(I18n.chatFamilyGroupTitle)
|
||||
: context.translate(I18n.chatConversationTitleFallback));
|
||||
final leadingIcon = state.kind == ChatConversationKind.familyGroup
|
||||
? Icons.groups_outlined
|
||||
: Icons.person_outline;
|
||||
|
||||
return Scaffold(
|
||||
appBar: ChatAppBar(
|
||||
title: title,
|
||||
leadingIcon: leadingIcon,
|
||||
isRefreshing: state.isReconciling,
|
||||
onRefresh: () => ref
|
||||
.read(
|
||||
chatConversationControllerProvider(widget.chatId).notifier,
|
||||
)
|
||||
.reconcileFromRemote(),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _InvertedPullToRefresh(
|
||||
isRefreshing: state.isReconciling,
|
||||
onRefresh: () => ref
|
||||
.read(
|
||||
chatConversationControllerProvider(widget.chatId)
|
||||
.notifier,
|
||||
)
|
||||
.reconcileFromRemote(),
|
||||
child: _MessagesList(
|
||||
state: state,
|
||||
scrollController: _scrollController,
|
||||
chatId: widget.chatId,
|
||||
),
|
||||
),
|
||||
),
|
||||
ChatInputBar(
|
||||
isSending: state.isSending,
|
||||
onSendText: (text) => ref
|
||||
.read(
|
||||
chatConversationControllerProvider(widget.chatId).notifier,
|
||||
)
|
||||
.sendText(text),
|
||||
onSendEmoji: (emoji) => ref
|
||||
.read(
|
||||
chatConversationControllerProvider(widget.chatId).notifier,
|
||||
)
|
||||
.sendEmoji(emoji),
|
||||
onSendImage: (path, source) => ref
|
||||
.read(
|
||||
chatConversationControllerProvider(widget.chatId).notifier,
|
||||
)
|
||||
.sendImage(filePath: path, source: source),
|
||||
onSendAudio: ({required filePath, required durationMs}) => ref
|
||||
.read(
|
||||
chatConversationControllerProvider(widget.chatId).notifier,
|
||||
)
|
||||
.sendAudio(filePath: filePath, durationMs: durationMs),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessagesList extends ConsumerWidget {
|
||||
final ChatConversationState state;
|
||||
final ScrollController scrollController;
|
||||
final String chatId;
|
||||
|
||||
const _MessagesList({
|
||||
required this.state,
|
||||
required this.scrollController,
|
||||
required this.chatId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (state.messages.isEmpty && !state.isLoadingMore) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
reverse: true,
|
||||
children: [
|
||||
const SizedBox(height: 120),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
context.translate(I18n.chatConversationEmpty),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final items = _buildItems(state.messages);
|
||||
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
reverse: true,
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: items.length + (state.isLoadingMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (state.isLoadingMore && index == items.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final entry = items[index];
|
||||
return switch (entry) {
|
||||
_MessageItem(:final message) => ChatMessageBubble(
|
||||
key: ValueKey(message.id),
|
||||
message: message,
|
||||
onRetry: message.failed
|
||||
? () => ref
|
||||
.read(chatConversationControllerProvider(chatId).notifier)
|
||||
.retryFailed(message.id)
|
||||
: null,
|
||||
),
|
||||
_DateSeparatorItem(:final date) => ChatDateSeparator(date: date),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<_FeedItem> _buildItems(List<ChatMessageEntity> messages) {
|
||||
final items = <_FeedItem>[];
|
||||
DateTime? previousDate;
|
||||
for (final message in messages) {
|
||||
final messageDay = DateTime(
|
||||
message.createdAt.year,
|
||||
message.createdAt.month,
|
||||
message.createdAt.day,
|
||||
);
|
||||
if (previousDate != null && previousDate != messageDay) {
|
||||
items.add(_DateSeparatorItem(previousDate));
|
||||
}
|
||||
items.add(_MessageItem(message));
|
||||
previousDate = messageDay;
|
||||
}
|
||||
if (previousDate != null) items.add(_DateSeparatorItem(previousDate));
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull-to-refresh oriented for a `reverse: true` chat list — the user
|
||||
/// swipes up from the bottom (overscroll past the last/oldest message at the
|
||||
/// top visual edge) and the spinner appears anchored to the bottom.
|
||||
class _InvertedPullToRefresh extends StatefulWidget {
|
||||
final bool isRefreshing;
|
||||
final Future<void> Function() onRefresh;
|
||||
final Widget child;
|
||||
|
||||
const _InvertedPullToRefresh({
|
||||
required this.isRefreshing,
|
||||
required this.onRefresh,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_InvertedPullToRefresh> createState() => _InvertedPullToRefreshState();
|
||||
}
|
||||
|
||||
class _InvertedPullToRefreshState extends State<_InvertedPullToRefresh> {
|
||||
static const _triggerDistance = 80.0;
|
||||
|
||||
double _accumulated = 0;
|
||||
bool _shouldTrigger = false;
|
||||
|
||||
bool _onNotification(ScrollNotification notification) {
|
||||
if (widget.isRefreshing) return false;
|
||||
|
||||
if (notification is ScrollStartNotification) {
|
||||
_accumulated = 0;
|
||||
_shouldTrigger = false;
|
||||
} else if (notification is OverscrollNotification) {
|
||||
final dy = notification.dragDetails?.delta.dy;
|
||||
if (dy != null) {
|
||||
_accumulated += dy.abs();
|
||||
if (_accumulated >= _triggerDistance) _shouldTrigger = true;
|
||||
}
|
||||
} else if (notification is ScrollEndNotification) {
|
||||
if (_shouldTrigger) widget.onRefresh();
|
||||
_accumulated = 0;
|
||||
_shouldTrigger = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
NotificationListener<ScrollNotification>(
|
||||
onNotification: _onNotification,
|
||||
child: widget.child,
|
||||
),
|
||||
if (widget.isRefreshing)
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 12,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
sealed class _FeedItem {
|
||||
const _FeedItem();
|
||||
}
|
||||
|
||||
class _MessageItem extends _FeedItem {
|
||||
final ChatMessageEntity message;
|
||||
const _MessageItem(this.message);
|
||||
}
|
||||
|
||||
class _DateSeparatorItem extends _FeedItem {
|
||||
final DateTime date;
|
||||
const _DateSeparatorItem(this.date);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:chat/src/core/providers/chat_repository_provider.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_audio_player_state.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_conversation_controller.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'chat_audio_player_controller.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ChatAudioPlayerController extends _$ChatAudioPlayerController {
|
||||
AudioPlayer? _player;
|
||||
StreamSubscription<PlayerState>? _stateSub;
|
||||
StreamSubscription<Duration>? _durationSub;
|
||||
StreamSubscription<Duration>? _positionSub;
|
||||
|
||||
@override
|
||||
ChatAudioPlayerState build(
|
||||
String messageId, {
|
||||
required Duration initialDuration,
|
||||
}) {
|
||||
ref.onDispose(() {
|
||||
_stateSub?.cancel();
|
||||
_durationSub?.cancel();
|
||||
_positionSub?.cancel();
|
||||
_player?.dispose();
|
||||
_player = null;
|
||||
});
|
||||
|
||||
ref.listen<String?>(currentlyPlayingAudioProvider, (_, next) {
|
||||
if (next != messageId && state.isPlaying) {
|
||||
_player?.pause();
|
||||
}
|
||||
});
|
||||
|
||||
return ChatAudioPlayerState(total: initialDuration);
|
||||
}
|
||||
|
||||
Future<void> toggle({String? localFilePath}) async {
|
||||
if (state.isPreparing) return;
|
||||
final player = _ensurePlayer();
|
||||
if (state.isPlaying) {
|
||||
await player.pause();
|
||||
ref.read(currentlyPlayingAudioProvider.notifier).clearIf(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isPreparing: true);
|
||||
try {
|
||||
ref.read(currentlyPlayingAudioProvider.notifier).setPlaying(messageId);
|
||||
final file = await ref.read(chatRepositoryProvider).resolveMessageFile(
|
||||
messageId: messageId,
|
||||
localFilePath: localFilePath,
|
||||
);
|
||||
await player.play(DeviceFileSource(file.path));
|
||||
} catch (_) {
|
||||
ref.read(currentlyPlayingAudioProvider.notifier).clearIf(messageId);
|
||||
} finally {
|
||||
state = state.copyWith(isPreparing: false);
|
||||
}
|
||||
}
|
||||
|
||||
AudioPlayer _ensurePlayer() {
|
||||
if (_player != null) return _player!;
|
||||
final player = AudioPlayer();
|
||||
_player = player;
|
||||
_stateSub = player.onPlayerStateChanged.listen((s) {
|
||||
state = state.copyWith(isPlaying: s == PlayerState.playing);
|
||||
if (s == PlayerState.completed) {
|
||||
state = state.copyWith(position: Duration.zero);
|
||||
ref.read(currentlyPlayingAudioProvider.notifier).clearIf(messageId);
|
||||
}
|
||||
});
|
||||
_durationSub = player.onDurationChanged.listen((d) {
|
||||
state = state.copyWith(total: d);
|
||||
});
|
||||
_positionSub = player.onPositionChanged.listen((p) {
|
||||
state = state.copyWith(position: p);
|
||||
});
|
||||
return player;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'chat_audio_player_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(ChatAudioPlayerController)
|
||||
const chatAudioPlayerControllerProvider = ChatAudioPlayerControllerFamily._();
|
||||
|
||||
final class ChatAudioPlayerControllerProvider
|
||||
extends $NotifierProvider<ChatAudioPlayerController, ChatAudioPlayerState> {
|
||||
const ChatAudioPlayerControllerProvider._({
|
||||
required ChatAudioPlayerControllerFamily super.from,
|
||||
required (String, {Duration initialDuration}) super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'chatAudioPlayerControllerProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$chatAudioPlayerControllerHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'chatAudioPlayerControllerProvider'
|
||||
''
|
||||
'$argument';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
ChatAudioPlayerController create() => ChatAudioPlayerController();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(ChatAudioPlayerState value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<ChatAudioPlayerState>(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ChatAudioPlayerControllerProvider &&
|
||||
other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$chatAudioPlayerControllerHash() =>
|
||||
r'031a1a9764f9725f321aba2566c36fef261d1714';
|
||||
|
||||
final class ChatAudioPlayerControllerFamily extends $Family
|
||||
with
|
||||
$ClassFamilyOverride<
|
||||
ChatAudioPlayerController,
|
||||
ChatAudioPlayerState,
|
||||
ChatAudioPlayerState,
|
||||
ChatAudioPlayerState,
|
||||
(String, {Duration initialDuration})
|
||||
> {
|
||||
const ChatAudioPlayerControllerFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'chatAudioPlayerControllerProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
ChatAudioPlayerControllerProvider call(
|
||||
String messageId, {
|
||||
required Duration initialDuration,
|
||||
}) => ChatAudioPlayerControllerProvider._(
|
||||
argument: (messageId, initialDuration: initialDuration),
|
||||
from: this,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => r'chatAudioPlayerControllerProvider';
|
||||
}
|
||||
|
||||
abstract class _$ChatAudioPlayerController
|
||||
extends $Notifier<ChatAudioPlayerState> {
|
||||
late final _$args = ref.$arg as (String, {Duration initialDuration});
|
||||
String get messageId => _$args.$1;
|
||||
Duration get initialDuration => _$args.initialDuration;
|
||||
|
||||
ChatAudioPlayerState build(
|
||||
String messageId, {
|
||||
required Duration initialDuration,
|
||||
});
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build(_$args.$1, initialDuration: _$args.initialDuration);
|
||||
final ref = this.ref as $Ref<ChatAudioPlayerState, ChatAudioPlayerState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<ChatAudioPlayerState, ChatAudioPlayerState>,
|
||||
ChatAudioPlayerState,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'chat_audio_player_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
abstract class ChatAudioPlayerState with _$ChatAudioPlayerState {
|
||||
const factory ChatAudioPlayerState({
|
||||
@Default(false) bool isPlaying,
|
||||
@Default(false) bool isPreparing,
|
||||
@Default(Duration.zero) Duration position,
|
||||
@Default(Duration.zero) Duration total,
|
||||
}) = _ChatAudioPlayerState;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// 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 'chat_audio_player_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$ChatAudioPlayerState {
|
||||
|
||||
bool get isPlaying; bool get isPreparing; Duration get position; Duration get total;
|
||||
/// Create a copy of ChatAudioPlayerState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ChatAudioPlayerStateCopyWith<ChatAudioPlayerState> get copyWith => _$ChatAudioPlayerStateCopyWithImpl<ChatAudioPlayerState>(this as ChatAudioPlayerState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatAudioPlayerState&&(identical(other.isPlaying, isPlaying) || other.isPlaying == isPlaying)&&(identical(other.isPreparing, isPreparing) || other.isPreparing == isPreparing)&&(identical(other.position, position) || other.position == position)&&(identical(other.total, total) || other.total == total));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,isPlaying,isPreparing,position,total);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatAudioPlayerState(isPlaying: $isPlaying, isPreparing: $isPreparing, position: $position, total: $total)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ChatAudioPlayerStateCopyWith<$Res> {
|
||||
factory $ChatAudioPlayerStateCopyWith(ChatAudioPlayerState value, $Res Function(ChatAudioPlayerState) _then) = _$ChatAudioPlayerStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
bool isPlaying, bool isPreparing, Duration position, Duration total
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ChatAudioPlayerStateCopyWithImpl<$Res>
|
||||
implements $ChatAudioPlayerStateCopyWith<$Res> {
|
||||
_$ChatAudioPlayerStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final ChatAudioPlayerState _self;
|
||||
final $Res Function(ChatAudioPlayerState) _then;
|
||||
|
||||
/// Create a copy of ChatAudioPlayerState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? isPlaying = null,Object? isPreparing = null,Object? position = null,Object? total = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
isPlaying: null == isPlaying ? _self.isPlaying : isPlaying // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isPreparing: null == isPreparing ? _self.isPreparing : isPreparing // ignore: cast_nullable_to_non_nullable
|
||||
as bool,position: null == position ? _self.position : position // ignore: cast_nullable_to_non_nullable
|
||||
as Duration,total: null == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
|
||||
as Duration,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [ChatAudioPlayerState].
|
||||
extension ChatAudioPlayerStatePatterns on ChatAudioPlayerState {
|
||||
/// 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( _ChatAudioPlayerState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatAudioPlayerState() 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( _ChatAudioPlayerState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatAudioPlayerState():
|
||||
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( _ChatAudioPlayerState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatAudioPlayerState() 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 isPlaying, bool isPreparing, Duration position, Duration total)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatAudioPlayerState() when $default != null:
|
||||
return $default(_that.isPlaying,_that.isPreparing,_that.position,_that.total);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 isPlaying, bool isPreparing, Duration position, Duration total) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatAudioPlayerState():
|
||||
return $default(_that.isPlaying,_that.isPreparing,_that.position,_that.total);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 isPlaying, bool isPreparing, Duration position, Duration total)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatAudioPlayerState() when $default != null:
|
||||
return $default(_that.isPlaying,_that.isPreparing,_that.position,_that.total);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _ChatAudioPlayerState implements ChatAudioPlayerState {
|
||||
const _ChatAudioPlayerState({this.isPlaying = false, this.isPreparing = false, this.position = Duration.zero, this.total = Duration.zero});
|
||||
|
||||
|
||||
@override@JsonKey() final bool isPlaying;
|
||||
@override@JsonKey() final bool isPreparing;
|
||||
@override@JsonKey() final Duration position;
|
||||
@override@JsonKey() final Duration total;
|
||||
|
||||
/// Create a copy of ChatAudioPlayerState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$ChatAudioPlayerStateCopyWith<_ChatAudioPlayerState> get copyWith => __$ChatAudioPlayerStateCopyWithImpl<_ChatAudioPlayerState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatAudioPlayerState&&(identical(other.isPlaying, isPlaying) || other.isPlaying == isPlaying)&&(identical(other.isPreparing, isPreparing) || other.isPreparing == isPreparing)&&(identical(other.position, position) || other.position == position)&&(identical(other.total, total) || other.total == total));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,isPlaying,isPreparing,position,total);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatAudioPlayerState(isPlaying: $isPlaying, isPreparing: $isPreparing, position: $position, total: $total)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$ChatAudioPlayerStateCopyWith<$Res> implements $ChatAudioPlayerStateCopyWith<$Res> {
|
||||
factory _$ChatAudioPlayerStateCopyWith(_ChatAudioPlayerState value, $Res Function(_ChatAudioPlayerState) _then) = __$ChatAudioPlayerStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
bool isPlaying, bool isPreparing, Duration position, Duration total
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$ChatAudioPlayerStateCopyWithImpl<$Res>
|
||||
implements _$ChatAudioPlayerStateCopyWith<$Res> {
|
||||
__$ChatAudioPlayerStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _ChatAudioPlayerState _self;
|
||||
final $Res Function(_ChatAudioPlayerState) _then;
|
||||
|
||||
/// Create a copy of ChatAudioPlayerState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? isPlaying = null,Object? isPreparing = null,Object? position = null,Object? total = null,}) {
|
||||
return _then(_ChatAudioPlayerState(
|
||||
isPlaying: null == isPlaying ? _self.isPlaying : isPlaying // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isPreparing: null == isPreparing ? _self.isPreparing : isPreparing // ignore: cast_nullable_to_non_nullable
|
||||
as bool,position: null == position ? _self.position : position // ignore: cast_nullable_to_non_nullable
|
||||
as Duration,total: null == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
|
||||
as Duration,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1,644 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:chat/src/core/domain/enums/chat_media_source.dart';
|
||||
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
|
||||
import 'package:chat/src/core/providers/chat_id_resolver_provider.dart';
|
||||
import 'package:chat/src/core/providers/chat_offline_queue_provider.dart';
|
||||
import 'package:chat/src/core/providers/watch_emoji_catalog_provider.dart';
|
||||
import 'package:chat/src/features/chat_conversation/application/chat_media_cleanup_service.dart';
|
||||
import 'package:chat/src/features/chat_conversation/application/chat_participants_service.dart';
|
||||
import 'package:chat/src/features/chat_conversation/application/chat_send_service.dart';
|
||||
import 'package:chat/src/features/chat_conversation/application/chat_sync_service.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_conversation_state.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:sf_infrastructure/sf_infrastructure.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
import 'package:sf_tracking/sf_tracking.dart';
|
||||
|
||||
part 'chat_conversation_controller.g.dart';
|
||||
|
||||
void _logError(String tag, Object error) {
|
||||
debugPrint('[Chat][$tag] $error');
|
||||
}
|
||||
|
||||
// TODO(chat-known-issues): coordinar con backend la heurística push
|
||||
// `phone.endsWith(chatId)` para que `1to1_<userId>_<deviceIdentificator>` y
|
||||
// `family_<delegationId>` reciban push correctamente; pedir `chatId` explícito
|
||||
// en pushData; confirmar tope de tamaño y mime types soportados.
|
||||
// Ver docs/chat-known-issues.md (TODO crear).
|
||||
|
||||
@riverpod
|
||||
class ChatConversationController extends _$ChatConversationController {
|
||||
Timer? _pollTimer;
|
||||
StreamSubscription<WebSocketEvent>? _wsSubscription;
|
||||
StreamSubscription<bool>? _wsReconnectSubscription;
|
||||
bool _initialised = false;
|
||||
|
||||
@override
|
||||
Future<ChatConversationState> build(String chatId) async {
|
||||
ref.onDispose(_disposeSubs);
|
||||
|
||||
final resolver = ref.read(chatIdResolverProvider);
|
||||
final isOneToOne = resolver.isOneToOne(chatId);
|
||||
final kind = isOneToOne
|
||||
? ChatConversationKind.oneToOne
|
||||
: ChatConversationKind.familyGroup;
|
||||
|
||||
final participants = await ref
|
||||
.read(chatParticipantsServiceProvider)
|
||||
.resolveForChat(isOneToOne: isOneToOne);
|
||||
|
||||
if (participants.identificators.isEmpty) {
|
||||
return ChatConversationState(
|
||||
chatId: chatId,
|
||||
kind: kind,
|
||||
title: participants.title,
|
||||
errorEvent: ChatConversationErrorEvent.deviceNotFound,
|
||||
);
|
||||
}
|
||||
|
||||
final sync = ref.read(chatSyncServiceProvider);
|
||||
final initial = await sync.fetchPage(
|
||||
chatId: chatId,
|
||||
participants: participants.identificators,
|
||||
page: 1,
|
||||
);
|
||||
final queued = await ref.read(chatOfflineQueueProvider).load(chatId);
|
||||
final messages = sync.mergeMessages([...initial.messages, ...queued]);
|
||||
|
||||
unawaited(
|
||||
ref.read(sfTrackingProvider).legacyChatOpened(isGroup: !isOneToOne),
|
||||
);
|
||||
|
||||
return ChatConversationState(
|
||||
chatId: chatId,
|
||||
kind: kind,
|
||||
title: participants.title,
|
||||
participantsIdentificators: participants.identificators,
|
||||
messages: messages,
|
||||
hasMore: initial.hasMore,
|
||||
currentPage: 1,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_initialised) return;
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
_initialised = true;
|
||||
|
||||
unawaited(ref.read(chatMediaCleanupServiceProvider).cleanupOrphanFiles());
|
||||
|
||||
if (current.participantsIdentificators.isEmpty) return;
|
||||
|
||||
_attachWebSocketListener(
|
||||
chatId: current.chatId,
|
||||
participants: current.participantsIdentificators,
|
||||
);
|
||||
_maybeStartPolling(
|
||||
chatId: current.chatId,
|
||||
participants: current.participantsIdentificators,
|
||||
);
|
||||
|
||||
final hasQueued =
|
||||
current.messages.any((m) => m.failed && m.isLocalOptimistic);
|
||||
if (hasQueued) {
|
||||
final pending = current.messages
|
||||
.where((m) => m.failed && m.isLocalOptimistic)
|
||||
.toList();
|
||||
unawaited(_drainOfflineQueue(pending));
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> sendText(String content) =>
|
||||
_sendTextual(content: content, type: ChatMessageType.text);
|
||||
|
||||
Future<bool> sendEmoji(String unicodeEmoji) {
|
||||
final code = ref.read(watchEmojiCatalogProvider).encodeOne(unicodeEmoji);
|
||||
if (code == null) return Future.value(false);
|
||||
return _sendTextual(
|
||||
content: code,
|
||||
displayContent: unicodeEmoji,
|
||||
type: ChatMessageType.text,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> sendImage({
|
||||
required String filePath,
|
||||
required ChatMediaSource source,
|
||||
}) =>
|
||||
_sendMedia(
|
||||
filePath: filePath,
|
||||
type: ChatMessageType.image,
|
||||
imageSource: source,
|
||||
);
|
||||
|
||||
Future<bool> sendAudio({
|
||||
required String filePath,
|
||||
required int durationMs,
|
||||
}) =>
|
||||
_sendMedia(
|
||||
filePath: filePath,
|
||||
type: ChatMessageType.audio,
|
||||
durationMs: durationMs,
|
||||
);
|
||||
|
||||
Future<void> loadMore() async {
|
||||
final current = state.value;
|
||||
if (current == null || current.isLoadingMore || !current.hasMore) return;
|
||||
if (current.participantsIdentificators.isEmpty) return;
|
||||
|
||||
state = AsyncData(current.copyWith(isLoadingMore: true));
|
||||
final nextPage = current.currentPage + 1;
|
||||
final result = await ref.read(chatSyncServiceProvider).fetchPage(
|
||||
chatId: current.chatId,
|
||||
participants: current.participantsIdentificators,
|
||||
page: nextPage,
|
||||
);
|
||||
|
||||
final merged = ref.read(chatSyncServiceProvider).mergeMessages(
|
||||
[...current.messages, ...result.messages],
|
||||
);
|
||||
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
messages: merged,
|
||||
currentPage: nextPage,
|
||||
isLoadingMore: false,
|
||||
hasMore: result.hasMore,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> reconcileFromRemote() async {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
if (current.participantsIdentificators.isEmpty) return;
|
||||
if (current.isReconciling) return;
|
||||
|
||||
state = AsyncData(current.copyWith(isReconciling: true));
|
||||
|
||||
final sync = ref.read(chatSyncServiceProvider);
|
||||
try {
|
||||
final result = await sync.fetchPage(
|
||||
chatId: current.chatId,
|
||||
participants: current.participantsIdentificators,
|
||||
page: 1,
|
||||
);
|
||||
if (!ref.mounted) return;
|
||||
|
||||
final latest = state.value ?? current;
|
||||
final merged =
|
||||
sync.mergeMessages([...latest.messages, ...result.messages]);
|
||||
state = AsyncData(
|
||||
latest.copyWith(messages: merged, isReconciling: false),
|
||||
);
|
||||
|
||||
_maybeStartPolling(
|
||||
chatId: current.chatId,
|
||||
participants: current.participantsIdentificators,
|
||||
);
|
||||
} catch (_) {
|
||||
if (!ref.mounted) return;
|
||||
final latest = state.value ?? current;
|
||||
state = AsyncData(latest.copyWith(isReconciling: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> retryFailed(String messageId) async {
|
||||
final current = state.value;
|
||||
if (current == null) return false;
|
||||
final failed = current.messages.firstWhere(
|
||||
(m) => m.id == messageId && m.failed,
|
||||
orElse: () => current.messages.first,
|
||||
);
|
||||
if (failed.id != messageId || !failed.failed) return false;
|
||||
return switch (failed.type) {
|
||||
ChatMessageType.text || ChatMessageType.emoji => () {
|
||||
final catalog = ref.read(watchEmojiCatalogProvider);
|
||||
final encoded = catalog.encodeOne(failed.content);
|
||||
return _sendTextual(
|
||||
content: encoded ?? failed.content,
|
||||
displayContent: encoded != null ? failed.content : null,
|
||||
type: ChatMessageType.text,
|
||||
retryId: messageId,
|
||||
);
|
||||
}(),
|
||||
ChatMessageType.image when failed.localFilePath != null =>
|
||||
_sendMedia(
|
||||
filePath: failed.localFilePath!,
|
||||
type: ChatMessageType.image,
|
||||
retryId: messageId,
|
||||
),
|
||||
ChatMessageType.audio when failed.localFilePath != null =>
|
||||
_sendMedia(
|
||||
filePath: failed.localFilePath!,
|
||||
type: ChatMessageType.audio,
|
||||
durationMs: failed.fileDurationMs ?? 0,
|
||||
retryId: messageId,
|
||||
),
|
||||
_ => Future.value(false),
|
||||
};
|
||||
}
|
||||
|
||||
void clearSuccessEvent() {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
state = AsyncData(current.copyWith(successEvent: null));
|
||||
}
|
||||
|
||||
void clearErrorEvent() {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
state = AsyncData(current.copyWith(errorEvent: null));
|
||||
}
|
||||
|
||||
Future<bool> _sendTextual({
|
||||
required String content,
|
||||
required ChatMessageType type,
|
||||
String? displayContent,
|
||||
String? retryId,
|
||||
}) async {
|
||||
final current = state.value;
|
||||
if (current == null) return false;
|
||||
|
||||
final participants = await _participantsForSend(current);
|
||||
if (participants.isEmpty) {
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
errorEvent: ChatConversationErrorEvent.deviceNotFound,
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
final user = await ref.read(userInfoProvider.future);
|
||||
if (!ref.mounted) return false;
|
||||
|
||||
final send = ref.read(chatSendServiceProvider);
|
||||
final resolver = ref.read(chatIdResolverProvider);
|
||||
final messageId = retryId ?? resolver.newChatMessageId();
|
||||
final userName = _resolveUserName(user);
|
||||
|
||||
final optimistic = send.buildOptimisticTextual(
|
||||
messageId: messageId,
|
||||
chatId: current.chatId,
|
||||
userId: user.id,
|
||||
userName: userName,
|
||||
firstParticipant: participants.first,
|
||||
type: type,
|
||||
displayContent: displayContent ?? content,
|
||||
now: DateTime.now(),
|
||||
);
|
||||
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
messages: _upsertMessage(current.messages, optimistic),
|
||||
isSending: true,
|
||||
errorEvent: null,
|
||||
successEvent: null,
|
||||
),
|
||||
);
|
||||
|
||||
final outcome = await send.sendTextual(
|
||||
primaryMessageId: messageId,
|
||||
participants: participants,
|
||||
chatId: current.chatId,
|
||||
type: type,
|
||||
wireContent: content,
|
||||
userId: user.id,
|
||||
userName: userName,
|
||||
);
|
||||
|
||||
if (!ref.mounted) return outcome.success;
|
||||
|
||||
if (outcome.success) {
|
||||
_onSendSuccess(messageId: messageId, primary: outcome.primaryMessage!);
|
||||
unawaited(_safeRemoveFromQueue(current.chatId, messageId));
|
||||
if (retryId == null) {
|
||||
unawaited(
|
||||
ref.read(sfTrackingProvider).legacyChatMessageSent(
|
||||
type: type.wireValue,
|
||||
isGroup: current.kind == ChatConversationKind.familyGroup,
|
||||
memberCount:
|
||||
current.kind == ChatConversationKind.familyGroup
|
||||
? participants.length
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_onSendError(messageId: messageId, errorEvent: outcome.errorEvent!);
|
||||
unawaited(
|
||||
_safeEnqueue(current.chatId, optimistic.copyWith(failed: true)),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _sendMedia({
|
||||
required String filePath,
|
||||
required ChatMessageType type,
|
||||
int? durationMs,
|
||||
ChatMediaSource? imageSource,
|
||||
String? retryId,
|
||||
}) async {
|
||||
final current = state.value;
|
||||
if (current == null) return false;
|
||||
|
||||
final participants = await _participantsForSend(current);
|
||||
if (participants.isEmpty) {
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
errorEvent: ChatConversationErrorEvent.deviceNotFound,
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
final user = await ref.read(userInfoProvider.future);
|
||||
if (!ref.mounted) return false;
|
||||
|
||||
final send = ref.read(chatSendServiceProvider);
|
||||
final resolver = ref.read(chatIdResolverProvider);
|
||||
final messageId = retryId ?? resolver.newChatMessageId();
|
||||
final userName = _resolveUserName(user);
|
||||
|
||||
final optimistic = await send.buildOptimisticMedia(
|
||||
messageId: messageId,
|
||||
chatId: current.chatId,
|
||||
userId: user.id,
|
||||
userName: userName,
|
||||
firstParticipant: participants.first,
|
||||
type: type,
|
||||
filePath: filePath,
|
||||
durationMs: durationMs,
|
||||
now: DateTime.now(),
|
||||
);
|
||||
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
messages: _upsertMessage(current.messages, optimistic),
|
||||
isSending: true,
|
||||
errorEvent: null,
|
||||
successEvent: null,
|
||||
),
|
||||
);
|
||||
|
||||
final outcome = await send.sendMedia(
|
||||
primaryMessageId: messageId,
|
||||
participants: participants,
|
||||
chatId: current.chatId,
|
||||
type: type,
|
||||
filePath: filePath,
|
||||
userId: user.id,
|
||||
userName: userName,
|
||||
durationMs: durationMs,
|
||||
onProgress: (aggregated) => _updateUploadProgress(messageId, aggregated),
|
||||
);
|
||||
|
||||
if (!ref.mounted) return outcome.success;
|
||||
|
||||
if (outcome.success) {
|
||||
_onSendSuccess(messageId: messageId, primary: outcome.primaryMessage!);
|
||||
unawaited(_safeRemoveFromQueue(current.chatId, messageId));
|
||||
if (retryId == null) {
|
||||
final isGroup = current.kind == ChatConversationKind.familyGroup;
|
||||
if (type == ChatMessageType.image) {
|
||||
unawaited(
|
||||
ref.read(sfTrackingProvider).legacyChatImageSent(
|
||||
source: imageSource?.name ?? 'unknown',
|
||||
isGroup: isGroup,
|
||||
memberCount: isGroup ? participants.length : null,
|
||||
originalSizeBytes: optimistic.fileSizeBytes,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
unawaited(
|
||||
ref.read(sfTrackingProvider).legacyChatAudioSent(
|
||||
isGroup: isGroup,
|
||||
memberCount: isGroup ? participants.length : null,
|
||||
durationMs: durationMs ?? 0,
|
||||
sizeBytes: optimistic.fileSizeBytes,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_onSendError(messageId: messageId, errorEvent: outcome.errorEvent!);
|
||||
unawaited(
|
||||
_safeEnqueue(current.chatId, optimistic.copyWith(failed: true)),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
void _onSendSuccess({
|
||||
required String messageId,
|
||||
required ChatMessageEntity primary,
|
||||
}) {
|
||||
final stateNow = state.value;
|
||||
if (stateNow == null) return;
|
||||
final updated = [
|
||||
for (final m in stateNow.messages)
|
||||
if (m.id == messageId)
|
||||
primary.copyWith(
|
||||
failed: false,
|
||||
isLocalOptimistic: false,
|
||||
uploadProgress: 1,
|
||||
localFilePath: primary.localFilePath ?? m.localFilePath,
|
||||
fileDurationMs: primary.fileDurationMs ?? m.fileDurationMs,
|
||||
fileSizeBytes: primary.fileSizeBytes ?? m.fileSizeBytes,
|
||||
)
|
||||
else
|
||||
m,
|
||||
];
|
||||
state = AsyncData(
|
||||
stateNow.copyWith(
|
||||
messages: updated,
|
||||
isSending: false,
|
||||
successEvent: ChatConversationSuccessEvent.messageSent,
|
||||
),
|
||||
);
|
||||
|
||||
_maybeStartPolling(
|
||||
chatId: stateNow.chatId,
|
||||
participants: stateNow.participantsIdentificators,
|
||||
);
|
||||
}
|
||||
|
||||
void _onSendError({
|
||||
required String messageId,
|
||||
required ChatConversationErrorEvent errorEvent,
|
||||
}) {
|
||||
final stateNow = state.value;
|
||||
if (stateNow == null) return;
|
||||
final updated = [
|
||||
for (final m in stateNow.messages)
|
||||
if (m.id == messageId) m.copyWith(failed: true) else m,
|
||||
];
|
||||
state = AsyncData(
|
||||
stateNow.copyWith(
|
||||
messages: updated,
|
||||
isSending: false,
|
||||
errorEvent: errorEvent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateUploadProgress(String messageId, double progress) {
|
||||
final stateNow = state.value;
|
||||
if (stateNow == null) return;
|
||||
final updated = [
|
||||
for (final m in stateNow.messages)
|
||||
if (m.id == messageId) m.copyWith(uploadProgress: progress) else m,
|
||||
];
|
||||
state = AsyncData(stateNow.copyWith(messages: updated));
|
||||
}
|
||||
|
||||
List<ChatMessageEntity> _upsertMessage(
|
||||
List<ChatMessageEntity> messages,
|
||||
ChatMessageEntity message,
|
||||
) =>
|
||||
[message, ...messages.where((m) => m.id != message.id)];
|
||||
|
||||
String _resolveUserName(UserEntity user) {
|
||||
final fullName = '${user.firstName} ${user.lastName}'.trim();
|
||||
if (fullName.isNotEmpty) return fullName;
|
||||
if (user.email.isNotEmpty) return user.email;
|
||||
return user.id;
|
||||
}
|
||||
|
||||
Future<List<String>> _participantsForSend(
|
||||
ChatConversationState current,
|
||||
) =>
|
||||
ref.read(chatParticipantsServiceProvider).resolveForSend(
|
||||
isOneToOne: current.kind == ChatConversationKind.oneToOne,
|
||||
fallback: current.participantsIdentificators,
|
||||
);
|
||||
|
||||
void _maybeStartPolling({
|
||||
required String chatId,
|
||||
required List<String> participants,
|
||||
}) {
|
||||
final hasPending = state.value?.messages.any(
|
||||
(m) => !m.failed && m.status.isPending,
|
||||
) ??
|
||||
false;
|
||||
if (!hasPending) {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
return;
|
||||
}
|
||||
if (_pollTimer != null) return;
|
||||
|
||||
final sync = ref.read(chatSyncServiceProvider);
|
||||
var hadErrorThisTick = false;
|
||||
_pollTimer = sync.createPollTimer(
|
||||
onTick: () async {
|
||||
hadErrorThisTick = false;
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final stillPending = current.messages.any(
|
||||
(m) => !m.failed && m.status.isPending,
|
||||
);
|
||||
if (!stillPending) {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
return;
|
||||
}
|
||||
final result = await sync.fetchPage(
|
||||
chatId: chatId,
|
||||
participants: participants,
|
||||
page: 1,
|
||||
);
|
||||
if (!ref.mounted) return;
|
||||
if (result.hasError) {
|
||||
hadErrorThisTick = true;
|
||||
return;
|
||||
}
|
||||
final stateNow = state.value;
|
||||
if (stateNow == null) return;
|
||||
final merged = sync.mergeMessages(
|
||||
[...stateNow.messages, ...result.messages],
|
||||
);
|
||||
state = AsyncData(stateNow.copyWith(messages: merged));
|
||||
},
|
||||
onTooManyErrors: () {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
},
|
||||
isErrorTick: () => hadErrorThisTick,
|
||||
);
|
||||
}
|
||||
|
||||
void _attachWebSocketListener({
|
||||
required String chatId,
|
||||
required List<String> participants,
|
||||
}) {
|
||||
final sync = ref.read(chatSyncServiceProvider);
|
||||
_wsSubscription?.cancel();
|
||||
_wsSubscription = sync.subscribeToWebSocket(
|
||||
chatId: chatId,
|
||||
participants: participants,
|
||||
onMatchingEvent: () {
|
||||
unawaited(reconcileFromRemote());
|
||||
},
|
||||
);
|
||||
_wsReconnectSubscription?.cancel();
|
||||
_wsReconnectSubscription = sync.subscribeToReconnect(
|
||||
onReconnect: () {
|
||||
unawaited(reconcileFromRemote());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _drainOfflineQueue(List<ChatMessageEntity> queued) async {
|
||||
for (final pending in queued) {
|
||||
if (!ref.mounted) return;
|
||||
await retryFailed(pending.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _safeRemoveFromQueue(String chatId, String messageId) async {
|
||||
try {
|
||||
await ref.read(chatOfflineQueueProvider).remove(chatId, messageId);
|
||||
} catch (e) {
|
||||
_logError('queue.remove', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _safeEnqueue(String chatId, ChatMessageEntity message) async {
|
||||
try {
|
||||
await ref.read(chatOfflineQueueProvider).enqueue(chatId, message);
|
||||
} catch (e) {
|
||||
_logError('queue.enqueue', e);
|
||||
}
|
||||
}
|
||||
|
||||
void _disposeSubs() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
_wsSubscription?.cancel();
|
||||
_wsSubscription = null;
|
||||
_wsReconnectSubscription?.cancel();
|
||||
_wsReconnectSubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class CurrentlyPlayingAudio extends _$CurrentlyPlayingAudio {
|
||||
@override
|
||||
String? build() => null;
|
||||
|
||||
void setPlaying(String messageId) => state = messageId;
|
||||
|
||||
void clearIf(String messageId) {
|
||||
if (state == messageId) state = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'chat_conversation_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(ChatConversationController)
|
||||
const chatConversationControllerProvider = ChatConversationControllerFamily._();
|
||||
|
||||
final class ChatConversationControllerProvider
|
||||
extends
|
||||
$AsyncNotifierProvider<
|
||||
ChatConversationController,
|
||||
ChatConversationState
|
||||
> {
|
||||
const ChatConversationControllerProvider._({
|
||||
required ChatConversationControllerFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'chatConversationControllerProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$chatConversationControllerHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'chatConversationControllerProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
ChatConversationController create() => ChatConversationController();
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ChatConversationControllerProvider &&
|
||||
other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$chatConversationControllerHash() =>
|
||||
r'646e9be6235657fdbf4359a216a06fe5120c3ba9';
|
||||
|
||||
final class ChatConversationControllerFamily extends $Family
|
||||
with
|
||||
$ClassFamilyOverride<
|
||||
ChatConversationController,
|
||||
AsyncValue<ChatConversationState>,
|
||||
ChatConversationState,
|
||||
FutureOr<ChatConversationState>,
|
||||
String
|
||||
> {
|
||||
const ChatConversationControllerFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'chatConversationControllerProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
ChatConversationControllerProvider call(String chatId) =>
|
||||
ChatConversationControllerProvider._(argument: chatId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'chatConversationControllerProvider';
|
||||
}
|
||||
|
||||
abstract class _$ChatConversationController
|
||||
extends $AsyncNotifier<ChatConversationState> {
|
||||
late final _$args = ref.$arg as String;
|
||||
String get chatId => _$args;
|
||||
|
||||
FutureOr<ChatConversationState> build(String chatId);
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build(_$args);
|
||||
final ref =
|
||||
this.ref
|
||||
as $Ref<AsyncValue<ChatConversationState>, ChatConversationState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<
|
||||
AsyncValue<ChatConversationState>,
|
||||
ChatConversationState
|
||||
>,
|
||||
AsyncValue<ChatConversationState>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(CurrentlyPlayingAudio)
|
||||
const currentlyPlayingAudioProvider = CurrentlyPlayingAudioProvider._();
|
||||
|
||||
final class CurrentlyPlayingAudioProvider
|
||||
extends $NotifierProvider<CurrentlyPlayingAudio, String?> {
|
||||
const CurrentlyPlayingAudioProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'currentlyPlayingAudioProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$currentlyPlayingAudioHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
CurrentlyPlayingAudio create() => CurrentlyPlayingAudio();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(String? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<String?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$currentlyPlayingAudioHash() =>
|
||||
r'9929190d7e8877add532a214a9621bca3ee3e69f';
|
||||
|
||||
abstract class _$CurrentlyPlayingAudio extends $Notifier<String?> {
|
||||
String? build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<String?, String?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<String?, String?>,
|
||||
String?,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
|
||||
part 'chat_conversation_state.freezed.dart';
|
||||
|
||||
enum ChatConversationKind { oneToOne, familyGroup }
|
||||
|
||||
enum ChatConversationSuccessEvent { messageSent }
|
||||
|
||||
enum ChatConversationErrorEvent {
|
||||
loadFailed,
|
||||
sendFailed,
|
||||
sendForbidden,
|
||||
deviceNotFound,
|
||||
fileTooLarge,
|
||||
fileUnsupported,
|
||||
audioRecordingFailed,
|
||||
imagePickFailed,
|
||||
permissionCameraDenied,
|
||||
permissionMicrophoneDenied,
|
||||
permissionPhotosDenied,
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ChatConversationState with _$ChatConversationState {
|
||||
const factory ChatConversationState({
|
||||
required String chatId,
|
||||
required ChatConversationKind kind,
|
||||
@Default(<ChatMessageEntity>[]) List<ChatMessageEntity> messages,
|
||||
@Default(false) bool isLoadingMore,
|
||||
@Default(false) bool isSending,
|
||||
@Default(false) bool isReconciling,
|
||||
@Default(true) bool hasMore,
|
||||
@Default(1) int currentPage,
|
||||
String? title,
|
||||
@Default(<String>[]) List<String> participantsIdentificators,
|
||||
ChatConversationSuccessEvent? successEvent,
|
||||
ChatConversationErrorEvent? errorEvent,
|
||||
}) = _ChatConversationState;
|
||||
}
|
||||
|
||||
extension ChatConversationErrorEventDisplay on ChatConversationErrorEvent {
|
||||
String? get displayKey => switch (this) {
|
||||
ChatConversationErrorEvent.loadFailed => I18n.errorChatLoadConversation,
|
||||
ChatConversationErrorEvent.sendFailed => I18n.errorChatSendMessage,
|
||||
ChatConversationErrorEvent.sendForbidden => I18n.errorChatSendForbidden,
|
||||
ChatConversationErrorEvent.deviceNotFound => I18n.errorChatDeviceNotFound,
|
||||
ChatConversationErrorEvent.fileTooLarge => I18n.errorChatFileTooLarge,
|
||||
ChatConversationErrorEvent.fileUnsupported => I18n.errorChatFileUnsupported,
|
||||
ChatConversationErrorEvent.audioRecordingFailed =>
|
||||
I18n.errorChatAudioRecordingFailed,
|
||||
ChatConversationErrorEvent.imagePickFailed => I18n.errorChatImagePickFailed,
|
||||
ChatConversationErrorEvent.permissionCameraDenied =>
|
||||
I18n.chatPermissionCameraDenied,
|
||||
ChatConversationErrorEvent.permissionMicrophoneDenied =>
|
||||
I18n.chatPermissionMicrophoneDenied,
|
||||
ChatConversationErrorEvent.permissionPhotosDenied =>
|
||||
I18n.chatPermissionPhotosDenied,
|
||||
};
|
||||
}
|
||||
|
||||
extension ChatConversationStateDisplay on ChatConversationState {
|
||||
String? get displayErrorKey => errorEvent?.displayKey;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// 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 'chat_conversation_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$ChatConversationState {
|
||||
|
||||
String get chatId; ChatConversationKind get kind; List<ChatMessageEntity> get messages; bool get isLoadingMore; bool get isSending; bool get isReconciling; bool get hasMore; int get currentPage; String? get title; List<String> get participantsIdentificators; ChatConversationSuccessEvent? get successEvent; ChatConversationErrorEvent? get errorEvent;
|
||||
/// Create a copy of ChatConversationState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ChatConversationStateCopyWith<ChatConversationState> get copyWith => _$ChatConversationStateCopyWithImpl<ChatConversationState>(this as ChatConversationState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatConversationState&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.kind, kind) || other.kind == kind)&&const DeepCollectionEquality().equals(other.messages, messages)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore)&&(identical(other.isSending, isSending) || other.isSending == isSending)&&(identical(other.isReconciling, isReconciling) || other.isReconciling == isReconciling)&&(identical(other.hasMore, hasMore) || other.hasMore == hasMore)&&(identical(other.currentPage, currentPage) || other.currentPage == currentPage)&&(identical(other.title, title) || other.title == title)&&const DeepCollectionEquality().equals(other.participantsIdentificators, participantsIdentificators)&&(identical(other.successEvent, successEvent) || other.successEvent == successEvent)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,chatId,kind,const DeepCollectionEquality().hash(messages),isLoadingMore,isSending,isReconciling,hasMore,currentPage,title,const DeepCollectionEquality().hash(participantsIdentificators),successEvent,errorEvent);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatConversationState(chatId: $chatId, kind: $kind, messages: $messages, isLoadingMore: $isLoadingMore, isSending: $isSending, isReconciling: $isReconciling, hasMore: $hasMore, currentPage: $currentPage, title: $title, participantsIdentificators: $participantsIdentificators, successEvent: $successEvent, errorEvent: $errorEvent)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ChatConversationStateCopyWith<$Res> {
|
||||
factory $ChatConversationStateCopyWith(ChatConversationState value, $Res Function(ChatConversationState) _then) = _$ChatConversationStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String chatId, ChatConversationKind kind, List<ChatMessageEntity> messages, bool isLoadingMore, bool isSending, bool isReconciling, bool hasMore, int currentPage, String? title, List<String> participantsIdentificators, ChatConversationSuccessEvent? successEvent, ChatConversationErrorEvent? errorEvent
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ChatConversationStateCopyWithImpl<$Res>
|
||||
implements $ChatConversationStateCopyWith<$Res> {
|
||||
_$ChatConversationStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final ChatConversationState _self;
|
||||
final $Res Function(ChatConversationState) _then;
|
||||
|
||||
/// Create a copy of ChatConversationState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? chatId = null,Object? kind = null,Object? messages = null,Object? isLoadingMore = null,Object? isSending = null,Object? isReconciling = null,Object? hasMore = null,Object? currentPage = null,Object? title = freezed,Object? participantsIdentificators = null,Object? successEvent = freezed,Object? errorEvent = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
chatId: null == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
|
||||
as String,kind: null == kind ? _self.kind : kind // ignore: cast_nullable_to_non_nullable
|
||||
as ChatConversationKind,messages: null == messages ? _self.messages : messages // ignore: cast_nullable_to_non_nullable
|
||||
as List<ChatMessageEntity>,isLoadingMore: null == isLoadingMore ? _self.isLoadingMore : isLoadingMore // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isSending: null == isSending ? _self.isSending : isSending // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isReconciling: null == isReconciling ? _self.isReconciling : isReconciling // ignore: cast_nullable_to_non_nullable
|
||||
as bool,hasMore: null == hasMore ? _self.hasMore : hasMore // ignore: cast_nullable_to_non_nullable
|
||||
as bool,currentPage: null == currentPage ? _self.currentPage : currentPage // ignore: cast_nullable_to_non_nullable
|
||||
as int,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,participantsIdentificators: null == participantsIdentificators ? _self.participantsIdentificators : participantsIdentificators // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,successEvent: freezed == successEvent ? _self.successEvent : successEvent // ignore: cast_nullable_to_non_nullable
|
||||
as ChatConversationSuccessEvent?,errorEvent: freezed == errorEvent ? _self.errorEvent : errorEvent // ignore: cast_nullable_to_non_nullable
|
||||
as ChatConversationErrorEvent?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [ChatConversationState].
|
||||
extension ChatConversationStatePatterns on ChatConversationState {
|
||||
/// 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( _ChatConversationState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatConversationState() 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( _ChatConversationState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatConversationState():
|
||||
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( _ChatConversationState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatConversationState() 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 chatId, ChatConversationKind kind, List<ChatMessageEntity> messages, bool isLoadingMore, bool isSending, bool isReconciling, bool hasMore, int currentPage, String? title, List<String> participantsIdentificators, ChatConversationSuccessEvent? successEvent, ChatConversationErrorEvent? errorEvent)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatConversationState() when $default != null:
|
||||
return $default(_that.chatId,_that.kind,_that.messages,_that.isLoadingMore,_that.isSending,_that.isReconciling,_that.hasMore,_that.currentPage,_that.title,_that.participantsIdentificators,_that.successEvent,_that.errorEvent);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 chatId, ChatConversationKind kind, List<ChatMessageEntity> messages, bool isLoadingMore, bool isSending, bool isReconciling, bool hasMore, int currentPage, String? title, List<String> participantsIdentificators, ChatConversationSuccessEvent? successEvent, ChatConversationErrorEvent? errorEvent) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatConversationState():
|
||||
return $default(_that.chatId,_that.kind,_that.messages,_that.isLoadingMore,_that.isSending,_that.isReconciling,_that.hasMore,_that.currentPage,_that.title,_that.participantsIdentificators,_that.successEvent,_that.errorEvent);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 chatId, ChatConversationKind kind, List<ChatMessageEntity> messages, bool isLoadingMore, bool isSending, bool isReconciling, bool hasMore, int currentPage, String? title, List<String> participantsIdentificators, ChatConversationSuccessEvent? successEvent, ChatConversationErrorEvent? errorEvent)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatConversationState() when $default != null:
|
||||
return $default(_that.chatId,_that.kind,_that.messages,_that.isLoadingMore,_that.isSending,_that.isReconciling,_that.hasMore,_that.currentPage,_that.title,_that.participantsIdentificators,_that.successEvent,_that.errorEvent);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _ChatConversationState implements ChatConversationState {
|
||||
const _ChatConversationState({required this.chatId, required this.kind, final List<ChatMessageEntity> messages = const <ChatMessageEntity>[], this.isLoadingMore = false, this.isSending = false, this.isReconciling = false, this.hasMore = true, this.currentPage = 1, this.title, final List<String> participantsIdentificators = const <String>[], this.successEvent, this.errorEvent}): _messages = messages,_participantsIdentificators = participantsIdentificators;
|
||||
|
||||
|
||||
@override final String chatId;
|
||||
@override final ChatConversationKind kind;
|
||||
final List<ChatMessageEntity> _messages;
|
||||
@override@JsonKey() List<ChatMessageEntity> get messages {
|
||||
if (_messages is EqualUnmodifiableListView) return _messages;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_messages);
|
||||
}
|
||||
|
||||
@override@JsonKey() final bool isLoadingMore;
|
||||
@override@JsonKey() final bool isSending;
|
||||
@override@JsonKey() final bool isReconciling;
|
||||
@override@JsonKey() final bool hasMore;
|
||||
@override@JsonKey() final int currentPage;
|
||||
@override final String? title;
|
||||
final List<String> _participantsIdentificators;
|
||||
@override@JsonKey() List<String> get participantsIdentificators {
|
||||
if (_participantsIdentificators is EqualUnmodifiableListView) return _participantsIdentificators;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_participantsIdentificators);
|
||||
}
|
||||
|
||||
@override final ChatConversationSuccessEvent? successEvent;
|
||||
@override final ChatConversationErrorEvent? errorEvent;
|
||||
|
||||
/// Create a copy of ChatConversationState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$ChatConversationStateCopyWith<_ChatConversationState> get copyWith => __$ChatConversationStateCopyWithImpl<_ChatConversationState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatConversationState&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.kind, kind) || other.kind == kind)&&const DeepCollectionEquality().equals(other._messages, _messages)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore)&&(identical(other.isSending, isSending) || other.isSending == isSending)&&(identical(other.isReconciling, isReconciling) || other.isReconciling == isReconciling)&&(identical(other.hasMore, hasMore) || other.hasMore == hasMore)&&(identical(other.currentPage, currentPage) || other.currentPage == currentPage)&&(identical(other.title, title) || other.title == title)&&const DeepCollectionEquality().equals(other._participantsIdentificators, _participantsIdentificators)&&(identical(other.successEvent, successEvent) || other.successEvent == successEvent)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,chatId,kind,const DeepCollectionEquality().hash(_messages),isLoadingMore,isSending,isReconciling,hasMore,currentPage,title,const DeepCollectionEquality().hash(_participantsIdentificators),successEvent,errorEvent);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatConversationState(chatId: $chatId, kind: $kind, messages: $messages, isLoadingMore: $isLoadingMore, isSending: $isSending, isReconciling: $isReconciling, hasMore: $hasMore, currentPage: $currentPage, title: $title, participantsIdentificators: $participantsIdentificators, successEvent: $successEvent, errorEvent: $errorEvent)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$ChatConversationStateCopyWith<$Res> implements $ChatConversationStateCopyWith<$Res> {
|
||||
factory _$ChatConversationStateCopyWith(_ChatConversationState value, $Res Function(_ChatConversationState) _then) = __$ChatConversationStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String chatId, ChatConversationKind kind, List<ChatMessageEntity> messages, bool isLoadingMore, bool isSending, bool isReconciling, bool hasMore, int currentPage, String? title, List<String> participantsIdentificators, ChatConversationSuccessEvent? successEvent, ChatConversationErrorEvent? errorEvent
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$ChatConversationStateCopyWithImpl<$Res>
|
||||
implements _$ChatConversationStateCopyWith<$Res> {
|
||||
__$ChatConversationStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _ChatConversationState _self;
|
||||
final $Res Function(_ChatConversationState) _then;
|
||||
|
||||
/// Create a copy of ChatConversationState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? chatId = null,Object? kind = null,Object? messages = null,Object? isLoadingMore = null,Object? isSending = null,Object? isReconciling = null,Object? hasMore = null,Object? currentPage = null,Object? title = freezed,Object? participantsIdentificators = null,Object? successEvent = freezed,Object? errorEvent = freezed,}) {
|
||||
return _then(_ChatConversationState(
|
||||
chatId: null == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
|
||||
as String,kind: null == kind ? _self.kind : kind // ignore: cast_nullable_to_non_nullable
|
||||
as ChatConversationKind,messages: null == messages ? _self._messages : messages // ignore: cast_nullable_to_non_nullable
|
||||
as List<ChatMessageEntity>,isLoadingMore: null == isLoadingMore ? _self.isLoadingMore : isLoadingMore // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isSending: null == isSending ? _self.isSending : isSending // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isReconciling: null == isReconciling ? _self.isReconciling : isReconciling // ignore: cast_nullable_to_non_nullable
|
||||
as bool,hasMore: null == hasMore ? _self.hasMore : hasMore // ignore: cast_nullable_to_non_nullable
|
||||
as bool,currentPage: null == currentPage ? _self.currentPage : currentPage // ignore: cast_nullable_to_non_nullable
|
||||
as int,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,participantsIdentificators: null == participantsIdentificators ? _self._participantsIdentificators : participantsIdentificators // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,successEvent: freezed == successEvent ? _self.successEvent : successEvent // ignore: cast_nullable_to_non_nullable
|
||||
as ChatConversationSuccessEvent?,errorEvent: freezed == errorEvent ? _self.errorEvent : errorEvent // ignore: cast_nullable_to_non_nullable
|
||||
as ChatConversationErrorEvent?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_recorder_state.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:record/record.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'chat_recorder_controller.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ChatRecorderController extends _$ChatRecorderController {
|
||||
AudioRecorder? _recorder;
|
||||
Timer? _ticker;
|
||||
DateTime? _startedAt;
|
||||
|
||||
@override
|
||||
ChatRecorderState build() {
|
||||
ref.onDispose(() {
|
||||
_ticker?.cancel();
|
||||
_ticker = null;
|
||||
_recorder?.dispose();
|
||||
_recorder = null;
|
||||
});
|
||||
return const ChatRecorderState();
|
||||
}
|
||||
|
||||
Future<bool> start() async {
|
||||
if (state.isRecording) return false;
|
||||
try {
|
||||
_recorder ??= AudioRecorder();
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final path =
|
||||
'${tempDir.path}/chat_audio_${DateTime.now().millisecondsSinceEpoch}.m4a';
|
||||
await _recorder!.start(
|
||||
const RecordConfig(encoder: AudioEncoder.aacLc),
|
||||
path: path,
|
||||
);
|
||||
_startedAt = DateTime.now();
|
||||
_currentPath = path;
|
||||
HapticFeedback.lightImpact();
|
||||
state = const ChatRecorderState(isRecording: true);
|
||||
_ticker?.cancel();
|
||||
_ticker = Timer.periodic(const Duration(milliseconds: 200), (_) {
|
||||
final start = _startedAt;
|
||||
if (start == null) return;
|
||||
state = state.copyWith(duration: DateTime.now().difference(start));
|
||||
});
|
||||
return true;
|
||||
} catch (_) {
|
||||
_resetTimers();
|
||||
state = const ChatRecorderState();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void updateDrag(double dx) {
|
||||
final clamped = dx.clamp(-200.0, 0.0);
|
||||
final willCancel = dx < -60;
|
||||
if (state.dragDx == clamped && state.willCancel == willCancel) return;
|
||||
if (willCancel != state.willCancel) {
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
state = state.copyWith(dragDx: clamped, willCancel: willCancel);
|
||||
}
|
||||
|
||||
Future<ChatRecordingResult> stop() async {
|
||||
if (!state.isRecording) {
|
||||
return const ChatRecordingResult(
|
||||
path: null,
|
||||
durationMs: 0,
|
||||
wasCancelled: false,
|
||||
);
|
||||
}
|
||||
final wasCancelled = state.willCancel;
|
||||
final durationMs = state.duration.inMilliseconds;
|
||||
_resetTimers();
|
||||
final path = await _recorder?.stop();
|
||||
state = const ChatRecorderState();
|
||||
return ChatRecordingResult(
|
||||
path: path ?? _currentPath,
|
||||
durationMs: durationMs,
|
||||
wasCancelled: wasCancelled,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancel() async {
|
||||
if (!state.isRecording) return;
|
||||
_resetTimers();
|
||||
await _recorder?.cancel();
|
||||
state = const ChatRecorderState();
|
||||
}
|
||||
|
||||
void _resetTimers() {
|
||||
_ticker?.cancel();
|
||||
_ticker = null;
|
||||
_startedAt = null;
|
||||
}
|
||||
|
||||
String? _currentPath;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'chat_recorder_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(ChatRecorderController)
|
||||
const chatRecorderControllerProvider = ChatRecorderControllerProvider._();
|
||||
|
||||
final class ChatRecorderControllerProvider
|
||||
extends $NotifierProvider<ChatRecorderController, ChatRecorderState> {
|
||||
const ChatRecorderControllerProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'chatRecorderControllerProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$chatRecorderControllerHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
ChatRecorderController create() => ChatRecorderController();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(ChatRecorderState value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<ChatRecorderState>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$chatRecorderControllerHash() =>
|
||||
r'e6627912c032fd81714488e1378b24848ad34ce9';
|
||||
|
||||
abstract class _$ChatRecorderController extends $Notifier<ChatRecorderState> {
|
||||
ChatRecorderState build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<ChatRecorderState, ChatRecorderState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<ChatRecorderState, ChatRecorderState>,
|
||||
ChatRecorderState,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'chat_recorder_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
abstract class ChatRecorderState with _$ChatRecorderState {
|
||||
const factory ChatRecorderState({
|
||||
@Default(false) bool isRecording,
|
||||
@Default(false) bool willCancel,
|
||||
@Default(0.0) double dragDx,
|
||||
@Default(Duration.zero) Duration duration,
|
||||
}) = _ChatRecorderState;
|
||||
}
|
||||
|
||||
class ChatRecordingResult {
|
||||
final String? path;
|
||||
final int durationMs;
|
||||
final bool wasCancelled;
|
||||
|
||||
const ChatRecordingResult({
|
||||
required this.path,
|
||||
required this.durationMs,
|
||||
required this.wasCancelled,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// 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 'chat_recorder_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$ChatRecorderState {
|
||||
|
||||
bool get isRecording; bool get willCancel; double get dragDx; Duration get duration;
|
||||
/// Create a copy of ChatRecorderState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ChatRecorderStateCopyWith<ChatRecorderState> get copyWith => _$ChatRecorderStateCopyWithImpl<ChatRecorderState>(this as ChatRecorderState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatRecorderState&&(identical(other.isRecording, isRecording) || other.isRecording == isRecording)&&(identical(other.willCancel, willCancel) || other.willCancel == willCancel)&&(identical(other.dragDx, dragDx) || other.dragDx == dragDx)&&(identical(other.duration, duration) || other.duration == duration));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,isRecording,willCancel,dragDx,duration);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatRecorderState(isRecording: $isRecording, willCancel: $willCancel, dragDx: $dragDx, duration: $duration)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ChatRecorderStateCopyWith<$Res> {
|
||||
factory $ChatRecorderStateCopyWith(ChatRecorderState value, $Res Function(ChatRecorderState) _then) = _$ChatRecorderStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
bool isRecording, bool willCancel, double dragDx, Duration duration
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ChatRecorderStateCopyWithImpl<$Res>
|
||||
implements $ChatRecorderStateCopyWith<$Res> {
|
||||
_$ChatRecorderStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final ChatRecorderState _self;
|
||||
final $Res Function(ChatRecorderState) _then;
|
||||
|
||||
/// Create a copy of ChatRecorderState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? isRecording = null,Object? willCancel = null,Object? dragDx = null,Object? duration = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
isRecording: null == isRecording ? _self.isRecording : isRecording // ignore: cast_nullable_to_non_nullable
|
||||
as bool,willCancel: null == willCancel ? _self.willCancel : willCancel // ignore: cast_nullable_to_non_nullable
|
||||
as bool,dragDx: null == dragDx ? _self.dragDx : dragDx // ignore: cast_nullable_to_non_nullable
|
||||
as double,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
|
||||
as Duration,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [ChatRecorderState].
|
||||
extension ChatRecorderStatePatterns on ChatRecorderState {
|
||||
/// 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( _ChatRecorderState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatRecorderState() 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( _ChatRecorderState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatRecorderState():
|
||||
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( _ChatRecorderState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatRecorderState() 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 isRecording, bool willCancel, double dragDx, Duration duration)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatRecorderState() when $default != null:
|
||||
return $default(_that.isRecording,_that.willCancel,_that.dragDx,_that.duration);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 isRecording, bool willCancel, double dragDx, Duration duration) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatRecorderState():
|
||||
return $default(_that.isRecording,_that.willCancel,_that.dragDx,_that.duration);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 isRecording, bool willCancel, double dragDx, Duration duration)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatRecorderState() when $default != null:
|
||||
return $default(_that.isRecording,_that.willCancel,_that.dragDx,_that.duration);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _ChatRecorderState implements ChatRecorderState {
|
||||
const _ChatRecorderState({this.isRecording = false, this.willCancel = false, this.dragDx = 0.0, this.duration = Duration.zero});
|
||||
|
||||
|
||||
@override@JsonKey() final bool isRecording;
|
||||
@override@JsonKey() final bool willCancel;
|
||||
@override@JsonKey() final double dragDx;
|
||||
@override@JsonKey() final Duration duration;
|
||||
|
||||
/// Create a copy of ChatRecorderState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$ChatRecorderStateCopyWith<_ChatRecorderState> get copyWith => __$ChatRecorderStateCopyWithImpl<_ChatRecorderState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatRecorderState&&(identical(other.isRecording, isRecording) || other.isRecording == isRecording)&&(identical(other.willCancel, willCancel) || other.willCancel == willCancel)&&(identical(other.dragDx, dragDx) || other.dragDx == dragDx)&&(identical(other.duration, duration) || other.duration == duration));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,isRecording,willCancel,dragDx,duration);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatRecorderState(isRecording: $isRecording, willCancel: $willCancel, dragDx: $dragDx, duration: $duration)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$ChatRecorderStateCopyWith<$Res> implements $ChatRecorderStateCopyWith<$Res> {
|
||||
factory _$ChatRecorderStateCopyWith(_ChatRecorderState value, $Res Function(_ChatRecorderState) _then) = __$ChatRecorderStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
bool isRecording, bool willCancel, double dragDx, Duration duration
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$ChatRecorderStateCopyWithImpl<$Res>
|
||||
implements _$ChatRecorderStateCopyWith<$Res> {
|
||||
__$ChatRecorderStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _ChatRecorderState _self;
|
||||
final $Res Function(_ChatRecorderState) _then;
|
||||
|
||||
/// Create a copy of ChatRecorderState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? isRecording = null,Object? willCancel = null,Object? dragDx = null,Object? duration = null,}) {
|
||||
return _then(_ChatRecorderState(
|
||||
isRecording: null == isRecording ? _self.isRecording : isRecording // ignore: cast_nullable_to_non_nullable
|
||||
as bool,willCancel: null == willCancel ? _self.willCancel : willCancel // ignore: cast_nullable_to_non_nullable
|
||||
as bool,dragDx: null == dragDx ? _self.dragDx : dragDx // ignore: cast_nullable_to_non_nullable
|
||||
as double,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
|
||||
as Duration,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:legacy_theme/legacy_theme.dart';
|
||||
import 'package:navigation/navigation.dart';
|
||||
import 'package:utils/utils.dart';
|
||||
|
||||
class ChatAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final String title;
|
||||
final IconData leadingIcon;
|
||||
final VoidCallback? onRefresh;
|
||||
final bool isRefreshing;
|
||||
|
||||
const ChatAppBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.leadingIcon,
|
||||
this.onRefresh,
|
||||
this.isRefreshing = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final legacyPrimary = context.sfColors.legacyPrimary;
|
||||
|
||||
return AppBar(
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
leading: IconButton(
|
||||
onPressed: () => GetIt.I<NavigationContract>().goBack(),
|
||||
icon: Icon(
|
||||
Icons.adaptive.arrow_back,
|
||||
color: legacyPrimary,
|
||||
size: SizeUtils.getByScreen(small: 32, big: 28),
|
||||
),
|
||||
),
|
||||
titleSpacing: 0,
|
||||
title: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(leadingIcon, color: legacyPrimary, size: 22),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: SizeUtils.getByScreen<double>(small: 16, big: 15),
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (onRefresh != null)
|
||||
IconButton(
|
||||
onPressed: isRefreshing ? null : onRefresh,
|
||||
icon: isRefreshing
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: legacyPrimary,
|
||||
),
|
||||
)
|
||||
: Icon(Icons.refresh, color: legacyPrimary),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:chat/src/core/domain/enums/chat_media_source.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:legacy_theme/legacy_theme.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
|
||||
class ChatAttachmentPickerSheet extends StatelessWidget {
|
||||
const ChatAttachmentPickerSheet({super.key});
|
||||
|
||||
static Future<ChatMediaSource?> show(BuildContext context) {
|
||||
return showModalBottomSheet<ChatMediaSource>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const ChatAttachmentPickerSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final legacyPrimary = context.sfColors.legacyPrimary;
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(Icons.photo_camera_outlined, color: legacyPrimary),
|
||||
title: Text(context.translate(I18n.chatComposerCameraOption)),
|
||||
onTap: () => Navigator.of(context).pop(ChatMediaSource.camera),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.photo_library_outlined, color: legacyPrimary),
|
||||
title: Text(context.translate(I18n.chatComposerGalleryOption)),
|
||||
onTap: () => Navigator.of(context).pop(ChatMediaSource.gallery),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_audio_player_controller.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_bubble_shell.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:legacy_theme/legacy_theme.dart';
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
final minutes = d.inMinutes.toString().padLeft(2, '0');
|
||||
final seconds = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
|
||||
class ChatAudioBubble extends ConsumerWidget {
|
||||
const ChatAudioBubble({super.key, required this.message, this.onRetry});
|
||||
|
||||
final ChatMessageEntity message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isOutgoing = message.isOutgoing;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bubbleColor = isOutgoing
|
||||
? context.sfColors.legacyPrimary
|
||||
: colorScheme.surfaceContainerHighest;
|
||||
final textColor =
|
||||
isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface;
|
||||
final isUploading =
|
||||
message.isLocalOptimistic && message.uploadProgress < 1;
|
||||
|
||||
final initialDuration = Duration(milliseconds: message.fileDurationMs ?? 0);
|
||||
final playerProvider = chatAudioPlayerControllerProvider(
|
||||
message.id,
|
||||
initialDuration: initialDuration,
|
||||
);
|
||||
final playerState = ref.watch(playerProvider);
|
||||
|
||||
final progress = playerState.total.inMilliseconds == 0
|
||||
? 0.0
|
||||
: playerState.position.inMilliseconds /
|
||||
playerState.total.inMilliseconds;
|
||||
|
||||
return ChatBubbleShell(
|
||||
isOutgoing: isOutgoing,
|
||||
color: bubbleColor,
|
||||
onTap: message.failed ? onRetry : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!isOutgoing && message.userName != null)
|
||||
ChatBubbleAuthorName(
|
||||
userName: message.userName!,
|
||||
textColor: textColor,
|
||||
bottomSpacing: 4,
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isUploading)
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
value: message.uploadProgress > 0
|
||||
? message.uploadProgress
|
||||
: null,
|
||||
strokeWidth: 2,
|
||||
color: textColor,
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
onPressed: () => ref
|
||||
.read(playerProvider.notifier)
|
||||
.toggle(localFilePath: message.localFilePath),
|
||||
iconSize: 32,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: Icon(
|
||||
playerState.isPlaying
|
||||
? Icons.pause_circle
|
||||
: Icons.play_circle,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: LinearProgressIndicator(
|
||||
value: progress.clamp(0, 1),
|
||||
color: textColor,
|
||||
backgroundColor: textColor.withValues(alpha: 0.3),
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatDuration(
|
||||
playerState.isPlaying ||
|
||||
playerState.position.inMilliseconds > 0
|
||||
? playerState.position
|
||||
: playerState.total,
|
||||
),
|
||||
style: TextStyle(fontSize: 12, color: textColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ChatBubbleFooter(message: message, textColor: textColor),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_status_indicator.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:utils/utils.dart';
|
||||
|
||||
class ChatBubbleShell extends StatelessWidget {
|
||||
const ChatBubbleShell({
|
||||
super.key,
|
||||
required this.isOutgoing,
|
||||
required this.color,
|
||||
required this.child,
|
||||
this.onTap,
|
||||
this.maxWidthFactor = 0.75,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||
this.clipBehavior = Clip.none,
|
||||
});
|
||||
|
||||
final bool isOutgoing;
|
||||
final Color color;
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
final double maxWidthFactor;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final Clip clipBehavior;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: isOutgoing ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: EdgeInsets.symmetric(
|
||||
horizontal: SizeUtils.getByScreen<double>(small: 12, big: 12),
|
||||
vertical: 4,
|
||||
),
|
||||
padding: padding,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * maxWidthFactor,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isOutgoing ? 16 : 4),
|
||||
bottomRight: Radius.circular(isOutgoing ? 4 : 16),
|
||||
),
|
||||
),
|
||||
clipBehavior: clipBehavior,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatBubbleFooter extends StatelessWidget {
|
||||
const ChatBubbleFooter({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.textColor,
|
||||
});
|
||||
|
||||
final ChatMessageEntity message;
|
||||
final Color textColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final timeText = DateFormat.Hm().format(message.createdAt);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
timeText,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: textColor.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
if (message.isOutgoing) ...[
|
||||
const SizedBox(width: 4),
|
||||
ChatStatusIndicator(status: message.status, failed: message.failed),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatBubbleAuthorName extends StatelessWidget {
|
||||
const ChatBubbleAuthorName({
|
||||
super.key,
|
||||
required this.userName,
|
||||
required this.textColor,
|
||||
this.bottomSpacing = 2,
|
||||
});
|
||||
|
||||
final String userName;
|
||||
final Color textColor;
|
||||
final double bottomSpacing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomSpacing),
|
||||
child: Text(
|
||||
userName,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textColor.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
|
||||
class ChatDateSeparator extends StatelessWidget {
|
||||
final DateTime date;
|
||||
|
||||
const ChatDateSeparator({super.key, required this.date});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final messageDate = DateTime(date.year, date.month, date.day);
|
||||
final difference = today.difference(messageDate).inDays;
|
||||
|
||||
final label = switch (difference) {
|
||||
0 => context.translate(I18n.chatTodayLabel),
|
||||
1 => context.translate(I18n.chatYesterdayLabel),
|
||||
_ => DateFormat.yMMMMd().format(date),
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:chat/src/core/domain/services/watch_emoji_catalog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChatEmojiPickerSheet extends StatelessWidget {
|
||||
final WatchEmojiCatalog catalog;
|
||||
|
||||
const ChatEmojiPickerSheet({super.key, required this.catalog});
|
||||
|
||||
static Future<String?> show(
|
||||
BuildContext context, {
|
||||
required WatchEmojiCatalog catalog,
|
||||
}) {
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => ChatEmojiPickerSheet(catalog: catalog),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final emojis = catalog.supportedUnicode.toList();
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 6,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
),
|
||||
itemCount: emojis.length,
|
||||
itemBuilder: (context, index) {
|
||||
final emoji = emojis[index];
|
||||
return InkWell(
|
||||
onTap: () => Navigator.of(context).pop(emoji),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Center(
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 32)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:chat/src/core/providers/chat_repository_provider.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_bubble_shell.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_image_viewer_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final _localFileExistsProvider =
|
||||
FutureProvider.family.autoDispose<bool, String?>((ref, path) async {
|
||||
if (path == null || path.isEmpty) return false;
|
||||
return File(path).exists();
|
||||
});
|
||||
|
||||
class _RemoteFileKey {
|
||||
const _RemoteFileKey({required this.messageId, this.localFilePath});
|
||||
|
||||
final String messageId;
|
||||
final String? localFilePath;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is _RemoteFileKey &&
|
||||
other.messageId == messageId &&
|
||||
other.localFilePath == localFilePath;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(messageId, localFilePath);
|
||||
}
|
||||
|
||||
final _remoteFileProvider =
|
||||
FutureProvider.family.autoDispose<File, _RemoteFileKey>((ref, key) {
|
||||
return ref.read(chatRepositoryProvider).resolveMessageFile(
|
||||
messageId: key.messageId,
|
||||
localFilePath: key.localFilePath,
|
||||
);
|
||||
});
|
||||
|
||||
class ChatImageBubble extends ConsumerWidget {
|
||||
const ChatImageBubble({super.key, required this.message, this.onRetry});
|
||||
|
||||
final ChatMessageEntity message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isOutgoing = message.isOutgoing;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isUploading =
|
||||
message.isLocalOptimistic && message.uploadProgress < 1;
|
||||
final localPath = message.localFilePath;
|
||||
final canFetchRemote = !message.isLocalOptimistic;
|
||||
|
||||
return ChatBubbleShell(
|
||||
isOutgoing: isOutgoing,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
onTap: () {
|
||||
if (message.failed) {
|
||||
onRetry?.call();
|
||||
return;
|
||||
}
|
||||
ChatImageViewerScreen.show(
|
||||
context,
|
||||
messageId: canFetchRemote ? message.id : null,
|
||||
localPath: localPath,
|
||||
);
|
||||
},
|
||||
padding: EdgeInsets.zero,
|
||||
maxWidthFactor: 0.7,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SizedBox(
|
||||
height: 220,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: _ImageContent(
|
||||
messageId: message.id,
|
||||
localPath: localPath,
|
||||
canFetchRemote: canFetchRemote,
|
||||
),
|
||||
),
|
||||
if (isUploading && !message.failed)
|
||||
Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: message.uploadProgress > 0
|
||||
? message.uploadProgress
|
||||
: null,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (message.failed)
|
||||
const Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Colors.black54,
|
||||
child: Center(
|
||||
child: Icon(Icons.error_outline,
|
||||
color: Colors.white, size: 32),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 6,
|
||||
child: _OverlayBadge(
|
||||
child: ChatBubbleFooter(message: message, textColor: Colors.white),
|
||||
),
|
||||
),
|
||||
if (!isOutgoing && message.userName != null)
|
||||
Positioned(
|
||||
left: 8,
|
||||
top: 6,
|
||||
child: _OverlayBadge(
|
||||
child: Text(
|
||||
message.userName!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageContent extends ConsumerWidget {
|
||||
const _ImageContent({
|
||||
required this.messageId,
|
||||
required this.localPath,
|
||||
required this.canFetchRemote,
|
||||
});
|
||||
|
||||
final String messageId;
|
||||
final String? localPath;
|
||||
final bool canFetchRemote;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncLocalExists = ref.watch(_localFileExistsProvider(localPath));
|
||||
final localExists = asyncLocalExists.value ?? false;
|
||||
|
||||
if (localPath != null && localExists) {
|
||||
return Image.file(
|
||||
File(localPath!),
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
);
|
||||
}
|
||||
if (canFetchRemote) {
|
||||
return _RemoteImage(messageId: messageId, localFilePath: localPath);
|
||||
}
|
||||
return const ColoredBox(color: Colors.black12);
|
||||
}
|
||||
}
|
||||
|
||||
class _OverlayBadge extends StatelessWidget {
|
||||
const _OverlayBadge({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RemoteImage extends ConsumerWidget {
|
||||
const _RemoteImage({required this.messageId, this.localFilePath});
|
||||
|
||||
final String messageId;
|
||||
final String? localFilePath;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncFile = ref.watch(
|
||||
_remoteFileProvider(
|
||||
_RemoteFileKey(messageId: messageId, localFilePath: localFilePath),
|
||||
),
|
||||
);
|
||||
return asyncFile.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => const Center(child: Icon(Icons.broken_image)),
|
||||
data: (file) => Image.file(
|
||||
file,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:chat/src/core/providers/chat_repository_provider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class ChatImageViewerScreen extends ConsumerStatefulWidget {
|
||||
final String? messageId;
|
||||
final String? localPath;
|
||||
|
||||
const ChatImageViewerScreen({super.key, this.messageId, this.localPath});
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
String? messageId,
|
||||
String? localPath,
|
||||
}) {
|
||||
return Navigator.of(context, rootNavigator: true).push<void>(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => ChatImageViewerScreen(
|
||||
messageId: messageId,
|
||||
localPath: localPath,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ConsumerState<ChatImageViewerScreen> createState() =>
|
||||
_ChatImageViewerScreenState();
|
||||
}
|
||||
|
||||
class _ChatImageViewerScreenState extends ConsumerState<ChatImageViewerScreen> {
|
||||
Future<File>? _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final localPath = widget.localPath;
|
||||
final messageId = widget.messageId;
|
||||
if (localPath != null && File(localPath).existsSync()) return;
|
||||
if (messageId == null) return;
|
||||
_future = ref.read(chatRepositoryProvider).resolveMessageFile(
|
||||
messageId: messageId,
|
||||
localFilePath: localPath,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
),
|
||||
body: GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Center(
|
||||
child: InteractiveViewer(child: _buildImage()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImage() {
|
||||
final localPath = widget.localPath;
|
||||
if (localPath != null && File(localPath).existsSync()) {
|
||||
return Image.file(File(localPath));
|
||||
}
|
||||
if (_future == null) return const SizedBox.shrink();
|
||||
return FutureBuilder<File>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
if (snapshot.hasError || snapshot.data == null) {
|
||||
return const Icon(
|
||||
Icons.broken_image,
|
||||
color: Colors.white,
|
||||
size: 48,
|
||||
);
|
||||
}
|
||||
return Image.file(snapshot.data!);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import 'package:chat/src/core/domain/enums/chat_media_source.dart';
|
||||
import 'package:chat/src/core/providers/watch_emoji_catalog_provider.dart';
|
||||
import 'package:chat/src/features/chat_conversation/application/chat_permission_flow_service.dart';
|
||||
import 'package:chat/src/features/chat_conversation/config/chat_conversation_config.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_recorder_controller.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_attachment_picker_sheet.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_emoji_picker_sheet.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_recording_overlay.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/widgets/emoji_blocking_input_formatter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:legacy_theme/legacy_theme.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
import 'package:utils/utils.dart';
|
||||
|
||||
class ChatInputBar extends ConsumerStatefulWidget {
|
||||
const ChatInputBar({
|
||||
super.key,
|
||||
required this.isSending,
|
||||
required this.onSendText,
|
||||
required this.onSendEmoji,
|
||||
required this.onSendImage,
|
||||
required this.onSendAudio,
|
||||
});
|
||||
|
||||
final bool isSending;
|
||||
final Future<bool> Function(String content) onSendText;
|
||||
final Future<bool> Function(String unicodeEmoji) onSendEmoji;
|
||||
final Future<bool> Function(String filePath, ChatMediaSource source)
|
||||
onSendImage;
|
||||
final Future<bool> Function({required String filePath, required int durationMs})
|
||||
onSendAudio;
|
||||
|
||||
@override
|
||||
ConsumerState<ChatInputBar> createState() => _ChatInputBarState();
|
||||
}
|
||||
|
||||
class _ChatInputBarState extends ConsumerState<ChatInputBar> {
|
||||
final _controller = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
bool _waitingForMicPermission = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final recorder = ref.watch(chatRecorderControllerProvider);
|
||||
|
||||
return Material(
|
||||
color: colorScheme.surface,
|
||||
elevation: 4,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
_Composer(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
isSending: widget.isSending,
|
||||
onSendText: _handleSendText,
|
||||
onAttachTap: _handleAttachTap,
|
||||
onEmojiTap: _handleEmojiTap,
|
||||
onRecordStart: _startRecording,
|
||||
onRecordEnd: _stopAndSendRecording,
|
||||
onRecordCancel: _cancelRecording,
|
||||
onRecordDragUpdate: _updateCancelHint,
|
||||
),
|
||||
if (recorder.isRecording)
|
||||
Positioned.fill(
|
||||
child: ChatRecordingOverlay(
|
||||
duration: recorder.duration,
|
||||
willCancel: recorder.willCancel,
|
||||
dragDx: recorder.dragDx,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleSendText() async {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty || widget.isSending) return;
|
||||
final ok = await widget.onSendText(text);
|
||||
if (ok && mounted) {
|
||||
_controller.clear();
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleAttachTap() async {
|
||||
final source = await ChatAttachmentPickerSheet.show(context);
|
||||
if (source == null || !mounted) return;
|
||||
await _pickAndSendImage(source);
|
||||
}
|
||||
|
||||
Future<void> _handleEmojiTap() async {
|
||||
if (widget.isSending) return;
|
||||
final catalog = ref.read(watchEmojiCatalogProvider);
|
||||
final picked = await ChatEmojiPickerSheet.show(context, catalog: catalog);
|
||||
if (picked == null || !mounted) return;
|
||||
await widget.onSendEmoji(picked);
|
||||
}
|
||||
|
||||
Future<void> _pickAndSendImage(ChatMediaSource source) async {
|
||||
final granted = await _requestPermission(
|
||||
source == ChatMediaSource.camera
|
||||
? ChatPermissionKind.camera
|
||||
: ChatPermissionKind.photos,
|
||||
deniedKey: source == ChatMediaSource.camera
|
||||
? I18n.chatPermissionCameraDenied
|
||||
: I18n.chatPermissionPhotosDenied,
|
||||
);
|
||||
if (!granted || !mounted) return;
|
||||
|
||||
try {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(
|
||||
source: source == ChatMediaSource.camera
|
||||
? ImageSource.camera
|
||||
: ImageSource.gallery,
|
||||
maxWidth: ChatConversationConfig.imagePickerMaxWidth.toDouble(),
|
||||
);
|
||||
if (picked == null) return;
|
||||
await widget.onSendImage(picked.path, source);
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
await showErrorDialog(context, I18n.errorChatImagePickFailed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startRecording() async {
|
||||
_waitingForMicPermission = true;
|
||||
final granted = await _requestPermission(
|
||||
ChatPermissionKind.microphone,
|
||||
deniedKey: I18n.chatPermissionMicrophoneDenied,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!granted) {
|
||||
_waitingForMicPermission = false;
|
||||
return;
|
||||
}
|
||||
if (!_waitingForMicPermission) return;
|
||||
_waitingForMicPermission = false;
|
||||
|
||||
final ok = await ref.read(chatRecorderControllerProvider.notifier).start();
|
||||
if (!ok && mounted) {
|
||||
await showErrorDialog(context, I18n.errorChatAudioRecordingFailed);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopAndSendRecording() async {
|
||||
if (_waitingForMicPermission) {
|
||||
_waitingForMicPermission = false;
|
||||
return;
|
||||
}
|
||||
final notifier = ref.read(chatRecorderControllerProvider.notifier);
|
||||
if (!ref.read(chatRecorderControllerProvider).isRecording) return;
|
||||
final result = await notifier.stop();
|
||||
if (result.path == null || result.wasCancelled) return;
|
||||
if (result.durationMs < ChatConversationConfig.minAudioRecordingMs) {
|
||||
if (mounted) {
|
||||
await showInfoDialog(context, I18n.chatComposerRecordTooShort);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await widget.onSendAudio(
|
||||
filePath: result.path!,
|
||||
durationMs: result.durationMs,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _cancelRecording() async {
|
||||
_waitingForMicPermission = false;
|
||||
if (!ref.read(chatRecorderControllerProvider).isRecording) return;
|
||||
await ref.read(chatRecorderControllerProvider.notifier).cancel();
|
||||
}
|
||||
|
||||
void _updateCancelHint(LongPressMoveUpdateDetails details) {
|
||||
ref
|
||||
.read(chatRecorderControllerProvider.notifier)
|
||||
.updateDrag(details.localOffsetFromOrigin.dx);
|
||||
}
|
||||
|
||||
Future<bool> _requestPermission(
|
||||
ChatPermissionKind kind, {
|
||||
required String deniedKey,
|
||||
}) async {
|
||||
final outcome =
|
||||
await ref.read(chatPermissionFlowServiceProvider).request(kind);
|
||||
if (outcome.isGranted) return true;
|
||||
if (!mounted) return false;
|
||||
if (outcome.decision != ChatPermissionDecision.permanentlyDeniedNeedsSettings) {
|
||||
await showErrorDialog(context, deniedKey);
|
||||
return false;
|
||||
}
|
||||
final shouldOpen = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
content: Text(context.translate(deniedKey)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(context.translate(I18n.cancel)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(context.translate(I18n.chatPermissionOpenSettings)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (shouldOpen == true) {
|
||||
await ref.read(chatPermissionFlowServiceProvider).openSettings();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class _Composer extends StatelessWidget {
|
||||
const _Composer({
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
required this.isSending,
|
||||
required this.onSendText,
|
||||
required this.onAttachTap,
|
||||
required this.onEmojiTap,
|
||||
required this.onRecordStart,
|
||||
required this.onRecordEnd,
|
||||
required this.onRecordCancel,
|
||||
required this.onRecordDragUpdate,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
final bool isSending;
|
||||
final Future<void> Function() onSendText;
|
||||
final Future<void> Function() onAttachTap;
|
||||
final Future<void> Function() onEmojiTap;
|
||||
final Future<void> Function() onRecordStart;
|
||||
final Future<void> Function() onRecordEnd;
|
||||
final Future<void> Function() onRecordCancel;
|
||||
final void Function(LongPressMoveUpdateDetails) onRecordDragUpdate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final padding = SizeUtils.getByScreen<double>(small: 8, big: 8);
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: padding, vertical: padding),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: isSending ? null : onAttachTap,
|
||||
icon: Icon(
|
||||
Icons.add_circle_outline,
|
||||
color: context.sfColors.legacyPrimary,
|
||||
),
|
||||
tooltip: context.translate(I18n.chatComposerAttachTooltip),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: isSending ? null : onEmojiTap,
|
||||
icon: Icon(
|
||||
Icons.emoji_emotions_outlined,
|
||||
color: context.sfColors.legacyPrimary,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
textInputAction: TextInputAction.newline,
|
||||
inputFormatters: const <TextInputFormatter>[
|
||||
EmojiBlockingInputFormatter(),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
hintText: context.translate(I18n.chatComposerHint),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_SendOrRecordAction(
|
||||
controller: controller,
|
||||
isSending: isSending,
|
||||
onSendText: onSendText,
|
||||
onRecordStart: onRecordStart,
|
||||
onRecordEnd: onRecordEnd,
|
||||
onRecordCancel: onRecordCancel,
|
||||
onRecordDragUpdate: onRecordDragUpdate,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SendOrRecordAction extends StatelessWidget {
|
||||
const _SendOrRecordAction({
|
||||
required this.controller,
|
||||
required this.isSending,
|
||||
required this.onSendText,
|
||||
required this.onRecordStart,
|
||||
required this.onRecordEnd,
|
||||
required this.onRecordCancel,
|
||||
required this.onRecordDragUpdate,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final bool isSending;
|
||||
final Future<void> Function() onSendText;
|
||||
final Future<void> Function() onRecordStart;
|
||||
final Future<void> Function() onRecordEnd;
|
||||
final Future<void> Function() onRecordCancel;
|
||||
final void Function(LongPressMoveUpdateDetails) onRecordDragUpdate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
final hasText = controller.text.trim().isNotEmpty;
|
||||
final canSend = hasText && !isSending;
|
||||
if (hasText || isSending) {
|
||||
return IconButton(
|
||||
onPressed: canSend ? onSendText : null,
|
||||
icon: isSending
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
Icons.send_rounded,
|
||||
color: canSend
|
||||
? context.sfColors.legacyPrimary
|
||||
: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
tooltip: context.translate(I18n.chatComposerSendTooltip),
|
||||
);
|
||||
}
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () =>
|
||||
showInfoDialog(context, I18n.chatComposerRecordHoldHint),
|
||||
onLongPress: onRecordStart,
|
||||
onLongPressMoveUpdate: onRecordDragUpdate,
|
||||
onLongPressEnd: (_) => onRecordEnd(),
|
||||
onLongPressCancel: onRecordCancel,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Icon(
|
||||
Icons.mic_outlined,
|
||||
color: context.sfColors.legacyPrimary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
|
||||
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
|
||||
import 'package:chat/src/core/domain/services/watch_emoji_catalog.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_audio_bubble.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_bubble_shell.dart';
|
||||
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_image_bubble.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:legacy_theme/legacy_theme.dart';
|
||||
import 'package:utils/utils.dart';
|
||||
|
||||
class ChatMessageBubble extends StatelessWidget {
|
||||
const ChatMessageBubble({super.key, required this.message, this.onRetry});
|
||||
|
||||
final ChatMessageEntity message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (message.type) {
|
||||
ChatMessageType.text || ChatMessageType.emoji => _TextBubble(
|
||||
message: message,
|
||||
onRetry: onRetry,
|
||||
),
|
||||
ChatMessageType.image => ChatImageBubble(
|
||||
message: message,
|
||||
onRetry: onRetry,
|
||||
),
|
||||
ChatMessageType.audio => ChatAudioBubble(
|
||||
message: message,
|
||||
onRetry: onRetry,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _TextBubble extends StatelessWidget {
|
||||
const _TextBubble({required this.message, this.onRetry});
|
||||
|
||||
final ChatMessageEntity message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isOutgoing = message.isOutgoing;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bubbleColor = isOutgoing
|
||||
? context.sfColors.legacyPrimary
|
||||
: colorScheme.surfaceContainerHighest;
|
||||
final textColor =
|
||||
isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface;
|
||||
|
||||
final isEmojiOnly =
|
||||
const WatchEmojiCatalog().isSupportedUnicode(message.content);
|
||||
|
||||
return ChatBubbleShell(
|
||||
isOutgoing: isOutgoing,
|
||||
color: bubbleColor,
|
||||
onTap: message.failed ? onRetry : null,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: SizeUtils.getByScreen<double>(small: 14, big: 14),
|
||||
vertical: SizeUtils.getByScreen<double>(small: 10, big: 9),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!isOutgoing && message.userName != null)
|
||||
ChatBubbleAuthorName(
|
||||
userName: message.userName!,
|
||||
textColor: textColor,
|
||||
),
|
||||
Text(
|
||||
message.content,
|
||||
style: TextStyle(
|
||||
fontSize: isEmojiOnly
|
||||
? 36
|
||||
: SizeUtils.getByScreen<double>(small: 15, big: 14),
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ChatBubbleFooter(message: message, textColor: textColor),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
|
||||
class ChatRecordingOverlay extends StatelessWidget {
|
||||
final Duration duration;
|
||||
final bool willCancel;
|
||||
final double dragDx;
|
||||
|
||||
const ChatRecordingOverlay({
|
||||
super.key,
|
||||
required this.duration,
|
||||
required this.willCancel,
|
||||
this.dragDx = 0,
|
||||
});
|
||||
|
||||
String _format(Duration d) {
|
||||
final minutes = d.inMinutes.toString().padLeft(2, '0');
|
||||
final seconds = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final accent = Colors.red.shade600;
|
||||
final accentStrong = Colors.red.shade800;
|
||||
final hintOpacity = (1.0 - dragDx.abs() / 60).clamp(0.0, 1.0);
|
||||
|
||||
return Material(
|
||||
color: colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
_PulsingDot(color: accent),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
_format(duration),
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_CancelHint(
|
||||
willCancel: willCancel,
|
||||
hintOpacity: hintOpacity,
|
||||
dragDx: dragDx,
|
||||
accent: accentStrong,
|
||||
outline: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Transform.translate(
|
||||
offset: Offset(dragDx * 0.6, 0),
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
scale: willCancel ? 1.15 : 1.0,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: willCancel ? accentStrong : accent,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: (willCancel ? accentStrong : accent)
|
||||
.withValues(alpha: 0.35),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.mic,
|
||||
color: Colors.white,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CancelHint extends StatelessWidget {
|
||||
final bool willCancel;
|
||||
final double hintOpacity;
|
||||
final double dragDx;
|
||||
final Color accent;
|
||||
final Color outline;
|
||||
|
||||
const _CancelHint({
|
||||
required this.willCancel,
|
||||
required this.hintOpacity,
|
||||
required this.dragDx,
|
||||
required this.accent,
|
||||
required this.outline,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
transitionBuilder: (child, animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0.2, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: willCancel
|
||||
? Text(
|
||||
context.translate(I18n.chatRecordingReleaseToCancel),
|
||||
key: const ValueKey('cancel'),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: accent,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
)
|
||||
: Opacity(
|
||||
key: const ValueKey('hint'),
|
||||
opacity: hintOpacity,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Transform.translate(
|
||||
offset: Offset(dragDx * 0.3, 0),
|
||||
child: Icon(
|
||||
Icons.chevron_left_rounded,
|
||||
size: 18,
|
||||
color: outline,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.translate(I18n.chatRecordingCancelHint),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: outline,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PulsingDot extends StatefulWidget {
|
||||
final Color color;
|
||||
const _PulsingDot({required this.color});
|
||||
|
||||
@override
|
||||
State<_PulsingDot> createState() => _PulsingDotState();
|
||||
}
|
||||
|
||||
class _PulsingDotState extends State<_PulsingDot>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
)..repeat(reverse: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (_, __) {
|
||||
final t = _controller.value;
|
||||
return SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 14 + 6 * t,
|
||||
height: 14 + 6 * t,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: widget.color.withValues(alpha: 0.3 * (1 - t)),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:chat/src/core/domain/enums/chat_message_status.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:legacy_theme/legacy_theme.dart';
|
||||
|
||||
class ChatStatusIndicator extends StatelessWidget {
|
||||
final ChatMessageStatus status;
|
||||
final bool failed;
|
||||
|
||||
const ChatStatusIndicator({
|
||||
super.key,
|
||||
required this.status,
|
||||
required this.failed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = failed
|
||||
? Theme.of(context).colorScheme.error
|
||||
: context.sfColors.legacyPrimary;
|
||||
final icon = failed
|
||||
? Icons.error_outline
|
||||
: switch (status) {
|
||||
ChatMessageStatus.wait => Icons.schedule,
|
||||
ChatMessageStatus.send => Icons.check,
|
||||
ChatMessageStatus.delivered => Icons.done_all,
|
||||
};
|
||||
return Icon(icon, size: 14, color: color);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user