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

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

30 KiB
Raw Permalink Blame History

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.

@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() 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.dartauth_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):

@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.dartchat_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).

@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.dartsession_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.

@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.dartpairing_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.

@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.dartchat_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.

@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.dartauth_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).

@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.dartonline_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.

@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.dartextension_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.

@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.dartchat_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.

@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.dartmitra_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:

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:

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:

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:

{
  "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:

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

  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

@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

@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