# Phase 3.1 Implementation Plan: Riverpod Migration & FCM Fallback ## Summary of Clarified Requirements | Topic | Decision | |---|---| | Work stream order | Riverpod migration first, then FCM fallback | | Riverpod style | Annotation-based (`@riverpod`) with code generation | | Mitra AuthBloc bug | Fix stuck-loading: emit `AuthInitial` when `currentUser` is null | | Mitra ping config | Control Center toggle: "require mitra ping" (boolean) + ping interval (seconds) | | Non-ping mode | Mitra stays online without heartbeat; no auto-offline timeout; QC handles quality | | Pairing FCM fallback | When WebSocket to mitra is closed, send pairing request via FCM push | | Mitra pairing confirmation | Must manually accept (no auto-accept via FCM) | | Unread badges (mitra) | Badge on "active sessions" button on home; badge on each session in list | | Unread badges (customer) | Badge on `_ActiveSessionCard` widget on home screen | | Badge clearing | Badges clear when messages are read | | Closure FCM fallback | Backend sends closure signal to both parties; uses FCM if WebSocket is down | | Closure screen | Must show closure screen on app (no silent updates) | | Control center | New config: "require mitra ping" toggle + ping interval input | | Backend changes for Riverpod | None — migration is Flutter-only | --- ## Work Stream 1: Riverpod Migration (Flutter-only) ### 1.1 Dependency Changes #### Both `client_app/pubspec.yaml` and `mitra_app/pubspec.yaml` **Add to dependencies:** | Package | Purpose | |---|---| | `flutter_riverpod` | Core Riverpod provider framework | | `hooks_riverpod` | Riverpod + flutter_hooks integration (`HookConsumerWidget`) | | `riverpod_annotation` | `@riverpod` / `@Riverpod(keepAlive: true)` annotations | | `flutter_hooks` | Hook utilities (`useTextEditingController`, `useEffect`, etc.) | **Add to dev_dependencies:** | Package | Purpose | |---|---| | `riverpod_generator` | Code generation for `@riverpod` providers | | `build_runner` | Runs code generation (`dart run build_runner build`) | | `custom_lint` | Required for riverpod_lint rules | | `riverpod_lint` | Lint rules for Riverpod best practices | **Remove after all Blocs are migrated:** | Package | | |---|---| | `flutter_bloc` | Replaced by Riverpod | | `equatable` | No longer needed — Riverpod state is compared by value | ### 1.2 App Root Changes #### `client_app/lib/main.dart` **Current:** `MultiBlocProvider` wraps `MaterialApp.router` with `AuthBloc`, `PairingBloc`, `ChatBloc`, `SessionClosureBloc`. **Target:** 1. Wrap `runApp` call with `ProviderScope`: `runApp(const ProviderScope(child: App()))` 2. Convert `App` from `StatefulWidget` to `HookConsumerWidget` 3. Remove `MultiBlocProvider` wrapper — providers are globally available via `ref` 4. Replace `_authBloc.stream.listen(...)` for FCM token registration with `ref.listen(authProvider, ...)` 5. Move `ApiClient` into a Riverpod provider: `@Riverpod(keepAlive: true) ApiClient apiClient(Ref ref) => ApiClient()` 6. Router creation: Use `ref.watch(authProvider)` to get auth state for redirect logic; replace `_BlocRefreshNotifier` with a Riverpod-based `ChangeNotifier` or use `ref.listen` on the auth provider **Files changed:** - `client_app/lib/main.dart` - `client_app/lib/router.dart` (remove `_BlocRefreshNotifier`, accept `WidgetRef` or use a provider for router) #### `mitra_app/lib/main.dart` **Current:** `MultiBlocProvider` wraps app with `AuthBloc`, `StatusBloc`, `ChatRequestBloc`, `MitraChatBloc`, `ExtensionBloc`. Also has `WidgetsBindingObserver` for lifecycle and `BlocListener` to trigger `StatusLoadRequested`. **Target:** 1. Wrap with `ProviderScope` 2. Convert `App` to `HookConsumerWidget` 3. Remove `MultiBlocProvider` — use `ref.watch()` / `ref.listen()` instead 4. Move lifecycle observer to a dedicated provider or custom hook (`useAppLifecycleState`) 5. Replace `BlocListener` triggering status load with `ref.listen(authProvider, ...)` inside a provider or widget **Files changed:** - `mitra_app/lib/main.dart` - `mitra_app/lib/router.dart` ### 1.3 Migration Per Bloc — Client App Migration order: AuthBloc (simplest, foundational) → ChatOpeningBloc (simple, no side effects) → SessionClosureBloc (simple API calls) → PairingBloc (WebSocket + timers) → ChatBloc (most complex, WebSocket + message state). #### 1.3.1 `client_app` AuthBloc → AuthNotifier **Source file:** `client_app/lib/core/auth/auth_bloc.dart` **Current:** BLoC with 8 events (AppStarted, AnonymousLoginRequested, GoogleLoginRequested, AppleLoginRequested, PhoneOtpRequested, OtpVerified, LinkAccountRequested, LogoutRequested) and 7 state classes. **Target:** `@Riverpod(keepAlive: true)` AsyncNotifier. ```dart @Riverpod(keepAlive: true) class Auth extends _$Auth { // State type: AsyncValue (sealed class) // Build method: checks Firebase currentUser, calls _verifyAndReturn or returns AuthData.initial // Methods: loginAnonymous(displayName), loginGoogle(), loginApple(), // requestOtp(phone), verifyOtp(verificationId, smsCode), // linkAccount(), logout() } ``` **State design:** Replace 7 separate state classes with a single sealed class: ```dart sealed class AuthData { const AuthData(); } class AuthDataInitial extends AuthData { const AuthDataInitial(); } class AuthDataAuthenticated extends AuthData { final Map profile; ... } class AuthDataAnonymous extends AuthData { final String customerId; final String displayName; ... } class AuthDataOtpSent extends AuthData { final String verificationId; ... } class AuthDataForceRegister extends AuthData { final String customerId; final String displayName; ... } ``` The `AsyncValue` wrapper handles loading/error automatically: - `state = const AsyncLoading()` replaces `emit(AuthLoading())` - `state = AsyncData(AuthDataAuthenticated(...))` replaces `emit(AuthAuthenticated(...))` - `state = AsyncError(...)` replaces `emit(AuthError(...))` **Widget changes:** - `BlocBuilder` → `ConsumerWidget` + `ref.watch(authProvider)` - `BlocListener` → `ref.listen(authProvider, ...)` - `context.read().add(LogoutRequested())` → `ref.read(authProvider.notifier).logout()` **Files affected:** - `client_app/lib/core/auth/auth_bloc.dart` → `auth_notifier.dart` + `.g.dart` - `client_app/lib/features/auth/screens/welcome_screen.dart` - `client_app/lib/features/auth/screens/display_name_screen.dart` - `client_app/lib/features/auth/screens/register_screen.dart` - `client_app/lib/features/auth/screens/otp_screen.dart` - `client_app/lib/features/auth/screens/force_register_screen.dart` - `client_app/lib/features/home/home_screen.dart` - `client_app/lib/router.dart` - `client_app/lib/main.dart` #### 1.3.2 `client_app` ChatOpeningBloc → ChatOpeningNotifier **Source file:** `client_app/lib/core/chat/chat_opening_bloc.dart` **Current:** Simple BLoC with one event (`LoadPricing`) and 4 states. Fetches pricing tiers from API. **Target:** `@riverpod` FutureProvider (auto-dispose, since pricing is ephemeral): ```dart @riverpod Future chatPricing(Ref ref) async { final apiClient = ref.read(apiClientProvider); final response = await apiClient.get('/api/client/chat/pricing'); // parse and return PricingData } ``` **PriceTier model** stays the same; move out of bloc file into a shared models file. **Files affected:** - `client_app/lib/core/chat/chat_opening_bloc.dart` → `chat_opening_notifier.dart` + `.g.dart` - `client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart` #### 1.3.3 `client_app` SessionClosureBloc → SessionClosureNotifier **Source file:** `client_app/lib/core/chat/session_closure_bloc.dart` **Current:** BLoC with 4 events (RequestExtension, DeclineExtension, ResetClosure, SubmitGoodbye) and 6 states. **Target:** `@riverpod` Notifier (synchronous state, async methods). ```dart @riverpod class SessionClosure extends _$SessionClosure { // State: SessionClosureData sealed class // (Initial, ExtendingWaitingMitra, ShowGoodbye, Submitting, Complete, Error) // build(): returns SessionClosureData.initial // requestExtension(sessionId, durationMinutes, price) // declineExtension() // reset() // submitGoodbye(sessionId, message) } ``` **Files affected:** - `client_app/lib/core/chat/session_closure_bloc.dart` → `session_closure_notifier.dart` + `.g.dart` - `client_app/lib/features/chat/screens/chat_screen.dart` #### 1.3.4 `client_app` PairingBloc → PairingNotifier **Source file:** `client_app/lib/core/pairing/pairing_bloc.dart` **Current:** BLoC with WebSocket connection, 60s timeout timer, pairing request flow. 6 public events + 3 private events, 7 state classes. **Target:** `@Riverpod(keepAlive: true)` Notifier with internal WebSocket and timer management. ```dart @Riverpod(keepAlive: true) class Pairing extends _$Pairing { WebSocketChannel? _channel; Timer? _timeoutTimer; StreamSubscription? _wsSubscription; // State: PairingData sealed class // (Initial, Searching, BestieFound, Active, NoBestie, Cancelled, Error) // build(): returns PairingData.initial // requestPairing() // requestPairingWithTier({durationMinutes, price, isFreeTrial}) // cancelPairing() // Internal: _connectWebSocket(), _onStatusUpdate(), _cleanup() } ``` **Key difference from BLoC:** Private events (`_PairingStatusUpdate`, `_PairingTimeout`, `_ConnectionError`) become direct method calls within the notifier since Riverpod notifiers can call `state = ...` from callbacks without needing to route through an event system. **Files affected:** - `client_app/lib/core/pairing/pairing_bloc.dart` → `pairing_notifier.dart` + `.g.dart` - `client_app/lib/features/home/home_screen.dart` - `client_app/lib/features/chat/screens/searching_screen.dart` - `client_app/lib/features/chat/screens/no_bestie_screen.dart` - `client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart` #### 1.3.5 `client_app` ChatBloc → ChatNotifier **Source file:** `client_app/lib/core/chat/chat_bloc.dart` **Current:** Most complex BLoC. Manages WebSocket connection, message list, typing indicators, session timer, message delivery/read status. 8 events, 4 state classes. `ChatConnected` has `copyWith` for granular updates. **Target:** `@riverpod` Notifier with internal WebSocket management. ```dart @riverpod class Chat extends _$Chat { WebSocketChannel? _channel; StreamSubscription? _wsSubscription; Timer? _typingTimer; // State: ChatData sealed class // (Initial, Connecting, Connected, Error) // ChatDataConnected holds: messages, isOtherTyping, remainingSeconds, // sessionExpired, sessionPaused, sessionClosing, extensionResponse // build(): returns ChatData.initial // connect(sessionId), disconnect() // sendMessage(content), sendTyping() // markDelivered(messageIds), markRead(messageIds) } ``` **Files affected:** - `client_app/lib/core/chat/chat_bloc.dart` → `chat_notifier.dart` + `.g.dart` - `client_app/lib/features/chat/screens/chat_screen.dart` - `client_app/lib/features/chat/screens/bestie_found_screen.dart` ### 1.4 Migration Per Bloc — Mitra App Migration order: AuthBloc (fix bug here) → StatusBloc (timer management) → ExtensionBloc (simple) → ChatRequestBloc (WebSocket) → MitraChatBloc (most complex). #### 1.4.1 `mitra_app` AuthBloc → AuthNotifier (BUG FIX) **Source file:** `mitra_app/lib/core/auth/auth_bloc.dart` **Bug:** Lines 65-68 — `_onAppStarted` only calls `_verifyAndEmit` when `_auth.currentUser != null`, but does NOT emit `AuthInitial` when `currentUser` is null. This leaves the app stuck in `AuthLoading`. The client_app's version correctly has `else { emit(AuthInitial()); }`. **Fix during migration:** The `build()` method of the new AsyncNotifier must return `AuthDataInitial` when `currentUser` is null. **Target:** `@Riverpod(keepAlive: true)` AsyncNotifier. ```dart @Riverpod(keepAlive: true) class Auth extends _$Auth { ConfirmationResult? _webConfirmationResult; @override FutureOr build() async { final currentUser = FirebaseAuth.instance.currentUser; if (currentUser != null) { return await _verifyAndReturn(); // returns MitraAuthData.authenticated(profile) } return const MitraAuthData.initial(); // FIX: explicitly return initial state } // Methods: requestOtp(phone), verifyOtp(verificationId, smsCode), logout() } ``` **Files affected:** - `mitra_app/lib/core/auth/auth_bloc.dart` → `auth_notifier.dart` + `.g.dart` - `mitra_app/lib/features/auth/screens/login_screen.dart` - `mitra_app/lib/features/auth/screens/otp_screen.dart` - `mitra_app/lib/features/home/home_screen.dart` - `mitra_app/lib/router.dart` - `mitra_app/lib/main.dart` #### 1.4.2 `mitra_app` StatusBloc → StatusNotifier **Source file:** `mitra_app/lib/core/status/status_bloc.dart` **Current:** BLoC with 6 events, heartbeat timer management, 4 states. **Target:** `@Riverpod(keepAlive: true)` Notifier (keepAlive because status persists across screens). ```dart @Riverpod(keepAlive: true) class OnlineStatus extends _$OnlineStatus { Timer? _heartbeatTimer; // State: OnlineStatusData (Initial, Loaded{isOnline}, Loading, Error) // build(): returns OnlineStatusData.initial // load(), toggleOnline(), toggleOffline(), onAppPaused(), onAppResumed() // Private: _startHeartbeat(), _stopHeartbeat(), _heartbeatTick() } ``` **Files affected:** - `mitra_app/lib/core/status/status_bloc.dart` → `online_status_notifier.dart` + `.g.dart` - `mitra_app/lib/features/home/home_screen.dart` - `mitra_app/lib/main.dart` (lifecycle handling) #### 1.4.3 `mitra_app` ExtensionBloc → ExtensionNotifier **Source file:** `mitra_app/lib/core/chat/extension_bloc.dart` **Current:** Simple BLoC with 2 events, 6 states. **Target:** `@riverpod` Notifier. ```dart @riverpod class Extension extends _$Extension { // State: ExtensionData (Idle, Responding, ShowGoodbye, Submitting, Complete, Error) // build(): returns ExtensionData.idle // respond(sessionId, extensionId, accepted) // submitGoodbye(sessionId, message) } ``` **Files affected:** - `mitra_app/lib/core/chat/extension_bloc.dart` → `extension_notifier.dart` + `.g.dart` - `mitra_app/lib/features/chat/screens/mitra_chat_screen.dart` #### 1.4.4 `mitra_app` ChatRequestBloc → ChatRequestNotifier **Source file:** `mitra_app/lib/core/chat/chat_request_bloc.dart` **Current:** BLoC with WebSocket connection for incoming chat requests. 6 events, 6 states. **Target:** `@Riverpod(keepAlive: true)` Notifier. ```dart @Riverpod(keepAlive: true) class ChatRequest extends _$ChatRequest { WebSocketChannel? _channel; StreamSubscription? _wsSubscription; // State: ChatRequestData // (Idle, Listening, Incoming{sessionId}, Accepting, Accepted{session}, Error) // build(): returns ChatRequestData.idle // startListening(), stopListening(), accept(sessionId), decline(sessionId) // Private: _connectWebSocket(), _onRequestReceived(), _closeWebSocket() } ``` **Files affected:** - `mitra_app/lib/core/chat/chat_request_bloc.dart` → `chat_request_notifier.dart` + `.g.dart` - `mitra_app/lib/features/home/home_screen.dart` - `mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart` #### 1.4.5 `mitra_app` MitraChatBloc → MitraChatNotifier **Source file:** `mitra_app/lib/core/chat/mitra_chat_bloc.dart` **Current:** Mirrors client ChatBloc closely. WebSocket + message list + typing + session events. 8 events, 4 states. **Target:** `@riverpod` Notifier. ```dart @riverpod class MitraChat extends _$MitraChat { WebSocketChannel? _channel; StreamSubscription? _wsSubscription; Timer? _typingTimer; // State: MitraChatData // (Initial, Connecting, Connected{messages, isOtherTyping, ...}, Error) // build(): returns MitraChatData.initial // connect(sessionId), disconnect(), sendMessage(content), sendTyping(), // markDelivered(ids), markRead(ids) } ``` **Files affected:** - `mitra_app/lib/core/chat/mitra_chat_bloc.dart` → `mitra_chat_notifier.dart` + `.g.dart` - `mitra_app/lib/features/chat/screens/mitra_chat_screen.dart` - `mitra_app/lib/features/chat/screens/active_sessions_screen.dart` ### 1.5 Router Changes Both apps use a `_BlocRefreshNotifier` that listens to the AuthBloc stream to trigger GoRouter redirects. Replace with a Riverpod-based approach: ```dart class RouterNotifier extends ChangeNotifier { RouterNotifier(this._ref) { _ref.listen(authProvider, (_, __) => notifyListeners()); } final Ref _ref; } ``` Pass as `refreshListenable` to GoRouter. The redirect function reads auth state via `_ref.read(authProvider)`. **Files changed:** - `client_app/lib/router.dart` - `mitra_app/lib/router.dart` ### 1.6 Code Generation After each migration step, run: ```bash dart run build_runner build --delete-conflicting-outputs ``` ### 1.7 Final Cleanup After all Blocs are migrated and verified: 1. Remove `flutter_bloc` and `equatable` from both `pubspec.yaml` files 2. Delete old Bloc files 3. Run `flutter pub get` to verify no remaining references 4. Global search for any remaining `BlocProvider`, `BlocBuilder`, `BlocListener`, `context.read<`, `context.watch<` — replace any stragglers ### 1.8 Testing Checklist — Riverpod Migration | Test | App | What to verify | |---|---|---| | Auth flow | client_app | Anonymous login, Google login, Apple login, OTP login, account linking, logout | | Auth flow | mitra_app | OTP login, logout, **verify stuck-loading bug is fixed** | | Router redirect | Both | Unauthenticated → login screen; authenticated → home; splash transitions correctly | | Pricing dialog | client_app | Pricing tiers load, free trial shows when eligible, tier selection triggers pairing | | Pairing flow | client_app | Request pairing, searching state, bestie found transition, cancel pairing, timeout | | Chat connect/send | client_app | WebSocket connects, messages send/receive, typing indicator, delivery/read status | | Session closure | client_app | Extension request, decline extension → goodbye, submit goodbye | | Status toggle | mitra_app | Go online, go offline, heartbeat fires every 15s, app lifecycle pause/resume | | Chat requests | mitra_app | Start listening when online, incoming request sheet, accept, decline | | Mitra chat | mitra_app | Connect to session, send/receive messages, typing, extension request handling | | Extension | mitra_app | Accept/reject extension, goodbye message submission | | App lifecycle | mitra_app | Backgrounding stops heartbeat, foregrounding resumes if online | | FCM token | Both | Token registers after auth, token re-registers on app relaunch | --- ## Work Stream 2: FCM Fallback for Chat Engine ### 2.1 Database Changes #### New `app_config` keys | Key | Default Value (JSONB) | Purpose | |---|---|---| | `require_mitra_ping` | `{ "value": true }` | Whether mitra must heartbeat to stay online | | `mitra_ping_interval_seconds` | `{ "value": 15 }` | How often mitra must ping (configurable) | **Migration addition to `backend/src/db/migrate.js`:** ```sql INSERT INTO app_config (key, value) VALUES ('require_mitra_ping', '{"value": true}') ON CONFLICT (key) DO NOTHING; INSERT INTO app_config (key, value) VALUES ('mitra_ping_interval_seconds', '{"value": 15}') ON CONFLICT (key) DO NOTHING; ``` No new tables needed. Existing `chat_messages` table with `status` and `read_at` columns is sufficient for unread counts. ### 2.2 Backend Changes #### 2.2.1 Config Service Updates **File:** `backend/src/services/config.service.js` Add two new functions: - `getMitraPingConfig()` — returns `{ require_ping, ping_interval_seconds }` - `setMitraPingConfig({ require_ping, ping_interval_seconds })` — upserts both keys #### 2.2.2 Internal Config Routes **File:** `backend/src/routes/internal/config.routes.js` | Method | Path | Purpose | |---|---|---| | `GET` | `/internal/config/mitra-ping` | Get require_ping + interval | | `PATCH` | `/internal/config/mitra-ping` | Update require_ping and/or interval | #### 2.2.3 Mitra Status Service Updates **File:** `backend/src/services/mitra-status.service.js` - Modify `autoOfflineStaleMitras`: if `require_ping` is `false`, skip the auto-offline sweep entirely; if `true`, use `ping_interval_seconds * 3` as the staleness threshold - Modify `heartbeat`: if `require_ping` is `false`, return early (no-op) - Add ping config to status GET response so mitra app knows the expected interval #### 2.2.4 Mitra Status Routes Update **File:** `backend/src/routes/public/mitra.status.routes.js` Update `GET /api/mitra/status` response to include: ```json { "success": true, "data": { "is_online": true, "require_ping": true, "ping_interval_seconds": 15 } } ``` #### 2.2.5 Pairing Service FCM Fallback Enhancement **File:** `backend/src/services/pairing.service.js` The existing `notifyMitra` already has FCM fallback. Enhancements needed: 1. FCM payload includes `session_id` for deep-linking 2. FCM notification shows confirmation that mitra must tap to accept 3. No auto-accept path from FCM — mitra must open app and manually accept **Updated FCM payload:** ```javascript await sendPushNotification(UserType.MITRA, mitraId, { title: 'Permintaan Chat Baru', body: 'Ada pelanggan yang ingin curhat! Ketuk untuk menerima.', data: { type: WsMessage.CHAT_REQUEST, session_id: data.session_id, action: 'open_accept', }, }) ``` #### 2.2.6 Closure Service FCM Fallback **File:** `backend/src/services/closure.service.js` In `initiateEarlyEnd` and `completeSession`, after sending WebSocket closure signals, add FCM fallback: ```javascript if (!isUserOnlineWs(UserType.CUSTOMER, session.customer_id)) { await sendPushNotification(UserType.CUSTOMER, session.customer_id, { title: 'Sesi Berakhir', body: 'Sesi curhat kamu telah berakhir. Ketuk untuk menulis pesan penutup.', data: { type: WsMessage.SESSION_CLOSING, session_id: sessionId }, }) } // Same for mitra ``` **File:** `backend/src/services/session-timer.service.js` Same fix in `onSessionExpired` — add FCM fallback for both parties after `SESSION_EXPIRED` and `SESSION_CLOSING` WebSocket messages. #### 2.2.7 Unread Count API **New endpoints:** | Method | Path | Purpose | |---|---|---| | `GET` | `/api/mitra/chat-requests/sessions/active-with-unread` | Active sessions + unread count per session | | `GET` | `/api/client/chat/session/active-with-unread` | Active session + unread count | **File:** `backend/src/services/session.service.js` Add `getActiveSessionsByMitraWithUnread(mitraId)` — joins `chat_sessions` with a subquery counting unread messages (where `sender_type = 'customer'` and `status IN ('sent', 'delivered')`). Add `getActiveSessionByCustomerWithUnread(customerId)` — same pattern for customer side. ### 2.3 Flutter Changes — Mitra App #### 2.3.1 Status Notifier Updates (Ping Config) **File:** `mitra_app/lib/core/status/online_status_notifier.dart` 1. Fetch `require_ping` and `ping_interval_seconds` from status API response 2. If `require_ping` is `false`, do NOT start heartbeat timer 3. If `require_ping` is `true`, use `ping_interval_seconds` from config (not hardcoded 15s) 4. On `AppPaused`: if `require_ping` is false, do nothing; if true, stop heartbeat as before #### 2.3.2 Unread Badge Provider **New file:** `mitra_app/lib/core/chat/unread_notifier.dart` ```dart @Riverpod(keepAlive: true) class UnreadSessions extends _$UnreadSessions { // Returns Map — { sessionId: unreadCount } // Polls every 10-30s or updates via WebSocket // totalUnread getter: sum of all values // markSessionRead(sessionId): optimistic update sets count to 0 } ``` #### 2.3.3 Home Screen Badge **File:** `mitra_app/lib/features/home/home_screen.dart` Add `Badge` widget wrapping the active sessions button icon, showing `totalUnread` count. #### 2.3.4 Active Sessions Screen Badge **File:** `mitra_app/lib/features/chat/screens/active_sessions_screen.dart` Show `Badge` on each session's `ListTile` with per-session unread count. Badge clears when user enters the session (mark-read via WebSocket). #### 2.3.5 Notification Service Updates **File:** `mitra_app/lib/core/notifications/notification_service.dart` Handle FCM-delivered messages: - `type: chat_request` → navigate to home screen, show incoming request bottom sheet - `type: session_closing` → navigate to the chat session closure screen ### 2.4 Flutter Changes — Client App #### 2.4.1 Unread Badge Provider **New file:** `client_app/lib/core/chat/unread_notifier.dart` ```dart @Riverpod(keepAlive: true) class UnreadCount extends _$UnreadCount { // Returns int — total unread count for active session // markRead(): sets to 0 } ``` #### 2.4.2 Home Screen Badge **File:** `client_app/lib/features/home/home_screen.dart` Add `Badge` widget on `_ActiveSessionCard`'s `CircleAvatar`, showing unread count. #### 2.4.3 Notification Service Update **File:** `client_app/lib/core/notifications/notification_service.dart` Handle closure FCM: `type: session_closing` → navigate to chat session screen (shows closure UI). ### 2.5 Control Center Changes **File:** `control_center/src/pages/settings/SettingsPage.jsx` Add new section for mitra ping configuration: - Checkbox: "Wajibkan Mitra Ping (Heartbeat)" — toggle `require_mitra_ping` - Number input: "Interval Ping" — sets `mitra_ping_interval_seconds` - Helper text explaining that disabling ping means QC is responsible for mitra quality --- ## 3. Implementation Order | Step | What | Apps Affected | Dependencies | |---|---|---|---| | **Work Stream 1: Riverpod Migration** | | | | | 1 | Add Riverpod dependencies to both pubspec.yaml | client_app, mitra_app | None | | 2 | Wrap app root with ProviderScope, create ApiClient provider | client_app, mitra_app | Step 1 | | 3 | Migrate client_app AuthBloc → AuthNotifier | client_app | Step 2 | | 4 | Update client_app router to use Riverpod auth state | client_app | Step 3 | | 5 | Migrate client_app ChatOpeningBloc → ChatOpeningNotifier | client_app | Step 3 | | 6 | Migrate client_app SessionClosureBloc → SessionClosureNotifier | client_app | Step 3 | | 7 | Migrate client_app PairingBloc → PairingNotifier | client_app | Step 3 | | 8 | Migrate client_app ChatBloc → ChatNotifier | client_app | Step 3, 6, 7 | | 9 | E2E test client_app (all flows) | client_app | Steps 3–8 | | 10 | Migrate mitra_app AuthBloc → AuthNotifier (**fix stuck-loading bug**) | mitra_app | Step 2 | | 11 | Update mitra_app router to use Riverpod auth state | mitra_app | Step 10 | | 12 | Migrate mitra_app StatusBloc → StatusNotifier | mitra_app | Step 10 | | 13 | Migrate mitra_app ExtensionBloc → ExtensionNotifier | mitra_app | Step 10 | | 14 | Migrate mitra_app ChatRequestBloc → ChatRequestNotifier | mitra_app | Step 10, 12 | | 15 | Migrate mitra_app MitraChatBloc → MitraChatNotifier | mitra_app | Step 10, 13 | | 16 | E2E test mitra_app (all flows + verify bug fix) | mitra_app | Steps 10–15 | | 17 | Remove flutter_bloc + equatable from both apps | client_app, mitra_app | Steps 9, 16 | | **Work Stream 2: FCM Fallback** | | | | | 18 | DB migration: add `require_mitra_ping` + `mitra_ping_interval_seconds` config | Backend | None | | 19 | Config service: add get/set for mitra ping config | Backend | Step 18 | | 20 | Internal config routes: add GET/PATCH `/internal/config/mitra-ping` | Backend | Step 19 | | 21 | Control center: add mitra ping config section to Settings | Control center | Step 20 | | 22 | Mitra status service: honor `require_mitra_ping` in auto-offline + heartbeat | Backend | Step 19 | | 23 | Mitra status routes: include ping config in GET response | Backend | Step 22 | | 24 | Mitra app StatusNotifier: use dynamic ping config from API | mitra_app | Step 23, 12 | | 25 | Pairing service: enhance FCM payload for chat request | Backend | Existing | | 26 | Mitra app NotificationService: handle FCM chat requests | mitra_app | Step 25, 14 | | 27 | Closure service: add FCM fallback for session_closing signal | Backend | Existing | | 28 | Session timer service: add FCM fallback for session_expired signal | Backend | Existing | | 29 | Client/mitra app NotificationService: handle closure FCM | Both apps | Steps 27–28 | | 30 | Unread count API: add session service functions + routes | Backend | Existing | | 31 | Mitra app: UnreadSessions provider + badges | mitra_app | Step 30 | | 32 | Client app: UnreadCount provider + badge | client_app | Step 30 | | 33 | E2E test: mitra ping config + non-ping mode + pairing via FCM | All | Steps 21–26 | | 34 | E2E test: closure FCM fallback + unread badges | All | Steps 27–32 | --- ## 4. New Dependencies | App | Package | Purpose | |---|---|---| | client_app | `flutter_riverpod` | Core Riverpod | | client_app | `hooks_riverpod` | Riverpod + Hooks integration | | client_app | `riverpod_annotation` | `@riverpod` annotations | | client_app | `flutter_hooks` | Hook utilities | | client_app (dev) | `riverpod_generator` | Code generation | | client_app (dev) | `build_runner` | Code generation runner | | client_app (dev) | `custom_lint` | Required for riverpod_lint | | client_app (dev) | `riverpod_lint` | Lint rules | | mitra_app | Same as client_app | Same | | mitra_app (dev) | Same as client_app | Same | **Removed after migration:** | App | Package | Reason | |---|---|---| | client_app | `flutter_bloc` | Replaced by Riverpod | | client_app | `equatable` | No longer needed | | mitra_app | `flutter_bloc` | Replaced by Riverpod | | mitra_app | `equatable` | No longer needed | No new backend or control_center dependencies. --- ## 5. Risks & Mitigations | Risk | Mitigation | |---|---| | Riverpod migration breaks auth redirect logic | Test router redirects thoroughly after step 4/11; keep old bloc files until verified | | WebSocket lifecycle differs between BLoC and Notifier | BLoC `close()` auto-called on `BlocProvider` dispose; Riverpod notifiers with `keepAlive: true` persist. Ensure `ref.onDispose()` cleans up WebSocket/timers | | Code generation conflicts | Run `build_runner build --delete-conflicting-outputs` after each migration step | | FCM notifications not received when app is killed | Already handled by `firebase_messaging` background handler; verify on both iOS and Android | | Non-ping mode mitras go stale in database | When `require_ping` is false, auto-offline sweep is completely skipped; only manual offline or Control Center action changes status | | Unread count polling creates excessive API calls | Use 10-30s polling interval; WebSocket-based real-time update can be added later |