Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions JournalApp.Tests/Data/PreferenceServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,37 @@ public void SelectedAppTheme_WithInvalidStoredValue_FallsBackToUnspecified()
result.Should().Be(AppTheme.Unspecified);
}

[Fact]
public void HandleOsThemeChanged_KeepsTheUsersChoice()
{
// Arrange
var preferences = Services.GetService<IPreferences>();
var preferenceService = Services.GetService<PreferenceService>();
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<PreferenceService>();
var raised = 0;
preferenceService.ThemeChanged += (_, _) => raised++;

// Act
preferenceService.HandleOsThemeChanged();

// Assert
raised.Should().Be(1);
}

[Fact]
public void SafetyPlan_WithMalformedJson_ReturnsNull()
{
Expand Down
49 changes: 49 additions & 0 deletions JournalApp.Tests/MaterialThemeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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));
}
}
3 changes: 2 additions & 1 deletion JournalApp/Components/DataPointView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ else if (Point.Type == PointType.Sleep)
}
else if (Point.Type == PointType.Scale)
{
<MudRating SelectedValue="DataPointService.GetScaleIndex(Point)" SelectedValueChanged="ScaleValueChanged" FullIcon="@Icons.Material.Rounded.Circle" EmptyIcon="@Icons.Material.Rounded.Circle" Color="Color.Primary" />
@* A filled and an outlined dot, because MudRating paints both icons in the same color and two filled circles would look identical. *@
<MudRating SelectedValue="DataPointService.GetScaleIndex(Point)" SelectedValueChanged="ScaleValueChanged" FullIcon="@Icons.Material.Rounded.Circle" EmptyIcon="@Icons.Material.Rounded.RadioButtonUnchecked" Color="Color.Primary" />
}
else if (Point.Type == PointType.LowToHigh)
{
Expand Down
8 changes: 4 additions & 4 deletions JournalApp/Components/JaMessageBox.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@

<MudDialog @attributes="UserAttributes" Class="@Classname" OnBackdropClick="OnBackdropClick">
<TitleContent>
@if (TitleContent is null)
@if (TitleContent is not null)
{
<MudText Typo="Typo.h6">@Title</MudText>
@TitleContent
}
else
else if (!string.IsNullOrWhiteSpace(Title))
{
@TitleContent
<MudText Typo="Typo.h6">@Title</MudText>
}
</TitleContent>
<DialogContent>
Expand Down
21 changes: 17 additions & 4 deletions JournalApp/Data/MaterialTheme.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
Expand All @@ -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,
},

Expand Down Expand Up @@ -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",
Expand Down
42 changes: 30 additions & 12 deletions JournalApp/Data/PreferenceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public PreferenceService(ILogger<PreferenceService> logger, IPreferences prefere
_application.RequestedThemeChanged += Application_RequestedThemeChanged;
}

UpdateStatusBar();
ApplyPlatformTheme();
}

public AppTheme SelectedAppTheme
Expand Down Expand Up @@ -181,26 +181,44 @@ public DateTimeOffset LastExportDate

public event EventHandler<bool> ThemeChanged;

private void Application_RequestedThemeChanged(object sender, AppThemeChangedEventArgs e)
{
_theme = e.RequestedTheme;
OnThemeChanged();
}
private void Application_RequestedThemeChanged(object sender, AppThemeChangedEventArgs e) => HandleOsThemeChanged();

/// <summary>
/// Repaints for an OS theme change without touching the theme the user picked, which stays whatever they chose including System.
/// </summary>
internal void HandleOsThemeChanged() => OnThemeChanged();

private void OnThemeChanged()
{
UpdateStatusBar();
ApplyPlatformTheme();
ThemeChanged?.Invoke(this, IsDarkMode);
}

private void UpdateStatusBar()
/// <summary>
/// Pushes the active theme out to the native chrome that the web layer can't reach.
/// </summary>
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);
}
}
Expand Down
1 change: 1 addition & 0 deletions JournalApp/MainPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
x:Class="JournalApp.MainPage"
SafeAreaEdges="Container"
BackgroundColor="{AppThemeBinding Light=#FFF8F9, Dark=#181215}">
<!-- The background is a first-frame default only; PreferenceService.ApplyPlatformTheme repaints it from the generated palette so device colors reach the letterbox too. -->

<BlazorWebView HostPage="wwwroot/index.html" BlazorWebViewInitialized="OnBlazorWebViewInitialized">
<BlazorWebView.RootComponents>
Expand Down
22 changes: 19 additions & 3 deletions JournalApp/Pages/Calendar/CalendarMonth.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down
12 changes: 6 additions & 6 deletions JournalApp/Pages/ManageCategoriesPage.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
.manage-list {
display: flex;
flex-direction: column;
gap: 3px;
gap: 4px;
}

.manage-category {
Expand All @@ -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 {
Expand Down
12 changes: 6 additions & 6 deletions JournalApp/Pages/SafetyPlanning/SafetyPlanPage.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,26 @@
.safety-plan-items-container {
display: flex;
flex-direction: column;
gap: 3px;
gap: 4px;
}

::deep .safety-plan-item {
display: flex;
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 {
Expand Down
Loading
Loading