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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
- **Undo last delete (added 2026-09-02, on branch `feature/undo-last-delete`, not yet merged to staging):** the Delete tool removed a wall/test point/AP permanently with zero confirmation or recovery — the single highest-risk unconfirmed action in the app. Deliberately not a full undo/redo stack (that would touch nearly every mutation in `WorkspaceViewModel`) — just single-level: `TryFindNearestElement` now also reports *what* it's about to remove, `DeleteNearestElementAsync` stashes `(Floor, object Item)` before removing, and a new `UndoDeleteCommand` re-adds it. An `InfoBar` with an Undo action button appears after a delete (`CanUndoDelete`/`LastDeletedItemDisplay`), overwritten by the next delete and cleared on floor switch so it never fires somewhere other than where the deletion happened. 5 new tests covering restore-after-delete for both a wall and a test point, overwrite-on-second-delete, and floor-switch clearing.
6 changes: 6 additions & 0 deletions src/Pulsemap.App/Services/LocalizationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ public string GetString(string key)
["WorkspaceRecaptureDialogContent"] = "This replaces its existing reading with a new scan from here.",
["WorkspaceRecaptureDialogPrimaryButton"] = "Recapture",
["WorkspaceRecaptureDialogCloseButton"] = "Cancel",
["WorkspaceUndoDeleteWallMessage"] = "Wall deleted.",
["WorkspaceUndoDeleteTestPointMessage"] = "Test point deleted.",
["WorkspaceUndoDeleteAccessPointMessage"] = "Access point deleted.",

["HomeUpdateAvailableFormat"] = "Pulsemap {0} is available.",
},
Expand Down Expand Up @@ -190,6 +193,9 @@ public string GetString(string key)
["WorkspaceRecaptureDialogContent"] = "Zastąpi to jego dotychczasowy odczyt nowym skanem z tego miejsca.",
["WorkspaceRecaptureDialogPrimaryButton"] = "Zarejestruj ponownie",
["WorkspaceRecaptureDialogCloseButton"] = "Anuluj",
["WorkspaceUndoDeleteWallMessage"] = "Usunięto ścianę.",
["WorkspaceUndoDeleteTestPointMessage"] = "Usunięto punkt pomiarowy.",
["WorkspaceUndoDeleteAccessPointMessage"] = "Usunięto punkt dostępowy.",

["HomeUpdateAvailableFormat"] = "Dostępny jest Pulsemap {0}.",
},
Expand Down
3 changes: 3 additions & 0 deletions src/Pulsemap.App/Strings/en-US/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,9 @@
<data name="WorkspaceClearWallSelectionButton.Content" xml:space="preserve">
<value>Clear selection</value>
</data>
<data name="WorkspaceUndoDeleteButton.Content" xml:space="preserve">
<value>Undo</value>
</data>

<!-- Snapshot comparison page -->
<data name="SnapshotComparisonTitle.Text" xml:space="preserve">
Expand Down
3 changes: 3 additions & 0 deletions src/Pulsemap.App/Strings/pl-PL/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,9 @@
<data name="WorkspaceClearWallSelectionButton.Content" xml:space="preserve">
<value>Wyczyść zaznaczenie</value>
</data>
<data name="WorkspaceUndoDeleteButton.Content" xml:space="preserve">
<value>Cofnij</value>
</data>

<!-- Snapshot comparison page -->
<data name="SnapshotComparisonTitle.Text" xml:space="preserve">
Expand Down
61 changes: 59 additions & 2 deletions src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -477,16 +477,64 @@ public async Task UpdateOutdoorBoundsAsync(Point2D min, Point2D max)
await SaveAndRefreshAsync();
}

// Single-level undo, scoped to the Delete tool only — the highest-risk edit in the app since
// it's the one permanent, unconfirmed action on the canvas. Not a general undo/redo stack:
// overwritten by the next delete, and cleared on floor switch so it never applies somewhere
// other than where the deletion actually happened.
private (Floor Floor, object Item)? _lastDeletedElement;

public bool CanUndoDelete => _lastDeletedElement is not null;

public string LastDeletedItemDisplay => _lastDeletedElement?.Item switch
{
Wall => _localizationService.GetString("WorkspaceUndoDeleteWallMessage"),
TestPoint => _localizationService.GetString("WorkspaceUndoDeleteTestPointMessage"),
AccessPoint => _localizationService.GetString("WorkspaceUndoDeleteAccessPointMessage"),
_ => string.Empty,
};

public async Task DeleteNearestElementAsync(Point2D at)
{
if (SelectedFloor is null || !TryFindNearestElement(SelectedFloor, at, out var remove))
if (SelectedFloor is null || !TryFindNearestElement(SelectedFloor, at, out var remove, out var removedItem))
{
return;
}

remove();
_selectedWalls.RemoveAll(wall => !SelectedFloor.Walls.Contains(wall));
SelectedWallCount = _selectedWalls.Count;
_lastDeletedElement = removedItem is not null ? (SelectedFloor, removedItem) : null;
OnPropertyChanged(nameof(CanUndoDelete));
OnPropertyChanged(nameof(LastDeletedItemDisplay));
UndoDeleteCommand.NotifyCanExecuteChanged();
await SaveAndRefreshAsync();
}

[RelayCommand(CanExecute = nameof(CanUndoDelete))]
private async Task UndoDeleteAsync()
{
if (_lastDeletedElement is not { } deleted)
{
return;
}

switch (deleted.Item)
{
case Wall wall:
deleted.Floor.Walls.Add(wall);
break;
case TestPoint testPoint:
deleted.Floor.TestPoints.Add(testPoint);
break;
case AccessPoint accessPoint:
deleted.Floor.AccessPoints.Add(accessPoint);
break;
}

_lastDeletedElement = null;
OnPropertyChanged(nameof(CanUndoDelete));
OnPropertyChanged(nameof(LastDeletedItemDisplay));
UndoDeleteCommand.NotifyCanExecuteChanged();
await SaveAndRefreshAsync();
}

Expand Down Expand Up @@ -716,6 +764,10 @@ partial void OnSelectedFloorChanged(Floor? value)
{
ClearWallSelection();

_lastDeletedElement = null;
OnPropertyChanged(nameof(CanUndoDelete));
UndoDeleteCommand.NotifyCanExecuteChanged();

_guidedWalkQueue.Clear();
IsGuidedWalkActive = false;
CurrentWalkPoint = null;
Expand Down Expand Up @@ -761,7 +813,7 @@ private void Recompute()
: Heatmap.Count(s => s.ValueDbm >= ReliableCoverageThresholdDbm) / (double)Heatmap.Count * 100;
}

private static bool TryFindNearestElement(Floor floor, Point2D at, out Action remove)
private static bool TryFindNearestElement(Floor floor, Point2D at, out Action remove, out object? removedItem)
{
var (nearestTestPoint, nearestTestPointDist) = NearestTestPoint(floor, at);
var (nearestAccessPoint, nearestAccessPointDist) = NearestAccessPoint(floor, at);
Expand All @@ -771,24 +823,29 @@ private static bool TryFindNearestElement(Floor floor, Point2D at, out Action re
if (best > DeleteHitToleranceMeters)
{
remove = static () => { };
removedItem = null;
return false;
}

if (best == nearestTestPointDist && nearestTestPoint is not null)
{
remove = () => floor.TestPoints.Remove(nearestTestPoint);
removedItem = nearestTestPoint;
}
else if (best == nearestAccessPointDist && nearestAccessPoint is not null)
{
remove = () => floor.AccessPoints.Remove(nearestAccessPoint);
removedItem = nearestAccessPoint;
}
else if (nearestWall is not null)
{
remove = () => floor.Walls.Remove(nearestWall);
removedItem = nearestWall;
}
else
{
remove = static () => { };
removedItem = null;
return false;
}

Expand Down
11 changes: 11 additions & 0 deletions src/Pulsemap.App/Views/WorkspacePage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@

<InfoBar Grid.Row="1" Severity="Error" IsOpen="{x:Bind ViewModel.HasError, Mode=OneWay}" Message="{x:Bind ViewModel.ErrorMessage, Mode=OneWay}" Margin="0,12,0,0" />

<InfoBar
Grid.Row="1"
Severity="Informational"
IsOpen="{x:Bind ViewModel.CanUndoDelete, Mode=OneWay}"
Message="{x:Bind ViewModel.LastDeletedItemDisplay, Mode=OneWay}"
Margin="0,12,0,0">
<InfoBar.ActionButton>
<Button x:Uid="WorkspaceUndoDeleteButton" Command="{x:Bind ViewModel.UndoDeleteCommand}" />
</InfoBar.ActionButton>
</InfoBar>

<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8" Margin="0,20,0,0" VerticalAlignment="Center">
<ToggleButton x:Uid="WorkspaceSelectToolButton" x:Name="SelectToolButton" Checked="ToolToggle_Checked" />
<ToggleButton x:Uid="WorkspaceAddTestPointToolButton" x:Name="AddTestPointToolButton" Checked="ToolToggle_Checked" />
Expand Down
84 changes: 84 additions & 0 deletions tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,90 @@ public async Task DeleteNearestElementAsync_OutsideTolerance_DoesNothing()
Assert.Single(sut.SelectedFloor!.TestPoints);
}

[Fact]
public async Task DeleteNearestElementAsync_RemovesElement_CanUndoDeleteBecomesTrue()
{
var floor = SquareRoomFloor(10);
floor.TestPoints.Add(new TestPoint { Position = new Point2D(5, 5) });
var sut = await LoadedViewModelAsync(floor);

await sut.DeleteNearestElementAsync(new Point2D(5.1, 5.1));

Assert.True(sut.CanUndoDelete);
Assert.True(sut.UndoDeleteCommand.CanExecute(null));
}

[Fact]
public async Task UndoDeleteCommand_AfterDeletingTestPoint_RestoresIt()
{
var floor = SquareRoomFloor(10);
var testPoint = new TestPoint { Position = new Point2D(5, 5) };
floor.TestPoints.Add(testPoint);
var sut = await LoadedViewModelAsync(floor);
await sut.DeleteNearestElementAsync(new Point2D(5.1, 5.1));

await sut.UndoDeleteCommand.ExecuteAsync(null);

Assert.Same(testPoint, Assert.Single(sut.SelectedFloor!.TestPoints));
Assert.False(sut.CanUndoDelete);
}

[Fact]
public async Task UndoDeleteCommand_AfterDeletingWall_RestoresIt()
{
var floor = SquareRoomFloor(10);
var wallToDelete = floor.Walls[0];
var midpoint = new Point2D((wallToDelete.Start.X + wallToDelete.End.X) / 2, (wallToDelete.Start.Y + wallToDelete.End.Y) / 2);
var sut = await LoadedViewModelAsync(floor);
await sut.DeleteNearestElementAsync(midpoint);
Assert.DoesNotContain(wallToDelete, sut.SelectedFloor!.Walls);

await sut.UndoDeleteCommand.ExecuteAsync(null);

Assert.Contains(wallToDelete, sut.SelectedFloor!.Walls);
}

[Fact]
public async Task DeleteNearestElementAsync_SecondDelete_OverwritesUndoWithTheNewerOne()
{
var floor = SquareRoomFloor(10);
var firstPoint = new TestPoint { Position = new Point2D(5, 5) };
var secondPoint = new TestPoint { Position = new Point2D(6, 6) };
floor.TestPoints.Add(firstPoint);
floor.TestPoints.Add(secondPoint);
var sut = await LoadedViewModelAsync(floor);
await sut.DeleteNearestElementAsync(new Point2D(5.1, 5.1));

await sut.DeleteNearestElementAsync(new Point2D(6.1, 6.1));
await sut.UndoDeleteCommand.ExecuteAsync(null);

var remaining = Assert.Single(sut.SelectedFloor!.TestPoints);
Assert.Same(secondPoint, remaining);
}

[Fact]
public async Task SwitchingFloor_ClearsUndoDeleteState()
{
var floorA = SquareRoomFloor(10);
floorA.TestPoints.Add(new TestPoint { Position = new Point2D(5, 5) });
var floorB = new Floor { PlanSource = new RoomListSource() };
_surveyFileService.SurveyToReturn = new Survey
{
Name = "Test Survey",
Type = SurveyType.NewDeployment,
TargetBands = [Band.TwoPointFourGhz],
Floors = [floorA, floorB],
};
var sut = CreateSut();
await sut.LoadAsync(FilePath);
await sut.DeleteNearestElementAsync(new Point2D(5.1, 5.1));
Assert.True(sut.CanUndoDelete);

sut.SelectedFloor = floorB;

Assert.False(sut.CanUndoDelete);
}

[Fact]
public async Task ToggleWallSelection_TogglesMembershipAndCount()
{
Expand Down
Loading