diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-20 23:42:34 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-20 23:42:34 +0300 |
| commit | ef0c310211d180d57f060aca3802bfed0958d312 (patch) | |
| tree | 7c3fedd84931dcc97aa7b27206c3797f38c32325 /player-android/lib/providers | |
| parent | 1a68b1aead492b50d824bde99fb1f30e1360ed26 (diff) | |
Add go_router + Riverpod wiring and extract SOLID-clean screen/nav layers (na)
Introduces flutter_riverpod and go_router dependencies. Wires up the app
with a Riverpod-managed GoRouter, auth-guarded redirect logic, and a shared
navigator key used by DioClient for 401 → /login redirects.
SOLID fixes applied:
- navigatorKey extracted to navigation_key.dart (DIP: breaks cross-layer
import from router.dart into api_client_provider.dart)
- AppRoutes extracted to app_routes.dart (SRP + avoids circular import
when screen files reference route constants)
- LoginScreen, HomeScreen, MediaDetailScreen, ShareScreen each moved to
their own file under lib/screens/ (SRP: router.dart is routing-only)
- router.dart re-exports AppRoutes for backward-compatible callers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android/lib/providers')
| -rw-r--r-- | player-android/lib/providers/api_client_provider.dart | 45 | ||||
| -rw-r--r-- | player-android/lib/providers/auth_state_provider.dart | 93 |
2 files changed, 138 insertions, 0 deletions
diff --git a/player-android/lib/providers/api_client_provider.dart b/player-android/lib/providers/api_client_provider.dart new file mode 100644 index 0000000..6ca177f --- /dev/null +++ b/player-android/lib/providers/api_client_provider.dart @@ -0,0 +1,45 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/dio_client.dart'; +import '../api/player_api_client.dart'; +import '../navigation_key.dart'; + +/// Base URL for the player-server API. +/// +/// In production this is injected from the environment or a config file. +/// The default points to a local dev instance so the app is runnable +/// without extra configuration. +const _kBaseUrl = String.fromEnvironment( + 'PLAYER_BASE_URL', + defaultValue: 'http://10.0.2.2:8080', +); + +/// Provides the production [TokenStorage] backed by the OS keychain. +/// +/// Riverpod keeps a single instance for the lifetime of [ProviderScope], so +/// there is exactly one [SecureTokenStorage] in the app — consistent with the +/// singleton intent of flutter_secure_storage. +final tokenStorageProvider = Provider<TokenStorage>((ref) { + return SecureTokenStorage(); +}); + +/// Provides a fully configured [PlayerApiClient] wired with bearer-token +/// injection and global 401 → /login redirect. +/// +/// Depends on [tokenStorageProvider] and [navigatorKey] (both singletons) so +/// the same [Dio] instance is reused for every call site — avoiding redundant +/// interceptor stacks. +final apiClientProvider = Provider<PlayerApiClient>((ref) { + final storage = ref.watch(tokenStorageProvider); + + final dioClient = DioClient( + baseUrl: Uri.parse(_kBaseUrl), + storage: storage, + // Share the navigator key with go_router so 401 redirects go through the + // correct router instance rather than the raw Navigator. + navigatorKey: navigatorKey, + loginRoute: '/login', + ); + + return PlayerApiClient(dio: dioClient.dio); +}); diff --git a/player-android/lib/providers/auth_state_provider.dart b/player-android/lib/providers/auth_state_provider.dart new file mode 100644 index 0000000..0e0c6fb --- /dev/null +++ b/player-android/lib/providers/auth_state_provider.dart @@ -0,0 +1,93 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'api_client_provider.dart'; + +/// All possible authentication states for the app. +/// +/// Using a sealed-like enum keeps the router redirect logic exhaustive and +/// avoids stringly-typed checks throughout the codebase. +enum AuthStatus { + /// Initial state while the app checks whether a stored token exists. + loading, + + /// A valid token was found in secure storage; the user is logged in. + authenticated, + + /// No token exists or it has been purged (e.g. after a 401 response). + unauthenticated, +} + +/// Immutable snapshot of the auth state passed through the provider graph. +/// +/// Keeping this as a value object (rather than a mutable notifier field) +/// makes it safe to pass into go_router's redirect callback and to compare +/// with `==` in tests. +class AuthState { + const AuthState({required this.status}); + + final AuthStatus status; + + /// Convenience constructors reduce noise at call sites. + const AuthState.loading() : status = AuthStatus.loading; + const AuthState.authenticated() : status = AuthStatus.authenticated; + const AuthState.unauthenticated() : status = AuthStatus.unauthenticated; + + bool get isLoading => status == AuthStatus.loading; + bool get isAuthenticated => status == AuthStatus.authenticated; + bool get isUnauthenticated => status == AuthStatus.unauthenticated; + + @override + String toString() => 'AuthState(${status.name})'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AuthState && + runtimeType == other.runtimeType && + status == other.status; + + @override + int get hashCode => status.hashCode; +} + +/// Notifier that owns the mutable [AuthState] and exposes mutation methods +/// for login / logout. +/// +/// [AsyncNotifier] is used because the initial state check is async (it reads +/// the secure token store). Downstream consumers can call [login] and +/// [logout] to drive route redirects via the router's [refreshListenable]. +class AuthStateNotifier extends AsyncNotifier<AuthState> { + @override + Future<AuthState> build() async { + // Determine whether a token already exists on app startup. This drives + // the initial route decision inside the go_router redirect callback. + final storage = ref.read(tokenStorageProvider); + final token = await storage.readToken(); + + return token != null && token.isNotEmpty + ? const AuthState.authenticated() + : const AuthState.unauthenticated(); + } + + /// Called after a successful login; persists [token] and updates state. + Future<void> login(String token) async { + final storage = ref.read(tokenStorageProvider); + await storage.writeToken(token); + state = const AsyncData(AuthState.authenticated()); + } + + /// Called on explicit logout or after [_UnauthorizedInterceptor] purges the + /// token. Clears the stored token and moves to the unauthenticated state. + Future<void> logout() async { + final storage = ref.read(tokenStorageProvider); + await storage.deleteToken(); + state = const AsyncData(AuthState.unauthenticated()); + } +} + +/// The single source of truth for authentication status, consumed by the +/// router's redirect callback and any widget that needs to gate on auth. +final authStateProvider = + AsyncNotifierProvider<AuthStateNotifier, AuthState>( + AuthStateNotifier.new, +); |
