From ef0c310211d180d57f060aca3802bfed0958d312 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 20 May 2026 23:42:34 +0300 Subject: Add go_router + Riverpod wiring and extract SOLID-clean screen/nav layers (na) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- player-android/lib/app_routes.dart | 14 +++ player-android/lib/main.dart | 57 +++------- player-android/lib/navigation_key.dart | 9 ++ .../lib/providers/api_client_provider.dart | 45 ++++++++ .../lib/providers/auth_state_provider.dart | 93 ++++++++++++++++ player-android/lib/router.dart | 117 +++++++++++++++++++++ player-android/lib/screens/home_screen.dart | 26 +++++ player-android/lib/screens/login_screen.dart | 17 +++ .../lib/screens/media_detail_screen.dart | 20 ++++ player-android/lib/screens/share_screen.dart | 17 +++ player-android/pubspec.lock | 32 ++++++ player-android/pubspec.yaml | 4 + 12 files changed, 411 insertions(+), 40 deletions(-) create mode 100644 player-android/lib/app_routes.dart create mode 100644 player-android/lib/navigation_key.dart create mode 100644 player-android/lib/providers/api_client_provider.dart create mode 100644 player-android/lib/providers/auth_state_provider.dart create mode 100644 player-android/lib/router.dart create mode 100644 player-android/lib/screens/home_screen.dart create mode 100644 player-android/lib/screens/login_screen.dart create mode 100644 player-android/lib/screens/media_detail_screen.dart create mode 100644 player-android/lib/screens/share_screen.dart diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart new file mode 100644 index 0000000..be3ff7c --- /dev/null +++ b/player-android/lib/app_routes.dart @@ -0,0 +1,14 @@ +/// Centralised route path constants for go_router. +/// +/// Keeping these in a standalone file lets screen widgets reference route +/// paths without importing router.dart, which would create circular imports +/// (router.dart imports screen files; screens should not import the router). +abstract final class AppRoutes { + static const login = '/login'; + static const home = '/home'; + static const mediaDetail = '/media/:id'; + static const share = '/share'; + + /// Returns the concrete path for a media-detail page given a numeric [id]. + static String mediaDetailPath(int id) => '/media/$id'; +} diff --git a/player-android/lib/main.dart b/player-android/lib/main.dart index eed6e99..156b8f8 100644 --- a/player-android/lib/main.dart +++ b/player-android/lib/main.dart @@ -1,50 +1,27 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -void main() => runApp(const PlayerAndroidApp()); +import 'router.dart'; -class PlayerAndroidApp extends StatelessWidget { - const PlayerAndroidApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Player', - initialRoute: '/', - routes: { - '/': (context) => const HomeScreen(), - '/now-playing': (context) => const NowPlayingScreen(), - }, - ); - } -} +/// Entry point — wraps the whole widget tree in a [ProviderScope] so every +/// widget and provider has access to the Riverpod container. +void main() => runApp(const ProviderScope(child: PlayerAndroidApp())); -// HomeScreen shows the media library. Placeholder until the library API is wired. -class HomeScreen extends StatelessWidget { - const HomeScreen({super.key}); +/// Root application widget. +/// +/// Uses [ConsumerWidget] to read [routerProvider] from Riverpod so that the +/// same [GoRouter] instance (and its navigator key) is reused across rebuilds. +/// [MaterialApp.router] delegates all navigation decisions to go_router. +class PlayerAndroidApp extends ConsumerWidget { + const PlayerAndroidApp({super.key}); @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Library')), - body: Center( - child: ElevatedButton( - onPressed: () => Navigator.pushNamed(context, '/now-playing'), - child: const Text('Now Playing'), - ), - ), - ); - } -} + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(routerProvider); -// NowPlayingScreen shows the active media item. Placeholder until playback is wired. -class NowPlayingScreen extends StatelessWidget { - const NowPlayingScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Now Playing')), - body: const Center(child: Text('No media selected')), + return MaterialApp.router( + title: 'Player', + routerConfig: router, ); } } diff --git a/player-android/lib/navigation_key.dart b/player-android/lib/navigation_key.dart new file mode 100644 index 0000000..8a2dee7 --- /dev/null +++ b/player-android/lib/navigation_key.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +/// The go_router navigator key, shared between [routerProvider] and +/// [_UnauthorizedInterceptor] in [DioClient] so that 401 responses can +/// trigger a navigation to /login without requiring a [BuildContext]. +/// +/// Kept in a dedicated file to break any circular import that would arise +/// if router.dart and api_client_provider.dart tried to import each other. +final navigatorKey = GlobalKey(debugLabel: 'go_router'); 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((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((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 { + @override + Future 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 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 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.new, +); diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart new file mode 100644 index 0000000..45775cf --- /dev/null +++ b/player-android/lib/router.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'app_routes.dart'; +import 'navigation_key.dart'; +import 'providers/auth_state_provider.dart'; +import 'screens/home_screen.dart'; +import 'screens/login_screen.dart'; +import 'screens/media_detail_screen.dart'; +import 'screens/share_screen.dart'; + +// Re-export AppRoutes so existing callers that import router.dart for routes +// do not need to change their import path. +export 'app_routes.dart' show AppRoutes; + +// --------------------------------------------------------------------------- +// Router provider +// --------------------------------------------------------------------------- + +/// Builds the [GoRouter] instance as a Riverpod [Provider] so that: +/// 1. The navigator key is shared with [DioClient] (enabling 401 redirects). +/// 2. The [refreshListenable] is driven by [authStateProvider] changes, +/// which triggers redirect re-evaluation on every auth state transition. +/// 3. The provider is created lazily and disposed with [ProviderScope]. +final routerProvider = Provider((ref) { + // Watch auth state so the router is rebuilt when it changes. + // Using a ChangeNotifier bridge because GoRouter's refreshListenable expects + // a Listenable, while Riverpod exposes streams/notifiers. + final notifier = _RouterRefreshNotifier(ref); + + return GoRouter( + // Share the navigator key with DioClient so imperative 401 redirects + // work through go_router rather than the raw Navigator. + navigatorKey: navigatorKey, + + // Trigger redirect re-evaluation whenever auth state changes. + refreshListenable: notifier, + + // Default entry point before redirect logic resolves. + initialLocation: AppRoutes.home, + + redirect: (context, state) { + final authAsync = ref.read(authStateProvider); + + // While the initial token check is in-flight, hold the current path. + // The router will re-evaluate once refreshListenable fires. + if (authAsync.isLoading || authAsync.hasError) return null; + + final auth = authAsync.requireValue; + final isLoginRoute = state.matchedLocation == AppRoutes.login; + + if (auth.isUnauthenticated && !isLoginRoute) { + // Guard every authenticated route: bounce to login. + return AppRoutes.login; + } + + if (auth.isAuthenticated && isLoginRoute) { + // Prevent the user from seeing the login screen once authenticated. + return AppRoutes.home; + } + + // No redirect needed. + return null; + }, + + routes: [ + GoRoute( + path: AppRoutes.login, + builder: (context, state) => const LoginScreen(), + ), + GoRoute( + path: AppRoutes.home, + builder: (context, state) => const HomeScreen(), + ), + GoRoute( + path: AppRoutes.mediaDetail, + builder: (context, state) { + // The ':id' path parameter is guaranteed by the route pattern. + final id = state.pathParameters['id']!; + return MediaDetailScreen(mediaId: id); + }, + ), + GoRoute( + path: AppRoutes.share, + builder: (context, state) => const ShareScreen(), + ), + ], + ); +}); + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Bridges Riverpod's [authStateProvider] to [GoRouter.refreshListenable]. +/// +/// GoRouter expects a [ChangeNotifier] (or any [Listenable]) for its refresh +/// mechanism. This notifier listens to the provider and calls [notifyListeners] +/// on every change, causing the router to re-run its redirect callback. +class _RouterRefreshNotifier extends ChangeNotifier { + _RouterRefreshNotifier(Ref ref) { + // Keep a reference to the subscription so we can cancel it on dispose. + _subscription = ref.listen>( + authStateProvider, + (_, __) => notifyListeners(), + ); + } + + late final ProviderSubscription> _subscription; + + @override + void dispose() { + _subscription.close(); + super.dispose(); + } +} diff --git a/player-android/lib/screens/home_screen.dart b/player-android/lib/screens/home_screen.dart new file mode 100644 index 0000000..f09d0e1 --- /dev/null +++ b/player-android/lib/screens/home_screen.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../app_routes.dart'; + +/// Home screen — will show the media library once the list API is wired. +/// +/// Uses [ConsumerWidget] so it can later watch Riverpod providers (e.g. +/// a media-list provider) without changing the class hierarchy. +class HomeScreen extends ConsumerWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text('Library')), + body: Center( + child: ElevatedButton( + onPressed: () => context.go(AppRoutes.mediaDetailPath(0)), + child: const Text('Open media (placeholder)'), + ), + ), + ); + } +} diff --git a/player-android/lib/screens/login_screen.dart b/player-android/lib/screens/login_screen.dart new file mode 100644 index 0000000..8a07877 --- /dev/null +++ b/player-android/lib/screens/login_screen.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +/// Login screen — will host the credentials form once the auth API is wired. +/// +/// Currently a lightweight placeholder; feature implementation will replace +/// the body without touching the router or other screens. +class LoginScreen extends StatelessWidget { + const LoginScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Login')), + body: const Center(child: Text('Login placeholder')), + ); + } +} diff --git a/player-android/lib/screens/media_detail_screen.dart b/player-android/lib/screens/media_detail_screen.dart new file mode 100644 index 0000000..c705a98 --- /dev/null +++ b/player-android/lib/screens/media_detail_screen.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +/// Media-detail screen — will display a single media item with player controls. +/// +/// Currently a lightweight placeholder; feature implementation will replace +/// the body without touching the router or other screens. +class MediaDetailScreen extends StatelessWidget { + const MediaDetailScreen({super.key, required this.mediaId}); + + /// The string form of the media ID extracted from the route path parameter. + final String mediaId; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Media $mediaId')), + body: Center(child: Text('Media detail for ID $mediaId – placeholder')), + ); + } +} diff --git a/player-android/lib/screens/share_screen.dart b/player-android/lib/screens/share_screen.dart new file mode 100644 index 0000000..df8a061 --- /dev/null +++ b/player-android/lib/screens/share_screen.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +/// Share screen — will list and manage share links for a media item. +/// +/// Currently a lightweight placeholder; feature implementation will replace +/// the body without touching the router or other screens. +class ShareScreen extends StatelessWidget { + const ShareScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Share')), + body: const Center(child: Text('Share placeholder')), + ); + } +} diff --git a/player-android/pubspec.lock b/player-android/pubspec.lock index 899bcb4..629cd8c 100644 --- a/player-android/pubspec.lock +++ b/player-android/pubspec.lock @@ -118,6 +118,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" flutter_secure_storage: dependency: "direct main" description: @@ -184,6 +192,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" hooks: dependency: transitive description: @@ -408,6 +424,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" sky_engine: dependency: transitive description: flutter @@ -429,6 +453,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: diff --git a/player-android/pubspec.yaml b/player-android/pubspec.yaml index 6464a8b..e1743a5 100644 --- a/player-android/pubspec.yaml +++ b/player-android/pubspec.yaml @@ -13,6 +13,10 @@ dependencies: dio: ^5.7.0 # flutter_secure_storage: stores the bearer token in the OS keychain/keystore. flutter_secure_storage: ^9.2.2 + # flutter_riverpod: reactive state-management; provides ProviderScope, ref.watch, etc. + flutter_riverpod: ^2.6.1 + # go_router: declarative routing for Flutter; replaces imperative Navigator calls. + go_router: ^14.8.1 dev_dependencies: flutter_test: -- cgit v1.2.3