152 lines
4.8 KiB
Dart
152 lines
4.8 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../../../common/network/api_exception.dart';
|
|
import '../../../common/network/dio_provider.dart';
|
|
import '../../../common/storage/preferences_storage.dart';
|
|
import '../../../common/storage/storage_keys.dart';
|
|
import '../data/auth_api.dart';
|
|
import '../data/auth_models.dart';
|
|
import 'auth_session_coordinator.dart';
|
|
|
|
const _legacyMockLoggedInKey = 'jinzhi_mock_logged_in';
|
|
|
|
final authProvider =
|
|
StateNotifierProvider<AuthController, AsyncValue<AuthState>>((ref) {
|
|
return AuthController(
|
|
ref.read(dioProvider),
|
|
preferences: ref.read(preferencesStorageProvider),
|
|
coordinator: ref.read(authSessionCoordinatorProvider),
|
|
);
|
|
});
|
|
|
|
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
|
AuthController(
|
|
this._dio, {
|
|
required PreferencesStorage preferences,
|
|
required AuthSessionCoordinator coordinator,
|
|
bool autoBootstrap = true,
|
|
}) : _preferences = preferences,
|
|
_coordinator = coordinator,
|
|
super(const AsyncLoading()) {
|
|
_coordinator.addListener(_onSessionEnded);
|
|
if (autoBootstrap) _bootstrap();
|
|
}
|
|
|
|
final Dio _dio;
|
|
final PreferencesStorage _preferences;
|
|
final AuthSessionCoordinator _coordinator;
|
|
|
|
AuthApi get _api => AuthApi(_dio);
|
|
|
|
Future<void> retryBootstrap() => _bootstrap();
|
|
|
|
Future<void> _bootstrap() async {
|
|
state = const AsyncLoading();
|
|
final token = await _coordinator.restore();
|
|
if (token == null || token.isEmpty) {
|
|
state = const AsyncData(GuestAuthState());
|
|
return;
|
|
}
|
|
final cached = _readCachedProfile();
|
|
if (cached != null) {
|
|
state = AsyncData(
|
|
LoggedInAuthState(user: cached, token: token, isVerified: false),
|
|
);
|
|
await _persistProfile(cached);
|
|
}
|
|
await _verifyProfile(token, _coordinator.generation);
|
|
}
|
|
|
|
Future<void> _verifyProfile(String token, int generation) async {
|
|
try {
|
|
final user = await _api.getProfile();
|
|
if (generation != _coordinator.generation) return;
|
|
await _persistProfile(user);
|
|
state = AsyncData(
|
|
LoggedInAuthState(user: user, token: token, isVerified: true),
|
|
);
|
|
} catch (error, stackTrace) {
|
|
if (generation != _coordinator.generation) return;
|
|
final exception = ApiException.from(error, stackTrace: stackTrace);
|
|
if (await _coordinator.expireIfNeeded(exception)) {
|
|
return;
|
|
}
|
|
// Any non-session error (transport, 403, 429, 5xx, decode) preserves a
|
|
// stale cached session. With no profile there is nothing safe to render,
|
|
// but the credential still remains available for retry.
|
|
if (state.valueOrNull is LoggedInAuthState) return;
|
|
state = AsyncError(exception, stackTrace);
|
|
}
|
|
}
|
|
|
|
Future<void> sendCode(String phone) => _api.sendSmsCode(phone);
|
|
|
|
Future<void> verifyCode(String phone, String code) async {
|
|
final reply = await _api.verifySmsCode(phone, code);
|
|
await _persistLogin(reply);
|
|
}
|
|
|
|
Future<void> verifyOneKeyLogin(String loginToken) async {
|
|
final reply = await _api.verifyOneKeyLogin(loginToken);
|
|
await _persistLogin(reply);
|
|
}
|
|
|
|
Future<void> _persistLogin(AuthLoginResult reply) async {
|
|
await _coordinator.startSession(reply.accessToken);
|
|
await _persistProfile(reply.user);
|
|
state = AsyncData(
|
|
LoggedInAuthState(user: reply.user, token: reply.accessToken),
|
|
);
|
|
}
|
|
|
|
Future<void> logout() async {
|
|
await _coordinator.logout();
|
|
}
|
|
|
|
Future<void> deleteAccount(String code) async {
|
|
final normalized = code.trim();
|
|
if (normalized.isEmpty) {
|
|
throw ArgumentError.value(code, 'code', 'must not be empty');
|
|
}
|
|
await _api.deleteAccount(normalized);
|
|
await _coordinator.accountDeleted();
|
|
}
|
|
|
|
Future<void> _persistProfile(JinzhiUser user) async {
|
|
await _preferences.setString(storageUserId, user.id);
|
|
await _preferences.setString(storageUserPhone, user.phone);
|
|
await _preferences.setString(storageUserCreatedAt, user.createdAt);
|
|
await _preferences.remove(_legacyMockLoggedInKey);
|
|
}
|
|
|
|
JinzhiUser? _readCachedProfile() {
|
|
final id = _preferences.getString(storageUserId)?.trim() ?? '';
|
|
final phone =
|
|
(_preferences.getString(storageUserPhone) ??
|
|
_preferences.getString(_legacyMockLoggedInKey) ??
|
|
'')
|
|
.trim();
|
|
final createdAt =
|
|
_preferences.getString(storageUserCreatedAt)?.trim() ?? '';
|
|
if (id.isEmpty && phone.isEmpty) return null;
|
|
return JinzhiUser(id: id, phone: phone, createdAt: createdAt);
|
|
}
|
|
|
|
void _onSessionEnded(SessionEndReason reason) {
|
|
state = AsyncData(
|
|
reason == SessionEndReason.expired
|
|
? const SessionExpiredAuthState()
|
|
: const GuestAuthState(),
|
|
);
|
|
}
|
|
|
|
bool get isLoggedIn => state.valueOrNull is LoggedInAuthState;
|
|
|
|
@override
|
|
void dispose() {
|
|
_coordinator.removeListener(_onSessionEnded);
|
|
super.dispose();
|
|
}
|
|
}
|