Skip to content

Commit d6e6808

Browse files
atulmguptaCopilot
andcommitted
fix(apps): sync unit preference from backend /settings (web useUnits parity)
The web app reads the account's unit preferences from the backend /settings document and renders mi/mph/°F/psi accordingly; the native app was hard-defaulting to metric, so every distance/speed/temp read km/°C even when the account is imperial. - Rename Shell/BackendThemeSync -> BackendSettingsSync and extend it to also map the backend unit_of_length / unit_of_temp to the native metric/imperial preference and persist it alongside theme + mode (one /settings read seeds all three). - ShellWindow.ApplySettings now rebuilds the visible page on a units flip (pages resolve their unit pref at construction, mirroring web useUnits at render); list pages that already subscribe to settings.Changed keep self-applying. - Pass the resolved unit pref to the dashboard widgets that accept one (vehicle hero, motor, charge status, recent drives, analytics summary) so the Command Center renders in-account units on first paint. Gates: full sln build 0 err, dotnet format clean, placeholders 0, 31537 tests pass. Verified via live screenshot: dashboard now shows mi / °F / Wh/mi matching http://localhost:3000. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 42c8156 commit d6e6808

4 files changed

Lines changed: 147 additions & 96 deletions

File tree

apps/windows/TeslaSync.App/App.xaml.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs ar
6060
private static async Task InitializeSettingsThenThemeAsync(ShellDataContext data)
6161
{
6262
await AppSettingsHost.InitializeAsync().ConfigureAwait(false);
63-
await BackendThemeSync.ApplyAsync(data).ConfigureAwait(false);
63+
await BackendSettingsSync.ApplyAsync(data).ConfigureAwait(false);
6464
}
6565

6666
/// <summary>
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
using System;
2+
using System.Text.Json;
3+
using System.Threading.Tasks;
4+
using TeslaSync.App.Core.Data.Repositories;
5+
using TeslaSync.App.Core.Data.State;
6+
using TeslaSync.App.Core.Settings;
7+
using TeslaSync.App.Settings;
8+
9+
namespace TeslaSync.App.Shell;
10+
11+
/// <summary>
12+
/// Web-parity startup settings seed. The web app reads the persisted accent theme, colour mode and unit
13+
/// preferences from the backend <c>/settings</c> document on first mount (see
14+
/// <c>web/src/components/ui/ThemeProvider.tsx</c> for theme/mode and the <c>useUnits</c>/settings hooks for
15+
/// units) and applies them over its local defaults. This mirrors that so the native app shows the SAME
16+
/// theme AND the SAME units the web app shows for this account: the local <see cref="AppSettings"/> defaults
17+
/// (dark / neon-cyan / metric) only stand in until this resolves, and an unreachable or erroring backend
18+
/// keeps those defaults — the same fallback the web uses when its <c>GET /settings</c> fetch fails. The
19+
/// result is persisted through <see cref="AppSettingsHost"/>, whose <c>Changed</c> event drives the shell's
20+
/// live re-theme (the native <c>applyThemeCSS</c> analogue) and the per-page <c>ApplyUnits</c> refresh.
21+
/// </summary>
22+
internal static class BackendSettingsSync
23+
{
24+
// The web ThemeProvider only accepts ids that exist in its theme/mode catalogs
25+
// (`saved in themes` / `saved in modes`); mirror that allow-list so an unexpected
26+
// backend value falls back to the local default instead of an unknown palette.
27+
private static readonly string[] AccentThemeIds =
28+
{ "neon-cyan", "tesla-red", "matrix-green", "royal-purple", "solar-amber", "custom" };
29+
30+
private static readonly string[] ColorModeIds =
31+
{ "dark", "light", "oled", "midnight", "auto", "sunset", "nord" };
32+
33+
/// <summary>
34+
/// Reads the backend settings document and, when it carries a recognised <c>theme</c>/<c>mode</c> or
35+
/// unit preference, commits them to <see cref="AppSettingsHost"/>. Best-effort and non-blocking.
36+
/// </summary>
37+
public static async Task ApplyAsync(ShellDataContext data)
38+
{
39+
try
40+
{
41+
var repo = new SettingsRepository(data.Api, data.Engine, data.Options);
42+
await foreach (var result in repo.GetSettingsAsync().ConfigureAwait(false))
43+
{
44+
if (result.Status != LoadStatus.Loaded)
45+
{
46+
continue;
47+
}
48+
49+
JsonElement document = result.Value;
50+
if (document.ValueKind != JsonValueKind.Object)
51+
{
52+
continue;
53+
}
54+
55+
string? theme = ReadId(document, "theme", AccentThemeIds);
56+
string? mode = ReadId(document, "mode", ColorModeIds);
57+
UnitSystemPreference? units = ReadUnits(document);
58+
if (theme is null && mode is null && units is null)
59+
{
60+
return;
61+
}
62+
63+
await AppSettingsHost.Service.UpdateAsync(s => s with
64+
{
65+
AccentThemeId = theme ?? s.AccentThemeId,
66+
ColorModeId = mode ?? s.ColorModeId,
67+
Units = units ?? s.Units,
68+
}).ConfigureAwait(false);
69+
return;
70+
}
71+
}
72+
catch (Exception)
73+
{
74+
// Best-effort: an unreachable/erroring backend keeps the local defaults in place,
75+
// which is exactly what the web does when its GET /settings fetch fails.
76+
}
77+
}
78+
79+
private static string? ReadId(JsonElement settings, string property, string[] allowed)
80+
{
81+
if (settings.TryGetProperty(property, out var value)
82+
&& value.ValueKind == JsonValueKind.String)
83+
{
84+
string? id = value.GetString();
85+
if (!string.IsNullOrEmpty(id) && Array.IndexOf(allowed, id) >= 0)
86+
{
87+
return id;
88+
}
89+
}
90+
91+
return null;
92+
}
93+
94+
// The backend stores units granularly (unit_of_length / unit_of_temp / unit_of_pressure), while the
95+
// native app carries a single metric/imperial preference. Length is the dominant display axis, so it
96+
// drives the mapping (mi -> imperial, km -> metric); temperature is a tie-breaker when length is absent.
97+
private static UnitSystemPreference? ReadUnits(JsonElement settings)
98+
{
99+
string? length = ReadString(settings, "unit_of_length");
100+
if (string.Equals(length, "mi", StringComparison.OrdinalIgnoreCase))
101+
{
102+
return UnitSystemPreference.Imperial;
103+
}
104+
105+
if (string.Equals(length, "km", StringComparison.OrdinalIgnoreCase))
106+
{
107+
return UnitSystemPreference.Metric;
108+
}
109+
110+
string? temp = ReadString(settings, "unit_of_temp");
111+
if (string.Equals(temp, "F", StringComparison.OrdinalIgnoreCase))
112+
{
113+
return UnitSystemPreference.Imperial;
114+
}
115+
116+
if (string.Equals(temp, "C", StringComparison.OrdinalIgnoreCase))
117+
{
118+
return UnitSystemPreference.Metric;
119+
}
120+
121+
return null;
122+
}
123+
124+
private static string? ReadString(JsonElement settings, string property) =>
125+
settings.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
126+
? value.GetString()
127+
: null;
128+
}

apps/windows/TeslaSync.App/Shell/BackendThemeSync.cs

Lines changed: 0 additions & 89 deletions
This file was deleted.

apps/windows/TeslaSync.App/Shell/ShellWindow.xaml.cs

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public sealed partial class ShellWindow : Window
4545
private ElementTheme _theme = ElementTheme.Default;
4646
private string? _appliedAccent;
4747
private string? _appliedMode;
48+
private UnitSystemPreference _appliedUnits = UnitSystemPreference.Metric;
4849
private bool _firstThemeApply = true;
4950
private AccessibilitySettings? _accessibility;
5051
private bool _navigating;
@@ -1337,16 +1338,20 @@ private void RegisterDataBackedPages()
13371338
// (DashboardStatsWidget is intentionally omitted — its backend GET /dashboard/stats endpoint currently 500s.)
13381339
_viewModel.PageFactory.Register("Dashboard", () =>
13391340
{
1341+
// Resolve the current unit preference (web useUnits() parity) so the data widgets render in the
1342+
// account's units. The factory re-runs on a settings change (ApplySettings rebuilds on a units
1343+
// flip), so the widgets pick up metric/imperial changes without a manual refresh.
1344+
var units = AppSettingsHost.Current.ToUnitPref();
13401345
var widgets = new UIElement[]
13411346
{
1342-
DashboardWidgets.VehicleHeroCardWidget.Create(_data.Vehicles, _data.Api, _data.Engine, _data.Options, _data.Localizer),
1347+
DashboardWidgets.VehicleHeroCardWidget.Create(_data.Vehicles, _data.Api, _data.Engine, _data.Options, _data.Localizer, units: units),
13431348
DashboardWidgets.DigitalTwinWidget.Create(_data.Vehicles, _data.Api, _data.Engine, _data.Options, _data.Localizer),
13441349
DashboardWidgets.BatteryRadialGaugeWidget.Create(_data.Vehicles, _data.Api, _data.Engine, _data.Options, _data.Localizer),
13451350
DashboardWidgets.BatteryDegradationForecastWidget.Create(_data.Vehicles, _data.Api, _data.Engine, _data.Options, _data.Localizer),
1346-
DashboardWidgets.MotorPerformanceWidget.Create(_data.Vehicles, _data.Api, _data.Engine, _data.Options, _data.Localizer),
1347-
DashboardWidgets.ChargeStatusWidget.Create(_data.Vehicles, _data.Api, _data.Engine, _data.Options, _data.Localizer),
1348-
DashboardWidgets.RecentDrivesWidget.Create(_data.Vehicles, _data.Api, _data.Engine, _data.Options, _data.Localizer),
1349-
DashboardWidgets.AnalyticsSummaryWidget.Create(_data.Api, _data.Engine, _data.Options, _data.Localizer),
1351+
DashboardWidgets.MotorPerformanceWidget.Create(_data.Vehicles, _data.Api, _data.Engine, _data.Options, _data.Localizer, units: units),
1352+
DashboardWidgets.ChargeStatusWidget.Create(_data.Vehicles, _data.Api, _data.Engine, _data.Options, _data.Localizer, units: units),
1353+
DashboardWidgets.RecentDrivesWidget.Create(_data.Vehicles, _data.Api, _data.Engine, _data.Options, _data.Localizer, units: units),
1354+
DashboardWidgets.AnalyticsSummaryWidget.Create(_data.Api, _data.Engine, _data.Options, _data.Localizer, units: units),
13501355
DashboardWidgets.SoftwareUpdateHistoryWidget.Create(_data.Vehicles, _data.Api, _data.Engine, _data.Options, _data.Localizer),
13511356
};
13521357
var page = new FeatureViews.Dashboard.DashboardPage(
@@ -1386,6 +1391,7 @@ private void ConfigureWindow()
13861391
startup.AccentThemeId, startup.ColorModeId, SystemPrefersDark(), Content as FrameworkElement);
13871392
_appliedAccent = startup.AccentThemeId;
13881393
_appliedMode = startup.ColorModeId;
1394+
_appliedUnits = startup.Units;
13891395

13901396
// The startup palette above is the local default; the deferred local-settings load and the
13911397
// backend /settings theme seed (web ThemeProvider parity) arrive as later Changed events. Clear the
@@ -1886,6 +1892,11 @@ private void ApplySettings(AppSettings settings)
18861892
&& (!string.Equals(_appliedAccent, settings.AccentThemeId, StringComparison.Ordinal)
18871893
|| !string.Equals(_appliedMode, settings.ColorModeId, StringComparison.Ordinal));
18881894

1895+
// Units flip the same way: pages resolve their unit pref at construction (mirrors the web's
1896+
// useUnits() at render), so a metric/imperial change needs a rebuild for non-reactive pages. List
1897+
// pages that subscribe to settings.Changed re-apply units themselves; the rebuild covers the rest.
1898+
bool unitsChanged = !_firstThemeApply && _appliedUnits != settings.Units;
1899+
18891900
if (highContrast)
18901901
{
18911902
if (Content is FrameworkElement hcRoot)
@@ -1903,12 +1914,13 @@ private void ApplySettings(AppSettings settings)
19031914

19041915
_appliedAccent = settings.AccentThemeId;
19051916
_appliedMode = settings.ColorModeId;
1917+
_appliedUnits = settings.Units;
19061918
_firstThemeApply = false;
19071919

19081920
ApplyDensity(settings.Density);
19091921
MaybeApplyStartupRoute(settings);
19101922

1911-
if (themeChanged && !string.IsNullOrEmpty(_viewModel.CurrentPath))
1923+
if ((themeChanged || unitsChanged) && !string.IsNullOrEmpty(_viewModel.CurrentPath))
19121924
{
19131925
NavigateTo(_viewModel.CurrentPath, pushHistory: false, record: false);
19141926
}

0 commit comments

Comments
 (0)