diff --git a/CHANGELOG.md b/CHANGELOG.md index 312e9c3..0dc4c48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added +- Undo and redo buttons in Home edit mode for stepping through layout changes. + +### Changed +- The Cancel button is gone from edit mode: tap Done or anywhere outside the grid to keep your changes, and use undo to revert individual edits. +- The resize badge now appears on every tile in edit mode, not just the selected one. +- Tiles wiggle uniformly and out of phase in edit mode, like the iOS Home Screen. + +### Fixed +- Tiles did not wiggle when entering edit mode. +- Tiles now glide smoothly into place while drag-reordering instead of jumping. +- Layout edits are kept when leaving the Home tab mid-edit instead of being silently discarded. +- The tile resize button is easier to tap and is now labeled for VoiceOver. + ## [1.1] - 2026-05-15 Initial release. diff --git a/Hemera/UI/Components/EditableTileModifier.swift b/Hemera/UI/Components/EditableTileModifier.swift index 2182818..76e5e31 100644 --- a/Hemera/UI/Components/EditableTileModifier.swift +++ b/Hemera/UI/Components/EditableTileModifier.swift @@ -5,12 +5,11 @@ import TileGridEngine /// Combines wiggle animation and a resize button for tiles in edit mode. /// /// Mimics Apple's Home app: in edit mode, tiles wiggle and a small circular -/// resize button appears in the bottom-trailing corner. Tapping it toggles -/// the tile size between small and medium. +/// resize button appears in the bottom-trailing corner of every tile. +/// Tapping it toggles the tile size between small and medium. struct EditableTileModifier: ViewModifier { let tile: Tile let isEditing: Bool - @Binding var selectedTileID: Tile.ID? let onResize: (TileSize) -> Void let onTap: () -> Void @@ -24,26 +23,20 @@ struct EditableTileModifier: ViewModifier { : "arrow.down.right.and.arrow.up.left" } - private var isSelected: Bool { - selectedTileID == tile.id - } - func body(content: Content) -> some View { content .allowsHitTesting(!isEditing) .overlay(alignment: .bottomTrailing) { - if isEditing && isSelected { + if isEditing { resizeButton } } .wiggle(isWiggling: isEditing, seed: tile.id.hashValue) .contentShape(Rectangle()) .onTapGesture { - if isEditing { - withAnimation(.easeInOut(duration: Mortar.Motion.normal)) { - selectedTileID = isSelected ? nil : tile.id - } - } else { + // In edit mode the gesture deliberately swallows the tap so it + // doesn't bubble to the grid's tap-outside-to-exit handler. + if !isEditing { onTap() } } @@ -55,34 +48,51 @@ struct EditableTileModifier: ViewModifier { Button { onResize(nextSize) } label: { - Image(systemName: resizeIcon) - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(.white) - .frame(width: Mortar.IconSize.s, height: Mortar.IconSize.s) - .background( - Circle() - .fill(.thinMaterial) - .environment(\.colorScheme, .dark) - ) + Label( + tile.size == .small ? Localization.makeLarger : Localization.makeSmaller, + systemImage: resizeIcon + ) + .labelStyle(.iconOnly) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white) + .frame(width: Mortar.IconSize.s, height: Mortar.IconSize.s) + .background( + Circle() + .fill(.thinMaterial) + .environment(\.colorScheme, .dark) + ) + // Minimum tap target (HIG) around the smaller visible circle. + .frame(width: Mortar.TapTarget.minimum, height: Mortar.TapTarget.minimum) + .contentShape(Rectangle()) } .buttonStyle(.plain) - .padding(6) .transition(.scale.combined(with: .opacity)) } } +private extension EditableTileModifier { + enum Localization { + static let makeLarger = String( + localized: "Make Larger", + comment: "Accessibility label for the tile resize button when tapping it enlarges the tile" + ) + static let makeSmaller = String( + localized: "Make Smaller", + comment: "Accessibility label for the tile resize button when tapping it shrinks the tile" + ) + } +} + extension View { func editableTile( _ tile: Tile, isEditing: Bool, - selectedTileID: Binding, onResize: @escaping (TileSize) -> Void, onTap: @escaping () -> Void ) -> some View { modifier(EditableTileModifier( tile: tile, isEditing: isEditing, - selectedTileID: selectedTileID, onResize: onResize, onTap: onTap )) diff --git a/Hemera/UI/Screens/Home/CuratedHomeView.swift b/Hemera/UI/Screens/Home/CuratedHomeView.swift index 410b390..ebb5ace 100644 --- a/Hemera/UI/Screens/Home/CuratedHomeView.swift +++ b/Hemera/UI/Screens/Home/CuratedHomeView.swift @@ -4,7 +4,7 @@ import Mortar import TileGridEngine struct CuratedHomeView: View { - @Bindable var viewModel: CuratedHomeViewModel + let viewModel: CuratedHomeViewModel @Namespace private var overlayTransition @Query(sort: \HomeTile.sortOrder) private var homeTiles: [HomeTile] @@ -82,14 +82,7 @@ struct CuratedHomeView: View { .navigationTitle(Localization.home) .toolbar { if viewModel.isEditing { - ToolbarItem(placement: .cancellationAction) { - Button(Localization.cancel) { - withAnimation { - viewModel.exitEditMode(commit: false) - } - } - } - ToolbarItem(placement: .topBarLeading) { + ToolbarItemGroup(placement: .topBarLeading) { Button { withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { viewModel.performUndo() @@ -100,6 +93,17 @@ struct CuratedHomeView: View { .labelStyle(.iconOnly) } .disabled(!viewModel.canUndo) + + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + viewModel.performRedo() + } + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } label: { + Label(Localization.redo, systemImage: "arrow.uturn.forward") + .labelStyle(.iconOnly) + } + .disabled(!viewModel.canRedo) } ToolbarItem(placement: .topBarTrailing) { Button(Localization.done) { @@ -122,6 +126,13 @@ struct CuratedHomeView: View { .task { viewModel.startFirstLoadTimer(tileCount: homeTiles.count) } + .onDisappear { + // Leaving the tab mid-edit would otherwise strand the draft; + // commit, matching the tap-outside-to-exit behavior. + if viewModel.isEditing { + viewModel.exitEditMode(commit: true) + } + } } } @@ -134,14 +145,13 @@ private extension CuratedHomeView { if let entityId = entityIdByTileId[tile.id], let vm = viewModel.viewModelFactory.makeViewModel(forEntityId: entityId) { let tileIndex = displayedTiles.firstIndex(where: { $0.id == tile.id }) ?? 0 - vm.makeCardView() + let card = vm.makeCardView() .matchedTransitionSource(id: vm.id, in: overlayTransition) .tileEntrance(index: tileIndex, isActive: viewModel.isFirstLoad) .environment(\.isMediumTile, tile.size == .medium) .editableTile( tile, isEditing: viewModel.isEditing, - selectedTileID: $viewModel.selectedTileID, onResize: { targetSize in withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { viewModel.resizeTile(tile, to: targetSize) @@ -154,30 +164,35 @@ private extension CuratedHomeView { } } ) - .contextMenu { - if !viewModel.isEditing { - Button { - withAnimation { - viewModel.enterEditMode(homeTiles: homeTiles) - } - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } label: { - Label(Localization.editLayout, systemImage: "square.and.pencil") - } - if !viewModel.isDemoMode { - Button { - viewModel.openEntityInHA(entityId: vm.id, deviceId: vm.deviceId) - } label: { - Label(Localization.openInHomeAssistant, systemImage: "globe") - } + + // Detached (not emptied) while editing so its long-press + // recognizer doesn't compete with the reorder drag. + if viewModel.isEditing { + card + } else { + card.contextMenu { + Button { + withAnimation { + viewModel.enterEditMode(homeTiles: homeTiles) } - Button(role: .destructive) { - viewModel.removeFromHome(entityId: vm.id) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } label: { + Label(Localization.editLayout, systemImage: "square.and.pencil") + } + if !viewModel.isDemoMode { + Button { + viewModel.openEntityInHA(entityId: vm.id, deviceId: vm.deviceId) } label: { - Label(Localization.removeFromHome, systemImage: "minus.circle") + Label(Localization.openInHomeAssistant, systemImage: "globe") } } + Button(role: .destructive) { + viewModel.removeFromHome(entityId: vm.id) + } label: { + Label(Localization.removeFromHome, systemImage: "minus.circle") + } } + } } else { EntityCard { Text(tile.title) @@ -189,9 +204,9 @@ private extension CuratedHomeView { private extension CuratedHomeView { enum Localization { static let home = String(localized: "Home", comment: "Navigation title for the Home screen showing pinned entities") - static let cancel = String(localized: "Cancel", comment: "Button to cancel layout editing and discard changes") - static let done = String(localized: "Done", comment: "Button to dismiss the current screen") + static let done = String(localized: "Done", comment: "Button to commit layout edits and leave edit mode on the Home screen") static let undo = String(localized: "Undo", comment: "Toolbar button to undo the last layout edit") + static let redo = String(localized: "Redo", comment: "Toolbar button to re-apply the last undone layout edit") static let editLayout = String(localized: "Edit Layout", comment: "Context menu action to enter tile layout editing mode") static let removeFromHome = String(localized: "Remove from Home", comment: "Context menu action to unpin an entity from the Home screen") static let openInHomeAssistant = String(localized: "Open in Home Assistant", comment: "Context menu action to open an entity in the Home Assistant web interface") diff --git a/Hemera/UI/Screens/Home/CuratedHomeViewModel.swift b/Hemera/UI/Screens/Home/CuratedHomeViewModel.swift index 072ac69..06ecae3 100644 --- a/Hemera/UI/Screens/Home/CuratedHomeViewModel.swift +++ b/Hemera/UI/Screens/Home/CuratedHomeViewModel.swift @@ -10,8 +10,8 @@ final class CuratedHomeViewModel { private(set) var isEditing = false private(set) var draftTiles: [Tile]? private(set) var undoStack: [EditAction] = [] + private(set) var redoStack: [EditAction] = [] private(set) var isFirstLoad = true - var selectedTileID: Tile.ID? let isDemoMode: Bool let haWebViewPresenter: HAWebViewPresenter @@ -26,6 +26,10 @@ final class CuratedHomeViewModel { !undoStack.isEmpty } + var canRedo: Bool { + !redoStack.isEmpty + } + /// Captured at `enterEditMode` time so commit can map tile IDs back to /// entity IDs without re-querying the live data. private var entityIdByTileId: [Tile.ID: String] = [:] @@ -99,7 +103,7 @@ final class CuratedHomeViewModel { draftTiles = entries.map(\.tile) entityIdByTileId = Dictionary(uniqueKeysWithValues: entries.map { ($0.tile.id, $0.entityId) }) undoStack = [] - selectedTileID = nil + redoStack = [] isEditing = true } @@ -119,7 +123,7 @@ final class CuratedHomeViewModel { draftTiles = nil entityIdByTileId = [:] undoStack = [] - selectedTileID = nil + redoStack = [] isEditing = false } @@ -133,6 +137,7 @@ final class CuratedHomeViewModel { /// pre-reorder order. func recordReorderUndo(previousOrder: [Tile.ID]) { undoStack.append(.reorder(previousOrder: previousOrder)) + redoStack = [] } func resizeTile(_ tile: Tile, to targetSize: TileSize) { @@ -142,22 +147,38 @@ final class CuratedHomeViewModel { let oldSize = tiles[index].size guard oldSize != targetSize else { return } undoStack.append(.resize(tileID: tile.id, oldSize: oldSize)) + redoStack = [] tiles[index].size = targetSize draftTiles = tiles } func performUndo() { - guard let action = undoStack.popLast(), var tiles = draftTiles else { return } + guard let action = undoStack.popLast(), let inverse = apply(action) else { return } + redoStack.append(inverse) + } + + func performRedo() { + guard let action = redoStack.popLast(), let inverse = apply(action) else { return } + undoStack.append(inverse) + } + + /// Applies an edit action to the draft and returns its inverse, so undo + /// and redo are the same operation walking opposite stacks. + private func apply(_ action: EditAction) -> EditAction? { + guard var tiles = draftTiles else { return nil } + let inverse: EditAction switch action { case .resize(let tileID, let oldSize): - if let index = tiles.firstIndex(where: { $0.id == tileID }) { - tiles[index].size = oldSize - } + guard let index = tiles.firstIndex(where: { $0.id == tileID }) else { return nil } + inverse = .resize(tileID: tileID, oldSize: tiles[index].size) + tiles[index].size = oldSize case .reorder(let previousOrder): + inverse = .reorder(previousOrder: tiles.map(\.id)) let byID = Dictionary(uniqueKeysWithValues: tiles.map { ($0.id, $0) }) tiles = previousOrder.compactMap { byID[$0] } } draftTiles = tiles + return inverse } // MARK: - First-load Animation diff --git a/Hemera/UI/TileGrid/SectionGrid.swift b/Hemera/UI/TileGrid/SectionGrid.swift index f558e48..61c0928 100644 --- a/Hemera/UI/TileGrid/SectionGrid.swift +++ b/Hemera/UI/TileGrid/SectionGrid.swift @@ -1,4 +1,5 @@ import SwiftUI +import Mortar import TileGridEngine /// Renders a single section of tiles using TileGridEngine for reordering. @@ -67,6 +68,7 @@ struct SectionGrid: View { content(tile) .frame(width: size.width, height: size.height) .offset(x: origin.x, y: origin.y) + .animation(Mortar.Motion.springSnappy, value: origin) .opacity(0.25) .allowsHitTesting(false) } @@ -108,6 +110,10 @@ struct SectionGrid: View { content(tile) .frame(width: size.width, height: size.height) .offset(x: visualOrigin.x, y: visualOrigin.y) + .animation( + isDragging || !isEditing ? nil : Mortar.Motion.springSnappy, + value: CGRect(origin: visualOrigin, size: size) + ) .scaleEffect(isDragging ? 1.03 : 1.0) .shadow(radius: isDragging ? 8 : 0) .zIndex(isDragging ? 1 : 0) diff --git a/HemeraTests/UI/Screens/Home/CuratedHomeViewModelTests.swift b/HemeraTests/UI/Screens/Home/CuratedHomeViewModelTests.swift index 8c458f2..8474e9a 100644 --- a/HemeraTests/UI/Screens/Home/CuratedHomeViewModelTests.swift +++ b/HemeraTests/UI/Screens/Home/CuratedHomeViewModelTests.swift @@ -171,6 +171,67 @@ struct CuratedHomeViewModelTests { #expect(vm.draftTiles?.map(\.id) == [tileA.id, tileB.id]) } + @Test + func performRedo_afterUndoingResize_reappliesTheResize() { + let vm = makeViewModel() + let tile = makeTile(entityId: "light.a", size: .small) + vm.enterEditMode(seed: [(tile, "light.a")]) + vm.resizeTile(tile, to: .medium) + vm.performUndo() + #expect(vm.canRedo == true) + + vm.performRedo() + + #expect(vm.draftTiles?.first?.size == .medium) + #expect(vm.canRedo == false) + #expect(vm.canUndo == true) + } + + @Test + func performRedo_afterUndoingReorder_reappliesTheOrder() { + let vm = makeViewModel() + let tileA = makeTile(entityId: "light.a") + let tileB = makeTile(entityId: "light.b") + vm.enterEditMode(seed: [(tileA, "light.a"), (tileB, "light.b")]) + vm.recordReorderUndo(previousOrder: [tileA.id, tileB.id]) + vm.applyReorder([tileB, tileA]) + vm.performUndo() + + vm.performRedo() + + #expect(vm.draftTiles?.map(\.id) == [tileB.id, tileA.id]) + } + + @Test + func newEdit_afterUndo_clearsRedoStack() { + let vm = makeViewModel() + let tile = makeTile(entityId: "light.a", size: .small) + vm.enterEditMode(seed: [(tile, "light.a")]) + vm.resizeTile(tile, to: .medium) + vm.performUndo() + #expect(vm.canRedo == true) + + vm.resizeTile(tile, to: .large) + + #expect(vm.canRedo == false) + } + + @Test + func undoRedo_roundTrip_isLossless() { + let vm = makeViewModel() + let tile = makeTile(entityId: "light.a", size: .small) + vm.enterEditMode(seed: [(tile, "light.a")]) + vm.resizeTile(tile, to: .medium) + + vm.performUndo() + vm.performRedo() + vm.performUndo() + + #expect(vm.draftTiles?.first?.size == .small) + #expect(vm.canUndo == false) + #expect(vm.canRedo == true) + } + // MARK: - Remove From Home @Test diff --git a/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift b/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift index 4bc8fd1..aa2ff7c 100644 --- a/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift +++ b/Packages/Mortar/Sources/Mortar/Modifiers/WiggleModifier.swift @@ -4,23 +4,20 @@ public struct WiggleModifier: ViewModifier { let isWiggling: Bool let seed: Int - @State private var animating = false - @State private var started = false @Environment(\.accessibilityReduceMotion) private var reduceMotion - private var angle: Double { - let base = 1.5 + Double(seed % 3) * 0.5 // 1.5-2.5 range - return animating ? base : -base - } + /// Peak rotation, identical for every tile so they all sweep the same + /// distance. + private static let angleBase = 2.0 - /// Staggered delay so tiles don't all start wiggling at once. - private var staggerDelay: Double { - let hash = abs(seed) - return Double(hash % 250) / 1000.0 - } + /// One full oscillation (a sweep there and back), identical for every + /// tile so they all wiggle at the same frequency. + private static let cycleDuration = 0.28 - private var wiggleDuration: Double { - 0.12 + Double(seed % 3) * 0.02 + /// Per-seed offset into the oscillation cycle, giving each tile a + /// distinct, stable phase. + private var phase: Double { + Double(seed.magnitude % 100) / 100.0 * 2 * .pi } public func body(content: Content) -> some View { @@ -34,41 +31,24 @@ public struct WiggleModifier: ViewModifier { } } } else { - content - .rotationEffect(.degrees(isWiggling && started ? angle : 0)) - .onChange(of: isWiggling) { _, newValue in - if newValue { - startWiggleWithDelay() - } else { - stopWiggle() - } - } - .onAppear { - if isWiggling { - startWiggleWithDelay() - } - } - } - } - - private func startWiggleWithDelay() { - DispatchQueue.main.asyncAfter(deadline: .now() + staggerDelay) { - guard isWiggling else { return } - started = true - withAnimation( - .easeInOut(duration: wiggleDuration) - .repeatForever(autoreverses: true) - ) { - animating = true + // Rotation is a pure function of wall-clock time, so the wiggle + // needs no stored animation state: it survives view identity + // changes and can never end up statically stuck mid-tilt (the + // failure mode of toggling a `repeatForever` from a task). + // Start/stop transitions inherit the caller's `withAnimation`. + // 60Hz cap: each tick re-evaluates on the main thread per tile, + // and a 0.28s sine cycle gains nothing from ProMotion's 120Hz. + TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: !isWiggling)) { context in + content + .rotationEffect(.degrees(angle(at: context.date))) } } } - private func stopWiggle() { - withAnimation(.easeOut(duration: Mortar.Motion.fast)) { - animating = false - started = false - } + private func angle(at date: Date) -> Double { + guard isWiggling else { return 0 } + let t = date.timeIntervalSinceReferenceDate + return sin((t / Self.cycleDuration) * 2 * .pi + phase) * Self.angleBase } } diff --git a/Packages/Mortar/Sources/Mortar/Tokens/TapTarget.swift b/Packages/Mortar/Sources/Mortar/Tokens/TapTarget.swift new file mode 100644 index 0000000..66bf36d --- /dev/null +++ b/Packages/Mortar/Sources/Mortar/Tokens/TapTarget.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Mortar { + public enum TapTarget { + /// Minimum tappable area for any control (Apple HIG). + public static let minimum: CGFloat = 44 + } +}