diff --git a/README.md b/README.md index 373156a..b8a35db 100644 --- a/README.md +++ b/README.md @@ -130,12 +130,12 @@ FullStop 的**迷你模式**让播放器化身为屏幕角落的一抹优雅— ## 开始使用 ### 准备工作 -1. **Spotify Premium** 订阅。 -2. 一个 Spotify 开发者 App 的 `Client ID` 和 `Secret`。 +1. **Spotify Premium** 订阅。 +2. 下载并打开 FullStop,点击「连接 Spotify」即可完成授权。无需任何额外配置。 ### 安装 (iOS) -1. 在 [Releases](https://github.com/0Chencc/FullStop/releases) 下载最新的 `.ipa` 文件。 -2. 使用 **AltStore**、**Sideloadly** 或 **TrollStore** 进行自签安装。 +1. 在 [Releases](https://github.com/0Chencc/FullStop/releases) 下载最新的 `.ipa` 文件。 +2. 使用 **AltStore**、**Sideloadly** 或 **TrollStore** 进行自签安装。 --- diff --git a/README_en.md b/README_en.md index ae3e448..8b9f169 100644 --- a/README_en.md +++ b/README_en.md @@ -133,7 +133,7 @@ Click the picture-in-picture button in the title bar, and the window shrinks ins ### Prerequisites 1. **Spotify Premium** subscription. -2. A Spotify Developer App with `Client ID` and `Secret`. +2. Download and open FullStop, click "Connect with Spotify" to authorize. No additional configuration needed. ### Installation (iOS) 1. Download the latest `.ipa` file from [Releases](https://github.com/0Chencc/FullStop/releases). diff --git a/README_ja.md b/README_ja.md index 6e5ae9a..8d20614 100644 --- a/README_ja.md +++ b/README_ja.md @@ -133,7 +133,7 @@ FullStopの**ミニモード**は、プレーヤーを画面の隅にそっと ### 必要なもの 1. **Spotify Premium** サブスクリプション。 -2. Spotify開発者アプリの `Client ID` と `Secret`。 +2. FullStop をダウンロードして開き、「Spotify に接続」をクリックするだけで認証完了。追加の設定は不要です。 ### インストール (iOS) 1. [Releases](https://github.com/0Chencc/FullStop/releases) から最新の `.ipa` ファイルをダウンロード。 diff --git a/img/ios.png b/img/ios.png new file mode 100644 index 0000000..f0b3be3 Binary files /dev/null and b/img/ios.png differ diff --git a/lib/app.dart b/lib/app.dart index 72838ad..172f0a6 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -6,7 +6,6 @@ import 'package:window_manager/window_manager.dart'; import 'l10n/app_localizations.dart'; import 'application/di/core_providers.dart' show sharedPrefsProvider; import 'application/providers/auth_provider.dart'; -import 'application/providers/credentials_provider.dart'; import 'application/providers/locale_provider.dart'; import 'application/providers/navigation_provider.dart'; import 'application/providers/playback_provider.dart'; @@ -14,7 +13,6 @@ import 'domain/entities/playback_state.dart'; import 'core/services/system_tray_service.dart'; import 'presentation/screens/home_screen.dart'; import 'presentation/screens/login_screen.dart'; -import 'presentation/screens/setup_guide_screen.dart'; import 'presentation/themes/app_theme.dart'; import 'presentation/widgets/custom_title_bar.dart'; import 'presentation/widgets/mini_player_content.dart'; @@ -191,7 +189,7 @@ class _AppShellState extends ConsumerState<_AppShell> with WindowListener { key: _navigatorKey, onGenerateRoute: (settings) { return MaterialPageRoute( - builder: (context) => const _AppRouter(), + builder: (context) => const _AuthRouter(), settings: settings, ); }, @@ -216,55 +214,19 @@ class _AppShellState extends ConsumerState<_AppShell> with WindowListener { ], ); } - return const _AppRouter(); + return const _AuthRouter(); } } -class _AppRouter extends ConsumerStatefulWidget { - const _AppRouter(); +/// Routes directly to auth flow — no setup guard needed with PKCE. +class _AuthRouter extends ConsumerStatefulWidget { + const _AuthRouter(); @override - ConsumerState<_AppRouter> createState() => _AppRouterState(); + ConsumerState<_AuthRouter> createState() => _AuthRouterState(); } -class _AppRouterState extends ConsumerState<_AppRouter> { - bool _setupComplete = false; - - void _onSetupComplete() { - setState(() { - _setupComplete = true; - }); - // After setup, check auth status - ref.read(authProvider.notifier).checkAuthStatus(); - } - - @override - Widget build(BuildContext context) { - final credentialsState = ref.watch(credentialsProvider); - - // Show loading while checking credentials - if (credentialsState.isLoading) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); - } - - // If credentials not configured and setup not complete, show setup guide - if (!credentialsState.hasSpotifyCredentials && !_setupComplete) { - return SetupGuideScreen(onSetupComplete: _onSetupComplete); - } - - // Credentials are configured, show auth flow - return const _AuthWrapper(); - } -} - -class _AuthWrapper extends ConsumerStatefulWidget { - const _AuthWrapper(); - - @override - ConsumerState<_AuthWrapper> createState() => _AuthWrapperState(); -} - -class _AuthWrapperState extends ConsumerState<_AuthWrapper> { +class _AuthRouterState extends ConsumerState<_AuthRouter> { bool _hasCheckedAuth = false; @override diff --git a/lib/application/di/auth_providers.dart b/lib/application/di/auth_providers.dart index d7e3c01..14505de 100644 --- a/lib/application/di/auth_providers.dart +++ b/lib/application/di/auth_providers.dart @@ -8,6 +8,7 @@ import '../../data/datasources/credentials_shared_prefs_datasource.dart'; import '../../data/repositories/auth_repository_impl.dart'; import '../../domain/entities/proxy_settings.dart'; import '../../domain/repositories/auth_repository.dart'; +import '../providers/credentials_provider.dart'; import 'core_providers.dart'; import 'spotify_providers.dart'; @@ -50,17 +51,17 @@ final oauthServiceProvider = Provider((ref) { // Auth Repository final authRepositoryProvider = Provider((ref) { final localDataSource = ref.watch(authLocalDataSourceProvider); - final credentialsDataSource = ref.watch(credentialsLocalDataSourceProvider); final apiClient = ref.watch(spotifyApiClientProvider); final dio = ref.watch(authDioProvider); final oauthService = ref.watch(oauthServiceProvider); + final clientId = ref.watch(effectiveSpotifyClientIdProvider); return AuthRepositoryImpl( localDataSource: localDataSource, - credentialsDataSource: credentialsDataSource, apiClient: apiClient, dio: dio, oauthService: oauthService, + clientId: clientId, ); }); @@ -73,8 +74,6 @@ class _PlaceholderCredentialsDataSource implements CredentialsLocalDataSource { @override Future clearAppProxySettings() async {} @override - Future clearSpotifyCredentials() async {} - @override Future getAudioFeaturesEnabled() async => false; @override Future getGetSongBpmApiKey() async => null; @@ -89,25 +88,23 @@ class _PlaceholderCredentialsDataSource implements CredentialsLocalDataSource { @override Future getAppProxySettings() async => const AppProxySettings(); @override - Future getSpotifyClientId() async => null; - @override - Future getSpotifyClientSecret() async => null; - @override Future hasGetSongBpmApiKey() async => false; @override Future hasLlmConfig() async => false; @override - Future hasSpotifyCredentials() async => false; - @override Future saveAppProxySettings(AppProxySettings config) async {} @override Future saveLlmCredentials({String apiKey = '', required String model, required String baseUrl}) async {} @override - Future saveSpotifyCredentials({required String clientId, required String clientSecret}) async {} - @override Future setAudioFeaturesEnabled(bool enabled) async {} @override Future setGetSongBpmApiKey(String apiKey) async {} @override Future setGpuAccelerationEnabled(bool enabled) async {} + @override + Future getCustomSpotifyClientId() async => null; + @override + Future saveCustomSpotifyClientId(String clientId) async {} + @override + Future clearCustomSpotifyClientId() async {} } diff --git a/lib/application/di/core_providers.dart b/lib/application/di/core_providers.dart index f69c39f..1ed977d 100644 --- a/lib/application/di/core_providers.dart +++ b/lib/application/di/core_providers.dart @@ -13,14 +13,13 @@ import '../../core/services/token_refresh_service.dart'; import '../../core/services/url_launcher_service.dart'; import '../../core/services/window_service.dart'; import '../../core/utils/logger.dart'; +import '../providers/credentials_provider.dart'; import '../../data/datasources/auth_local_datasource.dart'; import '../../data/datasources/auth_shared_prefs_datasource.dart'; -import '../../data/datasources/credentials_local_datasource.dart'; import '../../domain/entities/proxy_settings.dart'; import '../../data/services/app_links_deep_link_service.dart'; import '../../data/services/default_url_launcher_service.dart'; import '../../data/services/window_manager_service.dart'; -import 'auth_providers.dart' show credentialsLocalDataSourceProvider; // SharedPreferences provider for macOS/iOS final sharedPrefsProvider = FutureProvider((ref) async { @@ -99,17 +98,21 @@ final authDioProvider = Provider((ref) { // Token Refresh Service (lazily initialized to avoid circular dependencies) TokenRefreshService? _tokenRefreshService; +String? _lastTokenRefreshClientId; TokenRefreshService _getOrCreateTokenRefreshService( AuthLocalDataSource authDataSource, - CredentialsLocalDataSource credentialsDataSource, Dio authDio, + String clientId, ) { - _tokenRefreshService ??= TokenRefreshService( - authDataSource: authDataSource, - credentialsDataSource: credentialsDataSource, - dio: authDio, - ); + if (_tokenRefreshService == null || _lastTokenRefreshClientId != clientId) { + _tokenRefreshService = TokenRefreshService( + clientId: clientId, + authDataSource: authDataSource, + dio: authDio, + ); + _lastTokenRefreshClientId = clientId; + } return _tokenRefreshService!; } @@ -150,12 +153,12 @@ final apiDioProvider = Provider((ref) { // Get fresh data sources for token refresh final authLocalDataSource = ref.read(authLocalDataSourceProvider); - final credentialsDataSource = ref.read(credentialsLocalDataSourceProvider); + final clientId = ref.read(effectiveSpotifyClientIdProvider); final tokenRefreshService = _getOrCreateTokenRefreshService( authLocalDataSource, - credentialsDataSource, authDio, + clientId, ); final newToken = await tokenRefreshService.refreshToken(); diff --git a/lib/application/providers/credentials_provider.dart b/lib/application/providers/credentials_provider.dart index 106087b..52357d2 100644 --- a/lib/application/providers/credentials_provider.dart +++ b/lib/application/providers/credentials_provider.dart @@ -1,58 +1,48 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/config/app_config.dart'; import '../../data/datasources/credentials_local_datasource.dart'; import '../di/injection_container.dart'; /// State for credentials configuration class CredentialsState { final bool isLoading; - final bool hasSpotifyCredentials; final bool hasLlmConfig; - final String? spotifyClientId; - final String? spotifyClientSecret; final String? llmApiKey; final String? llmModel; final String? llmBaseUrl; + final String? customSpotifyClientId; final String? error; const CredentialsState({ this.isLoading = true, - this.hasSpotifyCredentials = false, this.hasLlmConfig = false, - this.spotifyClientId, - this.spotifyClientSecret, this.llmApiKey, this.llmModel, this.llmBaseUrl, + this.customSpotifyClientId, this.error, }); CredentialsState copyWith({ bool? isLoading, - bool? hasSpotifyCredentials, bool? hasLlmConfig, - String? spotifyClientId, - String? spotifyClientSecret, String? llmApiKey, String? llmModel, String? llmBaseUrl, + String? customSpotifyClientId, String? error, - bool clearSpotifyCredentials = false, bool clearLlmConfig = false, + bool clearCustomClientId = false, }) { return CredentialsState( isLoading: isLoading ?? this.isLoading, - hasSpotifyCredentials: - hasSpotifyCredentials ?? this.hasSpotifyCredentials, hasLlmConfig: hasLlmConfig ?? this.hasLlmConfig, - spotifyClientId: clearSpotifyCredentials - ? null - : (spotifyClientId ?? this.spotifyClientId), - spotifyClientSecret: clearSpotifyCredentials - ? null - : (spotifyClientSecret ?? this.spotifyClientSecret), llmApiKey: clearLlmConfig ? null : (llmApiKey ?? this.llmApiKey), llmModel: clearLlmConfig ? null : (llmModel ?? this.llmModel), llmBaseUrl: clearLlmConfig ? null : (llmBaseUrl ?? this.llmBaseUrl), + customSpotifyClientId: clearCustomClientId + ? null + : (customSpotifyClientId ?? this.customSpotifyClientId), error: error, ); } @@ -68,83 +58,25 @@ class CredentialsNotifier extends StateNotifier { Future _checkCredentials() async { try { - final hasSpotify = await _dataSource.hasSpotifyCredentials(); final hasLlm = await _dataSource.hasLlmConfig(); - final clientId = await _dataSource.getSpotifyClientId(); - final clientSecret = await _dataSource.getSpotifyClientSecret(); final llmApiKey = await _dataSource.getLlmApiKey(); final llmModel = await _dataSource.getLlmModel(); final llmBaseUrl = await _dataSource.getLlmBaseUrl(); + final customClientId = await _dataSource.getCustomSpotifyClientId(); state = state.copyWith( isLoading: false, - hasSpotifyCredentials: hasSpotify, hasLlmConfig: hasLlm, - spotifyClientId: clientId, - spotifyClientSecret: clientSecret, llmApiKey: llmApiKey, llmModel: llmModel, llmBaseUrl: llmBaseUrl, + customSpotifyClientId: customClientId, ); } catch (e) { state = state.copyWith(isLoading: false, error: e.toString()); } } - Future saveSpotifyCredentials({ - required String clientId, - required String clientSecret, - }) async { - try { - state = state.copyWith(isLoading: true, error: null); - - // Validate inputs - if (clientId.trim().isEmpty || clientSecret.trim().isEmpty) { - state = state.copyWith( - isLoading: false, - error: 'Client ID and Client Secret are required', - ); - return false; - } - - await _dataSource.saveSpotifyCredentials( - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - ); - - state = state.copyWith( - isLoading: false, - hasSpotifyCredentials: true, - spotifyClientId: clientId.trim(), - spotifyClientSecret: clientSecret.trim(), - ); - return true; - } catch (e) { - state = state.copyWith( - isLoading: false, - error: 'Failed to save credentials: $e', - ); - return false; - } - } - - Future clearSpotifyCredentials() async { - try { - state = state.copyWith(isLoading: true); - await _dataSource.clearSpotifyCredentials(); - state = state.copyWith( - isLoading: false, - hasSpotifyCredentials: false, - clearSpotifyCredentials: true, - ); - } catch (e) { - state = state.copyWith( - isLoading: false, - error: 'Failed to clear credentials: $e', - ); - } - } - Future saveLlmCredentials({ String apiKey = '', required String model, @@ -202,6 +134,26 @@ class CredentialsNotifier extends StateNotifier { } } + Future saveCustomSpotifyClientId(String clientId) async { + try { + await _dataSource.saveCustomSpotifyClientId(clientId.trim()); + state = state.copyWith(customSpotifyClientId: clientId.trim()); + return true; + } catch (e) { + state = state.copyWith(error: 'Failed to save custom Client ID: $e'); + return false; + } + } + + Future clearCustomSpotifyClientId() async { + try { + await _dataSource.clearCustomSpotifyClientId(); + state = state.copyWith(clearCustomClientId: true); + } catch (e) { + state = state.copyWith(error: 'Failed to clear custom Client ID: $e'); + } + } + Future refresh() async { await _checkCredentials(); } @@ -213,30 +165,12 @@ final credentialsProvider = return CredentialsNotifier(ref.watch(credentialsLocalDataSourceProvider)); }); -/// Helper provider to get Spotify credentials for API calls -final spotifyCredentialsProvider = FutureProvider(( - ref, -) async { - final dataSource = ref.watch(credentialsLocalDataSourceProvider); - final clientId = await dataSource.getSpotifyClientId(); - final clientSecret = await dataSource.getSpotifyClientSecret(); - - if (clientId == null || - clientId.isEmpty || - clientSecret == null || - clientSecret.isEmpty) { - return null; - } - - return SpotifyCredentials(clientId: clientId, clientSecret: clientSecret); +/// Derived provider that returns the effective Spotify Client ID +/// (custom > default) +final effectiveSpotifyClientIdProvider = Provider((ref) { + final creds = ref.watch(credentialsProvider); + final custom = creds.customSpotifyClientId; + return (custom != null && custom.isNotEmpty) + ? custom + : AppConfig.spotifyClientId; }); - -class SpotifyCredentials { - final String clientId; - final String clientSecret; - - const SpotifyCredentials({ - required this.clientId, - required this.clientSecret, - }); -} diff --git a/lib/application/providers/focus_session_provider.dart b/lib/application/providers/focus_session_provider.dart index 849739b..7a7d748 100644 --- a/lib/application/providers/focus_session_provider.dart +++ b/lib/application/providers/focus_session_provider.dart @@ -1,13 +1,13 @@ import 'package:dartz/dartz.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/errors/failures.dart'; +import 'credentials_provider.dart'; import '../../core/services/device_activation_service.dart'; import '../../domain/entities/focus_session.dart'; import '../../domain/entities/track.dart'; import '../../domain/repositories/focus_session_repository.dart'; import '../../domain/usecases/focus/play_focus_session.dart'; import '../di/injection_container.dart'; -import 'credentials_provider.dart'; enum FocusSessionStatus { initial, loading, success, error } @@ -132,18 +132,13 @@ class FocusSessionNotifier extends StateNotifier { ); try { - final futureRepo = ref.read(focusSessionRepositoryProvider.future); - final futureCredentials = ref.read(spotifyCredentialsProvider.future); - - final parallelResults = await (futureRepo, futureCredentials).wait; - final focusRepoAsync = parallelResults.$1; - final credentials = parallelResults.$2; + final focusRepoAsync = await ref.read(focusSessionRepositoryProvider.future); final playbackRepo = ref.read(playbackRepositoryProvider); final playUseCase = PlayFocusSession( focusRepository: focusRepoAsync, playbackRepository: playbackRepo, - clientId: credentials?.clientId, + clientId: ref.read(effectiveSpotifyClientIdProvider), ); final result = await playUseCase( @@ -197,11 +192,9 @@ class FocusSessionNotifier extends StateNotifier { state = state.copyWith(optimisticIsPlaying: true); final playbackRepo = ref.read(playbackRepositoryProvider); - final credentials = await ref.read(spotifyCredentialsProvider.future); - final activationService = DeviceActivationService.instance( playbackRepository: playbackRepo, - clientId: credentials?.clientId, + clientId: ref.read(effectiveSpotifyClientIdProvider), ); String? deviceId; diff --git a/lib/application/providers/playback_provider.dart b/lib/application/providers/playback_provider.dart index 6268c8b..bb1a492 100644 --- a/lib/application/providers/playback_provider.dart +++ b/lib/application/providers/playback_provider.dart @@ -2,11 +2,11 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/errors/failures.dart'; +import 'credentials_provider.dart'; import '../../core/services/device_activation_service.dart'; import '../../core/utils/logger.dart'; import '../../domain/entities/playback_state.dart'; import '../di/injection_container.dart'; -import 'credentials_provider.dart'; import 'focus_session_provider.dart'; /// Result of a like/unlike operation @@ -218,12 +218,11 @@ class PlaybackNotifier extends StateNotifier { /// Used when resuming a paused session Future resume() async { final playbackRepo = ref.read(playbackRepositoryProvider); - final credentials = await ref.read(spotifyCredentialsProvider.future); // Ensure we have an active device before resuming final activationService = DeviceActivationService.instance( playbackRepository: playbackRepo, - clientId: credentials?.clientId, + clientId: ref.read(effectiveSpotifyClientIdProvider), ); String? deviceId; diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 90a840b..87e84bb 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -1,4 +1,7 @@ class AppConfig { + // Spotify Client ID (public client — safe to embed, PKCE replaces client_secret) + static const String spotifyClientId = '94fcde08534f4025a402cd2bba93e1f0'; + // Custom URL scheme for OAuth callback static const String urlScheme = 'fullstop'; diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 84d7bbe..ac41445 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -6,10 +6,6 @@ class AppConstants { static const String tokenExpiryKey = 'spotify_token_expiry'; static const String userProfileKey = 'user_profile'; - // API Credentials keys - static const String spotifyClientIdKey = 'spotify_client_id'; - static const String spotifyClientSecretKey = 'spotify_client_secret'; - // LLM Config keys static const String llmProviderKey = 'llm_provider'; static const String llmApiKeyKey = 'llm_api_key'; @@ -28,6 +24,9 @@ class AppConstants { static const String audioFeaturesEnabledKey = 'audio_features_enabled'; static const String gpuAccelerationEnabledKey = 'gpu_acceleration_enabled'; + // Custom Spotify Client ID + static const String customSpotifyClientIdKey = 'custom_spotify_client_id'; + // GetSongBPM API static const String getSongBpmApiKeyKey = 'getsongbpm_api_key'; diff --git a/lib/core/services/oauth_service.dart b/lib/core/services/oauth_service.dart index ccbf634..61f96fc 100644 --- a/lib/core/services/oauth_service.dart +++ b/lib/core/services/oauth_service.dart @@ -3,6 +3,7 @@ import 'package:dartz/dartz.dart'; import '../config/app_config.dart'; import '../errors/failures.dart'; import '../utils/logger.dart'; +import '../utils/pkce_utils.dart'; import 'deep_link_service.dart'; import 'url_launcher_service.dart'; @@ -16,8 +17,13 @@ class AuthCancelledException implements Exception { class OAuthAuthorizationResult { final String code; final String state; + final String codeVerifier; - const OAuthAuthorizationResult({required this.code, required this.state}); + const OAuthAuthorizationResult({ + required this.code, + required this.state, + required this.codeVerifier, + }); } /// Abstract OAuth service interface @@ -45,6 +51,7 @@ class SpotifyOAuthService implements OAuthService { Completer? _activeCompleter; bool _isCancelled = false; String? _expectedState; + String? _codeVerifier; bool _isAuthenticating = false; SpotifyOAuthService({ @@ -73,6 +80,7 @@ class SpotifyOAuthService implements OAuthService { _linkSubscription = null; _activeCompleter = null; _expectedState = null; + _codeVerifier = null; _isAuthenticating = false; } @@ -166,6 +174,10 @@ class SpotifyOAuthService implements OAuthService { final scopeString = scopes.join(' '); _expectedState = DateTime.now().millisecondsSinceEpoch.toString(); + // PKCE: generate code_verifier and derive code_challenge + _codeVerifier = PkceUtils.generateCodeVerifier(); + final codeChallenge = PkceUtils.generateCodeChallenge(_codeVerifier!); + return Uri.parse(AppConfig.spotifyAuthUrl).replace( queryParameters: { 'client_id': clientId, @@ -174,6 +186,8 @@ class SpotifyOAuthService implements OAuthService { 'scope': scopeString, 'state': _expectedState, 'show_dialog': 'true', + 'code_challenge_method': 'S256', + 'code_challenge': codeChallenge, }, ); } @@ -198,6 +212,7 @@ class SpotifyOAuthService implements OAuthService { Uri callbackUri, ) async { final expectedState = _expectedState; + final codeVerifier = _codeVerifier!; await _linkSubscription?.cancel(); _linkSubscription = null; @@ -238,6 +253,10 @@ class SpotifyOAuthService implements OAuthService { _isAuthenticating = false; AppLogger.info('OAuth authorization completed successfully'); - return Right(OAuthAuthorizationResult(code: code, state: returnedState!)); + return Right(OAuthAuthorizationResult( + code: code, + state: returnedState!, + codeVerifier: codeVerifier, + )); } } diff --git a/lib/core/services/token_refresh_service.dart b/lib/core/services/token_refresh_service.dart index 43d7d58..ce991cc 100644 --- a/lib/core/services/token_refresh_service.dart +++ b/lib/core/services/token_refresh_service.dart @@ -1,24 +1,22 @@ -import 'dart:convert'; import 'package:dio/dio.dart'; import '../config/app_config.dart'; import '../utils/logger.dart'; import '../../data/datasources/auth_local_datasource.dart'; -import '../../data/datasources/credentials_local_datasource.dart'; /// Service responsible for refreshing Spotify access tokens. /// This is a low-level service used by the Dio interceptor. class TokenRefreshService { final AuthLocalDataSource authDataSource; - final CredentialsLocalDataSource credentialsDataSource; final Dio dio; + final String clientId; bool _isRefreshing = false; Future? _refreshFuture; TokenRefreshService({ required this.authDataSource, - required this.credentialsDataSource, required this.dio, + required this.clientId, }); /// Attempts to refresh the access token. @@ -49,27 +47,15 @@ class TokenRefreshService { return null; } - final clientId = await credentialsDataSource.getSpotifyClientId(); - final clientSecret = await credentialsDataSource.getSpotifyClientSecret(); - - if (clientId == null || - clientId.isEmpty || - clientSecret == null || - clientSecret.isEmpty) { - AppLogger.warning('No Spotify credentials available for token refresh'); - return null; - } - - final credentials = base64Encode(utf8.encode('$clientId:$clientSecret')); - final response = await dio.post( AppConfig.spotifyTokenUrl, - data: {'grant_type': 'refresh_token', 'refresh_token': refreshToken}, + data: { + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken, + 'client_id': clientId, + }, options: Options( - headers: { - 'Authorization': 'Basic $credentials', - 'Content-Type': 'application/x-www-form-urlencoded', - }, + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, contentType: Headers.formUrlEncodedContentType, ), ); diff --git a/lib/core/utils/pkce_utils.dart b/lib/core/utils/pkce_utils.dart new file mode 100644 index 0000000..fb2cc02 --- /dev/null +++ b/lib/core/utils/pkce_utils.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:crypto/crypto.dart'; + +class PkceUtils { + static const _charset = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + + /// Generate a cryptographically random code verifier (43-128 characters). + static String generateCodeVerifier([int length = 128]) { + final random = Random.secure(); + return List.generate( + length, + (_) => _charset[random.nextInt(_charset.length)], + ).join(); + } + + /// Compute code_challenge = BASE64URL(SHA256(code_verifier)). + static String generateCodeChallenge(String codeVerifier) { + final bytes = utf8.encode(codeVerifier); + final digest = sha256.convert(bytes); + return base64Url.encode(digest.bytes).replaceAll('=', ''); + } +} diff --git a/lib/data/datasources/credentials_local_datasource.dart b/lib/data/datasources/credentials_local_datasource.dart index 0aca226..b769cca 100644 --- a/lib/data/datasources/credentials_local_datasource.dart +++ b/lib/data/datasources/credentials_local_datasource.dart @@ -4,15 +4,6 @@ import '../../domain/entities/proxy_settings.dart'; /// Data source for storing and retrieving API credentials securely abstract class CredentialsLocalDataSource { - Future getSpotifyClientId(); - Future getSpotifyClientSecret(); - Future saveSpotifyCredentials({ - required String clientId, - required String clientSecret, - }); - Future clearSpotifyCredentials(); - Future hasSpotifyCredentials(); - // LLM credentials (optional) Future getLlmApiKey(); Future getLlmModel(); @@ -36,6 +27,11 @@ abstract class CredentialsLocalDataSource { Future getGpuAccelerationEnabled(); Future setGpuAccelerationEnabled(bool enabled); + // Custom Spotify Client ID + Future getCustomSpotifyClientId(); + Future saveCustomSpotifyClientId(String clientId); + Future clearCustomSpotifyClientId(); + // GetSongBPM API Future getGetSongBpmApiKey(); Future setGetSongBpmApiKey(String apiKey); @@ -48,48 +44,6 @@ class CredentialsLocalDataSourceImpl implements CredentialsLocalDataSource { CredentialsLocalDataSourceImpl(this._secureStorage); - // Spotify credentials - @override - Future getSpotifyClientId() async { - return await _secureStorage.read(key: AppConstants.spotifyClientIdKey); - } - - @override - Future getSpotifyClientSecret() async { - return await _secureStorage.read(key: AppConstants.spotifyClientSecretKey); - } - - @override - Future saveSpotifyCredentials({ - required String clientId, - required String clientSecret, - }) async { - await _secureStorage.write( - key: AppConstants.spotifyClientIdKey, - value: clientId, - ); - await _secureStorage.write( - key: AppConstants.spotifyClientSecretKey, - value: clientSecret, - ); - } - - @override - Future clearSpotifyCredentials() async { - await _secureStorage.delete(key: AppConstants.spotifyClientIdKey); - await _secureStorage.delete(key: AppConstants.spotifyClientSecretKey); - } - - @override - Future hasSpotifyCredentials() async { - final clientId = await getSpotifyClientId(); - final clientSecret = await getSpotifyClientSecret(); - return clientId != null && - clientId.isNotEmpty && - clientSecret != null && - clientSecret.isNotEmpty; - } - // LLM credentials @override Future getLlmApiKey() async { @@ -236,6 +190,25 @@ class CredentialsLocalDataSourceImpl implements CredentialsLocalDataSource { ); } + // Custom Spotify Client ID + @override + Future getCustomSpotifyClientId() async { + return await _secureStorage.read(key: AppConstants.customSpotifyClientIdKey); + } + + @override + Future saveCustomSpotifyClientId(String clientId) async { + await _secureStorage.write( + key: AppConstants.customSpotifyClientIdKey, + value: clientId, + ); + } + + @override + Future clearCustomSpotifyClientId() async { + await _secureStorage.delete(key: AppConstants.customSpotifyClientIdKey); + } + // GetSongBPM API @override Future getGetSongBpmApiKey() async { diff --git a/lib/data/datasources/credentials_shared_prefs_datasource.dart b/lib/data/datasources/credentials_shared_prefs_datasource.dart index bc6ad35..9b78cfa 100644 --- a/lib/data/datasources/credentials_shared_prefs_datasource.dart +++ b/lib/data/datasources/credentials_shared_prefs_datasource.dart @@ -10,42 +10,6 @@ class CredentialsSharedPrefsDataSource implements CredentialsLocalDataSource { CredentialsSharedPrefsDataSource(this._prefs); - // Spotify credentials - @override - Future getSpotifyClientId() async { - return _prefs.getString(AppConstants.spotifyClientIdKey); - } - - @override - Future getSpotifyClientSecret() async { - return _prefs.getString(AppConstants.spotifyClientSecretKey); - } - - @override - Future saveSpotifyCredentials({ - required String clientId, - required String clientSecret, - }) async { - await _prefs.setString(AppConstants.spotifyClientIdKey, clientId); - await _prefs.setString(AppConstants.spotifyClientSecretKey, clientSecret); - } - - @override - Future clearSpotifyCredentials() async { - await _prefs.remove(AppConstants.spotifyClientIdKey); - await _prefs.remove(AppConstants.spotifyClientSecretKey); - } - - @override - Future hasSpotifyCredentials() async { - final clientId = await getSpotifyClientId(); - final clientSecret = await getSpotifyClientSecret(); - return clientId != null && - clientId.isNotEmpty && - clientSecret != null && - clientSecret.isNotEmpty; - } - // LLM credentials @override Future getLlmApiKey() async { @@ -167,6 +131,22 @@ class CredentialsSharedPrefsDataSource implements CredentialsLocalDataSource { ); } + // Custom Spotify Client ID + @override + Future getCustomSpotifyClientId() async { + return _prefs.getString(AppConstants.customSpotifyClientIdKey); + } + + @override + Future saveCustomSpotifyClientId(String clientId) async { + await _prefs.setString(AppConstants.customSpotifyClientIdKey, clientId); + } + + @override + Future clearCustomSpotifyClientId() async { + await _prefs.remove(AppConstants.customSpotifyClientIdKey); + } + // GetSongBPM API @override Future getGetSongBpmApiKey() async { diff --git a/lib/data/repositories/auth_repository_impl.dart b/lib/data/repositories/auth_repository_impl.dart index f2f010d..c296036 100644 --- a/lib/data/repositories/auth_repository_impl.dart +++ b/lib/data/repositories/auth_repository_impl.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'package:dartz/dartz.dart'; import 'package:dio/dio.dart'; import '../../core/config/app_config.dart'; @@ -9,22 +8,21 @@ import '../../core/utils/logger.dart'; import '../../domain/entities/user.dart'; import '../../domain/repositories/auth_repository.dart'; import '../datasources/auth_local_datasource.dart'; -import '../datasources/credentials_local_datasource.dart'; import '../datasources/spotify_api_client.dart'; class AuthRepositoryImpl implements AuthRepository { final AuthLocalDataSource localDataSource; - final CredentialsLocalDataSource credentialsDataSource; final SpotifyApiClient apiClient; final Dio dio; final OAuthService oauthService; + final String clientId; AuthRepositoryImpl({ required this.localDataSource, - required this.credentialsDataSource, required this.apiClient, required this.dio, required this.oauthService, + required this.clientId, }); @override @@ -35,34 +33,18 @@ class AuthRepositoryImpl implements AuthRepository { @override Future> authenticate() async { try { - // Get credentials from secure storage - final clientId = await credentialsDataSource.getSpotifyClientId(); - final clientSecret = await credentialsDataSource.getSpotifyClientSecret(); - - if (clientId == null || - clientId.isEmpty || - clientSecret == null || - clientSecret.isEmpty) { - return const Left( - AuthFailure( - message: - 'Spotify API credentials not configured. Please set up your credentials first.', - ), - ); - } - - // Use OAuth service to get authorization code + // Use OAuth service to get authorization code (with PKCE code_verifier) final authResult = await oauthService.authorize( clientId: clientId, scopes: AppConfig.spotifyScopes, ); return authResult.fold((failure) => Left(failure), (result) async { - // Exchange code for tokens + // Exchange code for tokens using PKCE final tokenResponse = await _exchangeCodeForTokens( result.code, clientId, - clientSecret, + result.codeVerifier, ); AppLogger.info('Authentication completed successfully'); @@ -77,11 +59,9 @@ class AuthRepositoryImpl implements AuthRepository { Future> _exchangeCodeForTokens( String code, String clientId, - String clientSecret, + String codeVerifier, ) async { - final credentials = base64Encode(utf8.encode('$clientId:$clientSecret')); - - AppLogger.info('Exchanging code for tokens...'); + AppLogger.info('Exchanging code for tokens (PKCE)...'); AppLogger.info('Client ID: ${clientId.substring(0, 4)}...${clientId.substring(clientId.length - 4)}'); AppLogger.info('Redirect URI: ${AppConfig.spotifyRedirectUri}'); AppLogger.info('Code: ${code.substring(0, 10)}...'); @@ -93,12 +73,11 @@ class AuthRepositoryImpl implements AuthRepository { 'grant_type': 'authorization_code', 'code': code, 'redirect_uri': AppConfig.spotifyRedirectUri, + 'client_id': clientId, + 'code_verifier': codeVerifier, }, options: Options( - headers: { - 'Authorization': 'Basic $credentials', - 'Content-Type': 'application/x-www-form-urlencoded', - }, + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, contentType: Headers.formUrlEncodedContentType, ), ); @@ -134,29 +113,15 @@ class AuthRepositoryImpl implements AuthRepository { return const Left(AuthFailure(message: 'No refresh token available')); } - // Get credentials from secure storage - final clientId = await credentialsDataSource.getSpotifyClientId(); - final clientSecret = await credentialsDataSource.getSpotifyClientSecret(); - - if (clientId == null || - clientId.isEmpty || - clientSecret == null || - clientSecret.isEmpty) { - return const Left( - AuthFailure(message: 'Spotify API credentials not configured.'), - ); - } - - final credentials = base64Encode(utf8.encode('$clientId:$clientSecret')); - final response = await dio.post( AppConfig.spotifyTokenUrl, - data: {'grant_type': 'refresh_token', 'refresh_token': refreshToken}, + data: { + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken, + 'client_id': clientId, + }, options: Options( - headers: { - 'Authorization': 'Basic $credentials', - 'Content-Type': 'application/x-www-form-urlencoded', - }, + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, contentType: Headers.formUrlEncodedContentType, ), ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9d08a80..046ba6b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -71,21 +71,6 @@ "description": "Snackbar message when error is copied" }, - "reconfigureCredentials": "Reconfigure Credentials", - "@reconfigureCredentials": { - "description": "Button to reconfigure API credentials" - }, - - "apiConfigured": "API configured", - "@apiConfigured": { - "description": "Status text when API is configured" - }, - - "change": "Change", - "@change": { - "description": "Change button text" - }, - "credentialsStayOnDevice": "Your credentials stay on your device", "@credentialsStayOnDevice": { "description": "Security info text" @@ -216,52 +201,6 @@ "description": "Next track button" }, - "setupGuide": "Setup Guide", - "@setupGuide": { - "description": "Setup guide screen title" - }, - - - "setupDescription": "To get started, you'll need to create a Spotify Developer App and enter your credentials.", - "@setupDescription": { - "description": "Setup description text" - }, - - "step1Title": "Go to Spotify Developer Dashboard", - "@step1Title": { - "description": "Setup step 1 title" - }, - - "step2Title": "Create a new app", - "@step2Title": { - "description": "Setup step 2 title" - }, - - "step3Title": "Add redirect URI", - "@step3Title": { - "description": "Setup step 3 title" - }, - - "step4Title": "Copy your credentials", - "@step4Title": { - "description": "Setup step 4 title" - }, - - "clientId": "Client ID", - "@clientId": { - "description": "Client ID field label" - }, - - "clientSecret": "Client Secret", - "@clientSecret": { - "description": "Client Secret field label" - }, - - "saveAndContinue": "Save & Continue", - "@saveAndContinue": { - "description": "Save and continue button" - }, - "errorInvalidClient": "Invalid API credentials. Please check your Client ID and Secret.", "@errorInvalidClient": { "description": "Invalid client error message" @@ -1293,16 +1232,6 @@ "description": "Privacy section description" }, - "privacyOAuthSecurity": "OAuth Security", - "@privacyOAuthSecurity": { - "description": "Privacy section title" - }, - - "privacyOAuthSecurityDesc": "Authentication uses a local HTTP server on ports 8888-8891, 8080, or 3000 with CSRF protection via state parameter.", - "@privacyOAuthSecurityDesc": { - "description": "Privacy section description" - }, - "privacyYouControl": "You Control Your Data", "@privacyYouControl": { "description": "Privacy section title" @@ -1318,261 +1247,6 @@ "description": "Close button text" }, - "welcomeToFullStop": "Welcome to FullStop", - "@welcomeToFullStop": { - "description": "Welcome message on setup screen" - }, - - "updateCredentials": "Update Credentials", - "@updateCredentials": { - "description": "Title when reconfiguring credentials" - }, - - "connectSpotifyToStart": "Connect your Spotify account to get started", - "@connectSpotifyToStart": { - "description": "Subtitle on setup screen" - }, - - "updateSpotifyCredentials": "Update your Spotify API credentials", - "@updateSpotifyCredentials": { - "description": "Subtitle when reconfiguring" - }, - - "credentialsSecurelyStored": "Your credentials are stored securely on your device only", - "@credentialsSecurelyStored": { - "description": "Security notice text" - }, - - "privacyPolicy": "Privacy Policy", - "@privacyPolicy": { - "description": "Privacy policy tooltip" - }, - - "step1CreateApp": "Step 1: Create a Spotify App", - "@step1CreateApp": { - "description": "Setup step 1 title" - }, - - "openDeveloperDashboard": "Open Spotify Developer Dashboard", - "@openDeveloperDashboard": { - "description": "Button to open Spotify developer dashboard" - }, - - "openDeveloperDashboardHint": "Click the button below to open the Spotify Developer Dashboard in your browser.", - "@openDeveloperDashboardHint": { - "description": "Hint for opening developer dashboard" - }, - - "createNewApp": "Create a New App", - "@createNewApp": { - "description": "Instruction to create new app" - }, - - "createNewAppDesc": "Click \"Create App\" and fill in:\n• App name: Any name (e.g., \"My Focus App\")\n• App description: Personal use\n• Website: Leave empty or use any URL\n• Check \"Web API\" option", - "@createNewAppDesc": { - "description": "Instructions for creating new app" - }, - - "createNewAppDescShort": "Click \"Create App\" and fill in the following fields. Check \"Web API\" option.", - "@createNewAppDescShort": { - "description": "Short instructions for creating new app" - }, - - "appNameLabel": "App name", - "@appNameLabel": { - "description": "Label for app name field" - }, - - "appNameCopied": "App name copied!", - "@appNameCopied": { - "description": "Toast when app name is copied" - }, - - "appDescriptionLabel": "App description", - "@appDescriptionLabel": { - "description": "Label for app description field" - }, - - "appDescriptionCopied": "App description copied!", - "@appDescriptionCopied": { - "description": "Toast when app description is copied" - }, - - "redirectUriLabel": "Redirect URI", - "@redirectUriLabel": { - "description": "Label for redirect URI field" - }, - - "setRedirectUri": "Set Redirect URI (IMPORTANT!)", - "@setRedirectUri": { - "description": "Redirect URI setup instruction" - }, - - "setRedirectUriDesc": "In \"Redirect URIs\" field, add this EXACT URI:", - "@setRedirectUriDesc": { - "description": "Redirect URI setup description" - }, - - "copy": "Copy", - "@copy": { - "description": "Copy button text" - }, - - "redirectUriCopied": "Redirect URI copied!", - "@redirectUriCopied": { - "description": "Toast when redirect URI is copied" - }, - - "redirectUriWarning": "Click \"Add\" after pasting, then click \"Save\" at the bottom!", - "@redirectUriWarning": { - "description": "Warning about saving redirect URI" - }, - - "step2EnterCredentials": "Step 2: Enter Your Credentials", - "@step2EnterCredentials": { - "description": "Setup step 2 title" - }, - - "updateYourCredentials": "Update Your Credentials", - "@updateYourCredentials": { - "description": "Title when updating credentials" - }, - - "findCredentialsHint": "Find your credentials in the app settings page on the Spotify Developer Dashboard.", - "@findCredentialsHint": { - "description": "Hint for finding credentials" - }, - - "modifyCredentialsHint": "Modify the credentials below. Leave unchanged if correct.", - "@modifyCredentialsHint": { - "description": "Hint when modifying credentials" - }, - - "enterClientId": "Enter your Client ID", - "@enterClientId": { - "description": "Client ID field placeholder" - }, - - "clientIdRequired": "Client ID is required", - "@clientIdRequired": { - "description": "Validation error for empty client ID" - }, - - "clientIdTooShort": "Client ID seems too short", - "@clientIdTooShort": { - "description": "Validation error for short client ID" - }, - - "enterClientSecret": "Enter your Client Secret", - "@enterClientSecret": { - "description": "Client Secret field placeholder" - }, - - "clientSecretRequired": "Client Secret is required", - "@clientSecretRequired": { - "description": "Validation error for empty client secret" - }, - - "clientSecretTooShort": "Client Secret seems too short", - "@clientSecretTooShort": { - "description": "Validation error for short client secret" - }, - - "whereToFindCredentials": "Where to find these?", - "@whereToFindCredentials": { - "description": "Help section title" - }, - - "whereToFindCredentialsDesc": "In your Spotify app's Settings page, you'll see Client ID. Click \"View client secret\" to reveal the secret.", - "@whereToFindCredentialsDesc": { - "description": "Help section description" - }, - - "step3ReadyToConnect": "Step 3: Ready to Connect", - "@step3ReadyToConnect": { - "description": "Setup step 3 title" - }, - - "credentialsSaved": "Credentials Saved!", - "@credentialsSaved": { - "description": "Success message when credentials are saved" - }, - - "waitingForCredentials": "Waiting for Credentials", - "@waitingForCredentials": { - "description": "Waiting state message" - }, - - "credentialsSavedDesc": "Your Spotify API credentials have been securely stored. You can now connect to Spotify.", - "@credentialsSavedDesc": { - "description": "Success description" - }, - - "waitingForCredentialsDesc": "Please go back to Step 2 and enter your credentials.", - "@waitingForCredentialsDesc": { - "description": "Hint when waiting for credentials" - }, - - "spotifyPremiumRequired": "Spotify Premium Required", - "@spotifyPremiumRequired": { - "description": "Premium requirement notice" - }, - - "spotifyPremiumRequiredDesc": "This app requires Spotify Premium for playback control features.", - "@spotifyPremiumRequiredDesc": { - "description": "Premium requirement description" - }, - - "back": "Back", - "@back": { - "description": "Back button text" - }, - - "nextEnterCredentials": "Next: Enter Credentials", - "@nextEnterCredentials": { - "description": "Next button text for step 1" - }, - - "saveCredentials": "Save Credentials", - "@saveCredentials": { - "description": "Save credentials button text" - }, - - "updateCredentialsButton": "Update Credentials", - "@updateCredentialsButton": { - "description": "Update credentials button text" - }, - - "connectToSpotify": "Connect to Spotify", - "@connectToSpotify": { - "description": "Final connect button text" - }, - - "reconfigureApiCredentials": "Reconfigure API Credentials", - "@reconfigureApiCredentials": { - "description": "Button to reconfigure API" - }, - - "changeClientIdSecret": "Change your Client ID and Secret", - "@changeClientIdSecret": { - "description": "Subtitle for reconfigure option" - }, - - "reconfigureDialogTitle": "Reconfigure API Credentials", - "@reconfigureDialogTitle": { - "description": "Dialog title for reconfigure" - }, - - "reconfigureDialogContent": "This will clear your current API credentials and log you out.\n\nYou will need to enter your Client ID and Secret again.", - "@reconfigureDialogContent": { - "description": "Dialog content for reconfigure" - }, - - "reconfigure": "Reconfigure", - "@reconfigure": { - "description": "Reconfigure button text" - }, - "redirectUriForSpotifyApp": "Redirect URI for Spotify App", "@redirectUriForSpotifyApp": { "description": "Label for redirect URI info" @@ -1591,11 +1265,6 @@ } }, - "notConfigured": "Not configured", - "@notConfigured": { - "description": "Status when API is not configured" - }, - "llmOpenAiCompatible": "OpenAI-Compatible API", "@llmOpenAiCompatible": { "description": "LLM section title" @@ -1710,11 +1379,6 @@ } }, - "connectionFailed": "Connection Failed", - "@connectionFailed": { - "description": "Connection failed dialog title" - }, - "request": "Request:", "@request": { "description": "Request label in error dialog" @@ -1763,5 +1427,45 @@ "exitMiniPlayer": "Exit Mini Player", "@exitMiniPlayer": { "description": "Tooltip for exiting mini player mode" + }, + + "advancedOptions": "Advanced Options", + "@advancedOptions": { + "description": "Advanced options section title" + }, + + "customClientId": "Custom Client ID", + "@customClientId": { + "description": "Custom Spotify Client ID field label" + }, + + "customClientIdDescription": "The shared Client ID may be rate-limited by Spotify under heavy usage. You can create your own app on Spotify Developer Dashboard and use your own Client ID.", + "@customClientIdDescription": { + "description": "Description explaining why custom Client ID may be needed" + }, + + "customClientIdHint": "Enter your Spotify Client ID", + "@customClientIdHint": { + "description": "Hint for custom Client ID input" + }, + + "customClientIdSaved": "Custom Client ID saved", + "@customClientIdSaved": { + "description": "Toast when custom Client ID is saved" + }, + + "customClientIdCleared": "Restored to default Client ID", + "@customClientIdCleared": { + "description": "Toast when custom Client ID is cleared" + }, + + "useDefaultClient": "Use Default", + "@useDefaultClient": { + "description": "Button to clear custom Client ID and use default" + }, + + "customClientIdReauthRequired": "Switching Client ID requires re-login. Log out now?", + "@customClientIdReauthRequired": { + "description": "Dialog message when Client ID changes requiring re-authentication" } } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index f825e70..c89e3f0 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -29,12 +29,6 @@ "errorCopied": "エラーメッセージをクリップボードにコピーしました", - "reconfigureCredentials": "認証情報を再設定", - - "apiConfigured": "API設定済み", - - "change": "変更", - "credentialsStayOnDevice": "認証情報はお使いのデバイスにのみ保存されます", "controlsExistingSession": "既存のSpotifyセッションを操作します", @@ -87,25 +81,6 @@ "next": "次へ", - "setupGuide": "セットアップガイド", - - - "setupDescription": "まず、Spotify開発者アプリを作成し、認証情報を入力する必要があります。", - - "step1Title": "Spotify開発者ダッシュボードにアクセス", - - "step2Title": "新しいアプリを作成", - - "step3Title": "リダイレクトURIを追加", - - "step4Title": "認証情報をコピー", - - "clientId": "Client ID", - - "clientSecret": "Client Secret", - - "saveAndContinue": "保存して続行", - "errorInvalidClient": "API認証情報が無効です。Client IDとSecretを確認してください。", "errorRedirectUri": "リダイレクトURIが一致しません!SpotifyアプリにリダイレクトURIが正しく設定されていることを確認してください。", @@ -417,126 +392,18 @@ "privacyNoDataCollectionDesc": "使用状況分析、再生履歴、個人情報を収集、保存、送信することはありません。", - "privacyOAuthSecurity": "OAuthセキュリティ", - - "privacyOAuthSecurityDesc": "認証はポート8888-8891、8080、または3000のローカルHTTPサーバーを使用し、stateパラメータによるCSRF保護を行います。", - "privacyYouControl": "データはあなたの管理下に", "privacyYouControlDesc": "設定ページからいつでもクレデンシャルを削除できます。アプリをアンインストールすると、すべての保存データが削除されます。", "close": "閉じる", - "welcomeToFullStop": "FullStopへようこそ", - - "updateCredentials": "クレデンシャルを更新", - - "connectSpotifyToStart": "Spotifyアカウントを接続して開始", - - "updateSpotifyCredentials": "Spotify APIクレデンシャルを更新", - - "credentialsSecurelyStored": "クレデンシャルはデバイスにのみ安全に保存されます", - - "privacyPolicy": "プライバシーポリシー", - - "step1CreateApp": "ステップ1:Spotifyアプリを作成", - - "openDeveloperDashboard": "Spotify開発者ダッシュボードを開く", - - "openDeveloperDashboardHint": "下のボタンをクリックして、ブラウザでSpotify開発者ダッシュボードを開きます。", - - "createNewApp": "新しいアプリを作成", - - "createNewAppDesc": "「Create App」をクリックして入力:\n• App name:任意の名前(例:「My Focus App」)\n• App description:個人使用\n• Website:空欄またはURLを入力\n• 「Web API」オプションをチェック", - - "createNewAppDescShort": "「Create App」をクリックして以下のフィールドを入力し、「Web API」オプションをチェック。", - - "appNameLabel": "App name(アプリ名)", - - "appNameCopied": "アプリ名をコピーしました!", - - "appDescriptionLabel": "App description(アプリの説明)", - - "appDescriptionCopied": "アプリの説明をコピーしました!", - - "redirectUriLabel": "Redirect URI(リダイレクトURI)", - - "setRedirectUri": "リダイレクトURIを設定(重要!)", - - "setRedirectUriDesc": "「Redirect URIs」フィールドに以下のURIを追加:", - - "copy": "コピー", - - "redirectUriCopied": "リダイレクトURIをコピーしました!", - - "redirectUriWarning": "貼り付け後「Add」をクリックし、下の「Save」をクリック!", - - "step2EnterCredentials": "ステップ2:クレデンシャルを入力", - - "updateYourCredentials": "クレデンシャルを更新", - - "findCredentialsHint": "Spotify開発者ダッシュボードのアプリ設定ページでクレデンシャルを確認できます。", - - "modifyCredentialsHint": "以下のクレデンシャルを変更してください。正しい場合は変更不要です。", - - "enterClientId": "Client IDを入力", - - "clientIdRequired": "Client IDは必須です", - - "clientIdTooShort": "Client IDが短すぎます", - - "enterClientSecret": "Client Secretを入力", - - "clientSecretRequired": "Client Secretは必須です", - - "clientSecretTooShort": "Client Secretが短すぎます", - - "whereToFindCredentials": "どこで見つけられますか?", - - "whereToFindCredentialsDesc": "SpotifyアプリのSettingsページでClient IDが表示されます。「View client secret」をクリックしてシークレットを表示します。", - - "step3ReadyToConnect": "ステップ3:接続準備完了", - - "credentialsSaved": "クレデンシャルを保存しました!", - - "waitingForCredentials": "クレデンシャルを待機中", - - "credentialsSavedDesc": "Spotify APIクレデンシャルが安全に保存されました。Spotifyに接続できます。", - - "waitingForCredentialsDesc": "ステップ2に戻ってクレデンシャルを入力してください。", - - "spotifyPremiumRequired": "Spotify Premiumが必要", - - "spotifyPremiumRequiredDesc": "このアプリの再生コントロール機能にはSpotify Premiumが必要です。", - - "back": "戻る", - - "nextEnterCredentials": "次へ:クレデンシャルを入力", - - "saveCredentials": "クレデンシャルを保存", - - "updateCredentialsButton": "クレデンシャルを更新", - - "connectToSpotify": "Spotifyに接続", - - "reconfigureApiCredentials": "APIクレデンシャルを再設定", - - "changeClientIdSecret": "Client IDとSecretを変更", - - "reconfigureDialogTitle": "APIクレデンシャルを再設定", - - "reconfigureDialogContent": "現在のAPIクレデンシャルを削除してログアウトします。\n\nClient IDとSecretを再入力する必要があります。", - - "reconfigure": "再設定", - "redirectUriForSpotifyApp": "SpotifyアプリのリダイレクトURI", "spotifyApi": "Spotify API", "configured": "設定済み({clientId})", - "notConfigured": "未設定", - "llmOpenAiCompatible": "OpenAI互換API", "llmOpenAiCompatibleDesc": "OpenAI、Ollama、その他のOpenAI互換APIで動作します。\nOllamaなどのローカルモデルではAPI Keyは不要です。", @@ -599,5 +466,21 @@ "miniPlayer": "ミニプレーヤー", - "exitMiniPlayer": "ミニプレーヤーを終了" + "exitMiniPlayer": "ミニプレーヤーを終了", + + "advancedOptions": "詳細オプション", + + "customClientId": "カスタム Client ID", + + "customClientIdDescription": "共有 Client ID は Spotify のレート制限を受ける可能性があります。Spotify Developer Dashboard で独自のアプリを作成し、自分の Client ID を使用できます。", + + "customClientIdHint": "Spotify Client ID を入力", + + "customClientIdSaved": "カスタム Client ID を保存しました", + + "customClientIdCleared": "デフォルトの Client ID に戻しました", + + "useDefaultClient": "デフォルトを使用", + + "customClientIdReauthRequired": "Client ID の切り替えには再ログインが必要です。今すぐログアウトしますか?" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 65e618e..32b6874 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -172,7 +172,7 @@ abstract class AppLocalizations { /// **'Click to cancel and return to login screen'** String get cancelHint; - /// Connection failed dialog title + /// Error title /// /// In en, this message translates to: /// **'Connection Failed'** @@ -184,24 +184,6 @@ abstract class AppLocalizations { /// **'Error message copied to clipboard'** String get errorCopied; - /// Button to reconfigure API credentials - /// - /// In en, this message translates to: - /// **'Reconfigure Credentials'** - String get reconfigureCredentials; - - /// Status text when API is configured - /// - /// In en, this message translates to: - /// **'API configured'** - String get apiConfigured; - - /// Change button text - /// - /// In en, this message translates to: - /// **'Change'** - String get change; - /// Security info text /// /// In en, this message translates to: @@ -358,60 +340,6 @@ abstract class AppLocalizations { /// **'Next'** String get next; - /// Setup guide screen title - /// - /// In en, this message translates to: - /// **'Setup Guide'** - String get setupGuide; - - /// Setup description text - /// - /// In en, this message translates to: - /// **'To get started, you\'ll need to create a Spotify Developer App and enter your credentials.'** - String get setupDescription; - - /// Setup step 1 title - /// - /// In en, this message translates to: - /// **'Go to Spotify Developer Dashboard'** - String get step1Title; - - /// Setup step 2 title - /// - /// In en, this message translates to: - /// **'Create a new app'** - String get step2Title; - - /// Setup step 3 title - /// - /// In en, this message translates to: - /// **'Add redirect URI'** - String get step3Title; - - /// Setup step 4 title - /// - /// In en, this message translates to: - /// **'Copy your credentials'** - String get step4Title; - - /// Client ID field label - /// - /// In en, this message translates to: - /// **'Client ID'** - String get clientId; - - /// Client Secret field label - /// - /// In en, this message translates to: - /// **'Client Secret'** - String get clientSecret; - - /// Save and continue button - /// - /// In en, this message translates to: - /// **'Save & Continue'** - String get saveAndContinue; - /// Invalid client error message /// /// In en, this message translates to: @@ -1540,18 +1468,6 @@ abstract class AppLocalizations { /// **'We do not collect, store, or transmit any usage analytics, listening history, or personal information.'** String get privacyNoDataCollectionDesc; - /// Privacy section title - /// - /// In en, this message translates to: - /// **'OAuth Security'** - String get privacyOAuthSecurity; - - /// Privacy section description - /// - /// In en, this message translates to: - /// **'Authentication uses a local HTTP server on ports 8888-8891, 8080, or 3000 with CSRF protection via state parameter.'** - String get privacyOAuthSecurityDesc; - /// Privacy section title /// /// In en, this message translates to: @@ -1570,312 +1486,6 @@ abstract class AppLocalizations { /// **'Close'** String get close; - /// Welcome message on setup screen - /// - /// In en, this message translates to: - /// **'Welcome to FullStop'** - String get welcomeToFullStop; - - /// Title when reconfiguring credentials - /// - /// In en, this message translates to: - /// **'Update Credentials'** - String get updateCredentials; - - /// Subtitle on setup screen - /// - /// In en, this message translates to: - /// **'Connect your Spotify account to get started'** - String get connectSpotifyToStart; - - /// Subtitle when reconfiguring - /// - /// In en, this message translates to: - /// **'Update your Spotify API credentials'** - String get updateSpotifyCredentials; - - /// Security notice text - /// - /// In en, this message translates to: - /// **'Your credentials are stored securely on your device only'** - String get credentialsSecurelyStored; - - /// Privacy policy tooltip - /// - /// In en, this message translates to: - /// **'Privacy Policy'** - String get privacyPolicy; - - /// Setup step 1 title - /// - /// In en, this message translates to: - /// **'Step 1: Create a Spotify App'** - String get step1CreateApp; - - /// Button to open Spotify developer dashboard - /// - /// In en, this message translates to: - /// **'Open Spotify Developer Dashboard'** - String get openDeveloperDashboard; - - /// Hint for opening developer dashboard - /// - /// In en, this message translates to: - /// **'Click the button below to open the Spotify Developer Dashboard in your browser.'** - String get openDeveloperDashboardHint; - - /// Instruction to create new app - /// - /// In en, this message translates to: - /// **'Create a New App'** - String get createNewApp; - - /// Instructions for creating new app - /// - /// In en, this message translates to: - /// **'Click \"Create App\" and fill in:\n• App name: Any name (e.g., \"My Focus App\")\n• App description: Personal use\n• Website: Leave empty or use any URL\n• Check \"Web API\" option'** - String get createNewAppDesc; - - /// Short instructions for creating new app - /// - /// In en, this message translates to: - /// **'Click \"Create App\" and fill in the following fields. Check \"Web API\" option.'** - String get createNewAppDescShort; - - /// Label for app name field - /// - /// In en, this message translates to: - /// **'App name'** - String get appNameLabel; - - /// Toast when app name is copied - /// - /// In en, this message translates to: - /// **'App name copied!'** - String get appNameCopied; - - /// Label for app description field - /// - /// In en, this message translates to: - /// **'App description'** - String get appDescriptionLabel; - - /// Toast when app description is copied - /// - /// In en, this message translates to: - /// **'App description copied!'** - String get appDescriptionCopied; - - /// Label for redirect URI field - /// - /// In en, this message translates to: - /// **'Redirect URI'** - String get redirectUriLabel; - - /// Redirect URI setup instruction - /// - /// In en, this message translates to: - /// **'Set Redirect URI (IMPORTANT!)'** - String get setRedirectUri; - - /// Redirect URI setup description - /// - /// In en, this message translates to: - /// **'In \"Redirect URIs\" field, add this EXACT URI:'** - String get setRedirectUriDesc; - - /// Copy button text - /// - /// In en, this message translates to: - /// **'Copy'** - String get copy; - - /// Toast when redirect URI is copied - /// - /// In en, this message translates to: - /// **'Redirect URI copied!'** - String get redirectUriCopied; - - /// Warning about saving redirect URI - /// - /// In en, this message translates to: - /// **'Click \"Add\" after pasting, then click \"Save\" at the bottom!'** - String get redirectUriWarning; - - /// Setup step 2 title - /// - /// In en, this message translates to: - /// **'Step 2: Enter Your Credentials'** - String get step2EnterCredentials; - - /// Title when updating credentials - /// - /// In en, this message translates to: - /// **'Update Your Credentials'** - String get updateYourCredentials; - - /// Hint for finding credentials - /// - /// In en, this message translates to: - /// **'Find your credentials in the app settings page on the Spotify Developer Dashboard.'** - String get findCredentialsHint; - - /// Hint when modifying credentials - /// - /// In en, this message translates to: - /// **'Modify the credentials below. Leave unchanged if correct.'** - String get modifyCredentialsHint; - - /// Client ID field placeholder - /// - /// In en, this message translates to: - /// **'Enter your Client ID'** - String get enterClientId; - - /// Validation error for empty client ID - /// - /// In en, this message translates to: - /// **'Client ID is required'** - String get clientIdRequired; - - /// Validation error for short client ID - /// - /// In en, this message translates to: - /// **'Client ID seems too short'** - String get clientIdTooShort; - - /// Client Secret field placeholder - /// - /// In en, this message translates to: - /// **'Enter your Client Secret'** - String get enterClientSecret; - - /// Validation error for empty client secret - /// - /// In en, this message translates to: - /// **'Client Secret is required'** - String get clientSecretRequired; - - /// Validation error for short client secret - /// - /// In en, this message translates to: - /// **'Client Secret seems too short'** - String get clientSecretTooShort; - - /// Help section title - /// - /// In en, this message translates to: - /// **'Where to find these?'** - String get whereToFindCredentials; - - /// Help section description - /// - /// In en, this message translates to: - /// **'In your Spotify app\'s Settings page, you\'ll see Client ID. Click \"View client secret\" to reveal the secret.'** - String get whereToFindCredentialsDesc; - - /// Setup step 3 title - /// - /// In en, this message translates to: - /// **'Step 3: Ready to Connect'** - String get step3ReadyToConnect; - - /// Success message when credentials are saved - /// - /// In en, this message translates to: - /// **'Credentials Saved!'** - String get credentialsSaved; - - /// Waiting state message - /// - /// In en, this message translates to: - /// **'Waiting for Credentials'** - String get waitingForCredentials; - - /// Success description - /// - /// In en, this message translates to: - /// **'Your Spotify API credentials have been securely stored. You can now connect to Spotify.'** - String get credentialsSavedDesc; - - /// Hint when waiting for credentials - /// - /// In en, this message translates to: - /// **'Please go back to Step 2 and enter your credentials.'** - String get waitingForCredentialsDesc; - - /// Premium requirement notice - /// - /// In en, this message translates to: - /// **'Spotify Premium Required'** - String get spotifyPremiumRequired; - - /// Premium requirement description - /// - /// In en, this message translates to: - /// **'This app requires Spotify Premium for playback control features.'** - String get spotifyPremiumRequiredDesc; - - /// Back button text - /// - /// In en, this message translates to: - /// **'Back'** - String get back; - - /// Next button text for step 1 - /// - /// In en, this message translates to: - /// **'Next: Enter Credentials'** - String get nextEnterCredentials; - - /// Save credentials button text - /// - /// In en, this message translates to: - /// **'Save Credentials'** - String get saveCredentials; - - /// Update credentials button text - /// - /// In en, this message translates to: - /// **'Update Credentials'** - String get updateCredentialsButton; - - /// Final connect button text - /// - /// In en, this message translates to: - /// **'Connect to Spotify'** - String get connectToSpotify; - - /// Button to reconfigure API - /// - /// In en, this message translates to: - /// **'Reconfigure API Credentials'** - String get reconfigureApiCredentials; - - /// Subtitle for reconfigure option - /// - /// In en, this message translates to: - /// **'Change your Client ID and Secret'** - String get changeClientIdSecret; - - /// Dialog title for reconfigure - /// - /// In en, this message translates to: - /// **'Reconfigure API Credentials'** - String get reconfigureDialogTitle; - - /// Dialog content for reconfigure - /// - /// In en, this message translates to: - /// **'This will clear your current API credentials and log you out.\n\nYou will need to enter your Client ID and Secret again.'** - String get reconfigureDialogContent; - - /// Reconfigure button text - /// - /// In en, this message translates to: - /// **'Reconfigure'** - String get reconfigure; - /// Label for redirect URI info /// /// In en, this message translates to: @@ -1894,12 +1504,6 @@ abstract class AppLocalizations { /// **'Configured ({clientId})'** String configured(String clientId); - /// Status when API is not configured - /// - /// In en, this message translates to: - /// **'Not configured'** - String get notConfigured; - /// LLM section title /// /// In en, this message translates to: @@ -2085,6 +1689,54 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Exit Mini Player'** String get exitMiniPlayer; + + /// Advanced options section title + /// + /// In en, this message translates to: + /// **'Advanced Options'** + String get advancedOptions; + + /// Custom Spotify Client ID field label + /// + /// In en, this message translates to: + /// **'Custom Client ID'** + String get customClientId; + + /// Description explaining why custom Client ID may be needed + /// + /// In en, this message translates to: + /// **'The shared Client ID may be rate-limited by Spotify under heavy usage. You can create your own app on Spotify Developer Dashboard and use your own Client ID.'** + String get customClientIdDescription; + + /// Hint for custom Client ID input + /// + /// In en, this message translates to: + /// **'Enter your Spotify Client ID'** + String get customClientIdHint; + + /// Toast when custom Client ID is saved + /// + /// In en, this message translates to: + /// **'Custom Client ID saved'** + String get customClientIdSaved; + + /// Toast when custom Client ID is cleared + /// + /// In en, this message translates to: + /// **'Restored to default Client ID'** + String get customClientIdCleared; + + /// Button to clear custom Client ID and use default + /// + /// In en, this message translates to: + /// **'Use Default'** + String get useDefaultClient; + + /// Dialog message when Client ID changes requiring re-authentication + /// + /// In en, this message translates to: + /// **'Switching Client ID requires re-login. Log out now?'** + String get customClientIdReauthRequired; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index fdcca59..82609c9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -53,15 +53,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get errorCopied => 'Error message copied to clipboard'; - @override - String get reconfigureCredentials => 'Reconfigure Credentials'; - - @override - String get apiConfigured => 'API configured'; - - @override - String get change => 'Change'; - @override String get credentialsStayOnDevice => 'Your credentials stay on your device'; @@ -141,34 +132,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get next => 'Next'; - @override - String get setupGuide => 'Setup Guide'; - - @override - String get setupDescription => - 'To get started, you\'ll need to create a Spotify Developer App and enter your credentials.'; - - @override - String get step1Title => 'Go to Spotify Developer Dashboard'; - - @override - String get step2Title => 'Create a new app'; - - @override - String get step3Title => 'Add redirect URI'; - - @override - String get step4Title => 'Copy your credentials'; - - @override - String get clientId => 'Client ID'; - - @override - String get clientSecret => 'Client Secret'; - - @override - String get saveAndContinue => 'Save & Continue'; - @override String get errorInvalidClient => 'Invalid API credentials. Please check your Client ID and Secret.'; @@ -816,13 +779,6 @@ class AppLocalizationsEn extends AppLocalizations { String get privacyNoDataCollectionDesc => 'We do not collect, store, or transmit any usage analytics, listening history, or personal information.'; - @override - String get privacyOAuthSecurity => 'OAuth Security'; - - @override - String get privacyOAuthSecurityDesc => - 'Authentication uses a local HTTP server on ports 8888-8891, 8080, or 3000 with CSRF protection via state parameter.'; - @override String get privacyYouControl => 'You Control Your Data'; @@ -833,173 +789,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get close => 'Close'; - @override - String get welcomeToFullStop => 'Welcome to FullStop'; - - @override - String get updateCredentials => 'Update Credentials'; - - @override - String get connectSpotifyToStart => - 'Connect your Spotify account to get started'; - - @override - String get updateSpotifyCredentials => 'Update your Spotify API credentials'; - - @override - String get credentialsSecurelyStored => - 'Your credentials are stored securely on your device only'; - - @override - String get privacyPolicy => 'Privacy Policy'; - - @override - String get step1CreateApp => 'Step 1: Create a Spotify App'; - - @override - String get openDeveloperDashboard => 'Open Spotify Developer Dashboard'; - - @override - String get openDeveloperDashboardHint => - 'Click the button below to open the Spotify Developer Dashboard in your browser.'; - - @override - String get createNewApp => 'Create a New App'; - - @override - String get createNewAppDesc => - 'Click \"Create App\" and fill in:\n• App name: Any name (e.g., \"My Focus App\")\n• App description: Personal use\n• Website: Leave empty or use any URL\n• Check \"Web API\" option'; - - @override - String get createNewAppDescShort => - 'Click \"Create App\" and fill in the following fields. Check \"Web API\" option.'; - - @override - String get appNameLabel => 'App name'; - - @override - String get appNameCopied => 'App name copied!'; - - @override - String get appDescriptionLabel => 'App description'; - - @override - String get appDescriptionCopied => 'App description copied!'; - - @override - String get redirectUriLabel => 'Redirect URI'; - - @override - String get setRedirectUri => 'Set Redirect URI (IMPORTANT!)'; - - @override - String get setRedirectUriDesc => - 'In \"Redirect URIs\" field, add this EXACT URI:'; - - @override - String get copy => 'Copy'; - - @override - String get redirectUriCopied => 'Redirect URI copied!'; - - @override - String get redirectUriWarning => - 'Click \"Add\" after pasting, then click \"Save\" at the bottom!'; - - @override - String get step2EnterCredentials => 'Step 2: Enter Your Credentials'; - - @override - String get updateYourCredentials => 'Update Your Credentials'; - - @override - String get findCredentialsHint => - 'Find your credentials in the app settings page on the Spotify Developer Dashboard.'; - - @override - String get modifyCredentialsHint => - 'Modify the credentials below. Leave unchanged if correct.'; - - @override - String get enterClientId => 'Enter your Client ID'; - - @override - String get clientIdRequired => 'Client ID is required'; - - @override - String get clientIdTooShort => 'Client ID seems too short'; - - @override - String get enterClientSecret => 'Enter your Client Secret'; - - @override - String get clientSecretRequired => 'Client Secret is required'; - - @override - String get clientSecretTooShort => 'Client Secret seems too short'; - - @override - String get whereToFindCredentials => 'Where to find these?'; - - @override - String get whereToFindCredentialsDesc => - 'In your Spotify app\'s Settings page, you\'ll see Client ID. Click \"View client secret\" to reveal the secret.'; - - @override - String get step3ReadyToConnect => 'Step 3: Ready to Connect'; - - @override - String get credentialsSaved => 'Credentials Saved!'; - - @override - String get waitingForCredentials => 'Waiting for Credentials'; - - @override - String get credentialsSavedDesc => - 'Your Spotify API credentials have been securely stored. You can now connect to Spotify.'; - - @override - String get waitingForCredentialsDesc => - 'Please go back to Step 2 and enter your credentials.'; - - @override - String get spotifyPremiumRequired => 'Spotify Premium Required'; - - @override - String get spotifyPremiumRequiredDesc => - 'This app requires Spotify Premium for playback control features.'; - - @override - String get back => 'Back'; - - @override - String get nextEnterCredentials => 'Next: Enter Credentials'; - - @override - String get saveCredentials => 'Save Credentials'; - - @override - String get updateCredentialsButton => 'Update Credentials'; - - @override - String get connectToSpotify => 'Connect to Spotify'; - - @override - String get reconfigureApiCredentials => 'Reconfigure API Credentials'; - - @override - String get changeClientIdSecret => 'Change your Client ID and Secret'; - - @override - String get reconfigureDialogTitle => 'Reconfigure API Credentials'; - - @override - String get reconfigureDialogContent => - 'This will clear your current API credentials and log you out.\n\nYou will need to enter your Client ID and Secret again.'; - - @override - String get reconfigure => 'Reconfigure'; - @override String get redirectUriForSpotifyApp => 'Redirect URI for Spotify App'; @@ -1011,9 +800,6 @@ class AppLocalizationsEn extends AppLocalizations { return 'Configured ($clientId)'; } - @override - String get notConfigured => 'Not configured'; - @override String get llmOpenAiCompatible => 'OpenAI-Compatible API'; @@ -1120,4 +906,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get exitMiniPlayer => 'Exit Mini Player'; + + @override + String get advancedOptions => 'Advanced Options'; + + @override + String get customClientId => 'Custom Client ID'; + + @override + String get customClientIdDescription => + 'The shared Client ID may be rate-limited by Spotify under heavy usage. You can create your own app on Spotify Developer Dashboard and use your own Client ID.'; + + @override + String get customClientIdHint => 'Enter your Spotify Client ID'; + + @override + String get customClientIdSaved => 'Custom Client ID saved'; + + @override + String get customClientIdCleared => 'Restored to default Client ID'; + + @override + String get useDefaultClient => 'Use Default'; + + @override + String get customClientIdReauthRequired => + 'Switching Client ID requires re-login. Log out now?'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 25811ed..e842730 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -50,15 +50,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get errorCopied => 'エラーメッセージをクリップボードにコピーしました'; - @override - String get reconfigureCredentials => '認証情報を再設定'; - - @override - String get apiConfigured => 'API設定済み'; - - @override - String get change => '変更'; - @override String get credentialsStayOnDevice => '認証情報はお使いのデバイスにのみ保存されます'; @@ -137,33 +128,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get next => '次へ'; - @override - String get setupGuide => 'セットアップガイド'; - - @override - String get setupDescription => 'まず、Spotify開発者アプリを作成し、認証情報を入力する必要があります。'; - - @override - String get step1Title => 'Spotify開発者ダッシュボードにアクセス'; - - @override - String get step2Title => '新しいアプリを作成'; - - @override - String get step3Title => 'リダイレクトURIを追加'; - - @override - String get step4Title => '認証情報をコピー'; - - @override - String get clientId => 'Client ID'; - - @override - String get clientSecret => 'Client Secret'; - - @override - String get saveAndContinue => '保存して続行'; - @override String get errorInvalidClient => 'API認証情報が無効です。Client IDとSecretを確認してください。'; @@ -804,13 +768,6 @@ class AppLocalizationsJa extends AppLocalizations { String get privacyNoDataCollectionDesc => '使用状況分析、再生履歴、個人情報を収集、保存、送信することはありません。'; - @override - String get privacyOAuthSecurity => 'OAuthセキュリティ'; - - @override - String get privacyOAuthSecurityDesc => - '認証はポート8888-8891、8080、または3000のローカルHTTPサーバーを使用し、stateパラメータによるCSRF保護を行います。'; - @override String get privacyYouControl => 'データはあなたの管理下に'; @@ -821,167 +778,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get close => '閉じる'; - @override - String get welcomeToFullStop => 'FullStopへようこそ'; - - @override - String get updateCredentials => 'クレデンシャルを更新'; - - @override - String get connectSpotifyToStart => 'Spotifyアカウントを接続して開始'; - - @override - String get updateSpotifyCredentials => 'Spotify APIクレデンシャルを更新'; - - @override - String get credentialsSecurelyStored => 'クレデンシャルはデバイスにのみ安全に保存されます'; - - @override - String get privacyPolicy => 'プライバシーポリシー'; - - @override - String get step1CreateApp => 'ステップ1:Spotifyアプリを作成'; - - @override - String get openDeveloperDashboard => 'Spotify開発者ダッシュボードを開く'; - - @override - String get openDeveloperDashboardHint => - '下のボタンをクリックして、ブラウザでSpotify開発者ダッシュボードを開きます。'; - - @override - String get createNewApp => '新しいアプリを作成'; - - @override - String get createNewAppDesc => - '「Create App」をクリックして入力:\n• App name:任意の名前(例:「My Focus App」)\n• App description:個人使用\n• Website:空欄またはURLを入力\n• 「Web API」オプションをチェック'; - - @override - String get createNewAppDescShort => - '「Create App」をクリックして以下のフィールドを入力し、「Web API」オプションをチェック。'; - - @override - String get appNameLabel => 'App name(アプリ名)'; - - @override - String get appNameCopied => 'アプリ名をコピーしました!'; - - @override - String get appDescriptionLabel => 'App description(アプリの説明)'; - - @override - String get appDescriptionCopied => 'アプリの説明をコピーしました!'; - - @override - String get redirectUriLabel => 'Redirect URI(リダイレクトURI)'; - - @override - String get setRedirectUri => 'リダイレクトURIを設定(重要!)'; - - @override - String get setRedirectUriDesc => '「Redirect URIs」フィールドに以下のURIを追加:'; - - @override - String get copy => 'コピー'; - - @override - String get redirectUriCopied => 'リダイレクトURIをコピーしました!'; - - @override - String get redirectUriWarning => '貼り付け後「Add」をクリックし、下の「Save」をクリック!'; - - @override - String get step2EnterCredentials => 'ステップ2:クレデンシャルを入力'; - - @override - String get updateYourCredentials => 'クレデンシャルを更新'; - - @override - String get findCredentialsHint => - 'Spotify開発者ダッシュボードのアプリ設定ページでクレデンシャルを確認できます。'; - - @override - String get modifyCredentialsHint => '以下のクレデンシャルを変更してください。正しい場合は変更不要です。'; - - @override - String get enterClientId => 'Client IDを入力'; - - @override - String get clientIdRequired => 'Client IDは必須です'; - - @override - String get clientIdTooShort => 'Client IDが短すぎます'; - - @override - String get enterClientSecret => 'Client Secretを入力'; - - @override - String get clientSecretRequired => 'Client Secretは必須です'; - - @override - String get clientSecretTooShort => 'Client Secretが短すぎます'; - - @override - String get whereToFindCredentials => 'どこで見つけられますか?'; - - @override - String get whereToFindCredentialsDesc => - 'SpotifyアプリのSettingsページでClient IDが表示されます。「View client secret」をクリックしてシークレットを表示します。'; - - @override - String get step3ReadyToConnect => 'ステップ3:接続準備完了'; - - @override - String get credentialsSaved => 'クレデンシャルを保存しました!'; - - @override - String get waitingForCredentials => 'クレデンシャルを待機中'; - - @override - String get credentialsSavedDesc => - 'Spotify APIクレデンシャルが安全に保存されました。Spotifyに接続できます。'; - - @override - String get waitingForCredentialsDesc => 'ステップ2に戻ってクレデンシャルを入力してください。'; - - @override - String get spotifyPremiumRequired => 'Spotify Premiumが必要'; - - @override - String get spotifyPremiumRequiredDesc => - 'このアプリの再生コントロール機能にはSpotify Premiumが必要です。'; - - @override - String get back => '戻る'; - - @override - String get nextEnterCredentials => '次へ:クレデンシャルを入力'; - - @override - String get saveCredentials => 'クレデンシャルを保存'; - - @override - String get updateCredentialsButton => 'クレデンシャルを更新'; - - @override - String get connectToSpotify => 'Spotifyに接続'; - - @override - String get reconfigureApiCredentials => 'APIクレデンシャルを再設定'; - - @override - String get changeClientIdSecret => 'Client IDとSecretを変更'; - - @override - String get reconfigureDialogTitle => 'APIクレデンシャルを再設定'; - - @override - String get reconfigureDialogContent => - '現在のAPIクレデンシャルを削除してログアウトします。\n\nClient IDとSecretを再入力する必要があります。'; - - @override - String get reconfigure => '再設定'; - @override String get redirectUriForSpotifyApp => 'SpotifyアプリのリダイレクトURI'; @@ -993,9 +789,6 @@ class AppLocalizationsJa extends AppLocalizations { return '設定済み($clientId)'; } - @override - String get notConfigured => '未設定'; - @override String get llmOpenAiCompatible => 'OpenAI互換API'; @@ -1095,4 +888,30 @@ class AppLocalizationsJa extends AppLocalizations { @override String get exitMiniPlayer => 'ミニプレーヤーを終了'; + + @override + String get advancedOptions => '詳細オプション'; + + @override + String get customClientId => 'カスタム Client ID'; + + @override + String get customClientIdDescription => + '共有 Client ID は Spotify のレート制限を受ける可能性があります。Spotify Developer Dashboard で独自のアプリを作成し、自分の Client ID を使用できます。'; + + @override + String get customClientIdHint => 'Spotify Client ID を入力'; + + @override + String get customClientIdSaved => 'カスタム Client ID を保存しました'; + + @override + String get customClientIdCleared => 'デフォルトの Client ID に戻しました'; + + @override + String get useDefaultClient => 'デフォルトを使用'; + + @override + String get customClientIdReauthRequired => + 'Client ID の切り替えには再ログインが必要です。今すぐログアウトしますか?'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 40e2d3a..2910347 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -50,15 +50,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get errorCopied => '错误信息已复制到剪贴板'; - @override - String get reconfigureCredentials => '重新配置凭据'; - - @override - String get apiConfigured => 'API 已配置'; - - @override - String get change => '更改'; - @override String get credentialsStayOnDevice => '您的凭据仅保存在本地设备'; @@ -137,33 +128,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get next => '下一首'; - @override - String get setupGuide => '设置向导'; - - @override - String get setupDescription => '首先,您需要创建一个 Spotify 开发者应用并输入您的凭据。'; - - @override - String get step1Title => '前往 Spotify 开发者控制台'; - - @override - String get step2Title => '创建新应用'; - - @override - String get step3Title => '添加重定向 URI'; - - @override - String get step4Title => '复制您的凭据'; - - @override - String get clientId => 'Client ID'; - - @override - String get clientSecret => 'Client Secret'; - - @override - String get saveAndContinue => '保存并继续'; - @override String get errorInvalidClient => 'API 凭据无效。请检查您的 Client ID 和 Secret。'; @@ -801,13 +765,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get privacyNoDataCollectionDesc => '我们不收集、存储或传输任何使用分析、播放历史或个人信息。'; - @override - String get privacyOAuthSecurity => 'OAuth 安全'; - - @override - String get privacyOAuthSecurityDesc => - '身份验证使用本地 HTTP 服务器(端口 8888-8891、8080 或 3000),并通过 state 参数进行 CSRF 保护。'; - @override String get privacyYouControl => '数据由您掌控'; @@ -817,163 +774,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get close => '关闭'; - @override - String get welcomeToFullStop => '欢迎使用 FullStop'; - - @override - String get updateCredentials => '更新凭据'; - - @override - String get connectSpotifyToStart => '连接您的 Spotify 账户以开始使用'; - - @override - String get updateSpotifyCredentials => '更新您的 Spotify API 凭据'; - - @override - String get credentialsSecurelyStored => '您的凭据仅安全存储在您的设备上'; - - @override - String get privacyPolicy => '隐私政策'; - - @override - String get step1CreateApp => '第一步:创建 Spotify 应用'; - - @override - String get openDeveloperDashboard => '打开 Spotify 开发者控制台'; - - @override - String get openDeveloperDashboardHint => '点击下方按钮,在浏览器中打开 Spotify 开发者控制台。'; - - @override - String get createNewApp => '创建新应用'; - - @override - String get createNewAppDesc => - '点击「Create App」并填写:\n• App name:任意名称(如 \"My Focus App\")\n• App description:个人使用\n• Website:留空或填写任意 URL\n• 勾选「Web API」选项'; - - @override - String get createNewAppDescShort => '点击「Create App」并填写以下字段,勾选「Web API」选项。'; - - @override - String get appNameLabel => 'App name(应用名称)'; - - @override - String get appNameCopied => '应用名称已复制!'; - - @override - String get appDescriptionLabel => 'App description(应用描述)'; - - @override - String get appDescriptionCopied => '应用描述已复制!'; - - @override - String get redirectUriLabel => 'Redirect URI(重定向 URI)'; - - @override - String get setRedirectUri => '设置重定向 URI(重要!)'; - - @override - String get setRedirectUriDesc => '在「Redirect URIs」字段中添加以下 URI:'; - - @override - String get copy => '复制'; - - @override - String get redirectUriCopied => '重定向 URI 已复制!'; - - @override - String get redirectUriWarning => '粘贴后点击「Add」,然后点击底部的「Save」!'; - - @override - String get step2EnterCredentials => '第二步:输入凭据'; - - @override - String get updateYourCredentials => '更新您的凭据'; - - @override - String get findCredentialsHint => '在 Spotify 开发者控制台的应用设置页面中找到您的凭据。'; - - @override - String get modifyCredentialsHint => '修改下方凭据,如正确则无需更改。'; - - @override - String get enterClientId => '输入您的 Client ID'; - - @override - String get clientIdRequired => 'Client ID 为必填项'; - - @override - String get clientIdTooShort => 'Client ID 似乎太短'; - - @override - String get enterClientSecret => '输入您的 Client Secret'; - - @override - String get clientSecretRequired => 'Client Secret 为必填项'; - - @override - String get clientSecretTooShort => 'Client Secret 似乎太短'; - - @override - String get whereToFindCredentials => '在哪里找到这些?'; - - @override - String get whereToFindCredentialsDesc => - '在您的 Spotify 应用设置页面中,您会看到 Client ID。点击「View client secret」查看密钥。'; - - @override - String get step3ReadyToConnect => '第三步:准备连接'; - - @override - String get credentialsSaved => '凭据已保存!'; - - @override - String get waitingForCredentials => '等待输入凭据'; - - @override - String get credentialsSavedDesc => '您的 Spotify API 凭据已安全存储。现在可以连接 Spotify 了。'; - - @override - String get waitingForCredentialsDesc => '请返回第二步输入您的凭据。'; - - @override - String get spotifyPremiumRequired => '需要 Spotify Premium'; - - @override - String get spotifyPremiumRequiredDesc => - '本应用需要 Spotify Premium 订阅才能使用播放控制功能。'; - - @override - String get back => '返回'; - - @override - String get nextEnterCredentials => '下一步:输入凭据'; - - @override - String get saveCredentials => '保存凭据'; - - @override - String get updateCredentialsButton => '更新凭据'; - - @override - String get connectToSpotify => '连接 Spotify'; - - @override - String get reconfigureApiCredentials => '重新配置 API 凭据'; - - @override - String get changeClientIdSecret => '更改您的 Client ID 和 Secret'; - - @override - String get reconfigureDialogTitle => '重新配置 API 凭据'; - - @override - String get reconfigureDialogContent => - '这将清除当前的 API 凭据并登出。\n\n您需要重新输入 Client ID 和 Secret。'; - - @override - String get reconfigure => '重新配置'; - @override String get redirectUriForSpotifyApp => 'Spotify 应用的重定向 URI'; @@ -985,9 +785,6 @@ class AppLocalizationsZh extends AppLocalizations { return '已配置($clientId)'; } - @override - String get notConfigured => '未配置'; - @override String get llmOpenAiCompatible => 'OpenAI 兼容 API'; @@ -1087,4 +884,29 @@ class AppLocalizationsZh extends AppLocalizations { @override String get exitMiniPlayer => '退出迷你播放器'; + + @override + String get advancedOptions => '高级选项'; + + @override + String get customClientId => '自定义 Client ID'; + + @override + String get customClientIdDescription => + '公用 Client ID 在高频请求时可能被 Spotify 限流。你可以在 Spotify Developer Dashboard 创建自己的应用,使用自己的 Client ID。'; + + @override + String get customClientIdHint => '输入你的 Spotify Client ID'; + + @override + String get customClientIdSaved => '自定义 Client ID 已保存'; + + @override + String get customClientIdCleared => '已恢复使用默认 Client ID'; + + @override + String get useDefaultClient => '使用默认'; + + @override + String get customClientIdReauthRequired => '切换 Client ID 后需要重新登录,是否立即退出登录?'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e5ba787..84ebfcb 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -29,12 +29,6 @@ "errorCopied": "错误信息已复制到剪贴板", - "reconfigureCredentials": "重新配置凭据", - - "apiConfigured": "API 已配置", - - "change": "更改", - "credentialsStayOnDevice": "您的凭据仅保存在本地设备", "controlsExistingSession": "控制您现有的 Spotify 会话", @@ -87,25 +81,6 @@ "next": "下一首", - "setupGuide": "设置向导", - - - "setupDescription": "首先,您需要创建一个 Spotify 开发者应用并输入您的凭据。", - - "step1Title": "前往 Spotify 开发者控制台", - - "step2Title": "创建新应用", - - "step3Title": "添加重定向 URI", - - "step4Title": "复制您的凭据", - - "clientId": "Client ID", - - "clientSecret": "Client Secret", - - "saveAndContinue": "保存并继续", - "errorInvalidClient": "API 凭据无效。请检查您的 Client ID 和 Secret。", "errorRedirectUri": "重定向 URI 不匹配!请确保您的 Spotify 应用配置了正确的重定向 URI。", @@ -475,126 +450,18 @@ "privacyNoDataCollectionDesc": "我们不收集、存储或传输任何使用分析、播放历史或个人信息。", - "privacyOAuthSecurity": "OAuth 安全", - - "privacyOAuthSecurityDesc": "身份验证使用本地 HTTP 服务器(端口 8888-8891、8080 或 3000),并通过 state 参数进行 CSRF 保护。", - "privacyYouControl": "数据由您掌控", "privacyYouControlDesc": "您可以随时在设置页面清除凭据。卸载应用将删除所有存储的数据。", "close": "关闭", - "welcomeToFullStop": "欢迎使用 FullStop", - - "updateCredentials": "更新凭据", - - "connectSpotifyToStart": "连接您的 Spotify 账户以开始使用", - - "updateSpotifyCredentials": "更新您的 Spotify API 凭据", - - "credentialsSecurelyStored": "您的凭据仅安全存储在您的设备上", - - "privacyPolicy": "隐私政策", - - "step1CreateApp": "第一步:创建 Spotify 应用", - - "openDeveloperDashboard": "打开 Spotify 开发者控制台", - - "openDeveloperDashboardHint": "点击下方按钮,在浏览器中打开 Spotify 开发者控制台。", - - "createNewApp": "创建新应用", - - "createNewAppDesc": "点击「Create App」并填写:\n• App name:任意名称(如 \"My Focus App\")\n• App description:个人使用\n• Website:留空或填写任意 URL\n• 勾选「Web API」选项", - - "createNewAppDescShort": "点击「Create App」并填写以下字段,勾选「Web API」选项。", - - "appNameLabel": "App name(应用名称)", - - "appNameCopied": "应用名称已复制!", - - "appDescriptionLabel": "App description(应用描述)", - - "appDescriptionCopied": "应用描述已复制!", - - "redirectUriLabel": "Redirect URI(重定向 URI)", - - "setRedirectUri": "设置重定向 URI(重要!)", - - "setRedirectUriDesc": "在「Redirect URIs」字段中添加以下 URI:", - - "copy": "复制", - - "redirectUriCopied": "重定向 URI 已复制!", - - "redirectUriWarning": "粘贴后点击「Add」,然后点击底部的「Save」!", - - "step2EnterCredentials": "第二步:输入凭据", - - "updateYourCredentials": "更新您的凭据", - - "findCredentialsHint": "在 Spotify 开发者控制台的应用设置页面中找到您的凭据。", - - "modifyCredentialsHint": "修改下方凭据,如正确则无需更改。", - - "enterClientId": "输入您的 Client ID", - - "clientIdRequired": "Client ID 为必填项", - - "clientIdTooShort": "Client ID 似乎太短", - - "enterClientSecret": "输入您的 Client Secret", - - "clientSecretRequired": "Client Secret 为必填项", - - "clientSecretTooShort": "Client Secret 似乎太短", - - "whereToFindCredentials": "在哪里找到这些?", - - "whereToFindCredentialsDesc": "在您的 Spotify 应用设置页面中,您会看到 Client ID。点击「View client secret」查看密钥。", - - "step3ReadyToConnect": "第三步:准备连接", - - "credentialsSaved": "凭据已保存!", - - "waitingForCredentials": "等待输入凭据", - - "credentialsSavedDesc": "您的 Spotify API 凭据已安全存储。现在可以连接 Spotify 了。", - - "waitingForCredentialsDesc": "请返回第二步输入您的凭据。", - - "spotifyPremiumRequired": "需要 Spotify Premium", - - "spotifyPremiumRequiredDesc": "本应用需要 Spotify Premium 订阅才能使用播放控制功能。", - - "back": "返回", - - "nextEnterCredentials": "下一步:输入凭据", - - "saveCredentials": "保存凭据", - - "updateCredentialsButton": "更新凭据", - - "connectToSpotify": "连接 Spotify", - - "reconfigureApiCredentials": "重新配置 API 凭据", - - "changeClientIdSecret": "更改您的 Client ID 和 Secret", - - "reconfigureDialogTitle": "重新配置 API 凭据", - - "reconfigureDialogContent": "这将清除当前的 API 凭据并登出。\n\n您需要重新输入 Client ID 和 Secret。", - - "reconfigure": "重新配置", - "redirectUriForSpotifyApp": "Spotify 应用的重定向 URI", "spotifyApi": "Spotify API", "configured": "已配置({clientId})", - "notConfigured": "未配置", - "llmOpenAiCompatible": "OpenAI 兼容 API", "llmOpenAiCompatibleDesc": "支持 OpenAI、Ollama 及其他 OpenAI 兼容 API。\n本地模型(如 Ollama)可不填 API Key。", @@ -657,5 +524,21 @@ "miniPlayer": "迷你播放器", - "exitMiniPlayer": "退出迷你播放器" + "exitMiniPlayer": "退出迷你播放器", + + "advancedOptions": "高级选项", + + "customClientId": "自定义 Client ID", + + "customClientIdDescription": "公用 Client ID 在高频请求时可能被 Spotify 限流。你可以在 Spotify Developer Dashboard 创建自己的应用,使用自己的 Client ID。", + + "customClientIdHint": "输入你的 Spotify Client ID", + + "customClientIdSaved": "自定义 Client ID 已保存", + + "customClientIdCleared": "已恢复使用默认 Client ID", + + "useDefaultClient": "使用默认", + + "customClientIdReauthRequired": "切换 Client ID 后需要重新登录,是否立即退出登录?" } diff --git a/lib/presentation/screens/login_screen.dart b/lib/presentation/screens/login_screen.dart index b260150..a53416c 100644 --- a/lib/presentation/screens/login_screen.dart +++ b/lib/presentation/screens/login_screen.dart @@ -6,15 +6,27 @@ import '../../application/providers/auth_provider.dart'; import '../../application/providers/credentials_provider.dart'; import '../themes/app_theme.dart'; import '../widgets/app_logo.dart'; -import 'setup_guide_screen.dart'; -class LoginScreen extends ConsumerWidget { +class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + bool _advancedExpanded = false; + final _clientIdController = TextEditingController(); + + @override + void dispose() { + _clientIdController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final authState = ref.watch(authProvider); - final credentialsState = ref.watch(credentialsProvider); final isLoading = authState.status == AuthStatus.loading; return Scaffold( @@ -56,50 +68,6 @@ class LoginScreen extends ConsumerWidget { ), const SizedBox(height: 48), - // Credentials status - if (credentialsState.hasSpotifyCredentials && !isLoading) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - decoration: BoxDecoration( - color: AppTheme.spotifyGreen.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.check_circle, - size: 16, - color: AppTheme.spotifyGreen, - ), - const SizedBox(width: 8), - Text( - AppLocalizations.of(context)!.apiConfigured, - style: TextStyle( - fontSize: 12, - color: AppTheme.spotifyGreen, - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: () => _showReconfigureDialog(context, ref), - child: Text( - AppLocalizations.of(context)!.change, - style: TextStyle( - fontSize: 12, - color: AppTheme.spotifyGreen, - decoration: TextDecoration.underline, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 24), - // Loading state with cancel option if (isLoading) _buildLoadingState(context, ref), @@ -119,6 +87,11 @@ class LoginScreen extends ConsumerWidget { ), ), ), + const SizedBox(height: 16), + + // Advanced options (custom Client ID) + if (!isLoading) _buildAdvancedSection(context), + const SizedBox(height: 24), // Info section @@ -156,6 +129,168 @@ class LoginScreen extends ConsumerWidget { ); } + Widget _buildAdvancedSection(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final creds = ref.watch(credentialsProvider); + final hasCustom = + creds.customSpotifyClientId != null && + creds.customSpotifyClientId!.isNotEmpty; + final effectiveClientId = ref.watch(effectiveSpotifyClientIdProvider); + + return Column( + children: [ + // Clickable row to expand/collapse + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () { + setState(() { + _advancedExpanded = !_advancedExpanded; + if (_advancedExpanded && hasCustom) { + _clientIdController.text = creds.customSpotifyClientId!; + } + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.tune, + size: 16, + color: AppTheme.spotifyLightGray, + ), + const SizedBox(width: 6), + Text( + l10n.advancedOptions, + style: TextStyle( + fontSize: 13, + color: AppTheme.spotifyLightGray, + ), + ), + Icon( + _advancedExpanded ? Icons.expand_less : Icons.expand_more, + size: 18, + color: AppTheme.spotifyLightGray, + ), + ], + ), + ), + ), + if (_advancedExpanded) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.spotifyDarkGray, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Current status + Row( + children: [ + Icon(Icons.key, size: 16, color: AppTheme.spotifyLightGray), + const SizedBox(width: 8), + Expanded( + child: Text( + l10n.configured(_maskClientId(effectiveClientId)), + style: TextStyle( + fontSize: 12, + color: hasCustom + ? AppTheme.spotifyGreen + : AppTheme.spotifyLightGray, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + l10n.customClientIdDescription, + style: TextStyle( + fontSize: 12, + color: AppTheme.spotifyLightGray.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _clientIdController, + decoration: InputDecoration( + labelText: l10n.customClientId, + hintText: l10n.customClientIdHint, + border: const OutlineInputBorder(), + isDense: true, + suffixIcon: hasCustom + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + tooltip: l10n.useDefaultClient, + onPressed: () => _clearCustomClientId(l10n), + ) + : null, + ), + style: const TextStyle(fontFamily: 'monospace', fontSize: 13), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (hasCustom) + TextButton( + onPressed: () => _clearCustomClientId(l10n), + child: Text(l10n.useDefaultClient), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => _saveCustomClientId(l10n), + child: Text(l10n.save), + ), + ], + ), + ], + ), + ), + ], + ], + ); + } + + Future _saveCustomClientId(AppLocalizations l10n) async { + final value = _clientIdController.text.trim(); + if (value.isEmpty) return; + + final notifier = ref.read(credentialsProvider.notifier); + final success = await notifier.saveCustomSpotifyClientId(value); + + if (!mounted) return; + + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.customClientIdSaved)), + ); + } + } + + Future _clearCustomClientId(AppLocalizations l10n) async { + final notifier = ref.read(credentialsProvider.notifier); + await notifier.clearCustomSpotifyClientId(); + _clientIdController.clear(); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.customClientIdCleared)), + ); + } + + String _maskClientId(String clientId) { + if (clientId.length < 8) return '****'; + return '${clientId.substring(0, 4)}...${clientId.substring(clientId.length - 4)}'; + } + Widget _buildLoadingState(BuildContext context, WidgetRef ref) { return Container( padding: const EdgeInsets.all(24), @@ -265,19 +400,6 @@ class LoginScreen extends ConsumerWidget { ); } - void _showReconfigureDialog(BuildContext context, WidgetRef ref) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => SetupGuideScreen( - isReconfiguring: true, - onSetupComplete: () { - Navigator.of(context).pop(); - }, - ), - ), - ); - } - String _getErrorMessage(BuildContext context, String? error) { final l10n = AppLocalizations.of(context)!; if (error == null) return 'An unknown error occurred'; @@ -378,14 +500,6 @@ class LoginScreen extends ConsumerWidget { style: TextStyle(color: Colors.red.shade300, fontSize: 12), textAlign: TextAlign.center, ), - // Show reconfigure option on auth error - const SizedBox(height: 12), - TextButton.icon( - onPressed: () => _showReconfigureDialog(context, ref), - icon: const Icon(Icons.settings, size: 16), - label: Text(l10n.reconfigureCredentials), - style: TextButton.styleFrom(foregroundColor: Colors.red.shade300), - ), ], ), ); diff --git a/lib/presentation/screens/setup_guide_screen.dart b/lib/presentation/screens/setup_guide_screen.dart deleted file mode 100644 index 908433a..0000000 --- a/lib/presentation/screens/setup_guide_screen.dart +++ /dev/null @@ -1,855 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:fullstop/l10n/app_localizations.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:url_launcher/url_launcher.dart'; -import '../../application/providers/credentials_provider.dart'; -import '../../core/config/app_config.dart'; -import '../themes/app_theme.dart'; -import '../widgets/app_logo.dart'; - -class SetupGuideScreen extends ConsumerStatefulWidget { - final VoidCallback onSetupComplete; - final bool isReconfiguring; - - const SetupGuideScreen({ - super.key, - required this.onSetupComplete, - this.isReconfiguring = false, - }); - - @override - ConsumerState createState() => _SetupGuideScreenState(); -} - -class _SetupGuideScreenState extends ConsumerState { - final _formKey = GlobalKey(); - final _clientIdController = TextEditingController(); - final _clientSecretController = TextEditingController(); - bool _obscureSecret = true; - int _currentStep = 0; - bool _hasLoadedExistingCredentials = false; - - @override - void initState() { - super.initState(); - // If reconfiguring, start at step 2 (credentials entry) - if (widget.isReconfiguring) { - _currentStep = 1; - } - } - - @override - void dispose() { - _clientIdController.dispose(); - _clientSecretController.dispose(); - super.dispose(); - } - - void _loadExistingCredentials(CredentialsState credentialsState) { - if (!_hasLoadedExistingCredentials && widget.isReconfiguring) { - // Pre-fill existing credentials - if (credentialsState.spotifyClientId != null) { - _clientIdController.text = credentialsState.spotifyClientId!; - } - if (credentialsState.spotifyClientSecret != null) { - _clientSecretController.text = credentialsState.spotifyClientSecret!; - } - _hasLoadedExistingCredentials = true; - } - } - - @override - Widget build(BuildContext context) { - final credentialsState = ref.watch(credentialsProvider); - final l10n = AppLocalizations.of(context)!; - - // Load existing credentials when reconfiguring - _loadExistingCredentials(credentialsState); - - return Scaffold( - body: SafeArea( - child: Column( - children: [ - // Header - Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - const AppLogo(size: 80), - const SizedBox(height: 16), - Text( - widget.isReconfiguring - ? l10n.updateCredentials - : l10n.welcomeToFullStop, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - widget.isReconfiguring - ? l10n.updateSpotifyCredentials - : l10n.connectSpotifyToStart, - style: TextStyle( - fontSize: 14, - color: AppTheme.spotifyLightGray, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - - // Security Notice - _buildSecurityNotice(), - - const SizedBox(height: 16), - - // Main content - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - children: [ - _buildStepIndicator(), - const SizedBox(height: 24), - _buildCurrentStepContent(credentialsState), - const SizedBox(height: 24), - ], - ), - ), - ), - - // Navigation buttons - _buildNavigationButtons(credentialsState), - ], - ), - ), - ); - } - - Widget _buildSecurityNotice() { - final l10n = AppLocalizations.of(context)!; - return Container( - margin: const EdgeInsets.symmetric(horizontal: 24), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.spotifyGreen.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppTheme.spotifyGreen.withValues(alpha: 0.3)), - ), - child: Row( - children: [ - const Icon(Icons.security, color: AppTheme.spotifyGreen, size: 20), - const SizedBox(width: 12), - Expanded( - child: Text( - l10n.credentialsSecurelyStored, - style: TextStyle(fontSize: 12, color: AppTheme.spotifyLightGray), - ), - ), - IconButton( - icon: const Icon(Icons.info_outline, size: 18), - color: AppTheme.spotifyGreen, - onPressed: () => _showPrivacyPolicy(context), - tooltip: l10n.privacyPolicy, - ), - ], - ), - ); - } - - Widget _buildStepIndicator() { - return Row( - children: [ - for (int i = 0; i < 3; i++) ...[ - if (i > 0) - Expanded( - child: Container( - height: 2, - color: i <= _currentStep - ? AppTheme.spotifyGreen - : AppTheme.spotifyDarkGray, - ), - ), - GestureDetector( - onTap: () => setState(() => _currentStep = i), - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: i <= _currentStep - ? AppTheme.spotifyGreen - : AppTheme.spotifyDarkGray, - shape: BoxShape.circle, - ), - child: Center( - child: i < _currentStep - ? const Icon( - Icons.check, - size: 18, - color: AppTheme.spotifyBlack, - ) - : Text( - '${i + 1}', - style: TextStyle( - color: i <= _currentStep - ? AppTheme.spotifyBlack - : AppTheme.spotifyLightGray, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ], - ], - ); - } - - Widget _buildCurrentStepContent(CredentialsState credentialsState) { - switch (_currentStep) { - case 0: - return _buildStep1CreateApp(); - case 1: - return _buildStep2EnterCredentials(credentialsState); - case 2: - return _buildStep3Confirm(credentialsState); - default: - return const SizedBox(); - } - } - - Widget _buildStep1CreateApp() { - final l10n = AppLocalizations.of(context)!; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.step1CreateApp, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 16), - _buildInstructionCard( - icon: Icons.open_in_new, - title: '1. ${l10n.openDeveloperDashboard}', - description: l10n.openDeveloperDashboardHint, - child: SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () => - _launchUrl('https://developer.spotify.com/dashboard'), - icon: const Icon(Icons.open_in_browser), - label: Text(l10n.openDeveloperDashboard), - ), - ), - ), - const SizedBox(height: 12), - _buildInstructionCard( - icon: Icons.add_circle_outline, - title: '2. ${l10n.createNewApp}', - description: l10n.createNewAppDescShort, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - _buildCopyableField( - label: l10n.appNameLabel, - value: 'FullStop', - onCopied: () => _showCopiedSnackbar(l10n.appNameCopied), - ), - const SizedBox(height: 8), - _buildCopyableField( - label: l10n.appDescriptionLabel, - value: 'FullStop Custom API Client', - onCopied: () => _showCopiedSnackbar(l10n.appDescriptionCopied), - ), - const SizedBox(height: 8), - _buildCopyableField( - label: l10n.redirectUriLabel, - value: AppConfig.spotifyRedirectUri, - onCopied: () => _showCopiedSnackbar(l10n.redirectUriCopied), - isHighlighted: true, - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.orange.withValues(alpha: 0.5), - ), - ), - child: Row( - children: [ - const Icon( - Icons.warning_amber, - color: Colors.orange, - size: 16, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - l10n.redirectUriWarning, - style: TextStyle( - fontSize: 11, - color: Colors.orange.shade300, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ], - ); - } - - Widget _buildCopyableField({ - required String label, - required String value, - required VoidCallback onCopied, - bool isHighlighted = false, - }) { - final l10n = AppLocalizations.of(context)!; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: AppTheme.spotifyBlack, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppTheme.spotifyLightGray.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - fontSize: 10, - color: AppTheme.spotifyLightGray, - ), - ), - const SizedBox(height: 2), - SelectableText( - value, - style: TextStyle( - fontFamily: 'monospace', - color: isHighlighted - ? AppTheme.spotifyGreen - : AppTheme.spotifyWhite, - fontSize: 13, - fontWeight: isHighlighted - ? FontWeight.bold - : FontWeight.normal, - ), - ), - ], - ), - ), - IconButton( - onPressed: () { - Clipboard.setData(ClipboardData(text: value)); - onCopied(); - }, - icon: const Icon(Icons.copy, size: 18), - color: AppTheme.spotifyGreen, - tooltip: l10n.copy, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), - ), - ], - ), - ); - } - - void _showCopiedSnackbar(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - duration: const Duration(seconds: 2), - backgroundColor: AppTheme.spotifyGreen, - ), - ); - } - - Widget _buildStep2EnterCredentials(CredentialsState credentialsState) { - final l10n = AppLocalizations.of(context)!; - return Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.isReconfiguring - ? l10n.updateYourCredentials - : l10n.step2EnterCredentials, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - widget.isReconfiguring - ? l10n.modifyCredentialsHint - : l10n.findCredentialsHint, - style: TextStyle(fontSize: 14, color: AppTheme.spotifyLightGray), - ), - const SizedBox(height: 24), - - // Client ID field - Text( - l10n.clientId, - style: const TextStyle(fontWeight: FontWeight.w500), - ), - const SizedBox(height: 8), - TextFormField( - controller: _clientIdController, - style: const TextStyle(color: AppTheme.spotifyWhite), - decoration: InputDecoration( - hintText: l10n.enterClientId, - prefixIcon: const Icon(Icons.key), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - filled: true, - fillColor: AppTheme.spotifyDarkGray, - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return l10n.clientIdRequired; - } - if (value.trim().length < 10) { - return l10n.clientIdTooShort; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Client Secret field - Text( - l10n.clientSecret, - style: const TextStyle(fontWeight: FontWeight.w500), - ), - const SizedBox(height: 8), - TextFormField( - controller: _clientSecretController, - obscureText: _obscureSecret, - style: const TextStyle(color: AppTheme.spotifyWhite), - decoration: InputDecoration( - hintText: l10n.enterClientSecret, - prefixIcon: const Icon(Icons.lock), - suffixIcon: IconButton( - icon: Icon( - _obscureSecret ? Icons.visibility : Icons.visibility_off, - ), - onPressed: () { - setState(() => _obscureSecret = !_obscureSecret); - }, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - filled: true, - fillColor: AppTheme.spotifyDarkGray, - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return l10n.clientSecretRequired; - } - if (value.trim().length < 10) { - return l10n.clientSecretTooShort; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Help text - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), - ), - child: Row( - children: [ - const Icon(Icons.help_outline, color: Colors.blue, size: 20), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.whereToFindCredentials, - style: const TextStyle( - fontWeight: FontWeight.bold, - color: Colors.blue, - ), - ), - const SizedBox(height: 4), - Text( - l10n.whereToFindCredentialsDesc, - style: TextStyle( - fontSize: 12, - color: AppTheme.spotifyLightGray, - ), - ), - ], - ), - ), - ], - ), - ), - - if (credentialsState.error != null) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.red.withValues(alpha: 0.3)), - ), - child: Row( - children: [ - const Icon(Icons.error_outline, color: Colors.red, size: 20), - const SizedBox(width: 8), - Expanded( - child: Text( - credentialsState.error!, - style: const TextStyle(color: Colors.red, fontSize: 12), - ), - ), - ], - ), - ), - ], - ], - ), - ); - } - - Widget _buildStep3Confirm(CredentialsState credentialsState) { - final l10n = AppLocalizations.of(context)!; - final isConfigured = credentialsState.hasSpotifyCredentials; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.step3ReadyToConnect, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 24), - - // Status card - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: isConfigured - ? AppTheme.spotifyGreen.withValues(alpha: 0.1) - : AppTheme.spotifyDarkGray, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isConfigured - ? AppTheme.spotifyGreen - : AppTheme.spotifyLightGray.withValues(alpha: 0.3), - ), - ), - child: Column( - children: [ - Icon( - isConfigured ? Icons.check_circle : Icons.pending, - size: 64, - color: isConfigured - ? AppTheme.spotifyGreen - : AppTheme.spotifyLightGray, - ), - const SizedBox(height: 16), - Text( - isConfigured - ? l10n.credentialsSaved - : l10n.waitingForCredentials, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: isConfigured - ? AppTheme.spotifyGreen - : AppTheme.spotifyWhite, - ), - ), - const SizedBox(height: 8), - Text( - isConfigured - ? l10n.credentialsSavedDesc - : l10n.waitingForCredentialsDesc, - style: TextStyle( - fontSize: 14, - color: AppTheme.spotifyLightGray, - ), - textAlign: TextAlign.center, - ), - if (isConfigured && credentialsState.spotifyClientId != null) ...[ - const SizedBox(height: 12), - Text( - '${l10n.clientId}: ${_maskString(credentialsState.spotifyClientId!)}', - style: TextStyle( - fontSize: 12, - color: AppTheme.spotifyLightGray, - fontFamily: 'monospace', - ), - ), - ], - ], - ), - ), - - const SizedBox(height: 24), - - // Requirements reminder - _buildInstructionCard( - icon: Icons.workspace_premium, - title: l10n.spotifyPremiumRequired, - description: l10n.spotifyPremiumRequiredDesc, - ), - ], - ); - } - - Widget _buildInstructionCard({ - required IconData icon, - required String title, - required String description, - Widget? child, - }) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.spotifyDarkGray, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, size: 20, color: AppTheme.spotifyGreen), - const SizedBox(width: 12), - Expanded( - child: Text( - title, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - ], - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - description, - style: TextStyle( - fontSize: 13, - color: AppTheme.spotifyLightGray, - height: 1.5, - ), - ), - ), - if (child != null) ...[ - const SizedBox(height: 8), - Padding(padding: const EdgeInsets.only(left: 32), child: child), - ], - ], - ), - ); - } - - Widget _buildNavigationButtons(CredentialsState credentialsState) { - final l10n = AppLocalizations.of(context)!; - return Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: AppTheme.spotifyDarkGray, - border: Border( - top: BorderSide( - color: AppTheme.spotifyLightGray.withValues(alpha: 0.2), - ), - ), - ), - child: Row( - children: [ - // Back/Cancel button - if (_currentStep > 0 || widget.isReconfiguring) - Expanded( - child: OutlinedButton( - onPressed: () { - if (_currentStep > 0) { - setState(() => _currentStep--); - } else if (widget.isReconfiguring) { - Navigator.of(context).pop(); - } - }, - child: Text( - _currentStep == 0 && widget.isReconfiguring - ? l10n.cancel - : l10n.back, - ), - ), - ) - else - const Spacer(), - - const SizedBox(width: 16), - - // Next/Save/Finish button - Expanded(flex: 2, child: _buildPrimaryButton(credentialsState)), - ], - ), - ); - } - - Widget _buildPrimaryButton(CredentialsState credentialsState) { - final l10n = AppLocalizations.of(context)!; - if (_currentStep == 0) { - return ElevatedButton( - onPressed: () => setState(() => _currentStep++), - child: Text(l10n.nextEnterCredentials), - ); - } else if (_currentStep == 1) { - return ElevatedButton( - onPressed: credentialsState.isLoading ? null : _saveCredentials, - child: credentialsState.isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text( - widget.isReconfiguring - ? l10n.updateCredentialsButton - : l10n.saveCredentials, - ), - ); - } else { - return ElevatedButton( - onPressed: credentialsState.hasSpotifyCredentials - ? () { - // Call the callback first - widget.onSetupComplete(); - // Then navigate using our own context - if (mounted) { - Navigator.of(context).popUntil((route) => route.isFirst); - } - } - : null, - child: Text(l10n.connectToSpotify), - ); - } - } - - Future _saveCredentials() async { - if (!_formKey.currentState!.validate()) return; - - final success = await ref - .read(credentialsProvider.notifier) - .saveSpotifyCredentials( - clientId: _clientIdController.text.trim(), - clientSecret: _clientSecretController.text.trim(), - ); - - if (success && mounted) { - setState(() => _currentStep = 2); - } - } - - String _maskString(String value) { - if (value.length <= 8) return '****'; - return '${value.substring(0, 4)}...${value.substring(value.length - 4)}'; - } - - Future _launchUrl(String url) async { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } - - void _showPrivacyPolicy(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - backgroundColor: AppTheme.spotifyDarkGray, - title: Text(l10n.aboutPrivacySecurity), - content: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _buildPrivacySection( - l10n.privacySecureStorage, - l10n.privacySecureStorageDesc, - ), - _buildPrivacySection( - l10n.privacyDirectConnection, - l10n.privacyDirectConnectionDesc, - ), - _buildPrivacySection( - l10n.privacyNoDataCollection, - l10n.privacyNoDataCollectionDesc, - ), - _buildPrivacySection( - l10n.privacyYouControl, - l10n.privacyYouControlDesc, - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(l10n.close), - ), - ], - ), - ); - } - - Widget _buildPrivacySection(String title, String content) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.spotifyGreen, - ), - ), - const SizedBox(height: 4), - Text( - content, - style: TextStyle( - fontSize: 13, - color: AppTheme.spotifyLightGray, - height: 1.4, - ), - ), - ], - ), - ); - } -} diff --git a/lib/presentation/widgets/settings/about_section.dart b/lib/presentation/widgets/settings/about_section.dart index d862143..094bb57 100644 --- a/lib/presentation/widgets/settings/about_section.dart +++ b/lib/presentation/widgets/settings/about_section.dart @@ -358,10 +358,6 @@ class AboutSection extends StatelessWidget { l10n.privacyNoDataCollection, l10n.privacyNoDataCollectionDesc, ), - _buildPrivacySection( - l10n.privacyOAuthSecurity, - l10n.privacyOAuthSecurityDesc, - ), _buildPrivacySection( l10n.privacyYouControl, l10n.privacyYouControlDesc, diff --git a/lib/presentation/widgets/settings/api_credentials_section.dart b/lib/presentation/widgets/settings/api_credentials_section.dart index 9951c38..8f3748c 100644 --- a/lib/presentation/widgets/settings/api_credentials_section.dart +++ b/lib/presentation/widgets/settings/api_credentials_section.dart @@ -3,18 +3,35 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fullstop/l10n/app_localizations.dart'; import '../../../application/providers/auth_provider.dart'; import '../../../application/providers/credentials_provider.dart'; -import '../../../application/providers/playback_provider.dart'; import '../../../core/config/app_config.dart'; -import '../../screens/setup_guide_screen.dart'; import '../../themes/app_theme.dart'; -class ApiCredentialsSection extends ConsumerWidget { +class ApiCredentialsSection extends ConsumerStatefulWidget { const ApiCredentialsSection({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _ApiCredentialsSectionState(); +} + +class _ApiCredentialsSectionState extends ConsumerState { + bool _advancedExpanded = false; + final _clientIdController = TextEditingController(); + + @override + void dispose() { + _clientIdController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final credentialsState = ref.watch(credentialsProvider); + final effectiveClientId = ref.watch(effectiveSpotifyClientIdProvider); + final creds = ref.watch(credentialsProvider); + final hasCustom = + creds.customSpotifyClientId != null && + creds.customSpotifyClientId!.isNotEmpty; return Column( children: [ @@ -22,27 +39,153 @@ class ApiCredentialsSection extends ConsumerWidget { leading: const Icon(Icons.key), title: Text(l10n.spotifyApi), subtitle: Text( - credentialsState.hasSpotifyCredentials - ? l10n.configured( - _maskClientId(credentialsState.spotifyClientId), - ) - : l10n.notConfigured, + l10n.configured(_maskClientId(effectiveClientId)), ), - trailing: credentialsState.hasSpotifyCredentials - ? Icon(Icons.check_circle, color: AppTheme.spotifyGreen) - : const Icon(Icons.warning, color: Colors.orange), + trailing: Icon(Icons.check_circle, color: AppTheme.spotifyGreen), ), + _buildRedirectUriInfo(context), + // Advanced options ListTile( - leading: const Icon(Icons.refresh), - title: Text(l10n.reconfigureApiCredentials), - subtitle: Text(l10n.changeClientIdSecret), - onTap: () => _showReconfigureDialog(context, ref), + leading: const Icon(Icons.tune), + title: Text(l10n.advancedOptions), + trailing: Icon( + _advancedExpanded ? Icons.expand_less : Icons.expand_more, + ), + onTap: () { + setState(() { + _advancedExpanded = !_advancedExpanded; + if (_advancedExpanded && hasCustom) { + _clientIdController.text = creds.customSpotifyClientId!; + } + }); + }, ), - _buildRedirectUriInfo(context), + if (_advancedExpanded) + _buildAdvancedOptions(context, l10n, hasCustom), ], ); } + Widget _buildAdvancedOptions( + BuildContext context, + AppLocalizations l10n, + bool hasCustom, + ) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.customClientIdDescription, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _clientIdController, + decoration: InputDecoration( + labelText: l10n.customClientId, + hintText: l10n.customClientIdHint, + border: const OutlineInputBorder(), + isDense: true, + suffixIcon: hasCustom + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + tooltip: l10n.useDefaultClient, + onPressed: () => _clearCustomClientId(context, l10n), + ) + : null, + ), + style: const TextStyle(fontFamily: 'monospace', fontSize: 13), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (hasCustom) + TextButton( + onPressed: () => _clearCustomClientId(context, l10n), + child: Text(l10n.useDefaultClient), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => _saveCustomClientId(context, l10n), + child: Text(l10n.save), + ), + ], + ), + ], + ), + ); + } + + Future _saveCustomClientId( + BuildContext context, + AppLocalizations l10n, + ) async { + final value = _clientIdController.text.trim(); + if (value.isEmpty) return; + + final notifier = ref.read(credentialsProvider.notifier); + final success = await notifier.saveCustomSpotifyClientId(value); + + if (!context.mounted) return; + + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.customClientIdSaved)), + ); + _showReauthDialog(context, l10n); + } + } + + Future _clearCustomClientId( + BuildContext context, + AppLocalizations l10n, + ) async { + final notifier = ref.read(credentialsProvider.notifier); + await notifier.clearCustomSpotifyClientId(); + _clientIdController.clear(); + + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.customClientIdCleared)), + ); + _showReauthDialog(context, l10n); + } + + void _showReauthDialog(BuildContext context, AppLocalizations l10n) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.customClientIdReauthRequired), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop(); + ref.read(authProvider.notifier).logout(); + }, + child: Text(l10n.logout), + ), + ], + ), + ); + } + Widget _buildRedirectUriInfo(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Container( @@ -86,54 +229,8 @@ class ApiCredentialsSection extends ConsumerWidget { ); } - String _maskClientId(String? clientId) { - if (clientId == null || clientId.length < 8) return '****'; + String _maskClientId(String clientId) { + if (clientId.length < 8) return '****'; return '${clientId.substring(0, 4)}...${clientId.substring(clientId.length - 4)}'; } - - void _showReconfigureDialog(BuildContext context, WidgetRef ref) { - final l10n = AppLocalizations.of(context)!; - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - backgroundColor: AppTheme.spotifyDarkGray, - title: Text(l10n.reconfigureDialogTitle), - content: Text(l10n.reconfigureDialogContent), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(l10n.cancel), - ), - ElevatedButton( - onPressed: () async { - Navigator.pop(dialogContext); - // Stop playback polling first to prevent 401 errors - ref.read(playbackProvider.notifier).stopPolling(); - await ref - .read(credentialsProvider.notifier) - .clearSpotifyCredentials(); - await ref.read(authProvider.notifier).logout(); - // Navigate to SetupGuideScreen with isReconfiguring flag - if (context.mounted) { - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => SetupGuideScreen( - isReconfiguring: true, - // Pass a no-op callback since navigation is handled by pushReplacement - // The SetupGuideScreen will navigate back when setup is complete - onSetupComplete: () { - // Navigation is handled inside SetupGuideScreen using its own context - }, - ), - ), - ); - } - }, - style: ElevatedButton.styleFrom(backgroundColor: Colors.orange), - child: Text(l10n.reconfigure), - ), - ], - ), - ); - } } diff --git a/pubspec.lock b/pubspec.lock index e09173e..286f516 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -218,7 +218,7 @@ packages: source: hosted version: "3.1.2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/pubspec.yaml b/pubspec.yaml index 800c544..7a1346b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: logger: ^2.0.2+1 url_launcher: ^6.2.4 path: ^1.9.0 + crypto: ^3.0.3 path_provider: ^2.1.2 # Image Caching