mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
+9
-3
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: '',
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user