mirror of
https://github.com/home-assistant/iOS.git
synced 2026-04-13 01:12:53 -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>
149 lines
5.0 KiB
Swift
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)"
|
|
)
|
|
}
|
|
}
|
|
}
|