# 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 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 |
