import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/domain/ports/sm_account_gateway.dart'; import '../core/infra/sm_account_api/sm_account_client.dart'; import 'shared/settings_persistence.dart'; import 'tmdb_settings_provider.dart'; final smAccountGatewayProvider = Provider( (ref) => SmAccountClient(), ); enum SmAuthStatus { unauthenticated, restoring, authenticated, expired, degraded, } class SmAccountState { final SmAuthStatus status; final String email; final String baseUrl; final String dataRefreshToken; final bool isVip; final int? vipExpiresAt; final bool vipPermanent; const SmAccountState({ this.status = SmAuthStatus.unauthenticated, this.email = '', this.baseUrl = defaultBaseUrl, this.dataRefreshToken = '', this.isVip = false, this.vipExpiresAt, this.vipPermanent = false, }); static const defaultBaseUrl = 'https://your-server.example'; String get tmdbApiBaseUrl => '${baseUrl.replaceAll(RegExp(r'/+$'), '')}/3'; bool get loggedIn => status == SmAuthStatus.authenticated; bool get canUseDataPlane => status == SmAuthStatus.authenticated; bool get hasActiveSession => status == SmAuthStatus.authenticated || status == SmAuthStatus.restoring; bool get needsReauth => status == SmAuthStatus.expired || status == SmAuthStatus.degraded; bool get hasVip => status == SmAuthStatus.authenticated && isVip; SmAccountState copyWith({ SmAuthStatus? status, String? email, String? baseUrl, String? dataRefreshToken, bool? isVip, int? vipExpiresAt, bool? vipPermanent, bool clearVipExpiresAt = false, }) => SmAccountState( status: status ?? this.status, email: email ?? this.email, baseUrl: baseUrl ?? this.baseUrl, dataRefreshToken: dataRefreshToken ?? this.dataRefreshToken, isVip: isVip ?? this.isVip, vipExpiresAt: clearVipExpiresAt ? null : (vipExpiresAt ?? this.vipExpiresAt), vipPermanent: vipPermanent ?? this.vipPermanent, ); } class SmAccountNotifier extends AsyncNotifier with SettingsPersistence { static const _keyBaseUrl = 'smplayer.sm_account_base_url'; static const _keyEmail = 'smplayer.sm_account_email'; static const _keyCookie = 'smplayer.sm_account_cookie'; static const _keyRefreshToken = 'smplayer.sm_account_refresh_token'; static const _keyVip = 'smplayer.sm_account_vip'; static const _keyVipExpiresAt = 'smplayer.sm_account_vip_expires_at'; static const _keyVipPermanent = 'smplayer.sm_account_vip_permanent'; String? _accessToken; DateTime? _accessTokenExpiry; Future? _refreshInFlight; @override Future build() async { final prefs = await getPrefs(); final refreshToken = prefs.getString(_keyRefreshToken)?.trim() ?? ''; final legacyCookie = prefs.getString(_keyCookie)?.trim() ?? ''; final rawBaseUrl = prefs.getString(_keyBaseUrl)?.trim(); final baseUrl = (rawBaseUrl == null || rawBaseUrl.isEmpty) ? SmAccountState.defaultBaseUrl : rawBaseUrl; final email = prefs.getString(_keyEmail)?.trim() ?? ''; final isVip = prefs.getBool(_keyVip) ?? false; final vipExpiresAt = prefs.getInt(_keyVipExpiresAt); final vipPermanent = prefs.getBool(_keyVipPermanent) ?? false; if (legacyCookie.isNotEmpty) { await persistField(_keyCookie, null); } if (refreshToken.isNotEmpty) { Future.microtask(_restoreSession); return SmAccountState( status: SmAuthStatus.restoring, email: email, baseUrl: baseUrl, dataRefreshToken: refreshToken, isVip: isVip, vipExpiresAt: vipExpiresAt, vipPermanent: vipPermanent, ); } if (legacyCookie.isNotEmpty) { return SmAccountState( status: SmAuthStatus.degraded, email: email, baseUrl: baseUrl, ); } return SmAccountState( status: SmAuthStatus.unauthenticated, baseUrl: baseUrl, ); } Future _restoreSession() async { final current = state.value; if (current == null || current.status != SmAuthStatus.restoring || current.dataRefreshToken.isEmpty) { return; } final token = await getAccessToken(force: true); if (token != null) { return; } final after = state.value; if (after != null && after.status == SmAuthStatus.restoring && after.dataRefreshToken.isNotEmpty) { _setStatus(SmAuthStatus.authenticated); } } void _setStatus(SmAuthStatus status) { final current = state.value; if (current == null || current.status == status) return; state = AsyncValue.data(current.copyWith(status: status)); } Future getAccessToken({bool force = false}) async { final current = state.value; if (current == null || current.dataRefreshToken.isEmpty) return null; if (!force && _accessToken != null && _accessTokenExpiry != null && _accessTokenExpiry!.isAfter( DateTime.now().add(const Duration(seconds: 60)), )) { return _accessToken; } final inFlight = _refreshInFlight; if (inFlight != null) return inFlight; final refresh = _refreshAccessToken(current); _refreshInFlight = refresh; try { return await refresh; } finally { if (identical(_refreshInFlight, refresh)) { _refreshInFlight = null; } } } Future _refreshAccessToken(SmAccountState current) async { final result = await ref .read(smAccountGatewayProvider) .refreshAccessToken( baseUrl: current.baseUrl, refreshToken: current.dataRefreshToken, ); if (!result.ok) { _accessToken = null; _accessTokenExpiry = null; if (result.invalidRefreshToken) { await _markExpired(); } return null; } _accessToken = result.accessToken; _accessTokenExpiry = DateTime.now().add( Duration(seconds: result.expiresIn > 0 ? result.expiresIn : 900), ); final latest = state.value ?? current; final rotatedRefreshToken = result.refreshToken.trim(); final nextRefreshToken = (rotatedRefreshToken.isNotEmpty && rotatedRefreshToken != latest.dataRefreshToken) ? rotatedRefreshToken : latest.dataRefreshToken; if (nextRefreshToken != latest.dataRefreshToken) { await persistField(_keyRefreshToken, nextRefreshToken); } if (result.hasVipInfo) { await persistFields({ _keyVip: result.isVip, _keyVipExpiresAt: result.vipExpiresAt, _keyVipPermanent: result.vipPermanent, }); } state = AsyncValue.data( latest.copyWith( status: SmAuthStatus.authenticated, dataRefreshToken: nextRefreshToken, isVip: result.hasVipInfo ? result.isVip : null, vipExpiresAt: result.hasVipInfo ? result.vipExpiresAt : null, clearVipExpiresAt: result.hasVipInfo && result.vipExpiresAt == null, vipPermanent: result.hasVipInfo ? result.vipPermanent : null, ), ); return _accessToken; } Future login(String email, String password) async { final current = state.value ?? const SmAccountState(); final baseUrl = current.baseUrl.trim().isEmpty ? SmAccountState.defaultBaseUrl : current.baseUrl.trim(); final result = await ref .read(smAccountGatewayProvider) .login(baseUrl: baseUrl, email: email.trim(), password: password); if (!result.ok) return result; await _applySession(baseUrl, result); return result; } Future registerStart(String email) async { final baseUrl = _effectiveBaseUrl(); final trimmedEmail = email.trim(); final result = await ref .read(smAccountGatewayProvider) .registerStart(baseUrl: baseUrl, email: trimmedEmail); if (result.ok) { await persistField(_keyBaseUrl, baseUrl); } return result; } Future registerVerify( String email, String password, String code, ) async { final baseUrl = _effectiveBaseUrl(); final result = await ref .read(smAccountGatewayProvider) .registerVerify( baseUrl: baseUrl, email: email.trim(), password: password, code: code.trim(), ); if (!result.ok) return result; await _applySession(baseUrl, result); return result; } Future resendRegisterCode(String email) async { return ref .read(smAccountGatewayProvider) .resendCode(baseUrl: _effectiveBaseUrl(), email: email.trim()); } Future logout() async { final current = state.value ?? const SmAccountState(); _accessToken = null; _accessTokenExpiry = null; _refreshInFlight = null; if (current.dataRefreshToken.isNotEmpty) { await ref .read(smAccountGatewayProvider) .logout( baseUrl: current.baseUrl, refreshToken: current.dataRefreshToken, ); } await persistFields({ _keyEmail: null, _keyCookie: null, _keyRefreshToken: null, _keyVip: null, _keyVipExpiresAt: null, _keyVipPermanent: null, }); state = AsyncValue.data( SmAccountState( status: SmAuthStatus.unauthenticated, baseUrl: current.baseUrl, ), ); } Future _applySession(String baseUrl, SmLoginResult result) async { _accessToken = result.accessToken.isNotEmpty ? result.accessToken : null; _accessTokenExpiry = result.expiresIn > 0 ? DateTime.now().add(Duration(seconds: result.expiresIn)) : null; _refreshInFlight = null; await persistFields({ _keyBaseUrl: baseUrl, _keyEmail: result.email, _keyRefreshToken: result.refreshToken, _keyVip: result.isVip, _keyVipExpiresAt: result.vipExpiresAt, _keyVipPermanent: result.vipPermanent, _keyCookie: null, }); final current = state.value ?? const SmAccountState(); state = AsyncValue.data( current.copyWith( status: SmAuthStatus.authenticated, email: result.email, baseUrl: baseUrl, dataRefreshToken: result.refreshToken, isVip: result.isVip, vipExpiresAt: result.vipExpiresAt, clearVipExpiresAt: result.vipExpiresAt == null, vipPermanent: result.vipPermanent, ), ); if (result.isVip) { await ref.read(tmdbSettingsProvider.notifier).enableSmAccountProxy(); } } Future _markExpired() async { _accessToken = null; _accessTokenExpiry = null; _refreshInFlight = null; await persistFields({ _keyRefreshToken: null, _keyVip: null, _keyVipExpiresAt: null, _keyVipPermanent: null, }); final current = state.value ?? const SmAccountState(); state = AsyncValue.data( current.copyWith( status: SmAuthStatus.expired, dataRefreshToken: '', isVip: false, clearVipExpiresAt: true, vipPermanent: false, ), ); } String _effectiveBaseUrl() { final current = state.value ?? const SmAccountState(); final v = current.baseUrl.trim(); return v.isEmpty ? SmAccountState.defaultBaseUrl : v; } } final smAccountProvider = AsyncNotifierProvider( SmAccountNotifier.new, );