summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-20 23:42:34 +0300
committerPaul Buetow <paul@buetow.org>2026-05-20 23:42:34 +0300
commitef0c310211d180d57f060aca3802bfed0958d312 (patch)
tree7c3fedd84931dcc97aa7b27206c3797f38c32325
parent1a68b1aead492b50d824bde99fb1f30e1360ed26 (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>
-rw-r--r--player-android/lib/app_routes.dart14
-rw-r--r--player-android/lib/main.dart57
-rw-r--r--player-android/lib/navigation_key.dart9
-rw-r--r--player-android/lib/providers/api_client_provider.dart45
-rw-r--r--player-android/lib/providers/auth_state_provider.dart93
-rw-r--r--player-android/lib/router.dart117
-rw-r--r--player-android/lib/screens/home_screen.dart26
-rw-r--r--player-android/lib/screens/login_screen.dart17
-rw-r--r--player-android/lib/screens/media_detail_screen.dart20
-rw-r--r--player-android/lib/screens/share_screen.dart17
-rw-r--r--player-android/pubspec.lock32
-rw-r--r--player-android/pubspec.yaml4
12 files changed, 411 insertions, 40 deletions
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<NavigatorState>(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<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,
+);
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<GoRouter>((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<AsyncValue<AuthState>>(
+ authStateProvider,
+ (_, __) => notifyListeners(),
+ );
+ }
+
+ late final ProviderSubscription<AsyncValue<AuthState>> _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: