Phase 3.4: mitra_app self-managed auth cutover

Rips firebase_auth; phone OTP flow now talks directly to the new
backend endpoints, JWT access token lives in memory, refresh token
persists via flutter_secure_storage. WebSocket handshakes read the
access token from AuthBridge instead of Firebase.

Smoke-tested end-to-end against the backend via curl:
- otp/request → read stub code from backend log → otp/verify
- /api/mitra/auth/me + /api/shared/auth/refresh rotation
- logout → post-logout refresh correctly fails REFRESH_INVALID
- ACCOUNT_INACTIVE (403) + WRONG_FLOW (400) error paths verified
- Debug APK links cleanly

- pubspec: drop firebase_auth, add flutter_secure_storage
- core/auth/auth_bridge.dart: shared mutable state (access token +
  refresh callback + in-flight de-dup) as keepAlive provider
- core/auth/token_storage.dart: flutter_secure_storage wrapper
- core/auth/auth_notifier.dart: bootstrap → refresh; requestOtp +
  verifyOtp via /api/mitra/auth/*; logout; granular OTP error codes
- core/api/api_client.dart: Bearer from bridge + postRaw(skipAuth) for
  auth endpoints + single-retry 401 refresh
- core/chat/*_notifier.dart: WS auth frame reads bridge.accessToken
- features/auth/screens/otp_screen.dart: verificationId → otpRequestId
- mitra_app/CLAUDE.md: Auth section rewritten (was stale on Firebase)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 15:58:25 +08:00
parent 4a796277b8
commit 2b61c79a86
17 changed files with 496 additions and 140 deletions

View File

@@ -7,19 +7,18 @@ Flutter mobile application for mental health professionals (mitra/partners).
## Stack ## Stack
- **Framework:** Flutter (iOS + Android) - **Framework:** Flutter (iOS + Android)
- **Auth:** Firebase Auth — Google Sign-In, Apple Sign-In, Phone OTP - **Auth:** Self-managed (Phase 3.4). Phone OTP only — no Google / Apple. Access token lives in memory on an `AuthBridge`; refresh token persists in `flutter_secure_storage`. `firebase_auth` is no longer used; `firebase_messaging` is kept for FCM push.
- Fully native UI — no WebView, no Firebase-branded screens - **API:** Calls public Fastify backend (`/api/mitra/` and `/api/shared/` routes). `shared.auth` covers refresh + logout for both apps.
- Use `firebase_auth` + `google_sign_in` packages
- **API:** Calls public Fastify backend (`/api/mitra/` and `/api/shared/` routes)
## Key Concepts ## Key Concepts
- Users are **mitra** — trained mental health professionals - Users are **mitra** — trained mental health professionals
- Core flow: register + credential verification → set availability → accept sessions → chat with client → receive payment - Core flow: phone OTP login → set availability → accept sessions → chat with client → receive payment
- Mitra accounts require approval from control center before going live - Mitra accounts require approval from control center before going live (backend returns `ACCOUNT_INACTIVE` 403 on OTP verify when `is_active=false`)
## Conventions ## Conventions
- Never call `/api/client/` or `/internal/` routes from this app - Never call `/api/client/` or `/internal/` routes from this app
- All API calls must include Firebase JWT token in `Authorization` header - API calls go through `ApiClient`; it auto-attaches the JWT from `AuthBridge` and auto-refreshes on 401
- Mitra role must be verified server-side on every relevant request - WebSocket handshake (`/api/shared/ws`) sends the same access token in the first frame's `{type:"auth", token}` message
- Mitra role is encoded in the JWT claims (`user_type: "mitra"`) — the backend enforces the role per route; never trust client state alone

View File

@@ -1,5 +1,10 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart'; import '../auth/auth_bridge.dart';
/// Per-request flag: set `options.extra['skipAuth'] = true` to skip
/// token attachment + 401-refresh retry. Used by auth endpoints themselves
/// (login/refresh/OTP) to avoid recursion.
const _skipAuthKey = 'skipAuth';
class ApiClient { class ApiClient {
static const String baseUrl = String.fromEnvironment( static const String baseUrl = String.fromEnvironment(
@@ -7,19 +12,45 @@ class ApiClient {
defaultValue: 'https://api.halobestie.com', defaultValue: 'https://api.halobestie.com',
); );
final AuthBridge _bridge;
late final Dio _dio; late final Dio _dio;
ApiClient() { ApiClient(this._bridge) {
_dio = Dio(BaseOptions(baseUrl: baseUrl)); _dio = Dio(BaseOptions(baseUrl: baseUrl));
_dio.interceptors.add(InterceptorsWrapper( _dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async { onRequest: (options, handler) {
final user = FirebaseAuth.instance.currentUser; if (options.extra[_skipAuthKey] != true) {
if (user != null) { final token = _bridge.accessToken;
final token = await user.getIdToken(); if (token != null) {
options.headers['Authorization'] = 'Bearer $token'; options.headers['Authorization'] = 'Bearer $token';
}
} }
handler.next(options); handler.next(options);
}, },
onError: (err, handler) async {
final req = err.requestOptions;
final is401 = err.response?.statusCode == 401;
final alreadyRetried = req.extra['_retry'] == true;
final skipAuth = req.extra[_skipAuthKey] == true;
if (is401 && !alreadyRetried && !skipAuth) {
final newToken = await _bridge.tryRefresh();
if (newToken != null) {
req.extra['_retry'] = true;
req.headers['Authorization'] = 'Bearer $newToken';
try {
final response = await _dio.fetch(req);
return handler.resolve(response);
} catch (e) {
if (e is DioException) return handler.next(e);
rethrow;
}
} else {
_bridge.notifyUnauthenticated();
}
}
handler.next(err);
},
)); ));
} }
@@ -37,4 +68,20 @@ class ApiClient {
final response = await _dio.patch(path, data: data); final response = await _dio.patch(path, data: data);
return response.data as Map<String, dynamic>; return response.data as Map<String, dynamic>;
} }
/// Raw POST with per-call control over auth attachment. Used by the auth
/// notifier for OTP / refresh endpoints where attaching a (possibly stale)
/// access token — or triggering a 401-refresh loop — would be wrong.
Future<Map<String, dynamic>> postRaw(
String path, {
Map<String, dynamic>? data,
bool skipAuth = false,
}) async {
final response = await _dio.post(
path,
data: data,
options: Options(extra: {_skipAuthKey: skipAuth}),
);
return response.data as Map<String, dynamic>;
}
} }

View File

@@ -1,8 +1,9 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../auth/auth_bridge.dart';
import 'api_client.dart'; import 'api_client.dart';
part 'api_client_provider.g.dart'; part 'api_client_provider.g.dart';
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
ApiClient apiClient(Ref ref) => ApiClient(); ApiClient apiClient(Ref ref) => ApiClient(ref.read(authBridgeProvider));

View File

@@ -6,7 +6,7 @@ part of 'api_client_provider.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$apiClientHash() => r'90c807f03b90249684265cc91739139c2c89eeb9'; String _$apiClientHash() => r'c0ade0ab10bbe40a30a4e5b17ea8c8c27517f502';
/// See also [apiClient]. /// See also [apiClient].
@ProviderFor(apiClient) @ProviderFor(apiClient)

View File

@@ -0,0 +1,52 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'auth_bridge.g.dart';
/// Shared mutable auth state: decouples api_client / WS notifiers
/// (read-only consumers) from auth_notifier (writer). Also de-dupes
/// concurrent 401-triggered refreshes.
///
/// Auth notifier calls [setAccessToken] on successful auth / refresh,
/// registers [refresh] and [onUnauthenticated] callbacks on build.
class AuthBridge {
String? accessToken;
Future<String?> Function()? refresh;
void Function()? onUnauthenticated;
Completer<String?>? _inFlight;
void setAccessToken(String? token) {
accessToken = token;
}
void clear() {
accessToken = null;
}
Future<String?> tryRefresh() {
final existing = _inFlight;
if (existing != null) return existing.future;
final completer = Completer<String?>();
_inFlight = completer;
() async {
try {
final next = await refresh?.call();
completer.complete(next);
} catch (_) {
completer.complete(null);
} finally {
_inFlight = null;
}
}();
return completer.future;
}
void notifyUnauthenticated() {
onUnauthenticated?.call();
}
}
@Riverpod(keepAlive: true)
AuthBridge authBridge(Ref ref) => AuthBridge();

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_bridge.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$authBridgeHash() => r'eb546dd1e79626bfaa8619d004b4cf64b596f65a';
/// See also [authBridge].
@ProviderFor(authBridge)
final authBridgeProvider = Provider<AuthBridge>.internal(
authBridge,
name: r'authBridgeProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$authBridgeHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AuthBridgeRef = ProviderRef<AuthBridge>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,14 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client.dart'; import '../api/api_client.dart';
import '../api/api_client_provider.dart'; import '../api/api_client_provider.dart';
import 'auth_bridge.dart';
import 'token_storage.dart';
part 'auth_notifier.g.dart'; part 'auth_notifier.g.dart';
// States
sealed class MitraAuthData { sealed class MitraAuthData {
const MitraAuthData(); const MitraAuthData();
} }
@@ -23,103 +22,170 @@ class MitraAuthAuthenticatedData extends MitraAuthData {
} }
class MitraAuthOtpSentData extends MitraAuthData { class MitraAuthOtpSentData extends MitraAuthData {
final String verificationId; final String otpRequestId;
const MitraAuthOtpSentData(this.verificationId); final String? channelUsed;
const MitraAuthOtpSentData(this.otpRequestId, {this.channelUsed});
} }
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class MitraAuth extends _$MitraAuth { class MitraAuth extends _$MitraAuth {
FirebaseAuth get _auth => FirebaseAuth.instance; final _storage = TokenStorage();
ApiClient get _apiClient => ref.read(apiClientProvider); ApiClient get _apiClient => ref.read(apiClientProvider);
ConfirmationResult? _webConfirmationResult; AuthBridge get _bridge => ref.read(authBridgeProvider);
@override @override
FutureOr<MitraAuthData> build() async { FutureOr<MitraAuthData> build() async {
if (_auth.currentUser != null) { _bridge.refresh = _refreshForBridge;
return await _verifyAndReturn(); _bridge.onUnauthenticated = () {
_bridge.clear();
state = const AsyncData(MitraAuthInitialData());
};
final profile = await _refreshFromStorage();
if (profile != null) {
return MitraAuthAuthenticatedData(profile);
} }
return const MitraAuthInitialData(); // FIX: was missing in BLoC version return const MitraAuthInitialData();
} }
Future<void> requestOtp(String phone) async { /// Reads the stored refresh token, rotates it against the backend, and
state = const AsyncLoading(); /// returns the fresh profile. Also updates the [AuthBridge] access token
if (kIsWeb) { /// and persists the new refresh token. Returns null if no token is stored
try { /// or the refresh call fails (refresh expired, revoked, etc).
final confirmationResult = await _auth.signInWithPhoneNumber(phone); Future<Map<String, dynamic>?> _refreshFromStorage() async {
_webConfirmationResult = confirmationResult; final refreshToken = await _storage.readRefreshToken();
state = const AsyncData(MitraAuthOtpSentData('web')); if (refreshToken == null) return null;
} catch (e) {
state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current); try {
} final response = await _apiClient.postRaw(
} else { '/api/shared/auth/refresh',
final completer = Completer<void>(); data: {'refresh_token': refreshToken},
await _auth.verifyPhoneNumber( skipAuth: true,
phoneNumber: phone,
verificationCompleted: (credential) async {
try {
await _auth.signInWithCredential(credential);
state = AsyncData(await _verifyAndReturn());
} catch (_) {}
if (!completer.isCompleted) completer.complete();
},
verificationFailed: (e) {
state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current);
if (!completer.isCompleted) completer.complete();
},
codeSent: (verificationId, _) {
state = AsyncData(MitraAuthOtpSentData(verificationId));
if (!completer.isCompleted) completer.complete();
},
codeAutoRetrievalTimeout: (_) {
if (!completer.isCompleted) completer.complete();
},
); );
await completer.future; final data = response['data'] as Map<String, dynamic>;
final newAccess = data['access_token'] as String;
final newRefresh = data['refresh_token'] as String;
final profile = data['profile'] as Map<String, dynamic>;
await _storage.writeRefreshToken(newRefresh);
_bridge.setAccessToken(newAccess);
// Sync notifier state if it was already authenticated (e.g. mid-session
// 401 refresh). On initial bootstrap, build() sets state from our return.
final current = state.valueOrNull;
if (current is MitraAuthAuthenticatedData) {
state = AsyncData(MitraAuthAuthenticatedData(profile));
}
return profile;
} catch (_) {
await _storage.clear();
_bridge.clear();
final current = state.valueOrNull;
if (current is MitraAuthAuthenticatedData) {
state = const AsyncData(MitraAuthInitialData());
}
return null;
} }
} }
Future<void> verifyOtp(String verificationId, String smsCode) async { /// Thin adapter for [AuthBridge.refresh]: returns the rotated access
/// token (or null on failure) so api_client can retry a 401.
Future<String?> _refreshForBridge() async {
final profile = await _refreshFromStorage();
return profile == null ? null : _bridge.accessToken;
}
Future<void> requestOtp(String phone, {String? channel}) async {
state = const AsyncLoading(); state = const AsyncLoading();
try { try {
if (kIsWeb && _webConfirmationResult != null) { final response = await _apiClient.postRaw(
await _webConfirmationResult!.confirm(smsCode); '/api/mitra/auth/otp/request',
} else if (_auth.currentUser == null) { data: {
// Only sign in if not already signed in via auto-verification 'phone': phone,
final credential = PhoneAuthProvider.credential( if (channel != null) 'channel': channel,
verificationId: verificationId, },
smsCode: smsCode, skipAuth: true,
); );
await _auth.signInWithCredential(credential); final data = response['data'] as Map<String, dynamic>;
} state = AsyncData(MitraAuthOtpSentData(
state = AsyncData(await _verifyAndReturn()); data['otp_request_id'] as String,
} on FirebaseAuthException { channelUsed: data['channel_used'] as String?,
state = AsyncError('OTP tidak valid. Coba lagi.', StackTrace.current); ));
} catch (e) { } on DioException catch (e) {
state = AsyncError(e.toString().replaceFirst('Exception: ', ''), StackTrace.current); state = AsyncError(_otpRequestMessage(e), StackTrace.current);
} catch (_) {
state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current);
}
}
Future<void> verifyOtp(String otpRequestId, String code) async {
state = const AsyncLoading();
try {
final response = await _apiClient.postRaw(
'/api/mitra/auth/otp/verify',
data: {'otp_request_id': otpRequestId, 'code': code},
skipAuth: true,
);
final data = response['data'] as Map<String, dynamic>;
final accessToken = data['access_token'] as String;
final refreshToken = data['refresh_token'] as String;
final profile = data['profile'] as Map<String, dynamic>;
await _storage.writeRefreshToken(refreshToken);
_bridge.setAccessToken(accessToken);
state = AsyncData(MitraAuthAuthenticatedData(profile));
} on DioException catch (e) {
state = AsyncError(_otpVerifyMessage(e), StackTrace.current);
} catch (_) {
state = AsyncError('Gagal verifikasi. Coba lagi.', StackTrace.current);
} }
} }
Future<void> logout() async { Future<void> logout() async {
await _auth.signOut(); // Best-effort server-side revocation; local clear always runs.
try {
await _apiClient.post('/api/shared/auth/logout');
} catch (_) {}
await _storage.clear();
_bridge.clear();
state = const AsyncData(MitraAuthInitialData()); state = const AsyncData(MitraAuthInitialData());
} }
Future<MitraAuthData> _verifyAndReturn() async { String _otpRequestMessage(DioException e) {
try { final code = e.response?.data?['error']?['code'] as String?;
final response = await _apiClient.post('/api/mitra/auth/verify'); switch (code) {
return MitraAuthAuthenticatedData(response['data'] as Map<String, dynamic>); case 'PHONE_INVALID':
} on DioException catch (e) { return 'Nomor HP tidak valid.';
await _auth.signOut(); case 'OTP_COOLDOWN':
final code = e.response?.data?['error']?['code'] as String?; return e.response?.data?['error']?['message'] as String? ??
if (code == 'ACCOUNT_NOT_FOUND' || e.response?.statusCode == 404) { 'Tunggu sebentar sebelum minta OTP lagi.';
throw Exception('Akun tidak ditemukan. Hubungi administrator.'); case 'OTP_RATE_LIMIT_PHONE':
} else if (code == 'ACCOUNT_INACTIVE' || e.response?.statusCode == 403) { case 'OTP_RATE_LIMIT_IP':
throw Exception('Akun tidak aktif. Hubungi administrator.'); return 'Terlalu banyak permintaan OTP. Coba lagi nanti.';
} default:
throw Exception('Gagal masuk. Coba lagi.'); return 'Gagal mengirim OTP. Coba lagi.';
} on Exception { }
await _auth.signOut(); }
throw Exception('Gagal masuk. Coba lagi.');
String _otpVerifyMessage(DioException e) {
final code = e.response?.data?['error']?['code'] as String?;
switch (code) {
case 'ACCOUNT_INACTIVE':
return 'Akun tidak aktif. Hubungi administrator.';
case 'WRONG_FLOW':
return 'OTP tidak valid untuk login mitra.';
case 'CODE_MISMATCH':
case 'CODE_INVALID':
return 'Kode OTP salah.';
case 'OTP_EXPIRED':
return 'Kode OTP kedaluwarsa. Minta kode baru.';
case 'OTP_USED':
return 'Kode OTP sudah digunakan.';
case 'OTP_ATTEMPTS_EXCEEDED':
return 'Terlalu banyak percobaan. Minta kode baru.';
default:
return 'Gagal verifikasi. Coba lagi.';
} }
} }
} }

View File

@@ -6,7 +6,7 @@ part of 'auth_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$mitraAuthHash() => r'ddb09225b47b4e7683c9f8ad46abc21d9fb7a37b'; String _$mitraAuthHash() => r'c353c4b6cc0335c6276baa4029e361f4ec3b4a36';
/// See also [MitraAuth]. /// See also [MitraAuth].
@ProviderFor(MitraAuth) @ProviderFor(MitraAuth)

View File

@@ -0,0 +1,17 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStorage {
static const _refreshKey = 'mitra_refresh_token';
final _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
Future<String?> readRefreshToken() => _storage.read(key: _refreshKey);
Future<void> writeRefreshToken(String token) =>
_storage.write(key: _refreshKey, value: token);
Future<void> clear() => _storage.delete(key: _refreshKey);
}

View File

@@ -1,11 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart'; import '../api/api_client.dart';
import '../api/api_client_provider.dart'; import '../api/api_client_provider.dart';
import '../auth/auth_bridge.dart';
import '../constants.dart'; import '../constants.dart';
import '../notifications/notification_service.dart'; import '../notifications/notification_service.dart';
@@ -147,10 +147,9 @@ class ChatRequest extends _$ChatRequest {
Future<void> _connectWebSocket() async { Future<void> _connectWebSocket() async {
try { try {
final user = FirebaseAuth.instance.currentUser; final token = ref.read(authBridgeProvider).accessToken;
if (user == null) return; if (token == null) return;
final token = await user.getIdToken();
final wsUrl = ApiClient.baseUrl final wsUrl = ApiClient.baseUrl
.replaceFirst('https://', 'wss://') .replaceFirst('https://', 'wss://')
.replaceFirst('http://', 'ws://'); .replaceFirst('http://', 'ws://');

View File

@@ -6,7 +6,7 @@ part of 'chat_request_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$chatRequestHash() => r'c80b16e371658fbbaca88a75b48e16a3c0e057b3'; String _$chatRequestHash() => r'52aeaca594a44be3eeef7d264a1f311004b38416';
/// See also [ChatRequest]. /// See also [ChatRequest].
@ProviderFor(ChatRequest) @ProviderFor(ChatRequest)

View File

@@ -6,7 +6,7 @@ part of 'extension_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$mitraExtensionHash() => r'4eed73b51454238e2cd40a255c148f232f281913'; String _$mitraExtensionHash() => r'e1346601df43c42aea6f2bc984b507547507a57c';
/// See also [MitraExtension]. /// See also [MitraExtension].
@ProviderFor(MitraExtension) @ProviderFor(MitraExtension)

View File

@@ -1,11 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart'; import '../api/api_client.dart';
import '../api/api_client_provider.dart'; import '../api/api_client_provider.dart';
import '../auth/auth_bridge.dart';
import '../constants.dart'; import '../constants.dart';
part 'mitra_chat_notifier.g.dart'; part 'mitra_chat_notifier.g.dart';
@@ -137,8 +137,11 @@ class MitraChat extends _$MitraChat {
createdAt: DateTime.parse(m['created_at'] as String), createdAt: DateTime.parse(m['created_at'] as String),
)).toList(); )).toList();
final user = FirebaseAuth.instance.currentUser; final token = ref.read(authBridgeProvider).accessToken;
final token = await user?.getIdToken(); if (token == null) {
state = const MitraChatErrorData('Sesi berakhir. Silakan login ulang.');
return;
}
final wsUrl = ApiClient.baseUrl final wsUrl = ApiClient.baseUrl
.replaceFirst('https://', 'wss://') .replaceFirst('https://', 'wss://')
.replaceFirst('http://', 'ws://'); .replaceFirst('http://', 'ws://');

View File

@@ -6,7 +6,7 @@ part of 'mitra_chat_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$mitraChatHash() => r'827aa874dbcf49c17f94c0507f5e0a4064bcede3'; String _$mitraChatHash() => r'd5f4819264b9c71ce29a640ee2cfee608ead5e9e';
/// See also [MitraChat]. /// See also [MitraChat].
@ProviderFor(MitraChat) @ProviderFor(MitraChat)

View File

@@ -15,14 +15,14 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
final List<TextEditingController> _controllers = final List<TextEditingController> _controllers =
List.generate(6, (_) => TextEditingController()); List.generate(6, (_) => TextEditingController());
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode()); final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
String? _verificationId; String? _otpRequestId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final data = ref.read(mitraAuthProvider).valueOrNull; final data = ref.read(mitraAuthProvider).valueOrNull;
if (data is MitraAuthOtpSentData) { if (data is MitraAuthOtpSentData) {
_verificationId = data.verificationId; _otpRequestId = data.otpRequestId;
} }
} }
@@ -60,8 +60,8 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
void _submit() { void _submit() {
final otp = _otp; final otp = _otp;
if (otp.length != 6 || _verificationId == null) return; if (otp.length != 6 || _otpRequestId == null) return;
ref.read(mitraAuthProvider.notifier).verifyOtp(_verificationId!, otp); ref.read(mitraAuthProvider.notifier).verifyOtp(_otpRequestId!, otp);
} }
@override @override
@@ -69,10 +69,10 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
final authState = ref.watch(mitraAuthProvider); final authState = ref.watch(mitraAuthProvider);
final isLoading = authState is AsyncLoading; final isLoading = authState is AsyncLoading;
// Update verification ID if state changes // Update OTP request id if state changes (e.g. resend)
final data = authState.valueOrNull; final data = authState.valueOrNull;
if (data is MitraAuthOtpSentData) { if (data is MitraAuthOtpSentData) {
_verificationId = data.verificationId; _otpRequestId = data.otpRequestId;
} }
ref.listen(mitraAuthProvider, (prev, next) { ref.listen(mitraAuthProvider, (prev, next) {

View File

@@ -161,6 +161,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
code_builder: code_builder:
dependency: transitive dependency: transitive
description: description:
@@ -281,30 +289,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
firebase_auth:
dependency: "direct main"
description:
name: firebase_auth
sha256: cfc2d970829202eca09e2896f0a5aa7c87302817ecc0bdfa954f026046bf10ba
url: "https://pub.dev"
source: hosted
version: "4.20.0"
firebase_auth_platform_interface:
dependency: transitive
description:
name: firebase_auth_platform_interface
sha256: a0270e1db3b2098a14cb2a2342b3cd2e7e458e0c391b1f64f6f78b14296ec093
url: "https://pub.dev"
source: hosted
version: "7.3.0"
firebase_auth_web:
dependency: transitive
description:
name: firebase_auth_web
sha256: "64e067e763c6378b7e774e872f0f59f6812885e43020e25cde08f42e9459837b"
url: "https://pub.dev"
source: hosted
version: "5.12.0"
firebase_core: firebase_core:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -422,6 +406,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.1" version: "2.6.1"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
url: "https://pub.dev"
source: hosted
version: "9.2.4"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
url: "https://pub.dev"
source: hosted
version: "1.2.3"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.dev"
source: hosted
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -472,6 +504,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
hooks:
dependency: transitive
description:
name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
hooks_riverpod: hooks_riverpod:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -520,14 +560,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.5" version: "1.0.5"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
js: js:
dependency: transitive dependency: transitive
description: description:
name: js name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.2" version: "0.6.7"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:
@@ -608,6 +664,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
package_config: package_config:
dependency: transitive dependency: transitive
description: description:
@@ -624,6 +696,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@@ -632,6 +752,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.2" version: "7.0.2"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -664,6 +792,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.0" version: "1.5.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
riverpod: riverpod:
dependency: transitive dependency: transitive
description: description:
@@ -877,6 +1013,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.5" version: "2.4.5"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@@ -902,5 +1046,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.10.0 <4.0.0" dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.1" flutter: ">=3.38.4"

View File

@@ -11,11 +11,13 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
# Firebase # Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now)
firebase_core: ^2.27.1 firebase_core: ^2.27.1
firebase_auth: ^4.18.0
firebase_messaging: ^14.7.15 firebase_messaging: ^14.7.15
# Secure token storage (refresh + access)
flutter_secure_storage: ^9.2.2
# HTTP & WebSocket # HTTP & WebSocket
dio: ^5.4.3 dio: ^5.4.3
web_socket_channel: ^2.4.5 web_socket_channel: ^2.4.5