diff --git a/JournalApp/Data/CommonServices.cs b/JournalApp/Data/CommonServices.cs index 7bf80da8..357797b5 100644 --- a/JournalApp/Data/CommonServices.cs +++ b/JournalApp/Data/CommonServices.cs @@ -24,6 +24,7 @@ public static void AddCommonJournalAppServices(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); } diff --git a/JournalApp/Data/PreferenceService.cs b/JournalApp/Data/PreferenceService.cs index e47d4fed..4d2e8273 100644 --- a/JournalApp/Data/PreferenceService.cs +++ b/JournalApp/Data/PreferenceService.cs @@ -9,13 +9,15 @@ public sealed class PreferenceService : IPreferences, IDisposable private readonly ILogger logger; private readonly IPreferences _preferenceStore; private readonly Application _application; + private readonly SystemColorService _systemColorService; private Dictionary _moodColors; private AppTheme? _theme; - public PreferenceService(ILogger logger, IPreferences preferenceStore) + public PreferenceService(ILogger logger, IPreferences preferenceStore, SystemColorService systemColorService) { this.logger = logger; _preferenceStore = preferenceStore; + _systemColorService = systemColorService; // Not available in unit tests. if (Application.Current != null) @@ -119,13 +121,13 @@ private void UpdateStatusBar() #pragma warning disable CS0618 // Type or member is obsolete if (IsDarkMode) { - StatusBar.SetColor(Color.FromHex("#EAB8D6")); - StatusBar.SetStyle(StatusBarStyle.DarkContent); + StatusBar.SetColor(Color.FromHex("#111111")); + StatusBar.SetStyle(StatusBarStyle.LightContent); } else { - StatusBar.SetColor(Color.FromHex("#854C73")); - StatusBar.SetStyle(StatusBarStyle.LightContent); + StatusBar.SetColor(Color.FromHex("#FFFFFF")); + StatusBar.SetStyle(StatusBarStyle.DarkContent); } #pragma warning restore CS0618 // Type or member is obsolete } @@ -135,7 +137,7 @@ private void GenerateMoodColors() { var emojis = DataPoint.Moods.Where(x => x != "🤔").ToList(); #pragma warning disable CS0618 // Type or member is obsolete - var primary = Color.FromHex("#FF9FDF"); + var primary = Color.FromHex(_systemColorService.GetSourceColorHex()); #pragma warning restore CS0618 // Type or member is obsolete var complementary = primary.GetComplementary(); diff --git a/JournalApp/Pages/Calendar/CalendarDay.razor.css b/JournalApp/Pages/Calendar/CalendarDay.razor.css index 364a88ab..2ce262c4 100644 --- a/JournalApp/Pages/Calendar/CalendarDay.razor.css +++ b/JournalApp/Pages/Calendar/CalendarDay.razor.css @@ -5,7 +5,7 @@ } .calendar-day-with-mood { - color: #3A2F36; + color: var(--mud-palette-text-primary); } .calendar-day-number { diff --git a/JournalApp/Pages/MainLayout.razor b/JournalApp/Pages/MainLayout.razor index 48d9b532..0c9e9853 100644 --- a/JournalApp/Pages/MainLayout.razor +++ b/JournalApp/Pages/MainLayout.razor @@ -5,6 +5,8 @@ @inject KeyEventService KeyEventService @inject NavigationManager NavigationManager @inject PreferenceService PreferenceService +@inject SystemColorService SystemColorService +@inject IJSRuntime JSRuntime @@ -25,58 +27,9 @@ @code { bool _hasInitiallyRendered; + IJSObjectReference _themeModule; - MudTheme _theme = new() - { - PaletteLight = new PaletteLight() - { - // https://materialkolor.com Seed FFFE73D8, Spec 2025. - Primary = "#854C73", - TextPrimary = "#3A2F36", - PrimaryContrastText = "#FFF7F8", - Secondary = "#715867", - TextSecondary = "#FFF7F8", - SecondaryContrastText = "#FFF7F8", - Error = "#A8364B", - Background = "#FEF0F6", - Surface = "#FAEAF0", - - HoverOpacity = 0.1, - }, - - PaletteDark = new PaletteDark() - { - // https://materialkolor.com Seed FFFE73D8, Spec 2025. - Primary = "#EAB8D6", - TextPrimary = "#DECCD4", - PrimaryContrastText = "#59344D", - Secondary = "#DDBECF", - TextSecondary = "#503A47", - Error = "#F97386", - Background = "#1F171C", - Surface = "#120D10", - - HoverOpacity = 0.1, - }, - - LayoutProperties = new() - { - DefaultBorderRadius = "4px", - }, - - Typography = new() - { - Button = new ButtonTypography() - { - TextTransform = "none", - }, - - Caption = new CaptionTypography() - { - LineHeight = "1", - }, - }, - }; + MudTheme _theme = CreateBaseTheme(); protected override void OnInitialized() { @@ -107,13 +60,14 @@ if (firstRender) { _hasInitiallyRendered = true; + _ = ApplyDynamicThemeAsync(); } } public void OnThemeChanged(object sender, bool isDarkMode) { if (_hasInitiallyRendered) - StateHasChanged(); + _ = InvokeAsync(ApplyDynamicThemeAsync); } void OnNewIntent(object sender, EventArgs e) @@ -126,5 +80,104 @@ { PreferenceService.ThemeChanged -= OnThemeChanged; App.NewIntent -= OnNewIntent; + if (_themeModule != null) + _ = _themeModule.DisposeAsync(); + } + + static MudTheme CreateBaseTheme() + { + return new MudTheme + { + PaletteLight = new PaletteLight + { + Primary = "#5B5B5B", + PrimaryContrastText = "#FFFFFF", + Secondary = "#6B6B6B", + SecondaryContrastText = "#FFFFFF", + TextPrimary = "#1F1F1F", + TextSecondary = "#3B3B3B", + Error = "#B3261E", + Background = "#FFFFFF", + Surface = "#F5F5F5", + HoverOpacity = 0.1, + }, + PaletteDark = new PaletteDark + { + Primary = "#C7C7C7", + PrimaryContrastText = "#1F1F1F", + Secondary = "#B0B0B0", + SecondaryContrastText = "#1F1F1F", + TextPrimary = "#F1F1F1", + TextSecondary = "#CFCFCF", + Error = "#F2B8B5", + Background = "#0F0F0F", + Surface = "#1A1A1A", + HoverOpacity = 0.1, + }, + LayoutProperties = new LayoutProperties + { + DefaultBorderRadius = "4px", + }, + Typography = new Typography + { + Button = new ButtonTypography + { + TextTransform = "none", + }, + Caption = new CaptionTypography + { + LineHeight = "1", + }, + }, + }; + } + + async Task ApplyDynamicThemeAsync() + { + try + { + _themeModule ??= await JSRuntime.InvokeAsync("import", "./theme/material-theme.js"); + var sourceColor = SystemColorService.GetSourceColorHex(); + + var lightTokens = await _themeModule.InvokeAsync("getThemeTokens", sourceColor, false); + var darkTokens = await _themeModule.InvokeAsync("getThemeTokens", sourceColor, true); + + ApplyTokens(_theme.PaletteLight, lightTokens); + ApplyTokens(_theme.PaletteDark, darkTokens); + + StateHasChanged(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to apply dynamic theme. Using fallback palette."); + } + } + + static void ApplyTokens(PaletteLight palette, MaterialThemeTokens tokens) + { + palette.Primary = tokens.Primary; + palette.PrimaryContrastText = tokens.OnPrimary; + palette.Secondary = tokens.Secondary; + palette.SecondaryContrastText = tokens.OnSecondary; + palette.TextPrimary = tokens.OnBackground; + palette.TextSecondary = tokens.OnSurfaceVariant; + palette.Error = tokens.Error; + palette.Background = tokens.Background; + palette.Surface = tokens.Surface; + palette.HoverOpacity = 0.1; + } + + static void ApplyTokens(PaletteDark palette, MaterialThemeTokens tokens) + { + palette.Primary = tokens.Primary; + palette.PrimaryContrastText = tokens.OnPrimary; + palette.Secondary = tokens.Secondary; + palette.SecondaryContrastText = tokens.OnSecondary; + palette.TextPrimary = tokens.OnBackground; + palette.TextSecondary = tokens.OnSurfaceVariant; + palette.Error = tokens.Error; + palette.Background = tokens.Background; + palette.Surface = tokens.Surface; + palette.HoverOpacity = 0.1; } } diff --git a/JournalApp/Platforms/Android/Resources/values-night/colors.xml b/JournalApp/Platforms/Android/Resources/values-night/colors.xml new file mode 100644 index 00000000..d83b2323 --- /dev/null +++ b/JournalApp/Platforms/Android/Resources/values-night/colors.xml @@ -0,0 +1,5 @@ + + + #111111 + #111111 + diff --git a/JournalApp/Platforms/Android/Resources/values/colors.xml b/JournalApp/Platforms/Android/Resources/values/colors.xml index 5dbd0ffe..f57ead6a 100644 --- a/JournalApp/Platforms/Android/Resources/values/colors.xml +++ b/JournalApp/Platforms/Android/Resources/values/colors.xml @@ -1,5 +1,5 @@ - #854C73 - #854C73 - \ No newline at end of file + #FFFFFF + #FFFFFF + diff --git a/JournalApp/Resources/AppIcon/appicon.svg b/JournalApp/Resources/AppIcon/appicon.svg index a0c0b47c..512513e7 100644 --- a/JournalApp/Resources/AppIcon/appicon.svg +++ b/JournalApp/Resources/AppIcon/appicon.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/JournalApp/Resources/AppIcon/appiconfg.svg b/JournalApp/Resources/AppIcon/appiconfg.svg index ad549e01..75956ca3 100644 --- a/JournalApp/Resources/AppIcon/appiconfg.svg +++ b/JournalApp/Resources/AppIcon/appiconfg.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/JournalApp/Resources/AppIcon/appiconfg_debug.svg b/JournalApp/Resources/AppIcon/appiconfg_debug.svg index 78411db6..a251908f 100644 --- a/JournalApp/Resources/AppIcon/appiconfg_debug.svg +++ b/JournalApp/Resources/AppIcon/appiconfg_debug.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/JournalApp/Resources/Graphics/business_card.svg b/JournalApp/Resources/Graphics/business_card.svg index 238325a8..b7dc8bcd 100644 --- a/JournalApp/Resources/Graphics/business_card.svg +++ b/JournalApp/Resources/Graphics/business_card.svg @@ -17,19 +17,19 @@ xmlns:svg="http://www.w3.org/2000/svg">Diary & Mood TrackerDiary & Mood Tracker @@ -65,7 +65,7 @@ inkscape:window-maximized="1" inkscape:current-layer="svg1" /> @@ -73,7 +73,7 @@ id="g1"> Mood Tracker _logger; + private string _cachedSourceHex; + + public SystemColorService(ILogger logger) + { + _logger = logger; + } + + public string GetSourceColorHex() + { + if (!string.IsNullOrWhiteSpace(_cachedSourceHex)) + return _cachedSourceHex; + + var sourceColor = TryGetSystemAccentColor(); + _cachedSourceHex = sourceColor?.ToHex() ?? DefaultSourceColorHex; + + _logger.LogInformation("System source color: {SourceColor}", _cachedSourceHex); + return _cachedSourceHex; + } + + private static Color? TryGetSystemAccentColor() + { +#if ANDROID + if (OperatingSystem.IsAndroidVersionAtLeast(31)) + { + try + { + var context = Android.App.Application.Context; + var colorInt = context.GetColor(Android.Resource.Color.SystemAccent1_500); + return Color.FromArgb(unchecked((uint)colorInt)); + } + catch + { + // Ignore and fallback to the default. + } + } +#endif + return null; + } +} diff --git a/JournalApp/Theme/MaterialThemeTokens.cs b/JournalApp/Theme/MaterialThemeTokens.cs new file mode 100644 index 00000000..b50a6b16 --- /dev/null +++ b/JournalApp/Theme/MaterialThemeTokens.cs @@ -0,0 +1,23 @@ +namespace JournalApp; + +public sealed class MaterialThemeTokens +{ + public string Primary { get; set; } + public string OnPrimary { get; set; } + public string PrimaryContainer { get; set; } + public string OnPrimaryContainer { get; set; } + public string Secondary { get; set; } + public string OnSecondary { get; set; } + public string Tertiary { get; set; } + public string OnTertiary { get; set; } + public string Error { get; set; } + public string OnError { get; set; } + public string Background { get; set; } + public string OnBackground { get; set; } + public string Surface { get; set; } + public string OnSurface { get; set; } + public string SurfaceVariant { get; set; } + public string OnSurfaceVariant { get; set; } + public string Outline { get; set; } + public string Shadow { get; set; } +} diff --git a/JournalApp/Theme/material-theme.ts b/JournalApp/Theme/material-theme.ts new file mode 100644 index 00000000..e8b4ddcf --- /dev/null +++ b/JournalApp/Theme/material-theme.ts @@ -0,0 +1,82 @@ +import * as mcu from "@material/material-color-utilities"; + +type ThemeTokens = { + primary: string; + onPrimary: string; + primaryContainer: string; + onPrimaryContainer: string; + secondary: string; + onSecondary: string; + tertiary: string; + onTertiary: string; + error: string; + onError: string; + background: string; + onBackground: string; + surface: string; + onSurface: string; + surfaceVariant: string; + onSurfaceVariant: string; + outline: string; + shadow: string; +}; + +const mcuAny = mcu as unknown as Record; + +const normalizeHex = (value: string): string => { + if (!value) return "#5B5B5B"; + return value.startsWith("#") ? value : `#${value}`; +}; + +const getVariant = (): unknown => { + const variant = mcuAny.Variant; + if (!variant) return null; + return variant.EXPRESSIVE ?? variant.Expressive ?? variant.expressive ?? null; +}; + +const createScheme = (sourceHex: string, isDark: boolean) => { + const argbFromHex = mcuAny.argbFromHex; + if (!argbFromHex) throw new Error("Material Color Utilities missing argbFromHex"); + const sourceArgb = argbFromHex(normalizeHex(sourceHex)); + + if (mcuAny.SchemeExpressive) { + return new mcuAny.SchemeExpressive(sourceArgb, isDark, 0.0); + } + + const variant = getVariant(); + if (mcuAny.themeFromSourceColor) { + const theme = mcuAny.themeFromSourceColor(sourceArgb, variant ? { variant } : undefined); + return isDark ? theme.schemes.dark : theme.schemes.light; + } + + throw new Error("Material Color Utilities not available"); +}; + +const toHex = (argb: number): string => { + return mcuAny.hexFromArgb(argb); +}; + +export const getThemeTokens = (sourceHex: string, isDark: boolean): ThemeTokens => { + const scheme = createScheme(sourceHex, isDark); + + return { + primary: toHex(scheme.primary), + onPrimary: toHex(scheme.onPrimary), + primaryContainer: toHex(scheme.primaryContainer), + onPrimaryContainer: toHex(scheme.onPrimaryContainer), + secondary: toHex(scheme.secondary), + onSecondary: toHex(scheme.onSecondary), + tertiary: toHex(scheme.tertiary), + onTertiary: toHex(scheme.onTertiary), + error: toHex(scheme.error), + onError: toHex(scheme.onError), + background: toHex(scheme.background), + onBackground: toHex(scheme.onBackground), + surface: toHex(scheme.surface), + onSurface: toHex(scheme.onSurface), + surfaceVariant: toHex(scheme.surfaceVariant), + onSurfaceVariant: toHex(scheme.onSurfaceVariant), + outline: toHex(scheme.outline), + shadow: toHex(scheme.shadow) + }; +}; diff --git a/JournalApp/package.json b/JournalApp/package.json new file mode 100644 index 00000000..aa192d21 --- /dev/null +++ b/JournalApp/package.json @@ -0,0 +1,15 @@ +{ + "name": "journalapp-theme", + "private": true, + "type": "module", + "devDependencies": { + "@material/material-color-utilities": "^0.3.0", + "esbuild": "^0.21.5", + "typescript": "^5.5.4" + }, + "scripts": { + "build:theme": "esbuild theme/material-theme.ts --bundle --format=esm --outfile=wwwroot/theme/material-theme.js --target=es2020", + "watch:theme": "esbuild theme/material-theme.ts --bundle --format=esm --outfile=wwwroot/theme/material-theme.js --target=es2020 --watch", + "typecheck:theme": "tsc --noEmit --pretty" + } +} diff --git a/JournalApp/tsconfig.json b/JournalApp/tsconfig.json new file mode 100644 index 00000000..6a2d00bd --- /dev/null +++ b/JournalApp/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "Bundler", + "lib": ["ES2020", "DOM"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["theme/**/*.ts"] +} diff --git a/JournalApp/wwwroot/app.css b/JournalApp/wwwroot/app.css index 6bccc0b9..2933ad8b 100644 --- a/JournalApp/wwwroot/app.css +++ b/JournalApp/wwwroot/app.css @@ -15,8 +15,8 @@ body { display: flex; align-items: center; justify-content: center; - background-color: #FEF0F6; - color: #3A2F36; + background-color: #FFFFFF; + color: #1F1F1F; position: fixed; width: 100%; height: 100% @@ -25,8 +25,8 @@ body { @media (prefers-color-scheme: dark) { #splash-screen { /*The splash screen should use the system theme so it blends in, even if the app itself uses a different theme.*/ - background-color: #1F171C; - color: #DECCD4; + background-color: #111111; + color: #F1F1F1; } } @@ -58,9 +58,9 @@ body { gap: 8px; padding: 0; box-shadow: none; - background-color: var(--mud-palette-primary); - color: var(--mud-palette-primary-text); - --mud-palette-action-default: var(--mud-palette-primary-text); + background-color: var(--mud-palette-surface); + color: var(--mud-palette-text-primary); + --mud-palette-action-default: var(--mud-palette-text-primary); } .page-header { @@ -72,7 +72,7 @@ body { top: 0 !important; position: sticky !important; background-color: var(--mud-palette-surface); - color: var(--mud-palette-primary-text); + color: var(--mud-palette-text-primary); } .page-body { @@ -171,8 +171,8 @@ body { } .mud-snackbar { - background-color: var(--mud-palette-primary); - color: var(--mud-palette-primary-text); + background-color: var(--mud-palette-surface); + color: var(--mud-palette-text-primary); } .emoji-button { @@ -228,12 +228,12 @@ body { @media (prefers-color-scheme: dark) { .status-bar-safe-area { - background-color: #120D10; + background-color: #111111; } } @media (prefers-color-scheme: light) { .status-bar-safe-area { - background-color: #FFF8F9; + background-color: #FFFFFF; } } diff --git a/JournalApp/wwwroot/theme/material-theme.js b/JournalApp/wwwroot/theme/material-theme.js new file mode 100644 index 00000000..0cd2e2ab --- /dev/null +++ b/JournalApp/wwwroot/theme/material-theme.js @@ -0,0 +1,61 @@ +import * as mcu from "@material/material-color-utilities"; + +const mcuAny = mcu; + +const normalizeHex = (value) => { + if (!value) return "#5B5B5B"; + return value.startsWith("#") ? value : `#${value}`; +}; + +const getVariant = () => { + const variant = mcuAny.Variant; + if (!variant) return null; + return variant.EXPRESSIVE ?? variant.Expressive ?? variant.expressive ?? null; +}; + +const createScheme = (sourceHex, isDark) => { + const argbFromHex = mcuAny.argbFromHex; + if (!argbFromHex) throw new Error("Material Color Utilities missing argbFromHex"); + const sourceArgb = argbFromHex(normalizeHex(sourceHex)); + + if (mcuAny.SchemeExpressive) { + return new mcuAny.SchemeExpressive(sourceArgb, isDark, 0.0); + } + + const variant = getVariant(); + if (mcuAny.themeFromSourceColor) { + const theme = mcuAny.themeFromSourceColor(sourceArgb, variant ? { variant } : undefined); + return isDark ? theme.schemes.dark : theme.schemes.light; + } + + throw new Error("Material Color Utilities not available"); +}; + +const toHex = (argb) => { + return mcuAny.hexFromArgb(argb); +}; + +export const getThemeTokens = (sourceHex, isDark) => { + const scheme = createScheme(sourceHex, isDark); + + return { + primary: toHex(scheme.primary), + onPrimary: toHex(scheme.onPrimary), + primaryContainer: toHex(scheme.primaryContainer), + onPrimaryContainer: toHex(scheme.onPrimaryContainer), + secondary: toHex(scheme.secondary), + onSecondary: toHex(scheme.onSecondary), + tertiary: toHex(scheme.tertiary), + onTertiary: toHex(scheme.onTertiary), + error: toHex(scheme.error), + onError: toHex(scheme.onError), + background: toHex(scheme.background), + onBackground: toHex(scheme.onBackground), + surface: toHex(scheme.surface), + onSurface: toHex(scheme.onSurface), + surfaceVariant: toHex(scheme.surfaceVariant), + onSurfaceVariant: toHex(scheme.onSurfaceVariant), + outline: toHex(scheme.outline), + shadow: toHex(scheme.shadow) + }; +};