feat(mobile): port desktop color schemes + video viewer overlay (#390)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-23 10:00:00 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 686db1294e
commit 8599a4d2a0
13 changed files with 1209 additions and 128 deletions
+9 -3
View File
@@ -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(),
@@ -29,7 +29,7 @@ PageRoute<void> 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<void>(
builder: (_) =>
PageRouteBuilder<void>(
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<double> animation;
final Widget child;
const _ImageViewerRouteTransition({
const _MediaViewerRouteTransition({
required this.animation,
required this.child,
});
@@ -286,69 +290,66 @@ class _MediaVideoViewerPageState extends State<MediaVideoViewerPage> {
super.dispose();
}
@override
Widget build(BuildContext context) {
return _MediaViewerScaffold(
scaffoldKey: const ValueKey('message-media-video-viewer'),
title: 'Video',
child: Center(
child: FutureBuilder<void>(
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<void>(
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),
);
}
}
+23 -32
View File
@@ -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<ThemeMode>(
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<void>(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);
}
}
@@ -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<ThemeColors>.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,
);
}
}
@@ -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).
+193
View File
@@ -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),
);
}
+11 -8
View File
@@ -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,
+3 -3
View File
@@ -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);
}
+16
View File
@@ -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,
),
+2
View File
@@ -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';
+528
View File
@@ -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 = <String>{
'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>[
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;
}
+66 -19
View File
@@ -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<SharedPreferences>(
@@ -23,25 +27,6 @@ class ThemeNotifier extends Notifier<ThemeMode> {
}
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<ThemeNotifier, ThemeMode>(
@@ -66,3 +51,65 @@ class AccentNotifier extends Notifier<int> {
final accentProvider = NotifierProvider<AccentNotifier, int>(
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<String?> {
@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, String?>(
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,
);
}
@@ -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<Scaffold>(
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(