352 lines
11 KiB
Dart
352 lines
11 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter_riverpod/legacy.dart';
|
|
import '../core/storage/storage_manager.dart';
|
|
import '../api/guangya_api.dart';
|
|
|
|
class AuthState {
|
|
final bool isSignedIn;
|
|
final bool isLoading;
|
|
final bool isRefreshing;
|
|
final Map<String, dynamic>? userInfo;
|
|
final String? errorMessage;
|
|
final String phoneNumber;
|
|
final String verificationCode;
|
|
final String captchaToken;
|
|
final String verificationID;
|
|
final int codeCountdown;
|
|
final String qrPayload;
|
|
final String qrToken;
|
|
final String qrStatus;
|
|
|
|
const AuthState({
|
|
this.isSignedIn = false,
|
|
this.isLoading = true,
|
|
this.isRefreshing = false,
|
|
this.userInfo,
|
|
this.errorMessage,
|
|
this.phoneNumber = '+86 ',
|
|
this.verificationCode = '',
|
|
this.captchaToken = '',
|
|
this.verificationID = '',
|
|
this.codeCountdown = 0,
|
|
this.qrPayload = '',
|
|
this.qrToken = '',
|
|
this.qrStatus = '等待生成二维码',
|
|
});
|
|
|
|
AuthState copyWith({
|
|
bool? isSignedIn,
|
|
bool? isLoading,
|
|
bool? isRefreshing,
|
|
Map<String, dynamic>? userInfo,
|
|
bool clearUserInfo = false,
|
|
String? errorMessage,
|
|
bool clearError = false,
|
|
String? phoneNumber,
|
|
String? verificationCode,
|
|
String? captchaToken,
|
|
String? verificationID,
|
|
int? codeCountdown,
|
|
String? qrPayload,
|
|
String? qrToken,
|
|
String? qrStatus,
|
|
}) {
|
|
return AuthState(
|
|
isSignedIn: isSignedIn ?? this.isSignedIn,
|
|
isLoading: isLoading ?? this.isLoading,
|
|
isRefreshing: isRefreshing ?? this.isRefreshing,
|
|
userInfo: clearUserInfo ? null : (userInfo ?? this.userInfo),
|
|
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
|
|
phoneNumber: phoneNumber ?? this.phoneNumber,
|
|
verificationCode: verificationCode ?? this.verificationCode,
|
|
captchaToken: captchaToken ?? this.captchaToken,
|
|
verificationID: verificationID ?? this.verificationID,
|
|
codeCountdown: codeCountdown ?? this.codeCountdown,
|
|
qrPayload: qrPayload ?? this.qrPayload,
|
|
qrToken: qrToken ?? this.qrToken,
|
|
qrStatus: qrStatus ?? this.qrStatus,
|
|
);
|
|
}
|
|
|
|
// ── Derived getters ────────────────────────────────────────────
|
|
|
|
String get userName =>
|
|
_findStringDeep(userInfo, ['nickname', 'name', 'username']) ?? '光鸭用户';
|
|
|
|
String get memberLevel =>
|
|
_findStringDeep(userInfo, ['vipName', 'memberName', 'memberLevelName']) ??
|
|
'普通会员';
|
|
|
|
int? get capacity =>
|
|
_findInt64Deep(userInfo, ['capacity', 'totalCapacity']);
|
|
int? get usedCapacity =>
|
|
_findInt64Deep(userInfo, ['usedCapacity', 'usedSpace']);
|
|
|
|
String get capacityText {
|
|
if (capacity == null) return '空间信息暂不可用';
|
|
return '${_formatBytes(usedCapacity ?? 0)} / ${_formatBytes(capacity!)}';
|
|
}
|
|
|
|
// ── Static helpers ─────────────────────────────────────────────
|
|
|
|
static String? _findStringDeep(
|
|
Map<String, dynamic>? json, List<String> keys) {
|
|
if (json == null) return null;
|
|
for (final key in keys) {
|
|
final v = json[key];
|
|
if (v != null && v.toString().isNotEmpty) return v.toString();
|
|
}
|
|
for (final entry in json.entries) {
|
|
if (entry.value is Map<String, dynamic>) {
|
|
final found =
|
|
_findStringDeep(entry.value as Map<String, dynamic>, keys);
|
|
if (found != null) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static int? _findInt64Deep(
|
|
Map<String, dynamic>? json, List<String> keys) {
|
|
if (json == null) return null;
|
|
for (final key in keys) {
|
|
final v = json[key];
|
|
if (v != null) return v is int ? v : int.tryParse(v.toString());
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static String _formatBytes(int bytes) {
|
|
if (bytes < 1024) return '$bytes B';
|
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
if (bytes < 1024 * 1024 * 1024) {
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
}
|
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
|
}
|
|
}
|
|
|
|
class AuthNotifier extends StateNotifier<AuthState> {
|
|
final GuangyaAPI _api = GuangyaAPI(
|
|
deviceID: StorageManager.get<String>(StorageKeys.deviceID),
|
|
);
|
|
Timer? _qrPollingTimer;
|
|
Timer? _countdownTimer;
|
|
|
|
GuangyaAPI get api => _api;
|
|
|
|
AuthNotifier() : super(const AuthState()) {
|
|
_api.accessToken = '';
|
|
tryRestoreSession();
|
|
}
|
|
|
|
Future<void> tryRestoreSession() async {
|
|
final access = StorageManager.get<String>(StorageKeys.accessToken) ?? '';
|
|
final refresh = StorageManager.get<String>(StorageKeys.refreshToken);
|
|
|
|
_api.accessToken = access;
|
|
_api.refreshTokenValue = refresh;
|
|
|
|
if (access.isNotEmpty || refresh != null) {
|
|
try {
|
|
await _api.refreshAccessToken();
|
|
await _saveTokens();
|
|
state = state.copyWith(isSignedIn: true);
|
|
await loadAccount();
|
|
} catch (_) {
|
|
state = state.copyWith(isSignedIn: false);
|
|
_api.clearTokens();
|
|
await _clearTokens();
|
|
}
|
|
}
|
|
state = state.copyWith(isLoading: false);
|
|
}
|
|
|
|
Future<void> loadAccount() async {
|
|
try {
|
|
final userInfo = await _api.userInfo();
|
|
state = state.copyWith(userInfo: userInfo);
|
|
} catch (e) {
|
|
state = state.copyWith(errorMessage: e.toString());
|
|
}
|
|
}
|
|
|
|
// ── SMS Login ─────────────────────────────────────────────────────
|
|
|
|
void updatePhoneNumber(String value) {
|
|
state = state.copyWith(phoneNumber: value);
|
|
}
|
|
|
|
void updateVerificationCode(String value) {
|
|
state = state.copyWith(verificationCode: value);
|
|
}
|
|
|
|
Future<void> sendVerificationCode() async {
|
|
state = state.copyWith(clearError: true);
|
|
final cleanPhone =
|
|
state.phoneNumber.replaceAll(RegExp(r'[^\d]'), '');
|
|
if (cleanPhone.length < 8) {
|
|
state = state.copyWith(errorMessage: '请输入有效的手机号');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final initResult = await _api.loginSMSInit(cleanPhone);
|
|
final captcha =
|
|
AuthState._findStringDeep(initResult, ['captcha_token', 'captchaToken']);
|
|
if (captcha == null) {
|
|
if (AuthState._findStringDeep(initResult, ['url', 'verify_url']) !=
|
|
null) {
|
|
throw Exception('需要完成验证码验证后再发送短信');
|
|
}
|
|
throw Exception('获取验证码令牌失败');
|
|
}
|
|
state = state.copyWith(captchaToken: captcha);
|
|
await _api.loginSMSSend(cleanPhone, captchaToken: captcha);
|
|
_startCountdown();
|
|
} catch (e) {
|
|
state = state.copyWith(errorMessage: e.toString());
|
|
}
|
|
}
|
|
|
|
void _startCountdown() {
|
|
state = state.copyWith(codeCountdown: 60);
|
|
_countdownTimer?.cancel();
|
|
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
final next = state.codeCountdown - 1;
|
|
if (next <= 0) {
|
|
timer.cancel();
|
|
state = state.copyWith(codeCountdown: 0);
|
|
} else {
|
|
state = state.copyWith(codeCountdown: next);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> verifySMSCode() async {
|
|
state = state.copyWith(clearError: true);
|
|
try {
|
|
final verifyResult = await _api.loginSMSVerify(
|
|
state.verificationID, state.verificationCode);
|
|
final vToken = AuthState._findStringDeep(
|
|
verifyResult, ['verification_token', 'verificationToken']);
|
|
final code =
|
|
AuthState._findStringDeep(verifyResult, ['code']);
|
|
if (vToken == null || code == null) throw Exception('验证码验证失败');
|
|
|
|
await _api.loginSMSSignIn(
|
|
code: code,
|
|
verificationToken: vToken,
|
|
username: state.phoneNumber.replaceAll(RegExp(r'[^\d]'), ''),
|
|
captchaToken: state.captchaToken,
|
|
);
|
|
state = state.copyWith(isSignedIn: true);
|
|
await _saveTokens();
|
|
await loadAccount();
|
|
} catch (e) {
|
|
state = state.copyWith(errorMessage: e.toString());
|
|
}
|
|
}
|
|
|
|
// ── QR Login ──────────────────────────────────────────────────────
|
|
|
|
Future<void> initQRLogin() async {
|
|
state = state.copyWith(clearError: true);
|
|
try {
|
|
final result = await _api.loginQRInit();
|
|
final token = AuthState._findStringDeep(
|
|
result, ['device_code', 'deviceCode']) ??
|
|
'';
|
|
final payload = AuthState._findStringDeep(
|
|
result, ['verification_uri_complete', 'verificationUriComplete', 'qr_url', 'qrUrl', 'url', 'verification_uri', 'verificationUri', 'qrcode', 'qrCode', 'code', 'qr_payload', 'qrPayload']) ??
|
|
token;
|
|
state = state.copyWith(
|
|
qrPayload: payload,
|
|
qrToken: token,
|
|
qrStatus: '请使用光鸭APP扫描二维码',
|
|
);
|
|
_startQRPolling();
|
|
} catch (e) {
|
|
state = state.copyWith(errorMessage: e.toString());
|
|
}
|
|
}
|
|
|
|
void _startQRPolling() {
|
|
_qrPollingTimer?.cancel();
|
|
_qrPollingTimer =
|
|
Timer.periodic(const Duration(seconds: 5), (timer) async {
|
|
if (state.qrToken.isEmpty) {
|
|
timer.cancel();
|
|
return;
|
|
}
|
|
try {
|
|
final result = await _api.loginQRPoll(state.qrToken);
|
|
final accessToken = AuthState._findStringDeep(
|
|
result, ['access_token', 'accessToken']);
|
|
if (accessToken != null) {
|
|
timer.cancel();
|
|
state = state.copyWith(isSignedIn: true);
|
|
await _saveTokens();
|
|
await loadAccount();
|
|
} else {
|
|
final error =
|
|
AuthState._findStringDeep(result, ['error']);
|
|
String? newStatus;
|
|
if (error == 'slow_down') {
|
|
newStatus = '轮询过快,请稍候';
|
|
} else if (error == 'authorization_pending') {
|
|
newStatus = '等待扫码确认…';
|
|
}
|
|
if (newStatus != null) {
|
|
state = state.copyWith(qrStatus: newStatus);
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// Continue polling on transient errors
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Sign out ──────────────────────────────────────────────────────
|
|
|
|
Future<void> signOut() async {
|
|
_api.clearTokens();
|
|
await _clearTokens();
|
|
_qrPollingTimer?.cancel();
|
|
_countdownTimer?.cancel();
|
|
state = const AuthState(isLoading: false);
|
|
}
|
|
|
|
// ── Token persistence ─────────────────────────────────────────────
|
|
|
|
Future<void> _saveTokens() async {
|
|
await StorageManager.set(StorageKeys.accessToken, _api.accessToken);
|
|
if (_api.refreshTokenValue != null) {
|
|
await StorageManager.set(StorageKeys.refreshToken, _api.refreshTokenValue!);
|
|
}
|
|
if (_api.tokenExpiresAt != null) {
|
|
await StorageManager.set(
|
|
StorageKeys.tokenExpiresAt,
|
|
_api.tokenExpiresAt!.millisecondsSinceEpoch,
|
|
);
|
|
}
|
|
await StorageManager.set(StorageKeys.deviceID, _api.deviceID);
|
|
}
|
|
|
|
Future<void> _clearTokens() async {
|
|
await StorageManager.delete(StorageKeys.accessToken);
|
|
await StorageManager.delete(StorageKeys.refreshToken);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_qrPollingTimer?.cancel();
|
|
_countdownTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>(
|
|
(ref) => AuthNotifier(),
|
|
);
|