From d3084323bcb4462deed104cb92e74c73292a129d Mon Sep 17 00:00:00 2001 From: dmiesopust Date: Wed, 2 Sep 2026 11:33:28 +0200 Subject: [PATCH] feat(app): Workspace onboarding/tool help, and a warmer Home greeting Adds an always-available "Tool Help" flyout explaining all four canvas tools (including Select's three click-target-dependent behaviors) plus a skippable first-run coach-mark tour, tracked via a new AppSettings.HasSeenWorkspaceOnboarding flag. Also gives Home a time-of-day greeting instead of a static title, plus a small non-interactive wellbeing line picked at random each visit. --- CLAUDE.md | 3 + src/Pulsemap.App.Core/Settings/AppSettings.cs | 4 + .../Services/LocalizationService.cs | 16 ++++ src/Pulsemap.App/Strings/en-US/Resources.resw | 81 ++++++++++++++++++- src/Pulsemap.App/Strings/pl-PL/Resources.resw | 81 ++++++++++++++++++- src/Pulsemap.App/ViewModels/HomeViewModel.cs | 21 +++++ .../ViewModels/WorkspaceViewModel.cs | 19 ++++- src/Pulsemap.App/Views/HomePage.xaml | 3 +- src/Pulsemap.App/Views/WorkspacePage.xaml | 35 ++++++++ src/Pulsemap.App/Views/WorkspacePage.xaml.cs | 35 ++++++++ .../ViewModels/HomeViewModelTests.cs | 18 +++++ .../ViewModels/WorkspaceViewModelTests.cs | 33 +++++++- 12 files changed, 340 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dcd3c0c..eba8a84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,3 +60,6 @@ Don't invent a convention to fill a gap. Name what's missing, propose the smalle - **Outdoor-area bounds editor (added 2026-09-02, on branch `feature/outdoor-bounds-editor`, not yet merged to staging):** outdoor areas previously had no way to resize/reposition their extent from the UI — a new floor always got a fixed 40x40m default with no follow-up editor. `FloorPlanCanvas` now draws an outdoor floor's bounds as a dashed rectangle with one resize handle at its far corner (deliberately one anchored corner + one dragged corner, not four independent handles — the smallest interaction that still covers both resize and reposition); dragging inside the rectangle moves the whole area instead. A click first checks for an existing test point/AP within the usual 0.5m tolerance so precise clicks on markers placed inside the area still work — only a click that misses every marker starts a bounds drag. Drag state and a live preview stay canvas-local (mirrors the "canvas owns rendering/gesture recognition only" rule already documented on `FloorPlanCanvas`); only on release does a new `OutdoorBoundsChanged` event fire, which `WorkspaceViewModel.UpdateOutdoorBoundsAsync` persists to `Floor.OutdoorBoundsMin`/`Max`. - **Per-wall material editing and guided-walk improvements (added 2026-09-02, on branch `feature/wall-material-and-guided-walk`, not yet merged to staging):** the Select tool (previously a no-op placeholder) now does two things depending on what's clicked: a wall toggles into a multi-select set (highlighted blue), with a toolbar panel to batch-apply one material/thickness to everything selected; a test point instead prompts to recapture its reading (rescans via the currently selected adapter and replaces `Measurements`/`InterferenceReadings`, keeping `Position`). Guided walk gained a Skip button (dequeues without capturing, stays active), renders its remaining queue as faint markers on the canvas, and now persists its pending queue on `Floor.PendingGuidedWalkPoints`/`PendingGuidedWalkBand` — saved on every dequeue/skip/cancel, so closing and reopening the app mid-walk resumes instead of losing the remaining points; `OnSelectedFloorChanged` auto-resumes/pauses as the floor selector switches. 144 tests now on that branch (75 App/69 Core). - **In-app update check (added 2026-09-02, on branch `feature/update-check-notify`, not yet merged to staging — see ADR-0004):** Pulsemap's first-ever outbound network call, explicitly cleared with the PM given ADR-0003 had deliberately left this question open. Home now checks `https://api.github.com/repos/DotifyBIZ/pulsemap/releases/latest` on load (via new `IUpdateCheckService`/`GitHubUpdateCheckService`, App-layer, `IHttpClientFactory`-based) and shows a dismissible banner + release link if the tag is newer than the running build (`SemanticVersionComparer`, Core, pure/testable, compares `Assembly.GetName().Version` against a `vX.Y.Z` tag). Gated behind a new `AppSettings.CheckForUpdatesEnabled` (default on, Core, `FileAppSettingsService` at `%LocalAppData%\Pulsemap\settings.json` — Pulsemap's first app-preferences store, distinct from survey data and from `FileAppLogger`'s logs), with a visible toggle on the Settings page ("Check for updates on launch"). A real prerequisite gap found and fixed along the way: `scripts/build-installer.ps1`'s `dotnet publish` never passed `-p:Version=$Version`, so the published assembly's version was always the SDK's static default regardless of the actual release — without that fix, the whole comparison would have been meaningless. README's "won't phone home" bullet is amended accordingly (telemetry/account/server-free claim still holds; the literal zero-network-calls reading no longer does). 82 Core / 76 App tests on that branch — includes a stub-`HttpMessageHandler`-backed test of the actual HTTP/JSON path, not just the pure version-comparison logic. + +**UX pass (started 2026-09-02, in progress)** — a full-codebase UX review turned up several friction points (Select tool overloaded with undiscoverable per-click-target behavior, no undo anywhere, no onboarding, inconsistent delete confirmation, silent AP-suggestion replacement, no survey-type context in Workspace, the Pivot-clipping bug). Being fixed incrementally, each as its own branch: +- **Workspace onboarding + tool legend, and a warmer Home (added 2026-09-02, on branch `feature/workspace-onboarding`, not yet merged to staging):** a "Tool Help" button (flyout, always available) now explains all four canvas tools including Select's three click-target-dependent behaviors. A skippable first-run coach-mark tour (`TeachingTip`, one per tool button, Next/Skip actions) walks a new user through them once — tracked via a new `AppSettings.HasSeenWorkspaceOnboarding` (same `IAppSettingsService` store the update-check feature added), `WorkspaceViewModel.ShouldShowOnboardingAsync`/`MarkOnboardingSeenAsync`. Separately, at the user's request, Home's static "Home" title is now a time-of-day greeting (`HomeViewModel.GreetingDisplay`, morning/afternoon/evening/late-night buckets on `DateTime.Now.Hour`) plus a small, non-interactive `WellbeingMessageDisplay` line picked at random from 4 options each visit — no reply affordance, just a quiet touch. The now-unused static "HomeTitle" resource was removed from both languages since this change made it dead. diff --git a/src/Pulsemap.App.Core/Settings/AppSettings.cs b/src/Pulsemap.App.Core/Settings/AppSettings.cs index 95cdddb..be9fb68 100644 --- a/src/Pulsemap.App.Core/Settings/AppSettings.cs +++ b/src/Pulsemap.App.Core/Settings/AppSettings.cs @@ -8,4 +8,8 @@ public sealed class AppSettings /// API with no identifying data attached, not telemetry — but it's still an outbound network /// call, so it stays user-visible and disableable in Settings. public bool CheckForUpdatesEnabled { get; set; } = true; + + /// Whether the Workspace first-run coach-mark tour has been shown or skipped — + /// suppresses it on every later visit regardless of which survey is open. + public bool HasSeenWorkspaceOnboarding { get; set; } } diff --git a/src/Pulsemap.App/Services/LocalizationService.cs b/src/Pulsemap.App/Services/LocalizationService.cs index 7645e93..01f2b9d 100644 --- a/src/Pulsemap.App/Services/LocalizationService.cs +++ b/src/Pulsemap.App/Services/LocalizationService.cs @@ -117,6 +117,14 @@ public string GetString(string key) ["WorkspaceRecaptureDialogCloseButton"] = "Cancel", ["HomeUpdateAvailableFormat"] = "Pulsemap {0} is available.", + ["HomeGreetingMorning"] = "Good morning", + ["HomeGreetingAfternoon"] = "Good afternoon", + ["HomeGreetingEvening"] = "Good evening", + ["HomeGreetingNight"] = "Hello", + ["HomeWellbeingMessage1"] = "Hope you're doing well today.", + ["HomeWellbeingMessage2"] = "Take a moment for yourself if you need one.", + ["HomeWellbeingMessage3"] = "However today's going, we hope it gets a little easier.", + ["HomeWellbeingMessage4"] = "Glad you're here. Take care of yourself out there.", }, ["pl-PL"] = new Dictionary { @@ -192,6 +200,14 @@ public string GetString(string key) ["WorkspaceRecaptureDialogCloseButton"] = "Anuluj", ["HomeUpdateAvailableFormat"] = "Dostępny jest Pulsemap {0}.", + ["HomeGreetingMorning"] = "Dzień dobry", + ["HomeGreetingAfternoon"] = "Dzień dobry", + ["HomeGreetingEvening"] = "Dobry wieczór", + ["HomeGreetingNight"] = "Witaj", + ["HomeWellbeingMessage1"] = "Mamy nadzieję, że wszystko u Ciebie dobrze.", + ["HomeWellbeingMessage2"] = "Jeśli potrzebujesz chwili dla siebie, zrób sobie przerwę.", + ["HomeWellbeingMessage3"] = "Niezależnie od tego, jak mija dzisiejszy dzień, mamy nadzieję, że będzie choć trochę lżej.", + ["HomeWellbeingMessage4"] = "Miło, że tu jesteś. Zadbaj o siebie.", }, }; } diff --git a/src/Pulsemap.App/Strings/en-US/Resources.resw b/src/Pulsemap.App/Strings/en-US/Resources.resw index 86cd2c0..cd43180 100644 --- a/src/Pulsemap.App/Strings/en-US/Resources.resw +++ b/src/Pulsemap.App/Strings/en-US/Resources.resw @@ -25,9 +25,6 @@ - - Home - Your surveys, all in one place. @@ -278,6 +275,84 @@ Delete + + Tool Help + + + What each tool does + + + Select + + + Click a wall to add it to a multi-select for batch material/thickness editing. Click a test point to rescan and replace its reading. On an outdoor area, drag its edge to resize or drag inside it to move the whole area. + + + Add Test Point + + + Click anywhere on the canvas to place a manual test point without doing a full guided walk. + + + Draw Wall + + + Click one endpoint, then click the other to draw a wall between them. + + + Delete + + + Click near a wall, test point, or access point to remove it. An Undo option appears right after, until your next delete. + + + Welcome to the Workspace + + + Select does different things depending on what you click: a wall, a test point, or an outdoor area's edge. The Tool Help button always explains this if you forget. + + + Next + + + Skip tour + + + Add Test Point + + + Click anywhere to drop a manual test point — or use the Adapter tab's guided walk to be led through suggested points instead. + + + Next + + + Skip tour + + + Draw Wall + + + Click one end, then the other, to draw a wall. Give it a material afterward with Select for a more accurate coverage prediction. + + + Next + + + Skip tour + + + Delete + + + Click near anything to remove it — an Undo option appears right after, so a misclick isn't permanent. + + + Got it + + + Skip tour + Back diff --git a/src/Pulsemap.App/Strings/pl-PL/Resources.resw b/src/Pulsemap.App/Strings/pl-PL/Resources.resw index 5baa230..8b4b4f8 100644 --- a/src/Pulsemap.App/Strings/pl-PL/Resources.resw +++ b/src/Pulsemap.App/Strings/pl-PL/Resources.resw @@ -25,9 +25,6 @@ - - Strona główna - Wszystkie Twoje badania w jednym miejscu. @@ -278,6 +275,84 @@ Usuń + + Pomoc dot. narzędzi + + + Co robi każde narzędzie + + + Zaznacz + + + Kliknij ścianę, aby dodać ją do zaznaczenia wielokrotnego w celu zbiorczej edycji materiału/grubości. Kliknij punkt pomiarowy, aby ponownie zeskanować i zastąpić jego odczyt. W obszarze zewnętrznym przeciągnij krawędź, aby zmienić rozmiar, lub przeciągnij wnętrze, aby przesunąć cały obszar. + + + Dodaj punkt pomiarowy + + + Kliknij dowolne miejsce na płótnie, aby ręcznie umieścić punkt pomiarowy bez pełnego pomiaru terenowego. + + + Rysuj ścianę + + + Kliknij jeden koniec, a następnie drugi, aby narysować między nimi ścianę. + + + Usuń + + + Kliknij w pobliżu ściany, punktu pomiarowego lub punktu dostępowego, aby go usunąć. Zaraz potem pojawi się opcja cofnięcia, ważna do kolejnego usunięcia. + + + Witaj w Obszarze roboczym + + + Narzędzie Zaznacz robi różne rzeczy w zależności od tego, co klikniesz: ścianę, punkt pomiarowy lub krawędź obszaru zewnętrznego. Przycisk Pomoc dot. narzędzi zawsze to wyjaśni, gdybyś zapomniał. + + + Dalej + + + Pomiń + + + Dodaj punkt pomiarowy + + + Kliknij dowolne miejsce, aby umieścić ręczny punkt pomiarowy — albo skorzystaj z pomiaru terenowego w zakładce Adapter, aby zostać poprowadzonym przez sugerowane punkty. + + + Dalej + + + Pomiń + + + Rysuj ścianę + + + Kliknij jeden koniec, a potem drugi, aby narysować ścianę. Nadaj jej materiał później za pomocą Zaznacz, aby uzyskać dokładniejszą prognozę pokrycia. + + + Dalej + + + Pomiń + + + Usuń + + + Kliknij w pobliżu dowolnego elementu, aby go usunąć — zaraz potem pojawi się opcja cofnięcia, więc pomyłka nie jest trwała. + + + Rozumiem + + + Pomiń + Wstecz diff --git a/src/Pulsemap.App/ViewModels/HomeViewModel.cs b/src/Pulsemap.App/ViewModels/HomeViewModel.cs index 7d27ede..6bf3cdd 100644 --- a/src/Pulsemap.App/ViewModels/HomeViewModel.cs +++ b/src/Pulsemap.App/ViewModels/HomeViewModel.cs @@ -18,10 +18,31 @@ public partial class HomeViewModel( // Home is a dashboard, not the library — the Surveys nav tab shows the full, unlimited list. private const int RecentSurveysLimit = 3; + // A quiet, non-interactive touch — no reply expected or possible, just a small sign the app + // isn't purely transactional. Picked once per Home visit (HomeViewModel is transient, so a + // fresh navigation gets a fresh pick) rather than rotated on a timer or tracked across visits. + private static readonly string[] WellbeingMessageKeys = + [ + "HomeWellbeingMessage1", + "HomeWellbeingMessage2", + "HomeWellbeingMessage3", + "HomeWellbeingMessage4", + ]; + private string? _releaseUrl; public ObservableCollection Surveys { get; } = []; + public string GreetingDisplay => DateTime.Now.Hour switch + { + >= 5 and < 12 => localizationService.GetString("HomeGreetingMorning"), + >= 12 and < 17 => localizationService.GetString("HomeGreetingAfternoon"), + >= 17 and < 22 => localizationService.GetString("HomeGreetingEvening"), + _ => localizationService.GetString("HomeGreetingNight"), + }; + + public string WellbeingMessageDisplay { get; } = localizationService.GetString(WellbeingMessageKeys[Random.Shared.Next(WellbeingMessageKeys.Length)]); + [ObservableProperty] public partial bool IsLoading { get; set; } diff --git a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs index fde20c0..9e222e6 100644 --- a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs +++ b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs @@ -11,6 +11,7 @@ using Pulsemap.App.Core.Persistence; using Pulsemap.App.Core.Placement; using Pulsemap.App.Core.Propagation; +using Pulsemap.App.Core.Settings; using Pulsemap.App.Services; using Windows.System; @@ -42,6 +43,7 @@ public partial class WorkspaceViewModel : ObservableObject private readonly IReportExporter _reportExporter; private readonly ISurveyExportFilePickerService _exportFilePickerService; private readonly IKrigingInterpolator _krigingInterpolator; + private readonly IAppSettingsService _appSettingsService; private string? _filePath; @@ -55,7 +57,8 @@ public WorkspaceViewModel( ISurveyDataExporter surveyDataExporter, IReportExporter reportExporter, ISurveyExportFilePickerService exportFilePickerService, - IKrigingInterpolator krigingInterpolator) + IKrigingInterpolator krigingInterpolator, + IAppSettingsService appSettingsService) { _surveyFileService = surveyFileService; _propagationModel = propagationModel; @@ -67,6 +70,7 @@ public WorkspaceViewModel( _reportExporter = reportExporter; _exportFilePickerService = exportFilePickerService; _krigingInterpolator = krigingInterpolator; + _appSettingsService = appSettingsService; } public event EventHandler? FloorChanged; @@ -226,6 +230,19 @@ public string AccessPointSummaryDisplay public string WallSelectionCountDisplay => string.Format(CultureInfo.CurrentCulture, _localizationService.GetString("WorkspaceWallSelectionCountFormat"), SelectedWallCount); + public async Task ShouldShowOnboardingAsync() + { + var settings = await _appSettingsService.LoadAsync(); + return !settings.HasSeenWorkspaceOnboarding; + } + + public async Task MarkOnboardingSeenAsync() + { + var settings = await _appSettingsService.LoadAsync(); + settings.HasSeenWorkspaceOnboarding = true; + await _appSettingsService.SaveAsync(settings); + } + public async Task LoadAsync(string filePath, CancellationToken cancellationToken = default) { _filePath = filePath; diff --git a/src/Pulsemap.App/Views/HomePage.xaml b/src/Pulsemap.App/Views/HomePage.xaml index 882686b..fb92493 100644 --- a/src/Pulsemap.App/Views/HomePage.xaml +++ b/src/Pulsemap.App/Views/HomePage.xaml @@ -35,8 +35,9 @@ - + + + + + + + + + diff --git a/src/Pulsemap.App/Views/WorkspacePage.xaml.cs b/src/Pulsemap.App/Views/WorkspacePage.xaml.cs index b0a10dd..e6c6dc2 100644 --- a/src/Pulsemap.App/Views/WorkspacePage.xaml.cs +++ b/src/Pulsemap.App/Views/WorkspacePage.xaml.cs @@ -40,7 +40,42 @@ protected override async void OnNavigatedTo(NavigationEventArgs e) await ViewModel.LoadAsync(filePath); PopulateBandSelector(); await RenderCanvasAsync(); + + if (await ViewModel.ShouldShowOnboardingAsync()) + { + OnboardingStep1.IsOpen = true; + } + } + } + + private void OnboardingNext_Click(TeachingTip sender, object args) + { + sender.IsOpen = false; + + if (ReferenceEquals(sender, OnboardingStep1)) + { + OnboardingStep2.IsOpen = true; } + else if (ReferenceEquals(sender, OnboardingStep2)) + { + OnboardingStep3.IsOpen = true; + } + else if (ReferenceEquals(sender, OnboardingStep3)) + { + OnboardingStep4.IsOpen = true; + } + } + + private async void OnboardingFinish_Click(TeachingTip sender, object args) + { + sender.IsOpen = false; + await ViewModel.MarkOnboardingSeenAsync(); + } + + private async void OnboardingSkip_Click(TeachingTip sender, object args) + { + sender.IsOpen = false; + await ViewModel.MarkOnboardingSeenAsync(); } private async Task RenderCanvasAsync() diff --git a/tests/Pulsemap.App.Tests/ViewModels/HomeViewModelTests.cs b/tests/Pulsemap.App.Tests/ViewModels/HomeViewModelTests.cs index 8752534..7b2fd5d 100644 --- a/tests/Pulsemap.App.Tests/ViewModels/HomeViewModelTests.cs +++ b/tests/Pulsemap.App.Tests/ViewModels/HomeViewModelTests.cs @@ -98,4 +98,22 @@ public async Task LoadCommand_NoUpdateAvailable_DoesNotShowBanner() Assert.False(sut.HasUpdateAvailable); Assert.Null(sut.UpdateBannerMessage); } + + [Fact] + public void GreetingDisplay_IsNeverEmpty() + { + // Depends on the real current hour, so this asserts the switch always resolves to + // something rather than pinning an exact greeting string. + var sut = CreateSut(); + + Assert.False(string.IsNullOrWhiteSpace(sut.GreetingDisplay)); + } + + [Fact] + public void WellbeingMessageDisplay_PicksOneOfTheKnownMessages() + { + var sut = CreateSut(); + + Assert.StartsWith("HomeWellbeingMessage", sut.WellbeingMessageDisplay); + } } diff --git a/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs b/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs index 3020f66..701bfe7 100644 --- a/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs +++ b/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs @@ -20,6 +20,7 @@ public sealed class WorkspaceViewModelTests private readonly FakeSurveyDataExporter _surveyDataExporter = new(); private readonly FakeReportExporter _reportExporter = new(); private readonly FakeSurveyExportFilePickerService _exportFilePickerService = new(); + private readonly FakeAppSettingsService _appSettingsService = new(); // Real (not faked) — pure/deterministic, no I/O, and every test here loads a floor with zero // existing TestPoints, so StartGuidedWalk's adaptive path never actually calls into this (falls @@ -37,7 +38,8 @@ public sealed class WorkspaceViewModelTests _surveyDataExporter, _reportExporter, _exportFilePickerService, - _krigingInterpolator); + _krigingInterpolator, + _appSettingsService); [Fact] public async Task LoadAsync_ValidSurvey_PopulatesSurveyAndBands() @@ -53,6 +55,35 @@ public async Task LoadAsync_ValidSurvey_PopulatesSurveyAndBands() Assert.Contains(Band.TwoPointFourGhz, sut.AvailableBands); } + [Fact] + public async Task ShouldShowOnboardingAsync_NotSeenBefore_ReturnsTrue() + { + _appSettingsService.SettingsToReturn = new Core.Settings.AppSettings { HasSeenWorkspaceOnboarding = false }; + var sut = CreateSut(); + + Assert.True(await sut.ShouldShowOnboardingAsync()); + } + + [Fact] + public async Task ShouldShowOnboardingAsync_AlreadySeen_ReturnsFalse() + { + _appSettingsService.SettingsToReturn = new Core.Settings.AppSettings { HasSeenWorkspaceOnboarding = true }; + var sut = CreateSut(); + + Assert.False(await sut.ShouldShowOnboardingAsync()); + } + + [Fact] + public async Task MarkOnboardingSeenAsync_PersistsFlagAndFutureCheckReturnsFalse() + { + var sut = CreateSut(); + + await sut.MarkOnboardingSeenAsync(); + + Assert.True(_appSettingsService.LastSaved?.HasSeenWorkspaceOnboarding); + Assert.False(await sut.ShouldShowOnboardingAsync()); + } + [Fact] public async Task LoadAsync_LoadThrowsIOException_SetsErrorMessage() {