From 03c47df319085fdfb91fefffeaf9bb6abead9a52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:39:47 +0100 Subject: [PATCH 01/16] Add usage prediction common control section to Home Introduces a new Usage Prediction Common Control section at the top of the Home view, displaying commonly used entities. Adds HAUsagePredictionCommonControl model, request, and integration in HomeViewModel. Also moves entity and area registry models to a dedicated Models folder. --- .../ExperimentalSpace/Home/HomeView.swift | 112 +++++------------- .../Home/HomeViewModel.swift | 52 +------- .../HAUsagePredictionCommonControl.swift | 8 ++ 3 files changed, 41 insertions(+), 131 deletions(-) diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift index 2c4e371adc..c44d9dd628 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift @@ -121,95 +121,45 @@ struct HomeView: View { sectionOrder: viewModel.configuration.sectionOrder, visibleSectionIds: viewModel.configuration.visibleSectionIds ) - let layout = viewModel.configuration.areasLayout ?? .list return ScrollView { - switch layout { - case .grid: - areasGridView(sections: filteredSections) - case .list: - areasListView(sections: filteredSections) - } - } - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - - private func areasListView(sections: [HomeViewModel.RoomSection]) -> some View { - LazyVStack( - alignment: .leading, - spacing: DesignSystem.Spaces.three - ) { - predictionSection - - // Display regular sections - ForEach(sections) { section in - let visibleEntities = visibleEntitiesForSection(section) - if !visibleEntities.isEmpty || viewModel.configuration.visibleSectionIds.contains(section.id) { - Section { - entityTilesGrid( - for: visibleEntities, - section: section - ) - } header: { - sectionHeader(section.name, section: section) + LazyVStack( + alignment: .leading, + spacing: DesignSystem.Spaces.three + ) { + // Display usage prediction common control section at the top + if let usagePredictionSection = viewModel.usagePredictionSection { + let visibleEntities = visibleEntitiesForSection(usagePredictionSection) + if !visibleEntities.isEmpty { + Section { + entityTilesGrid( + for: visibleEntities, + section: usagePredictionSection + ) + } header: { + sectionHeader(usagePredictionSection.name, section: usagePredictionSection) + } } } - } - } - .padding() - } - - private func areasGridView(sections: [HomeViewModel.RoomSection]) -> some View { - VStack(alignment: .leading, spacing: .zero) { - predictionSection - .padding([.top, .horizontal]) - VStack(alignment: .leading, spacing: DesignSystem.Spaces.two) { - EntityDisplayComponents.sectionHeader( - L10n.HomeView.Areas.title, - showChevron: false - ) - LazyVGrid( - columns: [ - GridItem(.adaptive(minimum: 100, maximum: 150), spacing: DesignSystem.Spaces.one), - ], - spacing: DesignSystem.Spaces.one - ) { - ForEach(sections) { section in - if !isReorderMode { - AreaGridButton( - section: section, - action: { - selectedRoom = (id: section.id, name: section.name) - } + // Display regular sections + ForEach(filteredSections) { section in + let visibleEntities = visibleEntitiesForSection(section) + if !visibleEntities.isEmpty || viewModel.configuration.visibleSectionIds.contains(section.id) { + Section { + entityTilesGrid( + for: visibleEntities, + section: section ) - .matchedTransitionSource(id: section.id, in: roomNameSpace) + } header: { + sectionHeader(section.name, section: section) } } } } .padding() } - } - - @ViewBuilder - private var predictionSection: some View { - if viewModel.configuration.showUsagePredictionSection { - // Display usage prediction common control section at the top - if let usagePredictionSection = viewModel.usagePredictionSection { - let visibleEntities = visibleEntitiesForSection(usagePredictionSection) - if !visibleEntities.isEmpty { - Section { - entityTilesGrid( - for: visibleEntities, - section: usagePredictionSection - ) - } header: { - sectionHeader(usagePredictionSection.name, section: usagePredictionSection) - } - } - } - } + .transition(.opacity.combined(with: .move(edge: .bottom))) } private func visibleEntitiesForSection(_ section: HomeViewModel.RoomSection) -> [HAEntity] { @@ -418,13 +368,7 @@ struct HomeView: View { @ViewBuilder private func sectionHeader(_ title: String, section: HomeViewModel.RoomSection) -> some View { Group { - // Handle usage prediction section separately (not in groupedEntities) - if section.id == HomeViewModel.usagePredictionSectionId { - EntityDisplayComponents.sectionHeader( - title, - showChevron: false - ) - } else if let section = viewModel.groupedEntities.first(where: { $0.name == title }) { + if let section = viewModel.groupedEntities.first(where: { $0.name == title }) { EntityDisplayComponents.sectionHeader( title, showChevron: true, diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift index c75b541dfd..fb3df383a9 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift @@ -11,14 +11,9 @@ final class HomeViewModel: ObservableObject { struct RoomSection: Identifiable, Equatable { let id: String let name: String - let icon: String? let entityIds: Set } - // MARK: - Constants - - static let usagePredictionSectionId = "usage-prediction-common-control" - var groupedEntities: [RoomSection] = [] var isLoading = false var errorMessage: String? @@ -38,10 +33,6 @@ final class HomeViewModel: ObservableObject { } } - var cachedUserName: String = "" - private var lastUsagePredictionLoadTime: Date? - private let usagePredictionLoadInterval: TimeInterval = 120 // 2 minutes - var orderedSectionsForMenu: [RoomSection] { // Use the same ordering logic as filteredSections, but show ALL sections (no filtering) if configuration.sectionOrder.isEmpty { @@ -63,9 +54,8 @@ final class HomeViewModel: ObservableObject { return nil } return RoomSection( - id: Self.usagePredictionSectionId, - name: L10n.HomeView.CommonControls.title(cachedUserName), - icon: "app.background.dotted", + id: "usage-prediction-common-control", + name: "Common Controls", entityIds: Set(entities) ) } @@ -93,9 +83,6 @@ final class HomeViewModel: ObservableObject { isLoading = true errorMessage = nil - // Cache the username from the current user - await cacheUserName() - // Load usage prediction common control data await loadUsagePredictionCommonControl() @@ -104,51 +91,22 @@ final class HomeViewModel: ObservableObject { isLoading = false } - private func cacheUserName() async { - Current.api(for: server)?.connection.caches.user.once { [weak self] user in - guard let self else { return } - cachedUserName = user.name ?? "" - Current.Log.verbose("Cached user name: \(String(describing: user.name))") - } - } - private func loadUsagePredictionCommonControl() async { - // Check if we should load based on time interval - let shouldLoad = shouldLoadUsagePrediction() - guard shouldLoad else { - Current.Log.verbose("Skipping usage prediction load - within 2 minute interval") - return - } - Current.api(for: server)?.connection.send(.usagePredictionCommonControl()) { result in switch result { - case let .success(usagePredictionCommonControl): + case .success(let usagePredictionCommonControl): self.usagePredictionCommonControl = usagePredictionCommonControl - self.lastUsagePredictionLoadTime = Date() - case let .failure(error): + case .failure(let error): Current.Log.error("Failed to load usage prediction common control: \(error.localizedDescription)") } } } - private func shouldLoadUsagePrediction() -> Bool { - guard let lastLoadTime = lastUsagePredictionLoadTime else { - // Never loaded before, should load - return true - } - - let timeSinceLastLoad = Date().timeIntervalSince(lastLoadTime) - return timeSinceLastLoad >= usagePredictionLoadInterval - } - // MARK: - Lifecycle Management /// Call this when the app enters foreground func handleAppDidBecomeActive() { Current.Log.info("HomeViewModel: App became active, starting subscriptions") - Task { - await loadUsagePredictionCommonControl() - } startSubscriptions() } @@ -289,7 +247,7 @@ final class HomeViewModel: ObservableObject { guard let areas else { return } groupedEntities = areas.map { - RoomSection(id: $0.id, name: $0.name, icon: $0.icon, entityIds: $0.entities) + RoomSection(id: $0.id, name: $0.name, entityIds: $0.entities) } } diff --git a/Sources/Shared/Models/HAUsagePredictionCommonControl.swift b/Sources/Shared/Models/HAUsagePredictionCommonControl.swift index 509733b1c6..62a0fae537 100644 --- a/Sources/Shared/Models/HAUsagePredictionCommonControl.swift +++ b/Sources/Shared/Models/HAUsagePredictionCommonControl.swift @@ -1,3 +1,11 @@ +// +// HAUsagePredictionCommonControl.swift +// HomeAssistant +// +// Created by Bruno Pantaleão on 14/1/26. +// Copyright © 2026 Home Assistant. All rights reserved. +// + import Foundation import HAKit From 84a30d99c661b2a0ac0eb2fa50ae253b826a09fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:41:58 +0100 Subject: [PATCH 02/16] Handle usage prediction section header separately Added a conditional branch to render the section header for 'usage-prediction-common-control' without a chevron, distinguishing it from other grouped entities. --- Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift index c44d9dd628..13cc22050f 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift @@ -368,7 +368,13 @@ struct HomeView: View { @ViewBuilder private func sectionHeader(_ title: String, section: HomeViewModel.RoomSection) -> some View { Group { - if let section = viewModel.groupedEntities.first(where: { $0.name == title }) { + // Handle usage prediction section separately (not in groupedEntities) + if section.id == "usage-prediction-common-control" { + EntityDisplayComponents.sectionHeader( + title, + showChevron: false + ) + } else if let section = viewModel.groupedEntities.first(where: { $0.name == title }) { EntityDisplayComponents.sectionHeader( title, showChevron: true, From 898d4d60d65b4186f8e7ca4418ee90628e2450c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:51:36 +0100 Subject: [PATCH 03/16] Add common used controls to native dashboard view --- .../Home/HomeViewModel.swift | 19 ++++++++++++++++--- .../HAUsagePredictionCommonControl.swift | 8 -------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift index fb3df383a9..31bc3cf8bc 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift @@ -33,6 +33,8 @@ final class HomeViewModel: ObservableObject { } } + var cachedUserName: String = "" + var orderedSectionsForMenu: [RoomSection] { // Use the same ordering logic as filteredSections, but show ALL sections (no filtering) if configuration.sectionOrder.isEmpty { @@ -55,7 +57,7 @@ final class HomeViewModel: ObservableObject { } return RoomSection( id: "usage-prediction-common-control", - name: "Common Controls", + name: L10n.HomeView.CommonControls.title(cachedUserName), entityIds: Set(entities) ) } @@ -83,6 +85,9 @@ final class HomeViewModel: ObservableObject { isLoading = true errorMessage = nil + // Cache the username from the current user + await cacheUserName() + // Load usage prediction common control data await loadUsagePredictionCommonControl() @@ -91,12 +96,20 @@ final class HomeViewModel: ObservableObject { isLoading = false } + private func cacheUserName() async { + Current.api(for: server)?.connection.caches.user.once { [weak self] user in + guard let self else { return } + cachedUserName = user.name ?? "" + Current.Log.verbose("Cached user name: \(String(describing: user.name))") + } + } + private func loadUsagePredictionCommonControl() async { Current.api(for: server)?.connection.send(.usagePredictionCommonControl()) { result in switch result { - case .success(let usagePredictionCommonControl): + case let .success(usagePredictionCommonControl): self.usagePredictionCommonControl = usagePredictionCommonControl - case .failure(let error): + case let .failure(error): Current.Log.error("Failed to load usage prediction common control: \(error.localizedDescription)") } } diff --git a/Sources/Shared/Models/HAUsagePredictionCommonControl.swift b/Sources/Shared/Models/HAUsagePredictionCommonControl.swift index 62a0fae537..509733b1c6 100644 --- a/Sources/Shared/Models/HAUsagePredictionCommonControl.swift +++ b/Sources/Shared/Models/HAUsagePredictionCommonControl.swift @@ -1,11 +1,3 @@ -// -// HAUsagePredictionCommonControl.swift -// HomeAssistant -// -// Created by Bruno Pantaleão on 14/1/26. -// Copyright © 2026 Home Assistant. All rights reserved. -// - import Foundation import HAKit From d2b64bc5bf470f9f8eaaad51d02177ce22b838b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:17:45 +0100 Subject: [PATCH 04/16] Add configuration toggle to customization view --- .../ExperimentalSpace/Home/HomeView.swift | 37 +++++++++++-------- .../Home/HomeViewModel.swift | 29 ++++++++++++++- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift index 13cc22050f..3c225da03e 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift @@ -127,20 +127,7 @@ struct HomeView: View { alignment: .leading, spacing: DesignSystem.Spaces.three ) { - // Display usage prediction common control section at the top - if let usagePredictionSection = viewModel.usagePredictionSection { - let visibleEntities = visibleEntitiesForSection(usagePredictionSection) - if !visibleEntities.isEmpty { - Section { - entityTilesGrid( - for: visibleEntities, - section: usagePredictionSection - ) - } header: { - sectionHeader(usagePredictionSection.name, section: usagePredictionSection) - } - } - } + predictionSection // Display regular sections ForEach(filteredSections) { section in @@ -162,6 +149,26 @@ struct HomeView: View { .transition(.opacity.combined(with: .move(edge: .bottom))) } + @ViewBuilder + private var predictionSection: some View { + if viewModel.configuration.showUsagePredictionSection { + // Display usage prediction common control section at the top + if let usagePredictionSection = viewModel.usagePredictionSection { + let visibleEntities = visibleEntitiesForSection(usagePredictionSection) + if !visibleEntities.isEmpty { + Section { + entityTilesGrid( + for: visibleEntities, + section: usagePredictionSection + ) + } header: { + sectionHeader(usagePredictionSection.name, section: usagePredictionSection) + } + } + } + } + } + private func visibleEntitiesForSection(_ section: HomeViewModel.RoomSection) -> [HAEntity] { // Create lookup dictionaries once to avoid O(n) searches for each entity let appEntitiesDict = Dictionary( @@ -369,7 +376,7 @@ struct HomeView: View { private func sectionHeader(_ title: String, section: HomeViewModel.RoomSection) -> some View { Group { // Handle usage prediction section separately (not in groupedEntities) - if section.id == "usage-prediction-common-control" { + if section.id == HomeViewModel.usagePredictionSectionId { EntityDisplayComponents.sectionHeader( title, showChevron: false diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift index 31bc3cf8bc..61717e3f59 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift @@ -14,6 +14,10 @@ final class HomeViewModel: ObservableObject { let entityIds: Set } + // MARK: - Constants + + static let usagePredictionSectionId = "usage-prediction-common-control" + var groupedEntities: [RoomSection] = [] var isLoading = false var errorMessage: String? @@ -34,6 +38,8 @@ final class HomeViewModel: ObservableObject { } var cachedUserName: String = "" + private var lastUsagePredictionLoadTime: Date? + private let usagePredictionLoadInterval: TimeInterval = 120 // 2 minutes var orderedSectionsForMenu: [RoomSection] { // Use the same ordering logic as filteredSections, but show ALL sections (no filtering) @@ -56,7 +62,7 @@ final class HomeViewModel: ObservableObject { return nil } return RoomSection( - id: "usage-prediction-common-control", + id: Self.usagePredictionSectionId, name: L10n.HomeView.CommonControls.title(cachedUserName), entityIds: Set(entities) ) @@ -105,21 +111,42 @@ final class HomeViewModel: ObservableObject { } private func loadUsagePredictionCommonControl() async { + // Check if we should load based on time interval + let shouldLoad = shouldLoadUsagePrediction() + guard shouldLoad else { + Current.Log.verbose("Skipping usage prediction load - within 2 minute interval") + return + } + Current.api(for: server)?.connection.send(.usagePredictionCommonControl()) { result in switch result { case let .success(usagePredictionCommonControl): self.usagePredictionCommonControl = usagePredictionCommonControl + self.lastUsagePredictionLoadTime = Date() case let .failure(error): Current.Log.error("Failed to load usage prediction common control: \(error.localizedDescription)") } } } + private func shouldLoadUsagePrediction() -> Bool { + guard let lastLoadTime = lastUsagePredictionLoadTime else { + // Never loaded before, should load + return true + } + + let timeSinceLastLoad = Date().timeIntervalSince(lastLoadTime) + return timeSinceLastLoad >= usagePredictionLoadInterval + } + // MARK: - Lifecycle Management /// Call this when the app enters foreground func handleAppDidBecomeActive() { Current.Log.info("HomeViewModel: App became active, starting subscriptions") + Task { + await loadUsagePredictionCommonControl() + } startSubscriptions() } From 40293314bdfff0c1a3fcd0f7b20aad3326faf8b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:06:39 +0100 Subject: [PATCH 05/16] Add grid layout option to HomeView areas Introduces a grid layout for displaying area sections in HomeView, selectable via configuration. Adds the AreaGridButton component for grid presentation and refactors the view logic to support both list and grid layouts. --- .../ExperimentalSpace/Home/HomeView.swift | 102 ++++++++++++++---- 1 file changed, 84 insertions(+), 18 deletions(-) diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift index 3c225da03e..c8fd5eb3ee 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift @@ -121,32 +121,69 @@ struct HomeView: View { sectionOrder: viewModel.configuration.sectionOrder, visibleSectionIds: viewModel.configuration.visibleSectionIds ) + let layout = viewModel.configuration.areasLayout ?? .list return ScrollView { - LazyVStack( - alignment: .leading, - spacing: DesignSystem.Spaces.three + switch layout { + case .grid: + areasGridView(sections: filteredSections) + case .list: + areasListView(sections: filteredSections) + } + } + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + + private func areasListView(sections: [HomeViewModel.RoomSection]) -> some View { + LazyVStack( + alignment: .leading, + spacing: DesignSystem.Spaces.three + ) { + predictionSection + + // Display regular sections + ForEach(sections) { section in + let visibleEntities = visibleEntitiesForSection(section) + if !visibleEntities.isEmpty || viewModel.configuration.visibleSectionIds.contains(section.id) { + Section { + entityTilesGrid( + for: visibleEntities, + section: section + ) + } header: { + sectionHeader(section.name, section: section) + } + } + } + } + .padding() + } + + private func areasGridView(sections: [HomeViewModel.RoomSection]) -> some View { + VStack(alignment: .leading, spacing: DesignSystem.Spaces.three) { + predictionSection + .padding() + + LazyVGrid( + columns: [ + GridItem(.adaptive(minimum: 120, maximum: 200), spacing: DesignSystem.Spaces.two) + ], + spacing: DesignSystem.Spaces.two ) { - predictionSection - - // Display regular sections - ForEach(filteredSections) { section in - let visibleEntities = visibleEntitiesForSection(section) - if !visibleEntities.isEmpty || viewModel.configuration.visibleSectionIds.contains(section.id) { - Section { - entityTilesGrid( - for: visibleEntities, - section: section - ) - } header: { - sectionHeader(section.name, section: section) - } + ForEach(sections) { section in + if !isReorderMode { + AreaGridButton( + section: section, + action: { + selectedRoom = (id: section.id, name: section.name) + } + ) + .matchedTransitionSource(id: section.id, in: roomNameSpace) } } } .padding() } - .transition(.opacity.combined(with: .move(edge: .bottom))) } @ViewBuilder @@ -417,6 +454,35 @@ struct HomeView: View { } } +// MARK: - Area Grid Button + +@available(iOS 26.0, *) +struct AreaGridButton: View { + let section: HomeViewModel.RoomSection + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: DesignSystem.Spaces.one) { + Image(systemSymbol: .houseCircle) + .font(.system(size: 32)) + .foregroundColor(.haPrimary) + + Text(section.name) + .font(.body.weight(.semibold)) + .foregroundColor(.white) + .lineLimit(2) + .minimumScaleFactor(0.8) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .aspectRatio(1, contentMode: .fill) + .background(Color.haPrimary.opacity(0.2)) + .cornerRadius(12) + } + } +} + @available(iOS 26.0, *) #Preview { HomeView(server: ServerFixture.standard) From 196934cd8e11bd9971892b98fde7f5ade36db700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:44:24 +0100 Subject: [PATCH 06/16] Refactor area grid and add icon support in HomeView Extracted AreaGridButton into its own file and enhanced it to support custom icons for each area. Updated HomeView to display a section header for areas and improved the layout of the areas grid. Modified HomeViewModel.RoomSection to include an optional icon property and updated related logic. Added a new localization key for the areas section title. --- .../ExperimentalSpace/Home/HomeView.swift | 71 +++++++------------ .../Home/HomeViewModel.swift | 4 +- 2 files changed, 27 insertions(+), 48 deletions(-) diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift index c8fd5eb3ee..3513108c52 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift @@ -160,25 +160,31 @@ struct HomeView: View { } private func areasGridView(sections: [HomeViewModel.RoomSection]) -> some View { - VStack(alignment: .leading, spacing: DesignSystem.Spaces.three) { + VStack(alignment: .leading, spacing: .zero) { predictionSection - .padding() - - LazyVGrid( - columns: [ - GridItem(.adaptive(minimum: 120, maximum: 200), spacing: DesignSystem.Spaces.two) - ], - spacing: DesignSystem.Spaces.two - ) { - ForEach(sections) { section in - if !isReorderMode { - AreaGridButton( - section: section, - action: { - selectedRoom = (id: section.id, name: section.name) - } - ) - .matchedTransitionSource(id: section.id, in: roomNameSpace) + .padding([.top, .horizontal]) + + VStack(alignment: .leading, spacing: DesignSystem.Spaces.two) { + EntityDisplayComponents.sectionHeader( + L10n.HomeView.Areas.title, + showChevron: false + ) + LazyVGrid( + columns: [ + GridItem(.adaptive(minimum: 100, maximum: 150), spacing: DesignSystem.Spaces.one) + ], + spacing: DesignSystem.Spaces.one + ) { + ForEach(sections) { section in + if !isReorderMode { + AreaGridButton( + section: section, + action: { + selectedRoom = (id: section.id, name: section.name) + } + ) + .matchedTransitionSource(id: section.id, in: roomNameSpace) + } } } } @@ -454,35 +460,6 @@ struct HomeView: View { } } -// MARK: - Area Grid Button - -@available(iOS 26.0, *) -struct AreaGridButton: View { - let section: HomeViewModel.RoomSection - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(spacing: DesignSystem.Spaces.one) { - Image(systemSymbol: .houseCircle) - .font(.system(size: 32)) - .foregroundColor(.haPrimary) - - Text(section.name) - .font(.body.weight(.semibold)) - .foregroundColor(.white) - .lineLimit(2) - .minimumScaleFactor(0.8) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .aspectRatio(1, contentMode: .fill) - .background(Color.haPrimary.opacity(0.2)) - .cornerRadius(12) - } - } -} - @available(iOS 26.0, *) #Preview { HomeView(server: ServerFixture.standard) diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift index 61717e3f59..c75b541dfd 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift @@ -11,6 +11,7 @@ final class HomeViewModel: ObservableObject { struct RoomSection: Identifiable, Equatable { let id: String let name: String + let icon: String? let entityIds: Set } @@ -64,6 +65,7 @@ final class HomeViewModel: ObservableObject { return RoomSection( id: Self.usagePredictionSectionId, name: L10n.HomeView.CommonControls.title(cachedUserName), + icon: "app.background.dotted", entityIds: Set(entities) ) } @@ -287,7 +289,7 @@ final class HomeViewModel: ObservableObject { guard let areas else { return } groupedEntities = areas.map { - RoomSection(id: $0.id, name: $0.name, entityIds: $0.entities) + RoomSection(id: $0.id, name: $0.name, icon: $0.icon, entityIds: $0.entities) } } From c493c2fe39d0a2fa57c59bb7ce9bd7f80b69dfe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:44:37 +0100 Subject: [PATCH 07/16] Lint --- Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift index 3513108c52..2c4e371adc 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift @@ -171,7 +171,7 @@ struct HomeView: View { ) LazyVGrid( columns: [ - GridItem(.adaptive(minimum: 100, maximum: 150), spacing: DesignSystem.Spaces.one) + GridItem(.adaptive(minimum: 100, maximum: 150), spacing: DesignSystem.Spaces.one), ], spacing: DesignSystem.Spaces.one ) { From ec9e5da977c948a099b4ac56856df392cea116bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:41:15 +0100 Subject: [PATCH 08/16] Add unavailable state styling to EntityTileView Entity tiles now visually indicate when an entity is unavailable or unknown by using a gray, dashed border, reduced opacity, and a gray icon color. This improves clarity for users when entities are not accessible. --- .../EntityTile/EntityTileView.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift b/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift index bbd2c7a0cd..fa530b6310 100644 --- a/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift +++ b/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift @@ -38,8 +38,14 @@ struct EntityTileView: View { .clipShape(RoundedRectangle(cornerRadius: Constants.cornerRadius)) .overlay( RoundedRectangle(cornerRadius: Constants.cornerRadius) - .stroke(.tileBorder, lineWidth: Constants.borderLineWidth) + .stroke( + isUnavailable ? .gray : .tileBorder, + + style: isUnavailable ? StrokeStyle(lineWidth: Constants.borderLineWidth, dash: [5, 3]) : + StrokeStyle(lineWidth: Constants.borderLineWidth) + ) ) + .opacity(isUnavailable ? 0.5 : 1.0) .onChange(of: haEntity) { _, _ in updateIconColor() } @@ -59,6 +65,11 @@ struct EntityTileView: View { } } + private var isUnavailable: Bool { + let state = haEntity.state.lowercased() + return [Domain.State.unavailable.rawValue, Domain.State.unknown.rawValue].contains(state) + } + private var tileContent: some View { VStack(alignment: .leading, spacing: DesignSystem.Spaces.one) { contentRow @@ -143,6 +154,11 @@ struct EntityTileView: View { let rgbColor = haEntity.attributes["rgb_color"] as? [Int] let hsColor = haEntity.attributes["hs_color"] as? [Double] + if isUnavailable { + iconColor = .gray + return + } + iconColor = EntityIconColorProvider.iconColor( domain: Domain(entityId: haEntity.entityId) ?? .switch, state: state, From c4998c7e38fb4cdc7ed201a3c648fe1349e53664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:48:41 +0100 Subject: [PATCH 09/16] Initial summaries --- HomeAssistant.xcodeproj/project.pbxproj | 24 ++-- .../Home/DomainSummaryCard.swift | 103 ++++++++++++++++ .../ExperimentalSpace/Home/HomeView.swift | 14 +++ .../Home/HomeViewModel.swift | 114 ++++++++++++++++++ 4 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 9aa486555e..6121040d54 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -671,6 +671,7 @@ 422F88192D42989E00706A0A /* CustomWidgetPressButtonAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 422F88182D42989E00706A0A /* CustomWidgetPressButtonAppIntent.swift */; }; 422F881A2D42989E00706A0A /* CustomWidgetPressButtonAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 422F88182D42989E00706A0A /* CustomWidgetPressButtonAppIntent.swift */; }; 422F951F2CFDF7C5003B7514 /* HAApplicationShortcutItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 422F951E2CFDF7C5003B7514 /* HAApplicationShortcutItem.swift */; }; + 422FB62F2F17E33800243A68 /* DomainSummaryCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 422FB62E2F17E33800243A68 /* DomainSummaryCard.swift */; }; 423179802D54FADD0037A8A4 /* AppIntentHaptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4231797F2D54FADD0037A8A4 /* AppIntentHaptics.swift */; }; 423179812D54FADD0037A8A4 /* AppIntentHaptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4231797F2D54FADD0037A8A4 /* AppIntentHaptics.swift */; }; 42333ADB2D0B1771001E8408 /* EntityRegistryListForDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42333ADA2D0B1771001E8408 /* EntityRegistryListForDisplay.swift */; }; @@ -923,10 +924,10 @@ 429BA2AF2C800CAB00A50996 /* SFSymbolEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429BA2AE2C800CAB00A50996 /* SFSymbolEntity.swift */; }; 429BEA1A2D102F3A00F070F9 /* ConnectionErrorDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429BEA192D102F3A00F070F9 /* ConnectionErrorDetailsView.swift */; }; 429BEA1D2D10315F00F070F9 /* SheetCloseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429BEA1B2D1030EA00F070F9 /* SheetCloseButton.swift */; }; + 429C33BF2F17989F0033EF5E /* EntityPickerViewModel.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */; }; 429C33C22F17A7010033EF5E /* HAUsagePredictionCommonControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C33C12F17A7010033EF5E /* HAUsagePredictionCommonControl.swift */; }; 429C33C32F17A7010033EF5E /* HAUsagePredictionCommonControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C33C12F17A7010033EF5E /* HAUsagePredictionCommonControl.swift */; }; 429C33C52F17CE4C0033EF5E /* AreaGridButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C33C42F17CE4C0033EF5E /* AreaGridButton.swift */; }; - 429C33BF2F17989F0033EF5E /* EntityPickerViewModel.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */; }; 429C72202B28D0EC00BCD558 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C721F2B28D0EC00BCD558 /* Haptics.swift */; }; 429C82892DCCDB0F007B03AF /* HAProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E4C9922DCC9A6600DF2813 /* HAProgressView.swift */; }; 429D51C52EFA0E750014AD0D /* ModernAssistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429D51C42EFA0E750014AD0D /* ModernAssistView.swift */; }; @@ -2334,6 +2335,7 @@ 422F88152D428E8300706A0A /* CustomWidgetActivateAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomWidgetActivateAppIntent.swift; sourceTree = ""; }; 422F88182D42989E00706A0A /* CustomWidgetPressButtonAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomWidgetPressButtonAppIntent.swift; sourceTree = ""; }; 422F951E2CFDF7C5003B7514 /* HAApplicationShortcutItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAApplicationShortcutItem.swift; sourceTree = ""; }; + 422FB62E2F17E33800243A68 /* DomainSummaryCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainSummaryCard.swift; sourceTree = ""; }; 4231797F2D54FADD0037A8A4 /* AppIntentHaptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentHaptics.swift; sourceTree = ""; }; 42333ADA2D0B1771001E8408 /* EntityRegistryListForDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityRegistryListForDisplay.swift; sourceTree = ""; }; 4236229F2F05587800391BD0 /* EntityIconColorProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityIconColorProvider.swift; sourceTree = ""; }; @@ -2565,9 +2567,9 @@ 429BA2AE2C800CAB00A50996 /* SFSymbolEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFSymbolEntity.swift; sourceTree = ""; }; 429BEA192D102F3A00F070F9 /* ConnectionErrorDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionErrorDetailsView.swift; sourceTree = ""; }; 429BEA1B2D1030EA00F070F9 /* SheetCloseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetCloseButton.swift; sourceTree = ""; }; + 429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPickerViewModel.test.swift; sourceTree = ""; }; 429C33C12F17A7010033EF5E /* HAUsagePredictionCommonControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAUsagePredictionCommonControl.swift; sourceTree = ""; }; 429C33C42F17CE4C0033EF5E /* AreaGridButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AreaGridButton.swift; sourceTree = ""; }; - 429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPickerViewModel.test.swift; sourceTree = ""; }; 429C721F2B28D0EC00BCD558 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; 429D51C42EFA0E750014AD0D /* ModernAssistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModernAssistView.swift; sourceTree = ""; }; 42A0B9352EBB82EB00E074BF /* StatusBarButtonsConfigurator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarButtonsConfigurator.swift; sourceTree = ""; }; @@ -5389,6 +5391,14 @@ path = General; sourceTree = ""; }; + 429C33BE2F17988D0033EF5E /* EntityPicker */ = { + isa = PBXGroup; + children = ( + 429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */, + ); + path = EntityPicker; + sourceTree = ""; + }; 429C33C02F17A6CF0033EF5E /* Models */ = { isa = PBXGroup; children = ( @@ -5397,14 +5407,6 @@ 429C33C12F17A7010033EF5E /* HAUsagePredictionCommonControl.swift */, ); path = Models; - sourceTree = ""; - }; - 429C33BE2F17988D0033EF5E /* EntityPicker */ = { - isa = PBXGroup; - children = ( - 429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */, - ); - path = EntityPicker; sourceTree = ""; }; 42A2AB7E2C80750A00C5608D /* Control */ = { @@ -5794,6 +5796,7 @@ 4289FCB82F0BFF50005189AA /* HomeViewConfiguration.swift */, 420C914F2F0C6CAC005D04A6 /* HomeViewCustomizationView.swift */, 429C33C42F17CE4C0033EF5E /* AreaGridButton.swift */, + 422FB62E2F17E33800243A68 /* DomainSummaryCard.swift */, ); path = Home; sourceTree = ""; @@ -9171,6 +9174,7 @@ 42F5C0892F02BCDE00C50310 /* IntentFanEntity.swift in Sources */, 426BB0C52D393F0E0062D905 /* EntityPicker.swift in Sources */, 1112AEBB25F717E9007A541A /* LocationHistoryDetailViewController.swift in Sources */, + 422FB62F2F17E33800243A68 /* DomainSummaryCard.swift in Sources */, 11BD7B4D25B53D7F001826F0 /* AppMacBridgeStatusItemConfiguration.swift in Sources */, 11F55EED25D3B088003977AC /* NotificationDebugNotificationsViewController.swift in Sources */, 42DFE8742EFAE33D0058DADB /* AssistWavesAnimation.swift in Sources */, diff --git a/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift b/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift new file mode 100644 index 0000000000..a0c71b7940 --- /dev/null +++ b/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift @@ -0,0 +1,103 @@ +import SwiftUI + +@available(iOS 26.0, *) +struct DomainSummaryCard: View { + let summary: HomeViewModel.DomainSummary + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + // Icon + Image(systemName: summary.icon) + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(summary.isActive ? .orange : .secondary) + .frame(width: 44, height: 44) + + // Content + VStack(alignment: .leading, spacing: 2) { + Text(summary.displayName) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.primary) + + Text(summary.summaryText) + .font(.system(size: 15)) + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(UIColor.secondarySystemGroupedBackground)) + ) + } + .buttonStyle(.plain) + } +} + +@available(iOS 26.0, *) +struct DomainSummariesSection: View { + let summaries: [HomeViewModel.DomainSummary] + let onTapSummary: (HomeViewModel.DomainSummary) -> Void + + var body: some View { + if !summaries.isEmpty { + VStack(alignment: .leading, spacing: 12) { + // Section header + Text("Summaries") + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(.primary) + .padding(.horizontal, 4) + + // Grid of summary cards + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ], + spacing: 12 + ) { + ForEach(summaries) { summary in + DomainSummaryCard(summary: summary) { + onTapSummary(summary) + } + } + } + } + .padding(.bottom, 16) + } + } +} + +@available(iOS 26.0, *) +#Preview { + let summaries = [ + HomeViewModel.DomainSummary( + id: "light", + domain: "light", + displayName: "Lights", + icon: "lightbulb.fill", + count: 10, + activeCount: 3, + summaryText: "3 on" + ), + HomeViewModel.DomainSummary( + id: "cover", + domain: "cover", + displayName: "Covers", + icon: "rectangle.on.rectangle.angled", + count: 5, + activeCount: 0, + summaryText: "All closed" + ) + ] + + return DomainSummariesSection(summaries: summaries) { summary in + print("Tapped: \(summary.displayName)") + } + .padding() + .background(Color(UIColor.systemGroupedBackground)) +} diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift index 2c4e371adc..d6e39486cf 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift @@ -210,6 +210,20 @@ struct HomeView: View { } } } + + // Display domain summaries section + summariesSection + } + + @ViewBuilder + private var summariesSection: some View { + if !viewModel.domainSummaries.isEmpty { + DomainSummariesSection(summaries: viewModel.domainSummaries) { summary in + // TODO: Handle tap - could navigate to a filtered view of that domain + Current.Log.info("Tapped domain summary: \(summary.displayName)") + } + .padding(.top, 8) + } } private func visibleEntitiesForSection(_ section: HomeViewModel.RoomSection) -> [HAEntity] { diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift index c75b541dfd..76b9f5355d 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift @@ -15,6 +15,20 @@ final class HomeViewModel: ObservableObject { let entityIds: Set } + struct DomainSummary: Identifiable { + let id: String // domain name + let domain: String + let displayName: String + let icon: String + let count: Int + let activeCount: Int + let summaryText: String + + var isActive: Bool { + activeCount > 0 + } + } + // MARK: - Constants static let usagePredictionSectionId = "usage-prediction-common-control" @@ -41,6 +55,12 @@ final class HomeViewModel: ObservableObject { var cachedUserName: String = "" private var lastUsagePredictionLoadTime: Date? private let usagePredictionLoadInterval: TimeInterval = 120 // 2 minutes + + var domainSummaries: [DomainSummary] = [] { + didSet { + Current.Log.verbose("Domain summaries updated: \(domainSummaries.count) summaries") + } + } var orderedSectionsForMenu: [RoomSection] { // Use the same ordering logic as filteredSections, but show ALL sections (no filtering) @@ -220,6 +240,7 @@ final class HomeViewModel: ObservableObject { onChange: { [weak self] entities in guard let self else { return } appEntities = entities + computeDomainSummaries() } ) } @@ -282,6 +303,99 @@ final class HomeViewModel: ObservableObject { entityService.subscribeToEntitiesChanges(server: server) { [weak self] states in guard let self else { return } entityStates = states + computeDomainSummaries() + } + } + + // MARK: - Domain Summaries + + private func computeDomainSummaries() { + // Define domains we want to summarize (starting with light and cover) + let domainsToSummarize: [(domain: String, displayName: String, icon: String)] = [ + ("light", "Lights", "lightbulb.fill"), + ("cover", "Covers", "rectangle.on.rectangle.angled") + ] + + var summaries: [DomainSummary] = [] + + for domainInfo in domainsToSummarize { + let domainEntities = entityStates.values.filter { $0.domain == domainInfo.domain } + + // Filter out hidden and disabled entities + let visibleEntities = domainEntities.filter { entity in + guard let appEntity = appEntities?.first(where: { $0.entityId == entity.entityId }) else { + return false + } + return !appEntity.isHidden && !appEntity.isDisabled && + !configuration.hiddenEntityIds.contains(entity.entityId) + } + + guard !visibleEntities.isEmpty else { continue } + + let activeCount = visibleEntities.filter { entity in + isEntityActive(entity) + }.count + + let summaryText = generateSummaryText( + domain: domainInfo.domain, + totalCount: visibleEntities.count, + activeCount: activeCount + ) + + let summary = DomainSummary( + id: domainInfo.domain, + domain: domainInfo.domain, + displayName: domainInfo.displayName, + icon: domainInfo.icon, + count: visibleEntities.count, + activeCount: activeCount, + summaryText: summaryText + ) + + summaries.append(summary) + } + + domainSummaries = summaries + } + + private func isEntityActive(_ entity: HAEntity) -> Bool { + // Check if entity is in an "active" state + switch entity.domain { + case "light", "switch", "fan": + return entity.state == "on" + case "cover": + return entity.state == "open" || entity.state == "opening" + case "lock": + return entity.state == "unlocked" + case "climate": + return entity.state != "off" + case "media_player": + return entity.state == "playing" || entity.state == "paused" + default: + return entity.state == "on" + } + } + + private func generateSummaryText(domain: String, totalCount: Int, activeCount: Int) -> String { + switch domain { + case "light": + if activeCount == 0 { + // L10n.HomeView.Summaries.Light.allOff + return "All off" + } else { + // L10n.HomeView.Summaries.Light.countOn(activeCount) + return "\(activeCount) on" + } + case "cover": + if activeCount == 0 { + // L10n.HomeView.Summaries.Cover.allClosed + return "All closed" + } else { + // L10n.HomeView.Summaries.Cover.countOpen(activeCount) + return "\(activeCount) open" + } + default: + return "\(activeCount) active" } } From 1f36403c2327fb9a8bea29f7f057465329105399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:10:24 +0100 Subject: [PATCH 10/16] Refactor EntityTileView and add HomeEntityTileView EntityTileView is now a pure UI component, decoupled from Home Assistant-specific logic. Introduced HomeEntityTileView to encapsulate business logic such as device class lookup, icon color computation, and AppIntents integration. Updated EntityDisplayComponents to use HomeEntityTileView for Home Assistant entities. --- HomeAssistant.xcodeproj/project.pbxproj | 4 + .../EntityTile/EntityTileView.swift | 173 ++++++++++-------- .../EntityTile/HomeEntityTileView.swift | 122 ++++++++++++ .../Shared/EntityDisplayComponents.swift | 4 +- 4 files changed, 220 insertions(+), 83 deletions(-) create mode 100644 Sources/App/WebView/ExperimentalSpace/EntityTile/HomeEntityTileView.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 6121040d54..4759a84420 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -783,6 +783,7 @@ 426BB0C72D394BFF0062D905 /* AssistPipelinePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 426BB0C62D394BFF0062D905 /* AssistPipelinePicker.swift */; }; 426CBB6A2C9C543F003CA3AC /* ControlSwitchValueProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 426CBB692C9C543F003CA3AC /* ControlSwitchValueProvider.swift */; }; 426CBB6C2C9C550D003CA3AC /* IntentSwitchEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 426CBB6B2C9C550D003CA3AC /* IntentSwitchEntity.swift */; }; + 426D2F032F17E8530062C359 /* HomeEntityTileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 426D2F022F17E8530062C359 /* HomeEntityTileView.swift */; }; 426D9C742C9C60B000F278AF /* ControlEntityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 426D9C722C9C582F00F278AF /* ControlEntityProvider.swift */; }; 426D9C752C9C60B000F278AF /* ControlEntityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 426D9C722C9C582F00F278AF /* ControlEntityProvider.swift */; }; 426E02992EF98F5F008237D4 /* CameraListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 426E02982EF98F5F008237D4 /* CameraListRow.swift */; }; @@ -2428,6 +2429,7 @@ 426BB0C62D394BFF0062D905 /* AssistPipelinePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistPipelinePicker.swift; sourceTree = ""; }; 426CBB692C9C543F003CA3AC /* ControlSwitchValueProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlSwitchValueProvider.swift; sourceTree = ""; }; 426CBB6B2C9C550D003CA3AC /* IntentSwitchEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSwitchEntity.swift; sourceTree = ""; }; + 426D2F022F17E8530062C359 /* HomeEntityTileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeEntityTileView.swift; sourceTree = ""; }; 426D9C722C9C582F00F278AF /* ControlEntityProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlEntityProvider.swift; sourceTree = ""; }; 426E02982EF98F5F008237D4 /* CameraListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraListRow.swift; sourceTree = ""; }; 426E029A2EF98F65008237D4 /* CameraSectionReorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraSectionReorderView.swift; sourceTree = ""; }; @@ -5807,6 +5809,7 @@ 42DFE87C2EFB128B0058DADB /* EntityTileView.swift */, 4236229F2F05587800391BD0 /* EntityIconColorProvider.swift */, 42C60F902F07FB0F0071A6F6 /* MoreInfoDialog */, + 426D2F022F17E8530062C359 /* HomeEntityTileView.swift */, ); path = EntityTile; sourceTree = ""; @@ -9300,6 +9303,7 @@ 4273C48B2C8858470065A5B4 /* ControlOpenPageValueProvider.swift in Sources */, 427FEE072D9C03DE0047C00C /* OnboardingScanningInstanceRow.swift in Sources */, 420FE84B2B556BB100878E06 /* CarPlayActionsTemplate+Build.swift in Sources */, + 426D2F032F17E8530062C359 /* HomeEntityTileView.swift in Sources */, 42DF6B2F2CCF918D00D7EC14 /* BluetoothPermissionView.swift in Sources */, 425573C72B5572AD00145217 /* CarPlayServerListTemplate+Build.swift in Sources */, 4238E8572EB0EA4A00BDF010 /* ConnectionSecurityLevelBlockViewModel.swift in Sources */, diff --git a/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift b/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift index fa530b6310..4b31f83102 100644 --- a/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift +++ b/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift @@ -1,8 +1,48 @@ -import AppIntents import HAKit import Shared import SwiftUI +/// Pure UI component for displaying an entity tile +/// This view is designed to be reusable across different contexts in the app +/// +/// ## Usage Example +/// ```swift +/// // Simple usage with static data +/// EntityTileView( +/// entityName: "Living Room Light", +/// entityState: "On", +/// icon: .lightbulbIcon, +/// iconColor: .yellow, +/// isUnavailable: false, +/// onIconTap: { +/// // Handle icon tap action +/// }, +/// onTileTap: { +/// // Handle tile tap action +/// } +/// ) +/// +/// // Usage in a custom view +/// struct CustomEntityList: View { +/// let entities: [MyCustomEntity] +/// +/// var body: some View { +/// ForEach(entities) { entity in +/// EntityTileView( +/// entityName: entity.name, +/// entityState: entity.status, +/// icon: entity.icon, +/// iconColor: entity.color, +/// onIconTap: { performAction(on: entity) } +/// ) +/// } +/// } +/// } +/// ``` +/// +/// For Home Assistant entity integration, use `HomeEntityTileView` instead, +/// which handles all the business logic like device class lookup, icon color +/// computation, and AppIntents integration. @available(iOS 26.0, *) struct EntityTileView: View { enum Constants { @@ -15,20 +55,42 @@ struct EntityTileView: View { static let textVStackSpacing: CGFloat = 2 } - let server: Server - let haEntity: HAEntity + // MARK: - Display Data + + let entityName: String + let entityState: String + let icon: MaterialDesignIcons + let iconColor: Color + let isUnavailable: Bool + let onIconTap: (() -> Void)? + let onTileTap: (() -> Void)? + + // MARK: - State - @Namespace private var namespace @State private var triggerHaptic = 0 - @State private var iconColor: Color = .secondary - @State private var showMoreInfoDialog = false - @State private var deviceClass: DeviceClass = .unknown - init(server: Server, haEntity: HAEntity) { - self.server = server - self.haEntity = haEntity + // MARK: - Initializer + + init( + entityName: String, + entityState: String, + icon: MaterialDesignIcons, + iconColor: Color, + isUnavailable: Bool = false, + onIconTap: (() -> Void)? = nil, + onTileTap: (() -> Void)? = nil + ) { + self.entityName = entityName + self.entityState = entityState + self.icon = icon + self.iconColor = iconColor + self.isUnavailable = isUnavailable + self.onIconTap = onIconTap + self.onTileTap = onTileTap } + // MARK: - Body + var body: some View { tileContent .frame(height: Constants.tileHeight) @@ -40,35 +102,18 @@ struct EntityTileView: View { RoundedRectangle(cornerRadius: Constants.cornerRadius) .stroke( isUnavailable ? .gray : .tileBorder, - - style: isUnavailable ? StrokeStyle(lineWidth: Constants.borderLineWidth, dash: [5, 3]) : + style: isUnavailable ? + StrokeStyle(lineWidth: Constants.borderLineWidth, dash: [5, 3]) : StrokeStyle(lineWidth: Constants.borderLineWidth) ) ) .opacity(isUnavailable ? 0.5 : 1.0) - .onChange(of: haEntity) { _, _ in - updateIconColor() - } - .onAppear { - getDeviceClass() - updateIconColor() - } - .matchedTransitionSource(id: haEntity.entityId, in: namespace) .onTapGesture { - showMoreInfoDialog = true - } - .fullScreenCover(isPresented: $showMoreInfoDialog) { - EntityMoreInfoDialogView( - server: server, haEntity: haEntity - ) - .navigationTransition(.zoom(sourceID: haEntity.entityId, in: namespace)) + onTileTap?() } } - private var isUnavailable: Bool { - let state = haEntity.state.lowercased() - return [Domain.State.unavailable.rawValue, Domain.State.unknown.rawValue].contains(state) - } + // MARK: - View Components private var tileContent: some View { VStack(alignment: .leading, spacing: DesignSystem.Spaces.one) { @@ -93,42 +138,33 @@ struct EntityTileView: View { } private var entityNameText: some View { - Text(haEntity.attributes.friendlyName ?? haEntity.entityId) + Text(entityName) .font(.footnote) .fontWeight(.semibold) + #if os(iOS) .foregroundColor(Color(uiColor: .label)) + #else + .foregroundColor(.primary) + #endif .lineLimit(2) .multilineTextAlignment(.leading) } private var entityStateText: some View { - Text( - Domain(entityId: haEntity.entityId)?.contextualStateDescription(for: haEntity) ?? haEntity.state - ) - .font(.caption) - .foregroundColor(Color(uiColor: .secondaryLabel)) - } - - private func getDeviceClass() { - deviceClass = DeviceClassProvider.deviceClass( - for: haEntity.entityId, - serverId: server.identifier.rawValue - ) + Text(entityState) + .font(.caption) + #if os(iOS) + .foregroundColor(Color(uiColor: .secondaryLabel)) + #else + .foregroundColor(.secondary) + #endif } private var iconView: some View { - let icon: MaterialDesignIcons - if let entityIcon = haEntity.attributes.icon { - icon = MaterialDesignIcons(serversideValueNamed: entityIcon) - } else if let domain = Domain(entityId: haEntity.entityId) { - let stateString = haEntity.state - let domainState = Domain.State(rawValue: stateString) ?? .unknown - icon = domain.icon(deviceClass: deviceClass.rawValue, state: domainState) - } else { - icon = .homeIcon - } - - return Button(intent: AppIntentProvider.intent(for: haEntity, server: server)) { + Button { + onIconTap?() + triggerHaptic += 1 + } label: { VStack { Text(verbatim: icon.unicode) .font(.custom(MaterialDesignIcons.familyName, size: Constants.iconFontSize)) @@ -140,31 +176,6 @@ struct EntityTileView: View { .clipShape(Circle()) } .buttonStyle(.plain) - .simultaneousGesture( - TapGesture().onEnded { - triggerHaptic += 1 - } - ) .sensoryFeedback(.impact, trigger: triggerHaptic) } - - private func updateIconColor() { - let state = haEntity.state - let colorMode = haEntity.attributes["color_mode"] as? String - let rgbColor = haEntity.attributes["rgb_color"] as? [Int] - let hsColor = haEntity.attributes["hs_color"] as? [Double] - - if isUnavailable { - iconColor = .gray - return - } - - iconColor = EntityIconColorProvider.iconColor( - domain: Domain(entityId: haEntity.entityId) ?? .switch, - state: state, - colorMode: colorMode, - rgbColor: rgbColor, - hsColor: hsColor - ) - } } diff --git a/Sources/App/WebView/ExperimentalSpace/EntityTile/HomeEntityTileView.swift b/Sources/App/WebView/ExperimentalSpace/EntityTile/HomeEntityTileView.swift new file mode 100644 index 0000000000..4607f1a5ce --- /dev/null +++ b/Sources/App/WebView/ExperimentalSpace/EntityTile/HomeEntityTileView.swift @@ -0,0 +1,122 @@ +import AppIntents +import HAKit +import Shared +import SwiftUI + +/// Entity tile view specifically designed for use in HomeView +/// Handles business logic like device class lookup, icon color computation, +/// app intents integration, and more info dialog presentation +@available(iOS 26.0, *) +struct HomeEntityTileView: View { + let server: Server + let haEntity: HAEntity + + @Namespace private var namespace + @State private var iconColor: Color = .secondary + @State private var showMoreInfoDialog = false + @State private var deviceClass: DeviceClass = .unknown + + init(server: Server, haEntity: HAEntity) { + self.server = server + self.haEntity = haEntity + } + + var body: some View { + EntityTileView( + entityName: entityName, + entityState: entityState, + icon: icon, + iconColor: iconColor, + isUnavailable: isUnavailable, + onIconTap: handleIconTap, + onTileTap: handleTileTap + ) + .onChange(of: haEntity) { _, _ in + updateIconColor() + } + .onAppear { + getDeviceClass() + updateIconColor() + } + .matchedTransitionSource(id: haEntity.entityId, in: namespace) + .fullScreenCover(isPresented: $showMoreInfoDialog) { + EntityMoreInfoDialogView( + server: server, + haEntity: haEntity + ) + .navigationTransition(.zoom(sourceID: haEntity.entityId, in: namespace)) + } + } + + // MARK: - Computed Properties + + private var entityName: String { + haEntity.attributes.friendlyName ?? haEntity.entityId + } + + private var entityState: String { + Domain(entityId: haEntity.entityId)?.contextualStateDescription(for: haEntity) ?? haEntity.state + } + + private var icon: MaterialDesignIcons { + if let entityIcon = haEntity.attributes.icon { + return MaterialDesignIcons(serversideValueNamed: entityIcon) + } else if let domain = Domain(entityId: haEntity.entityId) { + let stateString = haEntity.state + let domainState = Domain.State(rawValue: stateString) ?? .unknown + return domain.icon(deviceClass: deviceClass.rawValue, state: domainState) + } else { + return .homeIcon + } + } + + private var isUnavailable: Bool { + let state = haEntity.state.lowercased() + return [Domain.State.unavailable.rawValue, Domain.State.unknown.rawValue].contains(state) + } + + // MARK: - Actions + + private func handleIconTap() { + #if os(iOS) + // Execute the app intent for the entity + let intent = AppIntentProvider.intent(for: haEntity, server: server) + Task { + _ = try? await intent.perform() + } + #endif + } + + private func handleTileTap() { + showMoreInfoDialog = true + } + + // MARK: - Business Logic + + private func getDeviceClass() { + deviceClass = DeviceClassProvider.deviceClass( + for: haEntity.entityId, + serverId: server.identifier.rawValue + ) + } + + private func updateIconColor() { + let state = haEntity.state + let colorMode = haEntity.attributes["color_mode"] as? String + let rgbColor = haEntity.attributes["rgb_color"] as? [Int] + let hsColor = haEntity.attributes["hs_color"] as? [Double] + + if isUnavailable { + iconColor = .gray + return + } + + iconColor = EntityIconColorProvider.iconColor( + domain: Domain(entityId: haEntity.entityId) ?? .switch, + state: state, + colorMode: colorMode, + rgbColor: rgbColor, + hsColor: hsColor + ) + } +} diff --git a/Sources/App/WebView/ExperimentalSpace/Shared/EntityDisplayComponents.swift b/Sources/App/WebView/ExperimentalSpace/Shared/EntityDisplayComponents.swift index 16e8e1d351..4868f16968 100644 --- a/Sources/App/WebView/ExperimentalSpace/Shared/EntityDisplayComponents.swift +++ b/Sources/App/WebView/ExperimentalSpace/Shared/EntityDisplayComponents.swift @@ -59,7 +59,7 @@ enum EntityDisplayComponents { ) -> some View { LazyVGrid(columns: standardGridColumns, spacing: DesignSystem.Spaces.oneAndHalf) { ForEach(entities, id: \.entityId) { entity in - EntityTileView( + HomeEntityTileView( server: server, haEntity: entity ) @@ -84,7 +84,7 @@ enum EntityDisplayComponents { ) -> some View { LazyVGrid(columns: standardGridColumns, spacing: DesignSystem.Spaces.oneAndHalf) { ForEach(entities, id: \.entityId) { entity in - EntityTileView( + HomeEntityTileView( server: server, haEntity: entity ) From e2b685361929480ad1d6c6507853db6566859d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:15:02 +0100 Subject: [PATCH 11/16] Refactor domain summaries to use EntityTileView Replaces DomainSummaryCard's custom layout with EntityTileView for consistency and code reuse. Updates DomainSummariesSection to use a Section header and adjusts grid spacing to use DesignSystem constants. Refactors icon mapping for domain summaries and removes redundant padding in HomeView. Cleans up formatting and improves maintainability. --- .../EntityTile/EntityTileView.swift | 14 ++-- .../EntityTile/HomeEntityTileView.swift | 2 +- .../Home/DomainSummaryCard.swift | 79 ++++++++----------- .../ExperimentalSpace/Home/HomeView.swift | 5 +- .../Home/HomeViewModel.swift | 36 ++++----- 5 files changed, 63 insertions(+), 73 deletions(-) diff --git a/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift b/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift index 4b31f83102..11c3d0a972 100644 --- a/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift +++ b/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift @@ -102,7 +102,7 @@ struct EntityTileView: View { RoundedRectangle(cornerRadius: Constants.cornerRadius) .stroke( isUnavailable ? .gray : .tileBorder, - style: isUnavailable ? + style: isUnavailable ? StrokeStyle(lineWidth: Constants.borderLineWidth, dash: [5, 3]) : StrokeStyle(lineWidth: Constants.borderLineWidth) ) @@ -141,11 +141,11 @@ struct EntityTileView: View { Text(entityName) .font(.footnote) .fontWeight(.semibold) - #if os(iOS) + #if os(iOS) .foregroundColor(Color(uiColor: .label)) - #else + #else .foregroundColor(.primary) - #endif + #endif .lineLimit(2) .multilineTextAlignment(.leading) } @@ -153,11 +153,11 @@ struct EntityTileView: View { private var entityStateText: some View { Text(entityState) .font(.caption) - #if os(iOS) + #if os(iOS) .foregroundColor(Color(uiColor: .secondaryLabel)) - #else + #else .foregroundColor(.secondary) - #endif + #endif } private var iconView: some View { diff --git a/Sources/App/WebView/ExperimentalSpace/EntityTile/HomeEntityTileView.swift b/Sources/App/WebView/ExperimentalSpace/EntityTile/HomeEntityTileView.swift index 4607f1a5ce..9bb60ac841 100644 --- a/Sources/App/WebView/ExperimentalSpace/EntityTile/HomeEntityTileView.swift +++ b/Sources/App/WebView/ExperimentalSpace/EntityTile/HomeEntityTileView.swift @@ -41,7 +41,7 @@ struct HomeEntityTileView: View { .matchedTransitionSource(id: haEntity.entityId, in: namespace) .fullScreenCover(isPresented: $showMoreInfoDialog) { EntityMoreInfoDialogView( - server: server, + server: server, haEntity: haEntity ) .navigationTransition(.zoom(sourceID: haEntity.entityId, in: namespace)) diff --git a/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift b/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift index a0c71b7940..b5f32062e5 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift @@ -1,40 +1,34 @@ +import Shared import SwiftUI @available(iOS 26.0, *) struct DomainSummaryCard: View { let summary: HomeViewModel.DomainSummary let action: () -> Void - + var body: some View { - Button(action: action) { - HStack(spacing: 12) { - // Icon - Image(systemName: summary.icon) - .font(.system(size: 28, weight: .semibold)) - .foregroundStyle(summary.isActive ? .orange : .secondary) - .frame(width: 44, height: 44) - - // Content - VStack(alignment: .leading, spacing: 2) { - Text(summary.displayName) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.primary) - - Text(summary.summaryText) - .font(.system(size: 15)) - .foregroundStyle(.secondary) - } - - Spacer() - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(UIColor.secondarySystemGroupedBackground)) - ) + EntityTileView( + entityName: summary.displayName, + entityState: summary.summaryText, + icon: iconForDomain(summary.icon), + iconColor: summary.isActive ? .orange : .gray, + isUnavailable: false, + onIconTap: action, + onTileTap: action + ) + } + + private func iconForDomain(_ systemName: String) -> MaterialDesignIcons { + // Map system icon names to Material Design Icons + // You can expand this mapping as needed + switch systemName { + case "lightbulb.fill": + return .lightbulbIcon + case "rectangle.on.rectangle.angled": + return .curtainsIcon + default: + return .dotsHorizontalIcon } - .buttonStyle(.plain) } } @@ -42,23 +36,16 @@ struct DomainSummaryCard: View { struct DomainSummariesSection: View { let summaries: [HomeViewModel.DomainSummary] let onTapSummary: (HomeViewModel.DomainSummary) -> Void - + var body: some View { if !summaries.isEmpty { - VStack(alignment: .leading, spacing: 12) { - // Section header - Text("Summaries") - .font(.system(size: 22, weight: .bold)) - .foregroundStyle(.primary) - .padding(.horizontal, 4) - - // Grid of summary cards + Section { LazyVGrid( columns: [ - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12) + GridItem(.flexible(), spacing: DesignSystem.Spaces.one), + GridItem(.flexible(), spacing: DesignSystem.Spaces.one), ], - spacing: 12 + spacing: DesignSystem.Spaces.one ) { ForEach(summaries) { summary in DomainSummaryCard(summary: summary) { @@ -66,8 +53,12 @@ struct DomainSummariesSection: View { } } } + } header: { + EntityDisplayComponents.sectionHeader( + "Summaries", // TODO: Replace with L10n.HomeView.Summaries.title when available + showChevron: false + ) } - .padding(.bottom, 16) } } } @@ -92,9 +83,9 @@ struct DomainSummariesSection: View { count: 5, activeCount: 0, summaryText: "All closed" - ) + ), ] - + return DomainSummariesSection(summaries: summaries) { summary in print("Tapped: \(summary.displayName)") } diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift index d6e39486cf..f43af691e3 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift @@ -210,11 +210,11 @@ struct HomeView: View { } } } - + // Display domain summaries section summariesSection } - + @ViewBuilder private var summariesSection: some View { if !viewModel.domainSummaries.isEmpty { @@ -222,7 +222,6 @@ struct HomeView: View { // TODO: Handle tap - could navigate to a filtered view of that domain Current.Log.info("Tapped domain summary: \(summary.displayName)") } - .padding(.top, 8) } } diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift index 76b9f5355d..50ba9825d8 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift @@ -23,7 +23,7 @@ final class HomeViewModel: ObservableObject { let count: Int let activeCount: Int let summaryText: String - + var isActive: Bool { activeCount > 0 } @@ -55,7 +55,7 @@ final class HomeViewModel: ObservableObject { var cachedUserName: String = "" private var lastUsagePredictionLoadTime: Date? private let usagePredictionLoadInterval: TimeInterval = 120 // 2 minutes - + var domainSummaries: [DomainSummary] = [] { didSet { Current.Log.verbose("Domain summaries updated: \(domainSummaries.count) summaries") @@ -306,42 +306,42 @@ final class HomeViewModel: ObservableObject { computeDomainSummaries() } } - + // MARK: - Domain Summaries - + private func computeDomainSummaries() { // Define domains we want to summarize (starting with light and cover) let domainsToSummarize: [(domain: String, displayName: String, icon: String)] = [ ("light", "Lights", "lightbulb.fill"), - ("cover", "Covers", "rectangle.on.rectangle.angled") + ("cover", "Covers", "rectangle.on.rectangle.angled"), ] - + var summaries: [DomainSummary] = [] - + for domainInfo in domainsToSummarize { let domainEntities = entityStates.values.filter { $0.domain == domainInfo.domain } - + // Filter out hidden and disabled entities let visibleEntities = domainEntities.filter { entity in guard let appEntity = appEntities?.first(where: { $0.entityId == entity.entityId }) else { return false } - return !appEntity.isHidden && !appEntity.isDisabled && - !configuration.hiddenEntityIds.contains(entity.entityId) + return !appEntity.isHidden && !appEntity.isDisabled && + !configuration.hiddenEntityIds.contains(entity.entityId) } - + guard !visibleEntities.isEmpty else { continue } - + let activeCount = visibleEntities.filter { entity in isEntityActive(entity) }.count - + let summaryText = generateSummaryText( domain: domainInfo.domain, totalCount: visibleEntities.count, activeCount: activeCount ) - + let summary = DomainSummary( id: domainInfo.domain, domain: domainInfo.domain, @@ -351,13 +351,13 @@ final class HomeViewModel: ObservableObject { activeCount: activeCount, summaryText: summaryText ) - + summaries.append(summary) } - + domainSummaries = summaries } - + private func isEntityActive(_ entity: HAEntity) -> Bool { // Check if entity is in an "active" state switch entity.domain { @@ -375,7 +375,7 @@ final class HomeViewModel: ObservableObject { return entity.state == "on" } } - + private func generateSummaryText(domain: String, totalCount: Int, activeCount: Int) -> String { switch domain { case "light": From 40f15bfd5172d463b3a81407249c76d130e46f02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:26:48 +0100 Subject: [PATCH 12/16] Add localized strings and refactor domain summaries Introduces localized strings for summaries, lights, and covers. Refactors DomainSummary to use a Domain enum instead of String, updates summary generation logic, and integrates new localization keys throughout the HomeViewModel and UI components. --- .../Resources/en.lproj/Localizable.strings | 3 ++ .../Home/DomainSummaryCard.swift | 25 ++++--------- .../Home/HomeViewModel.swift | 37 ++++++++++--------- .../Shared/Resources/Swiftgen/Strings.swift | 12 ++++++ 4 files changed, 43 insertions(+), 34 deletions(-) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index db586f9cde..400ba2632f 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -361,6 +361,9 @@ "home_view.customization.areas_layout.title" = "Areas layout"; "home_view.customization.areas_layout.list.title" = "List"; "home_view.customization.areas_layout.grid.title" = "Grid"; +"home_view.summaries.title" = "Summaries"; +"home_view.summaries.lights.title" = "Lights"; +"home_view.summaries.covers.title" = "Covers"; "improv.button.continue" = "Continue"; "improv.connection_state.authorized" = "Setting up Wi-Fi"; "improv.connection_state.provisioning" = "Connecting to Wi-Fi"; diff --git a/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift b/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift index b5f32062e5..e6778a50b1 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift @@ -10,25 +10,16 @@ struct DomainSummaryCard: View { EntityTileView( entityName: summary.displayName, entityState: summary.summaryText, - icon: iconForDomain(summary.icon), - iconColor: summary.isActive ? .orange : .gray, + icon: iconForDomain, + iconColor: summary.isActive ? summary.domain.accentColor : .gray, isUnavailable: false, onIconTap: action, onTileTap: action ) } - private func iconForDomain(_ systemName: String) -> MaterialDesignIcons { - // Map system icon names to Material Design Icons - // You can expand this mapping as needed - switch systemName { - case "lightbulb.fill": - return .lightbulbIcon - case "rectangle.on.rectangle.angled": - return .curtainsIcon - default: - return .dotsHorizontalIcon - } + var iconForDomain: MaterialDesignIcons { + summary.domain.icon() } } @@ -55,7 +46,7 @@ struct DomainSummariesSection: View { } } header: { EntityDisplayComponents.sectionHeader( - "Summaries", // TODO: Replace with L10n.HomeView.Summaries.title when available + L10n.HomeView.Summaries.title, showChevron: false ) } @@ -68,7 +59,7 @@ struct DomainSummariesSection: View { let summaries = [ HomeViewModel.DomainSummary( id: "light", - domain: "light", + domain: .light, displayName: "Lights", icon: "lightbulb.fill", count: 10, @@ -77,7 +68,7 @@ struct DomainSummariesSection: View { ), HomeViewModel.DomainSummary( id: "cover", - domain: "cover", + domain: .cover, displayName: "Covers", icon: "rectangle.on.rectangle.angled", count: 5, @@ -86,7 +77,7 @@ struct DomainSummariesSection: View { ), ] - return DomainSummariesSection(summaries: summaries) { summary in + DomainSummariesSection(summaries: summaries) { summary in print("Tapped: \(summary.displayName)") } .padding() diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift index 50ba9825d8..8dd31343cd 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift @@ -17,7 +17,7 @@ final class HomeViewModel: ObservableObject { struct DomainSummary: Identifiable { let id: String // domain name - let domain: String + let domain: Domain let displayName: String let icon: String let count: Int @@ -311,15 +311,15 @@ final class HomeViewModel: ObservableObject { private func computeDomainSummaries() { // Define domains we want to summarize (starting with light and cover) - let domainsToSummarize: [(domain: String, displayName: String, icon: String)] = [ - ("light", "Lights", "lightbulb.fill"), - ("cover", "Covers", "rectangle.on.rectangle.angled"), + let domainsToSummarize: [(domain: Domain, displayName: String, icon: String)] = [ + (.light, L10n.HomeView.Summaries.Lights.title, "lightbulb.fill"), + (.cover, L10n.HomeView.Summaries.Covers.title, "rectangle.on.rectangle.angled"), ] var summaries: [DomainSummary] = [] for domainInfo in domainsToSummarize { - let domainEntities = entityStates.values.filter { $0.domain == domainInfo.domain } + let domainEntities = entityStates.values.filter { $0.domain == domainInfo.domain.rawValue } // Filter out hidden and disabled entities let visibleEntities = domainEntities.filter { entity in @@ -343,7 +343,7 @@ final class HomeViewModel: ObservableObject { ) let summary = DomainSummary( - id: domainInfo.domain, + id: domainInfo.domain.rawValue, domain: domainInfo.domain, displayName: domainInfo.displayName, icon: domainInfo.icon, @@ -360,25 +360,28 @@ final class HomeViewModel: ObservableObject { private func isEntityActive(_ entity: HAEntity) -> Bool { // Check if entity is in an "active" state - switch entity.domain { - case "light", "switch", "fan": + guard let domain = Domain(entityId: entity.entityId) else { return entity.state == "on" - case "cover": + } + + switch domain { + case .light, .switch, .fan: + return entity.state == "on" + case .cover: return entity.state == "open" || entity.state == "opening" - case "lock": + case .lock: return entity.state == "unlocked" - case "climate": - return entity.state != "off" - case "media_player": - return entity.state == "playing" || entity.state == "paused" + case .automation, .scene, .script: + // These don't really have "active" states + return false default: return entity.state == "on" } } - private func generateSummaryText(domain: String, totalCount: Int, activeCount: Int) -> String { + private func generateSummaryText(domain: Domain, totalCount: Int, activeCount: Int) -> String { switch domain { - case "light": + case .light: if activeCount == 0 { // L10n.HomeView.Summaries.Light.allOff return "All off" @@ -386,7 +389,7 @@ final class HomeViewModel: ObservableObject { // L10n.HomeView.Summaries.Light.countOn(activeCount) return "\(activeCount) on" } - case "cover": + case .cover: if activeCount == 0 { // L10n.HomeView.Summaries.Cover.allClosed return "All closed" diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index e0e934d6a7..8fb6cd6222 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -1473,6 +1473,18 @@ public enum L10n { public static var experimental: String { return L10n.tr("Localizable", "home_view.navigation.subtitle.experimental") } } } + public enum Summaries { + /// Summaries + public static var title: String { return L10n.tr("Localizable", "home_view.summaries.title") } + public enum Covers { + /// Covers + public static var title: String { return L10n.tr("Localizable", "home_view.summaries.covers.title") } + } + public enum Lights { + /// Lights + public static var title: String { return L10n.tr("Localizable", "home_view.summaries.lights.title") } + } + } } public enum Improv { From 8334d7eddd8f7bdea61ea052d3032086a3d3b73c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:34:17 +0100 Subject: [PATCH 13/16] Show area name in entity tile state display Adds optional area name display to entity tiles by passing areaName to HomeEntityTileView and updating grid builders to support areaNameProvider. HomeViewModel now provides areaName lookup for entities, and HomeView passes this for usage prediction sections. Also improves text truncation for tile state display. --- .../EntityTile/EntityTileView.swift | 2 ++ .../EntityTile/HomeEntityTileView.swift | 12 ++++++++++-- .../WebView/ExperimentalSpace/Home/HomeView.swift | 9 +++++++-- .../ExperimentalSpace/Home/HomeViewModel.swift | 5 +++++ .../Shared/EntityDisplayComponents.swift | 15 +++++++++++---- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift b/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift index 11c3d0a972..1a344db597 100644 --- a/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift +++ b/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift @@ -158,6 +158,8 @@ struct EntityTileView: View { #else .foregroundColor(.secondary) #endif + .lineLimit(1) + .truncationMode(.tail) } private var iconView: some View { diff --git a/Sources/App/WebView/ExperimentalSpace/EntityTile/HomeEntityTileView.swift b/Sources/App/WebView/ExperimentalSpace/EntityTile/HomeEntityTileView.swift index 9bb60ac841..3cde84ab29 100644 --- a/Sources/App/WebView/ExperimentalSpace/EntityTile/HomeEntityTileView.swift +++ b/Sources/App/WebView/ExperimentalSpace/EntityTile/HomeEntityTileView.swift @@ -10,15 +10,17 @@ import SwiftUI struct HomeEntityTileView: View { let server: Server let haEntity: HAEntity + let areaName: String? @Namespace private var namespace @State private var iconColor: Color = .secondary @State private var showMoreInfoDialog = false @State private var deviceClass: DeviceClass = .unknown - init(server: Server, haEntity: HAEntity) { + init(server: Server, haEntity: HAEntity, areaName: String? = nil) { self.server = server self.haEntity = haEntity + self.areaName = areaName } var body: some View { @@ -55,7 +57,13 @@ struct HomeEntityTileView: View { } private var entityState: String { - Domain(entityId: haEntity.entityId)?.contextualStateDescription(for: haEntity) ?? haEntity.state + let state = Domain(entityId: haEntity.entityId)?.contextualStateDescription(for: haEntity) ?? haEntity.state + + if let areaName { + return "\(state) · \(areaName)" + } + + return state } private var icon: MaterialDesignIcons { diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift index f43af691e3..efa388b0d3 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift @@ -452,13 +452,18 @@ struct HomeView: View { } private func entityTilesGrid(for entities: [HAEntity], section: HomeViewModel.RoomSection) -> some View { - EntityDisplayComponents.conditionalEntityGrid( + let isUsagePredictionSection = section.id == HomeViewModel.usagePredictionSectionId + + return EntityDisplayComponents.conditionalEntityGrid( entities: entities, server: viewModel.server, isReorderMode: isReorderMode, draggedEntity: $draggedEntity, roomId: section.id, - viewModel: viewModel + viewModel: viewModel, + areaNameProvider: isUsagePredictionSection ? { entityId in + viewModel.areaName(for: entityId) + } : nil ) { entity in Group { EntityDisplayComponents.enterEditModeButton(isReorderMode: $isReorderMode) diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift index 8dd31343cd..dd3dbf136d 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift @@ -410,6 +410,11 @@ final class HomeViewModel: ObservableObject { } } + /// Returns the area name for a given entity ID, or nil if not found + func areaName(for entityId: String) -> String? { + groupedEntities.first(where: { $0.entityIds.contains(entityId) })?.name + } + func filteredSections(sectionOrder: [String], visibleSectionIds: Set) -> [RoomSection] { // Apply saved ordering let orderedSections: [RoomSection] diff --git a/Sources/App/WebView/ExperimentalSpace/Shared/EntityDisplayComponents.swift b/Sources/App/WebView/ExperimentalSpace/Shared/EntityDisplayComponents.swift index 4868f16968..40c9f8c904 100644 --- a/Sources/App/WebView/ExperimentalSpace/Shared/EntityDisplayComponents.swift +++ b/Sources/App/WebView/ExperimentalSpace/Shared/EntityDisplayComponents.swift @@ -55,13 +55,15 @@ enum EntityDisplayComponents { entities: [HAEntity], server: Server, isHidden: Bool = false, + areaNameProvider: ((String) -> String?)? = nil, contextMenuContent: @escaping (HAEntity) -> some View ) -> some View { LazyVGrid(columns: standardGridColumns, spacing: DesignSystem.Spaces.oneAndHalf) { ForEach(entities, id: \.entityId) { entity in HomeEntityTileView( server: server, - haEntity: entity + haEntity: entity, + areaName: areaNameProvider?(entity.entityId) ) .contentShape(Rectangle()) .opacity(isHidden ? 0.6 : 1.0) @@ -80,13 +82,15 @@ enum EntityDisplayComponents { server: Server, draggedEntity: Binding, roomId: String, - viewModel: HomeViewModel + viewModel: HomeViewModel, + areaNameProvider: ((String) -> String?)? = nil ) -> some View { LazyVGrid(columns: standardGridColumns, spacing: DesignSystem.Spaces.oneAndHalf) { ForEach(entities, id: \.entityId) { entity in HomeEntityTileView( server: server, - haEntity: entity + haEntity: entity, + areaName: areaNameProvider?(entity.entityId) ) .contentShape(Rectangle()) .modifier(EditModeIndicatorModifier( @@ -181,6 +185,7 @@ enum EntityDisplayComponents { draggedEntity: Binding, roomId: String, viewModel: HomeViewModel, + areaNameProvider: ((String) -> String?)? = nil, contextMenuContent: @escaping (HAEntity) -> some View ) -> some View { Group { @@ -190,13 +195,15 @@ enum EntityDisplayComponents { server: server, draggedEntity: draggedEntity, roomId: roomId, - viewModel: viewModel + viewModel: viewModel, + areaNameProvider: areaNameProvider ) } else { entityTilesGrid( entities: entities, server: server, isHidden: isHidden, + areaNameProvider: areaNameProvider, contextMenuContent: contextMenuContent ) } From ae88cb0f3667b75d630078e354c3451343c854da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:41:06 +0100 Subject: [PATCH 14/16] Update domainSummaries only on change and add Equatable Made DomainSummary conform to Equatable and updated the logic to assign domainSummaries only if the new value differs from the current one. Logging now occurs only when domainSummaries is actually updated, reducing unnecessary log noise. --- .../ExperimentalSpace/Home/HomeViewModel.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift index dd3dbf136d..c54f402b16 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift @@ -15,7 +15,7 @@ final class HomeViewModel: ObservableObject { let entityIds: Set } - struct DomainSummary: Identifiable { + struct DomainSummary: Identifiable, Equatable { let id: String // domain name let domain: Domain let displayName: String @@ -56,11 +56,7 @@ final class HomeViewModel: ObservableObject { private var lastUsagePredictionLoadTime: Date? private let usagePredictionLoadInterval: TimeInterval = 120 // 2 minutes - var domainSummaries: [DomainSummary] = [] { - didSet { - Current.Log.verbose("Domain summaries updated: \(domainSummaries.count) summaries") - } - } + var domainSummaries: [DomainSummary] = [] var orderedSectionsForMenu: [RoomSection] { // Use the same ordering logic as filteredSections, but show ALL sections (no filtering) @@ -355,7 +351,11 @@ final class HomeViewModel: ObservableObject { summaries.append(summary) } - domainSummaries = summaries + // Only update if summaries actually changed + if domainSummaries != summaries { + domainSummaries = summaries + Current.Log.verbose("Domain summaries updated: \(domainSummaries.count) summaries") + } } private func isEntityActive(_ entity: HAEntity) -> Bool { From 058b119f7f50667d0b5f3f2f1006de86386ee492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:49:47 +0100 Subject: [PATCH 15/16] Add localized summary strings for Home view Introduces new localized strings for Home view summaries, including lights, covers, and active counts. Updates DomainSummaryCard and HomeViewModel to use these localized strings via Swiftgen, improving internationalization and consistency. --- .../Resources/en.lproj/Localizable.strings | 5 ++++ .../Home/DomainSummaryCard.swift | 6 +---- .../Home/HomeViewModel.swift | 24 ++++++++----------- .../Shared/Resources/Swiftgen/Strings.swift | 16 +++++++++++++ 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 400ba2632f..fe5c632310 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -364,6 +364,11 @@ "home_view.summaries.title" = "Summaries"; "home_view.summaries.lights.title" = "Lights"; "home_view.summaries.covers.title" = "Covers"; +"home_view.summaries.lights.all_off" = "All off"; +"home_view.summaries.lights.count_on" = "%d on"; +"home_view.summaries.covers.all_closed" = "All closed"; +"home_view.summaries.covers.count_open" = "%d open"; +"home_view.summaries.count_active" = "%d active"; "improv.button.continue" = "Continue"; "improv.connection_state.authorized" = "Setting up Wi-Fi"; "improv.connection_state.provisioning" = "Connecting to Wi-Fi"; diff --git a/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift b/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift index e6778a50b1..8d53f5e689 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift @@ -10,17 +10,13 @@ struct DomainSummaryCard: View { EntityTileView( entityName: summary.displayName, entityState: summary.summaryText, - icon: iconForDomain, + icon: summary.domain.icon(), iconColor: summary.isActive ? summary.domain.accentColor : .gray, isUnavailable: false, onIconTap: action, onTileTap: action ) } - - var iconForDomain: MaterialDesignIcons { - summary.domain.icon() - } } @available(iOS 26.0, *) diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift index c54f402b16..9755a1a159 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift @@ -361,21 +361,21 @@ final class HomeViewModel: ObservableObject { private func isEntityActive(_ entity: HAEntity) -> Bool { // Check if entity is in an "active" state guard let domain = Domain(entityId: entity.entityId) else { - return entity.state == "on" + return entity.state == Domain.State.on.rawValue } switch domain { case .light, .switch, .fan: - return entity.state == "on" + return entity.state == Domain.State.on.rawValue case .cover: - return entity.state == "open" || entity.state == "opening" + return entity.state == Domain.State.open.rawValue || entity.state == Domain.State.opening.rawValue case .lock: - return entity.state == "unlocked" + return entity.state == Domain.State.unlocked.rawValue case .automation, .scene, .script: // These don't really have "active" states return false default: - return entity.state == "on" + return entity.state == Domain.State.on.rawValue } } @@ -383,22 +383,18 @@ final class HomeViewModel: ObservableObject { switch domain { case .light: if activeCount == 0 { - // L10n.HomeView.Summaries.Light.allOff - return "All off" + return L10n.HomeView.Summaries.Lights.allOff } else { - // L10n.HomeView.Summaries.Light.countOn(activeCount) - return "\(activeCount) on" + return L10n.HomeView.Summaries.Lights.countOn(activeCount) } case .cover: if activeCount == 0 { - // L10n.HomeView.Summaries.Cover.allClosed - return "All closed" + return L10n.HomeView.Summaries.Covers.allClosed } else { - // L10n.HomeView.Summaries.Cover.countOpen(activeCount) - return "\(activeCount) open" + return L10n.HomeView.Summaries.Covers.countOpen(activeCount) } default: - return "\(activeCount) active" + return L10n.HomeView.Summaries.countActive(activeCount) } } diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 8fb6cd6222..8f370d8356 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -1474,13 +1474,29 @@ public enum L10n { } } public enum Summaries { + /// %d active + public static func countActive(_ p1: Int) -> String { + return L10n.tr("Localizable", "home_view.summaries.count_active", p1) + } /// Summaries public static var title: String { return L10n.tr("Localizable", "home_view.summaries.title") } public enum Covers { + /// All closed + public static var allClosed: String { return L10n.tr("Localizable", "home_view.summaries.covers.all_closed") } + /// %d open + public static func countOpen(_ p1: Int) -> String { + return L10n.tr("Localizable", "home_view.summaries.covers.count_open", p1) + } /// Covers public static var title: String { return L10n.tr("Localizable", "home_view.summaries.covers.title") } } public enum Lights { + /// All off + public static var allOff: String { return L10n.tr("Localizable", "home_view.summaries.lights.all_off") } + /// %d on + public static func countOn(_ p1: Int) -> String { + return L10n.tr("Localizable", "home_view.summaries.lights.count_on", p1) + } /// Lights public static var title: String { return L10n.tr("Localizable", "home_view.summaries.lights.title") } } From c945751a6464f0ce248b313f056b58894616b7ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:57:20 +0100 Subject: [PATCH 16/16] Add toggle for showing summaries in Home view Introduces a new 'showSummaries' option in HomeViewConfiguration, allowing users to enable or disable the display of summaries in the Home view. Updates the UI to include a toggle for this setting and adds necessary localization and database support. --- Sources/App/Resources/en.lproj/Localizable.strings | 1 + .../App/WebView/ExperimentalSpace/Home/HomeView.swift | 2 +- .../ExperimentalSpace/Home/HomeViewConfiguration.swift | 5 ++++- .../Home/HomeViewCustomizationView.swift | 10 ++++++++++ Sources/Shared/Database/DatabaseTables.swift | 1 + .../Environment/HomeViewConfigurationTable.swift | 1 + Sources/Shared/Resources/Swiftgen/Strings.swift | 4 ++++ 7 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index fe5c632310..7822e5a43f 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -361,6 +361,7 @@ "home_view.customization.areas_layout.title" = "Areas layout"; "home_view.customization.areas_layout.list.title" = "List"; "home_view.customization.areas_layout.grid.title" = "Grid"; +"home_view.customization.summaries.title" = "Summaries"; "home_view.summaries.title" = "Summaries"; "home_view.summaries.lights.title" = "Lights"; "home_view.summaries.covers.title" = "Covers"; diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift index efa388b0d3..0fa5c917a8 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift @@ -217,7 +217,7 @@ struct HomeView: View { @ViewBuilder private var summariesSection: some View { - if !viewModel.domainSummaries.isEmpty { + if viewModel.configuration.showSummaries, !viewModel.domainSummaries.isEmpty { DomainSummariesSection(summaries: viewModel.domainSummaries) { summary in // TODO: Handle tap - could navigate to a filtered view of that domain Current.Log.info("Tapped domain summary: \(summary.displayName)") diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewConfiguration.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewConfiguration.swift index 93d09346e4..e1ac85456f 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewConfiguration.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewConfiguration.swift @@ -36,6 +36,7 @@ struct HomeViewConfiguration: Codable, FetchableRecord, PersistableRecord, Equat var hiddenEntityIds: Set var showUsagePredictionSection: Bool var areasLayout: AreasLayout? + var showSummaries: Bool init( id: String, @@ -45,7 +46,8 @@ struct HomeViewConfiguration: Codable, FetchableRecord, PersistableRecord, Equat entityOrderByRoom: [String: [String]] = [:], hiddenEntityIds: Set = [], showUsagePredictionSection: Bool = true, - areasLayout: AreasLayout? = .list + areasLayout: AreasLayout? = .list, + showSummaries: Bool = true ) { self.id = id self.sectionOrder = sectionOrder @@ -55,6 +57,7 @@ struct HomeViewConfiguration: Codable, FetchableRecord, PersistableRecord, Equat self.hiddenEntityIds = hiddenEntityIds self.showUsagePredictionSection = showUsagePredictionSection self.areasLayout = areasLayout + self.showSummaries = showSummaries } /// Fetch configuration for a specific server diff --git a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewCustomizationView.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewCustomizationView.swift index 0d16a48743..5dc330140f 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeViewCustomizationView.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewCustomizationView.swift @@ -35,6 +35,16 @@ struct HomeViewCustomizationView: View { } ) ) + + Toggle( + L10n.HomeView.Customization.Summaries.title, + isOn: Binding( + get: { viewModel.configuration.showSummaries }, + set: { newValue in + viewModel.configuration.showSummaries = newValue + } + ) + ) } } diff --git a/Sources/Shared/Database/DatabaseTables.swift b/Sources/Shared/Database/DatabaseTables.swift index 31de5c4340..63a816540e 100644 --- a/Sources/Shared/Database/DatabaseTables.swift +++ b/Sources/Shared/Database/DatabaseTables.swift @@ -102,6 +102,7 @@ public enum DatabaseTables { case hiddenEntityIds case showUsagePredictionSection case areasLayout + case showSummaries } // Camera List Configuration (per server) diff --git a/Sources/Shared/Environment/HomeViewConfigurationTable.swift b/Sources/Shared/Environment/HomeViewConfigurationTable.swift index f21c1301ac..e860d135a6 100644 --- a/Sources/Shared/Environment/HomeViewConfigurationTable.swift +++ b/Sources/Shared/Environment/HomeViewConfigurationTable.swift @@ -18,6 +18,7 @@ final class HomeViewConfigurationTable: DatabaseTableProtocol { t.column(DatabaseTables.HomeViewConfiguration.hiddenEntityIds.rawValue, .jsonText) t.column(DatabaseTables.HomeViewConfiguration.showUsagePredictionSection.rawValue, .boolean) t.column(DatabaseTables.HomeViewConfiguration.areasLayout.rawValue, .text) + t.column(DatabaseTables.HomeViewConfiguration.showSummaries.rawValue, .boolean) } } } else { diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 8f370d8356..eb8a57995f 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -1446,6 +1446,10 @@ public enum L10n { /// Controls prediction section public static var title: String { return L10n.tr("Localizable", "home_view.customization.common_controls.title") } } + public enum Summaries { + /// Summaries + public static var title: String { return L10n.tr("Localizable", "home_view.customization.summaries.title") } + } } public enum EmptyState { /// No entities found