From 8599a4d2a0f3f421a4ea2b21582f985d7fda9fcd Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 23 Apr 2026 11:00:00 -0600 Subject: [PATCH] feat(mobile): port desktop color schemes + video viewer overlay (#390) Co-authored-by: Claude Opus 4.6 (1M context) --- mobile/lib/app.dart | 12 +- .../features/channels/media_viewer_page.dart | 127 ++--- .../lib/features/settings/settings_page.dart | 55 +- .../features/settings/theme_picker_page.dart | 234 ++++++++ mobile/lib/shared/theme/accent_colors.dart | 1 + mobile/lib/shared/theme/adaptive_theme.dart | 193 +++++++ mobile/lib/shared/theme/app_theme.dart | 19 +- mobile/lib/shared/theme/color_scheme.dart | 6 +- mobile/lib/shared/theme/text_theme.dart | 16 + mobile/lib/shared/theme/theme.dart | 2 + mobile/lib/shared/theme/theme_catalog.dart | 528 ++++++++++++++++++ mobile/lib/shared/theme/theme_provider.dart | 85 ++- .../channels/message_content_test.dart | 59 ++ 13 files changed, 1209 insertions(+), 128 deletions(-) create mode 100644 mobile/lib/features/settings/theme_picker_page.dart create mode 100644 mobile/lib/shared/theme/adaptive_theme.dart create mode 100644 mobile/lib/shared/theme/theme_catalog.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 14b84913b..1ed8dd8e1 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -15,10 +16,15 @@ class App extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final themeMode = ref.watch(themeProvider); final accentIndex = ref.watch(accentProvider); + final schemeName = ref.watch(schemeProvider); final authState = ref.watch(authProvider); - final lightScheme = applyAccent(lightColorScheme, accentIndex); - final darkScheme = applyAccent(darkColorScheme, accentIndex); + final resolved = resolveSchemes(schemeName); + final lightScheme = applyAccent(resolved.light, accentIndex); + final darkScheme = applyAccent(resolved.dark, accentIndex); + // When a named scheme is selected it forces light or dark mode; + // otherwise respect the user's ThemeMode preference. + final effectiveMode = resolved.forcedMode ?? themeMode; // Eagerly initialize websocket session and lifecycle observer when // authenticated. These providers connect and manage the websocket. @@ -31,7 +37,7 @@ class App extends HookConsumerWidget { title: 'Sprout', theme: AppTheme.light(colorScheme: lightScheme), darkTheme: AppTheme.dark(colorScheme: darkScheme), - themeMode: themeMode, + themeMode: effectiveMode, home: authState.when( loading: () => const _SplashScreen(), error: (_, _) => const PairingPage(), diff --git a/mobile/lib/features/channels/media_viewer_page.dart b/mobile/lib/features/channels/media_viewer_page.dart index d513adcc6..a2be88a45 100644 --- a/mobile/lib/features/channels/media_viewer_page.dart +++ b/mobile/lib/features/channels/media_viewer_page.dart @@ -29,7 +29,7 @@ PageRoute buildImageViewerRoute({ semanticLabel: semanticLabel, ), transitionsBuilder: (context, animation, secondaryAnimation, child) => - _ImageViewerRouteTransition(animation: animation, child: child), + _MediaViewerRouteTransition(animation: animation, child: child), ); } @@ -54,18 +54,22 @@ void openVideoViewer( String? posterUrl, }) { Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => + PageRouteBuilder( + transitionDuration: _imageViewerPushDuration, + reverseTransitionDuration: _imageViewerPopDuration, + pageBuilder: (context, animation, secondaryAnimation) => MediaVideoViewerPage(videoUrl: videoUrl, posterUrl: posterUrl), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + _MediaViewerRouteTransition(animation: animation, child: child), ), ); } -class _ImageViewerRouteTransition extends StatelessWidget { +class _MediaViewerRouteTransition extends StatelessWidget { final Animation animation; final Widget child; - const _ImageViewerRouteTransition({ + const _MediaViewerRouteTransition({ required this.animation, required this.child, }); @@ -286,69 +290,66 @@ class _MediaVideoViewerPageState extends State { super.dispose(); } - @override - Widget build(BuildContext context) { - return _MediaViewerScaffold( - scaffoldKey: const ValueKey('message-media-video-viewer'), - title: 'Video', - child: Center( - child: FutureBuilder( - future: _initializeFuture, - builder: (context, snapshot) { - if (_error != null || snapshot.hasError) { - return const _MediaLoadFailure( - message: 'Failed to load video', - icon: LucideIcons.videoOff, - ); - } - - if (!_controller.value.isInitialized) { - return _VideoLoadingPoster(posterUrl: widget.posterUrl); - } - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - AspectRatio( - aspectRatio: _controller.value.aspectRatio, - child: VideoPlayer(_controller), - ), - const SizedBox(height: Grid.sm), - _VideoTransportBar(controller: _controller), - ], - ); - }, - ), - ), - ); - } -} - -class _MediaViewerScaffold extends StatelessWidget { - final Key scaffoldKey; - final String title; - final Widget child; - - const _MediaViewerScaffold({ - required this.scaffoldKey, - required this.title, - required this.child, - }); - @override Widget build(BuildContext context) { return Scaffold( - key: scaffoldKey, + key: const ValueKey('message-media-video-viewer'), backgroundColor: Colors.black, - appBar: AppBar( - backgroundColor: Colors.black, - foregroundColor: Colors.white, - scrolledUnderElevation: 0, - surfaceTintColor: Colors.transparent, - iconTheme: const IconThemeData(color: Colors.white), - title: Text(title), + body: Stack( + children: [ + Positioned.fill( + child: SafeArea( + child: Center( + child: FutureBuilder( + future: _initializeFuture, + builder: (context, snapshot) { + if (_error != null || snapshot.hasError) { + return const _MediaLoadFailure( + message: 'Failed to load video', + icon: LucideIcons.videoOff, + ); + } + + if (!_controller.value.isInitialized) { + return _VideoLoadingPoster(posterUrl: widget.posterUrl); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: VideoPlayer(_controller), + ), + const SizedBox(height: Grid.sm), + _VideoTransportBar(controller: _controller), + ], + ); + }, + ), + ), + ), + ), + PositionedDirectional( + top: Grid.sm, + end: Grid.sm, + child: SafeArea( + child: DecoratedBox( + decoration: const BoxDecoration( + color: Color.fromRGBO(0, 0, 0, 0.56), + shape: BoxShape.circle, + ), + child: IconButton( + key: const ValueKey('message-media-video-viewer-close'), + onPressed: () => Navigator.of(context).maybePop(), + tooltip: 'Close video viewer', + icon: const Icon(LucideIcons.x, color: Colors.white), + ), + ), + ), + ), + ], ), - body: SafeArea(child: child), ); } } diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 1d4b1ebad..10e3b4d4e 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -7,6 +7,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../../shared/auth/auth.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import 'theme_picker_page.dart'; class SettingsPage extends HookConsumerWidget { const SettingsPage({super.key}); @@ -15,6 +16,7 @@ class SettingsPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final config = ref.watch(relayConfigProvider); final selectedAccent = ref.watch(accentProvider); + final selectedScheme = ref.watch(schemeProvider); return Scaffold( appBar: AppBar(title: const Text('Settings')), @@ -94,28 +96,23 @@ class SettingsPage extends HookConsumerWidget { // Appearance Text('Appearance', style: context.textTheme.titleMedium), const SizedBox(height: Grid.twelve), - SegmentedButton( - segments: const [ - ButtonSegment( - value: ThemeMode.light, - icon: Icon(LucideIcons.sun), - label: Text('Light'), + + // Color scheme picker — navigates to dedicated page + ListTile( + leading: const Icon(LucideIcons.palette), + title: const Text('Color Scheme'), + subtitle: Text( + selectedScheme == null + ? 'Default (Catppuccin)' + : findTheme(selectedScheme)?.displayName ?? selectedScheme, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, ), - ButtonSegment( - value: ThemeMode.system, - icon: Icon(LucideIcons.monitor), - label: Text('System'), - ), - ButtonSegment( - value: ThemeMode.dark, - icon: Icon(LucideIcons.moon), - label: Text('Dark'), - ), - ], - selected: {ref.watch(themeProvider)}, - onSelectionChanged: (modes) { - ref.read(themeProvider.notifier).setThemeMode(modes.first); - }, + ), + trailing: const Icon(LucideIcons.chevronRight, size: 18), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ThemePickerPage()), + ), ), const SizedBox(height: Grid.xs), @@ -138,7 +135,6 @@ class SettingsPage extends HookConsumerWidget { .read(accentProvider.notifier) .setAccent(defaultAccentIndex), ), - // 8 accent colors from desktop for (var i = 0; i < accentColors.length; i++) _AccentSwatch( color: context.colors.brightness == Brightness.light @@ -219,19 +215,14 @@ class _AccentSwatch extends StatelessWidget { : Border.all(color: color.withValues(alpha: 0.4), width: 1), ), child: selected - ? Icon(LucideIcons.check, size: 16, color: _contrastColor(color)) + ? Icon( + LucideIcons.check, + size: 16, + color: contrastForeground(color), + ) : null, ), ), ); } - - static Color _contrastColor(Color bg) { - final lum = bg.computeLuminance(); - final contrastWithBlack = (lum + 0.05) / 0.05; - final contrastWithWhite = 1.05 / (lum + 0.05); - return contrastWithBlack >= contrastWithWhite - ? const Color(0xFF000000) - : const Color(0xFFFFFFFF); - } } diff --git a/mobile/lib/features/settings/theme_picker_page.dart b/mobile/lib/features/settings/theme_picker_page.dart new file mode 100644 index 000000000..755af24e8 --- /dev/null +++ b/mobile/lib/features/settings/theme_picker_page.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; + +class ThemePickerPage extends HookConsumerWidget { + const ThemePickerPage({super.key}); + + // ListTile height (~56px) used for scroll offset calculation. + static const _itemHeight = 56.0; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedScheme = ref.watch(schemeProvider); + final searchQuery = useState(''); + final searchController = useTextEditingController(); + final scrollController = useScrollController(); + + // Flat alphabetical list by display name + final sorted = List.from(themeCatalog) + ..sort((a, b) => a.displayName.compareTo(b.displayName)); + + final query = searchQuery.value.toLowerCase(); + final filtered = query.isEmpty + ? sorted + : sorted + .where((t) => t.displayName.toLowerCase().contains(query)) + .toList(); + final showDefault = + query.isEmpty || + 'default'.contains(query) || + 'catppuccin'.contains(query); + + // Default entry colors based on current brightness + final isLight = context.colors.brightness == Brightness.light; + final defaultBg = isLight + ? const Color(0xFFEFF1F5) + : const Color(0xFF24273A); + final defaultFg = isLight + ? const Color(0xFF4C4F69) + : const Color(0xFFCAD3F5); + final defaultComment = isLight + ? const Color(0xFF7C7F93) + : const Color(0xFF939AB7); + + // Auto-scroll to the selected theme on first build (no search active) + useEffect(() { + if (selectedScheme == null || query.isNotEmpty) return null; + final idx = sorted.indexWhere((t) => t.name == selectedScheme); + if (idx < 0) return null; + // +1 to account for the Default entry at the top + final offset = (idx + 1) * _itemHeight; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + scrollController.animateTo( + offset.clamp(0.0, scrollController.position.maxScrollExtent), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + return null; + }, const []); + + return Scaffold( + appBar: AppBar(title: const Text('Color Scheme')), + body: Column( + children: [ + // Always-visible search bar + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.lg), + border: Border.all(color: context.colors.outlineVariant), + ), + child: Row( + children: [ + Icon( + LucideIcons.search, + size: 16, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: TextField( + controller: searchController, + decoration: InputDecoration( + hintText: 'Search themes...', + hintStyle: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + style: context.textTheme.bodyMedium, + onChanged: (v) => searchQuery.value = v, + ), + ), + if (searchQuery.value.isNotEmpty) + GestureDetector( + onTap: () { + searchController.clear(); + searchQuery.value = ''; + }, + child: Icon( + LucideIcons.x, + size: 16, + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + + // Theme list + Expanded( + child: ListView( + controller: scrollController, + children: [ + // Default entry + if (showDefault) + _ThemeRow( + bg: defaultBg, + fg: defaultFg, + comment: defaultComment, + label: 'Default (Catppuccin)', + selected: selectedScheme == null, + onTap: () => + ref.read(schemeProvider.notifier).setScheme(null), + ), + + // All themes, flat alphabetical + for (final theme in filtered) + _ThemeRow( + bg: theme.bg, + fg: theme.fg, + comment: theme.comment, + label: theme.displayName, + selected: selectedScheme == theme.name, + onTap: () => + ref.read(schemeProvider.notifier).setScheme(theme.name), + ), + + // Empty state + if (!showDefault && filtered.isEmpty) + Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Center( + child: Text( + 'No themes found', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// A row showing a 3-stripe color bar (bg/fg/comment) + theme name + checkmark. +class _ThemeRow extends StatelessWidget { + const _ThemeRow({ + required this.bg, + required this.fg, + required this.comment, + required this.label, + required this.selected, + required this.onTap, + }); + + final Color bg; + final Color fg; + final Color comment; + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Container( + width: 56, + height: 28, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(Radii.sm), + border: Border.all(color: context.colors.outlineVariant, width: 1), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.sm - 1), + child: Row( + children: [ + Expanded( + child: ColoredBox(color: bg, child: const SizedBox.expand()), + ), + Expanded( + child: ColoredBox(color: fg, child: const SizedBox.expand()), + ), + Expanded( + child: ColoredBox( + color: comment, + child: const SizedBox.expand(), + ), + ), + ], + ), + ), + ), + title: Text(label), + trailing: selected + ? Icon(LucideIcons.check, size: 18, color: context.colors.primary) + : null, + onTap: onTap, + ); + } +} diff --git a/mobile/lib/shared/theme/accent_colors.dart b/mobile/lib/shared/theme/accent_colors.dart index 9a2e74079..cab417dfc 100644 --- a/mobile/lib/shared/theme/accent_colors.dart +++ b/mobile/lib/shared/theme/accent_colors.dart @@ -34,6 +34,7 @@ const accentColors = [ light: Color(0xFF6366F1), dark: Color(0xFF818CF8), ), + AccentColor(name: 'Lilac', light: Color(0xFFC0A2F1), dark: Color(0xFFC0A2F1)), ]; /// Default: Catppuccin Mauve (the current primary). diff --git a/mobile/lib/shared/theme/adaptive_theme.dart b/mobile/lib/shared/theme/adaptive_theme.dart new file mode 100644 index 000000000..f94c8bbb0 --- /dev/null +++ b/mobile/lib/shared/theme/adaptive_theme.dart @@ -0,0 +1,193 @@ +// Adaptive Theme Engine +// +// Derives a Material 3 [ColorScheme] from a syntax theme's key colors +// (bg, fg, comment, git). Detects light vs dark from background luminance +// and adjusts accordingly. +// +// Ported from desktop/src/shared/theme/adaptive-theme.ts. +import 'dart:math' as math; +import 'package:flutter/material.dart'; + +import 'color_scheme.dart' show contrastForeground; +import 'theme_catalog.dart'; + +// --------------------------------------------------------------------------- +// Color utilities +// --------------------------------------------------------------------------- + +double _luminance(Color c) => c.computeLuminance(); + +int _to255(double v) => (v * 255.0).round().clamp(0, 255); + +Color _mix(Color c1, Color c2, double factor) { + final r = c1.r + (c2.r - c1.r) * factor; + final g = c1.g + (c2.g - c1.g) * factor; + final b = c1.b + (c2.b - c1.b) * factor; + return Color.fromARGB(255, _to255(r), _to255(g), _to255(b)); +} + +Color _adjust(Color c, double amount) { + final target = amount > 0 ? const Color(0xFFFFFFFF) : const Color(0xFF000000); + return _mix(c, target, amount.abs()); +} + +// --------------------------------------------------------------------------- +// Chrome color calculation (sidebar/chrome area, slightly darker than editor) +// --------------------------------------------------------------------------- + +const _contrastValue = 0.035; +const _contrastOffset = 0.0135; + +double _calculateLumDiff(double bgLum) { + return _contrastValue * math.log(1 + (bgLum + _contrastOffset) * 10); +} + +Color _findColorWithLuminance(Color base, double targetLum) { + final baseLum = _luminance(base); + if ((baseLum - targetLum).abs() < 0.001) return base; + + final target = targetLum < baseLum + ? const Color(0xFF000000) + : const Color(0xFFFFFFFF); + var lo = 0.0; + var hi = 1.0; + + for (var i = 0; i < 20; i++) { + final mid = (lo + hi) / 2; + final testLum = _luminance(_mix(base, target, mid)); + final diff = testLum - targetLum; + + if (diff.abs() < 0.001) break; + + if (target == const Color(0xFF000000)) { + if (testLum > targetLum) { + lo = mid; + } else { + hi = mid; + } + } else { + if (testLum < targetLum) { + lo = mid; + } else { + hi = mid; + } + } + } + return _mix(base, target, (lo + hi) / 2); +} + +({Color chrome, Color primary}) _calculateChromeColors(Color syntaxBg) { + final bgLum = _luminance(syntaxBg); + final lumDiff = _calculateLumDiff(bgLum); + final targetChromeLum = bgLum - lumDiff; + + if (targetChromeLum >= 0) { + return ( + chrome: _findColorWithLuminance(syntaxBg, targetChromeLum), + primary: syntaxBg, + ); + } + + return ( + chrome: _findColorWithLuminance(syntaxBg, 0), + primary: _findColorWithLuminance(syntaxBg, lumDiff), + ); +} + +// --------------------------------------------------------------------------- +// Public API: generate a ColorScheme from theme colors +// --------------------------------------------------------------------------- + +/// Generate a full Material 3 [ColorScheme] from syntax theme colors. +/// +/// Maps the desktop's CSS variable system to Material 3 semantic slots: +/// --background → surface +/// --foreground → onSurface +/// --muted → surfaceContainerHighest +/// --muted-fg → onSurfaceVariant +/// --border → outline +/// --popover → surfaceContainerHigh (elevated surfaces) +/// --destructive → error +/// --sidebar-bg → surfaceContainerLowest (chrome) +ColorScheme generateColorScheme(ThemeColors theme) { + final isDark = theme.isDark; + final syntaxBg = theme.bg; + final syntaxFg = theme.fg; + final syntaxComment = theme.comment; + + final (:chrome, :primary) = _calculateChromeColors(syntaxBg); + + final dir = isDark ? 1.0 : -1.0; + Color elevate(double amount) => _adjust(primary, dir * amount); + + // Fallback git/accent colors + final fallbackGreen = isDark + ? const Color(0xFF3FB950) + : const Color(0xFF1A7F37); + final fallbackRed = isDark + ? const Color(0xFFF85149) + : const Color(0xFFCF222E); + + final accentGreen = theme.added ?? fallbackGreen; + final accentRed = theme.deleted ?? fallbackRed; + + // Derived surfaces + final borderColor = _mix(primary, syntaxFg, isDark ? 0.15 : 0.12); + final hoverBg = elevate(0.06); + final popoverBg = elevate(0.08); + + return ColorScheme( + brightness: isDark ? Brightness.dark : Brightness.light, + + // Primary — use the theme fg as a muted "primary" to keep the theme + // feeling cohesive. Accent color override happens in applyAccent(). + primary: syntaxFg, + onPrimary: contrastForeground(syntaxFg), + primaryContainer: hoverBg, + onPrimaryContainer: syntaxFg, + + // Secondary + secondary: syntaxComment, + onSecondary: contrastForeground(syntaxComment), + secondaryContainer: hoverBg, + onSecondaryContainer: syntaxFg, + + // Tertiary + tertiary: accentGreen, + onTertiary: contrastForeground(accentGreen), + tertiaryContainer: hoverBg, + onTertiaryContainer: accentGreen, + + // Error / destructive + error: accentRed, + onError: contrastForeground(accentRed), + errorContainer: _mix(primary, accentRed, 0.15), + onErrorContainer: accentRed, + + // Surfaces + surface: primary, + onSurface: syntaxFg, + onSurfaceVariant: syntaxComment, + + // Outline / borders + outline: borderColor, + outlineVariant: _mix(primary, borderColor, 0.5), + + // Inverse + inverseSurface: syntaxFg, + onInverseSurface: primary, + inversePrimary: syntaxComment, + + // Shadow / scrim + shadow: const Color(0xFF000000), + scrim: const Color(0xFF000000), + surfaceTint: syntaxFg, + + // Container hierarchy + surfaceContainerLowest: chrome, + surfaceContainerLow: _mix(chrome, primary, 0.5), + surfaceContainer: primary, + surfaceContainerHigh: popoverBg, + surfaceContainerHighest: elevate(0.04), + ); +} diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index aa1e8aae1..c9d8d5951 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -19,10 +19,10 @@ class Radii { class AppTheme { static ThemeData light({ColorScheme? colorScheme}) { final scheme = colorScheme ?? lightColorScheme; - const appColors = AppColors( - success: Color(0xFF40A02B), // Latte Green - warning: Color(0xFFDF8E1D), // Latte Yellow - accent: Color(0xFF1E66F5), // Latte Blue + final appColors = AppColors( + success: const Color(0xFF40A02B), // Catppuccin Latte Green — universal + warning: const Color(0xFFDF8E1D), // Latte Yellow + accent: scheme.tertiary, ); return _buildTheme( @@ -36,10 +36,12 @@ class AppTheme { static ThemeData dark({ColorScheme? colorScheme}) { final scheme = colorScheme ?? darkColorScheme; - const appColors = AppColors( - success: Color(0xFFA6DA95), // Macchiato Green - warning: Color(0xFFEED49F), // Macchiato Yellow - accent: Color(0xFF8AADF4), // Macchiato Blue + final appColors = AppColors( + success: const Color( + 0xFFA6DA95, + ), // Catppuccin Macchiato Green — universal + warning: const Color(0xFFEED49F), // Macchiato Yellow + accent: scheme.tertiary, ); return _buildTheme( @@ -66,6 +68,7 @@ class AppTheme { useMaterial3: true, colorScheme: scheme, extensions: [appColors], + fontFamily: 'Geist', textTheme: textTheme, appBarTheme: AppBarTheme( backgroundColor: scheme.surface, diff --git a/mobile/lib/shared/theme/color_scheme.dart b/mobile/lib/shared/theme/color_scheme.dart index db991375b..fae051f33 100644 --- a/mobile/lib/shared/theme/color_scheme.dart +++ b/mobile/lib/shared/theme/color_scheme.dart @@ -68,11 +68,11 @@ const darkColorScheme = ColorScheme( surfaceContainerHighest: Color(0xFF1E2030), // Macchiato Mantle ); -/// Compute a contrast-safe onPrimary color for a given accent. +/// Compute a contrast-safe foreground color for a given background. /// Uses WCAG contrast ratio (higher ratio wins) instead of a simple luminance /// cutoff, so colors like Blue (#3B82F6) correctly get black text (5.7:1) /// rather than white (3.7:1). -Color _contrastForeground(Color bg) { +Color contrastForeground(Color bg) { final lum = bg.computeLuminance(); // WCAG contrast ratio: (L1 + 0.05) / (L2 + 0.05), L1 >= L2 final contrastWithBlack = (lum + 0.05) / 0.05; // black luminance = 0 @@ -94,7 +94,7 @@ ColorScheme applyAccent(ColorScheme base, int accentIndex) { final color = base.brightness == Brightness.light ? accent.light : accent.dark; - final onColor = _contrastForeground(color); + final onColor = contrastForeground(color); return base.copyWith(primary: color, onPrimary: onColor, surfaceTint: color); } diff --git a/mobile/lib/shared/theme/text_theme.dart b/mobile/lib/shared/theme/text_theme.dart index b80d655ca..99a9bedc0 100644 --- a/mobile/lib/shared/theme/text_theme.dart +++ b/mobile/lib/shared/theme/text_theme.dart @@ -1,94 +1,110 @@ import 'package:flutter/material.dart'; const _fontFamily = 'Geist'; +const _fontFeatures = [FontFeature('ss03')]; const textTheme = TextTheme( displayLarge: TextStyle( fontFamily: _fontFamily, + fontFeatures: _fontFeatures, fontSize: 52, fontWeight: FontWeight.w400, height: 1.15, ), displayMedium: TextStyle( fontFamily: _fontFamily, + fontFeatures: _fontFeatures, fontSize: 44, fontWeight: FontWeight.w400, height: 1.15, ), displaySmall: TextStyle( fontFamily: _fontFamily, + fontFeatures: _fontFeatures, fontSize: 36, fontWeight: FontWeight.w400, height: 1.15, ), headlineLarge: TextStyle( fontFamily: _fontFamily, + fontFeatures: _fontFeatures, fontSize: 32, fontWeight: FontWeight.w600, height: 1.15, ), headlineMedium: TextStyle( fontFamily: _fontFamily, + fontFeatures: _fontFeatures, fontSize: 28, fontWeight: FontWeight.w600, height: 1.15, ), headlineSmall: TextStyle( fontFamily: _fontFamily, + fontFeatures: _fontFeatures, fontSize: 24, fontWeight: FontWeight.w600, height: 1.15, ), titleLarge: TextStyle( fontFamily: _fontFamily, + fontFeatures: _fontFeatures, fontSize: 24, fontWeight: FontWeight.w400, height: 1.15, ), titleMedium: TextStyle( fontFamily: _fontFamily, + fontFeatures: _fontFeatures, fontSize: 20, fontWeight: FontWeight.w500, height: 1.15, ), titleSmall: TextStyle( fontFamily: _fontFamily, + fontFeatures: _fontFeatures, fontSize: 16, fontWeight: FontWeight.w500, height: 1.15, ), labelLarge: TextStyle( fontFamily: _fontFamily, + fontFeatures: _fontFeatures, fontSize: 16, fontWeight: FontWeight.w500, height: 1.15, ), labelMedium: TextStyle( fontFamily: _fontFamily, + fontFeatures: _fontFeatures, fontSize: 14, fontWeight: FontWeight.w500, height: 1.15, ), labelSmall: TextStyle( fontFamily: _fontFamily, + fontFeatures: _fontFeatures, fontSize: 11, fontWeight: FontWeight.w500, height: 1.15, ), bodyLarge: TextStyle( fontFamily: _fontFamily, + fontFeatures: _fontFeatures, fontSize: 16, fontWeight: FontWeight.w400, height: 1.25, ), bodyMedium: TextStyle( fontFamily: _fontFamily, + fontFeatures: _fontFeatures, fontSize: 14, fontWeight: FontWeight.w400, height: 1.43, ), bodySmall: TextStyle( fontFamily: _fontFamily, + fontFeatures: _fontFeatures, fontSize: 12, fontWeight: FontWeight.w400, ), diff --git a/mobile/lib/shared/theme/theme.dart b/mobile/lib/shared/theme/theme.dart index 7dc6996fd..e8a956661 100644 --- a/mobile/lib/shared/theme/theme.dart +++ b/mobile/lib/shared/theme/theme.dart @@ -1,7 +1,9 @@ export 'accent_colors.dart'; +export 'adaptive_theme.dart'; export 'app_colors.dart'; export 'app_theme.dart'; export 'color_scheme.dart'; export 'grid.dart'; +export 'theme_catalog.dart'; export 'theme_extensions.dart'; export 'theme_provider.dart'; diff --git a/mobile/lib/shared/theme/theme_catalog.dart b/mobile/lib/shared/theme/theme_catalog.dart new file mode 100644 index 000000000..3a9780f20 --- /dev/null +++ b/mobile/lib/shared/theme/theme_catalog.dart @@ -0,0 +1,528 @@ +// Static catalog of all 60 Shiki syntax theme color definitions. +// +// Each entry contains the key colors extracted from the Shiki theme JSON: +// bg, fg, comment (+ optional git added/deleted colors). +// The adaptive theme engine uses these to derive a full Material ColorScheme. +import 'package:flutter/material.dart'; + +class ThemeColors { + final String name; + final Color bg; + final Color fg; + final Color comment; + final Color? added; + final Color? deleted; + + const ThemeColors({ + required this.name, + required this.bg, + required this.fg, + required this.comment, + this.added, + this.deleted, + }); + + bool get isDark => bg.computeLuminance() < 0.5; + + /// Human-readable display name: 'catppuccin-mocha' → 'Catppuccin Mocha'. + String get displayName => name + .split('-') + .map((w) => w.isNotEmpty ? '${w[0].toUpperCase()}${w.substring(1)}' : w) + .join(' '); +} + +/// Known light theme names — used to show sun/moon icons before loading. +const lightThemeNames = { + 'catppuccin-latte', + 'everforest-light', + 'github-light', + 'github-light-default', + 'github-light-high-contrast', + 'gruvbox-light-hard', + 'gruvbox-light-medium', + 'gruvbox-light-soft', + 'kanagawa-lotus', + 'light-plus', + 'material-theme-lighter', + 'min-light', + 'one-light', + 'rose-pine-dawn', + 'slack-ochin', + 'snazzy-light', + 'solarized-light', + 'vitesse-light', +}; + +/// All available color schemes, sorted alphabetically. +const themeCatalog = [ + ThemeColors( + name: 'andromeeda', + bg: Color(0xFF23262E), + fg: Color(0xFFD5CED9), + comment: Color(0xFFA0A1A7), + added: Color(0xFF9BC53D), + deleted: Color(0xFFFC644D), + ), + ThemeColors( + name: 'aurora-x', + bg: Color(0xFF07090F), + fg: Color(0xFFD4D4D4), + comment: Color(0xFF546E7A), + added: Color(0xFF64D389), + deleted: Color(0xFFDD5074), + ), + ThemeColors( + name: 'ayu-dark', + bg: Color(0xFF10141C), + fg: Color(0xFFBFBDB6), + comment: Color(0xFF5A6673), + added: Color(0xFF70BF56), + deleted: Color(0xFFF26D78), + ), + ThemeColors( + name: 'catppuccin-frappe', + bg: Color(0xFF303446), + fg: Color(0xFFC6D0F5), + comment: Color(0xFF949CBB), + added: Color(0xFFA6D189), + deleted: Color(0xFFE78284), + ), + ThemeColors( + name: 'catppuccin-latte', + bg: Color(0xFFEFF1F5), + fg: Color(0xFF4C4F69), + comment: Color(0xFF7C7F93), + added: Color(0xFF40A02B), + deleted: Color(0xFFD20F39), + ), + ThemeColors( + name: 'catppuccin-macchiato', + bg: Color(0xFF24273A), + fg: Color(0xFFCAD3F5), + comment: Color(0xFF939AB7), + added: Color(0xFFA6DA95), + deleted: Color(0xFFED8796), + ), + ThemeColors( + name: 'catppuccin-mocha', + bg: Color(0xFF1E1E2E), + fg: Color(0xFFCDD6F4), + comment: Color(0xFF9399B2), + added: Color(0xFFA6E3A1), + deleted: Color(0xFFF38BA8), + ), + ThemeColors( + name: 'dark-plus', + bg: Color(0xFF1E1E1E), + fg: Color(0xFFD4D4D4), + comment: Color(0xFF6A9955), + ), + ThemeColors( + name: 'dracula', + bg: Color(0xFF282A36), + fg: Color(0xFFF8F8F2), + comment: Color(0xFF6272A4), + added: Color(0xFF50FA7B), + deleted: Color(0xFFFF5555), + ), + ThemeColors( + name: 'dracula-soft', + bg: Color(0xFF282A36), + fg: Color(0xFFF6F6F4), + comment: Color(0xFF7B7F8B), + added: Color(0xFF50FA7B), + deleted: Color(0xFFEE6666), + ), + ThemeColors( + name: 'everforest-dark', + bg: Color(0xFF2D353B), + fg: Color(0xFFD3C6AA), + comment: Color(0xFFD3C6AA), + added: Color(0xFFA7C080), + deleted: Color(0xFFE67E80), + ), + ThemeColors( + name: 'everforest-light', + bg: Color(0xFFFDF6E3), + fg: Color(0xFF5C6A72), + comment: Color(0xFF5C6A72), + added: Color(0xFF8DA101), + deleted: Color(0xFFF85552), + ), + ThemeColors( + name: 'github-dark', + bg: Color(0xFF24292E), + fg: Color(0xFFE1E4E8), + comment: Color(0xFF6A737D), + added: Color(0xFF34D058), + deleted: Color(0xFFEA4A5A), + ), + ThemeColors( + name: 'github-dark-default', + bg: Color(0xFF0D1117), + fg: Color(0xFFE6EDF3), + comment: Color(0xFF8B949E), + added: Color(0xFF3FB950), + deleted: Color(0xFFF85149), + ), + ThemeColors( + name: 'github-dark-dimmed', + bg: Color(0xFF22272E), + fg: Color(0xFFADBac7), + comment: Color(0xFF768390), + added: Color(0xFF57AB5A), + deleted: Color(0xFFE5534B), + ), + ThemeColors( + name: 'github-dark-high-contrast', + bg: Color(0xFF0A0C10), + fg: Color(0xFFF0F3F6), + comment: Color(0xFFBDC4CC), + added: Color(0xFF26CD4D), + deleted: Color(0xFFFF6A69), + ), + ThemeColors( + name: 'github-light', + bg: Color(0xFFFFFFFF), + fg: Color(0xFF24292E), + comment: Color(0xFF6A737D), + added: Color(0xFF28A745), + deleted: Color(0xFFD73A49), + ), + ThemeColors( + name: 'github-light-default', + bg: Color(0xFFFFFFFF), + fg: Color(0xFF1F2328), + comment: Color(0xFF6E7781), + added: Color(0xFF1A7F37), + deleted: Color(0xFFCF222E), + ), + ThemeColors( + name: 'github-light-high-contrast', + bg: Color(0xFFFFFFFF), + fg: Color(0xFF0E1116), + comment: Color(0xFF66707B), + added: Color(0xFF055D20), + deleted: Color(0xFFA0111F), + ), + ThemeColors( + name: 'gruvbox-dark-hard', + bg: Color(0xFF1D2021), + fg: Color(0xFFEBDBB2), + comment: Color(0xFF928374), + added: Color(0xFFEBDBB2), + deleted: Color(0xFFCC241D), + ), + ThemeColors( + name: 'gruvbox-dark-medium', + bg: Color(0xFF282828), + fg: Color(0xFFEBDBB2), + comment: Color(0xFF928374), + added: Color(0xFFEBDBB2), + deleted: Color(0xFFCC241D), + ), + ThemeColors( + name: 'gruvbox-dark-soft', + bg: Color(0xFF32302F), + fg: Color(0xFFEBDBB2), + comment: Color(0xFF928374), + added: Color(0xFFEBDBB2), + deleted: Color(0xFFCC241D), + ), + ThemeColors( + name: 'gruvbox-light-hard', + bg: Color(0xFFF9F5D7), + fg: Color(0xFF3C3836), + comment: Color(0xFF928374), + added: Color(0xFF3C3836), + deleted: Color(0xFFCC241D), + ), + ThemeColors( + name: 'gruvbox-light-medium', + bg: Color(0xFFFBF1C7), + fg: Color(0xFF3C3836), + comment: Color(0xFF928374), + added: Color(0xFF3C3836), + deleted: Color(0xFFCC241D), + ), + ThemeColors( + name: 'gruvbox-light-soft', + bg: Color(0xFFF2E5BC), + fg: Color(0xFF3C3836), + comment: Color(0xFF928374), + added: Color(0xFF3C3836), + deleted: Color(0xFFCC241D), + ), + ThemeColors( + name: 'houston', + bg: Color(0xFF17191E), + fg: Color(0xFFEEF0F9), + comment: Color(0xFFEEF0F9), + added: Color(0xFF4BF3C8), + deleted: Color(0xFFF4587E), + ), + ThemeColors( + name: 'kanagawa-dragon', + bg: Color(0xFF181616), + fg: Color(0xFFC5C9C5), + comment: Color(0xFF737C73), + added: Color(0xFF76946A), + deleted: Color(0xFFC34043), + ), + ThemeColors( + name: 'kanagawa-lotus', + bg: Color(0xFFF2ECBC), + fg: Color(0xFF545464), + comment: Color(0xFF716E61), + added: Color(0xFF6E915F), + deleted: Color(0xFFD7474B), + ), + ThemeColors( + name: 'kanagawa-wave', + bg: Color(0xFF1F1F28), + fg: Color(0xFFDCD7BA), + comment: Color(0xFF727169), + added: Color(0xFF76946A), + deleted: Color(0xFFC34043), + ), + ThemeColors( + name: 'laserwave', + bg: Color(0xFF27212E), + fg: Color(0xFFFFFFFF), + comment: Color(0xFF91889B), + added: Color(0xFF74DFC4), + deleted: Color(0xFFB381C5), + ), + ThemeColors( + name: 'light-plus', + bg: Color(0xFFFFFFFF), + fg: Color(0xFF000000), + comment: Color(0xFF008000), + ), + ThemeColors( + name: 'material-theme', + bg: Color(0xFF263238), + fg: Color(0xFFEEFFFF), + comment: Color(0xFF546E7A), + added: Color(0xFFC3E88D), + deleted: Color(0xFFF07178), + ), + ThemeColors( + name: 'material-theme-darker', + bg: Color(0xFF212121), + fg: Color(0xFFEEFFFF), + comment: Color(0xFF545454), + added: Color(0xFFC3E88D), + deleted: Color(0xFFF07178), + ), + ThemeColors( + name: 'material-theme-lighter', + bg: Color(0xFFFAFAFA), + fg: Color(0xFF90A4AE), + comment: Color(0xFF90A4AE), + added: Color(0xFF91B859), + deleted: Color(0xFFE53935), + ), + ThemeColors( + name: 'material-theme-ocean', + bg: Color(0xFF0F111A), + fg: Color(0xFFBABED8), + comment: Color(0xFF464B5D), + added: Color(0xFFC3E88D), + deleted: Color(0xFFF07178), + ), + ThemeColors( + name: 'material-theme-palenight', + bg: Color(0xFF292D3E), + fg: Color(0xFFBABED8), + comment: Color(0xFF676E95), + added: Color(0xFFC3E88D), + deleted: Color(0xFFF07178), + ), + ThemeColors( + name: 'min-dark', + bg: Color(0xFF1F1F1F), + fg: Color(0xFFD4D4D4), + comment: Color(0xFF6B737C), + ), + ThemeColors( + name: 'min-light', + bg: Color(0xFFFFFFFF), + fg: Color(0xFF212121), + comment: Color(0xFFC2C3C5), + ), + ThemeColors( + name: 'monokai', + bg: Color(0xFF272822), + fg: Color(0xFFF8F8F2), + comment: Color(0xFF88846F), + ), + ThemeColors( + name: 'night-owl', + bg: Color(0xFF011627), + fg: Color(0xFFD6DEEB), + comment: Color(0xFF637777), + added: Color(0xFF9CCC65), + deleted: Color(0xFFEF5350), + ), + ThemeColors( + name: 'nord', + bg: Color(0xFF2E3440), + fg: Color(0xFFD8DEE9), + comment: Color(0xFF616E88), + added: Color(0xFFA3BE8C), + deleted: Color(0xFFBF616A), + ), + ThemeColors( + name: 'one-dark-pro', + bg: Color(0xFF282C34), + fg: Color(0xFFABB2BF), + comment: Color(0xFFABB2BF), + added: Color(0xFF109868), + deleted: Color(0xFF9A353D), + ), + ThemeColors( + name: 'one-light', + bg: Color(0xFFFAFAFA), + fg: Color(0xFF383A42), + comment: Color(0xFFA0A1A7), + ), + ThemeColors( + name: 'plastic', + bg: Color(0xFF21252B), + fg: Color(0xFFA9B2C3), + comment: Color(0xFF5F6672), + added: Color(0xFF98C379), + deleted: Color(0xFFE06C75), + ), + ThemeColors( + name: 'poimandres', + bg: Color(0xFF1B1E28), + fg: Color(0xFFA6ACCD), + comment: Color(0xFF767C9D), + added: Color(0xFF5FB3A1), + deleted: Color(0xFFD0679D), + ), + ThemeColors( + name: 'red', + bg: Color(0xFF390000), + fg: Color(0xFFF8F8F8), + comment: Color(0xFFE7C0C0), + ), + ThemeColors( + name: 'rose-pine', + bg: Color(0xFF191724), + fg: Color(0xFFE0DEF4), + comment: Color(0xFF6E6A86), + added: Color(0xFF9CCFD8), + deleted: Color(0xFF908CAA), + ), + ThemeColors( + name: 'rose-pine-dawn', + bg: Color(0xFFFAF4ED), + fg: Color(0xFF575279), + comment: Color(0xFF9893A5), + added: Color(0xFF56949F), + deleted: Color(0xFF797593), + ), + ThemeColors( + name: 'rose-pine-moon', + bg: Color(0xFF232136), + fg: Color(0xFFE0DEF4), + comment: Color(0xFF6E6A86), + added: Color(0xFF9CCFD8), + deleted: Color(0xFF908CAA), + ), + ThemeColors( + name: 'slack-dark', + bg: Color(0xFF222222), + fg: Color(0xFFE6E6E6), + comment: Color(0xFF6A9955), + added: Color(0xFFECB22E), + deleted: Color(0xFFFFFFFF), + ), + ThemeColors( + name: 'slack-ochin', + bg: Color(0xFFFFFFFF), + fg: Color(0xFF000000), + comment: Color(0xFF357B42), + added: Color(0xFFECB22E), + deleted: Color(0xFFFFFFFF), + ), + ThemeColors( + name: 'snazzy-light', + bg: Color(0xFFFAFBFC), + fg: Color(0xFF565869), + comment: Color(0xFFADB1C2), + added: Color(0xFF2DAE58), + deleted: Color(0xFFFF5C57), + ), + ThemeColors( + name: 'solarized-dark', + bg: Color(0xFF002B36), + fg: Color(0xFF839496), + comment: Color(0xFF586E75), + ), + ThemeColors( + name: 'solarized-light', + bg: Color(0xFFFDF6E3), + fg: Color(0xFF657B83), + comment: Color(0xFF93A1A1), + ), + ThemeColors( + name: 'synthwave-84', + bg: Color(0xFF262335), + fg: Color(0xFFD4D4D4), + comment: Color(0xFF848BBD), + added: Color(0xFF72F1B8), + deleted: Color(0xFFFE4450), + ), + ThemeColors( + name: 'tokyo-night', + bg: Color(0xFF1A1B26), + fg: Color(0xFFA9B1D6), + comment: Color(0xFF51597D), + added: Color(0xFF449DAB), + deleted: Color(0xFF914C54), + ), + ThemeColors( + name: 'vesper', + bg: Color(0xFF101010), + fg: Color(0xFFFFFFFF), + comment: Color(0xFF8B8B8B), + added: Color(0xFF99FFE4), + deleted: Color(0xFFFF8080), + ), + ThemeColors( + name: 'vitesse-black', + bg: Color(0xFF000000), + fg: Color(0xFFDBD7CA), + comment: Color(0xFF758575), + added: Color(0xFF4D9375), + deleted: Color(0xFFCB7676), + ), + ThemeColors( + name: 'vitesse-dark', + bg: Color(0xFF121212), + fg: Color(0xFFDBD7CA), + comment: Color(0xFF758575), + added: Color(0xFF4D9375), + deleted: Color(0xFFCB7676), + ), + ThemeColors( + name: 'vitesse-light', + bg: Color(0xFFFFFFFF), + fg: Color(0xFF393A34), + comment: Color(0xFFA0ADA0), + added: Color(0xFF1E754F), + deleted: Color(0xFFAB5959), + ), +]; + +/// Lookup a theme by name. Returns null if not found. +ThemeColors? findTheme(String name) { + for (final t in themeCatalog) { + if (t.name == name) return t; + } + return null; +} diff --git a/mobile/lib/shared/theme/theme_provider.dart b/mobile/lib/shared/theme/theme_provider.dart index 848af37ec..cc3c1fb1a 100644 --- a/mobile/lib/shared/theme/theme_provider.dart +++ b/mobile/lib/shared/theme/theme_provider.dart @@ -3,9 +3,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'accent_colors.dart'; +import 'adaptive_theme.dart'; +import 'color_scheme.dart'; +import 'theme_catalog.dart'; const _themeModeKey = 'sprout_theme_mode'; const _accentKey = 'sprout_accent_color'; +const _schemeKey = 'sprout_color_scheme'; /// Pre-loaded SharedPreferences instance, overridden in main(). final savedPrefsProvider = Provider( @@ -23,25 +27,6 @@ class ThemeNotifier extends Notifier { } return ThemeMode.system; } - - void setThemeMode(ThemeMode themeMode) { - state = themeMode; - ref.read(savedPrefsProvider).setString(_themeModeKey, themeMode.name); - } - - void toggleTheme() { - switch (state) { - case ThemeMode.light: - setThemeMode(ThemeMode.dark); - break; - case ThemeMode.dark: - setThemeMode(ThemeMode.system); - break; - case ThemeMode.system: - setThemeMode(ThemeMode.light); - break; - } - } } final themeProvider = NotifierProvider( @@ -66,3 +51,65 @@ class AccentNotifier extends Notifier { final accentProvider = NotifierProvider( AccentNotifier.new, ); + +/// Tracks the selected color scheme name. +/// null means "use default" (catppuccin-latte for light, catppuccin-macchiato +/// for dark — the original hardcoded schemes). +class SchemeNotifier extends Notifier { + @override + String? build() { + final prefs = ref.read(savedPrefsProvider); + final scheme = prefs.getString(_schemeKey); + + // One-time migration: if no scheme has been chosen and the user had a + // non-system themeMode persisted (from the old Light/System/Dark toggle), + // reset it to system so default Catppuccin respects platform brightness. + if (scheme == null) { + final storedMode = prefs.getString(_themeModeKey); + if (storedMode != null && storedMode != ThemeMode.system.name) { + prefs.setString(_themeModeKey, ThemeMode.system.name); + } + } + + return scheme; + } + + void setScheme(String? name) { + state = name; + final prefs = ref.read(savedPrefsProvider); + if (name == null) { + prefs.remove(_schemeKey); + } else { + prefs.setString(_schemeKey, name); + } + } +} + +final schemeProvider = NotifierProvider( + SchemeNotifier.new, +); + +/// Resolves the current scheme selection into light and dark [ColorScheme]s. +/// When a named scheme is selected, generates it via the adaptive engine. +/// When null (default), returns the original Catppuccin schemes. +({ColorScheme light, ColorScheme dark, ThemeMode? forcedMode}) resolveSchemes( + String? schemeName, +) { + if (schemeName == null) { + return (light: lightColorScheme, dark: darkColorScheme, forcedMode: null); + } + + final theme = findTheme(schemeName); + if (theme == null) { + return (light: lightColorScheme, dark: darkColorScheme, forcedMode: null); + } + + final scheme = generateColorScheme(theme); + + // A named scheme is inherently light or dark — force the mode. + return ( + light: scheme, + dark: scheme, + forcedMode: theme.isDark ? ThemeMode.dark : ThemeMode.light, + ); +} diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index efa85b7aa..16e4007c5 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -565,6 +565,65 @@ void main() { expect(find.byIcon(LucideIcons.play), findsOneWidget); }); + testWidgets( + 'tapping video preview opens overlay viewer with close button', + (tester) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: '![video](https://example.com/media/clip.mp4)', + tags: [ + [ + 'imeta', + 'url https://example.com/media/clip.mp4', + 'm video/mp4', + ], + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final preview = find.byKey( + const ValueKey( + 'message-media-video-preview:https://example.com/media/clip.mp4', + ), + ); + expect(preview, findsOneWidget); + + await tester.tap(preview); + await tester.pumpAndSettle(); + + // Video viewer opens as a modal overlay (no AppBar) + final viewer = tester.widget( + find.byKey(const ValueKey('message-media-video-viewer')), + ); + expect( + find.byKey(const ValueKey('message-media-video-viewer')), + findsOneWidget, + ); + expect(viewer.backgroundColor, Colors.black); + expect(viewer.appBar, isNull); + + // Close button is present + expect( + find.byKey(const ValueKey('message-media-video-viewer-close')), + findsOneWidget, + ); + + // Tapping close dismisses the viewer + await tester.tap( + find.byKey(const ValueKey('message-media-video-viewer-close')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('message-media-video-viewer')), + findsNothing, + ); + }, + ); + testWidgets('treats only mp4 fallback URLs as videos', (tester) async { await tester.pumpWidget( _testable(