Files
iOS/Sources/Extensions/Widgets/Common/WidgetTimelineProviderSupport.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

149 lines
5.0 KiB
Swift

import Shared
import SwiftUI
import WidgetKit
struct WidgetEntityState: Codable {
let value: String
let domainState: Domain.State?
let hexColor: String?
var color: Color? {
guard let hexColor else { return nil }
return Color(hex: hexColor)
}
}
struct WidgetEntitiesStateCache: Codable {
let cacheCreatedDate: Date
let states: [MagicItem: WidgetEntityState]
}
@available(iOS 17, *)
protocol WidgetSingleEntryTimelineProvider: AppIntentTimelineProvider {
var expiration: Measurement<UnitDuration> { get }
func makePlaceholder(in context: Context) -> Entry
func makeSnapshotEntry(for configuration: Intent, in context: Context) async -> Entry
func makeTimelineEntry(for configuration: Intent, in context: Context) async -> Entry
}
@available(iOS 17, *)
extension WidgetSingleEntryTimelineProvider {
func placeholder(in context: Context) -> Entry {
makePlaceholder(in: context)
}
func snapshot(for configuration: Intent, in context: Context) async -> Entry {
await makeSnapshotEntry(for: configuration, in: context)
}
func timeline(for configuration: Intent, in context: Context) async -> Timeline<Entry> {
let entry = await makeTimelineEntry(for: configuration, in: context)
return .init(
entries: [entry],
policy: .after(
Current.date()
.addingTimeInterval(expiration.converted(to: .seconds).value)
)
)
}
}
enum WidgetMagicItemInfoProvider {
static func load() async -> MagicItemProviderProtocol {
let infoProvider = Current.magicItemProvider()
_ = await infoProvider.loadInformation()
return infoProvider
}
}
@available(iOS 17, *)
struct WidgetEntityStateProvider {
let logPrefix: String
let cacheValiditySeconds: TimeInterval
let cacheURL: () -> URL
let shouldFetchStates: () -> Bool
let skipFetchLogMessage: String?
let itemFilter: (MagicItem) -> Bool
let stateValueFormatter: (ControlEntityProvider.State, String, String) -> String
func states(showStates: Bool, items: [MagicItem]) async -> [MagicItem: WidgetEntityState] {
guard showStates else {
Current.Log.verbose("States are disabled in \(logPrefix) widget configuration")
return [:]
}
guard shouldFetchStates() else {
if let skipFetchLogMessage {
Current.Log.verbose(skipFetchLogMessage)
}
return [:]
}
if let cache = readCache(), cache.cacheCreatedDate.timeIntervalSinceNow > -cacheValiditySeconds {
Current.Log.verbose("\(logPrefix) widget states cache is still valid, returning cached states")
return cache.states
}
Current.Log.verbose("\(logPrefix) widget has no valid cache, fetching states")
var states: [MagicItem: WidgetEntityState] = [:]
for item in items where itemFilter(item) {
let serverId = item.serverId
let entityId = item.id
guard let domain = item.domain,
let server = Current.servers.all.first(where: { $0.identifier.rawValue == serverId }) else { continue }
if let state: ControlEntityProvider.State = await ControlEntityProvider(domains: [domain]).state(
server: server,
entityId: entityId
) {
let value = stateValueFormatter(state, serverId, entityId)
states[item] = .init(
value: value,
domainState: state.domainState,
hexColor: state.color?.hex()
)
} else {
Current.Log.error(
"Failed to get state for entity in \(logPrefix) widget, entityId: \(entityId), serverId: \(serverId)"
)
}
}
writeCache(states)
return states
}
private func readCache() -> WidgetEntitiesStateCache? {
let fileURL = cacheURL()
do {
let data = try Data(contentsOf: fileURL)
return try JSONDecoder().decode(WidgetEntitiesStateCache.self, from: data)
} catch {
Current.Log
.error("Failed to load states cache in \(logPrefix) widget, error: \(error.localizedDescription)")
return nil
}
}
private func writeCache(_ states: [MagicItem: WidgetEntityState]) {
do {
let cache = WidgetEntitiesStateCache(
cacheCreatedDate: Current.date(),
states: states
)
let fileURL = cacheURL()
let encodedStates = try JSONEncoder().encode(cache)
try encodedStates.write(to: fileURL)
Current.Log.verbose(
"JSON saved successfully for \(logPrefix) widget cached states, file URL: \(fileURL.absoluteString)"
)
} catch {
Current.Log.error(
"Failed to cache states in \(logPrefix) widget, error: \(error.localizedDescription)"
)
}
}
}