Skip to content

Commit 6f577eb

Browse files
authored
Add rooms layout option to native dashboard (#4220)
<!-- Thank you for submitting a Pull Request and helping to improve Home Assistant. Please complete the following sections to help the processing and review of your changes. Please do not delete anything from this template. --> ## Summary <!-- Provide a brief summary of the changes you have made and most importantly what they aim to achieve --> ## Screenshots <!-- If this is a user-facing change not in the frontend, please include screenshots in light and dark mode. --> <img width="2568" height="1688" alt="CleanShot 2026-01-14 at 14 45 37@2x" src="https://github.com/user-attachments/assets/a1deb196-c51d-47e1-a2a3-15f6b82b697f" /> ## Link to pull request in Documentation repository <!-- Pull requests that add, change or remove functionality must have a corresponding pull request in the Companion App Documentation repository (https://github.com/home-assistant/companion.home-assistant). Please add the number of this pull request after the "#" --> Documentation: home-assistant/companion.home-assistant# ## Any other notes <!-- If there is any other information of note, like if this Pull Request is part of a bigger change, please include it here. -->
1 parent 5546cbe commit 6f577eb

File tree

15 files changed

+396
-45
lines changed

15 files changed

+396
-45
lines changed

HomeAssistant.xcodeproj/project.pbxproj

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,9 @@
923923
429BA2AF2C800CAB00A50996 /* SFSymbolEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429BA2AE2C800CAB00A50996 /* SFSymbolEntity.swift */; };
924924
429BEA1A2D102F3A00F070F9 /* ConnectionErrorDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429BEA192D102F3A00F070F9 /* ConnectionErrorDetailsView.swift */; };
925925
429BEA1D2D10315F00F070F9 /* SheetCloseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429BEA1B2D1030EA00F070F9 /* SheetCloseButton.swift */; };
926+
429C33C22F17A7010033EF5E /* HAUsagePredictionCommonControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C33C12F17A7010033EF5E /* HAUsagePredictionCommonControl.swift */; };
927+
429C33C32F17A7010033EF5E /* HAUsagePredictionCommonControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C33C12F17A7010033EF5E /* HAUsagePredictionCommonControl.swift */; };
928+
429C33C52F17CE4C0033EF5E /* AreaGridButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C33C42F17CE4C0033EF5E /* AreaGridButton.swift */; };
926929
429C33BF2F17989F0033EF5E /* EntityPickerViewModel.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */; };
927930
429C72202B28D0EC00BCD558 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C721F2B28D0EC00BCD558 /* Haptics.swift */; };
928931
429C82892DCCDB0F007B03AF /* HAProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E4C9922DCC9A6600DF2813 /* HAProgressView.swift */; };
@@ -2562,6 +2565,8 @@
25622565
429BA2AE2C800CAB00A50996 /* SFSymbolEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFSymbolEntity.swift; sourceTree = "<group>"; };
25632566
429BEA192D102F3A00F070F9 /* ConnectionErrorDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionErrorDetailsView.swift; sourceTree = "<group>"; };
25642567
429BEA1B2D1030EA00F070F9 /* SheetCloseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetCloseButton.swift; sourceTree = "<group>"; };
2568+
429C33C12F17A7010033EF5E /* HAUsagePredictionCommonControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAUsagePredictionCommonControl.swift; sourceTree = "<group>"; };
2569+
429C33C42F17CE4C0033EF5E /* AreaGridButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AreaGridButton.swift; sourceTree = "<group>"; };
25652570
429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPickerViewModel.test.swift; sourceTree = "<group>"; };
25662571
429C721F2B28D0EC00BCD558 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = "<group>"; };
25672572
429D51C42EFA0E750014AD0D /* ModernAssistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModernAssistView.swift; sourceTree = "<group>"; };
@@ -5384,6 +5389,16 @@
53845389
path = General;
53855390
sourceTree = "<group>";
53865391
};
5392+
429C33C02F17A6CF0033EF5E /* Models */ = {
5393+
isa = PBXGroup;
5394+
children = (
5395+
42333ADA2D0B1771001E8408 /* EntityRegistryListForDisplay.swift */,
5396+
42F1DA6F2B4EE2E8002729BC /* HAAreasRegistryResponse.swift */,
5397+
429C33C12F17A7010033EF5E /* HAUsagePredictionCommonControl.swift */,
5398+
);
5399+
path = Models;
5400+
sourceTree = "<group>";
5401+
};
53875402
429C33BE2F17988D0033EF5E /* EntityPicker */ = {
53885403
isa = PBXGroup;
53895404
children = (
@@ -5773,11 +5788,12 @@
57735788
isa = PBXGroup;
57745789
children = (
57755790
42DFE8772EFB08C80058DADB /* HomeView.swift */,
5776-
428DC0132F0D921F003B08D5 /* Domain+AccentColor.swift */,
57775791
42DFE8782EFB08C80058DADB /* HomeViewModel.swift */,
5792+
428DC0132F0D921F003B08D5 /* Domain+AccentColor.swift */,
57785793
42F5C0822F02B6DB00C50310 /* HomeSectionsReorderView.swift */,
57795794
4289FCB82F0BFF50005189AA /* HomeViewConfiguration.swift */,
57805795
420C914F2F0C6CAC005D04A6 /* HomeViewCustomizationView.swift */,
5796+
429C33C42F17CE4C0033EF5E /* AreaGridButton.swift */,
57815797
);
57825798
path = Home;
57835799
sourceTree = "<group>";
@@ -7024,13 +7040,12 @@
70247040
D03D891820E0A85300D4F28D /* Shared */ = {
70257041
isa = PBXGroup;
70267042
children = (
7043+
42BB4C362CD26490003E47FD /* HATypedRequest+App.swift */,
7044+
429C33C02F17A6CF0033EF5E /* Models */,
70277045
4245DC022EBA42C3005E0E04 /* AreasService.swift */,
70287046
420CFC612D3F9C15009A94F3 /* Database */,
70297047
4278CB822D01F09400CFAAC9 /* AppGesture.swift */,
70307048
424D2D0F2C89DACE00C610F1 /* HAAppEntity.swift */,
7031-
42BB4C362CD26490003E47FD /* HATypedRequest+App.swift */,
7032-
42333ADA2D0B1771001E8408 /* EntityRegistryListForDisplay.swift */,
7033-
42F1DA6F2B4EE2E8002729BC /* HAAreasRegistryResponse.swift */,
70347049
426D9C722C9C582F00F278AF /* ControlEntityProvider.swift */,
70357050
42A47D4A2C9AEF10003C597D /* DataWidgetsUpdater.swift */,
70367051
427E92BB2D65E3FE0001566B /* WidgetInteractionType.swift */,
@@ -9307,6 +9322,7 @@
93079322
42F1DA6B2B4ED1BF002729BC /* CarPlayAreasZonesTemplate.swift in Sources */,
93089323
11A48D8124CA8ADB0021BDD9 /* NotificationCategory+Observation.swift in Sources */,
93099324
42C60F8D2F057DB70071A6F6 /* VerticalSlider.swift in Sources */,
9325+
429C33C52F17CE4C0033EF5E /* AreaGridButton.swift in Sources */,
93109326
4263A6692EFB2F670089C338 /* ModernAssistBackgroundView.swift in Sources */,
93119327
42462E692D9ED75900ECC8A7 /* LocationPermissionView.swift in Sources */,
93129328
42D0AE452D88259000D9715A /* WidgetDocumentationLink.swift in Sources */,
@@ -9593,6 +9609,7 @@
95939609
B672333F225DB68B0031D629 /* WebSocketMessage.swift in Sources */,
95949610
11AF4D1A249C8253006C74C0 /* PedometerSensor.swift in Sources */,
95959611
1117FB4D250C5F7C00895C13 /* DeviceBattery.swift in Sources */,
9612+
429C33C32F17A7010033EF5E /* HAUsagePredictionCommonControl.swift in Sources */,
95969613
42070EEC2BAC517A0031E96F /* AssistInAppIntentHandler.swift in Sources */,
95979614
11B38EF9275C54A300205C7B /* RenderTemplateIntentHandler.swift in Sources */,
95989615
427A7CDA2EBDFB1700D17841 /* AppArea.swift in Sources */,
@@ -10080,6 +10097,7 @@
1008010097
B6A258482232539900ADD202 /* WebhookUpdateLocation.swift in Sources */,
1008110098
B6B74CBA2283983800D58A68 /* CLKComplication+Strings.swift in Sources */,
1008210099
119385A4249E8E360097F497 /* StorageSensor.swift in Sources */,
10100+
429C33C22F17A7010033EF5E /* HAUsagePredictionCommonControl.swift in Sources */,
1008310101
D05A4D32216DD206009FD1EB /* MJPEGStreamer.swift in Sources */,
1008410102
42B2637F2E16A1DC0042DF10 /* BaseColors.swift in Sources */,
1008510103
426266452C11B02C0081A818 /* InteractiveImmediateMessages.swift in Sources */,

Sources/App/Resources/en.lproj/Localizable.strings

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@
346346
"home_sections_reorder_view.done" = "Done";
347347
"home_sections_reorder_view.title" = "Reorder Rooms";
348348
"home_view.context_menu.hide" = "Hide";
349+
"home_view.areas.title" = "Areas";
349350
"home_view.empty_state.no_entities" = "No entities found";
350351
"home_view.menu.allow_multiple_selection" = "Allow multiple selection";
351352
"home_view.menu.customize" = "Customize";
@@ -355,6 +356,11 @@
355356
"home_view.menu.settings" = "Settings";
356357
"home_view.menu.show_all" = "Show All";
357358
"home_view.navigation.subtitle.experimental" = "Experimental feature";
359+
"home_view.common_controls.title" = "Welcome %@";
360+
"home_view.customization.common_controls.title" = "Controls prediction section";
361+
"home_view.customization.areas_layout.title" = "Areas layout";
362+
"home_view.customization.areas_layout.list.title" = "List";
363+
"home_view.customization.areas_layout.grid.title" = "Grid";
358364
"improv.button.continue" = "Continue";
359365
"improv.connection_state.authorized" = "Setting up Wi-Fi";
360366
"improv.connection_state.provisioning" = "Connecting to Wi-Fi";
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import SFSafeSymbols
2+
import Shared
3+
import SwiftUI
4+
5+
@available(iOS 26.0, *)
6+
struct AreaGridButton: View {
7+
enum Constants {
8+
static let cornerRadius: CGFloat = DesignSystem.CornerRadius.two
9+
static let borderLineWidth: CGFloat = DesignSystem.Border.Width.default
10+
static let iconSize: CGFloat = 32
11+
}
12+
13+
let section: HomeViewModel.RoomSection
14+
let action: () -> Void
15+
16+
var body: some View {
17+
Button(action: action) {
18+
VStack(spacing: DesignSystem.Spaces.oneAndHalf) {
19+
icon
20+
Text(section.name)
21+
.font(.footnote.weight(.semibold))
22+
.foregroundColor(Color(uiColor: .label))
23+
.lineLimit(2)
24+
.truncationMode(.middle)
25+
.multilineTextAlignment(.center)
26+
.padding(.horizontal, DesignSystem.Spaces.one)
27+
}
28+
.frame(maxWidth: .infinity, maxHeight: .infinity)
29+
.aspectRatio(1, contentMode: .fill)
30+
.background(Color.tileBackground)
31+
.clipShape(RoundedRectangle(cornerRadius: Constants.cornerRadius))
32+
.overlay(
33+
RoundedRectangle(cornerRadius: Constants.cornerRadius)
34+
.stroke(Color.tileBorder, lineWidth: Constants.borderLineWidth)
35+
)
36+
}
37+
}
38+
39+
private var icon: some View {
40+
let materialDesignIcon = MaterialDesignIcons(
41+
serversideValueNamed: section.icon.orEmpty,
42+
fallback: .squareRoundedOutlineIcon
43+
)
44+
return Group {
45+
Image(uiImage: materialDesignIcon.image(
46+
ofSize: .init(width: Constants.iconSize, height: Constants.iconSize),
47+
color: .haPrimary
48+
))
49+
}
50+
.font(.system(size: Constants.iconSize))
51+
}
52+
}

Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift

Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -121,29 +121,95 @@ struct HomeView: View {
121121
sectionOrder: viewModel.configuration.sectionOrder,
122122
visibleSectionIds: viewModel.configuration.visibleSectionIds
123123
)
124+
let layout = viewModel.configuration.areasLayout ?? .list
124125

125126
return ScrollView {
126-
LazyVStack(
127-
alignment: .leading,
128-
spacing: DesignSystem.Spaces.three
129-
) {
130-
ForEach(filteredSections) { section in
131-
let visibleEntities = visibleEntitiesForSection(section)
132-
if !visibleEntities.isEmpty || viewModel.configuration.visibleSectionIds.contains(section.id) {
133-
Section {
134-
entityTilesGrid(
135-
for: visibleEntities,
136-
section: section
127+
switch layout {
128+
case .grid:
129+
areasGridView(sections: filteredSections)
130+
case .list:
131+
areasListView(sections: filteredSections)
132+
}
133+
}
134+
.transition(.opacity.combined(with: .move(edge: .bottom)))
135+
}
136+
137+
private func areasListView(sections: [HomeViewModel.RoomSection]) -> some View {
138+
LazyVStack(
139+
alignment: .leading,
140+
spacing: DesignSystem.Spaces.three
141+
) {
142+
predictionSection
143+
144+
// Display regular sections
145+
ForEach(sections) { section in
146+
let visibleEntities = visibleEntitiesForSection(section)
147+
if !visibleEntities.isEmpty || viewModel.configuration.visibleSectionIds.contains(section.id) {
148+
Section {
149+
entityTilesGrid(
150+
for: visibleEntities,
151+
section: section
152+
)
153+
} header: {
154+
sectionHeader(section.name, section: section)
155+
}
156+
}
157+
}
158+
}
159+
.padding()
160+
}
161+
162+
private func areasGridView(sections: [HomeViewModel.RoomSection]) -> some View {
163+
VStack(alignment: .leading, spacing: .zero) {
164+
predictionSection
165+
.padding([.top, .horizontal])
166+
167+
VStack(alignment: .leading, spacing: DesignSystem.Spaces.two) {
168+
EntityDisplayComponents.sectionHeader(
169+
L10n.HomeView.Areas.title,
170+
showChevron: false
171+
)
172+
LazyVGrid(
173+
columns: [
174+
GridItem(.adaptive(minimum: 100, maximum: 150), spacing: DesignSystem.Spaces.one),
175+
],
176+
spacing: DesignSystem.Spaces.one
177+
) {
178+
ForEach(sections) { section in
179+
if !isReorderMode {
180+
AreaGridButton(
181+
section: section,
182+
action: {
183+
selectedRoom = (id: section.id, name: section.name)
184+
}
137185
)
138-
} header: {
139-
sectionHeader(section.name, section: section)
186+
.matchedTransitionSource(id: section.id, in: roomNameSpace)
140187
}
141188
}
142189
}
143190
}
144191
.padding()
145192
}
146-
.transition(.opacity.combined(with: .move(edge: .bottom)))
193+
}
194+
195+
@ViewBuilder
196+
private var predictionSection: some View {
197+
if viewModel.configuration.showUsagePredictionSection {
198+
// Display usage prediction common control section at the top
199+
if let usagePredictionSection = viewModel.usagePredictionSection {
200+
let visibleEntities = visibleEntitiesForSection(usagePredictionSection)
201+
if !visibleEntities.isEmpty {
202+
Section {
203+
entityTilesGrid(
204+
for: visibleEntities,
205+
section: usagePredictionSection
206+
)
207+
} header: {
208+
sectionHeader(usagePredictionSection.name, section: usagePredictionSection)
209+
}
210+
}
211+
}
212+
}
147213
}
148214

149215
private func visibleEntitiesForSection(_ section: HomeViewModel.RoomSection) -> [HAEntity] {
@@ -352,7 +418,13 @@ struct HomeView: View {
352418
@ViewBuilder
353419
private func sectionHeader(_ title: String, section: HomeViewModel.RoomSection) -> some View {
354420
Group {
355-
if let section = viewModel.groupedEntities.first(where: { $0.name == title }) {
421+
// Handle usage prediction section separately (not in groupedEntities)
422+
if section.id == HomeViewModel.usagePredictionSectionId {
423+
EntityDisplayComponents.sectionHeader(
424+
title,
425+
showChevron: false
426+
)
427+
} else if let section = viewModel.groupedEntities.first(where: { $0.name == title }) {
356428
EntityDisplayComponents.sectionHeader(
357429
title,
358430
showChevron: true,

Sources/App/WebView/ExperimentalSpace/Home/HomeViewConfiguration.swift

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,60 @@
11
import Foundation
22
import GRDB
3+
import SFSafeSymbols
34
import Shared
45

56
struct HomeViewConfiguration: Codable, FetchableRecord, PersistableRecord, Equatable {
7+
enum AreasLayout: Codable, CaseIterable {
8+
case list
9+
case grid
10+
11+
var localizableName: String {
12+
switch self {
13+
case .list:
14+
return L10n.HomeView.Customization.AreasLayout.List.title
15+
case .grid:
16+
return L10n.HomeView.Customization.AreasLayout.Grid.title
17+
}
18+
}
19+
20+
var icon: SFSymbol {
21+
switch self {
22+
case .list:
23+
return .listBullet
24+
case .grid:
25+
return .squareGrid2x2Fill
26+
}
27+
}
28+
}
29+
630
/// Server identifier (primary key)
731
let id: String
832
var sectionOrder: [String]
933
var visibleSectionIds: Set<String>
1034
var allowMultipleSelection: Bool
1135
var entityOrderByRoom: [String: [String]]
1236
var hiddenEntityIds: Set<String>
37+
var showUsagePredictionSection: Bool
38+
var areasLayout: AreasLayout?
1339

1440
init(
1541
id: String,
1642
sectionOrder: [String] = [],
1743
visibleSectionIds: Set<String> = [],
1844
allowMultipleSelection: Bool = false,
1945
entityOrderByRoom: [String: [String]] = [:],
20-
hiddenEntityIds: Set<String> = []
46+
hiddenEntityIds: Set<String> = [],
47+
showUsagePredictionSection: Bool = true,
48+
areasLayout: AreasLayout? = .list
2149
) {
2250
self.id = id
2351
self.sectionOrder = sectionOrder
2452
self.visibleSectionIds = visibleSectionIds
2553
self.allowMultipleSelection = allowMultipleSelection
2654
self.entityOrderByRoom = entityOrderByRoom
2755
self.hiddenEntityIds = hiddenEntityIds
56+
self.showUsagePredictionSection = showUsagePredictionSection
57+
self.areasLayout = areasLayout
2858
}
2959

3060
/// Fetch configuration for a specific server

0 commit comments

Comments
 (0)