iOS/Sources/App/Settings/EntityPicker/EntityPickerViewModel.swift
Bruno Pantaleão Gonçalves cd502a3ee1
Display entity picker as bottom sheet (#4278)
<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->

## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->

<img width="1658" height="1142" alt="CleanShot 2026-01-30 at 11 00
58@2x"
src="https://github.com/user-attachments/assets/a06062c9-f373-499e-b42a-1b07b244019c"
/>


## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->
2026-01-30 10:38:18 +00:00

250 lines
9.0 KiB
Swift

import Combine
import Foundation
import Shared
enum EntityGrouping: String, CaseIterable, Identifiable {
case domain
case area
var id: String { rawValue }
var displayName: String {
switch self {
case .domain: return L10n.EntityPicker.Filter.Domain.title
case .area: return L10n.EntityPicker.Filter.Area.title
}
}
}
final class EntityPickerViewModel: ObservableObject {
@Published var entities: [HAAppEntity] = []
@Published var registryEntities: [AppEntityRegistryListForDisplay] = []
@Published var registryEntriesData: [AppEntityRegistry] = []
@Published var deviceRegistryData: [AppDeviceRegistry] = []
@Published var areaData: [AppArea] = []
@Published var showList = false
@Published var searchTerm = ""
@Published var selectedServerId: String?
@Published var selectedDomainFilter: String? = nil
@Published var selectedAreaFilter: String? = nil
@Published var selectedGrouping: EntityGrouping = .area
@Published var entitiesByDomain: [String: [HAAppEntity]] = [:]
@Published var filteredEntitiesByGroup: [String: [HAAppEntity]] = [:]
// Cached lookups to avoid recomputation on every filter
private var cachedEntityToArea: [String: String] = [:]
private var cachedAreaIdToEntityIds: [String: Set<String>] = [:]
private var cachedEntitiesByServer: [String: [HAAppEntity]] = [:]
let domainFilter: Domain?
private var filterTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
/// Returns true if any filter (excluding server) has a non-default value
var hasActiveFilters: Bool {
let defaultDomainFilter = domainFilter?.rawValue
let isDomainFilterActive = selectedDomainFilter != defaultDomainFilter
let isAreaFilterActive = selectedAreaFilter != nil
let isGroupingFilterActive = selectedGrouping != .area
return isDomainFilterActive || isAreaFilterActive || isGroupingFilterActive
}
/// Resets all filters (except server) to their default values
func resetFilters() {
selectedDomainFilter = domainFilter?.rawValue
selectedAreaFilter = nil
selectedGrouping = .area
}
init(domainFilter: Domain?, selectedServerId: String?) {
self.domainFilter = domainFilter
self.selectedServerId = selectedServerId
self.selectedDomainFilter = domainFilter?.rawValue
setupFiltering()
}
private func setupFiltering() {
// Observe changes to filtering properties and update filtered results
Publishers.CombineLatest4($searchTerm, $selectedServerId, $selectedDomainFilter, $selectedAreaFilter)
.combineLatest($selectedGrouping)
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { [weak self] _, _ in
self?.updateFilteredEntities()
}
.store(in: &cancellables)
// Recompute area-based caches when area data changes
$areaData
.sink { [weak self] _ in
self?.rebuildAreaCaches()
}
.store(in: &cancellables)
// Re-fetch server-specific data when server changes
$selectedServerId
.removeDuplicates()
.sink { [weak self] serverId in
guard let self else { return }
// Clear server-specific cache when server changes
cachedEntitiesByServer.removeAll()
fetchServerData(for: serverId)
}
.store(in: &cancellables)
}
private func rebuildAreaCaches() {
var entityToArea: [String: String] = [:]
var areaIdToEntityIds: [String: Set<String>] = [:]
for area in areaData {
areaIdToEntityIds[area.areaId] = area.entities
for entityId in area.entities {
entityToArea[entityId] = area.name
}
}
cachedEntityToArea = entityToArea
cachedAreaIdToEntityIds = areaIdToEntityIds
}
private func entitiesForCurrentServer() -> [HAAppEntity] {
guard let serverId = selectedServerId else { return [] }
if let cached = cachedEntitiesByServer[serverId] {
return cached
}
// Build and cache
let result = entities.filter { $0.serverId == serverId }
cachedEntitiesByServer[serverId] = result
return result
}
private func fetchServerData(for serverId: String?) {
guard let serverId else { return }
do {
registryEntities = try AppEntityRegistryListForDisplay.config(serverId: serverId)
registryEntriesData = try AppEntityRegistry.config(serverId: serverId)
deviceRegistryData = try AppDeviceRegistry.config(serverId: serverId)
areaData = try AppArea.fetchAreas(for: serverId)
rebuildAreaCaches()
// Prime server cache for this server
cachedEntitiesByServer[serverId] = entities.filter { $0.serverId == serverId }
updateFilteredEntities()
} catch {
Current.Log.error("Failed to fetch server data for entity picker, error: \(error)")
}
}
func fetchEntities() {
do {
entities = try HAAppEntity.config()
groupByDomain()
// Rebuild caches with current data
rebuildAreaCaches()
if let serverId = selectedServerId {
cachedEntitiesByServer[serverId] = entities.filter { $0.serverId == serverId }
}
// Fetch server-specific data if a server is already selected
if let serverId = selectedServerId {
fetchServerData(for: serverId)
} else {
updateFilteredEntities()
}
} catch {
Current.Log.error("Failed to fetch entities for entity picker, error: \(error)")
}
}
private func groupByDomain() {
var groups = Dictionary(grouping: entities) { entity in
entity.domain
}
if let domainFilter {
groups = groups.filter { $0.key == domainFilter.rawValue }
}
entitiesByDomain = groups
}
private func updateFilteredEntities() {
filterTask?.cancel()
filterTask = Task {
await performFiltering()
}
}
private func performFiltering() async {
// Snapshot state needed for filtering
let searchTerm = searchTerm
let domainFilter = selectedDomainFilter
let areaFilter = selectedAreaFilter
let grouping = selectedGrouping
let noAreaTitle = L10n.EntityPicker.List.Area.NoArea.title
// Pull cached lookups
let entityToArea = cachedEntityToArea
let areaIdToEntityIds = cachedAreaIdToEntityIds
// Get entities already filtered by server
let serverScopedEntities = entitiesForCurrentServer()
let filtered = await Task.detached(priority: .userInitiated) { () -> [String: [HAAppEntity]] in
// Resolve area entity id set if filtering by area
let areaEntityIds: Set<String>? = areaFilter.flatMap { areaIdToEntityIds[$0] }
// First, filter entities by domain, area, and search
let filteredEntities = serverScopedEntities.filter { entity in
// Filter by domain if set
if let domainFilter, entity.domain != domainFilter { return false }
// Filter by area if set
if let areaEntityIds, !areaEntityIds.contains(entity.entityId) { return false }
// Filter by search term (only when 3+ chars)
if searchTerm.count > 2 {
let lower = searchTerm.lowercased()
if !entity.name.lowercased().contains(lower), !entity.entityId.lowercased().contains(lower) {
return false
}
}
return true
}
// Group by selected grouping
switch grouping {
case .domain:
return Dictionary(grouping: filteredEntities) { $0.domain }
case .area:
var result: [String: [HAAppEntity]] = [:]
for entity in filteredEntities {
let areaName = entityToArea[entity.entityId] ?? noAreaTitle
result[areaName, default: []].append(entity)
}
// Ensure the "No Area" group appears last by moving it to the end
if let noAreaGroup = result.removeValue(forKey: noAreaTitle) {
result[noAreaTitle] = noAreaGroup
}
return result
}
}.value
await MainActor.run {
self.filteredEntitiesByGroup = filtered
}
}
// MARK: - Test helpers (DEBUG only)
#if DEBUG
/// Exposes private groupByDomain for unit tests
func _test_groupByDomain() {
groupByDomain()
}
/// Exposes private updateFilteredEntities for unit tests
func _test_updateFilteredEntities() {
updateFilteredEntities()
}
#endif
}