Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -2334,6 +2336,7 @@
422F88152D428E8300706A0A /* CustomWidgetActivateAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomWidgetActivateAppIntent.swift; sourceTree = "<group>"; };
422F88182D42989E00706A0A /* CustomWidgetPressButtonAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomWidgetPressButtonAppIntent.swift; sourceTree = "<group>"; };
422F951E2CFDF7C5003B7514 /* HAApplicationShortcutItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAApplicationShortcutItem.swift; sourceTree = "<group>"; };
422FB62E2F17E33800243A68 /* DomainSummaryCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainSummaryCard.swift; sourceTree = "<group>"; };
4231797F2D54FADD0037A8A4 /* AppIntentHaptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentHaptics.swift; sourceTree = "<group>"; };
42333ADA2D0B1771001E8408 /* EntityRegistryListForDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityRegistryListForDisplay.swift; sourceTree = "<group>"; };
4236229F2F05587800391BD0 /* EntityIconColorProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityIconColorProvider.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2426,6 +2429,7 @@
426BB0C62D394BFF0062D905 /* AssistPipelinePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistPipelinePicker.swift; sourceTree = "<group>"; };
426CBB692C9C543F003CA3AC /* ControlSwitchValueProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlSwitchValueProvider.swift; sourceTree = "<group>"; };
426CBB6B2C9C550D003CA3AC /* IntentSwitchEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSwitchEntity.swift; sourceTree = "<group>"; };
426D2F022F17E8530062C359 /* HomeEntityTileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeEntityTileView.swift; sourceTree = "<group>"; };
426D9C722C9C582F00F278AF /* ControlEntityProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlEntityProvider.swift; sourceTree = "<group>"; };
426E02982EF98F5F008237D4 /* CameraListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraListRow.swift; sourceTree = "<group>"; };
426E029A2EF98F65008237D4 /* CameraSectionReorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraSectionReorderView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2565,9 +2569,9 @@
429BA2AE2C800CAB00A50996 /* SFSymbolEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFSymbolEntity.swift; sourceTree = "<group>"; };
429BEA192D102F3A00F070F9 /* ConnectionErrorDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionErrorDetailsView.swift; sourceTree = "<group>"; };
429BEA1B2D1030EA00F070F9 /* SheetCloseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetCloseButton.swift; sourceTree = "<group>"; };
429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPickerViewModel.test.swift; sourceTree = "<group>"; };
429C33C12F17A7010033EF5E /* HAUsagePredictionCommonControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAUsagePredictionCommonControl.swift; sourceTree = "<group>"; };
429C33C42F17CE4C0033EF5E /* AreaGridButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AreaGridButton.swift; sourceTree = "<group>"; };
429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPickerViewModel.test.swift; sourceTree = "<group>"; };
429C721F2B28D0EC00BCD558 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = "<group>"; };
429D51C42EFA0E750014AD0D /* ModernAssistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModernAssistView.swift; sourceTree = "<group>"; };
42A0B9352EBB82EB00E074BF /* StatusBarButtonsConfigurator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarButtonsConfigurator.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -5389,6 +5393,14 @@
path = General;
sourceTree = "<group>";
};
429C33BE2F17988D0033EF5E /* EntityPicker */ = {
isa = PBXGroup;
children = (
429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */,
);
path = EntityPicker;
sourceTree = "<group>";
};
429C33C02F17A6CF0033EF5E /* Models */ = {
isa = PBXGroup;
children = (
Expand All @@ -5397,14 +5409,6 @@
429C33C12F17A7010033EF5E /* HAUsagePredictionCommonControl.swift */,
);
path = Models;
sourceTree = "<group>";
};
429C33BE2F17988D0033EF5E /* EntityPicker */ = {
isa = PBXGroup;
children = (
429C33BC2F17986D0033EF5E /* EntityPickerViewModel.test.swift */,
);
path = EntityPicker;
sourceTree = "<group>";
};
42A2AB7E2C80750A00C5608D /* Control */ = {
Expand Down Expand Up @@ -5794,6 +5798,7 @@
4289FCB82F0BFF50005189AA /* HomeViewConfiguration.swift */,
420C914F2F0C6CAC005D04A6 /* HomeViewCustomizationView.swift */,
429C33C42F17CE4C0033EF5E /* AreaGridButton.swift */,
422FB62E2F17E33800243A68 /* DomainSummaryCard.swift */,
);
path = Home;
sourceTree = "<group>";
Expand All @@ -5804,6 +5809,7 @@
42DFE87C2EFB128B0058DADB /* EntityTileView.swift */,
4236229F2F05587800391BD0 /* EntityIconColorProvider.swift */,
42C60F902F07FB0F0071A6F6 /* MoreInfoDialog */,
426D2F022F17E8530062C359 /* HomeEntityTileView.swift */,
);
path = EntityTile;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
9 changes: 9 additions & 0 deletions Sources/App/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
171 changes: 100 additions & 71 deletions Sources/App/WebView/ExperimentalSpace/EntityTile/EntityTileView.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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))
Expand All @@ -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
)
}
}
Loading
Loading