summaryrefslogtreecommitdiff
path: root/player-android/lib
diff options
context:
space:
mode:
Diffstat (limited to 'player-android/lib')
-rw-r--r--player-android/lib/api/dio_player_api_client.dart13
-rw-r--r--player-android/lib/api/player_api_client.dart6
-rw-r--r--player-android/lib/providers/first_run_provider.dart33
-rw-r--r--player-android/lib/router.dart45
-rw-r--r--player-android/lib/screens/login_screen.dart232
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.';
+}