Phase 3.1 WIP: Riverpod migration (client_app Auth + ChatOpening)
- Add phase3.1 requirement and implementation plan docs - Add Riverpod dependencies to both client_app and mitra_app - Wrap both app roots with ProviderScope - Migrate client_app AuthBloc → AuthNotifier (@riverpod annotation) - Migrate client_app ChatOpeningBloc → chatPricingProvider (FutureProvider) - Update router to use Riverpod-based auth state for redirects - Update all auth screens (display name, register, OTP, force register) - Update home screen and pricing bottom sheet - Add android:usesCleartextTraffic for dev HTTP access on both apps - mitra_app prepared with ProviderScope + ApiClient provider (blocs next) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
198
client_app/lib/core/auth/auth_notifier.dart
Normal file
198
client_app/lib/core/auth/auth_notifier.dart
Normal file
@@ -0,0 +1,198 @@
|
||||
import 'dart:async';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:google_sign_in/google_sign_in.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 '../api/api_client.dart';
|
||||
import '../api/api_client_provider.dart';
|
||||
|
||||
part 'auth_notifier.g.dart';
|
||||
|
||||
// States
|
||||
sealed class AuthData {
|
||||
const AuthData();
|
||||
}
|
||||
|
||||
class AuthInitialData extends AuthData {
|
||||
const AuthInitialData();
|
||||
}
|
||||
|
||||
class AuthAuthenticatedData extends AuthData {
|
||||
final Map<String, dynamic> profile;
|
||||
const AuthAuthenticatedData(this.profile);
|
||||
}
|
||||
|
||||
class AuthAnonymousData extends AuthData {
|
||||
final String customerId;
|
||||
final String displayName;
|
||||
const AuthAnonymousData({required this.customerId, required this.displayName});
|
||||
}
|
||||
|
||||
class AuthOtpSentData extends AuthData {
|
||||
final String verificationId;
|
||||
const AuthOtpSentData(this.verificationId);
|
||||
}
|
||||
|
||||
class AuthForceRegisterData extends AuthData {
|
||||
final String customerId;
|
||||
final String displayName;
|
||||
const AuthForceRegisterData({required this.customerId, required this.displayName});
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class Auth extends _$Auth {
|
||||
FirebaseAuth get _auth => FirebaseAuth.instance;
|
||||
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||
|
||||
@override
|
||||
FutureOr<AuthData> build() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final customerId = prefs.getString('anonymous_customer_id');
|
||||
final displayName = prefs.getString('anonymous_display_name');
|
||||
final currentUser = _auth.currentUser;
|
||||
|
||||
if (currentUser != null && currentUser.isAnonymous && customerId != null && displayName != null) {
|
||||
try {
|
||||
final config = await _apiClient.get('/api/shared/config/anonymity');
|
||||
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();
|
||||
}
|
||||
|
||||
Future<void> loginAnonymous(String displayName) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
await _auth.signInAnonymously();
|
||||
final response = await _apiClient.post(
|
||||
'/api/shared/customer/anonymous',
|
||||
data: {'display_name': displayName},
|
||||
);
|
||||
final customer = response['data'] as Map<String, dynamic>;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('anonymous_customer_id', customer['id'] as String);
|
||||
await prefs.setString('anonymous_display_name', customer['display_name'] as String);
|
||||
state = AsyncData(AuthAnonymousData(
|
||||
customerId: customer['id'] as String,
|
||||
displayName: customer['display_name'] as String,
|
||||
));
|
||||
} catch (e) {
|
||||
state = AsyncError('Failed to continue as guest. Please try again.', StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loginGoogle() async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final googleUser = await GoogleSignIn().signIn();
|
||||
if (googleUser == null) {
|
||||
state = const AsyncData(AuthInitialData());
|
||||
return;
|
||||
}
|
||||
final googleAuth = await googleUser.authentication;
|
||||
final credential = GoogleAuthProvider.credential(
|
||||
accessToken: googleAuth.accessToken,
|
||||
idToken: googleAuth.idToken,
|
||||
);
|
||||
await _auth.signInWithCredential(credential);
|
||||
state = AsyncData(await _verifyAndReturn());
|
||||
} catch (e) {
|
||||
state = AsyncError('Google sign-in failed. Please try again.', StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loginApple() async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final appleCredential = await SignInWithApple.getAppleIDCredential(
|
||||
scopes: [AppleIDAuthorizationScopes.email],
|
||||
);
|
||||
final oauthCredential = OAuthProvider('apple.com').credential(
|
||||
idToken: appleCredential.identityToken,
|
||||
accessToken: appleCredential.authorizationCode,
|
||||
);
|
||||
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: (_) {
|
||||
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 {
|
||||
final credential = PhoneAuthProvider.credential(
|
||||
verificationId: verificationId,
|
||||
smsCode: smsCode,
|
||||
);
|
||||
await _auth.signInWithCredential(credential);
|
||||
state = AsyncData(await _verifyAndReturn());
|
||||
} catch (e) {
|
||||
state = AsyncError('Invalid OTP. Please try again.', StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> linkAccount() async {
|
||||
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<AuthData> _verifyAndReturn() async {
|
||||
final response = await _apiClient.post('/api/client/auth/verify');
|
||||
return AuthAuthenticatedData(response['data'] as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
24
client_app/lib/core/auth/auth_notifier.g.dart
Normal file
24
client_app/lib/core/auth/auth_notifier.g.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$authHash() => r'8cb877e94ccf4366b574ffe8c8b4b63321340b6d';
|
||||
|
||||
/// See also [Auth].
|
||||
@ProviderFor(Auth)
|
||||
final authProvider = AsyncNotifierProvider<Auth, AuthData>.internal(
|
||||
Auth.new,
|
||||
name: r'authProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$authHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$Auth = AsyncNotifier<AuthData>;
|
||||
// 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
|
||||
Reference in New Issue
Block a user