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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
60 changes: 35 additions & 25 deletions Hemera/UI/Components/EditableTileModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
}
}
Expand All @@ -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<Tile.ID?>,
onResize: @escaping (TileSize) -> Void,
onTap: @escaping () -> Void
) -> some View {
modifier(EditableTileModifier(
tile: tile,
isEditing: isEditing,
selectedTileID: selectedTileID,
onResize: onResize,
onTap: onTap
))
Expand Down
79 changes: 47 additions & 32 deletions Hemera/UI/Screens/Home/CuratedHomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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()
Expand All @@ -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) {
Expand All @@ -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)
}
}
}
}

Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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")
Expand Down
35 changes: 28 additions & 7 deletions Hemera/UI/Screens/Home/CuratedHomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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] = [:]
Expand Down Expand Up @@ -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
}

Expand All @@ -119,7 +123,7 @@ final class CuratedHomeViewModel {
draftTiles = nil
entityIdByTileId = [:]
undoStack = []
selectedTileID = nil
redoStack = []
isEditing = false
}

Expand All @@ -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) {
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Hemera/UI/TileGrid/SectionGrid.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import Mortar
import TileGridEngine

/// Renders a single section of tiles using TileGridEngine for reordering.
Expand Down Expand Up @@ -67,6 +68,7 @@ struct SectionGrid<Content: View>: 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)
}
Expand Down Expand Up @@ -108,6 +110,10 @@ struct SectionGrid<Content: View>: 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)
Expand Down
Loading
Loading