Files
iOS/Sources/Extensions/Widgets/Custom/WidgetCustomTimelineProvider.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

217 lines
7.3 KiB
Swift

import AppIntents
import GRDB
import Shared
import SwiftUI
import WidgetKit
struct WidgetCustomEntry: TimelineEntry {
var date: Date
var widget: CustomWidget?
var magicItemInfoProvider: MagicItemProviderProtocol
var entitiesState: [MagicItem: WidgetEntityState]
var showLastUpdateTime: Bool
var showStates: Bool
}
@available(iOS 17, *)
struct WidgetCustomTimelineProvider: WidgetSingleEntryTimelineProvider {
typealias Entry = WidgetCustomEntry
typealias Intent = WidgetCustomAppIntent
var expiration: Measurement<UnitDuration> {
WidgetCustomConstants.expiration
}
func makePlaceholder(in context: Context) -> WidgetCustomEntry {
.init(
date: .now,
magicItemInfoProvider: Current.magicItemProvider(),
entitiesState: [:],
showLastUpdateTime: false,
showStates: false
)
}
func makeSnapshotEntry(for configuration: WidgetCustomAppIntent, in context: Context) async -> WidgetCustomEntry {
let widget = widget(configuration: configuration, context: context)
return await .init(
date: .now,
widget: widget,
magicItemInfoProvider: WidgetMagicItemInfoProvider.load(),
entitiesState: [:],
showLastUpdateTime: configuration.showLastUpdateTime,
showStates: configuration.showStates
)
}
func makeTimelineEntry(for configuration: WidgetCustomAppIntent, in context: Context) async -> WidgetCustomEntry {
let widget = widget(configuration: configuration, context: context)
let entitiesState = await entitiesState(configuration: configuration, widget: widget)
return await .init(
date: .now,
widget: widget,
magicItemInfoProvider: WidgetMagicItemInfoProvider.load(),
entitiesState: entitiesState,
showLastUpdateTime: configuration.showLastUpdateTime,
showStates: configuration.showStates
)
}
private func widget(configuration: WidgetCustomAppIntent, context: Context) -> CustomWidget? {
var widgetId = configuration.widget?.id
if widgetId == nil {
do {
widgetId = try CustomWidget.widgets()?.first?.id
} catch {
Current.Log.error("Failed to get list of custom widgets, error: \(error.localizedDescription)")
}
}
do {
let widget = try CustomWidget.widgets()?.first { $0.id == widgetId }
// This prevents widgets displaying more items than the widget family size supports
let newWidgetWithPrefixedItems = CustomWidget(
id: widget?.id ?? "Uknown",
name: widget?.name ?? "Uknown",
items: Array((widget?.items ?? []).prefix(WidgetFamilySizes.size(for: context.family))),
itemsStates: widget?.itemsStates ?? [:]
)
return newWidgetWithPrefixedItems
} catch {
Current.Log
.error(
"Failed to load widgets in WidgetCustomTimelineProvider, id: \(String(describing: widgetId)), error: \(error.localizedDescription)"
)
return nil
}
}
private func entitiesState(
configuration: WidgetCustomAppIntent,
widget: CustomWidget?
) async -> [MagicItem: WidgetEntityState] {
guard let widget else { return [:] }
guard widget.itemsStates.isEmpty else {
Current.Log
.verbose(
"Avoid fetching states for widget with cached states (e.g. pending confirmation) to prevent delay on widget refresh"
)
return [:]
}
let stateProvider = WidgetEntityStateProvider(
logPrefix: "Widget custom",
cacheValiditySeconds: 1,
cacheURL: { AppConstants.widgetCachedStates(widgetId: widget.id) },
shouldFetchStates: { true },
skipFetchLogMessage: nil,
itemFilter: { item in
// No state needed for those domains
![.script, .scene, .inputButton].contains(item.domain)
},
stateValueFormatter: { state, serverId, entityId in
"\(StatePrecision.adjustPrecision(serverId: serverId, entityId: entityId, stateValue: state.value)) \(state.unitOfMeasurement ?? "")"
}
)
return await stateProvider.states(showStates: configuration.showStates, items: widget.items)
}
}
enum WidgetCustomConstants {
static var expiration: Measurement<UnitDuration> {
.init(value: 15, unit: .minutes)
}
}
@available(iOS 17.0, macOS 14.0, watchOS 10.0, *)
struct WidgetCustomAppIntent: AppIntent, WidgetConfigurationIntent {
static let title: LocalizedStringResource = .init("widgets.custom.title", defaultValue: "Custom widgets")
static var isDiscoverable: Bool = false
@Parameter(
title: "Widget"
)
var widget: CustomWidgetEntity?
@Parameter(
title: .init("widgets.custom.show_last_update_time.param.title", defaultValue: "Show last update time"),
default: false
)
var showLastUpdateTime: Bool
@Parameter(
title: .init("widgets.custom.show_states.param.title", defaultValue: "Show states (BETA)"),
description: .init(
"widgets.custom.show_states.description",
defaultValue: "Displaying latest states is not 100% guaranteed, you can give it a try and check the companion App documentation for more information."
),
default: false
)
var showStates: Bool
static var parameterSummary: some ParameterSummary {
Summary()
}
func perform() async throws -> some IntentResult {
.result()
}
}
@available(iOS 16.4, macOS 13.0, watchOS 9.0, *)
struct CustomWidgetEntity: AppEntity {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Custom Widget")
static let defaultQuery = CustomWidgetAppEntityQuery()
var id: String
var name: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}
init(
id: String,
name: String
) {
self.id = id
self.name = name
}
}
@available(iOS 16.4, macOS 13.0, watchOS 9.0, *)
struct CustomWidgetAppEntityQuery: EntityQuery, EntityStringQuery {
func entities(for identifiers: [String]) async throws -> [CustomWidgetEntity] {
widgets().filter { identifiers.contains($0.id) }.map { .init(id: $0.id, name: $0.name) }
}
func entities(matching string: String) async throws -> IntentItemCollection<CustomWidgetEntity> {
.init(items: widgets().filter { $0.name.lowercased().contains(string.lowercased()) }.map { .init(
id: $0.id,
name: $0.name
) })
}
func suggestedEntities() async throws -> IntentItemCollection<CustomWidgetEntity> {
.init(items: widgets().map { .init(id: $0.id, name: $0.name) })
}
private func widgets() -> [CustomWidget] {
do {
return try Current.database().read { db in
try CustomWidget.fetchAll(db)
}
} catch {
Current.Log
.error("Failed to load widgets in CustomWidgetAppEntityQuery, error: \(error.localizedDescription)")
return []
}
}
}