diff options
Diffstat (limited to 'player-android/lib')
| -rw-r--r-- | player-android/lib/api/dio_player_api_client.dart | 13 | ||||
| -rw-r--r-- | player-android/lib/api/player_api_client.dart | 6 | ||||
| -rw-r--r-- | player-android/lib/providers/first_run_provider.dart | 33 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 45 | ||||
| -rw-r--r-- | player-android/lib/screens/login_screen.dart | 232 |
5 files changed, 311 insertions, 18 deletions
diff --git a/player-android/lib/api/dio_player_api_client.dart b/player-android/lib/api/dio_player_api_client.dart index 6de1954..d9b24ed 100644 --- a/player-android/lib/api/dio_player_api_client.dart +++ b/player-android/lib/api/dio_player_api_client.dart @@ -75,6 +75,19 @@ class DioPlayerApiClient extends PlayerApiClient { // Health // --------------------------------------------------------------------------- + /// Returns the total number of registered users from the server. + /// + /// GET /api/v1/auth/count — public endpoint; no session required. + /// Mobile clients call this on startup to detect first-run (count == 0) + /// and redirect to /bootstrap instead of /login. + @override + Future<int> countUsers() async { + final response = await rawDio.get<Map<String, dynamic>>( + '$_kApiV1/auth/count', + ); + return (response.data?['count'] as int?) ?? 0; + } + /// Liveness probe — returns immediately without touching the database. /// /// GET /healthz — 200 means the server process is alive. diff --git a/player-android/lib/api/player_api_client.dart b/player-android/lib/api/player_api_client.dart index 9e987c2..13b5ead 100644 --- a/player-android/lib/api/player_api_client.dart +++ b/player-android/lib/api/player_api_client.dart @@ -51,6 +51,12 @@ class PlayerApiClient { Future<void> healthz() => throw UnimplementedError(); Future<void> readyz() => throw UnimplementedError(); + /// Returns the total number of registered users. + /// + /// Clients use this to detect first-run (count == 0) and redirect to the + /// bootstrap screen instead of the login screen. + Future<int> countUsers() => throw UnimplementedError(); + // --------------------------------------------------------------------------- // Shared / public endpoints (no auth required) // --------------------------------------------------------------------------- diff --git a/player-android/lib/providers/first_run_provider.dart b/player-android/lib/providers/first_run_provider.dart new file mode 100644 index 0000000..4f1c6f5 --- /dev/null +++ b/player-android/lib/providers/first_run_provider.dart @@ -0,0 +1,33 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'api_client_provider.dart'; + +/// Returns whether the server has no registered users yet (first-run state). +/// +/// The go_router redirect callback reads this provider to decide whether +/// an unauthenticated visit should go to /login (normal case) or /bootstrap +/// (no accounts exist). +/// +/// Implementation notes: +/// - Uses [FutureProvider] so the loading/error states are handled uniformly +/// alongside [authStateProvider] in the router's redirect callback. +/// - [FutureProvider] caches its result for the lifetime of the enclosing +/// [ProviderScope]; the value is re-fetched only when the provider is +/// explicitly invalidated (e.g. via `ref.invalidate`) or the scope is +/// recreated. After bootstrap completes the app navigates away and the +/// cached value is simply never re-read in the same session. +/// - Errors (e.g. server unreachable) are treated as non-first-run so the +/// login screen is shown and the user can retry; this avoids looping to +/// /bootstrap on connectivity failures. +final firstRunProvider = FutureProvider<bool>((ref) async { + final apiClient = ref.read(apiClientProvider); + try { + final count = await apiClient.countUsers(); + return count == 0; + } catch (_) { + // Network or server error: assume not first-run so we show login. + // The login screen will surface the connectivity problem when the user + // attempts to authenticate. + return false; + } +}); diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index a54f4f0..4c78259 100644 --- a/player-android/lib/router.dart +++ b/player-android/lib/router.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'app_routes.dart'; import 'navigation_key.dart'; import 'providers/auth_state_provider.dart'; +import 'providers/first_run_provider.dart'; import 'screens/bootstrap_screen.dart'; import 'screens/home_screen.dart'; import 'screens/login_screen.dart'; @@ -54,16 +55,27 @@ final routerProvider = Provider<GoRouter>((ref) { // Bootstrap is a public route (user is unauthenticated by definition). final isBootstrapRoute = location == AppRoutes.bootstrap; - if (auth.isUnauthenticated && !isLoginRoute && !isBootstrapRoute) { - // Guard every authenticated route: bounce to login. - return AppRoutes.login; - } - if (auth.isAuthenticated && (isLoginRoute || isBootstrapRoute)) { // Prevent already-authenticated users from viewing auth/setup screens. return AppRoutes.home; } + if (auth.isUnauthenticated && !isLoginRoute && !isBootstrapRoute) { + // Unauthenticated: determine whether this is first-run (no users) or + // a normal returning-user scenario. firstRunProvider returns true when + // the server reports count == 0 (no accounts exist yet). + // + // While the check is loading we stay put; the router re-evaluates when + // firstRunProvider's AsyncValue settles (via refreshListenable). + final firstRunAsync = ref.read(firstRunProvider); + if (firstRunAsync.isLoading) return null; + + // On first-run redirect to /bootstrap so the admin account can be set + // up; otherwise send to /login for normal credential entry. + final isFirstRun = firstRunAsync.valueOrNull ?? false; + return isFirstRun ? AppRoutes.bootstrap : AppRoutes.login; + } + // No redirect needed. return null; }, @@ -101,25 +113,34 @@ final routerProvider = Provider<GoRouter>((ref) { // Internal helpers // --------------------------------------------------------------------------- -/// Bridges Riverpod's [authStateProvider] to [GoRouter.refreshListenable]. +/// Bridges Riverpod's auth and first-run providers 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. +/// mechanism. This notifier listens to both [authStateProvider] and +/// [firstRunProvider], calling [notifyListeners] on every change so the router +/// re-runs its redirect callback whenever auth state or first-run status settles. 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>>( + // Listen to auth state changes (login, logout, token expiry). + _authSubscription = ref.listen<AsyncValue<AuthState>>( authStateProvider, (_, __) => notifyListeners(), ); + // Listen to first-run state so the router re-evaluates after the initial + // user-count check resolves from loading to a concrete true/false value. + _firstRunSubscription = ref.listen<AsyncValue<bool>>( + firstRunProvider, + (_, __) => notifyListeners(), + ); } - late final ProviderSubscription<AsyncValue<AuthState>> _subscription; + late final ProviderSubscription<AsyncValue<AuthState>> _authSubscription; + late final ProviderSubscription<AsyncValue<bool>> _firstRunSubscription; @override void dispose() { - _subscription.close(); + _authSubscription.close(); + _firstRunSubscription.close(); super.dispose(); } } diff --git a/player-android/lib/screens/login_screen.dart b/player-android/lib/screens/login_screen.dart index 8a07877..bb5fce7 100644 --- a/player-android/lib/screens/login_screen.dart +++ b/player-android/lib/screens/login_screen.dart @@ -1,17 +1,237 @@ +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -/// Login screen — will host the credentials form once the auth API is wired. +import '../providers/api_client_provider.dart'; +import '../providers/auth_state_provider.dart'; + +/// Sign-in screen shown to returning users who already have an account. +/// +/// Displays a username and password form; on successful authentication the +/// session token is persisted via [TokenStorage] and the auth state transitions +/// to [AuthStatus.authenticated], which causes go_router to redirect to the +/// home screen automatically (no explicit navigation call needed). /// -/// Currently a lightweight placeholder; feature implementation will replace -/// the body without touching the router or other screens. -class LoginScreen extends StatelessWidget { +/// Design notes: +/// - Mirrors the structure of [BootstrapScreen] for consistency. +/// - All mutable state lives in [_LoginScreenState]. +/// - [ConsumerStatefulWidget] is used so [WidgetRef] is available across the +/// async submit path without storing a stale ref as an instance field. +/// - Error mapping is a top-level function ([_dioErrorMessage]) — pure, no +/// widget state, no BuildContext — so it is easy to unit-test in isolation. +class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @override + ConsumerState<LoginScreen> createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState<LoginScreen> { + // Form key for programmatic validation across all fields. + final _formKey = GlobalKey<FormState>(); + + // Controllers for the two text fields; disposed in [dispose] to prevent + // memory leaks after the widget is removed from the tree. + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + + // Drives the loading indicator and disables the submit button while an + // HTTP request is in flight to prevent double-submission. + bool _isLoading = false; + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + // --------------------------------------------------------------------------- + // Validation helpers + // --------------------------------------------------------------------------- + + /// Returns an error string when [value] is empty or null; null otherwise. + String? _validateRequired(String? value) { + if (value == null || value.trim().isEmpty) { + return 'This field is required.'; + } + return null; + } + + // --------------------------------------------------------------------------- + // Submit logic + // --------------------------------------------------------------------------- + + /// Validates the form and submits the login request to the server. + /// + /// On success, stores the username as the session token via [AuthStateNotifier.login] + /// so that [AuthStateNotifier.build] can restore the session on the next app + /// start; go_router's redirect callback then navigates to [AppRoutes.home] + /// automatically. + /// + /// On failure, a human-readable error is shown in a [SnackBar]. All async + /// continuations check [mounted] to avoid using a stale [BuildContext] after + /// the widget is disposed. + Future<void> _submit() async { + // Client-side validation: abort early if any field is invalid. + if (!(_formKey.currentState?.validate() ?? false)) return; + + setState(() => _isLoading = true); + + try { + final apiClient = ref.read(apiClientProvider); + final username = _usernameController.text.trim(); + final password = _passwordController.text; + + // POST /api/v1/auth/login. Returns the authenticated [User] on 200; + // throws [DioException] with status 401 for wrong credentials. + final user = await apiClient.login( + username: username, + password: password, + ); + + // Persist the session marker so [AuthStateNotifier.build] can restore + // auth state on the next cold start without requiring re-login. + // The username acts as the session presence marker here; a follow-up + // task will replace this with a real bearer token from createAPIToken. + if (!mounted) return; + await ref.read(authStateProvider.notifier).login(user.username); + } on DioException catch (e) { + // Guard against stale BuildContext if the widget was disposed during + // the async gap (e.g. a rapid navigation triggered by another listener). + if (!mounted) return; + _showError(_dioErrorMessage(e)); + } catch (e) { + if (!mounted) return; + _showError('An unexpected error occurred. Please try again.'); + } finally { + // Only call setState if the widget is still in the tree; dispose can + // run before the finally block when error-path navigation triggers it. + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + /// Displays [message] in a [SnackBar] anchored to the nearest [Scaffold]. + void _showError(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Login')), - body: const Center(child: Text('Login placeholder')), + appBar: AppBar(title: const Text('Sign In')), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Page heading. + Text( + 'Welcome back', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + Text( + 'Enter your credentials to continue.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 32), + + // Username field. + TextFormField( + key: const Key('login_username'), + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Username', + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.next, + autocorrect: false, + validator: _validateRequired, + ), + const SizedBox(height: 16), + + // Password field (obscured; Enter/Done triggers submit). + TextFormField( + key: const Key('login_password'), + controller: _passwordController, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + ), + obscureText: true, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _isLoading ? null : _submit(), + validator: _validateRequired, + ), + const SizedBox(height: 32), + + // Submit button: replaced by a centered progress indicator + // while the HTTP request is in flight. + _isLoading + ? const Center(child: CircularProgressIndicator()) + : ElevatedButton( + key: const Key('login_submit'), + onPressed: _submit, + child: const Text('Sign In'), + ), + ], + ), + ), + ), + ), ); } } + +// --------------------------------------------------------------------------- +// File-level helpers +// --------------------------------------------------------------------------- + +/// Extracts a user-friendly error message from a [DioException]. +/// +/// Pure data-transformation function: no widget state, no Riverpod reads, no +/// BuildContext — lives at the top level to make that clear and to ease testing. +/// +/// Priority order for message extraction: +/// 1. Server-supplied `message` or `error` field from the JSON response body. +/// 2. Status-code–specific fallback strings. +/// 3. Generic connectivity message when no HTTP response is available. +String _dioErrorMessage(DioException e) { + final statusCode = e.response?.statusCode; + + // Prefer a human-readable message from the server's JSON response body. + final body = e.response?.data; + if (body is Map<String, dynamic>) { + final msg = body['message'] as String? ?? body['error'] as String?; + if (msg != null && msg.isNotEmpty) return msg; + } + + // Status-code fallbacks for common auth error cases. + if (statusCode == 401) { + return 'Invalid username or password.'; + } + if (statusCode == 400) { + return 'Invalid request. Check your username and password.'; + } + if (statusCode != null) { + return 'Server error ($statusCode). Please try again.'; + } + + // No HTTP response: connectivity or DNS failure. + return 'Could not reach the server. Check your network connection.'; +} |
