mirror of
https://github.com/home-assistant/iOS.git
synced 2026-04-13 10:53:45 -05:00
## Summary Adds a new widget called "Common Controls" that displays commonly used entities based on usage patterns from the `usage_prediction/common_control` endpoint. **New Files:** - `WidgetCommonlyUsedEntities.swift` - Widget definition using `WidgetBasicContainerView` (same UI as WidgetCustom) - `WidgetCommonlyUsedEntitiesTimelineProvider.swift` - Timeline provider with state fetching and caching **Key Changes:** - Filters entities to supported domains: `light`, `switch`, `cover`, `fan`, `climate`, `lock` - Added `climate` domain to `Domain` enum with thermostat icon - Added climate interaction in `MagicItem` (opens more info dialog) - Registered in iOS 17/18 widget bundles - Added `commonlyUsedEntities` to `WidgetsKind` and `DataWidgetsUpdater` - Updated `WidgetsKindTests` to include the new widget kind **Widget Behavior:** - Fetches entities from the configured server (or first available) via `usagePredictionCommonControl()` - Entity info from app database via `MagicItemProvider` - Entity states via `ControlEntityProvider` (same as WidgetCustom) - 15-minute refresh policy with 1-second state cache (iOS reload bug workaround) - Supports systemSmall/Medium/Large families ## Screenshots <img src="https://github.com/user-attachments/assets/ad37542d-b3b4-4476-81b4-8be7425b5f31"> ## Link to pull request in Documentation repository Documentation: home-assistant/companion.home-assistant# ## Any other notes <!-- START COPILOT CODING AGENT TIPS --> --- ✨ Let Copilot coding agent [set things up for you](https://github.com/home-assistant/iOS/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
305 lines
11 KiB
Swift
305 lines
11 KiB
Swift
import AppIntents
|
|
import Shared
|
|
import SwiftUI
|
|
import WidgetKit
|
|
|
|
struct WidgetBasicContainerView: View {
|
|
@Environment(\.widgetFamily) var family: WidgetFamily
|
|
|
|
let emptyViewGenerator: () -> AnyView
|
|
let contents: [WidgetBasicViewModel]
|
|
let type: WidgetType
|
|
let showLastUpdate: Bool
|
|
let showServerName: Bool
|
|
let serverName: String?
|
|
|
|
init(
|
|
emptyViewGenerator: @escaping () -> AnyView,
|
|
contents: [WidgetBasicViewModel],
|
|
type: WidgetType,
|
|
showLastUpdate: Bool = false,
|
|
showServerName: Bool = false,
|
|
serverName: String? = nil
|
|
) {
|
|
self.emptyViewGenerator = emptyViewGenerator
|
|
self.contents = contents
|
|
self.type = type
|
|
self.showLastUpdate = showLastUpdate
|
|
self.showServerName = showServerName
|
|
self.serverName = serverName
|
|
}
|
|
|
|
var body: some View {
|
|
WidgetBasicContainerWrapperView(
|
|
emptyViewGenerator: emptyViewGenerator,
|
|
contents: contents,
|
|
type: type,
|
|
showLastUpdate: showLastUpdate,
|
|
showServerName: showServerName,
|
|
serverName: serverName,
|
|
family: family
|
|
)
|
|
}
|
|
}
|
|
|
|
@available(iOS 18, *)
|
|
struct WidgetBasicContainerView_Previews: PreviewProvider {
|
|
struct WidgetBasicContainerViewPreviewData {
|
|
let modelsCount: Int
|
|
let withSubtitle: Bool
|
|
let withIconBackgroundColor: Bool
|
|
}
|
|
|
|
static var previews: some View {
|
|
WidgetBasicContainerView_Previews.systemSmallConfigurations.previews()
|
|
WidgetBasicContainerView_Previews.systemMediumConfigurations.previews()
|
|
WidgetBasicContainerView_Previews.systemLargeConfigurations.previews()
|
|
}
|
|
|
|
static var systemSmallConfigurations: SnapshottablePreviewConfigurations<WidgetBasicContainerViewPreviewData> =
|
|
.init(
|
|
configurations: Self.configurations(for: .systemSmall)
|
|
) { previewData in
|
|
widgetBasicContainerView(
|
|
modelsCount: previewData.modelsCount,
|
|
withSubtitle: previewData.withSubtitle,
|
|
withIconBackgroundColor: previewData.withIconBackgroundColor,
|
|
familySize: .systemSmall
|
|
)
|
|
.previewContext(WidgetPreviewContext(family: WidgetFamily.systemSmall))
|
|
#if !WIDGET_EXTENSION
|
|
.environment(\.widgetFamily, .systemSmall)
|
|
#endif
|
|
}
|
|
|
|
static var systemMediumConfigurations: SnapshottablePreviewConfigurations<WidgetBasicContainerViewPreviewData> =
|
|
.init(
|
|
configurations: Self.configurations(for: .systemMedium)
|
|
) { previewData in
|
|
widgetBasicContainerView(
|
|
modelsCount: previewData.modelsCount,
|
|
withSubtitle: previewData.withSubtitle,
|
|
withIconBackgroundColor: previewData.withIconBackgroundColor,
|
|
familySize: .systemMedium
|
|
)
|
|
.previewContext(WidgetPreviewContext(family: WidgetFamily.systemMedium))
|
|
#if !WIDGET_EXTENSION
|
|
.environment(\.widgetFamily, .systemMedium)
|
|
#endif
|
|
}
|
|
|
|
static var systemLargeConfigurations: SnapshottablePreviewConfigurations<WidgetBasicContainerViewPreviewData> =
|
|
.init(
|
|
configurations: Self.configurations(for: .systemLarge)
|
|
) { previewData in
|
|
widgetBasicContainerView(
|
|
modelsCount: previewData.modelsCount,
|
|
withSubtitle: previewData.withSubtitle,
|
|
withIconBackgroundColor: previewData.withIconBackgroundColor,
|
|
familySize: .systemLarge
|
|
)
|
|
.previewContext(WidgetPreviewContext(family: WidgetFamily.systemLarge))
|
|
#if !WIDGET_EXTENSION
|
|
.environment(\.widgetFamily, .systemLarge)
|
|
#endif
|
|
}
|
|
|
|
private static func maxTiles(for familySize: WidgetFamily) -> Int {
|
|
switch familySize {
|
|
case .systemSmall: 3
|
|
case .systemMedium: 6
|
|
case .systemLarge: 12
|
|
default: 12
|
|
}
|
|
}
|
|
|
|
private static func configurations(for familySize: WidgetFamily)
|
|
-> [
|
|
SnapshottablePreviewConfigurations<WidgetBasicContainerViewPreviewData>
|
|
.Configuration<WidgetBasicContainerViewPreviewData>
|
|
] {
|
|
(1 ... maxTiles(for: familySize))
|
|
.flatMap { maxTiles in
|
|
[
|
|
.init(
|
|
item: .init(
|
|
modelsCount: maxTiles,
|
|
withSubtitle: true,
|
|
withIconBackgroundColor: true
|
|
),
|
|
name: previewName(
|
|
"withSubtitleWithIconBackground",
|
|
widgetFamily: familySize,
|
|
tilesCount: maxTiles
|
|
)
|
|
),
|
|
.init(
|
|
item: .init(
|
|
modelsCount: maxTiles,
|
|
withSubtitle: true,
|
|
withIconBackgroundColor: false
|
|
),
|
|
name: previewName(
|
|
"withSubtitleWithoutIconBackground",
|
|
widgetFamily: familySize,
|
|
tilesCount: maxTiles
|
|
)
|
|
),
|
|
.init(
|
|
item: .init(
|
|
modelsCount: maxTiles,
|
|
withSubtitle: false,
|
|
withIconBackgroundColor: true
|
|
),
|
|
name: previewName(
|
|
"withoutSubtitleWithIconBackground",
|
|
widgetFamily: familySize,
|
|
tilesCount: maxTiles
|
|
)
|
|
),
|
|
.init(
|
|
item: .init(
|
|
modelsCount: maxTiles,
|
|
withSubtitle: false,
|
|
withIconBackgroundColor: false
|
|
),
|
|
name: previewName(
|
|
"withoutSubtitleWithoutIconBackground",
|
|
widgetFamily: familySize,
|
|
tilesCount: maxTiles
|
|
)
|
|
),
|
|
]
|
|
}
|
|
}
|
|
|
|
private static func previewName(
|
|
_ base: String,
|
|
widgetFamily: WidgetFamily,
|
|
tilesCount: Int
|
|
) -> String {
|
|
"\(base)-\(widgetFamily.description)-\(String(format: "%02d", tilesCount))_tiles"
|
|
}
|
|
|
|
private static func widgetBasicContainerView(
|
|
modelsCount: Int,
|
|
withSubtitle: Bool,
|
|
withIconBackgroundColor: Bool,
|
|
familySize: WidgetFamily
|
|
) -> some View {
|
|
WidgetBasicContainerWrapperView(
|
|
emptyViewGenerator: {
|
|
AnyView(EmptyView())
|
|
},
|
|
contents: models(
|
|
count: modelsCount,
|
|
withSubtitle: withSubtitle,
|
|
withIconBackgroundColor: withIconBackgroundColor
|
|
),
|
|
type: .custom,
|
|
family: familySize
|
|
)
|
|
}
|
|
|
|
private static func models(
|
|
count: Int,
|
|
withSubtitle: Bool,
|
|
withIconBackgroundColor: Bool
|
|
) -> [WidgetBasicViewModel] {
|
|
(0 ..< count).map { index in
|
|
WidgetBasicViewModel(
|
|
id: "\(index)",
|
|
title: "Title \(index)",
|
|
subtitle: withSubtitle ? "Subtitle \(index)" : nil,
|
|
interactionType: .appIntent(.refresh),
|
|
icon: .abTestingIcon,
|
|
showIconBackground: withIconBackgroundColor
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// This wrapper only exists so it can be snapshot tested with the proper family size which is not possible with the
|
|
/// `WidgetBasicContainerView` and the environment variable
|
|
struct WidgetBasicContainerWrapperView: View {
|
|
let emptyViewGenerator: () -> AnyView
|
|
let contents: [WidgetBasicViewModel]
|
|
let type: WidgetType
|
|
let showLastUpdate: Bool
|
|
let family: WidgetFamily
|
|
let showServerName: Bool
|
|
let serverName: String?
|
|
|
|
init(
|
|
emptyViewGenerator: @escaping () -> AnyView,
|
|
contents: [WidgetBasicViewModel],
|
|
type: WidgetType,
|
|
showLastUpdate: Bool = false,
|
|
showServerName: Bool = false,
|
|
serverName: String? = nil,
|
|
family: WidgetFamily
|
|
) {
|
|
self.emptyViewGenerator = emptyViewGenerator
|
|
self.contents = contents
|
|
self.type = type
|
|
self.showLastUpdate = showLastUpdate
|
|
self.family = family
|
|
self.showServerName = showServerName
|
|
self.serverName = serverName
|
|
}
|
|
|
|
var body: some View {
|
|
VStack {
|
|
if contents.isEmpty {
|
|
emptyViewGenerator()
|
|
} else {
|
|
content(for: Array(contents.prefix(WidgetFamilySizes.size(for: family))))
|
|
}
|
|
if showLastUpdate, !contents.isEmpty {
|
|
let lastUpdatedTextView = Text("\(L10n.Widgets.Custom.ShowUpdateTime.title) ") +
|
|
Text(Current.date(), style: .time)
|
|
Group {
|
|
if showServerName, let serverName {
|
|
Text(serverName) + Text(" · ") + lastUpdatedTextView
|
|
} else {
|
|
lastUpdatedTextView
|
|
}
|
|
}
|
|
.font(.system(size: 10).bold())
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.bottom, DesignSystem.Spaces.half)
|
|
.opacity(0.5)
|
|
}
|
|
}
|
|
// Whenever Apple allow apps to use material backgrounds we should update this
|
|
.widgetBackground(.primaryBackground)
|
|
}
|
|
|
|
@ViewBuilder
|
|
func content(for models: [WidgetBasicViewModel]) -> some View {
|
|
let modelsCount = models.count
|
|
let columnCount = WidgetFamilySizes.columns(family: family, modelCount: modelsCount)
|
|
let rows = Array(WidgetFamilySizes.rows(count: columnCount, models: models))
|
|
WidgetBasicView(
|
|
type: type,
|
|
rows: rows,
|
|
sizeStyle: WidgetFamilySizes.sizeStyle(
|
|
family: family,
|
|
modelsCount: modelsCount,
|
|
rowsCount: rows.count
|
|
)
|
|
)
|
|
}
|
|
|
|
// This is all widgets that are on the lock screen
|
|
// Lock screen widgets are transparent and don't need a colored background
|
|
private static var transparentFamilies: [WidgetFamily] {
|
|
if #available(iOS 16.0, *) {
|
|
[.accessoryCircular, .accessoryRectangular]
|
|
} else {
|
|
[]
|
|
}
|
|
}
|
|
}
|