- 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>
30 KiB
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:
- Wrap
runAppcall withProviderScope:runApp(const ProviderScope(child: App())) - Convert
AppfromStatefulWidgettoHookConsumerWidget - Remove
MultiBlocProviderwrapper — providers are globally available viaref - Replace
_authBloc.stream.listen(...)for FCM token registration withref.listen(authProvider, ...) - Move
ApiClientinto a Riverpod provider:@Riverpod(keepAlive: true) ApiClient apiClient(Ref ref) => ApiClient() - Router creation: Use
ref.watch(authProvider)to get auth state for redirect logic; replace_BlocRefreshNotifierwith a Riverpod-basedChangeNotifieror useref.listenon the auth provider
Files changed:
client_app/lib/main.dartclient_app/lib/router.dart(remove_BlocRefreshNotifier, acceptWidgetRefor 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:
- Wrap with
ProviderScope - Convert
ApptoHookConsumerWidget - Remove
MultiBlocProvider— useref.watch()/ref.listen()instead - Move lifecycle observer to a dedicated provider or custom hook (
useAppLifecycleState) - Replace
BlocListener<AuthBloc>triggering status load withref.listen(authProvider, ...)inside a provider or widget
Files changed:
mitra_app/lib/main.dartmitra_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.
@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:
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()replacesemit(AuthLoading())state = AsyncData(AuthDataAuthenticated(...))replacesemit(AuthAuthenticated(...))state = AsyncError(...)replacesemit(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.dartclient_app/lib/features/auth/screens/welcome_screen.dartclient_app/lib/features/auth/screens/display_name_screen.dartclient_app/lib/features/auth/screens/register_screen.dartclient_app/lib/features/auth/screens/otp_screen.dartclient_app/lib/features/auth/screens/force_register_screen.dartclient_app/lib/features/home/home_screen.dartclient_app/lib/router.dartclient_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):
@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.dartclient_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).
@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.dartclient_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.
@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.dartclient_app/lib/features/home/home_screen.dartclient_app/lib/features/chat/screens/searching_screen.dartclient_app/lib/features/chat/screens/no_bestie_screen.dartclient_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.
@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.dartclient_app/lib/features/chat/screens/chat_screen.dartclient_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.
@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.dartmitra_app/lib/features/auth/screens/login_screen.dartmitra_app/lib/features/auth/screens/otp_screen.dartmitra_app/lib/features/home/home_screen.dartmitra_app/lib/router.dartmitra_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).
@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.dartmitra_app/lib/features/home/home_screen.dartmitra_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.
@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.dartmitra_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.
@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.dartmitra_app/lib/features/home/home_screen.dartmitra_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.
@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.dartmitra_app/lib/features/chat/screens/mitra_chat_screen.dartmitra_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:
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.dartmitra_app/lib/router.dart
1.6 Code Generation
After each migration step, run:
dart run build_runner build --delete-conflicting-outputs
1.7 Final Cleanup
After all Blocs are migrated and verified:
- Remove
flutter_blocandequatablefrom bothpubspec.yamlfiles - Delete old Bloc files
- Run
flutter pub getto verify no remaining references - 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:
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: ifrequire_pingisfalse, skip the auto-offline sweep entirely; iftrue, useping_interval_seconds * 3as the staleness threshold - Modify
heartbeat: ifrequire_pingisfalse, 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:
{
"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:
- FCM payload includes
session_idfor deep-linking - FCM notification shows confirmation that mitra must tap to accept
- No auto-accept path from FCM — mitra must open app and manually accept
Updated FCM payload:
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:
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
- Fetch
require_pingandping_interval_secondsfrom status API response - If
require_pingisfalse, do NOT start heartbeat timer - If
require_pingistrue, useping_interval_secondsfrom config (not hardcoded 15s) - On
AppPaused: ifrequire_pingis 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
@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 sheettype: 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
@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 |