import 'dart:async'; import 'dart:math'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; import '../core/contracts/trakt/trakt_token.dart'; import '../core/infra/deep_link/deep_link_service.dart'; import '../core/infra/trakt_api/trakt_credentials.dart'; import '../core/infra/trakt_api/trakt_token_store.dart'; import '../shared/utils/app_logger.dart'; import 'di_providers.dart'; import 'shared/settings_persistence.dart'; const _tag = 'Trakt'; const _connectTimeout = Duration(minutes: 5); class TraktState { final bool isConnected; final String username; final String accessToken; final String refreshToken; final int expiresIn; final int createdAt; const TraktState({ this.isConnected = false, this.username = '', this.accessToken = '', this.refreshToken = '', this.expiresIn = 0, this.createdAt = 0, }); TraktState copyWith({ bool? isConnected, String? username, String? accessToken, String? refreshToken, int? expiresIn, int? createdAt, }) => TraktState( isConnected: isConnected ?? this.isConnected, username: username ?? this.username, accessToken: accessToken ?? this.accessToken, refreshToken: refreshToken ?? this.refreshToken, expiresIn: expiresIn ?? this.expiresIn, createdAt: createdAt ?? this.createdAt, ); } class TraktConnectException implements Exception { final String message; const TraktConnectException(this.message); @override String toString() => 'TraktConnectException: $message'; } class TraktSettingsNotifier extends AsyncNotifier with SettingsPersistence { static const _keyAccessToken = TraktTokenStore.keyAccessToken; static const _keyRefreshToken = TraktTokenStore.keyRefreshToken; static const _keyExpiresIn = TraktTokenStore.keyExpiresIn; static const _keyCreatedAt = TraktTokenStore.keyCreatedAt; static const _keyUsername = 'smplayer.trakt_username'; @override Future build() async { ref.read(traktAuthProvider).onCleared = () { final s = state.value; if (s != null && s.isConnected) { state = const AsyncValue.data(TraktState()); } }; final prefs = await getPrefs(); final accessToken = prefs.getString(_keyAccessToken)?.trim() ?? ''; return TraktState( isConnected: accessToken.isNotEmpty, username: prefs.getString(_keyUsername) ?? '', accessToken: accessToken, refreshToken: prefs.getString(_keyRefreshToken)?.trim() ?? '', expiresIn: prefs.getInt(_keyExpiresIn) ?? 0, createdAt: prefs.getInt(_keyCreatedAt) ?? 0, ); } Future connect() async { final gateway = ref.read(traktGatewayProvider); final csrfState = _generateState(); final redirectFuture = DeepLinkService.instance.uriStream .firstWhere((uri) => uri.queryParameters['state'] == csrfState) .timeout(_connectTimeout); final authorizeUrl = TraktCredentials.authorizeUrl(csrfState); final launched = await launchUrl( authorizeUrl, mode: LaunchMode.externalApplication, ); if (!launched) { throw const TraktConnectException('无法打开浏览器'); } final Uri redirect; try { redirect = await redirectFuture; } on TimeoutException { throw const TraktConnectException('连接超时,请重试'); } final error = redirect.queryParameters['error']; if (error != null && error.isNotEmpty) { AppLogger.warn(_tag, 'authorization denied: $error'); throw const TraktConnectException('已取消授权'); } final returnedState = redirect.queryParameters['state']; if (returnedState != csrfState) { AppLogger.warn(_tag, 'state mismatch'); throw const TraktConnectException('授权校验失败'); } final code = redirect.queryParameters['code']; if (code == null || code.isEmpty) { throw const TraktConnectException('未收到授权码'); } final TraktToken token; try { token = await gateway.exchangeCode(code); } catch (e) { AppLogger.warn(_tag, 'exchangeCode failed', e); throw const TraktConnectException('令牌交换失败'); } await _persistToken(token); var username = ''; try { final user = await gateway.getMe(token.accessToken); username = user.username; } catch (e) { AppLogger.warn(_tag, 'getMe failed (non-fatal)', e); } await persistField(_keyUsername, username); state = AsyncValue.data( TraktState( isConnected: true, username: username, accessToken: token.accessToken, refreshToken: token.refreshToken, expiresIn: token.expiresIn, createdAt: token.createdAt, ), ); } Future disconnect() async { await ref.read(traktAuthProvider).clear(); await persistField(_keyUsername, null); state = const AsyncValue.data(TraktState()); } Future ensureValidToken() { return ref.read(traktAuthProvider).validAccessToken(); } Future _persistToken(TraktToken token) async { await persistFields({ _keyAccessToken: token.accessToken, _keyRefreshToken: token.refreshToken, _keyExpiresIn: token.expiresIn, _keyCreatedAt: token.createdAt, }); } static String _generateState() { final rnd = Random.secure(); final bytes = List.generate(16, (_) => rnd.nextInt(256)); return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); } } final traktSettingsProvider = AsyncNotifierProvider( TraktSettingsNotifier.new, );