Phase 3.4: client_app self-managed auth cutover
Rips firebase_auth; auth talks directly to the new backend endpoints. Anonymous-first + phone OTP work end-to-end; Google/Apple SDKs are kept but buttons are hidden behind ENABLE_SOCIAL_AUTH until backend OAuth credentials are provisioned. Smoke-tested against the backend via curl: - anonymous → PATCH display_name → /me - OTP request (read stub code from backend log) → verify with anonymous_customer_id → same customer row preserved, display_name preserved, phone added → upgrade confirmed - refresh rotation + logout → post-logout refresh correctly fails REFRESH_INVALID - Debug APK builds clean - pubspec: drop firebase_auth; add flutter_secure_storage - core/auth/auth_bridge.dart: shared mutable state (access token + refresh callback + in-flight de-dup) — keepAlive provider - core/auth/token_storage.dart: flutter_secure_storage wrapper (customer_refresh_token key) - core/auth/social_auth_enabled.dart: const flag from --dart-define=ENABLE_SOCIAL_AUTH (default false) - core/auth/auth_notifier.dart: bootstrap via stored refresh; anonymous via /api/shared/auth/anonymous + PATCH display_name; phone OTP via /api/client/auth/*; Google + Apple wired (passes anonymous_customer_id for upgrade); anonymity config check for ForceRegister state; granular error-code mapping - core/api/api_client.dart: Bearer from bridge + postRaw(skipAuth) for auth endpoints + single-retry 401 refresh - core/chat/chat_notifier.dart + core/pairing/pairing_notifier.dart: WS auth frame reads bridge.accessToken - features/auth/screens/otp_screen.dart: verificationId → otpRequestId - features/auth/screens/register_screen.dart + force_register_screen.dart: Google/Apple buttons gated behind kSocialAuthEnabled; force_register drops obsolete linkAccount() (upgrade happens server-side now via anonymous_customer_id) - client_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:
@@ -7,20 +7,22 @@ Flutter mobile application for end users (clients) seeking mental health support
|
|||||||
## 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). Anonymous-first + phone OTP + (Google / Apple when creds arrive).
|
||||||
- Fully native UI — no WebView, no Firebase-branded screens
|
- Access token in memory on `AuthBridge`; refresh token persisted via `flutter_secure_storage`.
|
||||||
- Use `firebase_auth` + `google_sign_in` packages
|
- Google + Apple SDKs installed but buttons are hidden behind `--dart-define=ENABLE_SOCIAL_AUTH=true` until backend OAuth credentials exist.
|
||||||
- **API:** Calls public Fastify backend (`/api/client/` and `/api/shared/` routes)
|
- `firebase_auth` removed; `firebase_messaging` kept for FCM push.
|
||||||
|
- **API:** Calls public Fastify backend (`/api/client/` and `/api/shared/` routes). Refresh + logout live on `shared.auth`.
|
||||||
- **Payment:** Xendit (paid sessions, optional trial)
|
- **Payment:** Xendit (paid sessions, optional trial)
|
||||||
|
|
||||||
## Key Concepts
|
## Key Concepts
|
||||||
|
|
||||||
- Users are **clients** — they seek mental health support ("curhat")
|
- Users are **clients** — they seek mental health support ("curhat")
|
||||||
- Core flow: register → browse/match with mitra → book session → chat → pay
|
- Core flow: **server-issued anonymous** → optional phone/Google/Apple identity upgrade (same customer row via `anonymous_customer_id`) → browse/match with mitra → book session → chat → pay
|
||||||
- Trial period available for new users
|
- Anonymity toggle: if `/api/shared/config/anonymity` reports `anonymity_enabled = false`, the router shows `ForceRegisterScreen` until the user identifies
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Never call `/api/mitra/` or `/internal/` routes from this app
|
- Never call `/api/mitra/` 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
|
||||||
- Handle token refresh transparently
|
- WebSocket handshake (`/api/shared/ws`) reads the access token from `AuthBridge` in the first frame's `{type:"auth", token, session_id?}` message
|
||||||
|
- Use `const bool.fromEnvironment('ENABLE_SOCIAL_AUTH')` (via `social_auth_enabled.dart`) to gate any Google/Apple UI — never call `loginGoogle` / `loginApple` from a path reachable without that flag
|
||||||
|
|||||||
@@ -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
|
||||||
|
/// (anonymous/refresh/OTP/Google/Apple) to avoid recursion.
|
||||||
|
const _skipAuthKey = 'skipAuth';
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
static const String baseUrl = String.fromEnvironment(
|
static const String baseUrl = String.fromEnvironment(
|
||||||
@@ -7,25 +12,46 @@ 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;
|
||||||
|
|
||||||
Future<Map<String, dynamic>> post(String path, {Map<String, dynamic>? data}) async {
|
if (is401 && !alreadyRetried && !skipAuth) {
|
||||||
final response = await _dio.post(path, data: data);
|
final newToken = await _bridge.tryRefresh();
|
||||||
return response.data as Map<String, dynamic>;
|
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);
|
||||||
|
},
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? queryParameters}) async {
|
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? queryParameters}) async {
|
||||||
@@ -33,8 +59,30 @@ class ApiClient {
|
|||||||
return response.data as Map<String, dynamic>;
|
return response.data as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> post(String path, {Map<String, dynamic>? data}) async {
|
||||||
|
final response = await _dio.post(path, data: data);
|
||||||
|
return response.data as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> patch(String path, {Map<String, dynamic>? data}) async {
|
Future<Map<String, dynamic>> patch(String path, {Map<String, dynamic>? data}) async {
|
||||||
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 anonymous / refresh / OTP / Google / Apple endpoints where
|
||||||
|
/// attaching a 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>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
49
client_app/lib/core/auth/auth_bridge.dart
Normal file
49
client_app/lib/core/auth/auth_bridge.dart
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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.
|
||||||
|
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();
|
||||||
26
client_app/lib/core/auth/auth_bridge.g.dart
Normal file
26
client_app/lib/core/auth/auth_bridge.g.dart
Normal 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
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:google_sign_in/google_sign_in.dart';
|
import 'package:google_sign_in/google_sign_in.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
import 'package:sign_in_with_apple/sign_in_with_apple.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
|
// States
|
||||||
|
|
||||||
sealed class AuthData {
|
sealed class AuthData {
|
||||||
const AuthData();
|
const AuthData();
|
||||||
}
|
}
|
||||||
@@ -18,209 +20,384 @@ class AuthInitialData extends AuthData {
|
|||||||
const AuthInitialData();
|
const AuthInitialData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Signed-in with a server-generated anonymous customer (no identity yet).
|
||||||
|
class AuthAnonymousData extends AuthData {
|
||||||
|
final Map<String, dynamic> profile;
|
||||||
|
const AuthAnonymousData(this.profile);
|
||||||
|
|
||||||
|
String get customerId => profile['id'] as String;
|
||||||
|
String get displayName => (profile['display_name'] as String?) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signed-in with a real identity (phone / google / apple).
|
||||||
class AuthAuthenticatedData extends AuthData {
|
class AuthAuthenticatedData extends AuthData {
|
||||||
final Map<String, dynamic> profile;
|
final Map<String, dynamic> profile;
|
||||||
const AuthAuthenticatedData(this.profile);
|
const AuthAuthenticatedData(this.profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthAnonymousData extends AuthData {
|
/// Phone OTP requested; waiting for the code.
|
||||||
final String customerId;
|
|
||||||
final String displayName;
|
|
||||||
const AuthAnonymousData({required this.customerId, required this.displayName});
|
|
||||||
}
|
|
||||||
|
|
||||||
class AuthOtpSentData extends AuthData {
|
class AuthOtpSentData extends AuthData {
|
||||||
final String verificationId;
|
final String otpRequestId;
|
||||||
const AuthOtpSentData(this.verificationId);
|
final String? channelUsed;
|
||||||
|
const AuthOtpSentData(this.otpRequestId, {this.channelUsed});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Anonymity has been disabled by admin — user must identify before continuing.
|
||||||
class AuthForceRegisterData extends AuthData {
|
class AuthForceRegisterData extends AuthData {
|
||||||
final String customerId;
|
final Map<String, dynamic> profile;
|
||||||
final String displayName;
|
const AuthForceRegisterData(this.profile);
|
||||||
const AuthForceRegisterData({required this.customerId, required this.displayName});
|
|
||||||
|
String get customerId => profile['id'] as String;
|
||||||
|
String get displayName => (profile['display_name'] as String?) ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authenticated but display_name is empty (e.g. Apple withheld it) —
|
||||||
|
/// user must set one before chatting.
|
||||||
class AuthNeedsDisplayNameData extends AuthData {
|
class AuthNeedsDisplayNameData extends AuthData {
|
||||||
final Map<String, dynamic> profile;
|
final Map<String, dynamic> profile;
|
||||||
const AuthNeedsDisplayNameData(this.profile);
|
const AuthNeedsDisplayNameData(this.profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notifier
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class Auth extends _$Auth {
|
class Auth extends _$Auth {
|
||||||
FirebaseAuth get _auth => FirebaseAuth.instance;
|
final _storage = TokenStorage();
|
||||||
|
|
||||||
ApiClient get _apiClient => ref.read(apiClientProvider);
|
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||||
|
AuthBridge get _bridge => ref.read(authBridgeProvider);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<AuthData> build() async {
|
FutureOr<AuthData> build() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
_bridge.refresh = _refreshForBridge;
|
||||||
final customerId = prefs.getString('anonymous_customer_id');
|
_bridge.onUnauthenticated = () {
|
||||||
final displayName = prefs.getString('anonymous_display_name');
|
_bridge.clear();
|
||||||
final currentUser = _auth.currentUser;
|
state = const AsyncData(AuthInitialData());
|
||||||
|
};
|
||||||
|
|
||||||
if (currentUser != null && currentUser.isAnonymous && customerId != null && displayName != null) {
|
final profile = await _refreshFromStorage();
|
||||||
try {
|
if (profile == null) return const AuthInitialData();
|
||||||
final config = await _apiClient.get('/api/shared/config/anonymity');
|
return await _stateForProfile(profile);
|
||||||
final anonymityEnabled = config['data']['anonymity_enabled'] as bool;
|
|
||||||
if (!anonymityEnabled) {
|
|
||||||
return AuthForceRegisterData(customerId: customerId, displayName: displayName);
|
|
||||||
}
|
|
||||||
return AuthAnonymousData(customerId: customerId, displayName: displayName);
|
|
||||||
} catch (_) {
|
|
||||||
return AuthAnonymousData(customerId: customerId, displayName: displayName);
|
|
||||||
}
|
|
||||||
} else if (currentUser != null && !currentUser.isAnonymous) {
|
|
||||||
return await _verifyAndReturn();
|
|
||||||
}
|
|
||||||
return const AuthInitialData();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- Refresh / bootstrap ----------------
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> _refreshFromStorage() async {
|
||||||
|
final refreshToken = await _storage.readRefreshToken();
|
||||||
|
if (refreshToken == null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.postRaw(
|
||||||
|
'/api/shared/auth/refresh',
|
||||||
|
data: {'refresh_token': refreshToken},
|
||||||
|
skipAuth: true,
|
||||||
|
);
|
||||||
|
return _applyTokens(response);
|
||||||
|
} catch (_) {
|
||||||
|
await _storage.clear();
|
||||||
|
_bridge.clear();
|
||||||
|
final current = state.valueOrNull;
|
||||||
|
if (current is AuthAuthenticatedData ||
|
||||||
|
current is AuthAnonymousData ||
|
||||||
|
current is AuthForceRegisterData ||
|
||||||
|
current is AuthNeedsDisplayNameData) {
|
||||||
|
state = const AsyncData(AuthInitialData());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _refreshForBridge() async {
|
||||||
|
final profile = await _refreshFromStorage();
|
||||||
|
if (profile == null) return null;
|
||||||
|
// Keep the notifier state in sync if we were already signed in.
|
||||||
|
final current = state.valueOrNull;
|
||||||
|
if (current != null && current is! AuthInitialData && current is! AuthOtpSentData) {
|
||||||
|
state = AsyncData(await _stateForProfile(profile));
|
||||||
|
}
|
||||||
|
return _bridge.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists refresh token, updates the access token on the bridge, and
|
||||||
|
/// returns the profile. Shared by bootstrap, OTP verify, social sign-in.
|
||||||
|
Future<Map<String, dynamic>> _applyTokens(Map<String, dynamic> response) async {
|
||||||
|
final data = response['data'] as Map<String, dynamic>;
|
||||||
|
final access = data['access_token'] as String;
|
||||||
|
final refresh = data['refresh_token'] as String;
|
||||||
|
final profile = data['profile'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
await _storage.writeRefreshToken(refresh);
|
||||||
|
_bridge.setAccessToken(access);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decides which AuthData variant matches the current customer row,
|
||||||
|
/// consulting server config for the anonymity toggle.
|
||||||
|
Future<AuthData> _stateForProfile(Map<String, dynamic> profile) async {
|
||||||
|
final hasIdentity = _hasIdentity(profile);
|
||||||
|
if (hasIdentity) {
|
||||||
|
final displayName = profile['display_name'] as String?;
|
||||||
|
if (displayName == null || displayName.isEmpty) {
|
||||||
|
return AuthNeedsDisplayNameData(profile);
|
||||||
|
}
|
||||||
|
return AuthAuthenticatedData(profile);
|
||||||
|
}
|
||||||
|
// Anonymous — check if platform allows it.
|
||||||
|
try {
|
||||||
|
final config = await _apiClient.get('/api/shared/config/anonymity');
|
||||||
|
final anonymityEnabled = config['data']['anonymity_enabled'] as bool? ?? true;
|
||||||
|
if (!anonymityEnabled) return AuthForceRegisterData(profile);
|
||||||
|
} catch (_) {
|
||||||
|
// On config fetch failure, default to allowing anonymity (best UX).
|
||||||
|
}
|
||||||
|
return AuthAnonymousData(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasIdentity(Map<String, dynamic> profile) {
|
||||||
|
return (profile['phone'] as String?) != null ||
|
||||||
|
(profile['google_sub'] as String?) != null ||
|
||||||
|
(profile['apple_sub'] as String?) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _currentAnonymousCustomerId() {
|
||||||
|
final current = state.valueOrNull;
|
||||||
|
if (current is AuthAnonymousData) return current.customerId;
|
||||||
|
if (current is AuthForceRegisterData) return current.customerId;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Anonymous ----------------
|
||||||
|
|
||||||
Future<void> loginAnonymous(String displayName) async {
|
Future<void> loginAnonymous(String displayName) async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
try {
|
try {
|
||||||
await _auth.signInAnonymously();
|
final anon = await _apiClient.postRaw(
|
||||||
final response = await _apiClient.post(
|
'/api/shared/auth/anonymous',
|
||||||
'/api/shared/customer/anonymous',
|
skipAuth: true,
|
||||||
data: {'display_name': displayName},
|
|
||||||
);
|
);
|
||||||
final customer = response['data'] as Map<String, dynamic>;
|
final profile = await _applyTokens(anon);
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString('anonymous_customer_id', customer['id'] as String);
|
// Patch display name if user chose one (anonymous endpoint auto-generates).
|
||||||
await prefs.setString('anonymous_display_name', customer['display_name'] as String);
|
final trimmed = displayName.trim();
|
||||||
state = AsyncData(AuthAnonymousData(
|
Map<String, dynamic> finalProfile = profile;
|
||||||
customerId: customer['id'] as String,
|
if (trimmed.isNotEmpty && trimmed != profile['display_name']) {
|
||||||
displayName: customer['display_name'] as String,
|
try {
|
||||||
));
|
final patch = await _apiClient.patch(
|
||||||
} catch (e) {
|
'/api/client/auth/profile',
|
||||||
state = AsyncError('Failed to continue as guest. Please try again.', StackTrace.current);
|
data: {'display_name': trimmed},
|
||||||
|
);
|
||||||
|
finalProfile = patch['data'] as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
// Keep the server-generated name if patch fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state = AsyncData(await _stateForProfile(finalProfile));
|
||||||
|
} catch (_) {
|
||||||
|
state = AsyncError('Gagal masuk sebagai tamu. Coba lagi.', StackTrace.current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- Phone OTP ----------------
|
||||||
|
|
||||||
|
Future<void> requestOtp(String phone, {String? channel}) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.postRaw(
|
||||||
|
'/api/client/auth/otp/request',
|
||||||
|
data: {
|
||||||
|
'phone': phone,
|
||||||
|
if (channel != null) 'channel': channel,
|
||||||
|
},
|
||||||
|
skipAuth: true,
|
||||||
|
);
|
||||||
|
final data = response['data'] as Map<String, dynamic>;
|
||||||
|
state = AsyncData(AuthOtpSentData(
|
||||||
|
data['otp_request_id'] as String,
|
||||||
|
channelUsed: data['channel_used'] as String?,
|
||||||
|
));
|
||||||
|
} on DioException catch (e) {
|
||||||
|
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/client/auth/otp/verify',
|
||||||
|
data: {
|
||||||
|
'otp_request_id': otpRequestId,
|
||||||
|
'code': code,
|
||||||
|
if (_currentAnonymousCustomerId() != null)
|
||||||
|
'anonymous_customer_id': _currentAnonymousCustomerId(),
|
||||||
|
},
|
||||||
|
skipAuth: true,
|
||||||
|
);
|
||||||
|
final profile = await _applyTokens(response);
|
||||||
|
state = AsyncData(await _stateForProfile(profile));
|
||||||
|
} on DioException catch (e) {
|
||||||
|
state = AsyncError(_otpVerifyMessage(e), StackTrace.current);
|
||||||
|
} catch (_) {
|
||||||
|
state = AsyncError('Gagal verifikasi. Coba lagi.', StackTrace.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Google ----------------
|
||||||
|
|
||||||
Future<void> loginGoogle() async {
|
Future<void> loginGoogle() async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
try {
|
try {
|
||||||
final googleUser = await GoogleSignIn().signIn();
|
final googleUser = await GoogleSignIn().signIn();
|
||||||
if (googleUser == null) {
|
if (googleUser == null) {
|
||||||
|
// User cancelled — revert to previous state.
|
||||||
|
final previous = _currentAnonymousCustomerId();
|
||||||
|
if (previous != null) {
|
||||||
|
// We were anonymous; rebuild state from the stored refresh token to
|
||||||
|
// get the right variant (Anonymous vs ForceRegister).
|
||||||
|
final profile = await _refreshFromStorage();
|
||||||
|
if (profile != null) {
|
||||||
|
state = AsyncData(await _stateForProfile(profile));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
state = const AsyncData(AuthInitialData());
|
state = const AsyncData(AuthInitialData());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final googleAuth = await googleUser.authentication;
|
final googleAuth = await googleUser.authentication;
|
||||||
final credential = GoogleAuthProvider.credential(
|
final idToken = googleAuth.idToken;
|
||||||
accessToken: googleAuth.accessToken,
|
if (idToken == null) {
|
||||||
idToken: googleAuth.idToken,
|
state = AsyncError('Gagal mendapatkan kredensial Google.', StackTrace.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final response = await _apiClient.postRaw(
|
||||||
|
'/api/client/auth/google',
|
||||||
|
data: {
|
||||||
|
'id_token': idToken,
|
||||||
|
if (_currentAnonymousCustomerId() != null)
|
||||||
|
'anonymous_customer_id': _currentAnonymousCustomerId(),
|
||||||
|
},
|
||||||
|
skipAuth: true,
|
||||||
);
|
);
|
||||||
await _auth.signInWithCredential(credential);
|
final profile = await _applyTokens(response);
|
||||||
state = AsyncData(await _verifyAndReturn());
|
state = AsyncData(await _stateForProfile(profile));
|
||||||
} catch (e) {
|
} on DioException catch (e) {
|
||||||
state = AsyncError('Google sign-in failed. Please try again.', StackTrace.current);
|
state = AsyncError(_socialSignInMessage(e), StackTrace.current);
|
||||||
|
} catch (_) {
|
||||||
|
state = AsyncError('Gagal masuk dengan Google.', StackTrace.current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- Apple ----------------
|
||||||
|
|
||||||
Future<void> loginApple() async {
|
Future<void> loginApple() async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
try {
|
try {
|
||||||
final appleCredential = await SignInWithApple.getAppleIDCredential(
|
final credential = await SignInWithApple.getAppleIDCredential(
|
||||||
scopes: [AppleIDAuthorizationScopes.email],
|
scopes: [AppleIDAuthorizationScopes.email, AppleIDAuthorizationScopes.fullName],
|
||||||
);
|
);
|
||||||
final oauthCredential = OAuthProvider('apple.com').credential(
|
final idToken = credential.identityToken;
|
||||||
idToken: appleCredential.identityToken,
|
if (idToken == null) {
|
||||||
accessToken: appleCredential.authorizationCode,
|
state = AsyncError('Gagal mendapatkan kredensial Apple.', StackTrace.current);
|
||||||
);
|
return;
|
||||||
await _auth.signInWithCredential(oauthCredential);
|
|
||||||
state = AsyncData(await _verifyAndReturn());
|
|
||||||
} catch (e) {
|
|
||||||
state = AsyncError('Apple sign-in failed. Please try again.', StackTrace.current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> requestOtp(String phone) async {
|
|
||||||
state = const AsyncLoading();
|
|
||||||
final completer = Completer<void>();
|
|
||||||
await _auth.verifyPhoneNumber(
|
|
||||||
phoneNumber: phone,
|
|
||||||
verificationCompleted: (credential) async {
|
|
||||||
try {
|
|
||||||
await _auth.signInWithCredential(credential);
|
|
||||||
state = AsyncData(await _verifyAndReturn());
|
|
||||||
} catch (_) {}
|
|
||||||
if (!completer.isCompleted) completer.complete();
|
|
||||||
},
|
|
||||||
verificationFailed: (e) {
|
|
||||||
state = AsyncError('Failed to send OTP. Please try again.', StackTrace.current);
|
|
||||||
if (!completer.isCompleted) completer.complete();
|
|
||||||
},
|
|
||||||
codeSent: (verificationId, _) {
|
|
||||||
state = AsyncData(AuthOtpSentData(verificationId));
|
|
||||||
if (!completer.isCompleted) completer.complete();
|
|
||||||
},
|
|
||||||
codeAutoRetrievalTimeout: (_) {
|
|
||||||
if (!completer.isCompleted) completer.complete();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await completer.future;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> verifyOtp(String verificationId, String smsCode) async {
|
|
||||||
state = const AsyncLoading();
|
|
||||||
try {
|
|
||||||
// If already signed in via auto-verification, skip credential sign-in
|
|
||||||
if (_auth.currentUser == null || _auth.currentUser!.isAnonymous) {
|
|
||||||
final credential = PhoneAuthProvider.credential(
|
|
||||||
verificationId: verificationId,
|
|
||||||
smsCode: smsCode,
|
|
||||||
);
|
|
||||||
await _auth.signInWithCredential(credential);
|
|
||||||
}
|
}
|
||||||
state = AsyncData(await _verifyAndReturn());
|
final response = await _apiClient.postRaw(
|
||||||
} catch (e) {
|
'/api/client/auth/apple',
|
||||||
state = AsyncError('Invalid OTP. Please try again.', StackTrace.current);
|
data: {
|
||||||
|
'id_token': idToken,
|
||||||
|
if (_currentAnonymousCustomerId() != null)
|
||||||
|
'anonymous_customer_id': _currentAnonymousCustomerId(),
|
||||||
|
},
|
||||||
|
skipAuth: true,
|
||||||
|
);
|
||||||
|
final profile = await _applyTokens(response);
|
||||||
|
state = AsyncData(await _stateForProfile(profile));
|
||||||
|
} on SignInWithAppleAuthorizationException {
|
||||||
|
state = AsyncError('Login Apple dibatalkan.', StackTrace.current);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
state = AsyncError(_socialSignInMessage(e), StackTrace.current);
|
||||||
|
} catch (_) {
|
||||||
|
state = AsyncError('Gagal masuk dengan Apple.', StackTrace.current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> linkAccount() async {
|
// ---------------- Display name update ----------------
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final customerId = prefs.getString('anonymous_customer_id');
|
|
||||||
if (customerId == null || _auth.currentUser == null) return;
|
|
||||||
|
|
||||||
state = const AsyncLoading();
|
|
||||||
try {
|
|
||||||
await _apiClient.post('/api/shared/customer/link', data: {
|
|
||||||
'customer_id': customerId,
|
|
||||||
'firebase_uid': _auth.currentUser!.uid,
|
|
||||||
});
|
|
||||||
await prefs.remove('anonymous_customer_id');
|
|
||||||
await prefs.remove('anonymous_display_name');
|
|
||||||
state = AsyncData(await _verifyAndReturn());
|
|
||||||
} catch (e) {
|
|
||||||
state = AsyncError('Failed to link account. Please try again.', StackTrace.current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> logout() async {
|
|
||||||
await _auth.signOut();
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.remove('anonymous_customer_id');
|
|
||||||
await prefs.remove('anonymous_display_name');
|
|
||||||
state = const AsyncData(AuthInitialData());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setDisplayName(String displayName) async {
|
Future<void> setDisplayName(String displayName) async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
try {
|
try {
|
||||||
final response = await _apiClient.patch('/api/client/auth/profile', data: {
|
final response = await _apiClient.patch(
|
||||||
'display_name': displayName,
|
'/api/client/auth/profile',
|
||||||
});
|
data: {'display_name': displayName},
|
||||||
state = AsyncData(AuthAuthenticatedData(response['data'] as Map<String, dynamic>));
|
);
|
||||||
} catch (e) {
|
final profile = response['data'] as Map<String, dynamic>;
|
||||||
|
state = AsyncData(await _stateForProfile(profile));
|
||||||
|
} catch (_) {
|
||||||
state = AsyncError('Gagal menyimpan nama. Coba lagi.', StackTrace.current);
|
state = AsyncError('Gagal menyimpan nama. Coba lagi.', StackTrace.current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AuthData> _verifyAndReturn() async {
|
// ---------------- Logout ----------------
|
||||||
final response = await _apiClient.post('/api/client/auth/verify');
|
|
||||||
final profile = response['data'] as Map<String, dynamic>;
|
Future<void> logout() async {
|
||||||
if (profile['display_name'] == null || (profile['display_name'] as String).isEmpty) {
|
try {
|
||||||
return AuthNeedsDisplayNameData(profile);
|
await _apiClient.post('/api/shared/auth/logout');
|
||||||
|
} catch (_) {}
|
||||||
|
await _storage.clear();
|
||||||
|
_bridge.clear();
|
||||||
|
state = const AsyncData(AuthInitialData());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Error-code mapping ----------------
|
||||||
|
|
||||||
|
String _otpRequestMessage(DioException e) {
|
||||||
|
final code = e.response?.data?['error']?['code'] as String?;
|
||||||
|
switch (code) {
|
||||||
|
case 'PHONE_INVALID':
|
||||||
|
return 'Nomor HP tidak valid.';
|
||||||
|
case 'OTP_COOLDOWN':
|
||||||
|
return e.response?.data?['error']?['message'] as String? ??
|
||||||
|
'Tunggu sebentar sebelum minta OTP lagi.';
|
||||||
|
case 'OTP_RATE_LIMIT_PHONE':
|
||||||
|
case 'OTP_RATE_LIMIT_IP':
|
||||||
|
return 'Terlalu banyak permintaan OTP. Coba lagi nanti.';
|
||||||
|
default:
|
||||||
|
return 'Gagal mengirim OTP. Coba lagi.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _otpVerifyMessage(DioException e) {
|
||||||
|
final code = e.response?.data?['error']?['code'] as String?;
|
||||||
|
switch (code) {
|
||||||
|
case 'WRONG_FLOW':
|
||||||
|
return 'OTP tidak valid untuk login pelanggan.';
|
||||||
|
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.';
|
||||||
|
case 'IDENTITY_CONFLICT':
|
||||||
|
return 'Nomor ini sudah terdaftar di akun lain.';
|
||||||
|
default:
|
||||||
|
return 'Gagal verifikasi. Coba lagi.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _socialSignInMessage(DioException e) {
|
||||||
|
final code = e.response?.data?['error']?['code'] as String?;
|
||||||
|
switch (code) {
|
||||||
|
case 'IDENTITY_CONFLICT':
|
||||||
|
return 'Akun ini sudah terhubung ke pengguna lain.';
|
||||||
|
case 'INVALID_ID_TOKEN':
|
||||||
|
return 'Verifikasi identitas gagal. Coba lagi.';
|
||||||
|
default:
|
||||||
|
return 'Gagal masuk. Coba lagi.';
|
||||||
}
|
}
|
||||||
return AuthAuthenticatedData(profile);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'auth_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$authHash() => r'8cb877e94ccf4366b574ffe8c8b4b63321340b6d';
|
String _$authHash() => r'601e614f3297fb679f5baa893932a43ae981eb9d';
|
||||||
|
|
||||||
/// See also [Auth].
|
/// See also [Auth].
|
||||||
@ProviderFor(Auth)
|
@ProviderFor(Auth)
|
||||||
|
|||||||
7
client_app/lib/core/auth/social_auth_enabled.dart
Normal file
7
client_app/lib/core/auth/social_auth_enabled.dart
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// Build-time flag controlling whether Google / Apple sign-in buttons
|
||||||
|
/// are shown. Default: false until backend OAuth credentials are
|
||||||
|
/// provisioned. Enable with `--dart-define=ENABLE_SOCIAL_AUTH=true`.
|
||||||
|
const bool kSocialAuthEnabled = bool.fromEnvironment(
|
||||||
|
'ENABLE_SOCIAL_AUTH',
|
||||||
|
defaultValue: false,
|
||||||
|
);
|
||||||
17
client_app/lib/core/auth/token_storage.dart
Normal file
17
client_app/lib/core/auth/token_storage.dart
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
class TokenStorage {
|
||||||
|
static const _refreshKey = 'customer_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);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
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 'chat_notifier.g.dart';
|
part 'chat_notifier.g.dart';
|
||||||
@@ -135,8 +135,11 @@ class Chat extends _$Chat {
|
|||||||
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 ChatErrorData('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://');
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'chat_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$chatHash() => r'c67d0e916a9474e5142d1f07649792cd448607e4';
|
String _$chatHash() => r'b704f27f25fb06bbb266f394daf05ca12f518363';
|
||||||
|
|
||||||
/// See also [Chat].
|
/// See also [Chat].
|
||||||
@ProviderFor(Chat)
|
@ProviderFor(Chat)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$sessionClosureHash() => r'5799a386e1e9c925601567b1fb8c684be7c7e23c';
|
String _$sessionClosureHash() => r'22a7994290c3a0cc3c692a68063bdc8ffcb2bf87';
|
||||||
|
|
||||||
/// See also [SessionClosure].
|
/// See also [SessionClosure].
|
||||||
@ProviderFor(SessionClosure)
|
@ProviderFor(SessionClosure)
|
||||||
|
|||||||
@@ -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 'pairing_notifier.g.dart';
|
part 'pairing_notifier.g.dart';
|
||||||
@@ -114,10 +114,9 @@ class Pairing extends _$Pairing {
|
|||||||
|
|
||||||
Future<void> _connectWebSocket() async {
|
Future<void> _connectWebSocket() async {
|
||||||
_closeWebSocket();
|
_closeWebSocket();
|
||||||
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://');
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$pairingHash() => r'93049804c1d55a0195a56b97d6e7f34fe6ab8086';
|
String _$pairingHash() => r'a283e74d7cb4244bac74a950205c91d4b2cf3e9a';
|
||||||
|
|
||||||
/// See also [Pairing].
|
/// See also [Pairing].
|
||||||
@ProviderFor(Pairing)
|
@ProviderFor(Pairing)
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/auth/auth_notifier.dart';
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
|
import '../../../core/auth/social_auth_enabled.dart';
|
||||||
|
|
||||||
/// Shown when anonymity is disabled by admin.
|
/// Shown when anonymity is disabled by admin.
|
||||||
/// User must link their account. Display name is pre-filled.
|
/// User must identify themselves (phone OTP / Google / Apple).
|
||||||
|
/// Backend upgrades the existing anonymous customer row when the current
|
||||||
|
/// anonymous_customer_id is passed on sign-in (see AuthNotifier).
|
||||||
class ForceRegisterScreen extends ConsumerStatefulWidget {
|
class ForceRegisterScreen extends ConsumerStatefulWidget {
|
||||||
const ForceRegisterScreen({super.key});
|
const ForceRegisterScreen({super.key});
|
||||||
|
|
||||||
@@ -31,10 +34,6 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
|
|||||||
if (data is AuthOtpSentData) {
|
if (data is AuthOtpSentData) {
|
||||||
context.push('/auth/otp', extra: _phoneController.text.trim());
|
context.push('/auth/otp', extra: _phoneController.text.trim());
|
||||||
}
|
}
|
||||||
if (data is AuthAuthenticatedData) {
|
|
||||||
// After social login succeeds, link account to existing anonymous record
|
|
||||||
ref.read(authProvider.notifier).linkAccount();
|
|
||||||
}
|
|
||||||
if (next is AsyncError) {
|
if (next is AsyncError) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||||
}
|
}
|
||||||
@@ -52,27 +51,29 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
|
|||||||
style: TextStyle(fontSize: 16),
|
style: TextStyle(fontSize: 16),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
ElevatedButton.icon(
|
if (kSocialAuthEnabled) ...[
|
||||||
icon: const Icon(Icons.g_mobiledata),
|
ElevatedButton.icon(
|
||||||
onPressed: isLoading ? null
|
icon: const Icon(Icons.g_mobiledata),
|
||||||
: () => ref.read(authProvider.notifier).loginGoogle(),
|
onPressed: isLoading ? null
|
||||||
label: const Text('Lanjut dengan Google'),
|
: () => ref.read(authProvider.notifier).loginGoogle(),
|
||||||
),
|
label: const Text('Lanjut dengan Google'),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
ElevatedButton.icon(
|
const SizedBox(height: 12),
|
||||||
icon: const Icon(Icons.apple),
|
ElevatedButton.icon(
|
||||||
onPressed: isLoading ? null
|
icon: const Icon(Icons.apple),
|
||||||
: () => ref.read(authProvider.notifier).loginApple(),
|
onPressed: isLoading ? null
|
||||||
label: const Text('Lanjut dengan Apple'),
|
: () => ref.read(authProvider.notifier).loginApple(),
|
||||||
),
|
label: const Text('Lanjut dengan Apple'),
|
||||||
const Padding(
|
),
|
||||||
padding: EdgeInsets.symmetric(vertical: 24),
|
const Padding(
|
||||||
child: Row(children: [
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
Expanded(child: Divider()),
|
child: Row(children: [
|
||||||
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
|
Expanded(child: Divider()),
|
||||||
Expanded(child: Divider()),
|
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
|
||||||
]),
|
Expanded(child: Divider()),
|
||||||
),
|
]),
|
||||||
|
),
|
||||||
|
],
|
||||||
TextField(
|
TextField(
|
||||||
controller: _phoneController,
|
controller: _phoneController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ class OtpScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _OtpScreenState extends ConsumerState<OtpScreen> {
|
class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||||
final _otpController = TextEditingController();
|
final _otpController = TextEditingController();
|
||||||
String? _verificationId;
|
String? _otpRequestId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Capture verification ID from current state
|
// Capture OTP request id from current state
|
||||||
final data = ref.read(authProvider).valueOrNull;
|
final data = ref.read(authProvider).valueOrNull;
|
||||||
if (data is AuthOtpSentData) {
|
if (data is AuthOtpSentData) {
|
||||||
_verificationId = data.verificationId;
|
_otpRequestId = data.otpRequestId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,10 +35,10 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
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 AuthOtpSentData) {
|
if (data is AuthOtpSentData) {
|
||||||
_verificationId = data.verificationId;
|
_otpRequestId = data.otpRequestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
ref.listen(authProvider, (prev, next) {
|
ref.listen(authProvider, (prev, next) {
|
||||||
@@ -69,8 +69,8 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: isLoading ? null : () {
|
onPressed: isLoading ? null : () {
|
||||||
final otp = _otpController.text.trim();
|
final otp = _otpController.text.trim();
|
||||||
if (otp.length != 6 || _verificationId == null) return;
|
if (otp.length != 6 || _otpRequestId == null) return;
|
||||||
ref.read(authProvider.notifier).verifyOtp(_verificationId!, otp);
|
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, otp);
|
||||||
},
|
},
|
||||||
child: isLoading
|
child: isLoading
|
||||||
? const CircularProgressIndicator()
|
? const CircularProgressIndicator()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/auth/auth_notifier.dart';
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
|
import '../../../core/auth/social_auth_enabled.dart';
|
||||||
|
|
||||||
class RegisterScreen extends ConsumerStatefulWidget {
|
class RegisterScreen extends ConsumerStatefulWidget {
|
||||||
const RegisterScreen({super.key});
|
const RegisterScreen({super.key});
|
||||||
@@ -41,27 +42,29 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton.icon(
|
if (kSocialAuthEnabled) ...[
|
||||||
icon: const Icon(Icons.g_mobiledata),
|
ElevatedButton.icon(
|
||||||
onPressed: isLoading ? null
|
icon: const Icon(Icons.g_mobiledata),
|
||||||
: () => ref.read(authProvider.notifier).loginGoogle(),
|
onPressed: isLoading ? null
|
||||||
label: const Text('Lanjut dengan Google'),
|
: () => ref.read(authProvider.notifier).loginGoogle(),
|
||||||
),
|
label: const Text('Lanjut dengan Google'),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
ElevatedButton.icon(
|
const SizedBox(height: 12),
|
||||||
icon: const Icon(Icons.apple),
|
ElevatedButton.icon(
|
||||||
onPressed: isLoading ? null
|
icon: const Icon(Icons.apple),
|
||||||
: () => ref.read(authProvider.notifier).loginApple(),
|
onPressed: isLoading ? null
|
||||||
label: const Text('Lanjut dengan Apple'),
|
: () => ref.read(authProvider.notifier).loginApple(),
|
||||||
),
|
label: const Text('Lanjut dengan Apple'),
|
||||||
const Padding(
|
),
|
||||||
padding: EdgeInsets.symmetric(vertical: 24),
|
const Padding(
|
||||||
child: Row(children: [
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
Expanded(child: Divider()),
|
child: Row(children: [
|
||||||
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
|
Expanded(child: Divider()),
|
||||||
Expanded(child: Divider()),
|
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
|
||||||
]),
|
Expanded(child: Divider()),
|
||||||
),
|
]),
|
||||||
|
),
|
||||||
|
],
|
||||||
TextField(
|
TextField(
|
||||||
controller: _phoneController,
|
controller: _phoneController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||||
|
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
flutter_secure_storage_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -5,19 +5,19 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import firebase_auth
|
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import firebase_messaging
|
import firebase_messaging
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
|
import flutter_secure_storage_macos
|
||||||
import google_sign_in_ios
|
import google_sign_in_ios
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sign_in_with_apple
|
import sign_in_with_apple
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
|
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
|
||||||
|
|||||||
@@ -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: "0fed2133bee1369ee1118c1fef27b2ce0d84c54b7819a2b17dada5cfec3b03ff"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.7.0"
|
|
||||||
firebase_auth_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: firebase_auth_platform_interface
|
|
||||||
sha256: "871c9df4ec9a754d1a793f7eb42fa3b94249d464cfb19152ba93e14a5966b386"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.7.3"
|
|
||||||
firebase_auth_web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: firebase_auth_web
|
|
||||||
sha256: d9ada769c43261fd1b18decf113186e915c921a811bd5014f5ea08f4cf4bc57e
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.15.3"
|
|
||||||
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
|
||||||
@@ -520,6 +552,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:
|
||||||
@@ -568,14 +608,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:
|
||||||
@@ -656,6 +712,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:
|
||||||
@@ -672,6 +744,30 @@ 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:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -744,6 +840,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:
|
||||||
@@ -1045,6 +1149,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.3"
|
||||||
|
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:
|
||||||
@@ -1070,5 +1182,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"
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# Firebase
|
# Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now)
|
||||||
firebase_core: ^3.12.1
|
firebase_core: ^3.12.1
|
||||||
firebase_auth: ^5.5.1
|
|
||||||
firebase_messaging: ^15.2.5
|
firebase_messaging: ^15.2.5
|
||||||
|
|
||||||
# Social login
|
# Social login (kept — activated when OAuth creds arrive; buttons hidden behind
|
||||||
|
# ENABLE_SOCIAL_AUTH dart-define flag until then)
|
||||||
google_sign_in: ^6.2.1
|
google_sign_in: ^6.2.1
|
||||||
sign_in_with_apple: ^6.1.0
|
sign_in_with_apple: ^6.1.0
|
||||||
|
|
||||||
@@ -31,7 +31,8 @@ dependencies:
|
|||||||
flutter_hooks: ^0.20.5
|
flutter_hooks: ^0.20.5
|
||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
shared_preferences: ^2.2.3
|
shared_preferences: ^2.2.3 # onboarding flag, non-sensitive
|
||||||
|
flutter_secure_storage: ^9.2.2 # refresh token (encrypted)
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
go_router: ^13.2.1
|
go_router: ^13.2.1
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FirebaseAuthPluginCApiRegisterWithRegistrar(
|
|
||||||
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
|
||||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
firebase_auth
|
|
||||||
firebase_core
|
firebase_core
|
||||||
|
flutter_secure_storage_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
flutter_local_notifications_windows
|
flutter_local_notifications_windows
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
Reference in New Issue
Block a user