diff --git a/JournalApp.Tests/Data/PreferenceServiceTests.cs b/JournalApp.Tests/Data/PreferenceServiceTests.cs index dbae187..269d40a 100644 --- a/JournalApp.Tests/Data/PreferenceServiceTests.cs +++ b/JournalApp.Tests/Data/PreferenceServiceTests.cs @@ -43,6 +43,37 @@ public void SelectedAppTheme_WithInvalidStoredValue_FallsBackToUnspecified() result.Should().Be(AppTheme.Unspecified); } + [Fact] + public void HandleOsThemeChanged_KeepsTheUsersChoice() + { + // Arrange + var preferences = Services.GetService(); + var preferenceService = Services.GetService(); + preferenceService.SelectedAppTheme = AppTheme.Light; + + // Act + preferenceService.HandleOsThemeChanged(); + + // Assert + preferenceService.SelectedAppTheme.Should().Be(AppTheme.Light); + preferences.Get("theme", string.Empty).Should().Be(nameof(AppTheme.Light)); + } + + [Fact] + public void HandleOsThemeChanged_RaisesThemeChanged() + { + // Arrange + var preferenceService = Services.GetService(); + var raised = 0; + preferenceService.ThemeChanged += (_, _) => raised++; + + // Act + preferenceService.HandleOsThemeChanged(); + + // Assert + raised.Should().Be(1); + } + [Fact] public void SafetyPlan_WithMalformedJson_ReturnsNull() { diff --git a/JournalApp.Tests/MaterialThemeTests.cs b/JournalApp.Tests/MaterialThemeTests.cs index 06573e0..963fdf0 100644 --- a/JournalApp.Tests/MaterialThemeTests.cs +++ b/JournalApp.Tests/MaterialThemeTests.cs @@ -16,6 +16,8 @@ public void DefaultSeedReproducesOrchidPalette() Hex(theme.PaletteLight.PrimaryLighten).Should().Be("#FFD8EE"); Hex(theme.PaletteLight.PrimaryDarken).Should().Be("#69345A"); Hex(theme.PaletteLight.Secondary).Should().Be("#705766"); + Hex(theme.PaletteLight.SecondaryLighten).Should().Be("#FADAEB"); + Hex(theme.PaletteLight.SecondaryDarken).Should().Be("#57404E"); Hex(theme.PaletteLight.Tertiary).Should().Be("#81533F"); Hex(theme.PaletteLight.Error).Should().Be("#BA1A1A"); Hex(theme.PaletteLight.Info).Should().Be("#1A59C2"); @@ -38,6 +40,8 @@ public void DefaultSeedReproducesOrchidPalette() Hex(theme.PaletteDark.PrimaryLighten).Should().Be("#69345A"); Hex(theme.PaletteDark.PrimaryDarken).Should().Be("#FFD8EE"); Hex(theme.PaletteDark.Secondary).Should().Be("#DDBECF"); + Hex(theme.PaletteDark.SecondaryLighten).Should().Be("#57404E"); + Hex(theme.PaletteDark.SecondaryDarken).Should().Be("#FADAEB"); Hex(theme.PaletteDark.Tertiary).Should().Be("#F4B9A0"); Hex(theme.PaletteDark.Error).Should().Be("#FFB4AB"); Hex(theme.PaletteDark.Info).Should().Be("#B0C6FF"); @@ -64,4 +68,49 @@ public void OtherSeedsProduceDistinctPalettes() Hex(blue.PaletteLight.Primary).Should().NotBe("#844C72"); Hex(blue.PaletteLight.Background).Should().NotBe(Hex(blue.PaletteLight.Surface), "the surface container ladder should keep distinct tones"); } + + [Fact] + public void ToggleSegmentPairsStayLegibleForAnySeed() + { + // Light mode surfaces are only ~1.05:1 apart, so the toggle group leans on chroma and on a filled primary selection instead of tone. + // These are the pairs app.css actually draws, and every one of them has to clear the M3 4.5:1 text floor whatever seed the device supplies. + foreach (var seed in new uint[] { MaterialTheme.DefaultSeed, 0xFF4285F4, 0xFF4CAF50, 0xFFFF9800, 0xFF000000, 0xFFFFFFFF }) + { + var theme = MaterialTheme.FromSeed(seed); + + foreach (var palette in new Palette[] { theme.PaletteLight, theme.PaletteDark }) + { + Contrast(palette.SecondaryDarken, palette.SecondaryLighten).Should() + .BeGreaterThan(4.5, $"an unselected segment label must read on its tonal fill (seed {seed:X8})"); + + Contrast(palette.PrimaryContrastText, palette.Primary).Should() + .BeGreaterThan(4.5, $"a selected segment label must read on the filled primary pill (seed {seed:X8})"); + + Contrast(palette.Primary, palette.SecondaryLighten).Should() + .BeGreaterThan(3, $"the selected segment must separate from its unselected neighbours (seed {seed:X8})"); + + Contrast(palette.Primary, palette.Surface).Should() + .BeGreaterThan(3, $"the selected segment must separate from the row it sits on (seed {seed:X8})"); + } + } + } + + private static double Contrast(MudColor a, MudColor b) + { + var la = RelativeLuminance(a); + var lb = RelativeLuminance(b); + + return la > lb ? (la + 0.05) / (lb + 0.05) : (lb + 0.05) / (la + 0.05); + } + + private static double RelativeLuminance(MudColor color) + { + static double Channel(byte value) + { + var srgb = value / 255.0; + return srgb <= 0.03928 ? srgb / 12.92 : Math.Pow((srgb + 0.055) / 1.055, 2.4); + } + + return (0.2126 * Channel(color.R)) + (0.7152 * Channel(color.G)) + (0.0722 * Channel(color.B)); + } } diff --git a/JournalApp/Components/DataPointView.razor b/JournalApp/Components/DataPointView.razor index 0768125..19d0c96 100644 --- a/JournalApp/Components/DataPointView.razor +++ b/JournalApp/Components/DataPointView.razor @@ -21,7 +21,8 @@ else if (Point.Type == PointType.Sleep) } else if (Point.Type == PointType.Scale) { - + @* A filled and an outlined dot, because MudRating paints both icons in the same color and two filled circles would look identical. *@ + } else if (Point.Type == PointType.LowToHigh) { diff --git a/JournalApp/Components/JaMessageBox.razor b/JournalApp/Components/JaMessageBox.razor index 8717432..ac3d305 100644 --- a/JournalApp/Components/JaMessageBox.razor +++ b/JournalApp/Components/JaMessageBox.razor @@ -3,13 +3,13 @@ - @if (TitleContent is null) + @if (TitleContent is not null) { - @Title + @TitleContent } - else + else if (!string.IsNullOrWhiteSpace(Title)) { - @TitleContent + @Title } diff --git a/JournalApp/Data/MaterialTheme.cs b/JournalApp/Data/MaterialTheme.cs index 5ce4140..cb79ffc 100644 --- a/JournalApp/Data/MaterialTheme.cs +++ b/JournalApp/Data/MaterialTheme.cs @@ -59,15 +59,15 @@ public static MudTheme FromSeed(uint seed) Info = Hex(info[40]), InfoContrastText = "#FFFFFF", InfoLighten = Hex(info[90]), - InfoDarken = Hex(info[10]), + InfoDarken = Hex(info[30]), Success = Hex(success[40]), SuccessContrastText = "#FFFFFF", SuccessLighten = Hex(success[90]), - SuccessDarken = Hex(success[10]), + SuccessDarken = Hex(success[30]), Warning = Hex(warning[40]), WarningContrastText = "#FFFFFF", WarningLighten = Hex(warning[90]), - WarningDarken = Hex(warning[10]), + WarningDarken = Hex(warning[30]), Background = Hex(neutral[98]), BackgroundGray = Hex(neutral[96]), Surface = Hex(neutral[94]), @@ -85,6 +85,9 @@ public static MudTheme FromSeed(uint seed) Dark = Hex(neutral[20]), DarkContrastText = Hex(neutral[95]), + // M3 scrim is the neutral black at 32%; MudBlazor's default is a grey that lightens the page in dark mode. + OverlayDark = "rgba(0,0,0,0.32)", + HoverOpacity = 0.08, }, @@ -135,16 +138,26 @@ public static MudTheme FromSeed(uint seed) Dark = Hex(neutral[90]), DarkContrastText = Hex(neutral[20]), + // M3 scrim is the neutral black at 32%; MudBlazor's default is a grey that lightens the page in dark mode. + OverlayDark = "rgba(0,0,0,0.32)", + HoverOpacity = 0.08, }, LayoutProperties = new() { - DefaultBorderRadius = "8px", + // The M3 medium corner, so anything not styled by hand still lands on a real shape token. + DefaultBorderRadius = "12px", }, Typography = new() { + Default = new DefaultTypography() + { + // The device's own UI font is what makes a WebView app read as native; Roboto is the Android fallback. + FontFamily = ["system-ui", "Roboto", "Helvetica", "Arial", "sans-serif"], + }, + Button = new ButtonTypography() { TextTransform = "none", diff --git a/JournalApp/Data/PreferenceService.cs b/JournalApp/Data/PreferenceService.cs index f9bf1be..d23fea3 100644 --- a/JournalApp/Data/PreferenceService.cs +++ b/JournalApp/Data/PreferenceService.cs @@ -52,7 +52,7 @@ public PreferenceService(ILogger logger, IPreferences prefere _application.RequestedThemeChanged += Application_RequestedThemeChanged; } - UpdateStatusBar(); + ApplyPlatformTheme(); } public AppTheme SelectedAppTheme @@ -181,26 +181,44 @@ public DateTimeOffset LastExportDate public event EventHandler ThemeChanged; - private void Application_RequestedThemeChanged(object sender, AppThemeChangedEventArgs e) - { - _theme = e.RequestedTheme; - OnThemeChanged(); - } + private void Application_RequestedThemeChanged(object sender, AppThemeChangedEventArgs e) => HandleOsThemeChanged(); + + /// + /// Repaints for an OS theme change without touching the theme the user picked, which stays whatever they chose including System. + /// + internal void HandleOsThemeChanged() => OnThemeChanged(); private void OnThemeChanged() { - UpdateStatusBar(); + ApplyPlatformTheme(); ThemeChanged?.Invoke(this, IsDarkMode); } - private void UpdateStatusBar() + /// + /// Pushes the active theme out to the native chrome that the web layer can't reach. + /// + public void ApplyPlatformTheme() { + if (_application == null) + return; + + logger.LogDebug("Applying platform theme"); + + // The window's appearance drives the native resources the WebView sits inside, so it follows the in-app choice rather than only the OS. + if (_application.UserAppTheme != SelectedAppTheme) + _application.UserAppTheme = SelectedAppTheme; + + var surface = IsDarkMode ? GetTheme().PaletteDark.Background : GetTheme().PaletteLight.Background; + var background = Color.FromRgb(surface.R, surface.G, surface.B); + + // On Android 15 and up the system bars are transparent, so what shows behind them is this page background rather than any status bar color we set. + if (_application.Windows.Count > 0 && _application.Windows[0].Page is ContentPage page) + page.BackgroundColor = background; + if (OperatingSystem.IsAndroid()) { - logger.LogDebug("Updating status bar"); - // Match the M3 surface tone so the status bar blends into the page header. - var surface = IsDarkMode ? GetTheme().PaletteDark.Background : GetTheme().PaletteLight.Background; - StatusBar.SetColor(Color.FromRgb(surface.R, surface.G, surface.B)); + // Still needed below Android 15, where the status bar has its own color instead of showing the page through. + StatusBar.SetColor(background); StatusBar.SetStyle(IsDarkMode ? StatusBarStyle.LightContent : StatusBarStyle.DarkContent); } } diff --git a/JournalApp/MainPage.xaml b/JournalApp/MainPage.xaml index 24477a3..2562bb7 100644 --- a/JournalApp/MainPage.xaml +++ b/JournalApp/MainPage.xaml @@ -5,6 +5,7 @@ x:Class="JournalApp.MainPage" SafeAreaEdges="Container" BackgroundColor="{AppThemeBinding Light=#FFF8F9, Dark=#181215}"> + diff --git a/JournalApp/Pages/Calendar/CalendarMonth.razor.css b/JournalApp/Pages/Calendar/CalendarMonth.razor.css index 2ba557c..db64492 100644 --- a/JournalApp/Pages/Calendar/CalendarMonth.razor.css +++ b/JournalApp/Pages/Calendar/CalendarMonth.razor.css @@ -5,10 +5,13 @@ padding-top: 16px; } +/* The month grid is a group container like every other list group: flat, tonal, large radius. */ ::deep .calendar-month-grid { display: flex; flex-direction: column; - padding: 0; + padding: 4px; + border-radius: var(--ja-shape-lg-increased); + box-shadow: none; } ::deep .calendar-day-cell { @@ -18,14 +21,27 @@ padding: 1%; min-width: 32px; max-width: 96px; - border-radius: 8px; - transition: transform 0.1s ease-out; + border-radius: var(--ja-shape-md); + transition: transform var(--ja-motion-spatial); } ::deep .calendar-day-cell:has(:not(.calendar-day-empty)):active { transform: scale(0.95); } +/* M3 calendar weekday labels are label-medium on onSurfaceVariant, not bold body text. */ +::deep .calendar-day-header { + justify-content: center; + color: var(--mud-palette-text-secondary); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.5px; +} + +::deep .calendar-day-header b { + font-weight: 500; +} + /* M3 marks today with a solid primary ring instead of a dashed generic outline. */ ::deep .calendar-day-current { outline: 3px solid var(--mud-palette-primary); diff --git a/JournalApp/Pages/ManageCategoriesPage.razor.css b/JournalApp/Pages/ManageCategoriesPage.razor.css index a0bac21..e63a247 100644 --- a/JournalApp/Pages/ManageCategoriesPage.razor.css +++ b/JournalApp/Pages/ManageCategoriesPage.razor.css @@ -2,7 +2,7 @@ .manage-list { display: flex; flex-direction: column; - gap: 3px; + gap: 4px; } .manage-category { @@ -11,18 +11,18 @@ align-items: center; gap: 4px; background-color: var(--mud-palette-surface); - border-radius: 6px; + border-radius: var(--ja-shape-sm); padding: 6px 14px 6px 6px; } .manage-category:first-child { - border-top-left-radius: 18px; - border-top-right-radius: 18px; + border-top-left-radius: var(--ja-shape-lg-increased); + border-top-right-radius: var(--ja-shape-lg-increased); } .manage-category:last-child { - border-bottom-left-radius: 18px; - border-bottom-right-radius: 18px; + border-bottom-left-radius: var(--ja-shape-lg-increased); + border-bottom-right-radius: var(--ja-shape-lg-increased); } ::deep .manage-category-edit-button { diff --git a/JournalApp/Pages/SafetyPlanning/SafetyPlanPage.razor.css b/JournalApp/Pages/SafetyPlanning/SafetyPlanPage.razor.css index 02af801..563838f 100644 --- a/JournalApp/Pages/SafetyPlanning/SafetyPlanPage.razor.css +++ b/JournalApp/Pages/SafetyPlanning/SafetyPlanPage.razor.css @@ -2,7 +2,7 @@ .safety-plan-items-container { display: flex; flex-direction: column; - gap: 3px; + gap: 4px; } ::deep .safety-plan-item { @@ -10,18 +10,18 @@ flex-direction: column; gap: 12px; background-color: var(--mud-palette-surface); - border-radius: 6px; + border-radius: var(--ja-shape-sm); padding: 14px 16px; } ::deep .safety-plan-item:first-child { - border-top-left-radius: 18px; - border-top-right-radius: 18px; + border-top-left-radius: var(--ja-shape-lg-increased); + border-top-right-radius: var(--ja-shape-lg-increased); } ::deep .safety-plan-item:last-child { - border-bottom-left-radius: 18px; - border-bottom-right-radius: 18px; + border-bottom-left-radius: var(--ja-shape-lg-increased); + border-bottom-right-radius: var(--ja-shape-lg-increased); } ::deep .safety-plan-item-header { diff --git a/JournalApp/Pages/SettingsPage.razor.css b/JournalApp/Pages/SettingsPage.razor.css index e62d60a..971eef0 100644 --- a/JournalApp/Pages/SettingsPage.razor.css +++ b/JournalApp/Pages/SettingsPage.razor.css @@ -8,7 +8,7 @@ .settings-group { display: flex; flex-direction: column; - gap: 3px; + gap: 4px; } ::deep .settings-item { @@ -16,18 +16,18 @@ flex-direction: column; gap: 12px; background-color: var(--mud-palette-surface); - border-radius: 6px; + border-radius: var(--ja-shape-sm); padding: 14px 16px; } ::deep .settings-item:first-child { - border-top-left-radius: 18px; - border-top-right-radius: 18px; + border-top-left-radius: var(--ja-shape-lg-increased); + border-top-right-radius: var(--ja-shape-lg-increased); } ::deep .settings-item:last-child { - border-bottom-left-radius: 18px; - border-bottom-right-radius: 18px; + border-bottom-left-radius: var(--ja-shape-lg-increased); + border-bottom-right-radius: var(--ja-shape-lg-increased); } ::deep .settings-item-title { diff --git a/JournalApp/Pages/Trends/TrendsPage.razor b/JournalApp/Pages/Trends/TrendsPage.razor index f0b534b..1b4909d 100644 --- a/JournalApp/Pages/Trends/TrendsPage.razor +++ b/JournalApp/Pages/Trends/TrendsPage.razor @@ -17,6 +17,25 @@
+ @if (!_loaded) + { + + } + else if (AllPoints.Count == 0) + { + + } +