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
6 changes: 6 additions & 0 deletions Hemera/Entities/Automation/UI/AutomationCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ final class AutomationCardViewModel: Identifiable {
extension AutomationCardViewModel {
static func registration(controller: AutomationControlling) -> ViewModelFactory.Registration {
ViewModelFactory.Registration(
domain: AutomationEntity.domain,
makeViewModelsForArea: { area in
area.automations.sorted(by: { $0.entityId < $1.entityId }).map {
AutomationCardViewModel(automation: $0, controller: controller)
Expand All @@ -65,6 +66,9 @@ extension AutomationCardViewModel {
makeViewModelForEntityId: { entityId, context in
guard let automation = AutomationEntity.fetch(byId: entityId, in: context) else { return nil }
return AutomationCardViewModel(automation: automation, controller: controller)
},
entityExists: { entityId, context in
AutomationEntity.fetch(byId: entityId, in: context) != nil
}
)
}
Expand All @@ -77,6 +81,8 @@ extension AutomationCardViewModel: EntityCardViewModel {
AnyView(AutomationCard(viewModel: self))
}

var hasOverlay: Bool { true }

func makeOverlayView(isPresented: Binding<Bool>) -> AnyView? {
AnyView(AutomationControlPanel(viewModel: self, isPresented: isPresented))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ final class BinarySensorCardViewModel: Identifiable {
extension BinarySensorCardViewModel {
static func registration() -> ViewModelFactory.Registration {
ViewModelFactory.Registration(
domain: BinarySensorEntity.domain,
makeViewModelsForArea: { area in
area.binarySensors.sorted(by: { $0.entityId < $1.entityId }).map {
BinarySensorCardViewModel(binarySensor: $0)
Expand All @@ -70,6 +71,9 @@ extension BinarySensorCardViewModel {
makeViewModelForEntityId: { entityId, context in
guard let binarySensor = BinarySensorEntity.fetch(byId: entityId, in: context) else { return nil }
return BinarySensorCardViewModel(binarySensor: binarySensor)
},
entityExists: { entityId, context in
BinarySensorEntity.fetch(byId: entityId, in: context) != nil
}
)
}
Expand Down
4 changes: 4 additions & 0 deletions Hemera/Entities/Button/UI/ButtonCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ final class ButtonCardViewModel: Identifiable {
extension ButtonCardViewModel {
static func registration(controller: ButtonControlling) -> ViewModelFactory.Registration {
ViewModelFactory.Registration(
domain: ButtonEntity.domain,
makeViewModelsForArea: { area in
area.buttons.filter { $0.deviceClass.isUserActionable }.sorted(by: { $0.entityId < $1.entityId }).map {
ButtonCardViewModel(button: $0, controller: controller)
Expand All @@ -76,6 +77,9 @@ extension ButtonCardViewModel {
guard let button = ButtonEntity.fetch(byId: entityId, in: context),
button.deviceClass.isUserActionable else { return nil }
return ButtonCardViewModel(button: button, controller: controller)
},
entityExists: { entityId, context in
ButtonEntity.fetch(byId: entityId, in: context) != nil
}
)
}
Expand Down
6 changes: 6 additions & 0 deletions Hemera/Entities/Climate/UI/ClimateCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ extension ClimateCardViewModel {
extension ClimateCardViewModel {
static func registration(controller: ClimateControlling) -> ViewModelFactory.Registration {
ViewModelFactory.Registration(
domain: ClimateEntity.domain,
makeViewModelsForArea: { area in
area.climates.sorted(by: { $0.entityId < $1.entityId }).map {
ClimateCardViewModel(climate: $0, controller: controller)
Expand All @@ -369,6 +370,9 @@ extension ClimateCardViewModel {
makeViewModelForEntityId: { entityId, context in
guard let climate = ClimateEntity.fetch(byId: entityId, in: context) else { return nil }
return ClimateCardViewModel(climate: climate, controller: controller)
},
entityExists: { entityId, context in
ClimateEntity.fetch(byId: entityId, in: context) != nil
}
)
}
Expand All @@ -381,6 +385,8 @@ extension ClimateCardViewModel: EntityCardViewModel {
AnyView(ClimateCard(viewModel: self))
}

var hasOverlay: Bool { true }

func makeOverlayView(isPresented: Binding<Bool>) -> AnyView? {
AnyView(ClimateControlPanel(viewModel: self, isPresented: isPresented))
}
Expand Down
6 changes: 6 additions & 0 deletions Hemera/Entities/Cover/UI/CoverCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ private extension CoverCardViewModel {
extension CoverCardViewModel {
static func registration(controller: CoverControlling) -> ViewModelFactory.Registration {
ViewModelFactory.Registration(
domain: CoverEntity.domain,
makeViewModelsForArea: { area in
area.covers.sorted(by: { $0.entityId < $1.entityId }).map {
CoverCardViewModel(cover: $0, controller: controller)
Expand All @@ -252,6 +253,9 @@ extension CoverCardViewModel {
makeViewModelForEntityId: { entityId, context in
guard let cover = CoverEntity.fetch(byId: entityId, in: context) else { return nil }
return CoverCardViewModel(cover: cover, controller: controller)
},
entityExists: { entityId, context in
CoverEntity.fetch(byId: entityId, in: context) != nil
}
)
}
Expand All @@ -264,6 +268,8 @@ extension CoverCardViewModel: EntityCardViewModel {
AnyView(CoverCard(viewModel: self))
}

var hasOverlay: Bool { true }

func makeOverlayView(isPresented: Binding<Bool>) -> AnyView? {
AnyView(CoverControlPanel(viewModel: self, isPresented: isPresented))
}
Expand Down
7 changes: 7 additions & 0 deletions Hemera/Entities/EntityCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ protocol EntityCardViewModel: AnyObject, Observable, Identifiable where ID == St
@ViewBuilder
func makeCardView() -> AnyView

/**
Whether this entity presents a detail overlay when its card body is tapped.
Must stay in sync with `makeOverlayView` — `true` exactly when it returns non-nil.
*/
var hasOverlay: Bool { get }

/// Creates the overlay view for this entity, if any.
/// Return `nil` for entities that have no detail overlay (e.g. scenes).
func makeOverlayView(isPresented: Binding<Bool>) -> AnyView?
Expand All @@ -29,6 +35,7 @@ protocol EntityCardViewModel: AnyObject, Observable, Identifiable where ID == St
}

extension EntityCardViewModel {
var hasOverlay: Bool { false }
func makeOverlayView(isPresented: Binding<Bool>) -> AnyView? { nil }
func performPrimaryAction() { }
}
Expand Down
2 changes: 1 addition & 1 deletion Hemera/Entities/Light/UI/LightCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ struct LightCard: View {
subtitle: viewModel.isOn ? Localization.on : Localization.off,
accessibilityIdentifier: viewModel.id)
} backgroundOverlay: {
if isMediumTile {
if isMediumTile && viewModel.isDimmable {
CardFillOverlay(fraction: fillFraction, fillColor: viewModel.tintColor, anchor: .bottom)
}
}
Expand Down
21 changes: 20 additions & 1 deletion Hemera/Entities/Light/UI/LightCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ final class LightCardViewModel: Identifiable {

var supportedModes: [LightControlMode] {
guard let modes = light.supportedColorModes else { return [.brightness] }
// A light whose only color mode is "onoff" has no brightness/color controls.
if Set(modes) == ["onoff"] { return [] }
var result: [LightControlMode] = [.brightness]
if modes.contains("color_temp") {
if light.minMireds != nil && light.maxMireds != nil {
Expand All @@ -66,6 +68,12 @@ final class LightCardViewModel: Identifiable {
return result
}

/**
Whether the light exposes a brightness control (i.e. is dimmable).
`false` for on/off-only lights, which drive neither the fill nor the overlay.
*/
var isDimmable: Bool { supportedModes.contains(.brightness) }

nonisolated let id: String
var name: String { light.name }
var isOn: Bool { light.isOn }
Expand Down Expand Up @@ -151,6 +159,7 @@ final class LightCardViewModel: Identifiable {
extension LightCardViewModel {
static func registration(controller: LightControlling) -> ViewModelFactory.Registration {
ViewModelFactory.Registration(
domain: LightEntity.domain,
makeViewModelsForArea: { area in
area.lights.sorted(by: { $0.entityId < $1.entityId }).map {
LightCardViewModel(light: $0, controller: controller)
Expand All @@ -159,6 +168,9 @@ extension LightCardViewModel {
makeViewModelForEntityId: { entityId, context in
guard let light = LightEntity.fetch(byId: entityId, in: context) else { return nil }
return LightCardViewModel(light: light, controller: controller)
},
entityExists: { entityId, context in
LightEntity.fetch(byId: entityId, in: context) != nil
}
)
}
Expand All @@ -171,7 +183,14 @@ extension LightCardViewModel: EntityCardViewModel {
AnyView(LightCard(viewModel: self))
}

/**
On/off-only lights have no controllable modes, so they present no overlay —
the card icon toggle is their only control. Keep in sync with `makeOverlayView`.
*/
var hasOverlay: Bool { !supportedModes.isEmpty }

func makeOverlayView(isPresented: Binding<Bool>) -> AnyView? {
AnyView(LightControlPanel(viewModel: self, isPresented: isPresented))
guard hasOverlay else { return nil }
return AnyView(LightControlPanel(viewModel: self, isPresented: isPresented))
}
}
4 changes: 4 additions & 0 deletions Hemera/Entities/Scene/UI/SceneCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ final class SceneCardViewModel: Identifiable {
extension SceneCardViewModel {
static func registration(controller: SceneControlling) -> ViewModelFactory.Registration {
ViewModelFactory.Registration(
domain: SceneEntity.domain,
makeViewModelsForArea: { area in
area.scenes.sorted(by: { $0.entityId < $1.entityId }).map {
SceneCardViewModel(scene: $0, controller: controller)
Expand All @@ -49,6 +50,9 @@ extension SceneCardViewModel {
makeViewModelForEntityId: { entityId, context in
guard let scene = SceneEntity.fetch(byId: entityId, in: context) else { return nil }
return SceneCardViewModel(scene: scene, controller: controller)
},
entityExists: { entityId, context in
SceneEntity.fetch(byId: entityId, in: context) != nil
}
)
}
Expand Down
6 changes: 6 additions & 0 deletions Hemera/Entities/Switch/UI/SwitchCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ final class SwitchCardViewModel: Identifiable {
extension SwitchCardViewModel {
static func registration(controller: SwitchControlling) -> ViewModelFactory.Registration {
ViewModelFactory.Registration(
domain: SwitchEntity.domain,
makeViewModelsForArea: { area in
area.switches.sorted(by: { $0.entityId < $1.entityId }).map {
SwitchCardViewModel(switchEntity: $0, controller: controller)
Expand All @@ -68,6 +69,9 @@ extension SwitchCardViewModel {
makeViewModelForEntityId: { entityId, context in
guard let switchEntity = SwitchEntity.fetch(byId: entityId, in: context) else { return nil }
return SwitchCardViewModel(switchEntity: switchEntity, controller: controller)
},
entityExists: { entityId, context in
SwitchEntity.fetch(byId: entityId, in: context) != nil
}
)
}
Expand All @@ -80,6 +84,8 @@ extension SwitchCardViewModel: EntityCardViewModel {
AnyView(SwitchCard(viewModel: self))
}

var hasOverlay: Bool { true }

func makeOverlayView(isPresented: Binding<Bool>) -> AnyView? {
AnyView(SwitchControlPanel(viewModel: self, isPresented: isPresented))
}
Expand Down
29 changes: 27 additions & 2 deletions Hemera/Entities/ViewModelFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ final class ViewModelFactory {

/// A pair of closures that create ViewModels for a single entity domain.
struct Registration {
/// The Home Assistant domain string this registration owns (e.g. `"light"`).
let domain: String
/// Creates all ViewModels for entities of this domain within an area.
let makeViewModelsForArea: @MainActor (AreaEntity) -> [any EntityCardViewModel]
/// Creates a ViewModel for a single entity by ID, if it exists in storage.
let makeViewModelForEntityId: @MainActor (String, ModelContext) -> (any EntityCardViewModel)?
/// Whether an entity of this domain with the given ID still exists in storage.
/// A cheap existence probe that does not allocate a ViewModel.
let entityExists: @MainActor (String, ModelContext) -> Bool
}

private var registrations: [Registration] = []
private var registrationsByDomain: [String: Registration] = [:]
private var cache: [String: any EntityCardViewModel] = [:]
private let context: ModelContext
/// Retains the container so its entities stay valid as long as this factory (and any VMs it created) are alive.
Expand All @@ -38,6 +44,7 @@ final class ViewModelFactory {
/// Registers a domain's ViewModel factory.
func register(_ registration: Registration) {
registrations.append(registration)
registrationsByDomain[registration.domain] = registration
}

/// Registers all built-in entity domain factories.
Expand Down Expand Up @@ -78,7 +85,16 @@ final class ViewModelFactory {

/// Returns the cached VM for the entity, or creates one by looking it up in storage.
func makeViewModel(forEntityId entityId: String) -> (any EntityCardViewModel)? {
if let cached = cache[entityId] { return cached }
if let cached = cache[entityId] {
/**
Re-confirm the backing @Model still exists before returning the cached
VM; if the entity was deleted mid-session, drop the stale VM so callers
never touch an invalidated @Model. The cached instance is still returned
for live entities, preserving ephemeral interaction state.
*/
if entityExists(entityId) { return cached }
cache[entityId] = nil
}
for registration in registrations {
if let vm = registration.makeViewModelForEntityId(entityId, context) {
cache[entityId] = vm
Expand All @@ -88,6 +104,15 @@ final class ViewModelFactory {
return nil
}

/// Whether the entity backing a cached VM still exists in storage. Targets the
/// single registration owning the id's domain (HA ids are `"<domain>.<object>"`)
/// so the probe is one cheap fetch, not a fan-out across every domain.
private func entityExists(_ entityId: String) -> Bool {
let domain = String(entityId.prefix { $0 != "." })
guard let registration = registrationsByDomain[domain] else { return false }
return registration.entityExists(entityId, context)
}

/// If a VM for this entityId is already cached, return the cached instance;
/// otherwise adopt the freshly-built one.
private func cachedOrAdopt(_ fresh: any EntityCardViewModel) -> any EntityCardViewModel {
Expand All @@ -103,7 +128,7 @@ final class ViewModelFactory {
/// is unavailable or its primary action was invoked in place.
func handleCardTap(entityId: String) -> (any EntityCardViewModel)? {
guard let vm = makeViewModel(forEntityId: entityId), vm.isAvailable else { return nil }
if vm.makeOverlayView(isPresented: .constant(true)) != nil {
if vm.hasOverlay {
return vm
}
vm.performPrimaryAction()
Expand Down
9 changes: 9 additions & 0 deletions HemeraTests/Entities/AutomationCardViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ struct AutomationCardViewModelTests {
#expect(vm.iconName == "gearshape.2.fill")
}

// MARK: - Has Overlay

@Test
func hasOverlay_isTrue() {
let vm = makeViewModel(state: .on)
#expect(vm.hasOverlay)
#expect(vm.makeOverlayView(isPresented: .constant(true)) != nil)
}

// MARK: - Helpers

private func makeViewModel(state: AutomationEntity.State) -> AutomationCardViewModel {
Expand Down
Loading
Loading