feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+121
View File
@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
/// Accent colors matching the desktop Buzz app.
class AccentColor {
final String name;
final Color light;
final Color dark;
final bool useThemeForegroundInDark;
final String wireValue;
const AccentColor({
required this.name,
required this.light,
required this.dark,
this.useThemeForegroundInDark = false,
required this.wireValue,
});
}
const accentColors = [
AccentColor(
name: 'Neutral',
light: Color(0xFF000000),
dark: Color(0xFFE1E4E8),
wireValue: 'neutral',
useThemeForegroundInDark: true,
),
AccentColor(
name: 'Blue',
light: Color(0xFF3B82F6),
dark: Color(0xFF60A5FA),
wireValue: '#3b82f6',
),
AccentColor(
name: 'Cyan',
light: Color(0xFF06B6D4),
dark: Color(0xFF22D3EE),
wireValue: '#06b6d4',
),
AccentColor(
name: 'Green',
light: Color(0xFF22C55E),
dark: Color(0xFF4ADE80),
wireValue: '#22c55e',
),
AccentColor(
name: 'Orange',
light: Color(0xFFF97316),
dark: Color(0xFFFB923C),
wireValue: '#f97316',
),
AccentColor(
name: 'Red',
light: Color(0xFFEF4444),
dark: Color(0xFFF87171),
wireValue: '#ef4444',
),
AccentColor(
name: 'Pink',
light: Color(0xFFEC4899),
dark: Color(0xFFF472B6),
wireValue: '#ec4899',
),
AccentColor(
name: 'Lilac',
light: Color(0xFFC0A2F1),
dark: Color(0xFFC0A2F1),
wireValue: '#c0a2f1',
),
AccentColor(
name: 'Purple',
light: Color(0xFFA855F7),
dark: Color(0xFFC084FC),
wireValue: '#a855f7',
),
AccentColor(
name: 'Indigo',
light: Color(0xFF6366F1),
dark: Color(0xFF818CF8),
wireValue: '#6366f1',
),
];
Color accentColorForScheme(ColorScheme scheme, int accentIndex) {
if (accentIndex < 0 || accentIndex >= accentColors.length) {
return scheme.primary;
}
final accent = accentColors[accentIndex];
if (scheme.brightness == Brightness.dark && accent.useThemeForegroundInDark) {
return scheme.onSurface;
}
return scheme.brightness == Brightness.light ? accent.light : accent.dark;
}
/// New default: Black.
///
/// Keep this at the end of [accentColors] so existing saved accent indexes keep
/// pointing at the same colors.
const neutralAccentIndex = 0;
const defaultAccentIndex = neutralAccentIndex;
/// Legacy default: Catppuccin Mauve/the base theme primary.
const legacyDefaultAccentIndex = -1;
int? accentIndexForWireValue(String value) {
final index = accentColors.indexWhere((accent) => accent.wireValue == value);
return index < 0 ? null : index;
}
String legacyAccentWireValue(int? index) {
// Legacy mobile indexes 0...7 match desktop Blue...Indigo except Lilac,
// which did not exist. Black (8) has no desktop wire value, so migrate it
// deterministically to Neutral rather than publishing a mobile-only value.
if (index == 8) return 'neutral';
if (index != null && index >= 0 && index <= 5) {
return accentColors[index + 1].wireValue;
}
if (index == 6) return '#a855f7';
if (index == 7) return '#6366f1';
return '#3b82f6';
}
+181
View File
@@ -0,0 +1,181 @@
// 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';
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());
}
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),
);
}
/// 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),
);
}
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
@immutable
class AppColors extends ThemeExtension<AppColors> {
final Color success;
final Color warning;
final Color accent;
/// Gradient for the app's top section, non-null only under the Buzz themes.
/// Carried on the theme rather than read from a provider so any surface can
/// opt in via `context.appColors.topSectionGradient` — see
/// `buzzTopSectionGradient`.
final Gradient? topSectionGradient;
const AppColors({
required this.success,
required this.warning,
required this.accent,
this.topSectionGradient,
});
@override
AppColors copyWith({
Color? success,
Color? warning,
Color? accent,
Gradient? topSectionGradient,
}) => AppColors(
success: success ?? this.success,
warning: warning ?? this.warning,
accent: accent ?? this.accent,
topSectionGradient: topSectionGradient ?? this.topSectionGradient,
);
@override
AppColors lerp(ThemeExtension<AppColors>? other, double t) {
if (other is! AppColors) return this;
return AppColors(
success: Color.lerp(success, other.success, t)!,
warning: Color.lerp(warning, other.warning, t)!,
accent: Color.lerp(accent, other.accent, t)!,
topSectionGradient: Gradient.lerp(
topSectionGradient,
other.topSectionGradient,
t,
),
);
}
}
+339
View File
@@ -0,0 +1,339 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'app_colors.dart';
import 'color_scheme.dart';
import 'grid.dart';
import 'text_theme.dart';
/// Border radius constants matching desktop shadcn "New York" style.
/// Desktop uses --radius: 0.625rem (10px) as base:
/// lg = 10px, md = 8px, sm = 6px
class Radii {
/// Small radius for compact UI elements.
static const double xs = 4.0;
static const double lg = 10.0;
static const double md = 8.0;
static const double sm = 6.0;
static const double card = 12.0; // grouped settings cards
static const double popover = 20.0;
static const double dialog = 24.0; // desktop uses rounded-3xl for dialogs
/// Fully rounds pills, circles, and other capsule shapes.
static const double full = 999.0;
}
class AppTheme {
static ThemeData light({
ColorScheme? colorScheme,
Gradient? topSectionGradient,
}) {
final scheme = colorScheme ?? lightColorScheme;
final appColors = AppColors(
success: const Color(0xFF40A02B), // Catppuccin Latte Green — universal
warning: const Color(0xFFDF8E1D), // Latte Yellow
accent: scheme.tertiary,
topSectionGradient: topSectionGradient,
);
return _buildTheme(
scheme: scheme,
appColors: appColors,
brightness: Brightness.light,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
);
}
static ThemeData dark({
ColorScheme? colorScheme,
Gradient? topSectionGradient,
}) {
final scheme = colorScheme ?? darkColorScheme;
final appColors = AppColors(
success: const Color(
0xFFA6DA95,
), // Catppuccin Macchiato Green — universal
warning: const Color(0xFFEED49F), // Macchiato Yellow
accent: scheme.tertiary,
topSectionGradient: topSectionGradient,
);
return _buildTheme(
scheme: scheme,
appColors: appColors,
brightness: Brightness.dark,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
);
}
static ThemeData _buildTheme({
required ColorScheme scheme,
required AppColors appColors,
required Brightness brightness,
required Brightness statusBarIconBrightness,
required Brightness statusBarBrightness,
}) {
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
splashFactory: NoSplash.splashFactory,
scaffoldBackgroundColor: scheme.surface,
extensions: [appColors],
fontFamily: 'Inter',
textTheme: textTheme,
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
foregroundColor: scheme.onSurface,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
titleTextStyle: textTheme.titleMedium?.copyWith(
color: scheme.onSurface,
),
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: statusBarIconBrightness,
statusBarBrightness: statusBarBrightness,
),
),
// Bottom navigation: clean style, no indicator pill
navigationBarTheme: NavigationBarThemeData(
backgroundColor: scheme.surface,
elevation: 0,
indicatorColor: Colors.transparent,
iconTheme: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return IconThemeData(color: scheme.primary, size: 24);
}
return IconThemeData(color: scheme.onSurfaceVariant, size: 24);
}),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return textTheme.labelSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w600,
);
}
return textTheme.labelSmall?.copyWith(color: scheme.onSurfaceVariant);
}),
),
// Buttons: desktop uses rounded-md (8px), h-9 (36px), px-4 (16px)
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
textStyle: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
textStyle: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
side: BorderSide(color: scheme.outline, width: 1),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
textStyle: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: scheme.onSurface,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
textStyle: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
// Cards: desktop uses rounded-lg (10px), flat, no elevation
cardTheme: CardThemeData(
color: scheme.surfaceContainerHighest,
margin: EdgeInsets.zero,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.lg),
),
),
// Inputs: desktop uses outlined style, rounded-md (8px), h-9 (36px)
inputDecorationTheme: InputDecorationTheme(
filled: false,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: scheme.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: scheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: scheme.primary),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: scheme.error),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: scheme.error),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
isDense: true,
),
// Dialogs: desktop uses rounded-3xl (24px), custom overlay
dialogTheme: DialogThemeData(
backgroundColor: scheme.surface,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.dialog),
side: BorderSide(color: scheme.outline),
),
titleTextStyle: textTheme.titleLarge?.copyWith(
color: scheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
contentTextStyle: textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
progressIndicatorTheme: ProgressIndicatorThemeData(
strokeWidth: 2,
color: scheme.primary,
circularTrackColor: scheme.onSurfaceVariant.withValues(alpha: 0.2),
),
listTileTheme: ListTileThemeData(
titleTextStyle: textTheme.titleSmall?.copyWith(color: scheme.onSurface),
subtitleTextStyle: textTheme.bodyMedium?.copyWith(
color: scheme.secondary,
),
iconColor: scheme.secondary,
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
minVerticalPadding: Grid.twelve,
horizontalTitleGap: Grid.twelve,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
),
// Chips: desktop uses rounded-sm (6px)
chipTheme: ChipThemeData(
labelStyle: textTheme.bodySmall?.copyWith(color: scheme.secondary),
// M3 resolves the chip container via `color` (WidgetStateProperty);
// `selectedColor` is the legacy M2 path and is ignored here. Selected
// filter chips (Pulse/Search/Activity tabs) use the accent.
color: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return scheme.primary;
return scheme.surfaceContainerHighest;
}),
checkmarkColor: scheme.onPrimary,
shape: RoundedRectangleBorder(
side: BorderSide.none,
borderRadius: BorderRadius.circular(Radii.sm),
),
side: BorderSide.none,
padding: const EdgeInsets.symmetric(horizontal: 8),
labelPadding: EdgeInsets.zero,
),
// Popups/menus share the elevated 20px mobile popover treatment.
popupMenuTheme: PopupMenuThemeData(
color: scheme.surface.withValues(alpha: 0.98),
elevation: 8,
shadowColor: scheme.shadow.withValues(alpha: 0.18),
surfaceTintColor: Colors.transparent,
textStyle: textTheme.labelLarge?.copyWith(color: scheme.onSurface),
labelTextStyle: WidgetStatePropertyAll(
textTheme.labelLarge?.copyWith(color: scheme.onSurface),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.popover),
side: BorderSide(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
),
// Bottom sheet: match dialog radius
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: scheme.surface,
elevation: 0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
),
),
// Tooltips: desktop uses rounded-md, primary bg
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: scheme.primary,
borderRadius: BorderRadius.circular(Radii.md),
),
textStyle: textTheme.bodySmall?.copyWith(
color: scheme.onPrimary,
fontSize: 12,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
),
dividerTheme: DividerThemeData(
color: scheme.outline,
thickness: 1,
space: 1,
),
// Snackbar
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
),
);
}
}
+103
View File
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'accent_colors.dart';
import 'app_colors.dart';
/// Name of the first-party Buzz theme. Buzz reuses the GitHub Light palette for
/// every base color; the one thing that sets it apart is a branded gradient
/// painted across the app's top section. Mirrors desktop, where the same
/// gradient fills the sidebar canvas — see `data-buzz-sidebar` in
/// `desktop/src/shared/styles/globals/theme.css`.
const buzzThemeName = 'buzz';
/// Name of the dark counterpart, which reuses the GitHub Dark palette and the
/// dark-tuned gradient stops. Paired with [buzzThemeName] in `themePairs`, so
/// the two behave as a single "Buzz" choice under System mode.
const buzzDarkThemeName = 'buzz-dark';
/// Whether [themeName] is either half of the Buzz pair. Both halves enable the
/// gradient so System mode keeps it on across an OS light/dark switch.
bool isBuzzTheme(String themeName) =>
themeName == buzzThemeName || themeName == buzzDarkThemeName;
/// Whether the current widget tree is using the first-party Buzz treatment.
bool isBuzzThemeContext(BuildContext context) =>
Theme.of(context).extension<AppColors>()?.topSectionGradient != null;
/// Primary foreground for the mobile top navigation.
///
/// Every theme uses its own [ColorScheme.onSurface]. Buzz is the exception:
/// its desktop-matching top gradient needs a neutral black or white foreground
/// rather than the accent-derived color scheme foreground.
Color navigationPrimaryForeground(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (!isBuzzThemeContext(context)) return scheme.onSurface;
return scheme.brightness == Brightness.dark ? Colors.white : Colors.black;
}
/// Secondary label and placeholder foreground for the mobile top navigation.
Color navigationSecondaryForeground(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (!isBuzzThemeContext(context)) return scheme.onSurfaceVariant;
return navigationPrimaryForeground(context).withValues(alpha: 0.4);
}
/// Channel-section label and icon foreground for the mobile side navigation.
///
/// Section labels need more hierarchy than a placeholder. Buzz therefore uses
/// a stronger neutral over its gradient, while all other themes preserve their
/// established secondary foreground token.
Color navigationSectionForeground(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (!isBuzzThemeContext(context)) return scheme.onSurfaceVariant;
return navigationPrimaryForeground(context).withValues(alpha: 0.8);
}
/// Search-field surface for the mobile top navigation.
Color navigationSearchSurface(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (!isBuzzThemeContext(context)) return scheme.surfaceContainerHighest;
return navigationPrimaryForeground(context).withValues(alpha: 0.04);
}
/// A low-contrast navigation divider derived from the active theme foreground.
Color navigationDivider(BuildContext context, double opacity) =>
navigationPrimaryForeground(context).withValues(alpha: opacity);
/// Buzz renders with its fixed neutral foreground while preserving the stored
/// wire accent so the user's choice returns on another theme.
int effectiveAccentIndex(String themeName, String storedAccent) {
if (isBuzzTheme(themeName)) return neutralAccentIndex;
return accentIndexForWireValue(storedAccent) ?? defaultAccentIndex;
}
/// Gradient stops, matching desktop's `--buzz-gradient-*` custom properties.
const _lightTop = Color(0xFFE6E6B6);
const _lightBottom = Color(0xFFC4D0DA);
const _darkTop = Color(0xFF4A4616);
const _darkBottom = Color(0xFF0A1423);
/// The Buzz gradient for the app's top section, or null when [themeName] is not
/// a Buzz theme — in which case the section keeps its default frosted fill.
///
/// The stops are fully opaque: under Buzz the color replaces the frosted
/// treatment rather than tinting it, matching desktop's solid sidebar canvas.
///
/// [brightness] comes from the applied color scheme rather than the theme name,
/// so System mode picks the right stops as the OS switches.
LinearGradient? buzzTopSectionGradient(
String themeName,
Brightness brightness,
) {
if (!isBuzzTheme(themeName)) return null;
final isDark = brightness == Brightness.dark;
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
isDark ? _darkTop : _lightTop,
isDark ? _darkBottom : _lightBottom,
],
);
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'accent_colors.dart';
// Catppuccin Latte (mauve accent) — matches Buzz desktop light theme
const lightColorScheme = ColorScheme(
brightness: Brightness.light,
primary: Color(0xFF8839EF), // Latte Mauve
onPrimary: Color(0xFFEFF1F5), // Latte Base
primaryContainer: Color(0xFFE6E9EF), // Latte Mantle
onPrimaryContainer: Color(0xFF4C4F69), // Latte Text
secondary: Color(0xFF6C6F85), // Latte Subtext0
onSecondary: Color(0xFFFFFFFF),
secondaryContainer: Color(0xFFCCD0DA), // Latte Surface1
onSecondaryContainer: Color(0xFF4C4F69), // Latte Text
tertiary: Color(0xFF1E66F5), // Latte Blue
onTertiary: Color(0xFFFFFFFF),
tertiaryContainer: Color(0xFFBCC0CC), // Latte Surface2
onTertiaryContainer: Color(0xFF1E66F5), // Latte Blue
error: Color(0xFFD20F39), // Latte Red
onError: Color(0xFFFFFFFF),
errorContainer: Color(0xFFFDD8E0),
onErrorContainer: Color(0xFFD20F39),
surface: Color(0xFFFFFFFF),
onSurface: Color(0xFF4C4F69), // Latte Text
onSurfaceVariant: Color(0xFF6C6F85), // Latte Subtext0
outline: Color(0xFFBCC0CC), // Latte Surface2
outlineVariant: Color(0xFFCCD0DA), // Latte Surface1
inverseSurface: Color(0xFF4C4F69), // Latte Text
onInverseSurface: Color(0xFFEFF1F5), // Latte Base
inversePrimary: Color(0xFFA875F5), // Macchiato Mauve (saturated)
shadow: Color(0xFF000000),
scrim: Color(0xFF000000),
surfaceTint: Color(0xFF8839EF), // Latte Mauve
surfaceContainerHighest: Color(0xFFFFFFFF),
);
// Catppuccin Macchiato (mauve accent) — matches Buzz desktop dark theme
const darkColorScheme = ColorScheme(
brightness: Brightness.dark,
primary: Color(0xFFA875F5), // Macchiato Mauve (saturated)
onPrimary: Color(0xFF24273A), // Macchiato Base
primaryContainer: Color(0xFF363A4F), // Macchiato Surface0
onPrimaryContainer: Color(0xFFCAD3F5), // Macchiato Text
secondary: Color(0xFFA5ADCB), // Macchiato Subtext0
onSecondary: Color(0xFF24273A), // Macchiato Base
secondaryContainer: Color(0xFF494D64), // Macchiato Surface1
onSecondaryContainer: Color(0xFFCAD3F5), // Macchiato Text
tertiary: Color(0xFF8AADF4), // Macchiato Blue
onTertiary: Color(0xFF24273A), // Macchiato Base
tertiaryContainer: Color(0xFF363A4F), // Macchiato Surface0
onTertiaryContainer: Color(0xFF8AADF4), // Macchiato Blue
error: Color(0xFFED8796), // Macchiato Red
onError: Color(0xFF24273A), // Macchiato Base
errorContainer: Color(0xFF3D2030),
onErrorContainer: Color(0xFFED8796),
surface: Color(0xFF24273A), // Macchiato Base
onSurface: Color(0xFFCAD3F5), // Macchiato Text
onSurfaceVariant: Color(0xFFA5ADCB), // Macchiato Subtext0
outline: Color(0xFF494D64), // Macchiato Surface1
outlineVariant: Color(0xFF363A4F), // Macchiato Surface0
inverseSurface: Color(0xFFCAD3F5), // Macchiato Text
onInverseSurface: Color(0xFF24273A), // Macchiato Base
inversePrimary: Color(0xFF8839EF), // Latte Mauve
shadow: Color(0xFF000000),
scrim: Color(0xFF000000),
surfaceTint: Color(0xFFA875F5), // Macchiato Mauve (saturated)
surfaceContainerHighest: Color(0xFF1E2030), // Macchiato Mantle
);
/// 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) {
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
final contrastWithWhite = 1.05 / (lum + 0.05); // white luminance = 1
return contrastWithBlack >= contrastWithWhite
? const Color(0xFF000000)
: const Color(0xFFFFFFFF);
}
/// Returns a [ColorScheme] with the given accent applied as primary.
ColorScheme applyAccent(ColorScheme base, int accentIndex) {
if (accentIndex < 0 || accentIndex >= accentColors.length) {
return base;
}
final color = accentColorForScheme(base, accentIndex);
final onColor = contrastForeground(color);
return base.copyWith(primary: color, onPrimary: onColor, surfaceTint: color);
}
@@ -0,0 +1,167 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'accent_colors.dart';
import 'theme_catalog.dart';
import 'theme_provider.dart' show effectiveTheme, schemeForAppearanceMode;
const communityThemeDTag = 'community-theme';
const defaultCommunityTheme = CommunityThemePreference(
theme: 'buzz',
accent: '#3b82f6',
followSystem: true,
);
class CommunityThemePreference {
final int version;
final String theme;
final String accent;
final bool followSystem;
const CommunityThemePreference({
this.version = 1,
required this.theme,
required this.accent,
required this.followSystem,
});
factory CommunityThemePreference.fromJson(Map<String, dynamic> json) {
if (json['version'] != 1 ||
json['theme'] is! String ||
findTheme(json['theme'] as String) == null ||
json['accent'] is! String ||
accentIndexForWireValue(json['accent'] as String) == null ||
json['followSystem'] is! bool) {
throw const FormatException('Invalid community theme preference');
}
return CommunityThemePreference(
theme: json['theme'] as String,
accent: json['accent'] as String,
followSystem: json['followSystem'] as bool,
);
}
Map<String, dynamic> toJson() => {
'version': version,
'theme': theme,
'accent': accent,
'followSystem': followSystem,
};
ThemeMode get mode {
if (followSystem) return ThemeMode.system;
return findTheme(theme)?.isDark == true ? ThemeMode.dark : ThemeMode.light;
}
@override
bool operator ==(Object other) =>
other is CommunityThemePreference &&
theme == other.theme &&
accent == other.accent &&
followSystem == other.followSystem;
@override
int get hashCode => Object.hash(theme, accent, followSystem);
}
class CommunityThemeStorage {
static const _prefix = 'buzz-community-theme.v1';
static const _outboxPrefix = 'buzz-community-theme-outbox.v1';
static const _migrationPrefix = 'buzz-community-theme-migrated.v1';
static const _legacyModeKey = 'buzz_theme_mode';
static const _legacyAccentKey = 'buzz_accent_color';
static const _legacySchemeKey = 'buzz_color_scheme';
final SharedPreferences prefs;
const CommunityThemeStorage(this.prefs);
String key(String pubkey, String relayUrl) =>
'$_prefix:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}';
String outboxKey(String pubkey, String relayUrl) =>
'$_outboxPrefix:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}';
CommunityThemePreference? _readKey(String storageKey) {
try {
final raw = prefs.getString(storageKey);
if (raw == null) return null;
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return null;
return CommunityThemePreference.fromJson(decoded);
} catch (_) {
return null;
}
}
CommunityThemePreference? read(String pubkey, String relayUrl) =>
_readKey(key(pubkey, relayUrl));
CommunityThemePreference? readOutbox(String pubkey, String relayUrl) =>
_readKey(outboxKey(pubkey, relayUrl));
Future<bool> write(
String pubkey,
String relayUrl,
CommunityThemePreference preference,
) => prefs.setString(key(pubkey, relayUrl), jsonEncode(preference.toJson()));
Future<bool> writeOutbox(
String pubkey,
String relayUrl,
CommunityThemePreference preference,
) => prefs.setString(
outboxKey(pubkey, relayUrl),
jsonEncode(preference.toJson()),
);
Future<void> clearOutbox(
String pubkey,
String relayUrl,
CommunityThemePreference acknowledged,
) async {
if (readOutbox(pubkey, relayUrl) == acknowledged) {
await prefs.remove(outboxKey(pubkey, relayUrl));
}
}
bool hasMigrated(String pubkey) =>
prefs.getBool('$_migrationPrefix:$pubkey') == true;
Future<bool> markMigrated(String pubkey) =>
prefs.setBool('$_migrationPrefix:$pubkey', true);
Future<void> writeLegacy(CommunityThemePreference preference) async {
await prefs.setString(_legacyModeKey, preference.mode.name);
await prefs.setString(_legacySchemeKey, preference.theme);
await prefs.setInt(
_legacyAccentKey,
accentIndexForWireValue(preference.accent) ?? defaultAccentIndex,
);
}
CommunityThemePreference legacyPreference() {
final modeName = prefs.getString(_legacyModeKey);
final mode =
ThemeMode.values.where((value) => value.name == modeName).firstOrNull ??
ThemeMode.system;
final storedTheme = prefs.getString(_legacySchemeKey);
final theme = findTheme(storedTheme ?? 'buzz')?.name ?? 'buzz';
final legacyAccent = prefs.getInt(_legacyAccentKey);
final resolvedTheme = switch (mode) {
ThemeMode.system => schemeForAppearanceMode(theme, mode) ?? theme,
ThemeMode.light ||
ThemeMode.dark => effectiveTheme(theme, mode)?.name ?? theme,
};
return CommunityThemePreference(
theme: resolvedTheme,
accent: legacyAccentWireValue(legacyAccent),
followSystem: mode == ThemeMode.system,
);
}
}
String normalizeCommunityRelayUrl(String relayUrl) =>
relayUrl.trim().replaceFirst(RegExp(r'/+$'), '').toLowerCase();
@@ -0,0 +1,230 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../crypto/nip44.dart';
import '../relay/relay.dart';
import 'accent_colors.dart';
import 'community_theme_preference.dart';
import 'community_theme_sync.dart';
import 'theme_provider.dart';
class CommunityThemeNotifier extends Notifier<CommunityThemePreference> {
CommunityThemeSyncManager? _manager;
late CommunityThemeStorage _storage;
String? _pubkey;
String? _relayUrl;
Future<void> _persistenceQueue = Future<void>.value();
int _localRevision = 0;
CommunityThemePreference? _scopedLocalPreference;
String? _scopedLocalPubkey;
String? _scopedLocalRelayUrl;
@override
CommunityThemePreference build() {
_manager?.dispose();
_manager = null;
_storage = ref.watch(communityThemeStorageProvider);
final config = ref.watch(relayConfigProvider);
final session = ref.watch(relaySessionProvider);
final pubkey = pubkeyFromNsec(config.nsec);
_pubkey = pubkey;
_relayUrl = config.baseUrl;
if (pubkey == null || config.nsec == null) {
final legacy = _storage.legacyPreference();
unawaited(_storage.writeLegacy(legacy));
return legacy;
}
final cached = _storage.read(pubkey, config.baseUrl);
final dirty = _storage.readOutbox(pubkey, config.baseUrl);
final inMemoryLocal =
_scopedLocalPubkey == pubkey && _scopedLocalRelayUrl == config.baseUrl
? _scopedLocalPreference
: null;
if (inMemoryLocal == null) {
_scopedLocalPreference = null;
_scopedLocalPubkey = pubkey;
_scopedLocalRelayUrl = config.baseUrl;
}
final fallback = _storage.hasMigrated(pubkey)
? defaultCommunityTheme
: _storage.legacyPreference();
final initial = inMemoryLocal ?? dirty ?? cached ?? fallback;
if (session.status == SessionStatus.connected) {
late final CommunityThemeSyncManager manager;
manager = CommunityThemeSyncManager(
pubkey: pubkey,
relaySession: ref.read(relaySessionProvider.notifier),
signedEventRelay: SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: config.nsec!,
),
crypto: _crypto(config.nsec!, pubkey),
onRemote: (remote) => _applyRemote(manager, remote),
onPublished: (preference) {
if (_scopedLocalPubkey == pubkey &&
_scopedLocalRelayUrl == config.baseUrl &&
_scopedLocalPreference == preference) {
_scopedLocalPreference = null;
}
unawaited(_storage.clearOutbox(pubkey, config.baseUrl, preference));
},
);
_manager = manager;
final pending = inMemoryLocal ?? dirty;
if (pending != null) manager.publish(pending);
Future.microtask(() async {
final result = await manager.initialize();
if (_manager != manager) return;
if (result.status == CommunityThemeRemoteStatus.absent) {
final seedRevision = _localRevision;
await _enqueuePersistence(() async {
if (_manager != manager || _localRevision != seedRevision) return;
final currentDirty = _storage.readOutbox(pubkey, config.baseUrl);
final seed =
currentDirty ?? _storage.read(pubkey, config.baseUrl) ?? state;
if (!await _storage.write(pubkey, config.baseUrl, seed)) return;
if (_manager != manager || _localRevision != seedRevision) return;
if (!await _storage.writeOutbox(pubkey, config.baseUrl, seed)) {
return;
}
if (_manager == manager && _localRevision == seedRevision) {
manager.publish(seed);
}
});
}
if (result.status == CommunityThemeRemoteStatus.valid ||
result.status == CommunityThemeRemoteStatus.absent) {
await _storage.markMigrated(pubkey);
}
});
ref.onDispose(manager.dispose);
}
return initial;
}
void setMode(ThemeMode mode) {
var theme = state.theme;
if (mode == ThemeMode.system) {
theme = schemeForAppearanceMode(theme, mode) ?? theme;
} else {
final effective = effectiveTheme(theme, mode);
if (effective != null) theme = effective.name;
}
_save(
CommunityThemePreference(
theme: theme,
accent: state.accent,
followSystem: mode == ThemeMode.system,
),
);
}
void setTheme(String? theme) {
_save(
CommunityThemePreference(
theme: theme ?? defaultSchemeName,
accent: state.accent,
followSystem: state.followSystem,
),
);
}
void setAccent(int index) {
if (index < 0 || index >= accentColors.length) return;
_save(
CommunityThemePreference(
theme: state.theme,
accent: accentColors[index].wireValue,
followSystem: state.followSystem,
),
);
}
void _save(CommunityThemePreference preference) {
if (preference == state) return;
state = preference;
_localRevision++;
final pubkey = _pubkey;
final relayUrl = _relayUrl;
if (pubkey == null || relayUrl == null) {
unawaited(_storage.writeLegacy(preference));
return;
}
_scopedLocalPreference = preference;
_scopedLocalPubkey = pubkey;
_scopedLocalRelayUrl = relayUrl;
_manager?.stage(preference);
unawaited(
_enqueuePersistence(
() => _persistAndPublish(pubkey, relayUrl, preference),
),
);
}
Future<void> _enqueuePersistence(Future<void> Function() operation) {
final result = _persistenceQueue.then((_) => operation());
_persistenceQueue = result.catchError((Object _) {});
return result;
}
Future<void> _persistAndPublish(
String pubkey,
String relayUrl,
CommunityThemePreference preference,
) async {
if (!await _storage.write(pubkey, relayUrl, preference)) return;
if (!await _storage.writeOutbox(pubkey, relayUrl, preference)) return;
if (_pubkey == pubkey && _relayUrl == relayUrl) {
final manager = _manager;
if (manager != null) {
manager.stage(preference);
manager.publishStaged(preference);
}
}
}
void _applyRemote(
CommunityThemeSyncManager manager,
RemoteCommunityTheme remote,
) {
if (_manager != manager) return;
final pubkey = _pubkey;
final relayUrl = _relayUrl;
if (pubkey != null &&
relayUrl != null &&
_storage.readOutbox(pubkey, relayUrl) != null) {
final dirty = _storage.readOutbox(pubkey, relayUrl)!;
manager.publish(dirty);
return;
}
state = remote.preference;
if (pubkey != null && relayUrl != null) {
unawaited(_storage.write(pubkey, relayUrl, remote.preference));
}
}
}
CommunityThemeCrypto _crypto(String nsec, String pubkey) {
final privateHex = nostr.Nip19.decode(payload: nsec).data;
final key = getConversationKey(privateHex, pubkey);
return CommunityThemeCrypto(
encrypt: (plaintext) => nip44Encrypt(key, plaintext),
decrypt: (ciphertext) => nip44Decrypt(key, ciphertext),
);
}
final communityThemeStorageProvider = Provider<CommunityThemeStorage>(
(ref) => CommunityThemeStorage(ref.watch(savedPrefsProvider)),
);
final communityThemeProvider =
NotifierProvider<CommunityThemeNotifier, CommunityThemePreference>(
CommunityThemeNotifier.new,
);
@@ -0,0 +1,366 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import '../relay/relay.dart';
import 'community_theme_preference.dart';
class CommunityThemeCrypto {
final String Function(String) encrypt;
final String Function(String) decrypt;
const CommunityThemeCrypto({required this.encrypt, required this.decrypt});
}
enum CommunityThemeRemoteStatus { valid, absent, invalid, unavailable }
class RemoteCommunityTheme {
final CommunityThemePreference preference;
final int createdAt;
final String eventId;
const RemoteCommunityTheme({
required this.preference,
required this.createdAt,
required this.eventId,
});
}
class CommunityThemeRemoteResult {
final CommunityThemeRemoteStatus status;
final RemoteCommunityTheme? remote;
const CommunityThemeRemoteResult(this.status, [this.remote]);
}
class CommunityThemeSyncManager {
final String pubkey;
final RelaySessionNotifier relaySession;
final SignedEventRelay signedEventRelay;
final CommunityThemeCrypto crypto;
final Duration debounce;
final Duration publishRetryBase;
final Duration publishRetryMax;
final Duration subscriptionRetryBase;
final void Function(RemoteCommunityTheme) onRemote;
final void Function(CommunityThemePreference) onPublished;
Timer? _publishTimer;
Timer? _subscriptionRetryTimer;
void Function()? _unsubscribe;
CommunityThemePreference? _pending;
CommunityThemePreference? _lastPublished;
int _lastCreatedAt = 0;
String _lastEventId = '';
RemoteCommunityTheme? _lastRemote;
int _subscriptionEpoch = 0;
int _subscriptionRetryAttempt = 0;
int _publishRetryAttempt = 0;
bool _publishInFlight = false;
bool _publishRequestedWhileInFlight = false;
bool _disposed = false;
CommunityThemeSyncManager({
required this.pubkey,
required this.relaySession,
required this.signedEventRelay,
required this.crypto,
required this.onRemote,
this.onPublished = _ignorePublished,
this.debounce = const Duration(seconds: 2),
this.publishRetryBase = const Duration(seconds: 1),
this.publishRetryMax = const Duration(seconds: 30),
this.subscriptionRetryBase = const Duration(seconds: 1),
});
CommunityThemePreference? get pending => _pending;
Future<CommunityThemeRemoteResult> fetchRemote() async {
try {
final events = await relaySession.fetchHistory(_themeFilter(limit: 1));
if (events.isEmpty) {
return const CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.absent,
);
}
final event = events.reduce(_newerEvent);
final remote = _decode(event);
return remote == null
? const CommunityThemeRemoteResult(CommunityThemeRemoteStatus.invalid)
: CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.valid,
remote,
);
} catch (_) {
return const CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.unavailable,
);
}
}
Future<CommunityThemeRemoteResult> initialize() async {
final subscribed = await _startLiveSubscription();
if (_disposed) {
return const CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.unavailable,
);
}
final result = await fetchRemote();
if (_disposed) return result;
if (result.status == CommunityThemeRemoteStatus.valid) {
_accept(result.remote!);
}
final remote = _lastRemote;
if (remote != null) {
return CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.valid,
remote,
);
}
if (!subscribed && result.status == CommunityThemeRemoteStatus.absent) {
return const CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.unavailable,
);
}
return result;
}
Future<bool> _startLiveSubscription() async {
if (_disposed) return false;
final epoch = ++_subscriptionEpoch;
try {
final unsubscribe = await relaySession.subscribe(
_themeFilter(limit: 0),
(event) {
if (_disposed || epoch != _subscriptionEpoch) return;
final remote = _decode(event);
if (remote != null) _accept(remote);
},
onClosed: (message) => _handleSubscriptionClosed(epoch, message),
);
if (_disposed || epoch != _subscriptionEpoch) {
unsubscribe();
return false;
}
_unsubscribe = unsubscribe;
_subscriptionRetryAttempt = 0;
return true;
} catch (error) {
if (!_disposed && epoch == _subscriptionEpoch) {
debugPrint('[CommunityThemeSync] live subscription failed: $error');
_scheduleSubscriptionRetry();
}
return false;
}
}
void _handleSubscriptionClosed(int epoch, String message) {
if (_disposed || epoch != _subscriptionEpoch) return;
debugPrint('[CommunityThemeSync] live subscription closed: $message');
_unsubscribe = null;
_scheduleSubscriptionRetry();
}
void _scheduleSubscriptionRetry() {
if (_disposed || _subscriptionRetryTimer != null) return;
final multiplier = 1 << min(_subscriptionRetryAttempt, 5);
_subscriptionRetryAttempt++;
_subscriptionRetryTimer = Timer(subscriptionRetryBase * multiplier, () {
_subscriptionRetryTimer = null;
unawaited(_recoverLiveSubscription());
});
}
Future<void> _recoverLiveSubscription() async {
if (_disposed) return;
if (!await _startLiveSubscription()) return;
// A relay CLOSED removes the retained subscription from RelaySession, so
// reconnect replay cannot recover it. Query the replacement coordinate
// after re-subscribing to close the gap while this stream was silent.
final result = await fetchRemote();
if (_disposed) return;
if (result.status == CommunityThemeRemoteStatus.valid) {
_accept(result.remote!);
}
}
NostrFilter _themeFilter({required int limit}) => NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#d': [communityThemeDTag],
},
limit: limit,
);
void stage(CommunityThemePreference preference) {
if (_disposed) return;
_pending = preference;
_publishRetryAttempt = 0;
_publishTimer?.cancel();
_publishTimer = null;
}
void publish(CommunityThemePreference preference) {
stage(preference);
publishStaged(preference);
}
void publishStaged(CommunityThemePreference preference) {
if (_disposed || _pending != preference) return;
_schedulePublish(debounce);
}
void _schedulePublish(Duration delay) {
if (_disposed) return;
_publishTimer?.cancel();
_publishTimer = Timer(delay, () {
_publishTimer = null;
unawaited(flush());
});
}
void cancelPending() {
_publishTimer?.cancel();
_publishTimer = null;
_pending = null;
}
Future<void> flush() async {
if (_publishInFlight) {
_publishTimer?.cancel();
_publishTimer = null;
_publishRequestedWhileInFlight = true;
return;
}
final preference = _pending;
if (_disposed || preference == null) return;
if (preference == _lastPublished) {
_pending = null;
onPublished(preference);
return;
}
_publishInFlight = true;
try {
final content = crypto.encrypt(jsonEncode(preference.toJson()));
if (_disposed) return;
final createdAt = max(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
_lastCreatedAt + 1,
);
NostrEvent? signed;
await signedEventRelay.submit(
kind: EventKind.readState,
content: content,
tags: const [
['d', communityThemeDTag],
['t', communityThemeDTag],
],
createdAt: createdAt,
onSigned: (event) => signed = event,
);
if (_disposed) return;
final published = signed;
if (published == null) {
throw StateError('Signed event coordinate unavailable');
}
final publishedCoordinateIsStale =
_lastCreatedAt > published.createdAt ||
(_lastCreatedAt == published.createdAt &&
_lastEventId.isNotEmpty &&
_lastEventId.compareTo(published.id) < 0);
if (publishedCoordinateIsStale) {
_lastPublished = null;
_publishRetryAttempt = 0;
if (_pending == preference) _schedulePublish(Duration.zero);
return;
}
_lastCreatedAt = published.createdAt;
_lastEventId = published.id;
_lastPublished = preference;
_publishRetryAttempt = 0;
if (_pending == preference) _pending = null;
onPublished(preference);
} catch (error) {
debugPrint('[CommunityThemeSync] publish failed: $error');
if (_disposed || _pending != preference) return;
final multiplier = 1 << min(_publishRetryAttempt, 30);
_publishRetryAttempt++;
final retryMs = min(
publishRetryBase.inMilliseconds * multiplier,
publishRetryMax.inMilliseconds,
);
_schedulePublish(Duration(milliseconds: retryMs));
} finally {
_publishInFlight = false;
if (!_disposed &&
_pending != null &&
(_publishRequestedWhileInFlight || _pending != preference) &&
_publishTimer == null) {
_publishRequestedWhileInFlight = false;
_schedulePublish(Duration.zero);
} else {
_publishRequestedWhileInFlight = false;
}
}
}
RemoteCommunityTheme? _decode(NostrEvent event) {
if (event.pubkey != pubkey ||
event.getTagValue('d') != communityThemeDTag) {
return null;
}
try {
final decoded = jsonDecode(crypto.decrypt(event.content));
if (decoded is! Map<String, dynamic>) return null;
return RemoteCommunityTheme(
preference: CommunityThemePreference.fromJson(decoded),
createdAt: event.createdAt,
eventId: event.id,
);
} catch (_) {
return null;
}
}
void _accept(RemoteCommunityTheme remote) {
if (remote.createdAt < _lastCreatedAt ||
(remote.createdAt == _lastCreatedAt &&
_lastEventId.isNotEmpty &&
remote.eventId.compareTo(_lastEventId) >= 0)) {
return;
}
_lastCreatedAt = remote.createdAt;
_lastEventId = remote.eventId;
_lastRemote = remote;
if (_pending != null) {
_lastPublished = null;
return;
}
_lastPublished = null;
onRemote(remote);
}
void dispose() {
if (_disposed) return;
_disposed = true;
_subscriptionEpoch++;
_subscriptionRetryTimer?.cancel();
_subscriptionRetryTimer = null;
cancelPending();
_unsubscribe?.call();
_unsubscribe = null;
}
}
void _ignorePublished(CommunityThemePreference _) {}
NostrEvent _newerEvent(NostrEvent left, NostrEvent right) {
if (right.createdAt != left.createdAt) {
return right.createdAt > left.createdAt ? right : left;
}
return right.id.compareTo(left.id) < 0 ? right : left;
}
+37
View File
@@ -0,0 +1,37 @@
class Grid {
/// Quarter spacing - 2 pixels
static const double quarter = 2.0;
/// Half spacing - 4 pixels
static const double half = 4.0;
/// Extra extra small spacing - 8 pixels
static const double xxs = 8.0;
/// Twelve spacing - 12 pixels
static const double twelve = 12.0;
/// Extra small spacing - 16 pixels
static const double xs = 16.0;
/// Standard app content gutter - 20 pixels
static const double gutter = 20.0;
/// Small spacing - 24 pixels
static const double sm = 24.0;
/// Medium spacing - 32 pixels
static const double md = 32.0;
/// Large spacing - 40 pixels
static const double lg = 40.0;
/// Extra large spacing - 48 pixels
static const double xl = 48.0;
/// Extra extra large spacing - 64 pixels
static const double xxl = 64.0;
/// Extra extra extra large spacing - 80 pixels
static const double xxxl = 80.0;
}
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'grid.dart';
const _fontFamily = 'Inter';
/// Avatar size for full channel and thread messages.
const messageAvatarSize = 42.0;
/// Avatar size for conversation-oriented Activity rows.
const activityAvatarSize = messageAvatarSize;
/// Avatar size for compact message-result rows.
const compactMessageAvatarSize = messageAvatarSize;
/// Horizontal space between a message avatar and its content.
const messageAvatarContentGap = Grid.twelve;
/// Primary message copy: 15sp regular on a 20sp line height.
const messageBodyTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w400,
height: 20 / 15,
letterSpacing: 0,
);
/// Message author names: 15sp semibold on a 17sp line height.
const messageUsernameTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w600,
height: 17 / 15,
letterSpacing: 0,
);
/// Secondary author metadata: 15sp regular on a tight 17sp line height.
const messageMetadataTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w400,
height: 17 / 15,
letterSpacing: 0,
);
/// Message timestamps share the secondary author metadata style.
const messageTimestampTextStyle = messageMetadataTextStyle;
/// Compact reply previews: 13.1sp regular on a 17sp line height.
const replyPreviewTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 13.1,
fontWeight: FontWeight.w400,
height: 17 / 13.1,
letterSpacing: 0,
);
/// Reaction counts: 13.1sp medium on a 17sp line height.
const reactionCountTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 13.1,
fontWeight: FontWeight.w500,
height: 17 / 13.1,
letterSpacing: 0,
);
/// Channel and thread titles: 20sp bold on a 24sp line height.
const channelTitleTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 20,
fontWeight: FontWeight.w700,
height: 24 / 20,
letterSpacing: 0,
);
/// Primary labels in compact content lists.
const contentListTitleTextStyle = messageUsernameTextStyle;
/// Secondary copy in compact content lists.
const contentListBodyTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 13.1,
fontWeight: FontWeight.w400,
height: 17 / 13.1,
letterSpacing: 0,
);
/// Timestamps in compact content lists.
const contentListTimestampTextStyle = messageMetadataTextStyle;
/// Filter chip labels use a tighter 15sp Inter treatment.
const filterChipTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w400,
height: 1,
letterSpacing: 0,
);
/// Search fields use the primary 15sp body treatment.
const searchInputTextStyle = messageBodyTextStyle;
/// System message actor names: 15sp semibold on a 17sp line height.
const systemMessageHeadingTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w600,
height: 17 / 15,
letterSpacing: 0,
);
/// System message copy: 15sp regular on a 20sp line height.
const systemMessageBodyTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w400,
height: 20 / 15,
letterSpacing: 0,
);
/// Activity sender names share the primary author style.
const activityUsernameTextStyle = messageUsernameTextStyle;
/// Activity timestamps share the secondary author metadata style.
const activityTimestampTextStyle = messageMetadataTextStyle;
/// Activity context labels: 13.1sp medium on a 17sp line height.
const activityContextTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 13.1,
fontWeight: FontWeight.w500,
height: 17 / 13.1,
letterSpacing: 0,
);
/// Activity message previews use the primary message copy style.
const activityPreviewTextStyle = messageBodyTextStyle;
+121
View File
@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
const _fontFamily = 'Inter';
const _chatLineHeight = 22 / 16;
/// Optional 12sp body style for compact secondary metadata.
const bodyExtraSmallTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.25,
letterSpacing: 0,
);
const textTheme = TextTheme(
displayLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 52,
fontWeight: FontWeight.w400,
height: 1.23,
letterSpacing: 0,
),
displayMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 44,
fontWeight: FontWeight.w400,
height: 1.18,
letterSpacing: 0,
),
displaySmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 36,
fontWeight: FontWeight.w400,
height: 1.22,
letterSpacing: 0,
),
headlineLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 32,
fontWeight: FontWeight.w600,
height: 1.25,
letterSpacing: 0,
),
headlineMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 28,
fontWeight: FontWeight.w600,
height: 1.29,
letterSpacing: 0,
),
headlineSmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 24,
fontWeight: FontWeight.w600,
height: 1.33,
letterSpacing: 0,
),
titleLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 24,
fontWeight: FontWeight.w400,
height: 1.25,
letterSpacing: 0,
),
titleMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 20,
fontWeight: FontWeight.w500,
height: 1.3,
letterSpacing: 0,
),
titleSmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w500,
height: _chatLineHeight,
letterSpacing: 0,
),
labelLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.2,
letterSpacing: 0,
),
labelMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.25,
letterSpacing: 0,
),
labelSmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 11,
fontWeight: FontWeight.w500,
height: 1.2,
letterSpacing: 0,
),
bodyLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w400,
height: 20 / 16,
letterSpacing: 0,
),
bodyMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.3,
letterSpacing: 0,
),
bodySmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.25,
letterSpacing: 0,
),
);
+16
View File
@@ -0,0 +1,16 @@
export 'accent_colors.dart';
export 'adaptive_theme.dart';
export 'app_colors.dart';
export 'app_theme.dart';
export 'buzz_theme.dart';
export 'color_scheme.dart';
export 'community_theme_preference.dart';
export 'community_theme_provider.dart';
export 'community_theme_sync.dart';
export 'grid.dart';
export 'message_typography.dart';
export 'theme_catalog.dart';
export 'theme_extensions.dart';
export 'theme_pairs.dart';
export 'theme_provider.dart';
export 'text_theme.dart' show bodyExtraSmallTextStyle;
+548
View File
@@ -0,0 +1,548 @@
// 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>{
'buzz',
'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),
),
// Buzz and Buzz Dark are first-party: they borrow the GitHub Light / GitHub
// Dark palettes wholesale and are distinguished only by the branded gradient
// painted across the app's top section (see buzz_theme.dart).
ThemeColors(
name: 'buzz',
bg: Color(0xFFFFFFFF),
fg: Color(0xFF24292E),
comment: Color(0xFF6A737D),
added: Color(0xFF28A745),
deleted: Color(0xFFD73A49),
),
ThemeColors(
name: 'buzz-dark',
bg: Color(0xFF24292E),
fg: Color(0xFFE1E4E8),
comment: Color(0xFF6A737D),
added: Color(0xFF34D058),
deleted: Color(0xFFEA4A5A),
),
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;
}
@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
extension AppThemeExtension on BuildContext {
ThemeData get theme => Theme.of(this);
ColorScheme get colors => theme.colorScheme;
TextTheme get textTheme => theme.textTheme;
AppColors get appColors {
final ext = theme.extension<AppColors>();
assert(ext != null, 'AppColors not found in ThemeData.extensions');
return ext!;
}
}
+115
View File
@@ -0,0 +1,115 @@
import 'theme_catalog.dart';
/// Light → dark theme counterparts, ported from the desktop app's `THEME_PAIRS`
/// (`desktop/src/shared/theme/theme-loader.ts`) so both clients offer the same
/// System-mode pairings.
///
/// Buzz leads the map the way it leads desktop's, so the first-party pair sorts
/// ahead of the borrowed syntax themes wherever insertion order is preserved.
const themePairs = <String, String>{
'buzz': 'buzz-dark',
'catppuccin-latte': 'catppuccin-mocha',
'everforest-light': 'everforest-dark',
'github-light': 'github-dark',
'github-light-default': 'github-dark-default',
'github-light-high-contrast': 'github-dark-high-contrast',
'gruvbox-light-hard': 'gruvbox-dark-hard',
'gruvbox-light-medium': 'gruvbox-dark-medium',
'gruvbox-light-soft': 'gruvbox-dark-soft',
'kanagawa-lotus': 'kanagawa-wave',
'light-plus': 'dark-plus',
'material-theme-lighter': 'material-theme',
'min-light': 'min-dark',
'one-light': 'one-dark-pro',
'rose-pine-dawn': 'rose-pine',
'slack-ochin': 'slack-dark',
'solarized-light': 'solarized-dark',
'vitesse-light': 'vitesse-dark',
};
final Map<String, String> _darkToLight = {
for (final entry in themePairs.entries) entry.value: entry.key,
};
/// The counterpart of [name] in the opposite brightness, or null when the theme
/// has no pair. Resolves in both directions.
String? themePairFor(String name) => themePairs[name] ?? _darkToLight[name];
/// Whether [name] participates in a light/dark pair, and so can follow the OS.
bool isPairedTheme(String name) => themePairFor(name) != null;
/// [themeCatalog] partitioned the way the appearance picker offers it.
class ThemeGroups {
/// Light members of light/dark pairs — the System-mode options. Each stands
/// in for both halves of its pair.
final List<ThemeColors> paired;
/// Every light theme, paired or not — the Light-mode options.
final List<ThemeColors> light;
/// Every dark theme, paired or not — the Dark-mode options.
final List<ThemeColors> dark;
const ThemeGroups({
required this.paired,
required this.light,
required this.dark,
});
}
ThemeGroups? _groups;
/// [themeCatalog] grouped for the appearance picker, each list sorted by
/// display name. Computed once — the catalog is a compile-time constant.
ThemeGroups themeGroups() {
final cached = _groups;
if (cached != null) return cached;
final paired = <ThemeColors>[];
final light = <ThemeColors>[];
final dark = <ThemeColors>[];
for (final theme in themeCatalog) {
if (theme.isDark) {
dark.add(theme);
continue;
}
light.add(theme);
if (themePairs.containsKey(theme.name)) paired.add(theme);
}
int byDisplayName(ThemeColors a, ThemeColors b) =>
a.displayName.compareTo(b.displayName);
paired.sort(byDisplayName);
light.sort(byDisplayName);
dark.sort(byDisplayName);
return _groups = ThemeGroups(paired: paired, light: light, dark: dark);
}
/// Tokens that only describe a theme's brightness, stripped from paired labels.
const _modeTokens = <String>{
'light',
'latte',
'dawn',
'lotus',
'ochin',
'lighter',
'plus',
};
/// Display label for a pair, derived from its light member: strips the
/// mode-specific tokens so `github-light` reads as "Github" and stands for both
/// halves. Mirrors desktop's `pairedThemeLabel`.
String pairedThemeLabel(String lightName) {
final stripped = lightName
.split('-')
.where((token) => !_modeTokens.contains(token))
.toList();
// Stripping can remove everything (e.g. "light-plus") — fall back to the raw
// name so the row is never blank.
final parts = stripped.isEmpty ? lightName.split('-') : stripped;
return parts
.map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}')
.join(' ');
}
+269
View File
@@ -0,0 +1,269 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'accent_colors.dart';
import 'adaptive_theme.dart';
import 'buzz_theme.dart';
import 'color_scheme.dart';
import 'theme_catalog.dart';
import 'theme_pairs.dart';
const _themeModeKey = 'buzz_theme_mode';
const _accentKey = 'buzz_accent_color';
const _schemeKey = 'buzz_color_scheme';
/// Buzz ships as the default: the first-party pair, so a fresh install gets the
/// branded top-section gradient without picking a theme first.
const defaultSchemeName = buzzThemeName;
const defaultSchemeDisplayName = 'Buzz';
/// Pre-loaded SharedPreferences instance, overridden in main().
final savedPrefsProvider = Provider<SharedPreferences>(
(_) => throw UnimplementedError('Must be overridden'),
);
/// Tracks the appearance mode: follow the OS, or pin light/dark.
class ThemeNotifier extends Notifier<ThemeMode> {
@override
ThemeMode build() {
final prefs = ref.read(savedPrefsProvider);
final stored = prefs.getString(_themeModeKey);
if (stored != null) {
return ThemeMode.values.where((m) => m.name == stored).firstOrNull ??
ThemeMode.system;
}
return ThemeMode.system;
}
void setMode(ThemeMode mode) {
state = mode;
ref.read(savedPrefsProvider).setString(_themeModeKey, mode.name);
}
}
final themeProvider = NotifierProvider<ThemeNotifier, ThemeMode>(
ThemeNotifier.new,
);
/// Tracks the selected accent color index.
class AccentNotifier extends Notifier<int> {
@override
int build() {
final prefs = ref.read(savedPrefsProvider);
final stored = prefs.getInt(_accentKey);
if (stored == legacyDefaultAccentIndex) {
prefs.setInt(_accentKey, defaultAccentIndex);
return defaultAccentIndex;
}
if (stored == null || stored < 0 || stored >= accentColors.length) {
return defaultAccentIndex;
}
return stored;
}
void setAccent(int index) {
state = index;
ref.read(savedPrefsProvider).setInt(_accentKey, index);
}
}
final accentProvider = NotifierProvider<AccentNotifier, int>(
AccentNotifier.new,
);
/// Tracks the selected color scheme name.
/// null means "use default" ([defaultSchemeDisplayName]).
class SchemeNotifier extends Notifier<String?> {
@override
String? build() {
final prefs = ref.read(savedPrefsProvider);
final stored = prefs.getString(_schemeKey);
final compatible = schemeForAppearanceMode(
stored,
ref.watch(themeProvider),
);
if (compatible != stored) {
if (compatible == null) {
prefs.remove(_schemeKey);
} else {
prefs.setString(_schemeKey, compatible);
}
}
return compatible;
}
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,
);
/// Returns a scheme that can honor [mode] without silently pinning brightness.
///
/// System mode only offers paired themes because they have both light and dark
/// variants. Switching to it from an unpaired or unknown selection therefore
/// falls back to the first paired theme, matching the picker ordering.
String? schemeForAppearanceMode(String? schemeName, ThemeMode mode) {
if (mode != ThemeMode.system) return schemeName;
final selected = findTheme(schemeName ?? defaultSchemeName);
if (selected != null && isPairedTheme(selected.name)) return schemeName;
return themeGroups().paired.firstOrNull?.name ?? schemeName;
}
/// Resolves the selected scheme and appearance [mode] into light and dark
/// [ColorScheme]s, mirroring desktop's System / Light / Dark behaviour.
///
/// In [ThemeMode.system] a paired theme is rendered with its pair
/// ([themePairFor]) so Flutter swaps them with the OS. An unpaired theme is
/// pinned to its own brightness. In [ThemeMode.light] and [ThemeMode.dark] the
/// theme is coerced to that brightness and the mode is forced, so the OS setting
/// is ignored.
({
ColorScheme light,
ColorScheme dark,
ThemeColors? lightTheme,
ThemeColors? darkTheme,
ThemeMode? forcedMode,
})
resolveSchemes(String? schemeName, ThemeMode mode) {
final selected =
findTheme(schemeName ?? defaultSchemeName) ??
findTheme(defaultSchemeName);
if (selected == null) {
return (
light: lightColorScheme,
dark: darkColorScheme,
lightTheme: null,
darkTheme: null,
forcedMode: null,
);
}
switch (mode) {
case ThemeMode.system:
final pairName = themePairFor(selected.name);
if (pairName == null) {
final scheme = generateColorScheme(selected);
return (
light: scheme,
dark: scheme,
lightTheme: selected,
darkTheme: selected,
forcedMode: selected.isDark ? ThemeMode.dark : ThemeMode.light,
);
}
final counterpart = findTheme(pairName) ?? selected;
final lightTheme = selected.isDark ? counterpart : selected;
final darkTheme = selected.isDark ? selected : counterpart;
return (
light: generateColorScheme(lightTheme),
dark: generateColorScheme(darkTheme),
lightTheme: lightTheme,
darkTheme: darkTheme,
forcedMode: null,
);
case ThemeMode.light:
final theme = _themeForBrightness(selected, wantDark: false);
final scheme = generateColorScheme(theme);
return (
light: scheme,
dark: scheme,
lightTheme: theme,
darkTheme: theme,
forcedMode: ThemeMode.light,
);
case ThemeMode.dark:
final theme = _themeForBrightness(selected, wantDark: true);
final scheme = generateColorScheme(theme);
return (
light: scheme,
dark: scheme,
lightTheme: theme,
darkTheme: theme,
forcedMode: ThemeMode.dark,
);
}
}
/// The theme whose colors are actually applied for [schemeName] under [mode].
///
/// In [ThemeMode.system] that is the selected theme itself — its pair covers the
/// other brightness. In the pinned modes it is the theme coerced to the pinned
/// brightness, which is what the picker highlights so the checkmark always
/// tracks what is on screen.
ThemeColors? effectiveTheme(String? schemeName, ThemeMode mode) {
final selected =
findTheme(schemeName ?? defaultSchemeName) ??
findTheme(defaultSchemeName);
if (selected == null) return null;
return switch (mode) {
ThemeMode.system => selected,
ThemeMode.light => _themeForBrightness(selected, wantDark: false),
ThemeMode.dark => _themeForBrightness(selected, wantDark: true),
};
}
/// Label for the current theme selection. System mode names the pair as a whole
/// ("Github" rather than "Github Light"), since both halves are in play.
String themeSelectionLabel(String? schemeName, ThemeMode mode) {
final theme = effectiveTheme(schemeName, mode);
if (theme == null) return defaultSchemeDisplayName;
if (mode != ThemeMode.system) return theme.displayName;
final lightMember = themePairs.containsKey(theme.name)
? theme.name
: themePairFor(theme.name);
return lightMember == null
? theme.displayName
: pairedThemeLabel(lightMember);
}
/// Coerces [selected] to the requested brightness: itself when it already
/// matches, otherwise its pair, otherwise the first catalog theme of that
/// brightness. Keeps a stored light theme usable after switching to Dark mode
/// without rewriting the user's saved selection.
ThemeColors _themeForBrightness(
ThemeColors selected, {
required bool wantDark,
}) {
if (selected.isDark == wantDark) return selected;
final pairName = themePairFor(selected.name);
final paired = pairName == null ? null : findTheme(pairName);
if (paired != null && paired.isDark == wantDark) return paired;
// Match desktop's paired-first picker ordering: an unpaired selection falls
// back through the first-party default pair, not whichever borrowed theme
// sorts first in the full brightness group.
final defaultTheme = findTheme(defaultSchemeName);
if (defaultTheme != null) {
if (defaultTheme.isDark == wantDark) return defaultTheme;
final defaultPairName = themePairFor(defaultTheme.name);
final defaultPair = defaultPairName == null
? null
: findTheme(defaultPairName);
if (defaultPair != null && defaultPair.isDark == wantDark) {
return defaultPair;
}
}
final groups = themeGroups();
final fallback = wantDark ? groups.dark : groups.light;
return fallback.isEmpty ? selected : fallback.first;
}