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
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:
@@ -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';
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user