summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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
-rw-r--r--player-android/test/screens/login_screen_test.dart380
-rw-r--r--player-android/test/widget_smoke_test.dart81
-rw-r--r--player-server/internal/api/handlers_auth.go18
-rw-r--r--player-server/internal/api/handlers_test.go62
-rw-r--r--player-server/internal/api/server.go3
10 files changed, 827 insertions, 46 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.';
+}
diff --git a/player-android/test/screens/login_screen_test.dart b/player-android/test/screens/login_screen_test.dart
new file mode 100644
index 0000000..d05e4a2
--- /dev/null
+++ b/player-android/test/screens/login_screen_test.dart
@@ -0,0 +1,380 @@
+// Widget tests for LoginScreen.
+//
+// Tests cover:
+// 1. Successful login: API called with correct credentials, token persisted,
+// auth state transitions to authenticated.
+// 2. 401 error display: invalid-credentials response shows the error message.
+// 3. Network error display: connection failure shows connectivity message.
+// 4. Loading state: CircularProgressIndicator replaces the submit button
+// while the HTTP request is in flight.
+// 5. Form validation: empty fields prevent submission.
+//
+// Riverpod providers are overridden with fakes so tests run without a real
+// server or OS keychain.
+//
+// Run with: flutter test test/screens/login_screen_test.dart
+
+import 'dart:async';
+
+import 'package:dio/dio.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:player_android/api/dio_client.dart';
+import 'package:player_android/api/player_api_client.dart';
+import 'package:player_android/models/models.dart';
+import 'package:player_android/providers/api_client_provider.dart';
+import 'package:player_android/screens/login_screen.dart';
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+/// In-memory [TokenStorage] used to avoid the platform-specific OS keychain
+/// in tests. Stores the token in a plain Dart field.
+class _FakeTokenStorage implements TokenStorage {
+ String? _token;
+
+ @override
+ Future<String?> readToken() async => _token;
+
+ @override
+ Future<void> writeToken(String token) async => _token = token;
+
+ @override
+ Future<void> deleteToken() async => _token = null;
+}
+
+/// [PlayerApiClient] stub whose [login] behaviour is controlled by the test
+/// via [loginResult] and [loginError].
+///
+/// Every other method is left as [UnimplementedError] — [LoginScreen] only
+/// calls [login].
+class _FakeApiClient extends PlayerApiClient {
+ _FakeApiClient() : super(dio: Dio());
+
+ /// When non-null, [login] returns this [User].
+ User? loginResult;
+
+ /// When non-null, [login] throws this exception instead of returning.
+ Object? loginError;
+
+ // Captures the last credentials passed to login for assertion in tests.
+ String? capturedUsername;
+ String? capturedPassword;
+
+ @override
+ Future<User> login({
+ required String username,
+ required String password,
+ }) async {
+ capturedUsername = username;
+ capturedPassword = password;
+ if (loginError != null) throw loginError!;
+ return loginResult!;
+ }
+}
+
+/// [PlayerApiClient] stub that delays the [login] response until [complete]
+/// is called, allowing tests to inspect the loading state mid-flight.
+class _DelayedFakeApiClient extends PlayerApiClient {
+ _DelayedFakeApiClient() : super(dio: Dio());
+
+ // Completer that the test resolves at a chosen point in time.
+ final _completer = Completer<User>();
+
+ /// Resolves the pending login call with [user].
+ void complete(User user) => _completer.complete(user);
+
+ @override
+ Future<User> login({
+ required String username,
+ required String password,
+ }) =>
+ _completer.future;
+}
+
+// ---------------------------------------------------------------------------
+// Helper: build the widget under test inside a minimal ProviderScope.
+// ---------------------------------------------------------------------------
+
+/// Pumps [LoginScreen] inside a [ProviderScope] that overrides:
+/// - [apiClientProvider] with [fakeClient]
+/// - [tokenStorageProvider] with an in-memory fake (avoids platform keychain)
+///
+/// Returns the [_FakeTokenStorage] so callers can inspect what was persisted.
+Future<_FakeTokenStorage> _pumpLoginScreen(
+ WidgetTester tester,
+ PlayerApiClient fakeClient,
+) async {
+ final fakeStorage = _FakeTokenStorage();
+
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ // Avoid OS keychain calls during tests.
+ tokenStorageProvider.overrideWithValue(fakeStorage),
+ // Use the controllable fake instead of a real HTTP client.
+ apiClientProvider.overrideWithValue(fakeClient),
+ ],
+ child: const MaterialApp(
+ home: LoginScreen(),
+ ),
+ ),
+ );
+
+ return fakeStorage;
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+void main() {
+ // --------------------------------------------------------------------------
+ // Form validation
+ // --------------------------------------------------------------------------
+
+ group('form validation', () {
+ testWidgets('submitting empty form shows required-field errors',
+ (tester) async {
+ final fakeClient = _FakeApiClient();
+ await _pumpLoginScreen(tester, fakeClient);
+
+ // Tap submit without filling any field.
+ await tester.tap(find.byKey(const Key('login_submit')));
+ await tester.pump();
+
+ // Both fields should show a required-field error.
+ expect(find.text('This field is required.'), findsNWidgets(2));
+ // No API call should have been made.
+ expect(fakeClient.capturedUsername, isNull);
+ });
+
+ testWidgets('empty username shows required error', (tester) async {
+ final fakeClient = _FakeApiClient();
+ await _pumpLoginScreen(tester, fakeClient);
+
+ // Fill in password but leave username empty.
+ await tester.enterText(
+ find.byKey(const Key('login_password')), 'secret123');
+ await tester.tap(find.byKey(const Key('login_submit')));
+ await tester.pump();
+
+ expect(find.text('This field is required.'), findsOneWidget);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Successful login
+ // --------------------------------------------------------------------------
+
+ group('successful login', () {
+ testWidgets('calls login with correct credentials', (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..loginResult = const User(id: 1, username: 'alice', isAdmin: false);
+
+ await _pumpLoginScreen(tester, fakeClient);
+
+ await tester.enterText(
+ find.byKey(const Key('login_username')), 'alice');
+ await tester.enterText(
+ find.byKey(const Key('login_password')), 'mysecret');
+
+ await tester.tap(find.byKey(const Key('login_submit')));
+ await tester.pump();
+ await tester.pumpAndSettle();
+
+ // The fake should have received the exact credentials.
+ expect(fakeClient.capturedUsername, equals('alice'));
+ expect(fakeClient.capturedPassword, equals('mysecret'));
+ });
+
+ testWidgets('persists returned username as session token', (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..loginResult = const User(id: 2, username: 'bob', isAdmin: false);
+
+ final fakeStorage = await _pumpLoginScreen(tester, fakeClient);
+
+ await tester.enterText(
+ find.byKey(const Key('login_username')), 'bob');
+ await tester.enterText(
+ find.byKey(const Key('login_password')), 'supersecret');
+
+ await tester.tap(find.byKey(const Key('login_submit')));
+ await tester.pump();
+ await tester.pumpAndSettle();
+
+ // Username is persisted as the session marker (mirrors BootstrapScreen).
+ expect(fakeStorage._token, equals('bob'));
+ });
+
+ testWidgets('shows submit button initially and no loading indicator',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..loginResult = const User(id: 1, username: 'alice', isAdmin: false);
+
+ await _pumpLoginScreen(tester, fakeClient);
+
+ // Before any interaction: button visible, no spinner.
+ expect(find.byKey(const Key('login_submit')), findsOneWidget);
+ expect(find.byType(CircularProgressIndicator), findsNothing);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Loading state
+ // --------------------------------------------------------------------------
+
+ group('loading state', () {
+ testWidgets('loading indicator shown during a delayed login',
+ (tester) async {
+ // Use a completer so the login response is held until we choose to
+ // resolve it, giving us a window to assert on the loading state.
+ final fakeClient = _DelayedFakeApiClient();
+
+ await _pumpLoginScreen(tester, fakeClient);
+
+ await tester.enterText(
+ find.byKey(const Key('login_username')), 'alice');
+ await tester.enterText(
+ find.byKey(const Key('login_password')), 'supersecret');
+
+ // Tap submit — the _DelayedFakeApiClient won't resolve yet.
+ await tester.tap(find.byKey(const Key('login_submit')));
+ // Pump exactly one frame: setState(_isLoading=true) has run but the
+ // Future has not yet resolved.
+ await tester.pump();
+
+ // Loading state: spinner replaces the submit button.
+ expect(find.byType(CircularProgressIndicator), findsOneWidget);
+ expect(find.byKey(const Key('login_submit')), findsNothing);
+
+ // Resolve the fake and let the widget settle.
+ fakeClient.complete(const User(id: 1, username: 'alice', isAdmin: false));
+ await tester.pumpAndSettle();
+
+ // After completion: spinner gone.
+ expect(find.byType(CircularProgressIndicator), findsNothing);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Error display
+ // --------------------------------------------------------------------------
+
+ group('error display', () {
+ testWidgets('401 DioException shows invalid-credentials message',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..loginError = DioException(
+ requestOptions: RequestOptions(path: '/api/v1/auth/login'),
+ response: Response(
+ requestOptions: RequestOptions(path: '/api/v1/auth/login'),
+ statusCode: 401,
+ // Server returns {"error": "invalid credentials"} for bad logins.
+ data: <String, dynamic>{'error': 'invalid credentials'},
+ ),
+ type: DioExceptionType.badResponse,
+ );
+
+ await _pumpLoginScreen(tester, fakeClient);
+
+ await tester.enterText(
+ find.byKey(const Key('login_username')), 'alice');
+ await tester.enterText(
+ find.byKey(const Key('login_password')), 'wrongpassword');
+
+ await tester.tap(find.byKey(const Key('login_submit')));
+ await tester.pump();
+ await tester.pumpAndSettle();
+
+ // Server-supplied error message from the JSON body is shown in the
+ // SnackBar — this is the "error" field from the response.
+ expect(find.text('invalid credentials'), findsOneWidget);
+ });
+
+ testWidgets('401 without body shows fallback invalid-credentials message',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..loginError = DioException(
+ requestOptions: RequestOptions(path: '/api/v1/auth/login'),
+ response: Response(
+ requestOptions: RequestOptions(path: '/api/v1/auth/login'),
+ statusCode: 401,
+ // Empty body — no server-supplied message.
+ data: <String, dynamic>{},
+ ),
+ type: DioExceptionType.badResponse,
+ );
+
+ await _pumpLoginScreen(tester, fakeClient);
+
+ await tester.enterText(
+ find.byKey(const Key('login_username')), 'alice');
+ await tester.enterText(
+ find.byKey(const Key('login_password')), 'wrongpassword');
+
+ await tester.tap(find.byKey(const Key('login_submit')));
+ await tester.pump();
+ await tester.pumpAndSettle();
+
+ // Without a body message, the 401 fallback text is shown.
+ expect(find.text('Invalid username or password.'), findsOneWidget);
+ });
+
+ testWidgets('network error shows connectivity message', (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..loginError = DioException(
+ requestOptions: RequestOptions(path: '/api/v1/auth/login'),
+ type: DioExceptionType.connectionError,
+ );
+
+ await _pumpLoginScreen(tester, fakeClient);
+
+ await tester.enterText(
+ find.byKey(const Key('login_username')), 'alice');
+ await tester.enterText(
+ find.byKey(const Key('login_password')), 'secret');
+
+ await tester.tap(find.byKey(const Key('login_submit')));
+ await tester.pump();
+ await tester.pumpAndSettle();
+
+ expect(
+ find.textContaining('Could not reach the server'),
+ findsOneWidget,
+ );
+ });
+
+ testWidgets('500 server error shows generic server-error message',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..loginError = DioException(
+ requestOptions: RequestOptions(path: '/api/v1/auth/login'),
+ response: Response(
+ requestOptions: RequestOptions(path: '/api/v1/auth/login'),
+ statusCode: 500,
+ data: <String, dynamic>{},
+ ),
+ type: DioExceptionType.badResponse,
+ );
+
+ await _pumpLoginScreen(tester, fakeClient);
+
+ await tester.enterText(
+ find.byKey(const Key('login_username')), 'alice');
+ await tester.enterText(
+ find.byKey(const Key('login_password')), 'secret');
+
+ await tester.tap(find.byKey(const Key('login_submit')));
+ await tester.pump();
+ await tester.pumpAndSettle();
+
+ expect(
+ find.text('Server error (500). Please try again.'),
+ findsOneWidget,
+ );
+ });
+ });
+}
diff --git a/player-android/test/widget_smoke_test.dart b/player-android/test/widget_smoke_test.dart
index fba21d4..842a95e 100644
--- a/player-android/test/widget_smoke_test.dart
+++ b/player-android/test/widget_smoke_test.dart
@@ -1,48 +1,73 @@
// Widget smoke tests for PlayerAndroidApp.
//
-// These tests verify that the two named routes defined in main.dart render
-// without errors and contain the expected key widgets. Navigation between
-// HomeScreen and NowPlayingScreen is exercised end-to-end.
+// These tests verify that the app boots without crashing and that the root
+// widget tree renders correctly. Navigation is handled by go_router and
+// Riverpod; deeper screen tests live in test/screens/*.dart.
+//
+// The app requires a [ProviderScope] ancestor at the root — [PlayerAndroidApp]
+// is a [ConsumerWidget] that reads [routerProvider] from Riverpod. Wrapping
+// it in a [ProviderScope] and overriding [tokenStorageProvider] and
+// [apiClientProvider] avoids any platform-specific code (OS keychain, network)
+// during tests.
//
// Run with: flutter test test/widget_smoke_test.dart
-import 'package:flutter/material.dart';
+import 'package:dio/dio.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:player_android/api/dio_client.dart';
+import 'package:player_android/api/player_api_client.dart';
import 'package:player_android/main.dart';
+import 'package:player_android/providers/api_client_provider.dart';
-void main() {
- group('PlayerAndroidApp', () {
- testWidgets('starts on HomeScreen showing Library title', (tester) async {
- await tester.pumpWidget(const PlayerAndroidApp());
+// ---------------------------------------------------------------------------
+// Minimal fakes to avoid platform-specific code in smoke tests.
+// ---------------------------------------------------------------------------
- expect(find.text('Library'), findsOneWidget);
- expect(find.text('Now Playing'), findsOneWidget);
- });
+/// In-memory token storage that simulates a logged-in session so the router
+/// does not attempt a redirect to /login (which would trigger firstRunProvider
+/// and make a real network call).
+class _LoggedInTokenStorage implements TokenStorage {
+ @override
+ Future<String?> readToken() async => 'fake-session-token';
- testWidgets('navigates to NowPlayingScreen on button tap', (tester) async {
- await tester.pumpWidget(const PlayerAndroidApp());
+ @override
+ Future<void> writeToken(String token) async {}
- await tester.tap(find.widgetWithText(ElevatedButton, 'Now Playing'));
- await tester.pumpAndSettle();
+ @override
+ Future<void> deleteToken() async {}
+}
- expect(find.text('Now Playing'), findsWidgets);
- expect(find.text('No media selected'), findsOneWidget);
- });
+/// Minimal [PlayerApiClient] stub — only [countUsers] is exercised by the
+/// router redirect when auth state is unauthenticated.
+class _FakeApiClient extends PlayerApiClient {
+ _FakeApiClient() : super(dio: Dio());
- testWidgets('NowPlayingScreen back-navigates to HomeScreen', (tester) async {
- await tester.pumpWidget(const PlayerAndroidApp());
+ @override
+ Future<int> countUsers() async => 1; // Non-zero: normal login flow.
+}
- // Navigate to NowPlayingScreen.
- await tester.tap(find.widgetWithText(ElevatedButton, 'Now Playing'));
- await tester.pumpAndSettle();
- expect(find.text('No media selected'), findsOneWidget);
+void main() {
+ group('PlayerAndroidApp', () {
+ testWidgets('boots without crashing inside a ProviderScope', (tester) async {
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ // Simulate a logged-in session so the router does not try to
+ // hit the network for the firstRunProvider check.
+ tokenStorageProvider.overrideWithValue(_LoggedInTokenStorage()),
+ api