Files
iOS/Sources/Extensions/Widgets/Common/WidgetBasicContainerView.swift
Copilot ab3d3c34a9 Add Common Controls widget (#4365)
## 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>
2026-02-18 13:45:03 +01:00

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 {
[]
}
}
}