summaryrefslogtreecommitdiff
path: root/player-android
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-21 07:55:35 +0300
committerPaul Buetow <paul@buetow.org>2026-05-21 07:55:35 +0300
commitfaa3cf36bc5b57e8b133eab8df90110b80eb4cf7 (patch)
treec153cb0e335f498968b562456d82ae5afe81da11 /player-android
parent893e2aca022e6e9d72019a80b866334a777f60cc (diff)
Implement SettingsScreen with AuthGuard and settings persistence (sa)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android')
-rw-r--r--player-android/lib/app_routes.dart1
-rw-r--r--player-android/lib/providers/settings_provider.dart87
-rw-r--r--player-android/lib/router.dart15
-rw-r--r--player-android/lib/screens/settings_screen.dart234
-rw-r--r--player-android/pubspec.lock56
-rw-r--r--player-android/pubspec.yaml3
-rw-r--r--player-android/test/screens/settings_screen_test.dart341
7 files changed, 734 insertions, 3 deletions
diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart
index 2856753..aa76ba7 100644
--- a/player-android/lib/app_routes.dart
+++ b/player-android/lib/app_routes.dart
@@ -8,6 +8,7 @@ abstract final class AppRoutes {
static const home = '/home';
static const mediaDetail = '/media/:id';
static const share = '/share';
+ static const settings = '/settings';
/// First-run setup route shown when no admin account exists yet.
static const bootstrap = '/bootstrap';
diff --git a/player-android/lib/providers/settings_provider.dart b/player-android/lib/providers/settings_provider.dart
new file mode 100644
index 0000000..8ffec0f
--- /dev/null
+++ b/player-android/lib/providers/settings_provider.dart
@@ -0,0 +1,87 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+// SharedPreferences key for the server base URL setting.
+const _kBaseUrlKey = 'server_base_url';
+
+// Default base URL used when the user has not yet configured one.
+// Points to the local Android emulator loopback address so the app is
+// runnable out-of-the-box without any manual configuration.
+// Private: only referenced within this file; callers read the resolved URL
+// through [AppSettings.serverBaseUrl] obtained from [settingsProvider].
+const _kDefaultBaseUrl = 'http://10.0.2.2:8080';
+
+/// Immutable snapshot of persisted app settings.
+///
+/// Keeping settings as a value object means every state change produces a new
+/// instance, which plays well with Riverpod's equality-based rebuild suppression
+/// and keeps the notifier's contract straightforward.
+class AppSettings {
+ const AppSettings({required this.serverBaseUrl});
+
+ /// The base URL of the player-server API (e.g. "https://player.example.com").
+ final String serverBaseUrl;
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ other is AppSettings &&
+ runtimeType == other.runtimeType &&
+ serverBaseUrl == other.serverBaseUrl;
+
+ @override
+ int get hashCode => serverBaseUrl.hashCode;
+
+ @override
+ String toString() => 'AppSettings(serverBaseUrl: $serverBaseUrl)';
+}
+
+/// Manages persisted app settings via [SharedPreferences].
+///
+/// Uses [AsyncNotifier] because the initial state load is async (disk read).
+/// After initialisation, [setServerBaseUrl] writes to disk and updates state
+/// synchronously so the UI reflects changes immediately.
+///
+/// Design notes (SRP / ISP):
+/// - This notifier owns only settings persistence; auth is handled separately
+/// by [AuthStateNotifier] to maintain single responsibility.
+/// - [SharedPreferences] is created internally rather than injected because
+/// it is a platform singleton; tests override the entire provider via
+/// [ProviderScope] overrides instead.
+class SettingsNotifier extends AsyncNotifier<AppSettings> {
+ @override
+ Future<AppSettings> build() async {
+ // Load persisted settings from disk on first access. The platform
+ // SharedPreferences instance is a singleton; obtaining it here is cheap
+ // because subsequent calls return the cached instance.
+ final prefs = await SharedPreferences.getInstance();
+ final url = prefs.getString(_kBaseUrlKey) ?? _kDefaultBaseUrl;
+ return AppSettings(serverBaseUrl: url);
+ }
+
+ /// Persists [url] as the new server base URL and updates the in-memory state.
+ ///
+ /// The UI calls this when the user edits the URL field and submits. The
+ /// async write to [SharedPreferences] is awaited so that a subsequent cold
+ /// start will see the new value; the in-memory state is updated first so the
+ /// UI is not blocked on the disk write.
+ Future<void> setServerBaseUrl(String url) async {
+ // Update in-memory state first for immediate UI feedback.
+ state = AsyncData(AppSettings(serverBaseUrl: url));
+
+ // Persist to disk so the value survives app restarts.
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString(_kBaseUrlKey, url);
+ }
+}
+
+/// The single source of truth for persisted app settings.
+///
+/// Currently consumed by [SettingsScreen] for displaying and editing settings.
+/// Will also be consumed by [apiClientProvider] (for the server base URL) once
+/// that provider is wired to read from settings rather than
+/// [String.fromEnvironment] — tracked as a future task.
+final settingsProvider =
+ AsyncNotifierProvider<SettingsNotifier, AppSettings>(
+ SettingsNotifier.new,
+);
diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart
index 4c78259..255ffce 100644
--- a/player-android/lib/router.dart
+++ b/player-android/lib/router.dart
@@ -10,6 +10,7 @@ import 'screens/bootstrap_screen.dart';
import 'screens/home_screen.dart';
import 'screens/login_screen.dart';
import 'screens/media_detail_screen.dart';
+import 'screens/settings_screen.dart';
import 'screens/share_screen.dart';
// Re-export AppRoutes so existing callers that import router.dart for routes
@@ -61,9 +62,13 @@ final routerProvider = Provider<GoRouter>((ref) {
}
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).
+ // Unauthenticated: any route other than /login and /bootstrap is
+ // protected. This covers /home, /media/:id, /share, /settings, and
+ // any future authenticated routes added to the route table.
+ //
+ // Determine whether this is first-run (no users exist yet) or a normal
+ // returning-user scenario. firstRunProvider returns true when the
+ // server reports count == 0.
//
// While the check is loading we stay put; the router re-evaluates when
// firstRunProvider's AsyncValue settles (via refreshListenable).
@@ -105,6 +110,10 @@ final routerProvider = Provider<GoRouter>((ref) {
path: AppRoutes.share,
builder: (context, state) => const ShareScreen(),
),
+ GoRoute(
+ path: AppRoutes.settings,
+ builder: (context, state) => const SettingsScreen(),
+ ),
],
);
});
diff --git a/player-android/lib/screens/settings_screen.dart b/player-android/lib/screens/settings_screen.dart
new file mode 100644
index 0000000..143acd2
--- /dev/null
+++ b/player-android/lib/screens/settings_screen.dart
@@ -0,0 +1,234 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../app_routes.dart';
+import '../providers/api_client_provider.dart';
+import '../providers/auth_state_provider.dart';
+import '../providers/settings_provider.dart';
+
+/// Settings screen: editable server base URL, current username, and logout.
+///
+/// Design notes:
+/// - [ConsumerStatefulWidget] is used so that the text controller can be
+/// initialised from the persisted settings and [WidgetRef] is available
+/// throughout the async logout path without storing a stale ref.
+/// - The base URL is pre-filled from [settingsProvider] and saved on every
+/// submit (Enter key or "Save" button).
+/// - Logout clears the bearer token via [AuthStateNotifier.logout], which
+/// triggers go_router's redirect callback (via [refreshListenable]) and
+/// navigates to /login automatically. An explicit [context.go] acts as a
+/// safety net in case the redirect has not fired yet.
+/// - All async continuations guard on [mounted] to prevent setState/context
+/// calls after widget disposal.
+class SettingsScreen extends ConsumerStatefulWidget {
+ const SettingsScreen({super.key});
+
+ @override
+ ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
+}
+
+class _SettingsScreenState extends ConsumerState<SettingsScreen> {
+ // Controller for the server base URL text field. Initialised once from the
+ // persisted settings value and disposed when the widget leaves the tree.
+ final _urlController = TextEditingController();
+
+ // True while the logout round-trip (token deletion + state update) is in
+ // progress; prevents double-tapping the logout button.
+ bool _isLoggingOut = false;
+
+ // Tracks whether the URL controller has been seeded from the loaded settings
+ // so we populate it exactly once (on the first non-loading build).
+ bool _urlInitialised = false;
+
+ @override
+ void dispose() {
+ _urlController.dispose();
+ super.dispose();
+ }
+
+ // ---------------------------------------------------------------------------
+ // URL save logic
+ // ---------------------------------------------------------------------------
+
+ /// Validates the URL field and persists the new value via [SettingsNotifier].
+ ///
+ /// Trims whitespace so that a trailing newline from keyboard submission does
+ /// not get saved as part of the URL.
+ Future<void> _saveBaseUrl() async {
+ final url = _urlController.text.trim();
+ if (url.isEmpty) return;
+
+ // Persist the new URL; [SettingsNotifier] updates in-memory state first so
+ // the UI reflects the change immediately without waiting for the disk write.
+ await ref.read(settingsProvider.notifier).setServerBaseUrl(url);
+
+ // Dismiss the keyboard now that the value has been committed.
+ if (mounted) FocusScope.of(context).unfocus();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Logout logic
+ // ---------------------------------------------------------------------------
+
+ /// Clears the stored bearer token and transitions to the unauthenticated state.
+ ///
+ /// [AuthStateNotifier.logout] deletes the token from secure storage and sets
+ /// state to [AuthStatus.unauthenticated]. The router's [refreshListenable]
+ /// picks up the change and the redirect callback routes to /login automatically.
+ /// The explicit [context.go] below acts as a safety net.
+ Future<void> _logout() async {
+ setState(() => _isLoggingOut = true);
+ try {
+ await ref.read(authStateProvider.notifier).logout();
+ // Safety-net navigation in case the router redirect has not fired yet.
+ if (mounted) context.go(AppRoutes.login);
+ } finally {
+ // Only call setState if the widget is still in the tree; navigation may
+ // have triggered dispose before the finally block executes.
+ if (mounted) setState(() => _isLoggingOut = false);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Build
+ // ---------------------------------------------------------------------------
+
+ @override
+ Widget build(BuildContext context) {
+ // Watch settings to seed the URL field on first load.
+ final settingsAsync = ref.watch(settingsProvider);
+
+ // Seed the URL text field exactly once, after settings have loaded.
+ // Doing this in build (rather than initState) ensures we have the loaded
+ // value; [_urlInitialised] prevents clobbering an in-progress edit.
+ settingsAsync.whenData((settings) {
+ if (!_urlInitialised) {
+ _urlController.text = settings.serverBaseUrl;
+ _urlInitialised = true;
+ }
+ });
+
+ // Read the stored token as the username display. The token stored by
+ // AuthStateNotifier is the username string (LoginScreen and BootstrapScreen
+ // both call `authStateProvider.notifier.login(user.username)`).
+ final usernameAsync = ref.watch(_currentUsernameProvider);
+ final username = usernameAsync.valueOrNull ?? '—';
+
+ return Scaffold(
+ appBar: AppBar(title: const Text('Settings')),
+ body: SafeArea(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // ----------------------------------------------------------------
+ // Account section: signed-in username + logout.
+ // ----------------------------------------------------------------
+ Text(
+ 'Account',
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ const SizedBox(height: 12),
+
+ // Current username row.
+ Row(
+ children: [
+ const Icon(Icons.person_outline),
+ const SizedBox(width: 12),
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Signed in as',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ Text(
+ username,
+ key: const Key('settings_username'),
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ ],
+ ),
+ ],
+ ),
+ const SizedBox(height: 24),
+
+ // Logout button: shows a spinner while the token is being deleted.
+ _isLoggingOut
+ ? const Center(child: CircularProgressIndicator())
+ : OutlinedButton(
+ key: const Key('settings_logout'),
+ onPressed: _logout,
+ style: OutlinedButton.styleFrom(
+ foregroundColor:
+ Theme.of(context).colorScheme.error,
+ side: BorderSide(
+ color: Theme.of(context).colorScheme.error,
+ ),
+ ),
+ child: const Text('Log Out'),
+ ),
+
+ const SizedBox(height: 32),
+ const Divider(),
+ const SizedBox(height: 24),
+
+ // ----------------------------------------------------------------
+ // Server section: editable base URL.
+ // ----------------------------------------------------------------
+ Text(
+ 'Server',
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ const SizedBox(height: 12),
+
+ // Server base URL field pre-filled from persisted settings.
+ TextField(
+ key: const Key('settings_base_url'),
+ controller: _urlController,
+ decoration: const InputDecoration(
+ labelText: 'Server base URL',
+ border: OutlineInputBorder(),
+ helperText:
+ 'e.g. https://player.example.com or http://10.0.2.2:8080',
+ ),
+ keyboardType: TextInputType.url,
+ autocorrect: false,
+ textInputAction: TextInputAction.done,
+ // Persist when the user presses "Done" on the keyboard.
+ onSubmitted: (_) => _saveBaseUrl(),
+ ),
+ const SizedBox(height: 12),
+
+ ElevatedButton(
+ key: const Key('settings_save_url'),
+ onPressed: _saveBaseUrl,
+ child: const Text('Save URL'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// File-level helpers
+// ---------------------------------------------------------------------------
+
+/// Reads the current username from [tokenStorageProvider].
+///
+/// The username is stored as the bearer token value by [AuthStateNotifier.login]
+/// (both LoginScreen and BootstrapScreen call `login(user.username)`).
+/// This autoDispose FutureProvider is re-evaluated whenever the provider scope
+/// changes, ensuring the display is up-to-date after logout/login transitions.
+///
+/// Kept private (underscore prefix) because it is an implementation detail of
+/// this screen — no other file should depend on it.
+final _currentUsernameProvider = FutureProvider.autoDispose<String?>((ref) {
+ final storage = ref.watch(tokenStorageProvider);
+ return storage.readToken();
+});
diff --git a/player-android/pubspec.lock b/player-android/pubspec.lock
index d3ca08a..44e8eff 100644
--- a/player-android/pubspec.lock
+++ b/player-android/pubspec.lock
@@ -448,6 +448,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
+ shared_preferences:
+ dependency: "direct main"
+ description:
+ name: shared_preferences
+ sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.5"
+ shared_preferences_android:
+ dependency: transitive
+ description:
+ name: shared_preferences_android
+ sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.23"
+ shared_preferences_foundation:
+ dependency: transitive
+ description:
+ name: shared_preferences_foundation
+ sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.6"
+ shared_preferences_linux:
+ dependency: transitive
+ description:
+ name: shared_preferences_linux
+ sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.1"
+ shared_preferences_platform_interface:
+ dependency: transitive
+ description:
+ name: shared_preferences_platform_interface
+ sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2"
+ shared_preferences_web:
+ dependency: transitive
+ description:
+ name: shared_preferences_web
+ sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.3"
+ shared_preferences_windows:
+ dependency: transitive
+ description:
+ name: shared_preferences_windows
+ sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
diff --git a/player-android/pubspec.yaml b/player-android/pubspec.yaml
index ddd5d0d..c693cff 100644
--- a/player-android/pubspec.yaml
+++ b/player-android/pubspec.yaml
@@ -17,6 +17,9 @@ dependencies:
flutter_riverpod: ^2.6.1
# go_router: declarative routing for Flutter; replaces imperative Navigator calls.
go_router: ^14.8.1
+ # shared_preferences: persists simple key-value settings (e.g. server base URL)
+ # using platform-native storage (SharedPreferences on Android, NSUserDefaults on iOS).
+ shared_preferences: ^2.3.2
dev_dependencies:
flutter_test:
diff --git a/player-android/test/screens/settings_screen_test.dart b/player-android/test/screens/settings_screen_test.dart
new file mode 100644
index 0000000..71c2d7d
--- /dev/null
+++ b/player-android/test/screens/settings_screen_test.dart
@@ -0,0 +1,341 @@
+// Widget tests for SettingsScreen.
+//
+// Tests cover:
+// 1. Displays username: the token stored in TokenStorage is shown as the
+// signed-in username.
+// 2. Saves base URL: entering a URL and tapping Save persists it via the
+// settings provider.
+// 3. Logout flow: tapping Log Out calls AuthStateNotifier.logout and clears
+// the stored token.
+//
+// Riverpod providers are overridden with in-memory fakes so tests run without
+// a real server, OS keychain, or SharedPreferences disk I/O.
+//
+// Run with: flutter test test/screens/settings_screen_test.dart
+
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:go_router/go_router.dart';
+import 'package:player_android/api/dio_client.dart';
+import 'package:player_android/providers/api_client_provider.dart';
+import 'package:player_android/providers/auth_state_provider.dart';
+import 'package:player_android/providers/settings_provider.dart';
+import 'package:player_android/screens/settings_screen.dart';
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+/// In-memory [TokenStorage] that avoids the platform OS keychain in tests.
+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;
+}
+
+/// In-memory [SettingsNotifier] whose state is set directly in tests.
+///
+/// Uses [AsyncNotifier] the same way the production notifier does, but
+/// bypasses [SharedPreferences] so tests have no disk I/O.
+class _FakeSettingsNotifier extends SettingsNotifier {
+ _FakeSettingsNotifier(this._initialUrl);
+
+ final String _initialUrl;
+
+ // Captures the last URL passed to setServerBaseUrl for test assertions.
+ String? savedUrl;
+
+ @override
+ Future<AppSettings> build() async =>
+ AppSettings(serverBaseUrl: _initialUrl);
+
+ @override
+ Future<void> setServerBaseUrl(String url) async {
+ savedUrl = url;
+ // Mirror the production implementation: update in-memory state immediately.
+ state = AsyncData(AppSettings(serverBaseUrl: url));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Helper: pump SettingsScreen inside a minimal ProviderScope.
+// ---------------------------------------------------------------------------
+
+/// Pumps [SettingsScreen] inside a [ProviderScope] that overrides:
+/// - [tokenStorageProvider] with an in-memory fake (avoids OS keychain)
+/// - [settingsProvider] with an in-memory fake (avoids SharedPreferences)
+///
+/// Uses [MaterialApp.router] with a minimal [GoRouter] so that [context.go]
+/// calls inside [SettingsScreen._logout] do not throw "No GoRouter in context".
+///
+/// Returns a record containing:
+/// - [storage]: the fake token storage for post-test assertions.
+/// - [settings]: the fake settings notifier for post-test assertions.
+Future<({_FakeTokenStorage storage, _FakeSettingsNotifier settings})>
+ _pumpSettingsScreen(
+ WidgetTester tester, {
+ String initialToken = 'alice',
+ String initialUrl = 'http://10.0.2.2:8080',
+}) async {
+ final fakeStorage = _FakeTokenStorage().._token = initialToken;
+ final fakeSettings = _FakeSettingsNotifier(initialUrl);
+
+ // A minimal GoRouter that renders SettingsScreen at '/'. The /login route
+ // is included so that the safety-net context.go(AppRoutes.login) in
+ // _logout() does not trigger a "route not found" error.
+ final router = GoRouter(
+ initialLocation: '/',
+ routes: [
+ GoRoute(
+ path: '/',
+ builder: (_, __) => const SettingsScreen(),
+ ),
+ GoRoute(
+ path: '/login',
+ builder: (_, __) => const Scaffold(body: Text('Login')),
+ ),
+ ],
+ );
+
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ // Avoid OS keychain / SharedPreferences in tests.
+ tokenStorageProvider.overrideWithValue(fakeStorage),
+ settingsProvider.overrideWith(() => fakeSettings),
+ ],
+ child: MaterialApp.router(routerConfig: router),
+ ),
+ );
+
+ // Allow async providers (_currentUsernameProvider, settingsProvider) to
+ // resolve their futures before we inspect the widget tree.
+ await tester.pumpAndSettle();
+
+ return (storage: fakeStorage, settings: fakeSettings);
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+void main() {
+ // --------------------------------------------------------------------------
+ // Username display
+ // --------------------------------------------------------------------------
+
+ group('username display', () {
+ testWidgets('shows the token stored in TokenStorage as the username',
+ (tester) async {
+ await _pumpSettingsScreen(tester, initialToken: 'alice');
+
+ // The settings_username widget should show the stored token value.
+ expect(find.byKey(const Key('settings_username')), findsOneWidget);
+ expect(find.text('alice'), findsOneWidget);
+ });
+
+ testWidgets('shows placeholder when no token is stored', (tester) async {
+ // Pump with an empty token — simulates a freshly logged-out state
+ // where the screen is still mounted transiently before redirect.
+ final fakeStorage = _FakeTokenStorage(); // _token is null
+ final fakeSettings = _FakeSettingsNotifier('http://10.0.2.2:8080');
+
+ final router = GoRouter(
+ initialLocation: '/',
+ routes: [
+ GoRoute(
+ path: '/',
+ builder: (_, __) => const SettingsScreen(),
+ ),
+ GoRoute(
+ path: '/login',
+ builder: (_, __) => const Scaffold(body: Text('Login')),
+ ),
+ ],
+ );
+
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ tokenStorageProvider.overrideWithValue(fakeStorage),
+ settingsProvider.overrideWith(() => fakeSettings),
+ ],
+ child: MaterialApp.router(routerConfig: router),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ // When the token is null, the username row shows the fallback '—'.
+ expect(find.text('—'), findsOneWidget);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Base URL editing
+ // --------------------------------------------------------------------------
+
+ group('base URL', () {
+ testWidgets('pre-fills the URL field from the settings provider',
+ (tester) async {
+ await _pumpSettingsScreen(
+ tester,
+ initialUrl: 'https://player.example.com',
+ );
+
+ // The URL field should be seeded with the persisted value.
+ final field = tester.widget<TextField>(
+ find.byKey(const Key('settings_base_url')),
+ );
+ expect(field.controller?.text, equals('https://player.example.com'));
+ });
+
+ testWidgets('saves URL when Save URL button is tapped', (tester) async {
+ final result = await _pumpSettingsScreen(
+ tester,
+ initialUrl: 'http://10.0.2.2:8080',
+ );
+
+ // Edit the URL field.
+ await tester.tap(find.byKey(const Key('settings_base_url')));
+ await tester.pump();
+ await tester.enterText(
+ find.byKey(const Key('settings_base_url')),
+ 'https://new-server.example.com',
+ );
+
+ // Tap Save URL.
+ await tester.tap(find.byKey(const Key('settings_save_url')));
+ await tester.pumpAndSettle();
+
+ // The fake notifier should have captured the new URL.
+ expect(
+ result.settings.savedUrl,
+ equals('https://new-server.example.com'),
+ );
+ });
+
+ testWidgets('saves URL when keyboard Done action is triggered',
+ (tester) async {
+ final result = await _pumpSettingsScreen(tester);
+
+ await tester.tap(find.byKey(const Key('settings_base_url')));
+ await tester.pump();
+ await tester.enterText(
+ find.byKey(const Key('settings_base_url')),
+ 'http://192.168.1.100:8080',
+ );
+
+ // Simulate the "Done" keyboard action.
+ await tester.testTextInput.receiveAction(TextInputAction.done);
+ await tester.pumpAndSettle();
+
+ expect(
+ result.settings.savedUrl,
+ equals('http://192.168.1.100:8080'),
+ );
+ });
+
+ testWidgets('does not save when URL field is empty', (tester) async {
+ final result = await _pumpSettingsScreen(
+ tester,
+ initialUrl: 'http://10.0.2.2:8080',
+ );
+
+ // Clear the field and tap Save.
+ await tester.enterText(find.byKey(const Key('settings_base_url')), '');
+ await tester.tap(find.byKey(const Key('settings_save_url')));
+ await tester.pumpAndSettle();
+
+ // Nothing should have been saved since the field was blank.
+ expect(result.settings.savedUrl, isNull);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Logout flow
+ // --------------------------------------------------------------------------
+
+ group('logout flow', () {
+ testWidgets('logout button is visible and enabled initially',
+ (tester) async {
+ await _pumpSettingsScreen(tester);
+
+ expect(find.byKey(const Key('settings_logout')), findsOneWidget);
+ expect(find.byType(CircularProgressIndicator), findsNothing);
+ });
+
+ testWidgets('tapping logout clears the token from TokenStorage',
+ (tester) async {
+ final result = await _pumpSettingsScreen(
+ tester,
+ initialToken: 'alice',
+ );
+
+ // Verify the token is set before logout.
+ expect(result.storage._token, equals('alice'));
+
+ // Tap the logout button.
+ await tester.tap(find.byKey(const Key('settings_logout')));
+ await tester.pumpAndSettle();
+
+ // AuthStateNotifier.logout() should have deleted the token.
+ expect(result.storage._token, isNull);
+ });
+
+ testWidgets('tapping logout updates auth state to unauthenticated',
+ (tester) async {
+ // Capture the auth state notifier to inspect state after logout.
+ AuthState? capturedState;
+ final fakeStorage = _FakeTokenStorage().._token = 'bob';
+ final fakeSettings = _FakeSettingsNotifier('http://10.0.2.2:8080');
+
+ // Minimal GoRouter: '/' renders SettingsScreen, '/login' is the redirect
+ // target so context.go('/login') in the logout handler does not throw.
+ final router = GoRouter(
+ initialLocation: '/',
+ routes: [
+ GoRoute(
+ path: '/',
+ builder: (_, __) => Consumer(
+ builder: (context, ref, _) {
+ // Watch and capture auth state for post-logout assertion.
+ final authAsync = ref.watch(authStateProvider);
+ authAsync.whenData((s) => capturedState = s);
+ return const SettingsScreen();
+ },
+ ),
+ ),
+ GoRoute(
+ path: '/login',
+ builder: (_, __) => const Scaffold(body: Text('Login')),
+ ),
+ ],
+ );
+
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ tokenStorageProvider.overrideWithValue(fakeStorage),
+ settingsProvider.overrideWith(() => fakeSettings),
+ ],
+ child: MaterialApp.router(routerConfig: router),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byKey(const Key('settings_logout')));
+ await tester.pumpAndSettle();
+
+ // After logout the auth state should be unauthenticated.
+ expect(capturedState?.isUnauthenticated, isTrue);
+ });
+ });
+}