Files

199 lines
5.6 KiB
Dart
Raw Permalink Normal View History

2026-07-14 11:11:36 +08:00
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<TraktState>
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<TraktState> 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<void> 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<void> disconnect() async {
await ref.read(traktAuthProvider).clear();
await persistField(_keyUsername, null);
state = const AsyncValue.data(TraktState());
}
Future<String?> ensureValidToken() {
return ref.read(traktAuthProvider).validAccessToken();
}
Future<void> _persistToken(TraktToken token) async {
await persistFields(<String, dynamic>{
_keyAccessToken: token.accessToken,
_keyRefreshToken: token.refreshToken,
_keyExpiresIn: token.expiresIn,
_keyCreatedAt: token.createdAt,
});
}
static String _generateState() {
final rnd = Random.secure();
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
}
final traktSettingsProvider =
AsyncNotifierProvider<TraktSettingsNotifier, TraktState>(
TraktSettingsNotifier.new,
);