diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 9aa486555e..4759a84420 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 */; }; @@ -782,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 */; }; @@ -923,10 +925,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 +2336,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 = ""; }; @@ -2426,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 = ""; }; @@ -2565,9 +2569,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 +5393,14 @@ path = General; sourceTree = ""; }; + 429C33BE2F17988D0033EF5E /* EntityPicker */ = { + isa = PBXGroup; + children = ( + 429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */, + ); + path = EntityPicker; + sourceTree = ""; + }; 429C33C02F17A6CF0033EF5E /* Models */ = { isa = PBXGroup; children = ( @@ -5397,14 +5409,6 @@ 429C33C12F17A7010033EF5E /* HAUsagePredictionCommonControl.swift */, ); path = Models; - sourceTree = ""; - }; - 429C33BE2F17988D0033EF5E /* EntityPicker */ = { - isa = PBXGroup; - children = ( - 429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */, - ); - path = EntityPicker; sourceTree = ""; }; 42A2AB7E2C80750A00C5608D /* Control */ = { @@ -5794,6 +5798,7 @@ 4289FCB82F0BFF50005189AA /* HomeViewConfiguration.swift */, 420C914F2F0C6CAC005D04A6 /* HomeViewCustomizationView.swift */, 429C33C42F17CE4C0033EF5E /* AreaGridButton.swift */, + 422FB62E2F17E33800243A68 /* DomainSummaryCard.swift */, ); path = Home; sourceTree = ""; @@ -5804,6 +5809,7 @@ 42DFE87C2EFB128B0058DADB /* EntityTileView.swift */, 4236229F2F05587800391BD0 /* EntityIconColorProvider.swift */, 42C60F902F07FB0F0071A6F6 /* MoreInfoDialog */, + 426D2F022F17E8530062C359 /* HomeEntityTileView.swift */, ); path = EntityTile; sourceTree = ""; @@ -9171,6 +9177,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 */, @@ -9296,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/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index db586f9cde..7822e5a43f 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -361,6 +361,15 @@ "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"; +"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/EntityTile/EntityTileView.swift b/Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift index bbd2c7a0cd..1a344db597 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) @@ -38,27 +100,21 @@ 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) + ) ) - .onChange(of: haEntity) { _, _ in - updateIconColor() - } - .onAppear { - getDeviceClass() - updateIconColor() - } - .matchedTransitionSource(id: haEntity.entityId, in: namespace) + .opacity(isUnavailable ? 0.5 : 1.0) .onTapGesture { - showMoreInfoDialog = true - } - .fullScreenCover(isPresented: $showMoreInfoDialog) { - EntityMoreInfoDialogView( - server: server, haEntity: haEntity - ) - .navigationTransition(.zoom(sourceID: haEntity.entityId, in: namespace)) + onTileTap?() } } + // MARK: - View Components + private var tileContent: some View { VStack(alignment: .leading, spacing: DesignSystem.Spaces.one) { contentRow @@ -82,42 +138,35 @@ 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 + .lineLimit(1) + .truncationMode(.tail) } 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)) @@ -129,26 +178,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] - - 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..3cde84ab29 --- /dev/null +++ b/Sources/App/WebView/ExperimentalSpace/EntityTile/HomeEntityTileView.swift @@ -0,0 +1,130 @@ +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 + 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, areaName: String? = nil) { + self.server = server + self.haEntity = haEntity + self.areaName = areaName + } + + 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 { + let state = Domain(entityId: haEntity.entityId)?.contextualStateDescription(for: haEntity) ?? haEntity.state + + if let areaName { + return "\(state) ยท \(areaName)" + } + + return 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/Home/DomainSummaryCard.swift b/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift new file mode 100644 index 0000000000..8d53f5e689 --- /dev/null +++ b/Sources/App/WebView/ExperimentalSpace/Home/DomainSummaryCard.swift @@ -0,0 +1,81 @@ +import Shared +import SwiftUI + +@available(iOS 26.0, *) +struct DomainSummaryCard: View { + let summary: HomeViewModel.DomainSummary + let action: () -> Void + + var body: some View { + EntityTileView( + entityName: summary.displayName, + entityState: summary.summaryText, + icon: summary.domain.icon(), + iconColor: summary.isActive ? summary.domain.accentColor : .gray, + isUnavailable: false, + onIconTap: action, + onTileTap: action + ) + } +} + +@available(iOS 26.0, *) +struct DomainSummariesSection: View { + let summaries: [HomeViewModel.DomainSummary] + let onTapSummary: (HomeViewModel.DomainSummary) -> Void + + var body: some View { + if !summaries.isEmpty { + Section { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: DesignSystem.Spaces.one), + GridItem(.flexible(), spacing: DesignSystem.Spaces.one), + ], + spacing: DesignSystem.Spaces.one + ) { + ForEach(summaries) { summary in + DomainSummaryCard(summary: summary) { + onTapSummary(summary) + } + } + } + } header: { + EntityDisplayComponents.sectionHeader( + L10n.HomeView.Summaries.title, + showChevron: false + ) + } + } + } +} + +@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" + ), + ] + + 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..0fa5c917a8 100644 --- a/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift +++ b/Sources/App/WebView/ExperimentalSpace/Home/HomeView.swift @@ -210,6 +210,19 @@ struct HomeView: View { } } } + + // Display domain summaries section + summariesSection + } + + @ViewBuilder + private var summariesSection: some View { + 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)") + } + } } private func visibleEntitiesForSection(_ section: HomeViewModel.RoomSection) -> [HAEntity] { @@ -439,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/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/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift b/Sources/App/WebView/ExperimentalSpace/Home/HomeViewModel.swift index c75b541dfd..9755a1a159 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, Equatable { + let id: String // domain name + let domain: Domain + 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" @@ -42,6 +56,8 @@ final class HomeViewModel: ObservableObject { private var lastUsagePredictionLoadTime: Date? private let usagePredictionLoadInterval: TimeInterval = 120 // 2 minutes + var domainSummaries: [DomainSummary] = [] + var orderedSectionsForMenu: [RoomSection] { // Use the same ordering logic as filteredSections, but show ALL sections (no filtering) if configuration.sectionOrder.isEmpty { @@ -220,6 +236,7 @@ final class HomeViewModel: ObservableObject { onChange: { [weak self] entities in guard let self else { return } appEntities = entities + computeDomainSummaries() } ) } @@ -282,6 +299,102 @@ 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: 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.rawValue } + + // 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.rawValue, + domain: domainInfo.domain, + displayName: domainInfo.displayName, + icon: domainInfo.icon, + count: visibleEntities.count, + activeCount: activeCount, + summaryText: summaryText + ) + + summaries.append(summary) + } + + // 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 { + // Check if entity is in an "active" state + guard let domain = Domain(entityId: entity.entityId) else { + return entity.state == Domain.State.on.rawValue + } + + switch domain { + case .light, .switch, .fan: + return entity.state == Domain.State.on.rawValue + case .cover: + return entity.state == Domain.State.open.rawValue || entity.state == Domain.State.opening.rawValue + case .lock: + return entity.state == Domain.State.unlocked.rawValue + case .automation, .scene, .script: + // These don't really have "active" states + return false + default: + return entity.state == Domain.State.on.rawValue + } + } + + private func generateSummaryText(domain: Domain, totalCount: Int, activeCount: Int) -> String { + switch domain { + case .light: + if activeCount == 0 { + return L10n.HomeView.Summaries.Lights.allOff + } else { + return L10n.HomeView.Summaries.Lights.countOn(activeCount) + } + case .cover: + if activeCount == 0 { + return L10n.HomeView.Summaries.Covers.allClosed + } else { + return L10n.HomeView.Summaries.Covers.countOpen(activeCount) + } + default: + return L10n.HomeView.Summaries.countActive(activeCount) } } @@ -293,6 +406,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 16e8e1d351..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 - EntityTileView( + 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 - EntityTileView( + 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 ) } 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 e0e934d6a7..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 @@ -1473,6 +1477,34 @@ public enum L10n { public static var experimental: String { return L10n.tr("Localizable", "home_view.navigation.subtitle.experimental") } } } + 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") } + } + } } public enum Improv {