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:
2026-04-09 13:51:17 +08:00
parent b0502ac92b
commit d15b2f05fc
25 changed files with 2513 additions and 461 deletions

View 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>);
}
}

View 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