summaryrefslogtreecommitdiff
path: root/player-android/lib/providers/theme_provider.dart
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-22 09:25:48 +0300
committerPaul Buetow <paul@buetow.org>2026-05-22 09:25:48 +0300
commitcce598d8b698ecf4490127d5fad9bd0995ec1050 (patch)
tree11e617532e10915097c962f877b61e4476a33751 /player-android/lib/providers/theme_provider.dart
parent1824175276fcd29d4b6d4c14277c62e73247b7e2 (diff)
Implement Material 3 theming with light + dark mode toggle (fb)
Adds ThemeNotifier (AsyncNotifier) backed by shared_preferences to persist the user's light/dark/system preference across restarts. Color schemes derive from the dark and light palettes defined in player-server/docs/theming.md. MaterialApp now consumes themeProvider for themeMode, theme, and darkTheme; SettingsScreen gains a SegmentedButton appearance section for the toggle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android/lib/providers/theme_provider.dart')
-rw-r--r--player-android/lib/providers/theme_provider.dart169
1 files changed, 169 insertions, 0 deletions
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,
+);