summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--player-android/lib/main.dart18
-rw-r--r--player-android/lib/providers/theme_provider.dart169
-rw-r--r--player-android/lib/screens/settings_screen.dart74
3 files changed, 259 insertions, 2 deletions
diff --git a/player-android/lib/main.dart b/player-android/lib/main.dart
index 99e85cb..2c1b193 100644
--- a/player-android/lib/main.dart
+++ b/player-android/lib/main.dart
@@ -5,6 +5,7 @@ import 'package:just_audio/just_audio.dart';
import 'providers/audio_handler_provider.dart';
import 'providers/progress_queue_provider.dart';
+import 'providers/theme_provider.dart';
import 'router.dart';
import 'services/audio_handler.dart';
@@ -68,9 +69,13 @@ void main() async {
/// Root application widget.
///
-/// Uses [ConsumerWidget] to read [routerProvider] from Riverpod so that the
-/// same [GoRouter] instance (and its navigator key) is reused across rebuilds.
+/// Uses [ConsumerWidget] to read [routerProvider] and [themeProvider] from
+/// Riverpod so that the same [GoRouter] instance and the persisted [ThemeMode]
+/// are both available without additional state management in the widget itself.
+///
/// [MaterialApp.router] delegates all navigation decisions to go_router.
+/// [themeMode] is driven by [themeProvider] so the user's light/dark preference
+/// takes effect immediately on every screen and survives app restarts.
class PlayerAndroidApp extends ConsumerWidget {
const PlayerAndroidApp({super.key});
@@ -78,9 +83,18 @@ class PlayerAndroidApp extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
+ // Default to ThemeMode.system while the preference is loading so the app
+ // does not flash an incorrect theme during startup.
+ final themeMode =
+ ref.watch(themeProvider).valueOrNull ?? ThemeMode.system;
+
return MaterialApp.router(
title: 'Player',
routerConfig: router,
+ // Material 3 is enabled in both ThemeData instances; see theme_provider.dart.
+ theme: buildLightTheme(),
+ darkTheme: buildDarkTheme(),
+ themeMode: themeMode,
);
}
}
diff --git a/player-android/lib/providers/theme_provider.dart b/player-android/lib/providers/theme_provider.dart
new file mode 100644
index 0000000..7ebaa89
--- /dev/null
+++ b/player-android/lib/providers/theme_provider.dart
@@ -0,0 +1,169 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+// SharedPreferences key for the persisted theme mode.
+const _kThemeModeKey = 'theme_mode';
+
+// Mapping between the persisted string value and [ThemeMode] enum.
+// Using explicit strings (not enum index) so the stored values are stable
+// across code refactors that might change enum ordering.
+const _kLight = 'light';
+const _kDark = 'dark';
+const _kSystem = 'system';
+
+/// Returns the [ThemeMode] that corresponds to a persisted string.
+///
+/// Falls back to [ThemeMode.system] for any unknown or null value so that a
+/// fresh install (or a corrupt preference) always behaves sensibly.
+ThemeMode _themeModeFromString(String? value) => switch (value) {
+ _kLight => ThemeMode.light,
+ _kDark => ThemeMode.dark,
+ _ => ThemeMode.system,
+ };
+
+/// Returns the string that is persisted for a given [ThemeMode].
+String _themeModeToString(ThemeMode mode) => switch (mode) {
+ ThemeMode.light => _kLight,
+ ThemeMode.dark => _kDark,
+ ThemeMode.system => _kSystem,
+ };
+
+// ---------------------------------------------------------------------------
+// Color schemes derived from player-server/docs/theming.md
+//
+// Dark palette mirrors the CSS :root block; light palette mirrors
+// [data-theme="light"]. Material 3 ColorScheme is built from the key tokens:
+// primary ← --accent
+// onPrimary ← --text-inverse / white
+// surface ← --bg-surface
+// background ← --bg-body
+// error ← --danger
+// ---------------------------------------------------------------------------
+
+/// Material 3 dark [ColorScheme] matching the server's default dark palette.
+const darkColorScheme = ColorScheme(
+ brightness: Brightness.dark,
+ // Accent #5e9eff — the interactive highlight colour.
+ primary: Color(0xFF5E9EFF),
+ onPrimary: Color(0xFF0B0D12), // --text-inverse: dark text on accent buttons.
+ primaryContainer: Color(0xFF1E222C), // --bg-elevated
+ onPrimaryContainer: Color(0xFFE6E8EF), // --text-primary
+ secondary: Color(0xFF3DDC84), // --success: used for "playing" states.
+ onSecondary: Color(0xFF0B0D12),
+ secondaryContainer: Color(0xFF161920), // --bg-surface
+ onSecondaryContainer: Color(0xFFA3A8B8), // --text-secondary
+ tertiary: Color(0xFFFFB300), // --warn
+ onTertiary: Color(0xFF0B0D12),
+ tertiaryContainer: Color(0xFF1B1F27), // --bg-surface-hover
+ onTertiaryContainer: Color(0xFFE6E8EF),
+ error: Color(0xFFF25C5C), // --danger
+ onError: Color(0xFFFFFFFF),
+ errorContainer: Color(0xFF1E222C),
+ onErrorContainer: Color(0xFFF25C5C),
+ surface: Color(0xFF161920), // --bg-surface
+ onSurface: Color(0xFFE6E8EF), // --text-primary
+ onSurfaceVariant: Color(0xFFA3A8B8), // --text-secondary
+ outline: Color(0xFF252A36), // --border
+ outlineVariant: Color(0xFF2E3546), // --border-strong
+ shadow: Color(0xFF000000),
+ scrim: Color(0xFF000000),
+ inverseSurface: Color(0xFFE6E8EF),
+ onInverseSurface: Color(0xFF0F1117),
+ inversePrimary: Color(0xFF2B6CB0), // light accent for chip labels on dark bg
+);
+
+/// Material 3 light [ColorScheme] matching the server's [data-theme="light"] palette.
+const lightColorScheme = ColorScheme(
+ brightness: Brightness.light,
+ // Accent #2b6cb0 — the interactive highlight colour in light mode.
+ primary: Color(0xFF2B6CB0),
+ onPrimary: Color(0xFFFFFFFF), // --text-inverse: white text on accent buttons.
+ primaryContainer: Color(0xFFFFFFFF), // --bg-elevated
+ onPrimaryContainer: Color(0xFF12131A), // --text-primary
+ secondary: Color(0xFF258855), // --success
+ onSecondary: Color(0xFFFFFFFF),
+ secondaryContainer: Color(0xFFFFFFFF), // --bg-surface
+ onSecondaryContainer: Color(0xFF4A4F5E), // --text-secondary
+ tertiary: Color(0xFFFFB300), // --warn (unchanged in light mode)
+ onTertiary: Color(0xFF12131A),
+ tertiaryContainer: Color(0xFFF0F2F7), // --bg-surface-hover
+ onTertiaryContainer: Color(0xFF12131A),
+ error: Color(0xFFC53030), // --danger (light variant)
+ onError: Color(0xFFFFFFFF),
+ errorContainer: Color(0xFFF4F5F8),
+ onErrorContainer: Color(0xFFC53030),
+ surface: Color(0xFFFFFFFF), // --bg-surface
+ onSurface: Color(0xFF12131A), // --text-primary
+ onSurfaceVariant: Color(0xFF4A4F5E), // --text-secondary
+ outline: Color(0xFFD6DAE4), // --border
+ outlineVariant: Color(0xFFC3C9D6), // --border-strong
+ shadow: Color(0xFF000000),
+ scrim: Color(0xFF000000),
+ inverseSurface: Color(0xFF12131A),
+ onInverseSurface: Color(0xFFF4F5F8),
+ inversePrimary: Color(0xFF5E9EFF), // dark accent for chip labels on light bg
+);
+
+// ---------------------------------------------------------------------------
+// ThemeData factories
+// ---------------------------------------------------------------------------
+
+/// Builds a Material 3 [ThemeData] for dark mode.
+///
+/// [useMaterial3] must be true so that the ColorScheme tokens above are
+/// interpreted correctly by all M3 components (NavigationBar, Card, etc.).
+ThemeData buildDarkTheme() => ThemeData(
+ useMaterial3: true,
+ colorScheme: darkColorScheme,
+ scaffoldBackgroundColor: const Color(0xFF0F1117), // --bg-body dark
+ );
+
+/// Builds a Material 3 [ThemeData] for light mode.
+ThemeData buildLightTheme() => ThemeData(
+ useMaterial3: true,
+ colorScheme: lightColorScheme,
+ scaffoldBackgroundColor: const Color(0xFFF4F5F8), // --bg-body light
+ );
+
+// ---------------------------------------------------------------------------
+// ThemeNotifier
+// ---------------------------------------------------------------------------
+
+/// Manages the user's preferred [ThemeMode] and persists it via [SharedPreferences].
+///
+/// Uses [AsyncNotifier] because the initial load requires an async disk read.
+/// After the first load, [setThemeMode] updates the in-memory state immediately
+/// and then persists to disk so the UI is never blocked on I/O.
+///
+/// Design notes (SRP):
+/// - Theme persistence is isolated here; color definitions live as constants
+/// above. [SettingsNotifier] handles other persisted settings (server URL)
+/// and is kept separate to avoid growing a god-class.
+class ThemeNotifier extends AsyncNotifier<ThemeMode> {
+ @override
+ Future<ThemeMode> build() async {
+ // Read the persisted theme preference on first access. SharedPreferences
+ // returns a cached singleton on subsequent calls so this is cheap.
+ final prefs = await SharedPreferences.getInstance();
+ return _themeModeFromString(prefs.getString(_kThemeModeKey));
+ }
+
+ /// Updates the active [ThemeMode] and persists the choice to disk.
+ ///
+ /// The in-memory state is updated first so that [MaterialApp.themeMode]
+ /// changes immediately; the disk write follows asynchronously.
+ Future<void> setThemeMode(ThemeMode mode) async {
+ state = AsyncData(mode);
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString(_kThemeModeKey, _themeModeToString(mode));
+ }
+}
+
+/// The single source of truth for the active [ThemeMode].
+///
+/// Consumed by [PlayerAndroidApp] (via [themeProvider]) to set
+/// [MaterialApp.themeMode], and by [SettingsScreen] to render the toggle.
+final themeProvider = AsyncNotifierProvider<ThemeNotifier, ThemeMode>(
+ ThemeNotifier.new,
+);
diff --git a/player-android/lib/screens/settings_screen.dart b/player-android/lib/screens/settings_screen.dart
index be812eb..25bb6bc 100644
--- a/player-android/lib/screens/settings_screen.dart
+++ b/player-android/lib/screens/settings_screen.dart
@@ -6,6 +6,7 @@ import '../app_routes.dart';
import '../providers/api_client_provider.dart';
import '../providers/auth_state_provider.dart';
import '../providers/settings_provider.dart';
+import '../providers/theme_provider.dart';
/// Settings screen: editable server base URL, current username, and logout.
///
@@ -213,6 +214,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
const SizedBox(height: 24),
// ----------------------------------------------------------------
+ // Appearance section: light / dark / system theme toggle.
+ // ----------------------------------------------------------------
+ Text(
+ 'Appearance',
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ const SizedBox(height: 12),
+
+ _ThemeToggle(),
+
+ const SizedBox(height: 32),
+ const Divider(),
+ const SizedBox(height: 24),
+
+ // ----------------------------------------------------------------
// Sharing section: navigate to MyShares screen.
// ----------------------------------------------------------------
Text(
@@ -240,6 +256,64 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
}
// ---------------------------------------------------------------------------
+// Theme toggle widget
+// ---------------------------------------------------------------------------
+
+/// Segmented-button control that lets the user choose between
+/// light, dark, and system (follow OS) theme modes.
+///
+/// Kept as a separate [ConsumerWidget] (SRP) so [_SettingsScreenState] does
+/// not need to know about [themeProvider] — it only needs to place the widget.
+class _ThemeToggle extends ConsumerWidget {
+ // ignore: prefer_const_constructors_in_immutables — private widget, not const
+ _ThemeToggle();
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ // Default to system while the provider is loading so the toggle renders
+ // immediately rather than showing an empty state.
+ final current = ref.watch(themeProvider).valueOrNull ?? ThemeMode.system;
+
+ return _buildSegmentedButton(context, ref, current);
+ }
+
+ Widget _buildSegmentedButton(
+ BuildContext context,
+ WidgetRef ref,
+ ThemeMode current,
+ ) {
+ return SegmentedButton<ThemeMode>(
+ key: const Key('settings_theme_toggle'),
+ segments: const [
+ ButtonSegment(
+ value: ThemeMode.light,
+ icon: Icon(Icons.light_mode_outlined),
+ label: Text('Light'),
+ ),
+ ButtonSegment(
+ value: ThemeMode.system,
+ icon: Icon(Icons.brightness_auto_outlined),
+ label: Text('System'),
+ ),
+ ButtonSegment(
+ value: ThemeMode.dark,
+ icon: Icon(Icons.dark_mode_outlined),
+ label: Text('Dark'),
+ ),
+ ],
+ selected: {current},
+ // Allow only single selection — the user always has exactly one mode active.
+ multiSelectionEnabled: false,
+ onSelectionChanged: (selection) {
+ if (selection.isNotEmpty) {
+ ref.read(themeProvider.notifier).setThemeMode(selection.first);
+ }
+ },
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
// File-level helpers
// ---------------------------------------------------------------------------