summaryrefslogtreecommitdiff
path: root/player-android/lib
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/lib
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/lib')
-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
4 files changed, 334 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();
+});