Files
halobestie-clone/requirement/phase3.1-plan.md
ramadhan sjamsani d15b2f05fc Phase 3.1 WIP: Riverpod migration (client_app Auth + ChatOpening)
- Add phase3.1 requirement and implementation plan docs
- Add Riverpod dependencies to both client_app and mitra_app
- Wrap both app roots with ProviderScope
- Migrate client_app AuthBloc → AuthNotifier (@riverpod annotation)
- Migrate client_app ChatOpeningBloc → chatPricingProvider (FutureProvider)
- Update router to use Riverpod-based auth state for redirects
- Update all auth screens (display name, register, OTP, force register)
- Update home screen and pricing bottom sheet
- Add android:usesCleartextTraffic for dev HTTP access on both apps
- mitra_app prepared with ProviderScope + ApiClient provider (blocs next)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:51:17 +08:00

736 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<AuthBloc>` 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<AuthBloc>` 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<AuthData> (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<String, dynamic> 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<AuthBloc, AuthState>``ConsumerWidget` + `ref.watch(authProvider)`
- `BlocListener<AuthBloc, AuthState>``ref.listen(authProvider, ...)`
- `context.read<AuthBloc>().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<PricingData> 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<MitraAuthData> 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<String, int> — { 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 38 |
| 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 1015 |
| 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 2728 |
| 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 2126 |
| 34 | E2E test: closure FCM fallback + unread badges | All | Steps 2732 |
---
## 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 |