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>
This commit is contained in:
2026-04-09 13:51:17 +08:00
parent b0502ac92b
commit d15b2f05fc
25 changed files with 2513 additions and 461 deletions

View File

@@ -0,0 +1,735 @@
# 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 |

84
requirement/phase3.1.md Normal file
View File

@@ -0,0 +1,84 @@
# PRD: Phase 3 Stabilization & State Management Migration
# Overview
**Goal:** Stabilize Phase 3 (Chat Engine) through end-to-end testing and migrate Flutter state management from BLoC to Riverpod + flutter_hooks
**Success looks like:** All Phase 3 features are verified working end-to-end across client_app, mitra_app, and control_center. Both Flutter apps use Riverpod as their sole state management solution.
## Background
- Phase 3 (Chat Engine) is fully scaffolded but has not been end-to-end tested
- Current Flutter apps use BLoC pattern; Riverpod is preferred for maintainability and reduced boilerplate
- Migration should happen before Phase 4 to avoid compounding tech debt
## FCM fallback for Chat Engine
### Mitra Pairing
- Add configuration on Control center to configure Mitra's app require to ping or not.
- When Control Center allow non ping, application or backend will not force mitra to ping and allow them to keep online even when the app is closed or in backround
- Modify Mitra Pairing confirmation to send notification through FCM when websocket to Mitra is closed
### Bi-Directional Chat (WebSocket + FCM)
#### Mitra App
- When there is new unread message, mitra app must shows badge on active session
- When there is new unread message, mitra app must shows badge on the chat active session inside active session page
- Unread badge on each active session will be cleared when the message has been read
- Unread badge on active session button on main page will be cleared when the message has been read
#### Customer App
- When there is new unread message, Customer app must shows badge on active session
- Unread badge will be cleared when unread message has been cleared
### Chat Closure & Extension
- When chat closure called, backend will send closure signal to both Mitra and Customer
- Backend will use FCM if the websocket connection is down
### Control Center
- Control center shows configuration for ping from mitra
## Riverpod Migration
### Scope
- Migrate all BLoC classes in `client_app` and `mitra_app` to Riverpod annotation-based providers
- Replace `flutter_bloc` with `flutter_riverpod`, `riverpod_annotation`, `flutter_hooks`, and `hooks_riverpod`
- Add `riverpod_generator` + `build_runner` as dev dependencies for code generation
- No backend or control_center changes
### Migration Strategy
- [ ] Add Riverpod dependencies (`flutter_riverpod`, `hooks_riverpod`, `riverpod_annotation`) and dev dependencies (`riverpod_generator`, `build_runner`, `custom_lint`, `riverpod_lint`)
- [ ] Wrap app root with `ProviderScope`
- [ ] Migrate one Bloc at a time, starting with the simplest (e.g. AuthBloc)
- [ ] For each migrated Bloc:
1. Replace `Bloc`/`Cubit` class with `@riverpod` annotated `Notifier` or `AsyncNotifier` (extending `_$ClassName`)
2. Replace `BlocEvent` + `emit()` pattern with notifier methods that update `state` directly
3. Run `dart run build_runner build` to generate `.g.dart` files
4. Replace `BlocProvider` with generated provider (e.g. `authProvider`)
5. Replace `BlocBuilder` widgets with `ConsumerWidget` + `ref.watch()`
6. Replace `BlocListener` with `ref.listen()` inside widget or provider
7. Use `HookConsumerWidget` where flutter_hooks are needed (e.g. `useTextEditingController`, `useEffect`)
- [ ] Run E2E verification after each migration to catch regressions
- [ ] Remove `flutter_bloc` dependency only after all Blocs are migrated
### Affected Blocs
- [ ] `client_app` — AuthBloc, PairingBloc, ChatBloc, ChatOpeningBloc, SessionClosureBloc
- [ ] `mitra_app` — AuthBloc, OnlineStatusBloc, MitraChatBloc, ExtensionBloc
# Non-Functional Requirement
- [ ] WebSocket reconnects gracefully after network interruption (within 5s on stable network)
- [ ] Use FCM to send command or message when websocket is down
- [ ] No message loss during brief disconnects — undelivered messages sync on reconnect
- [ ] Chat screen maintains scroll position and input draft on app lifecycle events (background/foreground)
- [ ] Riverpod migration introduces zero new UI bugs — feature parity with BLoC implementation
# Tech Stack
- State management: Riverpod + flutter_hooks (replacing flutter_bloc)
- No backend changes expected — migration is Flutter-only