diff --git a/CommonUI/.gitignore b/CommonUI/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/CommonUI/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/CommonUI/Package.resolved b/CommonUI/Package.resolved new file mode 100644 index 000000000..6ac18d527 --- /dev/null +++ b/CommonUI/Package.resolved @@ -0,0 +1,96 @@ +{ + "originHash" : "822542af313faf8fe83f30c3acf880bf33b4214d7b137016615f52409ce94c8c", + "pins" : [ + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher.git", + "state" : { + "revision" : "d30a5fad881137e2267f96a8e3fc35c58999bb94", + "version" : "8.6.2" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage.git", + "state" : { + "revision" : "36e79ba485e9bb4d3cd4e3318908866dac5e7b51", + "version" : "5.21.5" + } + }, + { + "identity" : "sdwebimagesvgcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageSVGCoder.git", + "state" : { + "revision" : "85b5d58ad02c207c496fa34426dc6560d6ae32f0", + "version" : "1.8.0" + } + }, + { + "identity" : "sfsafesymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols.git", + "state" : { + "revision" : "e01b3d4f861412f8dcee8d93c417d2c2b0cdfd77", + "version" : "7.0.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "7cdf33371bf89b23b9cf4fd3ce8d3c825c28fbe8", + "version" : "1.9.0" + } + }, + { + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-timeout", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swhitty/swift-timeout.git", + "state" : { + "revision" : "4efb73b593d5553b90766d531db701ecf2306237", + "version" : "0.4.1" + } + } + ], + "version" : 3 +} diff --git a/CommonUI/Package.swift b/CommonUI/Package.swift new file mode 100644 index 000000000..f97ec6c42 --- /dev/null +++ b/CommonUI/Package.swift @@ -0,0 +1,45 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "CommonUI", + platforms: [.iOS("26.0"), .watchOS("26.0"), .macOS("26.0")], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "CommonUI", + targets: ["CommonUI"] + ) + ], + dependencies: [ + .package(path: "../OpenHABCore"), + .package(url: "https://github.com/apple/swift-numerics", from: "1.0.0") + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "CommonUI", + dependencies: ["OpenHABCore"], + swiftSettings: [ + .enableUpcomingFeature("ExistentialAny"), + // .enableUpcomingFeature("InternalImportsByDefault"), + .enableUpcomingFeature("MemberImportVisibility"), + .enableUpcomingFeature("StrictConcurrency"), + .unsafeFlags([ + "-Xfrontend", "-enable-actor-data-race-checks", + "-Xfrontend", "-strict-concurrency=complete" + ]) + ] + ), + .testTarget( + name: "CommonUITests", + dependencies: [ + .product(name: "Numerics", package: "swift-numerics"), + "CommonUI" + ] + ) + ] +) diff --git a/CommonUI/Sources/CommonUI/ColorExtension.swift b/CommonUI/Sources/CommonUI/ColorExtension.swift new file mode 100644 index 000000000..41d93a213 --- /dev/null +++ b/CommonUI/Sources/CommonUI/ColorExtension.swift @@ -0,0 +1,53 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import OpenHABCore +import SwiftUI + +public extension Color { + init(fromString string: String) { + self.init(UIColor(fromString: string)) + } + + init(hex: String) { + self.init(UIColor(hex: hex)) + } +} + +public typealias Kelvin = Double + +public extension Color { + init(temperature: Kelvin) { + let components = componentsForColorTemperature(temperature: temperature) + self.init(red: components.r, green: components.g, blue: components.b) + } + + func hexString() -> String { + let components = cgColor?.components + let r: CGFloat = components?[0] ?? 0.0 + let g: CGFloat = components?[1] ?? 0.0 + let b: CGFloat = components?[2] ?? 0.0 + + let hexString = String(format: "#%02lX%02lX%02lX", lroundf(Float(r * 255)), lroundf(Float(g * 255)), lroundf(Float(b * 255))) + return hexString + } +} + +// For algorithm see https://web.archive.org/web/20151024031939/http://www.zombieprototypes.com/?p=210 +// Algorithm see http://www.tannerhelland.com/4435/convert-temperature-rgb-algorithm-code/ +// swiftlint:disable:next large_tuple +func componentsForColorTemperature(temperature: Kelvin) -> (r: Double, g: Double, b: Double) { + let k = temperature / 100 + let r = (k <= 66 ? 255 : (329.698727446 * pow(k - 60, -0.1332047592))).clamped(to: 0 ... 255.0) / 255 + let g = (k <= 66 ? (99.4708025861 * log(k) - 161.1195681661) : 288.1221695283 * pow(k - 60, -0.0755148492)).clamped(to: 0 ... 255.0) / 255 + let b = (k >= 66 ? 255 : (k <= 19 ? 0 : 138.5177312231 * log(k - 10) - 305.0447927307)).clamped(to: 0 ... 255.0) / 255 + return (r: r, g: g, b: b) +} diff --git a/openHABWatch/Views/LogsViewer.swift b/CommonUI/Sources/CommonUI/LogsViewer.swift similarity index 97% rename from openHABWatch/Views/LogsViewer.swift rename to CommonUI/Sources/CommonUI/LogsViewer.swift index 9ba294479..4ec650f12 100644 --- a/openHABWatch/Views/LogsViewer.swift +++ b/CommonUI/Sources/CommonUI/LogsViewer.swift @@ -15,7 +15,7 @@ import SwiftUI // Thanks to https://useyourloaf.com/blog/fetching-oslog-messages-in-swift/ -struct LogsViewer: View { +public struct LogsViewer: View { private static let template = NSPredicate(format: "(subsystem BEGINSWITH $PREFIX)") @@ -25,7 +25,7 @@ struct LogsViewer: View { .system(size: 10) .monospaced() - var body: some View { + public var body: some View { ScrollView { Text(text) .font(myFont) @@ -36,6 +36,8 @@ struct LogsViewer: View { } } + public init() {} + private func fetchLogs() async -> String { let calendar = Calendar.current guard let dayAgo = calendar.date( diff --git a/CommonUI/Sources/CommonUI/OHTextTokenStyle.swift b/CommonUI/Sources/CommonUI/OHTextTokenStyle.swift new file mode 100644 index 000000000..a2e34e667 --- /dev/null +++ b/CommonUI/Sources/CommonUI/OHTextTokenStyle.swift @@ -0,0 +1,79 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import SwiftUI + +public enum OHTextToken { + case rowLabel + case rowValue + case rowValueCompact + case rowValueCallout + case section + case control + case secondary + case emphasis +} + +public enum OHAccessibilityToken { + public static let minimumHitTarget: CGFloat = 44 +} + +private struct OHTextTokenModifier: ViewModifier { + let token: OHTextToken + + func body(content: Content) -> some View { + let style = OHTextTokenStyle.from(token) + content + .font(style.font) + .lineLimit(style.lineLimit) + .minimumScaleFactor(style.minimumScaleFactor) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + } +} + +private struct OHTextTokenStyle { + let font: Font + let lineLimit: Int + let minimumScaleFactor: CGFloat + + static func from(_ token: OHTextToken) -> OHTextTokenStyle { + switch token { + case .rowLabel: + OHTextTokenStyle(font: .body, lineLimit: 1, minimumScaleFactor: 0.9) + case .rowValue: + OHTextTokenStyle(font: .body, lineLimit: 1, minimumScaleFactor: 0.9) + case .rowValueCompact: + OHTextTokenStyle(font: .caption, lineLimit: 1, minimumScaleFactor: 0.9) + case .rowValueCallout: + OHTextTokenStyle(font: .callout, lineLimit: 1, minimumScaleFactor: 0.9) + case .section: + OHTextTokenStyle(font: .callout, lineLimit: 1, minimumScaleFactor: 0.85) + case .control: + OHTextTokenStyle(font: .footnote, lineLimit: 1, minimumScaleFactor: 0.85) + case .secondary: + OHTextTokenStyle(font: .caption, lineLimit: 1, minimumScaleFactor: 0.9) + case .emphasis: + OHTextTokenStyle(font: .headline, lineLimit: 1, minimumScaleFactor: 0.9) + } + } +} + +public extension View { + func ohTextToken(_ token: OHTextToken) -> some View { + modifier(OHTextTokenModifier(token: token)) + } + + /// Applies the standard minimum tappable target used across row controls. + func ohMinimumHitTarget(_ minHeight: CGFloat = OHAccessibilityToken.minimumHitTarget) -> some View { + frame(minHeight: minHeight) + } +} diff --git a/CommonUI/Sources/CommonUI/PreviewConstants.swift b/CommonUI/Sources/CommonUI/PreviewConstants.swift new file mode 100644 index 000000000..bda1d6366 --- /dev/null +++ b/CommonUI/Sources/CommonUI/PreviewConstants.swift @@ -0,0 +1,583 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Foundation +import OpenHABCore +import os.log + +#if DEBUG +// swiftlint:disable type_body_length +public enum PreviewConstants { + public static let logger = Logger(subsystem: "org.openhab", category: "PreviewConstants") + + public static let remoteURLString = "http://192.168.2.10:8080" + + public static var openHABSitemapPage: OpenHABPage? { + let data = sitemapJson + do { + let sitemapPage = try data.decoded(as: Components.Schemas.PageDTO.self) + let openHABSitemapPage = OpenHABPage(sitemapPage) + return openHABSitemapPage + } catch { + logger.error("Should not throw \(error.localizedDescription)") + return nil + } + } + + public static let sitemapJson = Data(""" + { + "id": "watch", + "title": "watch", + "link": "http://192.168.2.10:8080/rest/sitemaps/watch/watch", + "leaf": true, + "timeout": false, + "widgets": [ + { + "widgetId": "00", + "type": "Switch", + "visibility": true, + "label": "Licht Keller WC Decke", + "icon": "switch", + "mappings": [], + "item": { + "link": "http://192.168.2.15:8081/rest/items/lcnLightSwitch6_1", + "state": "OFF", + "type": "Switch", + "name": "lcnLightSwitch6_1", + "label": "Licht Keller WC Decke", + "tags": [ + "Lighting" + ], + "groupNames": [ + "gKellerLicht", + "gLcn" + ] + }, + "widgets": [] + }, + { + "widgetId": "01", + "type": "Switch", + "visibility": true, + "label": "Licht Oberlicht", + "icon": "switch", + "mappings": [], + "item": { + "link": "http://192.168.2.15:8081/rest/items/lcnLightSwitch14_1", + "state": "OFF", + "type": "Switch", + "name": "lcnLightSwitch14_1", + "label": "Licht Oberlicht", + "tags": [ + "Lighting" + ], + "groupNames": [ + "gEGLicht", + "G_PresenceSimulation", + "gLcn" + ] + }, + "widgets": [] + }, + { + "widgetId": "02", + "type": "Switch", + "visibility": true, + "label": "Licht Esstisch [Test]", + "icon": "switch", + "mappings": [], + "item": { + "link": "http://192.168.2.15:8081/rest/items/lcnLightSwitch20_1", + "state": "ON", + "type": "Switch", + "name": "lcnLightSwitch20_1", + "label": "Licht Esstisch", + "tags": [], + "groupNames": [ + "gEGLicht", + "G_PresenceSimulation", + "gLcn", + "gStateON" + ] + }, + "widgets": [] + }, + { + "widgetId": "03", + "type": "Slider", + "visibility": true, + "label": "Esstisch [100]", + "icon": "slider", + "mappings": [], + "switchSupport": false, + "sendFrequency": 0, + "item": { + "link": "http://192.168.2.10:8080/rest/items/lcnLightDimmer", + "state": "95", + "stateDescription": { + "pattern": "%s", + "readOnly": false, + "options": [] + }, + "type": "Dimmer", + "name": "lcnLightDimmer", + "label": "Esstisch", + "tags": [ + "Lighting" + ], + "groupNames": [ + "gEGLicht", + "gLcn" + ] + }, + "widgets": [] + }, + { + "widgetId": "04", + "type": "Switch", + "visibility": true, + "label": "Fernsteuerung", + "icon": "switch", + "mappings": [ + { + "command": "0", + "label": "Overwrite" + }, + { + "command": "1", + "label": "Kalender" + }, + { + "command": "2", + "label": "Automatik" + } + ], + "item": { + "link": "http://192.168.2.15:8081/rest/items/Automatik", + "state": "2", + "type": "String", + "name": "Automatik", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "05", + "type": "Switch", + "visibility": true, + "label": "Jalousie WZ Süd links", + "icon": "rollershutter", + "mappings": [], + "item": { + "link": "http://192.168.2.15:8081/rest/items/lcnJalousieWZSuedLinks", + "state": "0", + "type": "Rollershutter", + "name": "lcnJalousieWZSuedLinks", + "label": "Jalousie WZ Süd links", + "tags": [], + "groupNames": [ + "gWZ", + "gEGJalousien", + "gHausJalousie", + "gJalousienSued", + "gEGJalousienSued", + "gLcn" + ] + }, + "widgets": [] + }, + { + "widgetId": "06", + "type": "Setpoint", + "visibility": true, + "label": "Setpoint Temperature [21.0 °C]", + "icon": "temperature", + "mappings": [], + "minValue": 8, + "maxValue": 25, + "step": 0.5, + "item": { + "link": "http://192.168.2.15:8081/rest/items/ZimmerPaul_SetpointTemperature", + "state": "21.0 °C", + "stateDescription": { + "pattern": "%.1f %unit%", + "readOnly": false, + "options": [] + }, + "type": "Number:Temperature", + "name": "ZimmerPaul_SetpointTemperature", + "label": "Setpoint Temperature [%.1f °C]", + "category": "Temperature", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "07", + "type": "Text", + "visibility": true, + "label": "Aussentemperatur [11.5 °C]", + "icon": "temperature", + "mappings": [], + "item": { + "link": "http://192.168.2.15:8081/rest/items/TempAussen", + "state": "11.5", + "stateDescription": { + "pattern": "%.1f °C", + "readOnly": false, + "options": [] + }, + "type": "Number", + "name": "TempAussen", + "label": "Aussentemperatur", + "category": "temperature", + "tags": [], + "groupNames": [ + "gOnewire" + ] + }, + "widgets": [] + }, + { + "widgetId": "08", + "type": "Image", + "visibility": true, + "label": "", + "icon": "image", + "mappings": [], + "url": "http://192.168.2.15:8081/proxy?sitemap=watch.sitemap&widgetId=08", + "widgets": [] + }, + { + "widgetId": "09", + "type": "Mapview", + "visibility": true, + "label": "Location [-°N -°E -m]", + "icon": "mapview", + "mappings": [], + "height": 5, + "item": { + "link": "http://192.168.2.15:8081/rest/items/GPSTrackerTi_Location", + "state":"52.5200066,13.4049540", + "stateDescription": { + "pattern": "%2$s°N %3$s°E %1$sm", + "readOnly": true, + "options": [] + }, + "type": "Location", + "name": "GPSTrackerTi_Location", + "label": "Location", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "10", + "type": "Colorpicker", + "visibility": true, + "label": "Color", + "icon": "colorlight", + "mappings": [], + "item": { + "link": "http://192.168.2.160:8080/rest/items/LEDVANCECLA60RGBWZ3_Color", + "state": "UNDEF", + "type": "Color", + "name": "LEDVANCECLA60RGBWZ3_Color", + "label": "Color", + "category": "ColorLight", + "tags": [], + "groupNames": [ + "dg_AllItems" + ] + }, + "widgets": [] + }, + { + "widgetId": "11", + "type": "Setpoint", + "visibility": true, + "label": "item in seconds [2400.0 s]", + "labelSource": "SITEMAP_WIDGET", + "icon": "", + "staticIcon": false, + "pattern": "%.1f s", + "unit": "s", + "mappings": [], + "minValue": 300, + "maxValue": 3600, + "step": 60, + "item": { + "link": "http://192.168.2.10:8080/rest/items/testTime", + "state": "2400 s", + "stateDescription": { + "pattern": "%.0f %unit%", + "readOnly": false, + "options": [] + }, + "unitSymbol": "s", + "type": "Number:Time", + "name": "testTime", + "label": "", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "12", + "type": "Setpoint", + "visibility": true, + "label": "item in minutes [40.0 min]", + "labelSource": "SITEMAP_WIDGET", + "icon": "", + "staticIcon": false, + "pattern": "%.1f min", + "unit": "min", + "mappings": [], + "minValue": 5, + "maxValue": 60, + "step": 5, + "state": "40 min", + "item": { + "link": "http://192.168.2.10:8080/rest/items/testTime", + "state": "2400 s", + "stateDescription": { + "pattern": "%.0f %unit%", + "readOnly": false, + "options": [] + }, + "unitSymbol": "s", + "type": "Number:Time", + "name": "testTime", + "label": "", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "13", + "type": "Colortemperaturepicker", + "visibility": true, + "label": "Color Temperature [2700 K]", + "labelSource": "SITEMAP_WIDGET", + "icon": "colorwheel", + "staticIcon": true, + "pattern": "%.0f %unit%", + "unit": "K", + "mappings": [], + "item": { + "link": "http://192.168.2.10:8080/rest/items/test_LEDLight_ColorTemp", + "state": "2700.0 K", + "stateDescription": { + "pattern": "%.0f %unit%", + "readOnly": false, + "options": [] + }, + "unitSymbol": "K", + "type": "Number:Temperature", + "name": "test_LEDLight_ColorTemp", + "label": "", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "14", + "type": "Slider", + "visibility": true, + "label": "Brightness", + "labelSource": "SITEMAP_WIDGET", + "icon": "", + "staticIcon": false, + "unit": "", + "mappings": [], + "switchSupport": false, + "releaseOnly": false, + "item": { + "link": "http://192.168.2.10:8080/rest/items/test_LEDLight_Brightness", + "state": "NULL", + "type": "Dimmer", + "name": "test_LEDLight_Brightness", + "label": "", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "15", + "type": "Colorpicker", + "visibility": true, + "label": "Color", + "labelSource": "SITEMAP_WIDGET", + "icon": "colorwheel", + "staticIcon": false, + "unit": "", + "mappings": [], + "item": { + "link": "http://192.168.2.10:8080/rest/items/test_LEDLight_color", + "state": "0,0,73", + "type": "Color", + "name": "test_LEDLight_color", + "label": "test_LEDLight_color", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "16", + "type": "Buttongrid", + "visibility": true, + "label": "Remote Control [-]", + "labelSource": "SITEMAP_WIDGET", + "icon": "screen", + "staticIcon": true, + "pattern": "%s", + "unit": "", + "mappings": [ + { + "row": 1, + "column": 1, + "command": "POWER", + "label": "Power", + "icon": "switch-off" + }, + { + "row": 1, + "column": 2, + "command": "MENU", + "label": "Menu" + }, + { + "row": 1, + "column": 3, + "command": "EXIT", + "label": "Exit" + }, + { + "row": 2, + "column": 2, + "command": "UP", + "label": "Up", + "icon": "f7:arrowtriangle_up" + }, + { + "row": 2, + "column": 4, + "command": "VOL_PLUS", + "label": "Volume +" + }, + { + "row": 3, + "column": 1, + "command": "LEFT", + "label": "Left", + "icon": "f7:arrowtriangle_left" + }, + { + "row": 3, + "column": 2, + "command": "OK", + "label": "Ok" + }, + { + "row": 3, + "column": 3, + "command": "RIGHT", + "label": "Right", + "icon": "f7:arrowtriangle_right" + }, + { + "row": 3, + "column": 4, + "command": "MUTE", + "label": "Mute", + "icon": "soundvolume_mute" + }, + { + "row": 4, + "column": 2, + "command": "DOWN", + "label": "Down", + "icon": "f7:arrowtriangle_down" + }, + { + "row": 4, + "column": 4, + "command": "VOL_MINUS", + "label": "Volume -" + } + ], + "item": { + "link": "http://192.168.2.10:8080/rest/items/test_RemoteControl", + "state": "NULL", + "stateDescription": { + "pattern": "%s", + "readOnly": false, + "options": [] + }, + "type": "String", + "name": "test_RemoteControl", + "label": "test_RemoteControl", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "17", + "type": "Input", + "visibility": true, + "label": "Meter [166000]", + "labelSource": "SITEMAP_WIDGET", + "icon": "energy", + "staticIcon": true, + "pattern": "%.0f %unit%", + "unit": "", + "mappings": [], + "inputHint": "number", + "item": { + "link": "http://192.168.2.10:8080/rest/items/Test_Meter_Reading", + "state": "166000.0", + "stateDescription": { + "pattern": "%.0f", + "readOnly": false, + "options": [] + }, + "type": "Number", + "name": "Test_Meter_Reading", + "label": "Test_Meter_Reading", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + + ] + } + """.utf8) +} + +// swiftlint:enable type_body_length +#endif diff --git a/openHABWatch/Views/Utils/TextLabelView.swift b/CommonUI/Sources/CommonUI/TextLabelView.swift similarity index 51% rename from openHABWatch/Views/Utils/TextLabelView.swift rename to CommonUI/Sources/CommonUI/TextLabelView.swift index 7e15262a8..3fac62d18 100644 --- a/openHABWatch/Views/Utils/TextLabelView.swift +++ b/CommonUI/Sources/CommonUI/TextLabelView.swift @@ -12,19 +12,27 @@ import OpenHABCore import SwiftUI -struct TextLabelView: View { +public struct TextLabelView: View { @ObservedObject var widget: OpenHABWidget - var lineLimit = 2 + var font: Font? + var token: OHTextToken + var lineLimit: Int - var body: some View { + public var body: some View { Text(widget.labelText ?? "") - .font(.caption) + .ohTextToken(token) + .font(font) .lineLimit(lineLimit) - .foregroundColor(!widget.labelcolor.isEmpty ? Color(fromString: widget.labelcolor) : .primary) + .foregroundStyle(!widget.labelcolor.isEmpty ? Color(fromString: widget.labelcolor) : .primary) } -} -#Preview { - let widget = UserData(preview: true).widgets[2] - TextLabelView(widget: widget) + public init(widget: OpenHABWidget, + font: Font? = nil, + token: OHTextToken = .rowLabel, + lineLimit: Int = 1) { + self.widget = widget + self.font = font + self.token = token + self.lineLimit = lineLimit + } } diff --git a/CommonUI/Tests/CommonUITests/CommonUITests.swift b/CommonUI/Tests/CommonUITests/CommonUITests.swift new file mode 100644 index 000000000..ed08dfbb7 --- /dev/null +++ b/CommonUI/Tests/CommonUITests/CommonUITests.swift @@ -0,0 +1,48 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +@testable import CommonUI +import Numerics +import Testing + +struct CommonUITests { + @Test func lowKelvinValue() { + let color = componentsForColorTemperature(temperature: 1000) + #expect(color.r.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.01)) + #expect(color.g < 0.5) + #expect(color.b.isApproximatelyEqual(to: 0.0, relativeTolerance: 0.01)) + } + + @Test func midKelvinValue() { + let color = componentsForColorTemperature(temperature: 5000) + #expect(color.r > 0.9) + #expect(color.g > 0.7) + #expect(color.b > 0.5) + } + + @Test func highKelvinValue() { + let color = componentsForColorTemperature(temperature: 10000) + #expect(color.r > 0.7) + #expect(color.g > 0.8) + #expect(color.b.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.01)) + } + + @Test func edgeKelvinValues() { + let veryLow = componentsForColorTemperature(temperature: 100) + #expect(veryLow.r.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.01)) + #expect(veryLow.b.isApproximatelyEqual(to: 0.0, relativeTolerance: 0.01)) + + let veryHigh = componentsForColorTemperature(temperature: 40000) + #expect(veryHigh.r <= 1.0) + #expect(veryHigh.g <= 1.0) + #expect(veryHigh.b.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.01)) + } +} diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index e986e4632..e7893b109 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -1,11 +1,11 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "OpenHABCore", - platforms: [.iOS(.v16), .watchOS(.v10), .macOS(.v14)], + platforms: [.iOS("26.0"), .watchOS("26.0"), .macOS("26.0")], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( @@ -21,7 +21,8 @@ let package = Package( .package(url: "https://github.com/apple/swift-http-types.git", from: "1.5.1"), .package(url: "https://github.com/SDWebImage/SDWebImageSVGCoder.git", from: "1.4.0"), .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols.git", from: "7.0.0"), - .package(url: "https://github.com/swhitty/swift-timeout.git", from: "0.4.0") + .package(url: "https://github.com/swhitty/swift-timeout.git", from: "0.4.0"), + .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -35,7 +36,8 @@ let package = Package( .product(name: "HTTPTypes", package: "swift-http-types"), .product(name: "SDWebImageSVGCoder", package: "SDWebImageSVGCoder"), .product(name: "SFSafeSymbols", package: "SFSafeSymbols"), - .product(name: "Timeout", package: "swift-timeout") + .product(name: "Timeout", package: "swift-timeout"), + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") ], swiftSettings: [ .enableUpcomingFeature("ExistentialAny"), diff --git a/OpenHABCore/Sources/OpenHABCore/Model/NumberState.swift b/OpenHABCore/Sources/OpenHABCore/Model/NumberState.swift index 917dc72fb..b26390cf4 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/NumberState.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/NumberState.swift @@ -37,11 +37,18 @@ public struct NumberState: CustomStringConvertible, Equatable { public func toString(locale: Locale?) -> String { if let format, !format.isEmpty { - let actualFormat = format + var actualFormat = format .replacingOccurrences(of: "%unit%", with: unit ?? "") // %s in Java is for Strings, but does not work in Swift, see // https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Strings/Articles/formatSpecifiers.html) .replacingOccurrences(of: "%s", with: "%@") + + // Escape trailing % that isn't already escaped (e.g., "%.0f %" should become "%.0f %%") + // This handles server-side format patterns that forgot to escape the percent sign + if actualFormat.hasSuffix(" %"), !actualFormat.hasSuffix(" %%") { + actualFormat = String(actualFormat.dropLast()) + "%%" + } + let formatValue: any CVarArg = if format.contains("%d") { intValue } else if format.contains("%s") { diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift index f75c5a9ca..dab02b398 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift @@ -122,6 +122,20 @@ public extension OpenHABItem { } return nil } + + func getImagePayload() -> ImagePayload { + switch type { + case .image: + guard let data = state?.components(separatedBy: ",")[safe: 1], let decodedData = Data(base64Encoded: data, options: .ignoreUnknownCharacters) else { + return .empty + } + return .embedded(data: decodedData) + case .stringItem: + return .link(url: URL(string: state ?? "")) + default: + return .empty + } + } } extension OpenHABItem { diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index cb070e84b..1a91a0311 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -9,33 +9,11 @@ // // SPDX-License-Identifier: EPL-2.0 -import Combine +@_exported import Combine import Foundation -import MapKit +@_exported import MapKit import os.log -public enum WidgetTypeEnum { - case switcher(Bool) - case slider // - case segmented(Int) - case unassigned - case rollershutter - case frame - case setpoint - case selection - case colorpicker - case chart - case image - case video - case webview - case mapview - - public var boolState: Bool { - guard case let .switcher(value) = self else { return false } - return value - } -} - public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObject { public enum WidgetType: String, Decodable { case chart = "Chart" @@ -53,11 +31,38 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje case text = "Text" case video = "Video" case webview = "Webview" + case colortemperaturepicker = "Colortemperaturepicker" + case buttongrid = "Buttongrid" + case button = "Button" case unknown = "Unknown" } + public enum LabelSource: String, Decodable { + case sitemapDefinition = "SITEMAP_WIDGET" + case itemLabel = "ITEM_LABEL" + case itemName = "ITEM_NAME" + case unknown = "UNKNOWN" + } + public enum InputHint: String, Decodable { - case text, number, date, time, datetime, unknown + case text, number, date, time, dateTime, unknown + + public init(rawValue: String) { + switch rawValue.lowercased() { + case "text": + self = .text + case "number": + self = .number + case "date": + self = .date + case "time": + self = .time + case "datetime", "dateTime": + self = .dateTime + default: + self = .unknown + } + } } public var id = "" @@ -65,7 +70,8 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public var sendCommand: ((_ item: OpenHABItem, _ command: String?) -> Void)? public var widgetId = "" @Published public var label = "" - public var icon = "" + @Published public var icon = "" + public var type: WidgetType = .unknown public var url = "" public var period = "" @@ -90,14 +96,22 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public var mappings: [OpenHABWidgetMapping] = [] public var widgets: [OpenHABWidget] = [] public var visibility = true - public var unit = "" - public var pattern = "" - public var staticIcon: Bool? - public var labelSource = "" + public var unit: String? + public var pattern: String? + @Published public var staticIcon: Bool? public var switchSupport = false - public var yAxisDecimalPattern: String? + public var labelSource = LabelSource.unknown + public var releaseOnly: Bool? + public var row: Int? + public var column: Int? + public var releaseCommand: String? + public var command: String? + public var stateless: Bool? + public var readOnly: Bool? { + item?.stateDescription?.readOnly + } - @Published public var stateEnumBinding: WidgetTypeEnum = .unassigned + public var yAxisDecimalPattern: String? // Text prior to "[" public var labelText: String? { @@ -126,6 +140,11 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje } } + /// Returns true if any mapping has press-and-release behavior + public var hasPressReleaseMappings: Bool { + mappingsOrItemOptions.contains { $0.hasPressReleaseBehavior } + } + public var stateValueAsBool: Bool? { item?.state?.parseAsBool() } @@ -154,46 +173,6 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje } } - public var stateEnum: WidgetTypeEnum { - switch type { - case .frame: - .frame - case .switchWidget: - // Reflecting the discussion held in https://github.com/openhab/openhab-core/issues/952 - if !mappings.isEmpty { - .segmented(Int(mappingIndex(byCommand: item?.state) ?? -1)) - } else if item?.isOfTypeOrGroupType(.switchItem) ?? false { - .switcher(item?.state == "ON" ? true : false) - } else if item?.isOfTypeOrGroupType(.rollershutter) ?? false { - .rollershutter - } else if !mappingsOrItemOptions.isEmpty { - .segmented(Int(mappingIndex(byCommand: item?.state) ?? -1)) - } else { - .switcher(item?.state == "ON" ? true : false) - } - case .setpoint: - .setpoint - case .slider: - .slider - case .selection: - .selection - case .colorpicker: - .colorpicker - case .chart: - .chart - case .image: - .image - case .video: - .video - case .webview: - .webview - case .mapview: - .mapview - default: - .unassigned - } - } - public func sendItemUpdate(state: NumberState?) { guard let item, let state else { Logger.restAPI.info("ItemUpdate for Item or State = nil") @@ -228,31 +207,39 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje mappingsOrItemOptions.firstIndex { $0.command == command } } - public func iconState() -> String { - var iconState = item?.state ?? "" - if let item, let itemState = item.state { - if item.isOfTypeOrGroupType(.color) { - // For items that control a color item fetch the correct icon - if type == .slider || (type == .switchWidget && mappings.isEmpty) { - if let brightness = itemState.parseAsBrightness() { - iconState = String(brightness) - if type == .switchWidget { - iconState = iconState == "0" ? "OFF" : "ON" - } + public func mapCommandtoIndex(with command: String?) -> Int { + Int(mappingIndex(byCommand: command) ?? 0) + } + + public func iconState() -> String? { + guard let item, let itemState = item.state else { return nil } + guard !itemState.isNoneIcon else { return nil } + if item.isOfTypeOrGroupType(.color) { + // For items that control a color item fetch the correct icon + if type == .slider || (type == .switchWidget && mappings.isEmpty) { + if let brightness = itemState.parseAsBrightness() { + let brightness = String(brightness) + if type == .switchWidget { + return brightness == "0" ? "OFF" : "ON" } else { - iconState = "OFF" + return brightness } - } else if let color = itemState.parseAsUIColor() { - iconState = "#\(color.toHex() ?? "000000")" + } else { + return "OFF" } - } else if type == .switchWidget, mappings.isEmpty, !item.isOfTypeOrGroupType(.rollershutter) { - // For switch items without mappings (just ON and OFF) that control a dimmer item - // and which are not ON or OFF already, set the state to "OFF" instead of 0 - // or to "ON" to fetch the correct icon - iconState = (itemState == "0" || itemState == "OFF") ? "OFF" : "ON" + } else if let color = itemState.parseAsUIColor() { + return "#\(color.hexString ?? "000000")" } + } else if item.isOfTypeOrGroupType(.number) || item.isOfTypeOrGroupType(.numberWithDimension) { + let numberState = itemState.parseAsNumber(format: item.stateDescription?.numberPattern) + return numberState.toString(locale: Locale(identifier: "US")) + } else if type == .switchWidget, mappings.isEmpty, !item.isOfTypeOrGroupType(.rollershutter) { + // For switch items without mappings (just ON and OFF) that control a dimmer item + // and which are not ON or OFF already, set the state to "OFF" instead of 0 + // or to "ON" to fetch the correct icon + return (itemState == "0" || itemState == "OFF") ? "OFF" : "ON" } - return iconState + return itemState } private func adj(_ raw: Double) -> Double { @@ -260,6 +247,41 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje valueAdjustedToStep += minValue return valueAdjustedToStep.clamped(to: minValue ... maxValue) } + + public func generateImageResult(rootUrl: String, + chartStyle: ChartStyle = .light) -> ImagePayload { + switch type { + case .chart: + guard let url = Endpoint.chart( + rootUrl: rootUrl, + period: period, + type: item?.type, + service: service, + name: item?.name, + legend: legend, + theme: chartStyle, + forceAsItem: forceAsItem, + yAxisDecimalPattern: yAxisDecimalPattern + ).url else { + Logger.restAPI.error("Failed to generate chart URL") + return .empty + } + return .link(url: url) + + case .image: + if let item { + return item.getImagePayload() + } + guard let url = URL(string: url) else { + Logger.restAPI.error("Invalid image URL: \(self.url)") + return .empty + } + return .link(url: url) + + default: + return .empty + } + } } public extension OpenHABWidget { @@ -292,10 +314,16 @@ public extension OpenHABWidget { visibility: Bool?, switchSupport: Bool?, forceAsItem: Bool?, - unit: String?, - pattern: String?, - staticIcon: Bool?, - labelSource: String?, + labelSource: LabelSource = .unknown, + releaseOnly: Bool? = nil, + row: Int? = nil, + column: Int? = nil, + releaseCommand: String? = nil, + command: String? = nil, + stateless: Bool? = nil, + staticIcon: Bool? = nil, + unit: String? = nil, + pattern: String? = nil, yAxisDecimalPattern: String? = nil) { self.init() id = widgetId @@ -305,8 +333,7 @@ public extension OpenHABWidget { self.icon = icon self.url = url ?? "" self.period = period ?? "" - self.minValue = minValue ?? 0.0 - self.maxValue = maxValue ?? 100.0 + self.step = step ?? 1.0 // Consider a minimal refresh rate of 100 ms, but 0 is special and means 'no refresh' if let refreshVal = refresh, refreshVal > 0 { @@ -331,19 +358,36 @@ public extension OpenHABWidget { self.widgets = widgets // Sanitize minValue, maxValue and step: min <= max, step >= 0 - self.maxValue = max(self.minValue, self.maxValue) + if type != .colortemperaturepicker { + self.minValue = minValue ?? 0.0 + self.maxValue = maxValue ?? 100.0 + self.maxValue = max(self.minValue, self.maxValue) + } else { + self.minValue = minValue ?? 1000.0 + self.maxValue = maxValue ?? 10000.0 + self.maxValue = max(self.minValue, self.maxValue) + } self.step = abs(self.step) self.visibility = visibility ?? true self.switchSupport = switchSupport ?? false self.forceAsItem = forceAsItem - self.unit = unit ?? "" - self.pattern = pattern ?? "" - self.staticIcon = staticIcon ?? false - self.labelSource = labelSource ?? "" - stateEnumBinding = stateEnum + self.unit = unit + self.pattern = pattern + self.staticIcon = staticIcon + self.labelSource = labelSource + self.releaseOnly = releaseOnly + self.row = row + self.column = column + self.releaseCommand = releaseCommand + self.command = command + self.stateless = stateless self.yAxisDecimalPattern = yAxisDecimalPattern } + + convenience init(icon: String, iconColor: String? = nil) { + self.init(widgetId: "\(UUID())", label: "", icon: icon, type: .unknown, url: nil, period: nil, minValue: nil, maxValue: nil, step: nil, refresh: nil, height: nil, isLeaf: nil, iconColor: iconColor, labelColor: nil, valueColor: nil, service: nil, state: nil, text: nil, legend: nil, inputHint: nil, encoding: nil, item: nil, linkedPage: nil, mappings: [], widgets: [], visibility: nil, switchSupport: nil, forceAsItem: nil, labelSource: .unknown, releaseOnly: nil) + } } // Recursive parsing of nested widget structure @@ -351,7 +395,24 @@ public extension [OpenHABWidget] { mutating func flatten(_ widgets: [Element]) { for widget in widgets { append(widget) - flatten(widget.widgets) + if widget.type != .buttongrid { + flatten(widget.widgets) + } + } + } +} + +public extension OpenHABWidget { + var preferredRowHeight: CGFloat? { + switch type { + case .frame: + label.isEmpty ? 0 : 35.0 + case .image, .chart, .video: + nil // Automatic sizing + case .webview, .mapview: + 44.0 * CGFloat(height ?? 8) + default: + 44.0 } } } @@ -378,7 +439,7 @@ extension OpenHABWidget { state: widget.state, text: "", legend: widget.legend, - inputHint: InputHint(rawValue: widget.inputHint ?? "unknown") ?? .unknown, + inputHint: InputHint(rawValue: widget.inputHint ?? "unknown"), encoding: widget.encoding, item: OpenHABItem(widget.item), linkedPage: OpenHABPage(widget.linkedPage), @@ -387,11 +448,43 @@ extension OpenHABWidget { visibility: widget.visibility, switchSupport: widget.switchSupport, forceAsItem: widget.forceAsItem, + labelSource: OpenHABWidget.LabelSource(rawValue: widget.labelSource ?? "") ?? .unknown, + releaseOnly: widget.releaseOnly, + row: widget.row.map { Int($0) }, + column: widget.column.map { Int($0) }, + releaseCommand: widget.releaseCommand, + command: widget.command, + stateless: widget.stateless, + staticIcon: widget.staticIcon, unit: widget.unit, pattern: widget.pattern, - staticIcon: widget.staticIcon, - labelSource: widget.labelSource, yAxisDecimalPattern: widget.yAxisDecimalPattern ) } } + +// Required for behavior of Slider +public extension OpenHABWidget { + func shouldUseSliderUpdatesDuringMove() -> Bool { + if let releaseOnly { + return !releaseOnly + } + + guard let item else { + return false + } + + if item.isOfTypeOrGroupType(.dimmer) || + item.isOfTypeOrGroupType(.number) || + item.isOfTypeOrGroupType(.color) { + return true + } + + if item.isOfTypeOrGroupType(.numberWithDimension) { + // Allow live updates for percent values, but not for e.g. temperatures + return stateValueAsNumberState?.unit == "%" + } + + return false + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift index f8cc78bab..e7531f55a 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift @@ -14,15 +14,29 @@ import Foundation public struct OpenHABWidgetMapping: Decodable, Sendable { public var command = "" public var label = "" + public var row: Int? + public var column: Int? + public var icon: String? + public var releaseCommand: String? - public init(command: String?, label: String?) { + /// Returns true if this mapping has press-and-release behavior + public var hasPressReleaseBehavior: Bool { + guard let releaseCommand else { return false } + return !releaseCommand.isEmpty + } + + public init(command: String?, label: String?, row: Int? = nil, column: Int? = nil, icon: String? = nil, releaseCommand: String? = nil) { self.command = command.orEmpty self.label = label.orEmpty + self.row = row + self.column = column + self.icon = icon + self.releaseCommand = releaseCommand } } extension OpenHABWidgetMapping { init(_ mapping: Components.Schemas.MappingDTO) { - self.init(command: mapping.command, label: mapping.label) + self.init(command: mapping.command, label: mapping.label, row: mapping.row.map { Int($0) }, column: mapping.column.map { Int($0) }, icon: mapping.icon, releaseCommand: mapping.releaseCommand) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/WidgetDisplayState.swift b/OpenHABCore/Sources/OpenHABCore/Model/WidgetDisplayState.swift new file mode 100644 index 000000000..8ca0baa89 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Model/WidgetDisplayState.swift @@ -0,0 +1,62 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Foundation + +/// Immutable snapshot of widget values required for row rendering. +public struct WidgetDisplayState: Sendable { + public let widgetId: String + public let labelText: String + public let labelValue: String? + public let effectiveState: String + public let isOn: Bool + public let adjustedValue: Double + public let minValue: Double + public let maxValue: Double + public let step: Double + public let switchSupport: Bool + public let hasLinkedPage: Bool + public let readOnly: Bool + public let mappings: [OpenHABWidgetMapping] + public let hasPressReleaseMappings: Bool + public let selectedIndex: Int? + public let selectedLabel: String? +} + +public extension OpenHABWidget { + var displayState: WidgetDisplayState { + let mappings = mappingsOrItemOptions + let effectiveState = state.isEmpty ? (item?.state ?? "") : state + let selectedIndex = mappingIndex(byCommand: item?.state).map { Int($0) } + let selectedLabel = selectedIndex.flatMap { index in + mappings.indices.contains(index) ? mappings[index].label : nil + } + + return WidgetDisplayState( + widgetId: widgetId, + labelText: labelText ?? label, + labelValue: labelValue, + effectiveState: effectiveState, + isOn: effectiveState.parseAsBool(), + adjustedValue: adjustedValue, + minValue: minValue, + maxValue: maxValue, + step: step, + switchSupport: switchSupport, + hasLinkedPage: linkedPage != nil, + readOnly: readOnly ?? false, + mappings: mappings, + hasPressReleaseMappings: hasPressReleaseMappings, + selectedIndex: selectedIndex, + selectedLabel: selectedLabel + ) + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Model/WidgetRenderingKind.swift b/OpenHABCore/Sources/OpenHABCore/Model/WidgetRenderingKind.swift new file mode 100644 index 000000000..4228b7811 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Model/WidgetRenderingKind.swift @@ -0,0 +1,88 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Foundation + +public enum WidgetRenderingKind: Sendable { + case segmentedSwitch + case toggleSwitch + case rollershutterSwitch + case slider + case dateInput + case textInput + case text + case frame + case setpoint + case selection + case colorPicker + case image + case chart + case video + case webview + case mapview + case colorTemperaturePicker + case buttonGrid + case generic +} + +public extension OpenHABWidget { + var renderingKind: WidgetRenderingKind { + switch type { + case .switchWidget: + if !mappings.isEmpty { + return .segmentedSwitch + } + if item?.isOfTypeOrGroupType(.switchItem) ?? false { + return .toggleSwitch + } + if item?.isOfTypeOrGroupType(.rollershutter) ?? false { + return .rollershutterSwitch + } + if !mappingsOrItemOptions.isEmpty { + return .segmentedSwitch + } + return .toggleSwitch + case .slider: + return .slider + case .input: + if [.date, .time, .dateTime].contains(inputHint) { + return .dateInput + } + return .textInput + case .text: + return .text + case .frame: + return .frame + case .setpoint: + return .setpoint + case .selection: + return .selection + case .colorpicker: + return .colorPicker + case .image: + return .image + case .chart: + return .chart + case .video: + return .video + case .webview: + return .webview + case .mapview: + return .mapview + case .colortemperaturepicker: + return .colorTemperaturePicker + case .buttongrid: + return .buttonGrid + case .group, .defaultWidget, .button, .unknown: + return .generic + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/DoubleExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/DoubleExtension.swift index 8d091bfe5..52598e432 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/DoubleExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/DoubleExtension.swift @@ -12,6 +12,10 @@ import Foundation public extension Double { + var asColorTemperatureInKelvin: Double { + self < 1000 ? 1_000_000 / self : self + } + func valueText(step: Double) -> String { let digits = max(-Decimal(step).exponent, 0) let numberFormatter = NumberFormatter() diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index 725293039..a4f454eaf 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -187,8 +187,12 @@ public extension Endpoint { if source == "if" || source == "iconify" { queryItems = [URLQueryItem(name: "height", value: "64")] - if !iconColor.isEmpty, let colorString = UIColor(fromString: iconColor).toHex() { - queryItems.append(URLQueryItem(name: "color", value: "#\(colorString)")) + if !iconColor.isEmpty { + let uiColor = UIColor(fromString: iconColor) + let colorString = uiColor.hexString + if let colorString { + queryItems.append(URLQueryItem(name: "color", value: "#\(colorString)")) + } } if let widgetId { queryItems.append(URLQueryItem(name: "widgetId", value: widgetId)) @@ -206,8 +210,8 @@ public extension Endpoint { iconName = "none" } - if staticIcon != true { - queryItems.append(URLQueryItem(name: "state", value: state ?? "null")) + if staticIcon != true, let state { + queryItems.append(URLQueryItem(name: "state", value: state)) } queryItems.append(contentsOf: [ diff --git a/openHAB/UILabel+Localization.swift b/OpenHABCore/Sources/OpenHABCore/Util/ImageType.swift similarity index 67% rename from openHAB/UILabel+Localization.swift rename to OpenHABCore/Sources/OpenHABCore/Util/ImageType.swift index b5dd1fc5f..ba2582e35 100644 --- a/openHAB/UILabel+Localization.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ImageType.swift @@ -12,13 +12,14 @@ import Foundation import UIKit -extension UILabel { - @IBInspectable var localizationKey: String { - get { - "" - } - set { - text = NSLocalizedString(newValue, comment: "") - } - } +public enum ImagePayload { + case link(url: URL?) + case embedded(data: Data) + case empty +} + +public enum ImageType { + case link(url: URL?) + case embedded(image: UIImage?) + case empty } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift b/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift index fc802cb89..1e4caeb12 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift @@ -20,20 +20,14 @@ final class RealPathMonitor: NWPathMonitoring, Sendable { init() { monitor = NWPathMonitor() } - + func startMonitoring(handler: @escaping (Bool) async -> Void) async { - if #available(iOS 17, watchOS 10, *) { - for await path in monitor { - Logger.nwPathMonitoring.debug("Path monitor update: \(path.debugDescription)") - await handler(path.status == .satisfied || path.status == .requiresConnection) - } - } else { - for await path in monitor.paths() { - await handler(path.status == .satisfied || path.status == .requiresConnection) - } + for await path in monitor { + Logger.nwPathMonitoring.debug("Path monitor update: \(path.debugDescription)") + await handler(path.status == .satisfied || path.status == .requiresConnection) } } - + func cancel() { monitor.cancel() } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 0eff3686a..5d4e170f0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -523,16 +523,16 @@ public extension NetworkTracker { return service } - func send(to item: OpenHABItem, command: String) async throws { - try await send(to: item.name, command: command) + func send(to item: OpenHABItem, command: String, sourcePrefix: String? = nil, deviceId: String? = nil) async throws { + try await send(to: item.name, command: command, sourcePrefix: sourcePrefix, deviceId: deviceId) } - func send(to item: String, command: String) async throws { - try await service().sendItemCommand(itemname: item, command: command, sourcePrefix: nil, deviceId: nil) + func send(to item: String, command: String, sourcePrefix: String? = nil, deviceId: String? = nil) async throws { + try await service().sendItemCommand(itemname: item, command: command, sourcePrefix: sourcePrefix, deviceId: deviceId) } - func updateState(item: OpenHABItem, state: String) async throws { - try await service().updateItemState(itemname: item.name, with: state, sourcePrefix: nil, deviceId: nil) + func updateState(item: OpenHABItem, state: String, sourcePrefix: String? = nil, deviceId: String? = nil) async throws { + try await service().updateItemState(itemname: item.name, with: state, sourcePrefix: sourcePrefix, deviceId: deviceId) } func getStaticItems() async throws -> [OpenHABItem] { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 99a8331c0..924b2d0b9 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -95,18 +95,9 @@ public actor OpenAPIService { } private static func getServerURL(for url: URL) -> URL { - if let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), - let host = urlComponents.host, - host.contains("myopenhab.org"), - host != "home.myopenhab.org" { -// URL(string: "https://home.myopenhab.org")! - var newComponents = urlComponents - newComponents.host = "home.myopenhab.org" -// newComponents.scheme = "https" - return newComponents.url! - } else { - return url - } + // Respect the configured connection URL. Forcing cloud hosts can fail + // DNS resolution on some networks/simulators and breaks sitemap loading. + url } private func prepareURLSessionConfiguration(longPolling: Bool) -> URLSessionConfiguration { @@ -117,7 +108,11 @@ public actor OpenAPIService { } private func sourceComponent(deviceId: String?) -> String? { + #if os(watchOS) + let base = "org.openhab.watchos" + #else let base = "org.openhab.ios" + #endif guard let deviceId else { return base } let trimmed = deviceId.trimmingCharacters(in: .whitespacesAndNewlines) // Actor must not include the delegation separator per openHAB source spec. diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift index 25ec0f5f4..de223f357 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift @@ -14,6 +14,9 @@ import Kingfisher import os.log import SDWebImageSVGCoder import SFSafeSymbols +#if canImport(AppKit) +import WebKit +#endif /// An image processor for openHAB icons and images with SVG color preprocessing support. /// @@ -224,6 +227,12 @@ public struct OpenHABImageProcessor: ImageProcessor { guard !data.isEmpty else { return nil } if isSVG(data: data) { + #if os(macOS) + if let image = renderSVGWithWebKit(data) { + return image + } + #endif + // Apply color preprocessing to SVG if iconColor is specified let processedData = preprocessSVG(data) @@ -239,6 +248,26 @@ public struct OpenHABImageProcessor: ImageProcessor { } } + #if os(macOS) + private func renderSVGWithWebKit(_ data: Data) -> NSImage? { + guard let svgString = String(data: data, encoding: .utf8) else { return nil } + let webView = WKWebView(frame: CGRect(origin: .zero, size: CGSize(width: 256, height: 256))) + webView.loadHTMLString("\(svgString)", baseURL: nil) + + let config = WKSnapshotConfiguration() + config.rect = CGRect(origin: .zero, size: webView.bounds.size) + + var snapshotImage: NSImage? + let sema = DispatchSemaphore(value: 0) + webView.takeSnapshot(with: config) { image, _ in + snapshotImage = image + sema.signal() + } + _ = sema.wait(timeout: .now() + 2) + return snapshotImage + } + #endif + private func isSVG(data: Data?) -> Bool { guard let data else { return false } if let start = String(data: data.prefix(200), encoding: .utf8) { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 5e5ecc9e0..652502cf7 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -9,33 +9,37 @@ // // SPDX-License-Identifier: EPL-2.0 -@preconcurrency import Combine +import AsyncAlgorithms import os.log -import UIKit +import SwiftUI -@MainActor -private let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! +// Thread-safe access to UserDefaults - still needs main actor due to UserDefaults not being Sendable +private nonisolated(unsafe) let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! -@MainActor @propertyWrapper public struct UserDefault { private let key: String private let defaultValue: T private let isHomeProperty: Bool private let subject: CurrentValueSubject + private let channel: AsyncChannel public var wrappedValue: T { get { PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) } set { - PreferencesAccess.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject) { $0 } + PreferencesAccess.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject, channel: channel) { $0 } } } public var projectedValue: AnyPublisher { subject.eraseToAnyPublisher() } + + public var asyncValues: AsyncChannel { + channel + } public init(_ key: String, defaultValue: T, isHomeProperty: Bool = false) { self.key = key @@ -43,16 +47,17 @@ public struct UserDefault { self.isHomeProperty = isHomeProperty let currentValue = PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) subject = CurrentValueSubject(currentValue) + channel = AsyncChannel() } } -@MainActor @propertyWrapper public struct UserDefaultObject { private let key: String private let defaultValue: T private let isHomeProperty: Bool private let subject: CurrentValueSubject + private let channel: AsyncChannel private let objectDecoder: (Any) -> (T?) = { guard let data = $0 as? Data else { @@ -68,13 +73,17 @@ public struct UserDefaultObject { PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) } set { - PreferencesAccess.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject, converter: objectEncoder) + PreferencesAccess.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject, channel: channel, converter: objectEncoder) } } public var projectedValue: AnyPublisher { subject.eraseToAnyPublisher() } + + public var asyncValues: AsyncChannel { + channel + } init(_ key: String, defaultValue: T, isHomeProperty: Bool = false) { self.key = key @@ -84,11 +93,11 @@ public struct UserDefaultObject { // Combine publication let currentValue = PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) subject = CurrentValueSubject(currentValue) + channel = AsyncChannel() } } -@MainActor -public struct HomePreferences: Codable, Equatable { +public struct HomePreferences: Codable, Equatable, Sendable { public let id: UUID public var defaultView = "web" public var demomode = true @@ -104,15 +113,54 @@ public struct HomePreferences: Codable, Equatable { public var sitemapForWatchLabel = "watch" public var homeName = "Home" public var sseCommandItem = "" + public var lastSelectedTab = "main" + public var tabConfiguration = TabEntry.defaultConfiguration fileprivate init(id: UUID) { self.id = id } } -@MainActor -public struct ApplicationPreferences: Codable, Equatable { +public struct TabEntry: Codable, Equatable, Hashable, Sendable { + public var id: String + public var enabled: Bool + + public init(id: String, enabled: Bool) { + self.id = id + self.enabled = enabled + } + + public static let defaultConfiguration: [TabEntry] = [ + TabEntry(id: "main", enabled: true), + TabEntry(id: "sitemaps", enabled: true), + TabEntry(id: "tiles", enabled: true), + TabEntry(id: "system", enabled: true) + ] +} + +public struct ApplicationPreferences: Codable, Equatable, Sendable { public var showSearchField = true + public var sendCrashReports = false + public var idleOff = false + public var hideStatusBar = false +} + +public struct ScreenSaverPreferences: Codable, Equatable, Sendable { + public var isEnabled = false + public var showsTime = true + public var showsDate = true + public var idleInterval = 120.0 + public var movementInterval = 8.0 + public var fontName = "" + public var timeFontRatio = 0.2 + public var dateFontRatio = 0.4 + public var enableDimming = true + public var dimLevel = 0.3 + public var showsSeconds = false + public var use24Hour = false + public var fadeDuration = 2.0 + public var restoreBrightness = true + public var wakeBrightness = 1.0 } // MARK: Retrieving preference from user defaults, reacting to preference change @@ -128,7 +176,7 @@ public struct ApplicationPreferences: Codable, Equatable { // MARK: !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! private enum PreferencesAccess { - @MainActor fileprivate static func getPreference(key: String, defaultValue: T, encoder: (T) -> (some Sendable)?, decoder: (Any?) -> T?) -> T { + fileprivate static func getPreference(key: String, defaultValue: T, encoder: (T) -> (some Sendable)?, decoder: (Any?) -> T?) -> T { let preferenceValue = sharedDefaults.object(forKey: key) if let preferenceConverted = decoder(preferenceValue) { return preferenceConverted @@ -144,7 +192,7 @@ private enum PreferencesAccess { } } - @MainActor fileprivate static func preferenceChanged(newValue: T, key: String, isHomeProperty: Bool, subject: CurrentValueSubject, sanitize: (T) -> (T?) = { $0 }, converter: (T) -> (some Sendable)?) { + fileprivate static func preferenceChanged(newValue: T, key: String, isHomeProperty: Bool, subject: CurrentValueSubject, channel: AsyncChannel, sanitize: (T) -> (T?) = { $0 }, converter: (T) -> (some Sendable)?) { guard let sanitized = sanitize(newValue) else { Logger.preferences.debug("Preference \(key) new value \(String(describing: newValue), privacy: .private) could not be sanitized, will be ignored") return @@ -158,6 +206,11 @@ private enum PreferencesAccess { sharedDefaults.set(convertedValue, forKey: key) subject.send(sanitized) + + // Send to AsyncChannel + Task { + await channel.send(sanitized) + } } } @@ -170,12 +223,6 @@ public actor Preferences { @UserDefaultObject("currentHomePreferences", defaultValue: HomePreferences(id: defaultHomeId)) public private(set) var currentHomePreferences: HomePreferences - @UserDefault("sendCrashReports", defaultValue: false) - public var sendCrashReports: Bool - - @UserDefault("idleOff", defaultValue: false) - public var idleOff: Bool - @UserDefaultObject( "applicationPreferences", defaultValue: @@ -183,53 +230,12 @@ public actor Preferences { ) public private(set) var applicationPreferences: ApplicationPreferences - @UserDefault("screensaverEnabled", defaultValue: false) - public var screensaverEnabled: Bool - - @UserDefault("screensaverShowsTime", defaultValue: true) - public var screensaverShowsTime: Bool - - @UserDefault("screensaverShowsDate", defaultValue: true) - public var screensaverShowsDate: Bool - - @UserDefault("screensaverIdleInterval", defaultValue: 120.0) - public var screensaverIdleInterval: Double - - @UserDefault("screensaverMovementInterval", defaultValue: 8.0) - public var screensaverMovementInterval: Double - - @UserDefault("screensaverFontName", defaultValue: "") - public var screensaverFontName: String - - @UserDefault("screensaverTimeFontRatio", defaultValue: 0.2) - public var screensaverTimeFontRatio: Double - - @UserDefault("screensaverDateFontRatio", defaultValue: 0.4) - public var screensaverDateFontRatio: Double - - @UserDefault("screensaverEnableDimming", defaultValue: true) - public var screensaverEnableDimming: Bool - - @UserDefault("screensaverDimLevel", defaultValue: 0.3) - public var screensaverDimLevel: Double - - @UserDefault("screensaverShowsSeconds", defaultValue: false) - public var screensaverShowsSeconds: Bool - - @UserDefault("screensaverUse24Hour", defaultValue: false) - public var screensaverUse24Hour: Bool - - @UserDefault("screensaverFadeDuration", defaultValue: 2.0) - public var screensaverFadeDuration: Double - - @UserDefault("screensaverRestoreBrightness", defaultValue: true) - public var screensaverRestoreBrightness: Bool - - @UserDefault("screensaverWakeBrightness", defaultValue: 1.0) - public var screensaverWakeBrightness: Double - - @UserDefault("hideStatusBar", defaultValue: false) - public var hideStatusBar: Bool + @UserDefaultObject( + "screensaverPreferences", + defaultValue: + ScreenSaverPreferences() + ) + public private(set) var screensaverPreferences: ScreenSaverPreferences @UserDefault("currentWebViewPath", defaultValue: "") public var currentWebViewPath: String @@ -248,20 +254,62 @@ public actor Preferences { @UserDefault("didMigrateToMultipleHomes", defaultValue: false) private var didMigrateToMultipleHomes: Bool - @MainActor + @UserDefault("didMigrateScreenSaverPreferences", defaultValue: false) + private var didMigrateScreenSaverPreferences: Bool + + @UserDefault("didMigrateApplicationPreferences", defaultValue: false) + private var didMigrateApplicationPreferences: Bool + private var internalPreferenceChangeOngoing = false - @MainActor private func internalPreferenceChange(_ change: () -> Void) { internalPreferenceChangeOngoing = true change() internalPreferenceChangeOngoing = false } + + // MARK: - AsyncChannel Access + + /// Access the AsyncChannel for currentHomePreferences + public var currentHomePreferencesChannel: AsyncChannel { + _currentHomePreferences.asyncValues + } + + /// Access the AsyncChannel for applicationPreferences + public var applicationPreferencesChannel: AsyncChannel { + _applicationPreferences.asyncValues + } + + /// Access the AsyncChannel for screensaverPreferences + public var screensaverPreferencesChannel: AsyncChannel { + _screensaverPreferences.asyncValues + } + + /// Access the AsyncChannel for storedHomes + public var storedHomesChannel: AsyncChannel<[UUID: HomePreferences]> { + _storedHomes.asyncValues + } + + // Setter methods for actor-isolated properties (used in migration) + func setDidMigrateToSharedDefaults(_ value: Bool) { + didMigrateToSharedDefaults = value + } + + func setDidMigrateToMultipleHomes(_ value: Bool) { + didMigrateToMultipleHomes = value + } + + func setDidMigrateScreenSaverPreferences(_ value: Bool) { + didMigrateScreenSaverPreferences = value + } + + func setDidMigrateApplicationPreferences(_ value: Bool) { + didMigrateApplicationPreferences = value + } } // MARK: Multiple homes -@MainActor public extension Preferences { func listStoredHomes() -> [UUID] { let preferenceIds = storedHomes @@ -343,27 +391,36 @@ public extension Preferences { private func storeActiveHome() { var all = storedHomes - let homeId = Preferences.shared.activeHomeId - all[homeId] = Preferences.shared.currentHomePreferences + let homeId = activeHomeId + all[homeId] = currentHomePreferences storedHomes = all Logger.preferences.debug("Stored preferences for current home \(homeId.uuidString)") } - func modifyActiveHome(modificationFunction: @MainActor (inout HomePreferences) -> Void) { + func modifyActiveHome(modificationFunction: @Sendable (inout HomePreferences) -> Void) { var homePreferences = currentHomePreferences modificationFunction(&homePreferences) currentHomePreferences = homePreferences storeActiveHome() } - func modifyApplicationPreferences(modificationFunction: @MainActor (inout ApplicationPreferences) -> Void) { + func modifyApplicationPreferences(modificationFunction: @Sendable (inout ApplicationPreferences) -> Void) { var applicationPreferences = applicationPreferences modificationFunction(&applicationPreferences) self.applicationPreferences = applicationPreferences } + + func modifyScreenSaverPreferences(modificationFunction: @Sendable (inout ScreenSaverPreferences) -> Void) { + var screensaverPreferences = screensaverPreferences + modificationFunction(&screensaverPreferences) + self.screensaverPreferences = screensaverPreferences + } + + func setCurrentWebViewPath(_ path: String) { + currentWebViewPath = path + } } -@MainActor public extension Preferences { func firstStoredHome(where predicate: (HomePreferences) -> Bool) -> (id: UUID, record: HomePreferences)? { for (uuid, record) in storedHomes { @@ -382,18 +439,19 @@ public extension Preferences { // MARK: Migration -@MainActor public extension Preferences { - static func migratePreferences() { - Preferences.shared.initializeStoredHomes() - migrateToSharedDefaultsIfRequired() - migrateToMultipleHomesIfRequired() + static func migratePreferences() async { + await Preferences.shared.initializeStoredHomes() + await migrateToSharedDefaultsIfRequired() + await migrateToMultipleHomesIfRequired() + await migrateApplicationPreferencesIfRequired() + await migrateScreenSaverPreferencesIfRequired() } - private static func migrateToSharedDefaultsIfRequired() { - guard !Preferences.shared.didMigrateToSharedDefaults else { return } + private static func migrateToSharedDefaultsIfRequired() async { + guard await !Preferences.shared.didMigrateToSharedDefaults else { return } - Preferences.shared.modifyActiveHome { currentHomePreferences in + await Preferences.shared.modifyActiveHome { currentHomePreferences in currentHomePreferences.localConnectionConfig.url = UserDefaults.standard.string(forKey: "localUrl") ?? currentHomePreferences.localConnectionConfig.url currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth currentHomePreferences.localConnectionConfig.ignoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? currentHomePreferences.localConnectionConfig.ignoreSSL @@ -408,18 +466,15 @@ public extension Preferences { currentHomePreferences.defaultSitemap = UserDefaults.standard.string(forKey: "defaultSitemap") ?? currentHomePreferences.defaultSitemap } - Preferences.shared.idleOff = UserDefaults.standard.object(forKey: "idleOff") as? Bool ?? Preferences.shared.idleOff - Preferences.shared.sendCrashReports = UserDefaults.standard.object(forKey: "sendCrashReports") as? Bool ?? Preferences.shared.sendCrashReports - - Preferences.shared.didMigrateToSharedDefaults = true + await Preferences.shared.setDidMigrateToSharedDefaults(true) // this was done implicitly - Preferences.shared.didMigrateToMultipleHomes = true + await Preferences.shared.setDidMigrateToMultipleHomes(true) } - private static func migrateToMultipleHomesIfRequired() { - guard !Preferences.shared.didMigrateToMultipleHomes else { return } + private static func migrateToMultipleHomesIfRequired() async { + guard await !Preferences.shared.didMigrateToMultipleHomes else { return } - migrateToSharedDefaultsIfRequired() + await migrateToSharedDefaultsIfRequired() let oldLocalUrl = sharedDefaults.string(forKey: "localUrl") let oldRemoteUrl = sharedDefaults.string(forKey: "remoteUrl") @@ -428,21 +483,21 @@ public extension Preferences { let oldAlwaysSendCreds = sharedDefaults.object(forKey: "alwaysSendCreds") as? Bool let oldIgnoreSSL = sharedDefaults.object(forKey: "ignoreSSL") as? Bool - // Create new configuration - var newLocalConfiguration = Preferences.shared.currentHomePreferences.localConnectionConfig - newLocalConfiguration.url = oldLocalUrl ?? newLocalConfiguration.url - newLocalConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newLocalConfiguration.alwaysSendBasicAuth - newLocalConfiguration.ignoreSSL = oldIgnoreSSL ?? newLocalConfiguration.ignoreSSL - - var newRemoteConfiguration = Preferences.shared.currentHomePreferences.remoteConnectionConfig - newRemoteConfiguration.url = oldRemoteUrl ?? newRemoteConfiguration.url - newRemoteConfiguration.username = oldUsername ?? newRemoteConfiguration.username - newRemoteConfiguration.password = oldPassword ?? newRemoteConfiguration.password - newRemoteConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newRemoteConfiguration.alwaysSendBasicAuth - newRemoteConfiguration.ignoreSSL = oldIgnoreSSL ?? newRemoteConfiguration.ignoreSSL - // Save to Preferences - Preferences.shared.modifyActiveHome { currentHomePreferences in + await Preferences.shared.modifyActiveHome { currentHomePreferences in + // Create new configuration inside the closure to avoid capture issues + var newLocalConfiguration = currentHomePreferences.localConnectionConfig + newLocalConfiguration.url = oldLocalUrl ?? newLocalConfiguration.url + newLocalConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newLocalConfiguration.alwaysSendBasicAuth + newLocalConfiguration.ignoreSSL = oldIgnoreSSL ?? newLocalConfiguration.ignoreSSL + + var newRemoteConfiguration = currentHomePreferences.remoteConnectionConfig + newRemoteConfiguration.url = oldRemoteUrl ?? newRemoteConfiguration.url + newRemoteConfiguration.username = oldUsername ?? newRemoteConfiguration.username + newRemoteConfiguration.password = oldPassword ?? newRemoteConfiguration.password + newRemoteConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newRemoteConfiguration.alwaysSendBasicAuth + newRemoteConfiguration.ignoreSSL = oldIgnoreSSL ?? newRemoteConfiguration.ignoreSSL + currentHomePreferences.defaultView = sharedDefaults.string(forKey: "defaultView") ?? currentHomePreferences.defaultView currentHomePreferences.demomode = sharedDefaults.object(forKey: "demomode") as? Bool ?? currentHomePreferences.demomode currentHomePreferences.realTimeSliders = sharedDefaults.object(forKey: "realTimeSliders") as? Bool ?? currentHomePreferences.realTimeSliders @@ -457,16 +512,105 @@ public extension Preferences { currentHomePreferences.sitemapForWatchLabel = sharedDefaults.string(forKey: "sitemapForWatchLabel") ?? currentHomePreferences.sitemapForWatchLabel } - Preferences.shared.didMigrateToMultipleHomes = true + await Preferences.shared.setDidMigrateToMultipleHomes(true) + } + + private static func migrateApplicationPreferencesIfRequired() async { + guard await !Preferences.shared.didMigrateApplicationPreferences else { return } + + // Check if old preferences exist in UserDefaults + let oldSendCrashReports = sharedDefaults.object(forKey: "sendCrashReports") as? Bool + let oldIdleOff = sharedDefaults.object(forKey: "idleOff") as? Bool + let oldHideStatusBar = sharedDefaults.object(forKey: "hideStatusBar") as? Bool + + // Only migrate if at least one old preference exists + if oldSendCrashReports != nil || oldIdleOff != nil || oldHideStatusBar != nil { + await Preferences.shared.modifyApplicationPreferences { prefs in + if let oldSendCrashReports { prefs.sendCrashReports = oldSendCrashReports } + if let oldIdleOff { prefs.idleOff = oldIdleOff } + if let oldHideStatusBar { prefs.hideStatusBar = oldHideStatusBar } + } + + Logger.preferences.info("Migrated application preferences from individual keys to ApplicationPreferences struct") + + // Clean up old keys + sharedDefaults.removeObject(forKey: "sendCrashReports") + sharedDefaults.removeObject(forKey: "idleOff") + sharedDefaults.removeObject(forKey: "hideStatusBar") + } + + await Preferences.shared.setDidMigrateApplicationPreferences(true) + } + + private static func migrateScreenSaverPreferencesIfRequired() async { + guard await !Preferences.shared.didMigrateScreenSaverPreferences else { return } + + // Check if old preferences exist in UserDefaults + let oldEnabled = sharedDefaults.object(forKey: "screensaverEnabled") as? Bool + let oldShowsTime = sharedDefaults.object(forKey: "screensaverShowsTime") as? Bool + let oldShowsDate = sharedDefaults.object(forKey: "screensaverShowsDate") as? Bool + let oldIdleInterval = sharedDefaults.object(forKey: "screensaverIdleInterval") as? Double + let oldMovementInterval = sharedDefaults.object(forKey: "screensaverMovementInterval") as? Double + let oldFontName = sharedDefaults.string(forKey: "screensaverFontName") + let oldTimeFontRatio = sharedDefaults.object(forKey: "screensaverTimeFontRatio") as? Double + let oldDateFontRatio = sharedDefaults.object(forKey: "screensaverDateFontRatio") as? Double + let oldEnableDimming = sharedDefaults.object(forKey: "screensaverEnableDimming") as? Bool + let oldDimLevel = sharedDefaults.object(forKey: "screensaverDimLevel") as? Double + let oldShowsSeconds = sharedDefaults.object(forKey: "screensaverShowsSeconds") as? Bool + let oldUse24Hour = sharedDefaults.object(forKey: "screensaverUse24Hour") as? Bool + let oldFadeDuration = sharedDefaults.object(forKey: "screensaverFadeDuration") as? Double + let oldRestoreBrightness = sharedDefaults.object(forKey: "screensaverRestoreBrightness") as? Bool + let oldWakeBrightness = sharedDefaults.object(forKey: "screensaverWakeBrightness") as? Double + + // Only migrate if at least one old preference exists + if oldEnabled != nil || oldShowsTime != nil || oldIdleInterval != nil { + await Preferences.shared.modifyScreenSaverPreferences { prefs in + if let oldEnabled { prefs.isEnabled = oldEnabled } + if let oldShowsTime { prefs.showsTime = oldShowsTime } + if let oldShowsDate { prefs.showsDate = oldShowsDate } + if let oldIdleInterval { prefs.idleInterval = oldIdleInterval } + if let oldMovementInterval { prefs.movementInterval = oldMovementInterval } + if let oldFontName { prefs.fontName = oldFontName } + if let oldTimeFontRatio { prefs.timeFontRatio = oldTimeFontRatio } + if let oldDateFontRatio { prefs.dateFontRatio = oldDateFontRatio } + if let oldEnableDimming { prefs.enableDimming = oldEnableDimming } + if let oldDimLevel { prefs.dimLevel = oldDimLevel } + if let oldShowsSeconds { prefs.showsSeconds = oldShowsSeconds } + if let oldUse24Hour { prefs.use24Hour = oldUse24Hour } + if let oldFadeDuration { prefs.fadeDuration = oldFadeDuration } + if let oldRestoreBrightness { prefs.restoreBrightness = oldRestoreBrightness } + if let oldWakeBrightness { prefs.wakeBrightness = oldWakeBrightness } + } + + Logger.preferences.info("Migrated screen saver preferences from individual keys to ScreenSaverPreferences struct") + + // Clean up old keys (optional, but keeps UserDefaults tidy) + sharedDefaults.removeObject(forKey: "screensaverEnabled") + sharedDefaults.removeObject(forKey: "screensaverShowsTime") + sharedDefaults.removeObject(forKey: "screensaverShowsDate") + sharedDefaults.removeObject(forKey: "screensaverIdleInterval") + sharedDefaults.removeObject(forKey: "screensaverMovementInterval") + sharedDefaults.removeObject(forKey: "screensaverFontName") + sharedDefaults.removeObject(forKey: "screensaverTimeFontRatio") + sharedDefaults.removeObject(forKey: "screensaverDateFontRatio") + sharedDefaults.removeObject(forKey: "screensaverEnableDimming") + sharedDefaults.removeObject(forKey: "screensaverDimLevel") + sharedDefaults.removeObject(forKey: "screensaverShowsSeconds") + sharedDefaults.removeObject(forKey: "screensaverUse24Hour") + sharedDefaults.removeObject(forKey: "screensaverFadeDuration") + sharedDefaults.removeObject(forKey: "screensaverRestoreBrightness") + sharedDefaults.removeObject(forKey: "screensaverWakeBrightness") + } + + await Preferences.shared.setDidMigrateScreenSaverPreferences(true) } } // MARK: All connections -@MainActor public extension Preferences { func getNotificationConnection() -> ConnectionConfiguration? { - getNotificationConnection(of: [Preferences.shared.currentHomePreferences.remoteConnectionConfig]) + getNotificationConnection(of: [currentHomePreferences.remoteConnectionConfig]) } func getNotificationConnection(of homeConfig: HomePreferences) -> ConnectionConfiguration? { @@ -506,3 +650,79 @@ public extension ConnectionConfiguration { priority: 1 ) } + +// MARK: - SwiftUI Observable Wrapper + +/// A @MainActor observable wrapper for Preferences that can be used in SwiftUI views +@MainActor +@Observable +public final class PreferencesObserver { + public static let shared = PreferencesObserver() + + public private(set) var currentHomePreferences: HomePreferences + public private(set) var applicationPreferences: ApplicationPreferences + public private(set) var screensaverPreferences: ScreenSaverPreferences + + private var currentHomeTask: Task? + private var applicationTask: Task? + private var screensaverTask: Task? + + private init() { + // Initialize with default values - will be updated immediately by channels + self.currentHomePreferences = HomePreferences(id: UUID()) + self.applicationPreferences = ApplicationPreferences() + self.screensaverPreferences = ScreenSaverPreferences() + + // Bootstrap initial values and start listeners + Task { [weak self] in + guard let self else { return } + // Fetch initial values from the actor + let initialHome = await Preferences.shared.currentHomePreferences + let initialApp = await Preferences.shared.applicationPreferences + let initialScreen = await Preferences.shared.screensaverPreferences + + // Apply initial values on the main actor + self.currentHomePreferences = initialHome + self.applicationPreferences = initialApp + self.screensaverPreferences = initialScreen + + // Start listening to async channels + self.currentHomeTask = Task { [weak self] in + guard let self else { return } + let channel = await Preferences.shared.currentHomePreferencesChannel + for await value in channel { + await MainActor.run { self.currentHomePreferences = value } + } + } + + self.applicationTask = Task { [weak self] in + guard let self else { return } + let channel = await Preferences.shared.applicationPreferencesChannel + for await value in channel { + await MainActor.run { self.applicationPreferences = value } + } + } + + self.screensaverTask = Task { [weak self] in + guard let self else { return } + let channel = await Preferences.shared.screensaverPreferencesChannel + for await value in channel { + await MainActor.run { self.screensaverPreferences = value } + } + } + } + } + + @MainActor + deinit { + currentHomeTask?.cancel() + applicationTask?.cancel() + screensaverTask?.cancel() + } + + /// Get notification connection for current home + public func getNotificationConnection() async -> ConnectionConfiguration? { + await Preferences.shared.getNotificationConnection() + } +} + diff --git a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift index f4024c11f..15f8528e8 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift @@ -184,4 +184,15 @@ public extension String? { "" } } + + var isNilOrEmpty: Bool { + self == nil || self == "" + } +} + +public extension String { + var isNoneIcon: Bool { + let pattern = #"^(oh:([a-z]+:)?)?none$"# + return range(of: pattern, options: .regularExpression) != nil + } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift index 69fe02c93..c6f493588 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift @@ -162,6 +162,30 @@ public extension UIColor { } public extension UIColor { + var hexString: String? { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { + return nil + } + + let r = UInt8(round(red * 255)) + let g = UInt8(round(green * 255)) + let b = UInt8(round(blue * 255)) + let a = UInt8(round(alpha * 255)) + + func toHex(_ value: UInt8) -> String { + let hex = String(value, radix: 16, uppercase: true) + return hex.count == 1 ? "0" + hex : hex + } + + let components = [r, g, b] + (a == 255 ? [] : [a]) + return components.map(toHex).joined() + } + /// Initializes a UIColor from a string, supporting both named colors and hexadecimal color codes. /// /// This convenience initializer attempts to parse the input string as either a named color @@ -208,12 +232,7 @@ public extension UIColor { } // Try hex let hexColor = UIColor(hex: string) - // If hexColor is gray, input was invalid - if hexColor.toHex() == UIColor.gray.toHex() { - self.init(cgColor: UIColor.gray.cgColor) - } else { - self.init(cgColor: hexColor.cgColor) - } + self.init(cgColor: hexColor.cgColor) } /// Initializes a UIColor from a hexadecimal color string. diff --git a/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandDispatcher.swift b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandDispatcher.swift new file mode 100644 index 000000000..6c673948b --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandDispatcher.swift @@ -0,0 +1,195 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Foundation + +@MainActor +public final class WidgetCommandDispatcher { + private var pendingTasks: [String: Task] = [:] + + public init() {} + + @MainActor + public func send(_ command: String?, + for widget: OpenHABWidget, + policy: WidgetCommandPolicy, + phase: WidgetCommandPhase = .change, + key: String? = nil, + fallbackItem: OpenHABItem? = nil) { + guard let command, !command.isEmpty else { return } + + switch policy { + case .immediate: + dispatch(command: command, for: widget, fallbackItem: fallbackItem) + case let .debounce(duration): + guard phase != .release else { + dispatch(command: command, for: widget, fallbackItem: fallbackItem) + return + } + sendDebounced( + command, + for: widget, + duration: duration, + key: key, + fallbackItem: fallbackItem + ) + case .finalOnly: + guard phase == .release else { return } + dispatch(command: command, for: widget, fallbackItem: fallbackItem) + case .pressRelease: + guard phase == .press || phase == .release else { return } + dispatch(command: command, for: widget, fallbackItem: fallbackItem) + } + } + + @MainActor + public func send(_ command: String?, + for item: OpenHABItem?, + policy: WidgetCommandPolicy, + phase: WidgetCommandPhase = .change, + key: String? = nil, + execute: @escaping @MainActor (_ itemname: String, _ command: String) -> Void) { + guard let command, !command.isEmpty, let item else { return } + + switch policy { + case .immediate: + execute(item.name, command) + case let .debounce(duration): + guard phase != .release else { + execute(item.name, command) + return + } + sendDebounced( + command, + for: item, + duration: duration, + key: key, + execute: execute + ) + case .finalOnly: + guard phase == .release else { return } + execute(item.name, command) + case .pressRelease: + guard phase == .press || phase == .release else { return } + execute(item.name, command) + } + } + + @MainActor + public func sendPress(_ command: String?, + for widget: OpenHABWidget, + fallbackItem: OpenHABItem? = nil) { + guard let command, !command.isEmpty else { return } + dispatch(command: command, for: widget, fallbackItem: fallbackItem) + } + + @MainActor + public func sendRelease(_ command: String?, + for widget: OpenHABWidget, + fallbackItem: OpenHABItem? = nil) { + guard let command, !command.isEmpty else { return } + dispatch(command: command, for: widget, fallbackItem: fallbackItem) + } + + @MainActor + public func sendItemUpdate(_ state: NumberState?, + for widget: OpenHABWidget, + fallbackItem: OpenHABItem? = nil) { + guard let state else { return } + + if let item = widget.item ?? fallbackItem, + let sendCommand = widget.sendCommand { + if item.isOfTypeOrGroupType(.numberWithDimension) { + sendCommand(item, state.toString(locale: Locale(identifier: "US"))) + } else { + sendCommand(item, state.stringValue) + } + return + } + + widget.sendItemUpdate(state: state) + } + + @MainActor + public func cancelPending(for widget: OpenHABWidget, key: String? = nil) { + let taskKey = commandKey(for: widget, key: key) + pendingTasks[taskKey]?.cancel() + pendingTasks.removeValue(forKey: taskKey) + } + + @MainActor + public func cancelPending(for item: OpenHABItem, key: String? = nil) { + let taskKey = commandKey(for: item, key: key) + pendingTasks[taskKey]?.cancel() + pendingTasks.removeValue(forKey: taskKey) + } + + @MainActor + private func sendDebounced(_ command: String, + for widget: OpenHABWidget, + duration: Duration, + key: String?, + fallbackItem: OpenHABItem?) { + let taskKey = commandKey(for: widget, key: key) + pendingTasks[taskKey]?.cancel() + pendingTasks[taskKey] = Task { @MainActor [weak self] in + try? await Task.sleep(for: duration) + guard !Task.isCancelled else { return } + self?.dispatch(command: command, for: widget, fallbackItem: fallbackItem) + self?.pendingTasks.removeValue(forKey: taskKey) + } + } + + @MainActor + private func sendDebounced(_ command: String, + for item: OpenHABItem, + duration: Duration, + key: String?, + execute: @escaping @MainActor (_ itemname: String, _ command: String) -> Void) { + let taskKey = commandKey(for: item, key: key) + pendingTasks[taskKey]?.cancel() + pendingTasks[taskKey] = Task { @MainActor [weak self] in + try? await Task.sleep(for: duration) + guard !Task.isCancelled else { return } + execute(item.name, command) + self?.pendingTasks.removeValue(forKey: taskKey) + } + } + + @MainActor + private func dispatch(command: String, for widget: OpenHABWidget, fallbackItem: OpenHABItem?) { + if let item = widget.item ?? fallbackItem, + let sendCommand = widget.sendCommand { + sendCommand(item, command) + return + } + widget.sendCommand(command) + } + + private func commandKey(for widget: OpenHABWidget, key: String?) -> String { + if let key { + return "\(widget.widgetId)-\(key)" + } + return widget.widgetId + } + + private func commandKey(for item: OpenHABItem, key: String?) -> String { + if let key { + return "\(item.name)-\(key)" + } + return item.name + } + + deinit { + pendingTasks.values.forEach { $0.cancel() } + pendingTasks.removeAll() + } +} diff --git a/openHAB/ReusableView.swift b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandLifecycleState.swift similarity index 59% rename from openHAB/ReusableView.swift rename to OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandLifecycleState.swift index c50908411..8bd903570 100644 --- a/openHAB/ReusableView.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandLifecycleState.swift @@ -9,17 +9,8 @@ // // SPDX-License-Identifier: EPL-2.0 -import Foundation -import UIKit - -protocol ReusableView { - static var reuseIdentifier: String { get } -} - -extension ReusableView { - static var reuseIdentifier: String { - String(describing: self) - } +public enum WidgetCommandLifecycleState: Sendable, Equatable { + case idle + case sending + case failed(message: String?) } - -extension UITableViewCell: ReusableView {} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandPolicy.swift b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandPolicy.swift new file mode 100644 index 000000000..57f077cdd --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandPolicy.swift @@ -0,0 +1,46 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Foundation + +public enum WidgetCommandDefaults { + public static let slider: WidgetCommandPolicy = .debounce(.milliseconds(500)) + public static let colorPicker: WidgetCommandPolicy = .debounce(.milliseconds(200)) + public static let immediate: WidgetCommandPolicy = .immediate + public static let finalOnly: WidgetCommandPolicy = .finalOnly + public static let pressRelease: WidgetCommandPolicy = .pressRelease + + public static func policy(for widget: OpenHABWidget) -> WidgetCommandPolicy { + switch widget.type { + case .slider: + slider + case .colorpicker: + colorPicker + default: + immediate + } + } +} + +public enum WidgetCommandPhase: Sendable { + case press + case change + case release +} + +public enum WidgetCommandPolicy: Sendable { + case immediate + case debounce(Duration) + /// Dispatches only when phase is `.release`. + case finalOnly + /// Dispatches only for `.press` and `.release` phases. + case pressRelease +} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ETagCheckerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ETagCheckerTests.swift index 797e537d5..4c0059f4e 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/ETagCheckerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/ETagCheckerTests.swift @@ -13,6 +13,70 @@ import Foundation @testable import OpenHABCore import Testing +// MARK: - Mock URLProtocol + +class MockURLProtocol: URLProtocol { + // Use nonisolated(unsafe) to suppress concurrency warnings + // Safe because tests run with .serialized, ensuring sequential execution + nonisolated(unsafe) static var mockResponses: [URL: (statusCode: Int, headers: [String: String])] = [:] + nonisolated(unsafe) static var shouldFail = false + nonisolated(unsafe) static var error: Error? + + static func reset() { + mockResponses.removeAll() + shouldFail = false + error = nil + } + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + if MockURLProtocol.shouldFail { + let error = MockURLProtocol.error ?? URLError(.badServerResponse) + client?.urlProtocol(self, didFailWithError: error) + return + } + + guard let url = request.url else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + + guard let mock = MockURLProtocol.mockResponses[url] else { + // Return 404 if no mock configured + let response = HTTPURLResponse( + url: url, + statusCode: 404, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + return + } + + let response = HTTPURLResponse( + url: url, + statusCode: mock.statusCode, + httpVersion: "HTTP/1.1", + headerFields: mock.headers + )! + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + @Suite("ETagChecker Tests", .serialized) struct ETagCheckerTests { @Test("First run stores ETag and returns changed") @@ -246,67 +310,3 @@ struct ETagCheckerTests { ) } } - -// MARK: - Mock URLProtocol - -class MockURLProtocol: URLProtocol { - // Use nonisolated(unsafe) to suppress concurrency warnings - // Safe because tests run with .serialized, ensuring sequential execution - nonisolated(unsafe) static var mockResponses: [URL: (statusCode: Int, headers: [String: String])] = [:] - nonisolated(unsafe) static var shouldFail: Bool = false - nonisolated(unsafe) static var error: Error? - - override class func canInit(with request: URLRequest) -> Bool { - true - } - - override class func canonicalRequest(for request: URLRequest) -> URLRequest { - request - } - - override func startLoading() { - if MockURLProtocol.shouldFail { - let error = MockURLProtocol.error ?? URLError(.badServerResponse) - client?.urlProtocol(self, didFailWithError: error) - return - } - - guard let url = request.url else { - client?.urlProtocol(self, didFailWithError: URLError(.badURL)) - return - } - - guard let mock = MockURLProtocol.mockResponses[url] else { - // Return 404 if no mock configured - let response = HTTPURLResponse( - url: url, - statusCode: 404, - httpVersion: "HTTP/1.1", - headerFields: nil - )! - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data()) - client?.urlProtocolDidFinishLoading(self) - return - } - - let response = HTTPURLResponse( - url: url, - statusCode: mock.statusCode, - httpVersion: "HTTP/1.1", - headerFields: mock.headers - )! - - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data()) - client?.urlProtocolDidFinishLoading(self) - } - - override func stopLoading() {} - - static func reset() { - mockResponses.removeAll() - shouldFail = false - error = nil - } -} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/OpenHABWidgetIconStateTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/OpenHABWidgetIconStateTests.swift new file mode 100644 index 000000000..c2129b3b8 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/OpenHABWidgetIconStateTests.swift @@ -0,0 +1,55 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import OpenHABCore +import SwiftUI +import Testing + +@Suite +struct OpenHABWidgetIconStateTests { +// @Test +// func returnsLowercasedState() { +// let widget = makeTestWidget(itemState: "ON") +// #expect(widget.iconState() == "on") +// } + +// @Test +// func handlesMixedCase() { +// let widget = makeTestWidget(itemState: "ClOsEd") +// #expect(widget.iconState() == "closed") +// } + +// @Test +// func returnsNilWhenStateIsEmpty() { +// let widget = makeTestWidget(itemState: "") +// #expect(widget.iconState() == nil) +// } + + @Test + func returnsNilWhenStateIsNil() { + let widget = makeTestWidget(itemState: nil) + #expect(widget.iconState() == nil) + } + + @Test + func acceptsNumericString() { + let widget = makeTestWidget(itemState: "123") + #expect(widget.iconState() == "123") + } + + // MARK: - Helpers + + private func makeTestWidget(itemState: String?) -> OpenHABWidget { + let dto = OpenHABWidget() + dto.item = OpenHABItem(name: "String", type: "String", state: itemState, link: "122", label: "labe", groupType: nil, stateDescription: nil, commandDescription: nil, members: [], category: nil, options: nil) + return dto + } +} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/StringExtensionTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/StringExtensionTests.swift index 9e92d8d49..f54b04828 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/StringExtensionTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/StringExtensionTests.swift @@ -22,4 +22,20 @@ struct StringExtensionTests { #expect("///".removeTrailingSlashes() == "") #expect("".removeTrailingSlashes() == "") } + + @Test + func testIsNoneIcon() throws { + let testCases: [String: Bool] = [ + "none": true, + "oh:none": true, + "oh:classic:none": true, + "oh:foo:none": true, + "f7:none": false, + "lights": false + ] + + for (input, expected) in testCases { + #expect(input.isNoneIcon == expected, "\(input) failed") + } + } } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/UIColorTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/UIColorTests.swift new file mode 100644 index 000000000..4312bdf4e --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/UIColorTests.swift @@ -0,0 +1,66 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import OpenHABCore +import Testing +import UIKit + +@Suite +struct UIColorTests { + @Test + func resolvesNamedColor_exactMatch() { + #expect(UIColor(fromString: "red") == .ohRed) + } + + @Test + func resolvesNamedColor_caseInsensitive() { + #expect(UIColor(fromString: "NAvY") == .ohNavy) + } + + @Test + func resolvesNamedColor_withWhitespace() { + #expect(UIColor(fromString: " green ") == .ohGreen) + } + + @Test + func fallsBackToHexParsing() { + #expect(UIColor(fromString: "#FF0000") == UIColor(hex: "#FF0000")) + } + + @Test + func returnsFallbackForInvalidColor() { + let color = UIColor(fromString: "notAColor") + let fallback = UIColor(hex: "notAColor") + #expect(color == fallback) + } + + @Test + func hexStringForMultipleColors() { + // Array of (UIColor, expectedHexString) + let testCases: [(UIColor, String?)] = [ + (UIColor.red, "FF0000"), + (UIColor.green, "00FF00"), + (UIColor.blue, "0000FF"), + (UIColor.white, "FFFFFF"), + (UIColor.black, "000000"), + (UIColor(red: 1, green: 0, blue: 0, alpha: 0.5), "FF000080"), // 50% alpha + (UIColor(patternImage: UIImage()), nil) // Non-RGB color should return nil + ] + + for (index, testCase) in testCases.enumerated() { + let (color, expectedHex) = testCase + #expect( + color.hexString == expectedHex, + "Case \(index): Expected \(expectedHex ?? "nil"), got \(color.hexString ?? "nil")" + ) + } + } +} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/WidgetDisplayStateTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/WidgetDisplayStateTests.swift new file mode 100644 index 000000000..b2a44694e --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/WidgetDisplayStateTests.swift @@ -0,0 +1,117 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import OpenHABCore +import Testing + +@Suite +struct WidgetDisplayStateTests { + @Test + func usesItemStateWhenWidgetStateIsEmpty() { + let widget = makeWidget( + widgetState: "", + itemState: "ON", + label: "Kitchen Light [On]" + ) + + let display = widget.displayState + + #expect(display.effectiveState == "ON") + #expect(display.isOn) + #expect(display.labelText == "Kitchen Light") + #expect(display.labelValue == "On") + } + + @Test + func widgetStateOverridesItemState() { + let widget = makeWidget( + widgetState: "OFF", + itemState: "ON", + label: "Switch" + ) + + let display = widget.displayState + + #expect(display.effectiveState == "OFF") + #expect(!display.isOn) + } + + @Test + func resolvesSelectedMappingFromItemState() { + let widget = makeWidget( + widgetState: "", + itemState: "AUTO", + label: "Mode", + mappings: [ + OpenHABWidgetMapping(command: "MANUAL", label: "Manual"), + OpenHABWidgetMapping(command: "AUTO", label: "Auto") + ] + ) + + let display = widget.displayState + + #expect(display.selectedIndex == 1) + #expect(display.selectedLabel == "Auto") + } + + // MARK: - Helpers + + private func makeWidget(widgetState: String, + itemState: String, + label: String, + mappings: [OpenHABWidgetMapping] = []) -> OpenHABWidget { + let item = OpenHABItem( + name: "Item", + type: "Switch", + state: itemState, + link: "", + label: nil, + groupType: nil, + stateDescription: nil, + commandDescription: nil, + members: [], + category: nil, + options: nil + ) + let widget = OpenHABWidget( + widgetId: "widget-id", + label: label, + icon: "switch", + type: .switchWidget, + url: nil, + period: nil, + minValue: 0, + maxValue: 100, + step: 1, + refresh: nil, + height: nil, + isLeaf: nil, + iconColor: nil, + labelColor: nil, + valueColor: nil, + service: nil, + state: widgetState, + text: nil, + legend: nil, + inputHint: nil, + encoding: nil, + item: item, + linkedPage: nil, + mappings: mappings, + widgets: [], + visibility: true, + switchSupport: false, + forceAsItem: nil, + labelSource: .sitemapDefinition + ) + return widget + } +} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/WidgetRenderingKindTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/WidgetRenderingKindTests.swift new file mode 100644 index 000000000..40f96e4d7 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/WidgetRenderingKindTests.swift @@ -0,0 +1,74 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import OpenHABCore +import Testing + +@Suite +struct WidgetRenderingKindTests { + @Test + func switchWithMappingsUsesSegmentedKind() { + let widget = makeWidget(type: .switchWidget, itemType: "Switch") + widget.mappings = [OpenHABWidgetMapping(command: "ON", label: "On")] + + #expect(widget.renderingKind == .segmentedSwitch) + } + + @Test + func switchWithSwitchItemUsesToggleKind() { + let widget = makeWidget(type: .switchWidget, itemType: "Switch") + + #expect(widget.renderingKind == .toggleSwitch) + } + + @Test + func switchWithRollershutterItemUsesRollershutterKind() { + let widget = makeWidget(type: .switchWidget, itemType: "Rollershutter") + + #expect(widget.renderingKind == .rollershutterSwitch) + } + + @Test + func inputDateHintUsesDateInputKind() { + let widget = makeWidget(type: .input, itemType: "String") + widget.inputHint = .dateTime + + #expect(widget.renderingKind == .dateInput) + } + + @Test + func inputTextHintUsesTextInputKind() { + let widget = makeWidget(type: .input, itemType: "String") + widget.inputHint = .text + + #expect(widget.renderingKind == .textInput) + } + + private func makeWidget(type: OpenHABWidget.WidgetType, itemType: String) -> OpenHABWidget { + let item = OpenHABItem( + name: "Item", + type: itemType, + state: "OFF", + link: "", + label: nil, + groupType: nil, + stateDescription: nil, + commandDescription: nil, + members: [], + category: nil, + options: nil + ) + let widget = OpenHABWidget() + widget.type = type + widget.item = item + return widget + } +} diff --git a/TestPlans/openHABTests.xctestplan b/TestPlans/openHABTests.xctestplan index 3ed419e45..19f3494b5 100644 --- a/TestPlans/openHABTests.xctestplan +++ b/TestPlans/openHABTests.xctestplan @@ -15,6 +15,14 @@ "codeCoverage" : false }, "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:OpenHABCore", + "identifier" : "OpenHABCoreTests", + "name" : "OpenHABCoreTests" + } + }, { "parallelizable" : true, "target" : { @@ -24,11 +32,10 @@ } }, { - "parallelizable" : true, "target" : { - "containerPath" : "container:OpenHABCore", - "identifier" : "OpenHABCoreTests", - "name" : "OpenHABCoreTests" + "containerPath" : "container:CommonUI", + "identifier" : "CommonUITests", + "name" : "CommonUITests" } } ], diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index e434e5906..c34fe7e5e 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -7,20 +7,31 @@ objects = { /* Begin PBXBuildFile section */ + AA0001022F5A000000000001 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001012F5A000000000001 /* SplashView.swift */; }; + AA0001042F5A000000000002 /* OpenHABApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001032F5A000000000002 /* OpenHABApp.swift */; }; + 0C93C5B057F863322A1B9820 /* SystemTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4229903F00160C965E22DE21 /* SystemTab.swift */; }; + 10472F7AF99C4940A6144817 /* TextRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399449C421544C61AD83450C /* TextRow.swift */; }; 1224F78F228A89FD00750965 /* WatchMessageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1224F78D228A89FC00750965 /* WatchMessageService.swift */; }; + 1CE7AC462008E29FA37F231D /* AppServicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BEA2793F07C36BB98D9B8 /* AppServicesViewModel.swift */; }; + 2C9BBB28C068BCC10291A566 /* TilesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE239F0348D19000408BA8AA /* TilesTab.swift */; }; 2F08AFC72E5FADCF00E70611 /* NotificationCenterDelegateImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F08AFC62E5FADC500E70611 /* NotificationCenterDelegateImpl.swift */; }; + 2F54B56D2F40AC1A00DA1F1A /* REFACTORING_SUMMARY.md in Resources */ = {isa = PBXBuildFile; fileRef = 2F54B56C2F40AC1A00DA1F1A /* REFACTORING_SUMMARY.md */; }; + 2F54B5742F428ACA00DA1F1A /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 2F54B5732F428ACA00DA1F1A /* AsyncAlgorithms */; }; + 2F54B5772F428B0200DA1F1A /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 2F54B5762F428B0200DA1F1A /* Algorithms */; }; 2F55E7BB2DEE447700EC8350 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F55E7BA2DEE447700EC8350 /* SettingsView.swift */; }; 2F55E7BD2DEE44A800EC8350 /* ClientCertificatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F55E7BC2DEE44A800EC8350 /* ClientCertificatesView.swift */; }; - 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F6412ED2CE494A80039FB28 /* DatePickerUITableViewCell.swift */; }; + 2F77DF3C2F43D3D700BE3744 /* OpenHABWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F77DF3B2F43D3D700BE3744 /* OpenHABWebView.swift */; }; + 2F77DF3E2F43D42E00BE3744 /* WEBVIEW_MIGRATION.md in Resources */ = {isa = PBXBuildFile; fileRef = 2F77DF3D2F43D42E00BE3744 /* WEBVIEW_MIGRATION.md */; }; + 2F77DF402F43D94C00BE3744 /* UIKIT_MIGRATION_ANALYSIS.md in Resources */ = {isa = PBXBuildFile; fileRef = 2F77DF3F2F43D94C00BE3744 /* UIKIT_MIGRATION_ANALYSIS.md */; }; + 2F77DF422F43D96800BE3744 /* CertificateManagementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F77DF412F43D96800BE3744 /* CertificateManagementService.swift */; }; + 2F77DF442F43D97400BE3744 /* IdleTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F77DF432F43D97400BE3744 /* IdleTimerService.swift */; }; + 2F77DF462F43D9A400BE3744 /* UIKIT_REMOVAL_GUIDE.md in Resources */ = {isa = PBXBuildFile; fileRef = 2F77DF452F43D9A400BE3744 /* UIKIT_REMOVAL_GUIDE.md */; }; 2FBCF58C2DEB0B7700CD5D83 /* HomeSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */; }; - 2FEFD8F62BE7C5BE00E387B9 /* TextInputUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */; }; 2FF459362E230C6A00C0B640 /* OpenHABIntentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF459352E230C6A00C0B640 /* OpenHABIntentHelper.swift */; }; 4D6470DA2561F935007B03FC /* openHABIntents.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4D6470D32561F935007B03FC /* openHABIntents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 652B81042E2193B500648510 /* ScreenSaverSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */; }; 652B81092E2193DA00648510 /* ScreenSaverManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652B81062E2193DA00648510 /* ScreenSaverManager.swift */; }; 652B810A2E2193DA00648510 /* ScreenSaverConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652B81052E2193DA00648510 /* ScreenSaverConfiguration.swift */; }; - 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653B54C1285E714900298ECD /* OpenHABViewController.swift */; }; - 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */; }; 6557AF8F2C0241C10094D0C8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6557AF8E2C0241C10094D0C8 /* PrivacyInfo.xcprivacy */; }; 6557AF902C0241C10094D0C8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6557AF8E2C0241C10094D0C8 /* PrivacyInfo.xcprivacy */; }; 6557AF922C039D140094D0C8 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 6557AF912C039D140094D0C8 /* FirebaseMessaging */; }; @@ -28,13 +39,15 @@ 657144512C1E438700C8A1F3 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657144502C1E438700C8A1F3 /* NotificationService.swift */; }; 657144552C1E438700C8A1F3 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6571444E2C1E438700C8A1F3 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 657144962C30A16700C8A1F3 /* OpenHABCore in Frameworks */ = {isa = PBXBuildFile; productRef = 657144952C30A16700C8A1F3 /* OpenHABCore */; }; - 65C2EF492E244C8500A0C19F /* OpenHABNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */; }; 65F055442E3D4E41004E98FE /* ItemSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F055432E3D4E41004E98FE /* ItemSelectionView.swift */; }; 7BFFEA908B9E47FCB5C46E6E /* VideoStreamManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 905A534AB32C4104AC55A75C /* VideoStreamManager.swift */; }; + 801159DCB99C03EFA71F4B9D /* MainWebTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5135184D9630899A34EFBA95 /* MainWebTab.swift */; }; + 911CC92713E7DF11C3295A4C /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C3E3BF4C930ADA95E6E997 /* SafariView.swift */; }; 932602EE2382892B00EAD685 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DAC6608B236F6F4200F4501E /* Assets.xcassets */; }; 933D7F0722E7015100621A03 /* OpenHABUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933D7F0622E7015000621A03 /* OpenHABUITests.swift */; }; 933D7F0F22E7030600621A03 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933D7F0E22E7030600621A03 /* SnapshotHelper.swift */; }; 934E592528F16EBA00162004 /* OpenHABCore in Frameworks */ = {isa = PBXBuildFile; productRef = 934E592428F16EBA00162004 /* OpenHABCore */; }; + 934E592728F16EBA00162004 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 934E592628F16EBA00162004 /* Kingfisher */; }; 934E592928F16EBA00162004 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = 934E592828F16EBA00162004 /* DeviceKit */; }; 935B484625342B8E00E44CF0 /* URL+Static.swift in Sources */ = {isa = PBXBuildFile; fileRef = 935B484525342B8E00E44CF0 /* URL+Static.swift */; }; 93685A7A2ADE755C0077A9A6 /* openHABTests.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 93685A792ADE755C0077A9A6 /* openHABTests.xctestplan */; }; @@ -42,18 +55,16 @@ 937E4471270B36D000A98C26 /* OpenHABCore in Frameworks */ = {isa = PBXBuildFile; productRef = 937E4470270B36D000A98C26 /* OpenHABCore */; }; 937E4473270B36DD00A98C26 /* OpenHABCore in Frameworks */ = {isa = PBXBuildFile; productRef = 937E4472270B36DD00A98C26 /* OpenHABCore */; }; 937E4485270B379900A98C26 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = 937E4484270B379900A98C26 /* DeviceKit */; }; + 937E4488270B37A600A98C26 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 937E4487270B37A600A98C26 /* Kingfisher */; }; 937E448C270B37CA00A98C26 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 937E448B270B37CA00A98C26 /* Kingfisher */; }; 937E448E270B37D200A98C26 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = 937E448D270B37D200A98C26 /* DeviceKit */; }; + 937E4492270B37FE00A98C26 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 937E4491270B37FE00A98C26 /* Kingfisher */; }; 937E44E2270B393C00A98C26 /* OpenHABCore in Frameworks */ = {isa = PBXBuildFile; productRef = 937E44E1270B393C00A98C26 /* OpenHABCore */; }; 938620C0257E223C00A63200 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 938BF9C824EFCCC000E6B52F /* Localizable.strings */; }; 938BF89624EFBC5400E6B52F /* LocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938BF89524EFBC5400E6B52F /* LocalizationTests.swift */; }; - 938BF9C424EFCB9F00E6B52F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 938BF9C324EFCB9F00E6B52F /* Main.storyboard */; }; - 938BF9C624EFCC0700E6B52F /* UILabel+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938BF9C524EFCC0700E6B52F /* UILabel+Localization.swift */; }; 938BF9D024EFCCC000E6B52F /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 938BF9C824EFCCC000E6B52F /* Localizable.strings */; }; 938BF9D124EFCCC000E6B52F /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 938BF9CA24EFCCC000E6B52F /* InfoPlist.strings */; }; - 938BF9D324EFD0B700E6B52F /* UIViewController+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938BF9D224EFD0B700E6B52F /* UIViewController+Localization.swift */; }; 938BF9D524EFD5B100E6B52F /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 938BF9C824EFCCC000E6B52F /* Localizable.strings */; }; - 938EDCE122C4FEB800661CA1 /* ScaleAspectFitImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938EDCE022C4FEB800661CA1 /* ScaleAspectFitImageView.swift */; }; 9397EDEC2587837000F266E1 /* openHABWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = DA0775152346705D0086C685 /* openHABWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 93AEE42427D9D76B008EB207 /* GetItemStateIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6471112561FDA7007B03FC /* GetItemStateIntentHandler.swift */; }; 93AEE42527D9D76E008EB207 /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6470D52561F935007B03FC /* IntentHandler.swift */; }; @@ -66,9 +77,11 @@ 93F8063527AE6C620035A6B0 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8063427AE6C620035A6B0 /* FirebaseCrashlytics */; }; 93F8064727AE7A050035A6B0 /* SwiftMessages in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064627AE7A050035A6B0 /* SwiftMessages */; }; 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064927AE7A2E0035A6B0 /* FlexColorPicker */; }; - 93F8065027AE7A830035A6B0 /* SideMenu in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064F27AE7A830035A6B0 /* SideMenu */; }; A3F4C3A51A49A5940019A09F /* MainLaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = A3F4C3A41A49A5940019A09F /* MainLaunchScreen.xib */; }; - B7D5ECE121499E55001B0EC6 /* MapViewTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D5ECE021499E55001B0EC6 /* MapViewTableViewCell.swift */; }; + A75A53645D1542CBAC658099 /* TabCustomizationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3698C230427F48A782B1980B /* TabCustomizationSection.swift */; }; + BE875E6CE1B6E05E492F29CE /* OpenHABTabRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52411B814C63C9F1CC8CED90 /* OpenHABTabRootView.swift */; }; + C4377202F7D642B5A8349008 /* SelectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86D8BF295C448039B2B85EB /* SelectionRow.swift */; }; + D8C89708AE75DEFCD78566EC /* SitemapsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13181EE034B26E3A1FF7F4A8 /* SitemapsTab.swift */; }; DA0749DE23E0B5950057FA83 /* ColorPickerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0749DD23E0B5950057FA83 /* ColorPickerRow.swift */; }; DA0749E023E0BF510057FA83 /* ColorSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0749DF23E0BF510057FA83 /* ColorSelection.swift */; }; DA07751B2346705F0086C685 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA07751A2346705F0086C685 /* Assets.xcassets */; }; @@ -81,19 +94,33 @@ DA0F37D023D4ACC7007EAB48 /* SliderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0F37CF23D4ACC7007EAB48 /* SliderRow.swift */; }; DA10161B2DC7BAE500552D14 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = DA10161A2DC7BAE500552D14 /* SFSafeSymbols */; }; DA15BFBD23C6726400BD8ADA /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA15BFBC23C6726400BD8ADA /* AppSettings.swift */; }; - DA162BEC2CD3B53E0040DAE5 /* LogsViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA162BEB2CD3B53E0040DAE5 /* LogsViewer.swift */; }; DA19E25B22FD801D002F8F2F /* OpenHABGeneralTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA19E25A22FD801D002F8F2F /* OpenHABGeneralTests.swift */; }; DA21EAE22339621C001AB415 /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA21EAE12339621C001AB415 /* Throttler.swift */; }; + DA2741002EA62F1F002FE576 /* SitemapPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2740FF2EA62F1F002FE576 /* SitemapPageView.swift */; }; + DA2741022EA62FA3002FE576 /* SegmentSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2741012EA62FA3002FE576 /* SegmentSelectionView.swift */; }; DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA28C361225241DE00AB409C /* WebKit.framework */; settings = {ATTRIBUTES = (Required, ); }; }; DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */; }; - DA2AEB702D92CF3E00897D80 /* UITableViewCellExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */; }; DA2AEBA02D92FB6500897D80 /* NoIconDisplayableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */; }; DA2C4FD52B4F573300D1C533 /* SDWebImageSVGCoder in Frameworks */ = {isa = PBXBuildFile; productRef = DA2C4FD42B4F573300D1C533 /* SDWebImageSVGCoder */; }; + DA2D2F8A2F3943A800EC605A /* WidgetRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2D2F892F3943A800EC605A /* WidgetRowViewModel.swift */; }; DA2E0AA423DC96E9009B0A99 /* ImageWithAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0AA323DC96E9009B0A99 /* ImageWithAction.swift */; }; DA2E0B0E23DCC153009B0A99 /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0B0D23DCC152009B0A99 /* MapView.swift */; }; DA2E0B1023DCC439009B0A99 /* MapViewRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */; }; DA32D1B42C8C98C40018D974 /* IconWithAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32D1B32C8C98C40018D974 /* IconWithAction.swift */; }; DA3563D92E5096BE00BC0138 /* ScreenSaverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3563D82E5096BE00BC0138 /* ScreenSaverView.swift */; }; + DA35E2B02E1EDB86003987BB /* SetpointRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2AF2E1EDB86003987BB /* SetpointRowView.swift */; }; + DA35E2BD2E1EEA9D003987BB /* MapRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B52E1EEA9D003987BB /* MapRowView.swift */; }; + DA35E2BE2E1EEA9D003987BB /* DatePickerInputRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B32E1EEA9D003987BB /* DatePickerInputRowView.swift */; }; + DA35E2BF2E1EEA9D003987BB /* WebRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BC2E1EEA9D003987BB /* WebRowView.swift */; }; + DA35E2C02E1EEA9D003987BB /* ColorPickerRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B22E1EEA9D003987BB /* ColorPickerRowView.swift */; }; + DA35E2C22E1EEA9D003987BB /* TextInputRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BA2E1EEA9D003987BB /* TextInputRowView.swift */; }; + DA35E2C32E1EEA9D003987BB /* ImageRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B42E1EEA9D003987BB /* ImageRowView.swift */; }; + DA35E2C42E1EEA9D003987BB /* SelectionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B82E1EEA9D003987BB /* SelectionRowView.swift */; }; + DA35E2C52E1EEA9D003987BB /* RollershutterRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B62E1EEA9D003987BB /* RollershutterRowView.swift */; }; + DA35E2C62E1EEA9D003987BB /* SegmentedRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B72E1EEA9D003987BB /* SegmentedRowView.swift */; }; + DA35E2C72E1EEA9D003987BB /* VideoRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BB2E1EEA9D003987BB /* VideoRowView.swift */; }; + DA35E2CB2E1F93AD003987BB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2CA2E1F93AD003987BB /* ImageView.swift */; }; + DA35E2CD2E1F96CA003987BB /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2CC2E1F96CA003987BB /* IconView.swift */; }; DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800132D836892009CF127 /* ConnectionSettingsView.swift */; }; DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800152D836EF0009CF127 /* MainUISettingsView.swift */; }; DA4800182D837221009CF127 /* AboutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800172D837221009CF127 /* AboutSettingsView.swift */; }; @@ -102,68 +129,63 @@ DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */; }; DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800202D839D39009CF127 /* AnimatedSecureTextField.swift */; }; DA4D4DB5233F9ACB00B37E37 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = DA4D4DB4233F9ACB00B37E37 /* README.md */; }; - DA50C7BD2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */; }; - DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */; }; DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */; }; + DA64ACA62DBEAD5600294F60 /* SitemapPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */; }; + DA64ACA82DBEAD8300294F60 /* SitemapPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */; }; + DA64ACAA2DBEAD9000294F60 /* SitemapNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA92DBEAD9000294F60 /* SitemapNavigationView.swift */; }; DA65871F236F83CE007E2E7F /* UserDefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65871E236F83CD007E2E7F /* UserDefaultsExtension.swift */; }; - DA6B2EEF2C861BC900DF77CF /* DrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */; }; DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */; }; DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EF42C89F8F200DF77CF /* ColorPickerView.swift */; }; DA6B2EF72C8B92E800DF77CF /* SelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */; }; - DA7125372EC892BB0067D7B2 /* LoggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7125362EC892BB0067D7B2 /* LoggerView.swift */; }; - DA7224D223828D3400712D20 /* PreviewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7224D123828D3300712D20 /* PreviewConstants.swift */; }; DA72E1B8236DEA0900B8EF3A /* AppMessageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA72E1B5236DEA0900B8EF3A /* AppMessageService.swift */; }; DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA77E19A2D886D9B007CFF0F /* SingleConnectionSettingsView.swift */; }; DA7ACD5F2DC3DB130055CFC7 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = DA7ACD5E2DC3DB130055CFC7 /* SFSafeSymbols */; settings = {ATTRIBUTES = (Required, ); }; }; - DA7E1E4B2233986E002AEFD8 /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7E1E47222EB00B002AEFD8 /* PlayerView.swift */; }; DA7F002D2EB376CF00DE943A /* ServerCertificatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7F002C2EB376CF00DE943A /* ServerCertificatesView.swift */; }; DA817E7A234BF39B00C91824 /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = DA817E79234BF39B00C91824 /* CHANGELOG.md */; }; DA88F8C622EC377200B408E5 /* ReleaseNotes.md in Resources */ = {isa = PBXBuildFile; fileRef = DA88F8C522EC377100B408E5 /* ReleaseNotes.md */; }; + DA8B14B62F3A0DFF007753FD /* WidgetRowFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B52F3A0DFF007753FD /* WidgetRowFactory.swift */; }; + DA8B14B82F3A120E007753FD /* PreviewWidgetFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B72F3A11F0007753FD /* PreviewWidgetFactory.swift */; }; + DA8B14BA2F3A373A007753FD /* PreviewNavigationContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B92F3A373A007753FD /* PreviewNavigationContainer.swift */; }; + DA8B14BC2F3A3CB5007753FD /* WatchTypography.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14BB2F3A3CB5007753FD /* WatchTypography.swift */; }; DA94AEB42EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */; }; - DA95F3332E0F2B1700FE4474 /* OpenHABRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */; }; - DA95F3352E0F2C1600FE4474 /* OpenHABSitemapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */; }; DA96415A2F292EE200CEC181 /* BonjourDiscoveryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9641592F292EE200CEC181 /* BonjourDiscoveryViewModelTests.swift */; }; DA96415C2F292F0600CEC181 /* OpenHABEndPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA96415B2F292F0600CEC181 /* OpenHABEndPoint.swift */; }; DA9721C324E29A8F0092CCFD /* UserDefaultsBacked.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9721C224E29A8F0092CCFD /* UserDefaultsBacked.swift */; }; DA9A7EFD2D668D5900824156 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = DA9A7EFC2D668D5900824156 /* SFSafeSymbols */; }; DA9A7EFF2D66915900824156 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = DA9A7EFE2D66915900824156 /* SFSafeSymbols */; }; DA9F81872C85020F00B47B72 /* RTFTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9F81862C85020F00B47B72 /* RTFTextView.swift */; }; - DAA42BA821DC97E000244B2A /* NotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA42BA721DC97DF00244B2A /* NotificationTableViewCell.swift */; }; - DAA42BAA21DC983B00244B2A /* VideoUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA42BA921DC983B00244B2A /* VideoUITableViewCell.swift */; }; - DAA42BAC21DC984A00244B2A /* WebUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA42BAB21DC984A00244B2A /* WebUITableViewCell.swift */; }; DAA599B82EAC0FE7003A8726 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = DAA599B72EAC0FE7003A8726 /* AppIcon.icon */; }; DAA599B92EAC0FE7003A8726 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = DAA599B72EAC0FE7003A8726 /* AppIcon.icon */; }; - DAAAB2812EA3843100F1B05D /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DAAAB2802EA3843100F1B05D /* Kingfisher */; }; - DAAAB2832EA3874500F1B05D /* SegmentSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAAAB2822EA3874400F1B05D /* SegmentSelectionView.swift */; }; - DAAC30872CBBF0420041927F /* SitemapPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0775262346705F0086C685 /* SitemapPageView.swift */; }; DABB5E332D98972F009A4B8A /* SDWebImageSVGCoder in Frameworks */ = {isa = PBXBuildFile; productRef = DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */; }; DABED17B2E451694000B92EF /* BonjourDiscoverySheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABED17A2E451694000B92EF /* BonjourDiscoverySheet.swift */; }; DABED17D2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABED17C2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift */; }; DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC131102DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift */; }; DAC131122DA32F5D00075AE2 /* Intents.intentdefinition in Resources */ = {isa = PBXBuildFile; fileRef = 935D340A257B7DC00020A404 /* Intents.intentdefinition */; }; - DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */; }; DAC6608D236F771600F4501E /* PreferencesSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */; }; DAC9395522B00E7600C5F423 /* XCTestCaseExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */; }; + DAC949FA2E219F0D007E67B7 /* CommonUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAC949F92E219F0D007E67B7 /* CommonUI */; }; + DAC949FC2E219F30007E67B7 /* CommonUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAC949FB2E219F30007E67B7 /* CommonUI */; }; + DAC949FE2E21A2D1007E67B7 /* FrameRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC949FD2E21A2D1007E67B7 /* FrameRowView.swift */; }; DAC9AF4924F966FA006DAE93 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9AF4824F966FA006DAE93 /* LazyView.swift */; }; DACA368E2D7440B9003CD237 /* OpenHABWidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */; }; - DAD085712AE4782D001D36BE /* OpenHABWatchAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD085702AE4782D001D36BE /* OpenHABWatchAppTests.swift */; }; DAD0857B2AE4782F001D36BE /* OpenHABWatchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0857A2AE4782F001D36BE /* OpenHABWatchUITests.swift */; }; DAD0857D2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0857C2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift */; }; DAD0858B2AE56F0E001D36BE /* OpenHABWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0855F2AE47824001D36BE /* OpenHABWatch.swift */; }; - DADC420A2E7AB899004E866F /* SDWebImage in Frameworks */ = {isa = PBXBuildFile; productRef = DADC42092E7AB899004E866F /* SDWebImage */; }; - DAEAA89D21E6B06400267EA3 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89C21E6B06300267EA3 /* ReusableView.swift */; }; - DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89E21E6B16600267EA3 /* UITableView.swift */; }; - DAF0A28B2C56E3A300A14A6A /* RollershutterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF0A28A2C56E3A300A14A6A /* RollershutterCell.swift */; }; - DAF0A28D2C56EF8900A14A6A /* SetpointCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF0A28C2C56EF8900A14A6A /* SetpointCell.swift */; }; - DAF0A28F2C56F1EE00A14A6A /* ColorPickerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF0A28E2C56F1EE00A14A6A /* ColorPickerCell.swift */; }; + DADC420A2E7AB899004E866F /* (null) in Frameworks */ = {isa = PBXBuildFile; }; + DAE2800A2E35F5590028EE24 /* IconURLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE280092E35F5590028EE24 /* IconURLView.swift */; }; + DAE7B4A72E26927C00B9FE99 /* ButtonGridRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE7B4A62E26927C00B9FE99 /* ButtonGridRowView.swift */; }; + DAEA21D82DBF472D00D54342 /* RowViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */; }; + DAEA21DA2DBF477E00D54342 /* SwitchRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21D92DBF477E00D54342 /* SwitchRowView.swift */; }; + DAEA21DC2DBF47DA00D54342 /* SliderRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DB2DBF47DA00D54342 /* SliderRowView.swift */; }; + DAEA21DE2DBF481300D54342 /* TextRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DD2DBF481300D54342 /* TextRowView.swift */; }; + DAEA21E02DBF483E00D54342 /* GenericRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DF2DBF483E00D54342 /* GenericRowView.swift */; }; + DAEE35072E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEE35062E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift */; }; DAF231D227BB6EEA00AB916C /* OpenHABSVGTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF231D127BB6EEA00AB916C /* OpenHABSVGTests.swift */; }; DAF231D827BB702500AB916C /* valid_xmlns.svg in Resources */ = {isa = PBXBuildFile; fileRef = DAF231D527BB702400AB916C /* valid_xmlns.svg */; }; DAF231D927BB702500AB916C /* invalid_xmlns.svg in Resources */ = {isa = PBXBuildFile; fileRef = DAF231D627BB702500AB916C /* invalid_xmlns.svg */; }; DAF231DB27BB828000AB916C /* pantryUseTagPoints2NonExistentElement.svg in Resources */ = {isa = PBXBuildFile; fileRef = DAF231DA27BB828000AB916C /* pantryUseTagPoints2NonExistentElement.svg */; }; DAF231E327BBD1A000AB916C /* embeddedpng_valid.svg in Resources */ = {isa = PBXBuildFile; fileRef = DAF231E227BBD1A000AB916C /* embeddedpng_valid.svg */; }; - DAF4578223D630C70018B495 /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578123D630C70018B495 /* IconView.swift */; }; - DAF4578523D7807A0018B495 /* Color+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934B610B2348D2F9009112D5 /* Color+Extension.swift */; }; - DAF4578723D798A50018B495 /* TextLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578623D798A50018B495 /* TextLabelView.swift */; }; + DAF4578223D630C70018B495 /* WatchIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578123D630C70018B495 /* WatchIconView.swift */; }; DAF4578923D79AA50018B495 /* DetailTextLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */; }; DAF457A023DA3E1C0018B495 /* SegmentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4579F23DA3E1C0018B495 /* SegmentRow.swift */; }; DAF457A223DB6C640018B495 /* RollershutterRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF457A123DB6C640018B495 /* RollershutterRow.swift */; }; @@ -172,14 +194,8 @@ DAF4581623DC48400018B495 /* GenericRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4581523DC483F0018B495 /* GenericRow.swift */; }; DAF4581823DC4A050018B495 /* ImageRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4581723DC4A050018B495 /* ImageRow.swift */; }; DAF4581E23DC60020018B495 /* ImageRawRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4581D23DC60020018B495 /* ImageRawRow.swift */; }; - DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */; }; - DF05FF231896BD2D00FF2F9B /* SelectionUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF05FF221896BD2D00FF2F9B /* SelectionUITableViewCell.swift */; }; - DF06F1FC18FEC2020011E7B9 /* ColorPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF06F1FB18FEC2020011E7B9 /* ColorPickerViewController.swift */; }; - DF4B84131886DAC400F34902 /* FrameUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4B84121886DAC400F34902 /* FrameUITableViewCell.swift */; }; - DF4B84161886EACA00F34902 /* GenericUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4B84151886EACA00F34902 /* GenericUITableViewCell.swift */; }; - DFA13CB418872EBD006355C3 /* SwitchUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA13CB318872EBD006355C3 /* SwitchUITableViewCell.swift */; }; - DFA16EBB18883DE500EDB0BB /* SliderUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA16EBA18883DE500EDB0BB /* SliderUITableViewCell.swift */; }; - DFA16EC118898A8400EDB0BB /* SegmentedUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA16EC018898A8400EDB0BB /* SegmentedUITableViewCell.swift */; }; + DAF5AA682E4F3A39004F18D7 /* EmbeddingRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF5AA672E4F3A38004F18D7 /* EmbeddingRowView.swift */; }; + DAFF80982E4F47830084513E /* SDWebImage in Frameworks */ = {isa = PBXBuildFile; productRef = DAFF80972E4F47830084513E /* SDWebImage */; }; DFB2622B18830A3600D3244D /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DFB2622A18830A3600D3244D /* Foundation.framework */; }; DFB2622D18830A3600D3244D /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DFB2622C18830A3600D3244D /* CoreGraphics.framework */; }; DFB2622F18830A3600D3244D /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DFB2622E18830A3600D3244D /* UIKit.framework */; }; @@ -188,7 +204,6 @@ DFDA3CEA193CADB200888039 /* ping.wav in Resources */ = {isa = PBXBuildFile; fileRef = DFDA3CE9193CADB200888039 /* ping.wav */; }; DFDF45311932042B00A6E581 /* legal.rtf in Resources */ = {isa = PBXBuildFile; fileRef = DFDF45301932042B00A6E581 /* legal.rtf */; }; DFE10414197415F900D94943 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DFE10413197415F900D94943 /* Security.framework */; }; - DFFD8FD118EDBD4F003B502A /* UICircleButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFFD8FD018EDBD4F003B502A /* UICircleButton.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -227,6 +242,13 @@ remoteGlobalIDString = DFB2622618830A3600D3244D; remoteInfo = openHAB; }; + DA8B15552F3BB74B007753FD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DFB2621F18830A3600D3244D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DA0775142346705D0086C685; + remoteInfo = openHABWatch; + }; DAA0708C2B504B280060BB0E /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DFB2621F18830A3600D3244D /* Project object */; @@ -290,14 +312,25 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + AA0001012F5A000000000001 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = ""; }; + AA0001032F5A000000000002 /* OpenHABApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABApp.swift; sourceTree = ""; }; 1224F78D228A89FC00750965 /* WatchMessageService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WatchMessageService.swift; sourceTree = ""; }; + 13181EE034B26E3A1FF7F4A8 /* SitemapsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapsTab.swift; sourceTree = ""; }; 2F08AFC62E5FADC500E70611 /* NotificationCenterDelegateImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenterDelegateImpl.swift; sourceTree = ""; }; + 2F54B56C2F40AC1A00DA1F1A /* REFACTORING_SUMMARY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = REFACTORING_SUMMARY.md; sourceTree = ""; }; 2F55E7BA2DEE447700EC8350 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 2F55E7BC2DEE44A800EC8350 /* ClientCertificatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificatesView.swift; sourceTree = ""; }; - 2F6412ED2CE494A80039FB28 /* DatePickerUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerUITableViewCell.swift; sourceTree = ""; }; + 2F77DF3B2F43D3D700BE3744 /* OpenHABWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWebView.swift; sourceTree = ""; }; + 2F77DF3D2F43D42E00BE3744 /* WEBVIEW_MIGRATION.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = WEBVIEW_MIGRATION.md; sourceTree = ""; }; + 2F77DF3F2F43D94C00BE3744 /* UIKIT_MIGRATION_ANALYSIS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = UIKIT_MIGRATION_ANALYSIS.md; sourceTree = ""; }; + 2F77DF412F43D96800BE3744 /* CertificateManagementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateManagementService.swift; sourceTree = ""; }; + 2F77DF432F43D97400BE3744 /* IdleTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdleTimerService.swift; sourceTree = ""; }; + 2F77DF452F43D9A400BE3744 /* UIKIT_REMOVAL_GUIDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = UIKIT_REMOVAL_GUIDE.md; sourceTree = ""; }; 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSelectionView.swift; sourceTree = ""; }; - 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextInputUITableViewCell.swift; sourceTree = ""; }; 2FF459352E230C6A00C0B640 /* OpenHABIntentHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABIntentHelper.swift; sourceTree = ""; }; + 3698C230427F48A782B1980B /* TabCustomizationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationSection.swift; sourceTree = ""; }; + 399449C421544C61AD83450C /* TextRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRow.swift; sourceTree = ""; }; + 4229903F00160C965E22DE21 /* SystemTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTab.swift; sourceTree = ""; }; 4D38D951256897490039DA6E /* SetNumberValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNumberValueIntentHandler.swift; sourceTree = ""; }; 4D38D959256897770039DA6E /* SetStringValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetStringValueIntentHandler.swift; sourceTree = ""; }; 4D38D9612568978E0039DA6E /* SetColorValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetColorValueIntentHandler.swift; sourceTree = ""; }; @@ -309,19 +342,18 @@ 4D64720D256315D9007B03FC /* openHABIntents.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = openHABIntents.entitlements; sourceTree = ""; }; 4D647220256331B9007B03FC /* SetSwitchStateIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetSwitchStateIntentHandler.swift; sourceTree = ""; }; 4D64724C256346BD007B03FC /* SetDimmerRollerValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDimmerRollerValueIntentHandler.swift; sourceTree = ""; }; + 5135184D9630899A34EFBA95 /* MainWebTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWebTab.swift; sourceTree = ""; }; + 52411B814C63C9F1CC8CED90 /* OpenHABTabRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABTabRootView.swift; sourceTree = ""; }; 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverSettingsView.swift; sourceTree = ""; }; 652B81052E2193DA00648510 /* ScreenSaverConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverConfiguration.swift; sourceTree = ""; }; 652B81062E2193DA00648510 /* ScreenSaverManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverManager.swift; sourceTree = ""; }; - 653B54C1285E714900298ECD /* OpenHABViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABViewController.swift; sourceTree = ""; }; 653C09D41EAD691A00BA4C4A /* openHAB.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = openHAB.entitlements; sourceTree = ""; }; - 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWebViewController.swift; sourceTree = ""; }; 6557AF8E2C0241C10094D0C8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 656916D81FCB82BC00667B2A /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "openHAB/GoogleService-Info.plist"; sourceTree = SOURCE_ROOT; }; 6571444E2C1E438700C8A1F3 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 657144502C1E438700C8A1F3 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 657144522C1E438700C8A1F3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 657144972C30A3E300C8A1F3 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; - 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABNavigationController.swift; sourceTree = ""; }; 65F055432E3D4E41004E98FE /* ItemSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSelectionView.swift; sourceTree = ""; }; 905A534AB32C4104AC55A75C /* VideoStreamManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoStreamManager.swift; sourceTree = ""; }; 931384B324F259BC00A73AB5 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; @@ -338,7 +370,6 @@ 933D7F0622E7015000621A03 /* OpenHABUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABUITests.swift; sourceTree = ""; }; 933D7F0822E7015100621A03 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 933D7F0E22E7030600621A03 /* SnapshotHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SnapshotHelper.swift; path = fastlane/SnapshotHelper.swift; sourceTree = SOURCE_ROOT; }; - 934B610B2348D2F9009112D5 /* Color+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Color+Extension.swift"; sourceTree = ""; }; 935B484525342B8E00E44CF0 /* URL+Static.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Static.swift"; sourceTree = ""; }; 935D3412257B7E2F0020A404 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = Resources/nl.lproj/Intents.strings; sourceTree = ""; }; 935D3419257B7E820020A404 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Resources/Base.lproj/Intents.intentdefinition; sourceTree = ""; }; @@ -351,24 +382,21 @@ 935D3451257B7EA60020A404 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = Resources/es.lproj/Intents.strings; sourceTree = ""; }; 93685A792ADE755C0077A9A6 /* openHABTests.xctestplan */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = openHABTests.xctestplan; sourceTree = ""; }; 938BF89524EFBC5400E6B52F /* LocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationTests.swift; sourceTree = ""; }; - 938BF9C324EFCB9F00E6B52F /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; - 938BF9C524EFCC0700E6B52F /* UILabel+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Localization.swift"; sourceTree = ""; }; 938BF9C924EFCCC000E6B52F /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 938BF9CB24EFCCC000E6B52F /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; 938BF9CC24EFCCC000E6B52F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 938BF9CD24EFCCC000E6B52F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; 938BF9CE24EFCCC000E6B52F /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 938BF9CF24EFCCC000E6B52F /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; - 938BF9D224EFD0B700E6B52F /* UIViewController+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Localization.swift"; sourceTree = ""; }; - 938EDCE022C4FEB800661CA1 /* ScaleAspectFitImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScaleAspectFitImageView.swift; sourceTree = ""; }; + A3C3E3BF4C930ADA95E6E997 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; A3F4C3A41A49A5940019A09F /* MainLaunchScreen.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; name = MainLaunchScreen.xib; path = ../MainLaunchScreen.xib; sourceTree = ""; }; - B7D5ECE021499E55001B0EC6 /* MapViewTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewTableViewCell.swift; sourceTree = ""; }; + D64BEA2793F07C36BB98D9B8 /* AppServicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServicesViewModel.swift; sourceTree = ""; }; + D86D8BF295C448039B2B85EB /* SelectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionRow.swift; sourceTree = ""; }; DA0749DD23E0B5950057FA83 /* ColorPickerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerRow.swift; sourceTree = ""; }; DA0749DF23E0BF510057FA83 /* ColorSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorSelection.swift; sourceTree = ""; }; DA0775152346705D0086C685 /* openHABWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = openHABWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; DA07751A2346705F0086C685 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; DA07751C2346705F0086C685 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - DA0775262346705F0086C685 /* SitemapPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageView.swift; sourceTree = ""; }; DA07752A2346705F0086C685 /* OpenHABWatchAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWatchAppDelegate.swift; sourceTree = ""; }; DA07752C2346705F0086C685 /* NotificationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationController.swift; sourceTree = ""; }; DA07752E2346705F0086C685 /* NotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView.swift; sourceTree = ""; }; @@ -380,7 +408,6 @@ DA0DA9E12E0C9B74000C5D0A /* BuildTools */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = BuildTools; sourceTree = ""; }; DA0F37CF23D4ACC7007EAB48 /* SliderRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderRow.swift; sourceTree = ""; }; DA15BFBC23C6726400BD8ADA /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; - DA162BEB2CD3B53E0040DAE5 /* LogsViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsViewer.swift; sourceTree = ""; }; DA19E25A22FD801D002F8F2F /* OpenHABGeneralTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABGeneralTests.swift; sourceTree = ""; }; DA1C2E4B230DC28F00FACFB0 /* Appfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Appfile; sourceTree = ""; }; DA1C2E4C230DC28F00FACFB0 /* SnapshotHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotHelper.swift; sourceTree = ""; }; @@ -416,10 +443,12 @@ DA1C2E6D230DC28F00FACFB0 /* primary_category.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = primary_category.txt; sourceTree = ""; }; DA1C2E6E230DC28F00FACFB0 /* Snapfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Snapfile; sourceTree = ""; }; DA21EAE12339621C001AB415 /* Throttler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Throttler.swift; sourceTree = ""; }; + DA2740FF2EA62F1F002FE576 /* SitemapPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageView.swift; sourceTree = ""; }; + DA2741012EA62FA3002FE576 /* SegmentSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentSelectionView.swift; sourceTree = ""; }; DA28C361225241DE00AB409C /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageLoader.swift; sourceTree = ""; }; - DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewCellExtension.swift; sourceTree = ""; }; DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoIconDisplayableCell.swift; sourceTree = ""; }; + DA2D2F892F3943A800EC605A /* WidgetRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetRowViewModel.swift; sourceTree = ""; }; DA2DC22F21F2736C00830730 /* openHABTestsSwift.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = openHABTestsSwift.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DA2DC23321F2736C00830730 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DA2E0AA323DC96E9009B0A99 /* ImageWithAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageWithAction.swift; sourceTree = ""; }; @@ -427,6 +456,19 @@ DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewRow.swift; sourceTree = ""; }; DA32D1B32C8C98C40018D974 /* IconWithAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconWithAction.swift; sourceTree = ""; }; DA3563D82E5096BE00BC0138 /* ScreenSaverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverView.swift; sourceTree = ""; }; + DA35E2AF2E1EDB86003987BB /* SetpointRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetpointRowView.swift; sourceTree = ""; }; + DA35E2B22E1EEA9D003987BB /* ColorPickerRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerRowView.swift; sourceTree = ""; }; + DA35E2B32E1EEA9D003987BB /* DatePickerInputRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerInputRowView.swift; sourceTree = ""; }; + DA35E2B42E1EEA9D003987BB /* ImageRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRowView.swift; sourceTree = ""; }; + DA35E2B52E1EEA9D003987BB /* MapRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapRowView.swift; sourceTree = ""; }; + DA35E2B62E1EEA9D003987BB /* RollershutterRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollershutterRowView.swift; sourceTree = ""; }; + DA35E2B72E1EEA9D003987BB /* SegmentedRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedRowView.swift; sourceTree = ""; }; + DA35E2B82E1EEA9D003987BB /* SelectionRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionRowView.swift; sourceTree = ""; }; + DA35E2BA2E1EEA9D003987BB /* TextInputRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextInputRowView.swift; sourceTree = ""; }; + DA35E2BB2E1EEA9D003987BB /* VideoRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRowView.swift; sourceTree = ""; }; + DA35E2BC2E1EEA9D003987BB /* WebRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRowView.swift; sourceTree = ""; }; + DA35E2CA2E1F93AD003987BB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; + DA35E2CC2E1F96CA003987BB /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = ""; }; DA4800132D836892009CF127 /* ConnectionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSettingsView.swift; sourceTree = ""; }; DA4800152D836EF0009CF127 /* MainUISettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainUISettingsView.swift; sourceTree = ""; }; DA4800172D837221009CF127 /* AboutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutSettingsView.swift; sourceTree = ""; }; @@ -436,43 +478,37 @@ DA4800202D839D39009CF127 /* AnimatedSecureTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedSecureTextField.swift; sourceTree = ""; }; DA4D4DB4233F9ACB00B37E37 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; DA4D4E0E2340A00200B37E37 /* Changes.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Changes.md; sourceTree = ""; }; - DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderWithSwitchSupportRow.swift; sourceTree = ""; }; - DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderWithSwitchSupportUITableViewCell.swift; sourceTree = ""; }; DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificatesViewModel.swift; sourceTree = ""; }; + DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageViewModel.swift; sourceTree = ""; }; + DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageView.swift; sourceTree = ""; }; + DA64ACA92DBEAD9000294F60 /* SitemapNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapNavigationView.swift; sourceTree = ""; }; DA65871E236F83CD007E2E7F /* UserDefaultsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsExtension.swift; sourceTree = ""; }; - DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawerView.swift; sourceTree = ""; }; DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; DA6B2EF42C89F8F200DF77CF /* ColorPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerView.swift; sourceTree = ""; }; DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionView.swift; sourceTree = ""; }; - DA7125362EC892BB0067D7B2 /* LoggerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerView.swift; sourceTree = ""; }; - DA7224D123828D3300712D20 /* PreviewConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewConstants.swift; sourceTree = ""; }; DA72E1B5236DEA0900B8EF3A /* AppMessageService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppMessageService.swift; sourceTree = ""; }; DA77E19A2D886D9B007CFF0F /* SingleConnectionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleConnectionSettingsView.swift; sourceTree = ""; }; - DA7E1E47222EB00B002AEFD8 /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; DA7F002C2EB376CF00DE943A /* ServerCertificatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerCertificatesView.swift; sourceTree = ""; }; DA817E79234BF39B00C91824 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; DA88F8C522EC377100B408E5 /* ReleaseNotes.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = ReleaseNotes.md; sourceTree = ""; }; + DA8B14B52F3A0DFF007753FD /* WidgetRowFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetRowFactory.swift; sourceTree = ""; }; + DA8B14B72F3A11F0007753FD /* PreviewWidgetFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewWidgetFactory.swift; sourceTree = ""; }; + DA8B14B92F3A373A007753FD /* PreviewNavigationContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewNavigationContainer.swift; sourceTree = ""; }; + DA8B14BB2F3A3CB5007753FD /* WatchTypography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchTypography.swift; sourceTree = ""; }; + DA8B15512F3BB74B007753FD /* openHABWatchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = openHABWatchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleMJPEGPlayer.swift; sourceTree = ""; }; - DA94AF432EC8DE41003BB3C8 /* VideoStreamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoStreamManager.swift; sourceTree = ""; }; - DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABRootViewController.swift; sourceTree = ""; }; - DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABSitemapViewController.swift; sourceTree = ""; }; DA9641592F292EE200CEC181 /* BonjourDiscoveryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonjourDiscoveryViewModelTests.swift; sourceTree = ""; }; DA96415B2F292F0600CEC181 /* OpenHABEndPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABEndPoint.swift; sourceTree = ""; }; DA9721C224E29A8F0092CCFD /* UserDefaultsBacked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsBacked.swift; sourceTree = ""; }; DA9F81862C85020F00B47B72 /* RTFTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTFTextView.swift; sourceTree = ""; }; - DAA42BA721DC97DF00244B2A /* NotificationTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCell.swift; sourceTree = ""; }; - DAA42BA921DC983B00244B2A /* VideoUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoUITableViewCell.swift; sourceTree = ""; }; - DAA42BAB21DC984A00244B2A /* WebUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebUITableViewCell.swift; sourceTree = ""; }; DAA599B72EAC0FE7003A8726 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; - DAAAB2822EA3874400F1B05D /* SegmentSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentSelectionView.swift; sourceTree = ""; }; DABED17A2E451694000B92EF /* BonjourDiscoverySheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonjourDiscoverySheet.swift; sourceTree = ""; }; DABED17C2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonjourDiscoveryViewModel.swift; sourceTree = ""; }; DAC131102DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetSwitchStateIntentHandlerTests.swift; sourceTree = ""; }; - DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerViewController.swift; sourceTree = ""; }; DAC6608B236F6F4200F4501E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSwiftUIView.swift; sourceTree = ""; }; - DAC9394322AD4A7A00C5F423 /* OpenHABWatchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWatchTests.swift; sourceTree = ""; }; DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestCaseExtension.swift; sourceTree = ""; }; + DAC949FD2E21A2D1007E67B7 /* FrameRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameRowView.swift; sourceTree = ""; }; DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWidgetExtension.swift; sourceTree = ""; }; DAC9AF4824F966FA006DAE93 /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = ""; }; DACB636127D3FC6500041931 /* error.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = error.png; sourceTree = ""; }; @@ -480,25 +516,26 @@ DAD0855F2AE47824001D36BE /* OpenHABWatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWatch.swift; sourceTree = ""; }; DAD085662AE4782A001D36BE /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DAD0856C2AE4782B001D36BE /* openHABWatchSwiftUI Watch AppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "openHABWatchSwiftUI Watch AppTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - DAD085702AE4782D001D36BE /* OpenHABWatchAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWatchAppTests.swift; sourceTree = ""; }; DAD085762AE4782E001D36BE /* openHABWatchSwiftUI Watch AppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "openHABWatchSwiftUI Watch AppUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; DAD0857A2AE4782F001D36BE /* OpenHABWatchUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWatchUITests.swift; sourceTree = ""; }; DAD0857C2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWatchLaunchTests.swift; sourceTree = ""; }; DAD488B3287DDDFE00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = Resources/nb.lproj/Intents.strings; sourceTree = ""; }; DAD488B4287DDDFF00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; DAD488B5287DDDFF00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; - DAEAA89C21E6B06300267EA3 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; - DAEAA89E21E6B16600267EA3 /* UITableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableView.swift; sourceTree = ""; }; - DAF0A28A2C56E3A300A14A6A /* RollershutterCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollershutterCell.swift; sourceTree = ""; }; - DAF0A28C2C56EF8900A14A6A /* SetpointCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetpointCell.swift; sourceTree = ""; }; - DAF0A28E2C56F1EE00A14A6A /* ColorPickerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerCell.swift; sourceTree = ""; }; + DAE280092E35F5590028EE24 /* IconURLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconURLView.swift; sourceTree = ""; }; + DAE7B4A62E26927C00B9FE99 /* ButtonGridRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonGridRowView.swift; sourceTree = ""; }; + DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowViewFactory.swift; sourceTree = ""; }; + DAEA21D92DBF477E00D54342 /* SwitchRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchRowView.swift; sourceTree = ""; }; + DAEA21DB2DBF47DA00D54342 /* SliderRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderRowView.swift; sourceTree = ""; }; + DAEA21DD2DBF481300D54342 /* TextRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRowView.swift; sourceTree = ""; }; + DAEA21DF2DBF483E00D54342 /* GenericRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericRowView.swift; sourceTree = ""; }; + DAEE35062E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorTemperaturePickerRowView.swift; sourceTree = ""; }; DAF231D127BB6EEA00AB916C /* OpenHABSVGTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABSVGTests.swift; sourceTree = ""; }; DAF231D527BB702400AB916C /* valid_xmlns.svg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = valid_xmlns.svg; sourceTree = ""; }; DAF231D627BB702500AB916C /* invalid_xmlns.svg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = invalid_xmlns.svg; sourceTree = ""; }; DAF231DA27BB828000AB916C /* pantryUseTagPoints2NonExistentElement.svg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = pantryUseTagPoints2NonExistentElement.svg; sourceTree = ""; }; DAF231E227BBD1A000AB916C /* embeddedpng_valid.svg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = embeddedpng_valid.svg; sourceTree = ""; }; - DAF4578123D630C70018B495 /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = ""; }; - DAF4578623D798A50018B495 /* TextLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLabelView.swift; sourceTree = ""; }; + DAF4578123D630C70018B495 /* WatchIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchIconView.swift; sourceTree = ""; }; DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailTextLabelView.swift; sourceTree = ""; }; DAF4579F23DA3E1C0018B495 /* SegmentRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SegmentRow.swift; sourceTree = ""; }; DAF457A123DB6C640018B495 /* RollershutterRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollershutterRow.swift; sourceTree = ""; }; @@ -507,16 +544,9 @@ DAF4581523DC483F0018B495 /* GenericRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericRow.swift; sourceTree = ""; }; DAF4581723DC4A050018B495 /* ImageRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRow.swift; sourceTree = ""; }; DAF4581D23DC60020018B495 /* ImageRawRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRawRow.swift; sourceTree = ""; }; - DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewImageUITableViewCell.swift; sourceTree = ""; }; + DAF5AA672E4F3A38004F18D7 /* EmbeddingRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddingRowView.swift; sourceTree = ""; }; DAF6F4112C67E83B0083883E /* openapiCorrected.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = openapiCorrected.json; sourceTree = ""; }; DAFD2FE62E0D96700059A1EB /* OsLogRewriter */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = OsLogRewriter; sourceTree = ""; }; - DF05FF221896BD2D00FF2F9B /* SelectionUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectionUITableViewCell.swift; sourceTree = ""; }; - DF06F1FB18FEC2020011E7B9 /* ColorPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorPickerViewController.swift; sourceTree = ""; }; - DF4B84121886DAC400F34902 /* FrameUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameUITableViewCell.swift; sourceTree = ""; }; - DF4B84151886EACA00F34902 /* GenericUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GenericUITableViewCell.swift; sourceTree = ""; }; - DFA13CB318872EBD006355C3 /* SwitchUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwitchUITableViewCell.swift; sourceTree = ""; }; - DFA16EBA18883DE500EDB0BB /* SliderUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderUITableViewCell.swift; sourceTree = ""; }; - DFA16EC018898A8400EDB0BB /* SegmentedUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SegmentedUITableViewCell.swift; sourceTree = ""; }; DFB2622718830A3600D3244D /* openHAB.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = openHAB.app; sourceTree = BUILT_PRODUCTS_DIR; }; DFB2622A18830A3600D3244D /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; DFB2622C18830A3600D3244D /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; @@ -528,21 +558,11 @@ DFDA3CE9193CADB200888039 /* ping.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = ping.wav; sourceTree = ""; }; DFDF45301932042B00A6E581 /* legal.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = legal.rtf; sourceTree = ""; }; DFE10413197415F900D94943 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; - DFFD8FD018EDBD4F003B502A /* UICircleButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UICircleButton.swift; sourceTree = ""; }; + EE239F0348D19000408BA8AA /* TilesTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TilesTab.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - DA2AEB752D92D32000897D80 /* Cells */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = Cells; - sourceTree = ""; - }; + DA8B15522F3BB74B007753FD /* openHABWatchTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = openHABWatchTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -550,6 +570,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DAC949FC2E219F30007E67B7 /* CommonUI in Frameworks */, + 934E592728F16EBA00162004 /* Kingfisher in Frameworks */, 937E4473270B36DD00A98C26 /* OpenHABCore in Frameworks */, DA2C4FD52B4F573300D1C533 /* SDWebImageSVGCoder in Frameworks */, 934E592528F16EBA00162004 /* OpenHABCore in Frameworks */, @@ -565,6 +587,7 @@ buildActionMask = 2147483647; files = ( DA7ACD5F2DC3DB130055CFC7 /* SFSafeSymbols in Frameworks */, + 937E4492270B37FE00A98C26 /* Kingfisher in Frameworks */, 937E44E2270B393C00A98C26 /* OpenHABCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -592,6 +615,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DA8B154E2F3BB74B007753FD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DAD085692AE4782A001D36BE /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -614,17 +644,20 @@ DFB2622B18830A3600D3244D /* Foundation.framework in Frameworks */, 937E4485270B379900A98C26 /* DeviceKit in Frameworks */, DABB5E332D98972F009A4B8A /* SDWebImageSVGCoder in Frameworks */, - DAAAB2812EA3843100F1B05D /* Kingfisher in Frameworks */, DFB2622F18830A3600D3244D /* UIKit.framework in Frameworks */, - DADC420A2E7AB899004E866F /* SDWebImage in Frameworks */, + DAFF80982E4F47830084513E /* SDWebImage in Frameworks */, + DADC420A2E7AB899004E866F /* (null) in Frameworks */, 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */, DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */, - 93F8065027AE7A830035A6B0 /* SideMenu in Frameworks */, + DAC949FA2E219F0D007E67B7 /* CommonUI in Frameworks */, DFE10414197415F900D94943 /* Security.framework in Frameworks */, + 2F54B5742F428ACA00DA1F1A /* AsyncAlgorithms in Frameworks */, + 2F54B5772F428B0200DA1F1A /* Algorithms in Frameworks */, 93F8064727AE7A050035A6B0 /* SwiftMessages in Frameworks */, DFB2622D18830A3600D3244D /* CoreGraphics.framework in Frameworks */, 93F8063527AE6C620035A6B0 /* FirebaseCrashlytics in Frameworks */, DA9A7EFF2D66915900824156 /* SFSafeSymbols in Frameworks */, + 937E4488270B37A600A98C26 /* Kingfisher in Frameworks */, 937E4471270B36D000A98C26 /* OpenHABCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -657,6 +690,39 @@ path = openHABWatch/External; sourceTree = SOURCE_ROOT; }; + 2F7D1D592EFEC12A004A786D /* RootView */ = { + isa = PBXGroup; + children = ( + D64BEA2793F07C36BB98D9B8 /* AppServicesViewModel.swift */, + 52411B814C63C9F1CC8CED90 /* OpenHABTabRootView.swift */, + 5135184D9630899A34EFBA95 /* MainWebTab.swift */, + 13181EE034B26E3A1FF7F4A8 /* SitemapsTab.swift */, + EE239F0348D19000408BA8AA /* TilesTab.swift */, + 4229903F00160C965E22DE21 /* SystemTab.swift */, + A3C3E3BF4C930ADA95E6E997 /* SafariView.swift */, + 2F54B56C2F40AC1A00DA1F1A /* REFACTORING_SUMMARY.md */, + ); + path = RootView; + sourceTree = ""; + }; + 2F7D1D5A2EFEC137004A786D /* SitemapView */ = { + isa = PBXGroup; + children = ( + DA9F81862C85020F00B47B72 /* RTFTextView.swift */, + DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */, + DAF5AA672E4F3A38004F18D7 /* EmbeddingRowView.swift */, + DAE280092E35F5590028EE24 /* IconURLView.swift */, + DA35E2CC2E1F96CA003987BB /* IconView.swift */, + DA35E2CA2E1F93AD003987BB /* ImageView.swift */, + DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */, + DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */, + DA64ACA92DBEAD9000294F60 /* SitemapNavigationView.swift */, + 2F77DF3B2F43D3D700BE3744 /* OpenHABWebView.swift */, + 2F77DF3D2F43D42E00BE3744 /* WEBVIEW_MIGRATION.md */, + ); + path = SitemapView; + sourceTree = ""; + }; 4D6470D42561F935007B03FC /* openHABIntents */ = { isa = PBXGroup; children = ( @@ -729,6 +795,8 @@ DA9721C224E29A8F0092CCFD /* UserDefaultsBacked.swift */, DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */, DAC9AF4824F966FA006DAE93 /* LazyView.swift */, + DA8B14B52F3A0DFF007753FD /* WidgetRowFactory.swift */, + DA2D2F892F3943A800EC605A /* WidgetRowViewModel.swift */, ); path = Model; sourceTree = ""; @@ -836,7 +904,6 @@ 938BF89524EFBC5400E6B52F /* LocalizationTests.swift */, DA9641592F292EE200CEC181 /* BonjourDiscoveryViewModelTests.swift */, DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */, - DAC9394322AD4A7A00C5F423 /* OpenHABWatchTests.swift */, DAF231D127BB6EEA00AB916C /* OpenHABSVGTests.swift */, DA2DC23321F2736C00830730 /* Info.plist */, DA96415B2F292F0600CEC181 /* OpenHABEndPoint.swift */, @@ -844,6 +911,22 @@ path = openHABTestsSwift; sourceTree = ""; }; + DA35E2B12E1EEA58003987BB /* SwiftUI */ = { + isa = PBXGroup; + children = ( + 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */, + DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */, + AA0001012F5A000000000001 /* SplashView.swift */, + 2F7D1D592EFEC12A004A786D /* RootView */, + 2F7D1D5A2EFEC137004A786D /* SitemapView */, + DAC949FF2E21A473007E67B7 /* Rows */, + DA48001F2D837CD8009CF127 /* SettingsView */, + 652B81082E2193DA00648510 /* ScreenSaver */, + DF4B84101886DA9900F34902 /* Widgets */, + ); + path = SwiftUI; + sourceTree = ""; + }; DA48001F2D837CD8009CF127 /* SettingsView */ = { isa = PBXGroup; children = ( @@ -852,6 +935,7 @@ 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */, DA4800172D837221009CF127 /* AboutSettingsView.swift */, DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */, + 3698C230427F48A782B1980B /* TabCustomizationSection.swift */, 2F55E7BC2DEE44A800EC8350 /* ClientCertificatesView.swift */, DA4800132D836892009CF127 /* ConnectionSettingsView.swift */, DA77E19A2D886D9B007CFF0F /* SingleConnectionSettingsView.swift */, @@ -868,8 +952,7 @@ DA658720236F841F007E2E7F /* Views */ = { isa = PBXGroup; children = ( - DA0775262346705F0086C685 /* SitemapPageView.swift */, - DA162BEB2CD3B53E0040DAE5 /* LogsViewer.swift */, + DA2740FF2EA62F1F002FE576 /* SitemapPageView.swift */, DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */, DAF457A323DB7A820018B495 /* Rows */, DAF457A723DBA2C40018B495 /* Utils */, @@ -885,21 +968,38 @@ path = openHABIntentsTests; sourceTree = ""; }; - DACE66522C63B2070069E514 /* openapitest */ = { + DAC949FF2E21A473007E67B7 /* Rows */ = { isa = PBXGroup; children = ( - DAF6F4112C67E83B0083883E /* openapiCorrected.json */, + DAE7B4A62E26927C00B9FE99 /* ButtonGridRowView.swift */, + DA35E2B22E1EEA9D003987BB /* ColorPickerRowView.swift */, + DAEE35062E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift */, + DA35E2B32E1EEA9D003987BB /* DatePickerInputRowView.swift */, + DAC949FD2E21A2D1007E67B7 /* FrameRowView.swift */, + DAEA21DF2DBF483E00D54342 /* GenericRowView.swift */, + DA35E2B42E1EEA9D003987BB /* ImageRowView.swift */, + DA35E2B52E1EEA9D003987BB /* MapRowView.swift */, + DA35E2B62E1EEA9D003987BB /* RollershutterRowView.swift */, + DA35E2B72E1EEA9D003987BB /* SegmentedRowView.swift */, + DA35E2B82E1EEA9D003987BB /* SelectionRowView.swift */, + DA35E2AF2E1EDB86003987BB /* SetpointRowView.swift */, + DAEA21DB2DBF47DA00D54342 /* SliderRowView.swift */, + DAEA21D92DBF477E00D54342 /* SwitchRowView.swift */, + DA35E2BA2E1EEA9D003987BB /* TextInputRowView.swift */, + DAEA21DD2DBF481300D54342 /* TextRowView.swift */, + DA35E2BB2E1EEA9D003987BB /* VideoRowView.swift */, + DA35E2BC2E1EEA9D003987BB /* WebRowView.swift */, ); - name = openapitest; - path = openapi/openapitest; + path = Rows; sourceTree = ""; }; - DAD0856F2AE4782D001D36BE /* openHABWatchSwiftUI Watch AppTests */ = { + DACE66522C63B2070069E514 /* openapitest */ = { isa = PBXGroup; children = ( - DAD085702AE4782D001D36BE /* OpenHABWatchAppTests.swift */, + DAF6F4112C67E83B0083883E /* openapiCorrected.json */, ); - path = "openHABWatchSwiftUI Watch AppTests"; + name = openapitest; + path = openapi/openapitest; sourceTree = ""; }; DAD085792AE4782F001D36BE /* openHABWatchSwiftUI Watch AppUITests */ = { @@ -915,6 +1015,8 @@ isa = PBXGroup; children = ( DAD085662AE4782A001D36BE /* Preview Assets.xcassets */, + DA8B14B92F3A373A007753FD /* PreviewNavigationContainer.swift */, + DA8B14B72F3A11F0007753FD /* PreviewWidgetFactory.swift */, ); path = "Preview Content"; sourceTree = ""; @@ -941,15 +1043,16 @@ DAF457A323DB7A820018B495 /* Rows */ = { isa = PBXGroup; children = ( - DAAAB2822EA3874400F1B05D /* SegmentSelectionView.swift */, DA077649234683BC0086C685 /* SwitchRow.swift */, DA0F37CF23D4ACC7007EAB48 /* SliderRow.swift */, - DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */, DAF457A123DB6C640018B495 /* RollershutterRow.swift */, DAF4579F23DA3E1C0018B495 /* SegmentRow.swift */, + D86D8BF295C448039B2B85EB /* SelectionRow.swift */, + DA2741012EA62FA3002FE576 /* SegmentSelectionView.swift */, DAF457A523DB9CE00018B495 /* SetpointRow.swift */, DAF457A823DBA4990018B495 /* FrameRow.swift */, DAF4581523DC483F0018B495 /* GenericRow.swift */, + 399449C421544C61AD83450C /* TextRow.swift */, DAF4581723DC4A050018B495 /* ImageRow.swift */, DAF4581D23DC60020018B495 /* ImageRawRow.swift */, DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */, @@ -961,15 +1064,13 @@ DAF457A723DBA2C40018B495 /* Utils */ = { isa = PBXGroup; children = ( - DA7224D123828D3300712D20 /* PreviewConstants.swift */, - 934B610B2348D2F9009112D5 /* Color+Extension.swift */, - DAF4578123D630C70018B495 /* IconView.swift */, - DAF4578623D798A50018B495 /* TextLabelView.swift */, + DAF4578123D630C70018B495 /* WatchIconView.swift */, DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */, DA2E0AA323DC96E9009B0A99 /* ImageWithAction.swift */, DA2E0B0D23DCC152009B0A99 /* MapView.swift */, DA0749DF23E0BF510057FA83 /* ColorSelection.swift */, DA32D1B32C8C98C40018D974 /* IconWithAction.swift */, + DA8B14BB2F3A3CB5007753FD /* WatchTypography.swift */, ); path = Utils; sourceTree = ""; @@ -977,29 +1078,12 @@ DF4B83FD18857FA100F34902 /* UI */ = { isa = PBXGroup; children = ( - 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */, - 652B81082E2193DA00648510 /* ScreenSaver */, - DA2AEB752D92D32000897D80 /* Cells */, + DFFD8FCE18EDBD30003B502A /* Util */, DABED17C2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift */, - DA7125362EC892BB0067D7B2 /* LoggerView.swift */, - 653B54C1285E714900298ECD /* OpenHABViewController.swift */, - DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */, - DA94AF432EC8DE41003BB3C8 /* VideoStreamManager.swift */, DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */, - 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */, - DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */, DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */, - DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */, - DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */, - DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */, - DA48001F2D837CD8009CF127 /* SettingsView */, 1224F78B228A89E300750965 /* Watch */, - 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */, - DA9F81862C85020F00B47B72 /* RTFTextView.swift */, - DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */, - DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */, - DF4B84101886DA9900F34902 /* Widgets */, - DFFD8FCE18EDBD30003B502A /* Util */, + DA35E2B12E1EEA58003987BB /* SwiftUI */, ); name = UI; sourceTree = ""; @@ -1016,28 +1100,7 @@ DF4B84101886DA9900F34902 /* Widgets */ = { isa = PBXGroup; children = ( - 2F6412ED2CE494A80039FB28 /* DatePickerUITableViewCell.swift */, - DAF0A28E2C56F1EE00A14A6A /* ColorPickerCell.swift */, - DF06F1FB18FEC2020011E7B9 /* ColorPickerViewController.swift */, - DF4B84121886DAC400F34902 /* FrameUITableViewCell.swift */, - DF4B84151886EACA00F34902 /* GenericUITableViewCell.swift */, - DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */, - B7D5ECE021499E55001B0EC6 /* MapViewTableViewCell.swift */, - DAA42BA721DC97DF00244B2A /* NotificationTableViewCell.swift */, - DAF0A28A2C56E3A300A14A6A /* RollershutterCell.swift */, - DFA16EC018898A8400EDB0BB /* SegmentedUITableViewCell.swift */, - DF05FF221896BD2D00FF2F9B /* SelectionUITableViewCell.swift */, - DAF0A28C2C56EF8900A14A6A /* SetpointCell.swift */, - DFA16EBA18883DE500EDB0BB /* SliderUITableViewCell.swift */, - DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */, - DFA13CB318872EBD006355C3 /* SwitchUITableViewCell.swift */, - 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */, - DAA42BA921DC983B00244B2A /* VideoUITableViewCell.swift */, 905A534AB32C4104AC55A75C /* VideoStreamManager.swift */, - DAA42BAB21DC984A00244B2A /* WebUITableViewCell.swift */, - DAEAA89C21E6B06300267EA3 /* ReusableView.swift */, - DAEAA89E21E6B16600267EA3 /* UITableView.swift */, - DA7E1E47222EB00B002AEFD8 /* PlayerView.swift */, DA21EAE12339621C001AB415 /* Throttler.swift */, DA6B2EF42C89F8F200DF77CF /* ColorPickerView.swift */, DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */, @@ -1060,9 +1123,9 @@ DA0775162346705D0086C685 /* openHABWatch */, 4D6470D42561F935007B03FC /* openHABIntents */, DAC1310F2DA3208E00075AE2 /* openHABIntentsTests */, - DAD0856F2AE4782D001D36BE /* openHABWatchSwiftUI Watch AppTests */, DAD085792AE4782F001D36BE /* openHABWatchSwiftUI Watch AppUITests */, 6571444F2C1E438700C8A1F3 /* NotificationService */, + DA8B15522F3BB74B007753FD /* openHABWatchTests */, DFB2622818830A3600D3244D /* Products */, DFB2622918830A3600D3244D /* Frameworks */, DA1C2E4A230DC28F00FACFB0 /* fastlane */, @@ -1083,6 +1146,7 @@ DAD0856C2AE4782B001D36BE /* openHABWatchSwiftUI Watch AppTests.xctest */, DAD085762AE4782E001D36BE /* openHABWatchSwiftUI Watch AppUITests.xctest */, 6571444E2C1E438700C8A1F3 /* NotificationService.appex */, + DA8B15512F3BB74B007753FD /* openHABWatchTests.xctest */, ); name = Products; sourceTree = ""; @@ -1104,8 +1168,8 @@ isa = PBXGroup; children = ( DACE66522C63B2070069E514 /* openapitest */, - 938BF9C324EFCB9F00E6B52F /* Main.storyboard */, A3F4C3A41A49A5940019A09F /* MainLaunchScreen.xib */, + AA0001032F5A000000000002 /* OpenHABApp.swift */, DFB2623A18830A3600D3244D /* AppDelegate.swift */, 2F08AFC62E5FADC500E70611 /* NotificationCenterDelegateImpl.swift */, DFDEE3FE1883228C008B26AC /* Models */, @@ -1113,6 +1177,8 @@ DF4B83FD18857FA100F34902 /* UI */, DFB2623118830A3600D3244D /* Supporting Files */, 938BF9C724EFCCC000E6B52F /* Resources */, + 2F77DF3F2F43D94C00BE3744 /* UIKIT_MIGRATION_ANALYSIS.md */, + 2F77DF452F43D9A400BE3744 /* UIKIT_REMOVAL_GUIDE.md */, ); path = openHAB; sourceTree = ""; @@ -1135,6 +1201,7 @@ isa = PBXGroup; children = ( DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */, + DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */, ); name = Models; sourceTree = ""; @@ -1142,10 +1209,8 @@ DFFD8FCE18EDBD30003B502A /* Util */ = { isa = PBXGroup; children = ( - 938EDCE022C4FEB800661CA1 /* ScaleAspectFitImageView.swift */, - DFFD8FD018EDBD4F003B502A /* UICircleButton.swift */, - 938BF9C524EFCC0700E6B52F /* UILabel+Localization.swift */, - 938BF9D224EFD0B700E6B52F /* UIViewController+Localization.swift */, + 2F77DF412F43D96800BE3744 /* CertificateManagementService.swift */, + 2F77DF432F43D97400BE3744 /* IdleTimerService.swift */, 935B484525342B8E00E44CF0 /* URL+Static.swift */, ); name = Util; @@ -1168,6 +1233,7 @@ ); name = openHABIntents; packageProductDependencies = ( + 937E4491270B37FE00A98C26 /* Kingfisher */, 937E44E1270B393C00A98C26 /* OpenHABCore */, DA7ACD5E2DC3DB130055CFC7 /* SFSafeSymbols */, ); @@ -1230,9 +1296,11 @@ name = openHABWatch; packageProductDependencies = ( 934E592428F16EBA00162004 /* OpenHABCore */, + 934E592628F16EBA00162004 /* Kingfisher */, 934E592828F16EBA00162004 /* DeviceKit */, DA2C4FD42B4F573300D1C533 /* SDWebImageSVGCoder */, DA9A7EFC2D668D5900824156 /* SFSafeSymbols */, + DAC949FB2E219F30007E67B7 /* CommonUI */, ); productName = openHABWatchSwift2; productReference = DA0775152346705D0086C685 /* openHABWatch.app */; @@ -1256,6 +1324,29 @@ productReference = DA2DC22F21F2736C00830730 /* openHABTestsSwift.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + DA8B15502F3BB74B007753FD /* openHABWatchTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = DA8B15592F3BB74B007753FD /* Build configuration list for PBXNativeTarget "openHABWatchTests" */; + buildPhases = ( + DA8B154D2F3BB74B007753FD /* Sources */, + DA8B154E2F3BB74B007753FD /* Frameworks */, + DA8B154F2F3BB74B007753FD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + DA8B15562F3BB74B007753FD /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + DA8B15522F3BB74B007753FD /* openHABWatchTests */, + ); + name = openHABWatchTests; + packageProductDependencies = ( + ); + productName = openHABWatchTests; + productReference = DA8B15512F3BB74B007753FD /* openHABWatchTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; DAD0856B2AE4782A001D36BE /* openHABWatchSwiftUI Watch AppTests */ = { isa = PBXNativeTarget; buildConfigurationList = DAD085842AE47831001D36BE /* Build configuration list for PBXNativeTarget "openHABWatchSwiftUI Watch AppTests" */; @@ -1311,22 +1402,19 @@ 4D6470D92561F935007B03FC /* PBXTargetDependency */, 657144542C1E438700C8A1F3 /* PBXTargetDependency */, ); - fileSystemSynchronizedGroups = ( - DA2AEB752D92D32000897D80 /* Cells */, - ); name = openHAB; packageProductDependencies = ( 937E4470270B36D000A98C26 /* OpenHABCore */, 937E4484270B379900A98C26 /* DeviceKit */, + 937E4487270B37A600A98C26 /* Kingfisher */, 93F8063427AE6C620035A6B0 /* FirebaseCrashlytics */, 93F8064627AE7A050035A6B0 /* SwiftMessages */, 93F8064927AE7A2E0035A6B0 /* FlexColorPicker */, - 93F8064F27AE7A830035A6B0 /* SideMenu */, 6557AF912C039D140094D0C8 /* FirebaseMessaging */, DA9A7EFE2D66915900824156 /* SFSafeSymbols */, DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */, - DADC42092E7AB899004E866F /* SDWebImage */, - DAAAB2802EA3843100F1B05D /* Kingfisher */, + 2F54B5732F428ACA00DA1F1A /* AsyncAlgorithms */, + 2F54B5762F428B0200DA1F1A /* Algorithms */, ); productName = openHAB; productReference = DFB2622718830A3600D3244D /* openHAB.app */; @@ -1340,8 +1428,8 @@ attributes = { BuildIndependentTargetsInParallel = YES; CLASSPREFIX = OpenHAB; - LastSwiftUpdateCheck = 1540; - LastUpgradeCheck = 2600; + LastSwiftUpdateCheck = 2620; + LastUpgradeCheck = 2610; ORGANIZATIONNAME = "openHAB e.V."; TargetAttributes = { 4D6470D22561F935007B03FC = { @@ -1362,6 +1450,10 @@ LastSwiftMigration = 1020; TestTargetID = DFB2622618830A3600D3244D; }; + DA8B15502F3BB74B007753FD = { + CreatedOnToolsVersion = 26.2; + TestTargetID = DA0775142346705D0086C685; + }; DAD0856B2AE4782A001D36BE = { CreatedOnToolsVersion = 15.0; TestTargetID = DA0775142346705D0086C685; @@ -1411,14 +1503,15 @@ mainGroup = DFB2621E18830A3600D3244D; packageReferences = ( 937E4483270B379900A98C26 /* XCRemoteSwiftPackageReference "DeviceKit" */, + 937E4486270B37A600A98C26 /* XCRemoteSwiftPackageReference "Kingfisher" */, 93F8063327AE6C620035A6B0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, 93F8064527AE7A050035A6B0 /* XCRemoteSwiftPackageReference "SwiftMessages" */, 93F8064827AE7A2E0035A6B0 /* XCRemoteSwiftPackageReference "FlexColorPicker" */, - 93F8064E27AE7A820035A6B0 /* XCRemoteSwiftPackageReference "SideMenu" */, DA3B75AC2C59729200E219AB /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */, DADC42082E7AB899004E866F /* XCRemoteSwiftPackageReference "SDWebImage" */, - DAF9D9072EA4F95700416F22 /* XCRemoteSwiftPackageReference "Kingfisher" */, + 2F54B5722F428ACA00DA1F1A /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, + 2F54B5752F428B0200DA1F1A /* XCRemoteSwiftPackageReference "swift-algorithms" */, ); productRefGroup = DFB2622818830A3600D3244D /* Products */; projectDirPath = ""; @@ -1432,6 +1525,7 @@ DAD0856B2AE4782A001D36BE /* openHABWatchSwiftUI Watch AppTests */, DAD085752AE4782D001D36BE /* openHABWatchSwiftUI Watch AppUITests */, 6571444D2C1E438700C8A1F3 /* NotificationService */, + DA8B15502F3BB74B007753FD /* openHABWatchTests */, ); }; /* End PBXProject section */ @@ -1482,6 +1576,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DA8B154F2F3BB74B007753FD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DAD0856A2AE4782A001D36BE /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1502,15 +1603,18 @@ files = ( DAC131122DA32F5D00075AE2 /* Intents.intentdefinition in Resources */, 938BF9D024EFCCC000E6B52F /* Localizable.strings in Resources */, + 2F77DF402F43D94C00BE3744 /* UIKIT_MIGRATION_ANALYSIS.md in Resources */, DFB2624618830A3600D3244D /* Images.xcassets in Resources */, 6557AF8F2C0241C10094D0C8 /* PrivacyInfo.xcprivacy in Resources */, + 2F77DF462F43D9A400BE3744 /* UIKIT_REMOVAL_GUIDE.md in Resources */, 93685A7A2ADE755C0077A9A6 /* openHABTests.xctestplan in Resources */, 938BF9D124EFCCC000E6B52F /* InfoPlist.strings in Resources */, - 938BF9C424EFCB9F00E6B52F /* Main.storyboard in Resources */, + 2F54B56D2F40AC1A00DA1F1A /* REFACTORING_SUMMARY.md in Resources */, DA817E7A234BF39B00C91824 /* CHANGELOG.md in Resources */, DA4D4DB5233F9ACB00B37E37 /* README.md in Resources */, DA88F8C622EC377200B408E5 /* ReleaseNotes.md in Resources */, DFDF45311932042B00A6E581 /* legal.rtf in Resources */, + 2F77DF3E2F43D42E00BE3744 /* WEBVIEW_MIGRATION.md in Resources */, 656916D91FCB82BC00667B2A /* GoogleService-Info.plist in Resources */, A3F4C3A51A49A5940019A09F /* MainLaunchScreen.xib in Resources */, DFDA3CEA193CADB200888039 /* ping.wav in Resources */, @@ -1524,7 +1628,7 @@ DAF0A2902C56FE9F00A14A6A /* Run swiftformat & swiftlint */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; - buildActionMask = 2147483647; + buildActionMask = 8; files = ( ); inputFileListPaths = ( @@ -1536,7 +1640,7 @@ ); outputPaths = ( ); - runOnlyForDeploymentPostprocessing = 0; + runOnlyForDeploymentPostprocessing = 1; shellPath = /bin/sh; shellScript = "[[ -n \"$CI\" ]] && exit 0\n\ncd BuildTools\nSDKROOT=(xcrun --sdk macosx --show-sdk-path)\n\nswift package plugin --allow-writing-to-package-directory --allow-writing-to-directory \"$SRCROOT\" swiftformat \"$SRCROOT\" --config ./.swiftformat --cache /private/tmp/\nswift package plugin --allow-writing-to-package-directory --allow-writing-to-directory ../ swiftlint --cache-path /private/tmp/\n"; }; @@ -1586,37 +1690,39 @@ DA15BFBD23C6726400BD8ADA /* AppSettings.swift in Sources */, DA2E0B1023DCC439009B0A99 /* MapViewRow.swift in Sources */, DA0F37D023D4ACC7007EAB48 /* SliderRow.swift in Sources */, + DA2741022EA62FA3002FE576 /* SegmentSelectionView.swift in Sources */, DA32D1B42C8C98C40018D974 /* IconWithAction.swift in Sources */, DA07764A234683BC0086C685 /* SwitchRow.swift in Sources */, DA2E0AA423DC96E9009B0A99 /* ImageWithAction.swift in Sources */, - DA162BEC2CD3B53E0040DAE5 /* LogsViewer.swift in Sources */, - DAF4578723D798A50018B495 /* TextLabelView.swift in Sources */, + DA8B14B82F3A120E007753FD /* PreviewWidgetFactory.swift in Sources */, + DA8B14B62F3A0DFF007753FD /* WidgetRowFactory.swift in Sources */, DA0749DE23E0B5950057FA83 /* ColorPickerRow.swift in Sources */, - DA7224D223828D3400712D20 /* PreviewConstants.swift in Sources */, DAF4581E23DC60020018B495 /* ImageRawRow.swift in Sources */, DAD0858B2AE56F0E001D36BE /* OpenHABWatch.swift in Sources */, - DAF4578223D630C70018B495 /* IconView.swift in Sources */, - DAAAB2832EA3874500F1B05D /* SegmentSelectionView.swift in Sources */, + DAF4578223D630C70018B495 /* WatchIconView.swift in Sources */, DA07752D2346705F0086C685 /* NotificationController.swift in Sources */, DA0749E023E0BF510057FA83 /* ColorSelection.swift in Sources */, DA65871F236F83CE007E2E7F /* UserDefaultsExtension.swift in Sources */, + DA2D2F8A2F3943A800EC605A /* WidgetRowViewModel.swift in Sources */, DA9721C324E29A8F0092CCFD /* UserDefaultsBacked.swift in Sources */, DA72E1B8236DEA0900B8EF3A /* AppMessageService.swift in Sources */, DA07752B2346705F0086C685 /* OpenHABWatchAppDelegate.swift in Sources */, DAF4581623DC48400018B495 /* GenericRow.swift in Sources */, + 10472F7AF99C4940A6144817 /* TextRow.swift in Sources */, DAF457A023DA3E1C0018B495 /* SegmentRow.swift in Sources */, + DA8B14BC2F3A3CB5007753FD /* WatchTypography.swift in Sources */, + C4377202F7D642B5A8349008 /* SelectionRow.swift in Sources */, DAF4578923D79AA50018B495 /* DetailTextLabelView.swift in Sources */, - DAAC30872CBBF0420041927F /* SitemapPageView.swift in Sources */, DAC9AF4924F966FA006DAE93 /* LazyView.swift in Sources */, DA0776F0234788010086C685 /* UserData.swift in Sources */, DAC6608D236F771600F4501E /* PreferencesSwiftUIView.swift in Sources */, - DA50C7BD2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift in Sources */, DAF457A623DB9CE00018B495 /* SetpointRow.swift in Sources */, DAF4581823DC4A050018B495 /* ImageRow.swift in Sources */, + DA2741002EA62F1F002FE576 /* SitemapPageView.swift in Sources */, DA07752F2346705F0086C685 /* NotificationView.swift in Sources */, DA0775312346705F0086C685 /* ComplicationController.swift in Sources */, - DAF4578523D7807A0018B495 /* Color+Extension.swift in Sources */, DAF457A223DB6C640018B495 /* RollershutterRow.swift in Sources */, + DA8B14BA2F3A373A007753FD /* PreviewNavigationContainer.swift in Sources */, DAF457A923DBA4990018B495 /* FrameRow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1634,11 +1740,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DA8B154D2F3BB74B007753FD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DAD085682AE4782A001D36BE /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - DAD085712AE4782D001D36BE /* OpenHABWatchAppTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1655,74 +1767,79 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - DA95F3332E0F2B1700FE4474 /* OpenHABRootViewController.swift in Sources */, - DA7E1E4B2233986E002AEFD8 /* PlayerView.swift in Sources */, - 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */, + 1CE7AC462008E29FA37F231D /* AppServicesViewModel.swift in Sources */, + AA0001022F5A000000000001 /* SplashView.swift in Sources */, + AA0001042F5A000000000002 /* OpenHABApp.swift in Sources */, + BE875E6CE1B6E05E492F29CE /* OpenHABTabRootView.swift in Sources */, + 801159DCB99C03EFA71F4B9D /* MainWebTab.swift in Sources */, + D8C89708AE75DEFCD78566EC /* SitemapsTab.swift in Sources */, + 2C9BBB28C068BCC10291A566 /* TilesTab.swift in Sources */, + 2F77DF3C2F43D3D700BE3744 /* OpenHABWebView.swift in Sources */, + 0C93C5B057F863322A1B9820 /* SystemTab.swift in Sources */, + 911CC92713E7DF11C3295A4C /* SafariView.swift in Sources */, DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */, - DAF0A28B2C56E3A300A14A6A /* RollershutterCell.swift in Sources */, + A75A53645D1542CBAC658099 /* TabCustomizationSection.swift in Sources */, + DA64ACA62DBEAD5600294F60 /* SitemapPageViewModel.swift in Sources */, DABED17B2E451694000B92EF /* BonjourDiscoverySheet.swift in Sources */, - DF06F1FC18FEC2020011E7B9 /* ColorPickerViewController.swift in Sources */, 1224F78F228A89FD00750965 /* WatchMessageService.swift in Sources */, 2FBCF58C2DEB0B7700CD5D83 /* HomeSelectionView.swift in Sources */, - DAA42BAC21DC984A00244B2A /* WebUITableViewCell.swift in Sources */, - DF4B84131886DAC400F34902 /* FrameUITableViewCell.swift in Sources */, - DF4B84161886EACA00F34902 /* GenericUITableViewCell.swift in Sources */, 935B484625342B8E00E44CF0 /* URL+Static.swift in Sources */, - B7D5ECE121499E55001B0EC6 /* MapViewTableViewCell.swift in Sources */, + DAEA21DA2DBF477E00D54342 /* SwitchRowView.swift in Sources */, + DAEA21DC2DBF47DA00D54342 /* SliderRowView.swift in Sources */, DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */, + 2F77DF422F43D96800BE3744 /* CertificateManagementService.swift in Sources */, + DA35E2B02E1EDB86003987BB /* SetpointRowView.swift in Sources */, + DA35E2CB2E1F93AD003987BB /* ImageView.swift in Sources */, + DAEE35072E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift in Sources */, + 2F77DF442F43D97400BE3744 /* IdleTimerService.swift in Sources */, DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */, DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */, - 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */, DA7F002D2EB376CF00DE943A /* ServerCertificatesView.swift in Sources */, - DA95F3352E0F2C1600FE4474 /* OpenHABSitemapViewController.swift in Sources */, - DAA42BAA21DC983B00244B2A /* VideoUITableViewCell.swift in Sources */, 7BFFEA908B9E47FCB5C46E6E /* VideoStreamManager.swift in Sources */, DFB2623B18830A3600D3244D /* AppDelegate.swift in Sources */, 652B81092E2193DA00648510 /* ScreenSaverManager.swift in Sources */, 652B810A2E2193DA00648510 /* ScreenSaverConfiguration.swift in Sources */, 2F55E7BD2DEE44A800EC8350 /* ClientCertificatesView.swift in Sources */, DA6B2EF72C8B92E800DF77CF /* SelectionView.swift in Sources */, + DA64ACA82DBEAD8300294F60 /* SitemapPageView.swift in Sources */, + DA64ACAA2DBEAD9000294F60 /* SitemapNavigationView.swift in Sources */, DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */, DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */, - DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */, + DA35E2BD2E1EEA9D003987BB /* MapRowView.swift in Sources */, + DA35E2BE2E1EEA9D003987BB /* DatePickerInputRowView.swift in Sources */, + DAC949FE2E21A2D1007E67B7 /* FrameRowView.swift in Sources */, + DA35E2BF2E1EEA9D003987BB /* WebRowView.swift in Sources */, + DA35E2C02E1EEA9D003987BB /* ColorPickerRowView.swift in Sources */, + DA35E2C22E1EEA9D003987BB /* TextInputRowView.swift in Sources */, + DA35E2C32E1EEA9D003987BB /* ImageRowView.swift in Sources */, + DA35E2C42E1EEA9D003987BB /* SelectionRowView.swift in Sources */, + DA35E2C52E1EEA9D003987BB /* RollershutterRowView.swift in Sources */, + DA35E2C62E1EEA9D003987BB /* SegmentedRowView.swift in Sources */, + DA35E2C72E1EEA9D003987BB /* VideoRowView.swift in Sources */, + DA35E2CD2E1F96CA003987BB /* IconView.swift in Sources */, DA9F81872C85020F00B47B72 /* RTFTextView.swift in Sources */, DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */, - DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */, + DAE7B4A72E26927C00B9FE99 /* ButtonGridRowView.swift in Sources */, DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */, + DAEA21D82DBF472D00D54342 /* RowViewFactory.swift in Sources */, DA21EAE22339621C001AB415 /* Throttler.swift in Sources */, - DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */, - DA6B2EEF2C861BC900DF77CF /* DrawerView.swift in Sources */, + DAE2800A2E35F5590028EE24 /* IconURLView.swift in Sources */, DABED17D2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift in Sources */, DA94AEB42EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift in Sources */, DA48001A2D83742A009CF127 /* DebugSettingsView.swift in Sources */, - 938BF9D324EFD0B700E6B52F /* UIViewController+Localization.swift in Sources */, - DAA42BA821DC97E000244B2A /* NotificationTableViewCell.swift in Sources */, 2F08AFC72E5FADCF00E70611 /* NotificationCenterDelegateImpl.swift in Sources */, - DAF0A28F2C56F1EE00A14A6A /* ColorPickerCell.swift in Sources */, - 2FEFD8F62BE7C5BE00E387B9 /* TextInputUITableViewCell.swift in Sources */, - 938EDCE122C4FEB800661CA1 /* ScaleAspectFitImageView.swift in Sources */, DA2AEBA02D92FB6500897D80 /* NoIconDisplayableCell.swift in Sources */, - DA2AEB702D92CF3E00897D80 /* UITableViewCellExtension.swift in Sources */, DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */, + DAEA21DE2DBF481300D54342 /* TextRowView.swift in Sources */, 652B81042E2193B500648510 /* ScreenSaverSettingsView.swift in Sources */, DA3563D92E5096BE00BC0138 /* ScreenSaverView.swift in Sources */, - DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */, 2F55E7BB2DEE447700EC8350 /* SettingsView.swift in Sources */, DA4800182D837221009CF127 /* AboutSettingsView.swift in Sources */, - 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */, - DA7125372EC892BB0067D7B2 /* LoggerView.swift in Sources */, - 65C2EF492E244C8500A0C19F /* OpenHABNavigationController.swift in Sources */, + DAEA21E02DBF483E00D54342 /* GenericRowView.swift in Sources */, DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */, 65F055442E3D4E41004E98FE /* ItemSelectionView.swift in Sources */, - DFA16EC118898A8400EDB0BB /* SegmentedUITableViewCell.swift in Sources */, - DAF0A28D2C56EF8900A14A6A /* SetpointCell.swift in Sources */, - DAEAA89D21E6B06400267EA3 /* ReusableView.swift in Sources */, - DF05FF231896BD2D00FF2F9B /* SelectionUITableViewCell.swift in Sources */, - DFA16EBB18883DE500EDB0BB /* SliderUITableViewCell.swift in Sources */, - DFA13CB418872EBD006355C3 /* SwitchUITableViewCell.swift in Sources */, - DFFD8FD118EDBD4F003B502A /* UICircleButton.swift in Sources */, + DAF5AA682E4F3A39004F18D7 /* EmbeddingRowView.swift in Sources */, DA48001C2D837556009CF127 /* SitemapSettingsView.swift in Sources */, - 938BF9C624EFCC0700E6B52F /* UILabel+Localization.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1754,6 +1871,11 @@ target = DFB2622618830A3600D3244D /* openHAB */; targetProxy = DA2DC23421F2736C00830730 /* PBXContainerItemProxy */; }; + DA8B15562F3BB74B007753FD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DA0775142346705D0086C685 /* openHABWatch */; + targetProxy = DA8B15552F3BB74B007753FD /* PBXContainerItemProxy */; + }; DAA0708D2B504B280060BB0E /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DA0775142346705D0086C685 /* openHABWatch */; @@ -1837,7 +1959,6 @@ GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = openHABIntents/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1873,7 +1994,6 @@ GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = openHABIntents/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1911,7 +2031,6 @@ INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationService; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 openHAB e.V. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1952,7 +2071,6 @@ INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationService; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 openHAB e.V. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1986,7 +2104,6 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = openHABUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2005,7 +2122,6 @@ SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = openHAB; - WATCHOS_DEPLOYMENT_TARGET = 8.0; }; name = Debug; }; @@ -2027,7 +2143,6 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = openHABUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2074,7 +2189,6 @@ INFOPLIST_KEY_CLKComplicationPrincipalClass = openHABWatch.ComplicationController; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = org.openhab.app; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "@executable_path/Frameworks", "@executable_path/../../Frameworks", @@ -2090,7 +2204,6 @@ SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = 4; VERSIONING_SYSTEM = "apple-generic"; - WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Debug; }; @@ -2121,7 +2234,6 @@ INFOPLIST_KEY_CLKComplicationPrincipalClass = openHABWatch.ComplicationController; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = org.openhab.app; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "@executable_path/Frameworks", "@executable_path/../../Frameworks", @@ -2138,7 +2250,6 @@ SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = 4; VERSIONING_SYSTEM = "apple-generic"; - WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Release; }; @@ -2158,7 +2269,6 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = openHABTestsSwift/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2196,7 +2306,6 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = openHABTestsSwift/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2216,6 +2325,81 @@ }; name = Release; }; + DA8B15572F3BB74B007753FD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = PBAPXHRAM9; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.openhab.openHABWatchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/openHABWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/openHABWatch"; + }; + name = Debug; + }; + DA8B15582F3BB74B007753FD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = PBAPXHRAM9; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.openhab.openHABWatchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/openHABWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/openHABWatch"; + }; + name = Release; + }; DAD085852AE47831001D36BE /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2244,7 +2428,6 @@ SWIFT_EMIT_LOC_STRINGS = NO; TARGETED_DEVICE_FAMILY = 4; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/openHABWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/openHABWatch"; - WATCHOS_DEPLOYMENT_TARGET = 9.0; }; name = Debug; }; @@ -2278,7 +2461,6 @@ SWIFT_EMIT_LOC_STRINGS = NO; TARGETED_DEVICE_FAMILY = 4; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/openHABWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/openHABWatch"; - WATCHOS_DEPLOYMENT_TARGET = 9.0; }; name = Release; }; @@ -2309,7 +2491,6 @@ SWIFT_EMIT_LOC_STRINGS = NO; TARGETED_DEVICE_FAMILY = 4; TEST_TARGET_NAME = openHABWatch; - WATCHOS_DEPLOYMENT_TARGET = 9.0; }; name = Debug; }; @@ -2342,7 +2523,6 @@ SWIFT_EMIT_LOC_STRINGS = NO; TARGETED_DEVICE_FAMILY = 4; TEST_TARGET_NAME = openHABWatch; - WATCHOS_DEPLOYMENT_TARGET = 9.0; }; name = Release; }; @@ -2399,18 +2579,30 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 3.1.1; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_ENABLE_BARE_SLASH_REGEX = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = NO; + SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = NO; + SWIFT_UPCOMING_FEATURE_DYNAMIC_ACTOR_ISOLATION = NO; SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; + SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = NO; + SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; + SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = NO; + SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = NO; + SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = NO; + SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = NO; + SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY = NO; + SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = NO; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; - WATCHOS_DEPLOYMENT_TARGET = 9.0; + WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Debug; }; @@ -2462,20 +2654,32 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 3.1.1; ONLY_ACTIVE_ARCH = NO; PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app"; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_ENABLE_BARE_SLASH_REGEX = NO; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = NO; + SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = NO; + SWIFT_UPCOMING_FEATURE_DYNAMIC_ACTOR_ISOLATION = NO; SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; + SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = NO; + SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; + SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = NO; + SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = NO; + SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = NO; + SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = NO; + SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY = NO; + SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = NO; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; - WATCHOS_DEPLOYMENT_TARGET = 9.0; + WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Release; }; @@ -2495,7 +2699,6 @@ INFOPLIST_FILE = "openHAB/openHAB-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = openHAB; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2512,6 +2715,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; SUPPORTS_MACCATALYST = NO; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_INSTALL_OBJC_HEADER = NO; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; @@ -2542,7 +2746,6 @@ INFOPLIST_FILE = "openHAB/openHAB-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = openHAB; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2560,6 +2763,7 @@ PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app"; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_INSTALL_OBJC_HEADER = NO; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; @@ -2620,6 +2824,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DA8B15592F3BB74B007753FD /* Build configuration list for PBXNativeTarget "openHABWatchTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DA8B15572F3BB74B007753FD /* Debug */, + DA8B15582F3BB74B007753FD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DAD085842AE47831001D36BE /* Build configuration list for PBXNativeTarget "openHABWatchSwiftUI Watch AppTests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -2659,6 +2872,22 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 2F54B5722F428ACA00DA1F1A /* XCRemoteSwiftPackageReference "swift-async-algorithms" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-async-algorithms.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.2; + }; + }; + 2F54B5752F428B0200DA1F1A /* XCRemoteSwiftPackageReference "swift-algorithms" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-algorithms.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.1; + }; + }; 937E4483270B379900A98C26 /* XCRemoteSwiftPackageReference "DeviceKit" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/devicekit/DeviceKit.git"; @@ -2672,7 +2901,7 @@ repositoryURL = "https://github.com/onevcat/Kingfisher.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 8.6.0; + minimumVersion = 8.0.0; }; }; 93F8063327AE6C620035A6B0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { @@ -2699,14 +2928,6 @@ minimumVersion = 1.0.0; }; }; - 93F8064E27AE7A820035A6B0 /* XCRemoteSwiftPackageReference "SideMenu" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/jonkykong/SideMenu.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 6.5.0; - }; - }; DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SDWebImage/SDWebImageSVGCoder.git"; @@ -2731,17 +2952,19 @@ minimumVersion = 5.21.2; }; }; - DAF9D9072EA4F95700416F22 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/onevcat/Kingfisher"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 8.6.1; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 2F54B5732F428ACA00DA1F1A /* AsyncAlgorithms */ = { + isa = XCSwiftPackageProductDependency; + package = 2F54B5722F428ACA00DA1F1A /* XCRemoteSwiftPackageReference "swift-async-algorithms" */; + productName = AsyncAlgorithms; + }; + 2F54B5762F428B0200DA1F1A /* Algorithms */ = { + isa = XCSwiftPackageProductDependency; + package = 2F54B5752F428B0200DA1F1A /* XCRemoteSwiftPackageReference "swift-algorithms" */; + productName = Algorithms; + }; 6557AF912C039D140094D0C8 /* FirebaseMessaging */ = { isa = XCSwiftPackageProductDependency; package = 93F8063327AE6C620035A6B0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; @@ -2755,6 +2978,11 @@ isa = XCSwiftPackageProductDependency; productName = OpenHABCore; }; + 934E592628F16EBA00162004 /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = 937E4486270B37A600A98C26 /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; 934E592828F16EBA00162004 /* DeviceKit */ = { isa = XCSwiftPackageProductDependency; package = 937E4483270B379900A98C26 /* XCRemoteSwiftPackageReference "DeviceKit" */; @@ -2773,6 +3001,11 @@ package = 937E4483270B379900A98C26 /* XCRemoteSwiftPackageReference "DeviceKit" */; productName = DeviceKit; }; + 937E4487270B37A600A98C26 /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = 937E4486270B37A600A98C26 /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; 937E448B270B37CA00A98C26 /* Kingfisher */ = { isa = XCSwiftPackageProductDependency; package = 937E4486270B37A600A98C26 /* XCRemoteSwiftPackageReference "Kingfisher" */; @@ -2783,6 +3016,11 @@ package = 937E4483270B379900A98C26 /* XCRemoteSwiftPackageReference "DeviceKit" */; productName = DeviceKit; }; + 937E4491270B37FE00A98C26 /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = 937E4486270B37A600A98C26 /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; 937E44E1270B393C00A98C26 /* OpenHABCore */ = { isa = XCSwiftPackageProductDependency; productName = OpenHABCore; @@ -2802,11 +3040,6 @@ package = 93F8064827AE7A2E0035A6B0 /* XCRemoteSwiftPackageReference "FlexColorPicker" */; productName = FlexColorPicker; }; - 93F8064F27AE7A830035A6B0 /* SideMenu */ = { - isa = XCSwiftPackageProductDependency; - package = 93F8064E27AE7A820035A6B0 /* XCRemoteSwiftPackageReference "SideMenu" */; - productName = SideMenu; - }; DA10161A2DC7BAE500552D14 /* SFSafeSymbols */ = { isa = XCSwiftPackageProductDependency; package = DA3B75AC2C59729200E219AB /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; @@ -2832,18 +3065,21 @@ package = DA3B75AC2C59729200E219AB /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; productName = SFSafeSymbols; }; - DAAAB2802EA3843100F1B05D /* Kingfisher */ = { - isa = XCSwiftPackageProductDependency; - productName = Kingfisher; - }; DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */ = { isa = XCSwiftPackageProductDependency; package = DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */; productName = SDWebImageSVGCoder; }; - DADC42092E7AB899004E866F /* SDWebImage */ = { + DAC949F92E219F0D007E67B7 /* CommonUI */ = { + isa = XCSwiftPackageProductDependency; + productName = CommonUI; + }; + DAC949FB2E219F30007E67B7 /* CommonUI */ = { + isa = XCSwiftPackageProductDependency; + productName = CommonUI; + }; + DAFF80972E4F47830084513E /* SDWebImage */ = { isa = XCSwiftPackageProductDependency; - package = DADC42082E7AB899004E866F /* XCRemoteSwiftPackageReference "SDWebImage" */; productName = SDWebImage; }; /* End XCSwiftPackageProductDependency section */ diff --git a/openHAB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1091bf224..54e0836b8 100644 --- a/openHAB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5c49fd5f990d2edca55cf73c7a8dfe3d2cb79e2dec9ce0c99e387d1ef121891d", + "originHash" : "8569e30a45db15e05c7c1667e727a277e8587c9ef61be4f1f5677da0a5122781", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -199,6 +199,15 @@ "version" : "509.1.1" } }, + { + "identity" : "swift-timeout", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swhitty/swift-timeout.git", + "state" : { + "revision" : "4efb73b593d5553b90766d531db701ecf2306237", + "version" : "0.4.1" + } + }, { "identity" : "swiftformatplugin", "kind" : "remoteSourceControl", @@ -225,6 +234,15 @@ "revision" : "c0ff6c65bfc00e6a707957cb7069988c5cde2a30", "version" : "10.0.2" } + }, + { + "identity" : "webui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/cybozu/WebUI.git", + "state" : { + "revision" : "d1408d9ad8056f4619b71f06a0b0ba1ff553f3a3", + "version" : "4.2.1" + } } ], "version" : 3 diff --git a/openHAB.xcodeproj/xcshareddata/xcschemes/openHAB.xcscheme b/openHAB.xcodeproj/xcshareddata/xcschemes/openHAB.xcscheme index eeb266494..fe5c433f4 100644 --- a/openHAB.xcodeproj/xcshareddata/xcschemes/openHAB.xcscheme +++ b/openHAB.xcodeproj/xcshareddata/xcschemes/openHAB.xcscheme @@ -1,6 +1,6 @@ + + + + + + + + + + + + + + diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index fd88104fe..2bea53cfe 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8c495fbbc017171018ba53ee5be99160fda17c9899843b6dc912c62822b09a70", + "originHash" : "83f21c0111d9078178f4dfa8fc448732dea814a1e407c8e6224198feff68ce09", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk.git", "state" : { - "revision" : "793b67f4652e1a39d03fab6650033768afe6d15e", - "version" : "12.5.0" + "revision" : "674d9a7ee9858207181a3dd0b42c77298c6fb71b", + "version" : "12.8.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "c2d59acf17a8ba7ed80a763593c67c9c7c006ad1", - "version" : "12.5.0" + "revision" : "2ffd220823f3716904733162e9ae685545c276d1", + "version" : "12.8.0" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/grpc-binary.git", "state" : { - "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", - "version" : "1.69.0" + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/gtm-session-fetcher.git", "state" : { - "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", - "version" : "3.5.0" + "revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7", + "version" : "5.0.0" } }, { @@ -112,10 +112,10 @@ { "identity" : "kingfisher", "kind" : "remoteSourceControl", - "location" : "https://github.com/onevcat/Kingfisher", + "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { - "revision" : "4d75de347da985a70c63af4d799ed482021f6733", - "version" : "8.6.1" + "revision" : "d30a5fad881137e2267f96a8e3fc35c58999bb94", + "version" : "8.6.2" } }, { @@ -148,10 +148,10 @@ { "identity" : "sdwebimage", "kind" : "remoteSourceControl", - "location" : "https://github.com/SDWebImage/SDWebImage.git", + "location" : "https://github.com/SDWebImage/SDWebImage", "state" : { - "revision" : "34cf2423a2c4088d06a3b08655603b5bc3eeeb3a", - "version" : "5.21.2" + "revision" : "36e79ba485e9bb4d3cd4e3318908866dac5e7b51", + "version" : "5.21.5" } }, { @@ -173,12 +173,21 @@ } }, { - "identity" : "sidemenu", + "identity" : "swift-algorithms", "kind" : "remoteSourceControl", - "location" : "https://github.com/jonkykong/SideMenu.git", + "location" : "https://github.com/apple/swift-algorithms.git", "state" : { - "revision" : "8bd4fd128923cf5494fa726839af8afe12908ad9", - "version" : "6.5.0" + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "2971dd5d9f6e0515664b01044826bcea16e59fac", + "version" : "1.1.2" } }, { @@ -186,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", - "version" : "1.1.4" + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" } }, { @@ -200,30 +209,30 @@ } }, { - "identity" : "swift-openapi-runtime", + "identity" : "swift-numerics", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-openapi-runtime", + "location" : "https://github.com/apple/swift-numerics", "state" : { - "revision" : "8f33cc5dfe81169fb167da73584b9c72c3e8bc23", - "version" : "1.8.2" + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" } }, { - "identity" : "swift-openapi-urlsession", + "identity" : "swift-openapi-runtime", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-openapi-urlsession", + "location" : "https://github.com/apple/swift-openapi-runtime", "state" : { - "revision" : "6fac6f7c428d5feea2639b5f5c8b06ddfb79434b", - "version" : "1.1.0" + "revision" : "7cdf33371bf89b23b9cf4fd3ce8d3c825c28fbe8", + "version" : "1.9.0" } }, { - "identity" : "swift-protobuf", + "identity" : "swift-openapi-urlsession", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", + "location" : "https://github.com/apple/swift-openapi-urlsession", "state" : { - "revision" : "ebc7251dd5b37f627c93698e4374084d98409633", - "version" : "1.28.2" + "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", + "version" : "1.2.0" } }, { @@ -240,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swhitty/swift-timeout.git", "state" : { - "revision" : "89f44421c61476b4aa290c573d427a3f1831492f", - "version" : "0.4.0" + "revision" : "4efb73b593d5553b90766d531db701ecf2306237", + "version" : "0.4.1" } }, { @@ -267,8 +276,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SwiftKickMobile/SwiftMessages.git", "state" : { - "revision" : "c544df6ce316422b9d65a571f932e800213dd09c", - "version" : "10.0.1" + "revision" : "c0ff6c65bfc00e6a707957cb7069988c5cde2a30", + "version" : "10.0.2" } } ], diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 85e9eb744..d8e5bc89a 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -10,7 +10,6 @@ // SPDX-License-Identifier: EPL-2.0 import AVFoundation -import Combine import Firebase import FirebaseMessaging import Kingfisher @@ -18,17 +17,14 @@ import OpenHABCore import os.log import SDWebImageSVGCoder import SwiftMessages +import SwiftUI import UIKit @preconcurrency import UserNotifications import WatchConnectivity +import AsyncAlgorithms -@main class AppDelegate: UIResponder, UIApplicationDelegate { - static var appDelegate: AppDelegate! - - var window: UIWindow? - - private var crashlyticsSubscriber: AnyCancellable? + private var crashlyticsTask: Task? private let notificationDelegate = NotificationCenterDelegateImpl() @@ -47,9 +43,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - override init() { - super.init() - AppDelegate.appDelegate = self + deinit { + crashlyticsTask?.cancel() } func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { @@ -58,11 +53,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let appDefaults = ["CacheDataAgressively": NSNumber(value: true)] UserDefaults.standard.register(defaults: appDefaults) - Preferences.migratePreferences() + UNUserNotificationCenter.current().delegate = notificationDelegate - setupFirebase() + Logger.appDelegate.info("didFinishLaunchingWithOptions ended") + return true + } - UNUserNotificationCenter.current().delegate = notificationDelegate + /// Setup that can be deferred until after the UI appears + @MainActor + func performDeferredSetup() { + setupFirebase() registerForPushNotifications() Logger.appDelegate.info("uniq id: \(UIDevice.current.identifierForVendor?.uuidString ?? "")") @@ -74,34 +74,38 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } catch { Logger.appDelegate.info("Setting category to AVAudioSessionCategoryPlayback failed.") } - Logger.appDelegate.info("didFinishLaunchingWithOptions ended") activateWatchConnectivity() configureImageCoders() - /// load and start the screensaver - if let keyWindow = UIApplication.shared.firstKeyWindow { - var config = ScreenSaverConfiguration() - config.isEnabled = Preferences.shared.screensaverEnabled - config.showsTime = Preferences.shared.screensaverShowsTime - config.showsDate = Preferences.shared.screensaverShowsDate - config.idleInterval = Preferences.shared.screensaverIdleInterval - config.movementInterval = Preferences.shared.screensaverMovementInterval - config.fontName = Preferences.shared.screensaverFontName.isEmpty ? nil : Preferences.shared.screensaverFontName - config.timeFontSizeRatio = CGFloat(Preferences.shared.screensaverTimeFontRatio) - config.dateFontRelativeSize = CGFloat(Preferences.shared.screensaverDateFontRatio) - config.enablesAutoDimming = Preferences.shared.screensaverEnableDimming - config.dimLevel = CGFloat(Preferences.shared.screensaverDimLevel) - config.wakeBrightnessLevel = CGFloat(Preferences.shared.screensaverWakeBrightness) - config.showsSeconds = Preferences.shared.screensaverShowsSeconds - config.uses24HourTime = Preferences.shared.screensaverUse24Hour - config.restoresBrightness = Preferences.shared.screensaverRestoreBrightness - - ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) - } + startScreenSaverMonitoring() + } - return true + @MainActor + func startScreenSaverMonitoring() { + Task { @MainActor in + if let keyWindow = UIApplication.shared.firstKeyWindow { + let prefs = await Preferences.shared.screensaverPreferences + var config = ScreenSaverConfiguration() + config.isEnabled = prefs.isEnabled + config.showsTime = prefs.showsTime + config.showsDate = prefs.showsDate + config.idleInterval = prefs.idleInterval + config.movementInterval = prefs.movementInterval + config.fontName = prefs.fontName.isEmpty ? nil : prefs.fontName + config.timeFontSizeRatio = CGFloat(prefs.timeFontRatio) + config.dateFontRelativeSize = CGFloat(prefs.dateFontRatio) + config.enablesAutoDimming = prefs.enableDimming + config.dimLevel = CGFloat(prefs.dimLevel) + config.wakeBrightnessLevel = CGFloat(prefs.wakeBrightness) + config.showsSeconds = prefs.showsSeconds + config.uses24HourTime = prefs.use24Hour + config.restoresBrightness = prefs.restoreBrightness + + ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) + } + } } @MainActor @@ -115,10 +119,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // init Firebase crash reporting FirebaseApp.configure() FirebaseApp.app()?.isDataCollectionDefaultEnabled = false - crashlyticsSubscriber = Preferences.shared.$sendCrashReports.sink { - Crashlytics.crashlytics().setCrashlyticsCollectionEnabled($0) - Logger.appDelegate.debug("setCrashlyticsCollectionEnabled to \($0)") + + // Use AsyncAlgorithms to observe application preferences changes + crashlyticsTask = Task { + let channel = await Preferences.shared.applicationPreferencesChannel + for await applicationProperties in channel { + let sendCrashReports = applicationProperties.sendCrashReports + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(sendCrashReports) + Logger.appDelegate.debug("setCrashlyticsCollectionEnabled to \(sendCrashReports)") + } } + Messaging.messaging().delegate = self } @@ -220,49 +231,6 @@ extension Notification.Name { static let openHABDidReceiveNotification = Notification.Name("openHABDidReceiveNotification") } -extension AppDelegate { - func applicationWillResignActive(_ application: UIApplication) { - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. - NotificationCenter.default.post(name: .disableScreenSaver, object: nil) - } - - func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. - } - - func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. - } - - func applicationDidBecomeActive(_ application: UIApplication) { - // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. - if let keyWindow = UIApplication.shared.firstKeyWindow { - var config = ScreenSaverConfiguration() - config.isEnabled = Preferences.shared.screensaverEnabled - config.showsTime = Preferences.shared.screensaverShowsTime - config.showsDate = Preferences.shared.screensaverShowsDate - config.idleInterval = Preferences.shared.screensaverIdleInterval - config.movementInterval = Preferences.shared.screensaverMovementInterval - config.fontName = Preferences.shared.screensaverFontName.isEmpty ? nil : Preferences.shared.screensaverFontName - config.timeFontSizeRatio = CGFloat(Preferences.shared.screensaverTimeFontRatio) - config.dateFontRelativeSize = CGFloat(Preferences.shared.screensaverDateFontRatio) - config.enablesAutoDimming = Preferences.shared.screensaverEnableDimming - config.dimLevel = CGFloat(Preferences.shared.screensaverDimLevel) - config.wakeBrightnessLevel = CGFloat(Preferences.shared.screensaverWakeBrightness) - config.showsSeconds = Preferences.shared.screensaverShowsSeconds - config.uses24HourTime = Preferences.shared.screensaverUse24Hour - config.restoresBrightness = Preferences.shared.screensaverRestoreBrightness - - ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) - } - } - - func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. - } -} extension AppDelegate: MessagingDelegate { nonisolated func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { diff --git a/openHAB/Cells/Providers/ColorPickerCellProvider.swift b/openHAB/Cells/Providers/ColorPickerCellProvider.swift deleted file mode 100644 index e44478267..000000000 --- a/openHAB/Cells/Providers/ColorPickerCellProvider.swift +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct ColorPickerCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "ColorPickerCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as ColorPickerCell - } - - @MainActor - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? ColorPickerCell else { return } - cell.delegate = controller - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/DatePickerInputProvider.swift b/openHAB/Cells/Providers/DatePickerInputProvider.swift deleted file mode 100644 index a6e13358f..000000000 --- a/openHAB/Cells/Providers/DatePickerInputProvider.swift +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct DatePickerInputProvider: WidgetCellProvider { - var reuseIdentifier: String { "DatePickerUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as DatePickerUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? DatePickerUITableViewCell else { return } - cell.controller = controller - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/FrameCellProvider.swift b/openHAB/Cells/Providers/FrameCellProvider.swift deleted file mode 100644 index dd712c784..000000000 --- a/openHAB/Cells/Providers/FrameCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct FrameCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "FrameUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as FrameUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? FrameUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/GenericCellProvider.swift b/openHAB/Cells/Providers/GenericCellProvider.swift deleted file mode 100644 index 35f0beba6..000000000 --- a/openHAB/Cells/Providers/GenericCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct GenericCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "GenericUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as GenericUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? GenericUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/ImageCellProvider.swift b/openHAB/Cells/Providers/ImageCellProvider.swift deleted file mode 100644 index 9444cadfe..000000000 --- a/openHAB/Cells/Providers/ImageCellProvider.swift +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct ImageCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "NewImageUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as NewImageUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? NewImageUITableViewCell else { return } - cell.didLoad = { [weak controller] in - controller?.updateWidgetTableView() - } - cell.openHABRootUrl = controller.openHABRootUrl - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/MapViewCellProvider.swift b/openHAB/Cells/Providers/MapViewCellProvider.swift deleted file mode 100644 index a05d965db..000000000 --- a/openHAB/Cells/Providers/MapViewCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct MapViewCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "MapViewTableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as MapViewTableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? MapViewTableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/RollershutterCellProvider.swift b/openHAB/Cells/Providers/RollershutterCellProvider.swift deleted file mode 100644 index fa5726eed..000000000 --- a/openHAB/Cells/Providers/RollershutterCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct RollershutterCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "RollerShutterTableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as RollershutterCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? RollershutterCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/SegmentedCellProvider.swift b/openHAB/Cells/Providers/SegmentedCellProvider.swift deleted file mode 100644 index 66203ecfb..000000000 --- a/openHAB/Cells/Providers/SegmentedCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct SegmentedCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "SegmentedUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as SegmentedUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? SegmentedUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/SelectionCellProvider.swift b/openHAB/Cells/Providers/SelectionCellProvider.swift deleted file mode 100644 index b6ef5aee0..000000000 --- a/openHAB/Cells/Providers/SelectionCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct SelectionCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "SelectionUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as SelectionUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? SelectionUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/SetpointCellProvider.swift b/openHAB/Cells/Providers/SetpointCellProvider.swift deleted file mode 100644 index d1d28fa20..000000000 --- a/openHAB/Cells/Providers/SetpointCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct SetpointCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "SetpointCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as SetpointCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? SetpointCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/SliderProvider.swift b/openHAB/Cells/Providers/SliderProvider.swift deleted file mode 100644 index 44e9c1650..000000000 --- a/openHAB/Cells/Providers/SliderProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct SliderProvider: WidgetCellProvider { - var reuseIdentifier: String { "SliderUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as SliderUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? SliderUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/SliderWithSwitchProvider.swift b/openHAB/Cells/Providers/SliderWithSwitchProvider.swift deleted file mode 100644 index 6c16d75ed..000000000 --- a/openHAB/Cells/Providers/SliderWithSwitchProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct SliderWithSwitchProvider: WidgetCellProvider { - var reuseIdentifier: String { "SliderWithSwitchSupportUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as SliderWithSwitchSupportUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? SliderWithSwitchSupportUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/SwitchCellProvider.swift b/openHAB/Cells/Providers/SwitchCellProvider.swift deleted file mode 100644 index 4f11a8910..000000000 --- a/openHAB/Cells/Providers/SwitchCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct SwitchCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "SwitchUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as SwitchUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? SwitchUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/TextInputProvider.swift b/openHAB/Cells/Providers/TextInputProvider.swift deleted file mode 100644 index 37d3c4b63..000000000 --- a/openHAB/Cells/Providers/TextInputProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct TextInputProvider: WidgetCellProvider { - var reuseIdentifier: String { "TextInputUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as TextInputUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? TextInputUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/VideoCellProvider.swift b/openHAB/Cells/Providers/VideoCellProvider.swift deleted file mode 100644 index 1fa4e03f2..000000000 --- a/openHAB/Cells/Providers/VideoCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct VideoCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "VideoUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as VideoUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? VideoUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/WebViewCellProvider.swift b/openHAB/Cells/Providers/WebViewCellProvider.swift deleted file mode 100644 index b3dcd5840..000000000 --- a/openHAB/Cells/Providers/WebViewCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -struct WebViewCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "WebUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as WebUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? WebUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/WidgetCellProvider.swift b/openHAB/Cells/WidgetCellProvider.swift deleted file mode 100644 index 1cd477d2a..000000000 --- a/openHAB/Cells/WidgetCellProvider.swift +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import UIKit - -enum WidgetCellFactory { - static func provider(for widget: OpenHABWidget) -> any WidgetCellProvider { - switch widget.type { - case .switchWidget: - if !widget.mappings.isEmpty { - SegmentedCellProvider() - } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { - SwitchCellProvider() - } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { - RollershutterCellProvider() - } else if !widget.mappingsOrItemOptions.isEmpty { - SegmentedCellProvider() - } else { - SwitchCellProvider() - } - case .slider: - if widget.switchSupport { - SliderWithSwitchProvider() - } else { - SliderProvider() - } - case .input: - if [.date, .time, .datetime].contains(widget.inputHint) { - DatePickerInputProvider() - } else { - TextInputProvider() - } - case .frame: FrameCellProvider() - case .setpoint: SetpointCellProvider() - case .selection: SelectionCellProvider() - case .colorpicker: ColorPickerCellProvider() - case .image, .chart: ImageCellProvider() - case .video: VideoCellProvider() - case .webview: WebViewCellProvider() - case .mapview: MapViewCellProvider() - case .group, .text, .defaultWidget, .unknown: - GenericCellProvider() - } - } -} - -protocol WidgetCellProvider { - var reuseIdentifier: String { get } - @MainActor func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell - @MainActor func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) -} diff --git a/openHAB/CertificateManagementService.swift b/openHAB/CertificateManagementService.swift new file mode 100644 index 000000000..4f6fc9ae5 --- /dev/null +++ b/openHAB/CertificateManagementService.swift @@ -0,0 +1,241 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Foundation +import OpenHABCore +import SwiftUI + +/// Service for managing certificate-related alerts and user interactions in SwiftUI +@MainActor +@Observable +class CertificateManagementService { + static let shared = CertificateManagementService() + + // MARK: - Alert Types + + struct ServerCertificateAlert: Identifiable { + let id = UUID() + let title: String + let message: String + let completion: (ServerCertificateManager.EvaluateResult) -> Void + } + + struct ClientCertificateImportAlert: Identifiable { + let id = UUID() + let title: String + let message: String + let completion: (Bool) -> Void + } + + struct ClientCertificatePasswordAlert: Identifiable { + let id = UUID() + let title: String + let message: String + var password: String = "" + let completion: (String?) -> Void + } + + struct ClientCertificateErrorAlert: Identifiable { + let id = UUID() + let title: String + let message: String + } + + // MARK: - Published Alert States + + var serverCertificateAlert: ServerCertificateAlert? + var clientCertificateImportAlert: ClientCertificateImportAlert? + var clientCertificatePasswordAlert: ClientCertificatePasswordAlert? + var clientCertificateErrorAlert: ClientCertificateErrorAlert? + + private init() { + setupCertificateManagers() + } + + private func setupCertificateManagers() { + CertificateManagers.clientCertificateManager.delegate = self + CertificateManagers.serverCertificateManager.delegate = self + } +} + +// MARK: - ServerCertificateManagerDelegate + +extension CertificateManagementService: ServerCertificateManagerDelegate { + func evaluateServerTrust(summary certificateSummary: String?, forDomain domain: String?) async -> ServerCertificateManager.EvaluateResult { + await withCheckedContinuation { continuation in + let title = NSLocalizedString("ssl_certificate_warning", comment: "") + let message = String(format: NSLocalizedString("ssl_certificate_invalid", comment: ""), + certificateSummary ?? "", domain ?? "") + + serverCertificateAlert = ServerCertificateAlert( + title: title, + message: message + ) { result in + continuation.resume(returning: result) + self.serverCertificateAlert = nil + } + } + } + + func evaluateCertificateMismatch(summary certificateSummary: String?, forDomain domain: String?) async -> ServerCertificateManager.EvaluateResult { + await withCheckedContinuation { continuation in + let title = NSLocalizedString("ssl_certificate_warning", comment: "") + let message = String(format: NSLocalizedString("ssl_certificate_no_match", comment: ""), + certificateSummary ?? "", domain ?? "") + + serverCertificateAlert = ServerCertificateAlert( + title: title, + message: message + ) { result in + continuation.resume(returning: result) + self.serverCertificateAlert = nil + } + } + } + + func acceptedServerCertificatesChanged() { + // User's decision about trusting server certificates has changed + // Send updates to the paired watch + Task { + await WatchMessageService.singleton.syncPreferencesToWatch() + } + } +} + +// MARK: - ClientCertificateManagerDelegate + +extension CertificateManagementService: ClientCertificateManagerDelegate { + func askForClientCertificateImport(_ clientCertificateManager: ClientCertificateManager?) async -> Bool { + await withCheckedContinuation { continuation in + let title = NSLocalizedString("certificate_import_title", comment: "") + let message = NSLocalizedString("certificate_import_text", comment: "") + + clientCertificateImportAlert = ClientCertificateImportAlert( + title: title, + message: message + ) { shouldImport in + if shouldImport { + Task { + await clientCertificateManager?.clientCertificateAccepted(password: nil) + } + } else { + clientCertificateManager?.clientCertificateRejected() + } + continuation.resume(returning: shouldImport) + self.clientCertificateImportAlert = nil + } + } + } + + func askForCertificatePassword(_ clientCertificateManager: ClientCertificateManager?) async -> String? { + await withCheckedContinuation { continuation in + let title = NSLocalizedString("certificate_import_title", comment: "") + let message = NSLocalizedString("certificate_import_password", comment: "") + + clientCertificatePasswordAlert = ClientCertificatePasswordAlert( + title: title, + message: message + ) { password in + continuation.resume(returning: password) + self.clientCertificatePasswordAlert = nil + } + } + } + + func alertClientCertificateError(_ clientCertificateManager: ClientCertificateManager?, errMsg: String) async { + let title = NSLocalizedString("certificate_import_title", comment: "") + clientCertificateErrorAlert = ClientCertificateErrorAlert( + title: title, + message: errMsg + ) + } +} + +// MARK: - SwiftUI Alert View Modifiers + +extension View { + /// Adds certificate management alerts to a view + func certificateManagementAlerts() -> some View { + modifier(CertificateManagementAlertsModifier()) + } +} + +struct CertificateManagementAlertsModifier: ViewModifier { + @State private var certificateService = CertificateManagementService.shared + + func body(content: Content) -> some View { + content + // Server Certificate Alert + .alert(item: $certificateService.serverCertificateAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + primaryButton: .default(Text(NSLocalizedString("always", comment: ""))) { + alert.completion(.permitAlways) + }, + secondaryButton: .cancel(Text(NSLocalizedString("abort", comment: ""))) { + alert.completion(.deny) + } + ) + } + // Client Certificate Import Alert + .alert(item: $certificateService.clientCertificateImportAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + primaryButton: .default(Text(NSLocalizedString("okay", comment: ""))) { + alert.completion(true) + }, + secondaryButton: .cancel(Text(NSLocalizedString("cancel", comment: ""))) { + alert.completion(false) + } + ) + } + // Client Certificate Password Alert + .alert( + certificateService.clientCertificatePasswordAlert?.title ?? "", + isPresented: Binding( + get: { certificateService.clientCertificatePasswordAlert != nil }, + set: { if !$0 { + certificateService.clientCertificatePasswordAlert?.completion(nil) + certificateService.clientCertificatePasswordAlert = nil + }} + ) + ) { + if let alert = certificateService.clientCertificatePasswordAlert { + SecureField(NSLocalizedString("password", comment: ""), text: Binding( + get: { alert.password }, + set: { certificateService.clientCertificatePasswordAlert?.password = $0 } + )) + + Button(NSLocalizedString("okay", comment: "")) { + alert.completion(alert.password.isEmpty ? nil : alert.password) + } + + Button(NSLocalizedString("cancel", comment: ""), role: .cancel) { + alert.completion(nil) + } + } + } message: { + if let alert = certificateService.clientCertificatePasswordAlert { + Text(alert.message) + } + } + // Client Certificate Error Alert + .alert(item: $certificateService.clientCertificateErrorAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + dismissButton: .default(Text(NSLocalizedString("okay", comment: ""))) + ) + } + } +} diff --git a/openHAB/ColorPickerCell.swift b/openHAB/ColorPickerCell.swift deleted file mode 100644 index c8d4bbdf2..000000000 --- a/openHAB/ColorPickerCell.swift +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import os.log -import UIKit - -protocol ColorPickerCellDelegate: NSObjectProtocol { - func didPressColorButton(_ cell: ColorPickerCell?) -} - -class ColorPickerCell: GenericUITableViewCell { - weak var delegate: (any ColorPickerCellDelegate)? - - @IBOutlet private var downButton: UIButton! - @IBOutlet private var upButton: UIButton! - @IBOutlet private var colorButton: UICircleButton! - - required init?(coder: NSCoder) { - Logger.widgets.info("ColorPickerCell initWithCoder") - - super.init(coder: coder) - - selectionStyle = .none - separatorInset = .zero - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - selectionStyle = .none - separatorInset = .zero - } - - @IBAction private func colorButtonPressed(_ sender: Any) { - delegate?.didPressColorButton(self) - } - - override func displayWidget() { - customTextLabel?.text = widget.labelText - colorButton?.backgroundColor = widget.item?.stateAsUIColor() - upButton?.addTarget(self, action: .upButtonPressed, for: .touchUpInside) - downButton?.addTarget(self, action: .downButtonPressed, for: .touchUpInside) - } - - @objc - func upButtonPressed() { - Logger.widgets.info("ON button pressed") - widget.sendCommand("ON") - } - - @objc - func downButtonPressed() { - Logger.widgets.info("OFF button pressed") - widget.sendCommand("OFF") - } -} - -private extension Selector { - static let upButtonPressed = #selector(ColorPickerCell.upButtonPressed) - static let downButtonPressed = #selector(ColorPickerCell.downButtonPressed) -} diff --git a/openHAB/ColorPickerViewController.swift b/openHAB/ColorPickerViewController.swift deleted file mode 100644 index 2989c56ca..000000000 --- a/openHAB/ColorPickerViewController.swift +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import FlexColorPicker -import OpenHABCore -import os.log -import UIKit - -class ColorPickerViewController: DefaultColorPickerViewController { - var widget: OpenHABWidget? - - /// Throttle engine - private var throttler: Throttler? - - /// Throttling interval - var throttlingInterval: TimeInterval? = 0 { - didSet { - guard let interval = throttlingInterval else { - throttler = nil - return - } - throttler = Throttler(maxInterval: interval) - } - } - - required init?(coder: NSCoder) { - Logger.widgets.info("ColorPickerViewController initWithCoder") - super.init(coder: coder) - } - - override func viewDidLoad() { - Logger.widgets.info("ColorPickerViewController viewDidLoad") - - if let color = widget?.item?.stateAsUIColor() { - selectedColor = color - } - - delegate = self - - if #available(iOS 13.0, *) { - // if nothing is set DefaultColorPickerViewController will fall back to .white - // if we set this manually DefaultColorPickerViewController will go with that - view.backgroundColor = .ohSystemBackground - } else { - // do nothing - DefaultColorPickerViewController will handle this - } - - super.viewDidLoad() - throttlingInterval = 0.3 - } - - func sendColorUpdate(color: UIColor) { - // swiftlint:disable:next large_tuple - var (hue, saturation, brightness, alpha): (CGFloat, CGFloat, CGFloat, CGFloat) = (0.0, 0.0, 0.0, 0.0) - color.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) - - hue *= 360 - saturation *= 100 - brightness *= 100 - - Logger.widgets.info("Color changed to HSB(\(hue), \(saturation), \(brightness)).") - - widget?.sendCommand("\(hue),\(saturation),\(brightness)") - } -} - -extension ColorPickerViewController: @preconcurrency ColorPickerDelegate { - func colorPicker(_ colorPicker: ColorPickerController, selectedColor: UIColor, usingControl: any ColorControl) { - if let throttler { - throttler.throttle { DispatchQueue.main.async { self.sendColorUpdate(color: selectedColor) } } - } else { - sendColorUpdate(color: selectedColor) - } - } - - func colorPicker(_ colorPicker: ColorPickerController, confirmedColor: UIColor, usingControl: any ColorControl) { - sendColorUpdate(color: confirmedColor) - } -} diff --git a/openHAB/DatePickerUITableViewCell.swift b/openHAB/DatePickerUITableViewCell.swift deleted file mode 100644 index 1618d3859..000000000 --- a/openHAB/DatePickerUITableViewCell.swift +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import UIKit - -class DatePickerUITableViewCell: GenericUITableViewCell { - static let dateFormatter = { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - return dateFormatter - }() - - override var widget: OpenHABWidget! { - get { - super.widget - } - set(widget) { - super.widget = widget - switch widget.inputHint { - case .date: - datePicker.datePickerMode = .date - case .time: - datePicker.datePickerMode = .time - case .datetime: - datePicker.datePickerMode = .dateAndTime - default: - fatalError("Must not use this cell for input other than date and time") - } - guard let date = widget.item?.state else { - datePicker.date = Date() - return - } - datePicker.date = DateFormatter.iso8601Full.date(from: date) ?? Date.now - } - } - - weak var controller: OpenHABSitemapViewController! - - @IBOutlet private(set) var datePicker: UIDatePicker! { - didSet { - datePicker.addAction(UIAction { [weak self] _ in - guard let self else { return } - controller?.sendCommand(widget.item, commandToSend: DateFormatter.iso8601Full.string(from: datePicker.date)) - }, for: .valueChanged) - } - } -} diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift deleted file mode 100644 index 65b12af81..000000000 --- a/openHAB/DrawerView.swift +++ /dev/null @@ -1,311 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Combine -import Kingfisher -import OpenHABCore -import os.log -import SafariServices -import SFSafeSymbols -import SwiftUI - -class CurrentViewState: ObservableObject { - @Published var isWebViewActive = false -} - -enum DrawerViewError: Error, CustomDebugStringConvertible { - case noRootURL - - var debugDescription: String { - switch self { - case .noRootURL: - "No root URL" - } - } -} - -struct ImageView: View { - let url: String - - @EnvironmentObject var networkTracker: MainActorNetworkTracker - - var body: some View { - if !url.isEmpty { - switch url { - case _ where url.hasPrefix("data:image"): - let provider = Base64ImageDataProvider(base64String: url.deletingPrefix("data:image/png;base64,"), cacheKey: UUID().uuidString) - return KFImage(source: .provider(provider)).resizable() - case _ where url.hasPrefix("http"): - return KFImage(URL(string: url)).resizable() - default: - let builtURL = Endpoint.resource( - openHABRootUrl: networkTracker.activeConnection?.configuration.url ?? "", - path: url.prepare() - ).url - return KFImage(builtURL).resizable() - } - } else { - // This will always fallback to placeholder - return KFImage(URL(string: "bundle://openHABIcon")).placeholder { Image("openHABIcon").resizable() } - } - } -} - -// Display the connected URL -struct ConnectionView: View { - @StateObject private var networkTracker = MainActorNetworkTracker.shared - - var body: some View { - HStack { - if let activeConnection = networkTracker.activeConnection { - Image(systemSymbol: .cloudFill) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 20, height: 20) - Text(activeConnection.configuration.url).font(.footnote) - } else { - Image(systemSymbol: .exclamationmarkIcloudFill) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 20, height: 20) - Text("connecting").font(.footnote) - } - } - } -} - -struct DrawerView: View { - @State private var sitemaps: [OpenHABSitemap] = [] - @State private var uiTiles: [OpenHABUiTile] = [] - @State private var selectedSection: Int? - @State private var connectedUrl = "Not connected" // Default label text - - @EnvironmentObject private var networkTracker: MainActorNetworkTracker - @EnvironmentObject private var currentViewState: CurrentViewState - - var onDismiss: (TargetController) -> Void - @Environment(\.dismiss) private var dismiss - - @ScaledMetric var iconWidth = 20.0 - - @State private var sitemapForWatch: String? - - var mainSection: some View { - Section(header: Text("Main")) { - menuEntry(image: Image("openHABIcon").resizable(), goTo: .webview) { - HStack { - Text("Home").accessibilityIdentifier("Home") - if currentViewState.isWebViewActive { - Spacer() - Image(systemSymbol: .arrowClockwise) - .accessibilityHidden(true) - } - } - } - } - } - - var tilesSection: some View { - Section(header: Text("Tiles")) { - ForEach(uiTiles, id: \.url) { tile in - menuEntry( - image: ImageView(url: tile.imageUrl), - goTo: .tile(tile.url) - ) { - Text(tile.name) - } - } - } - } - - var sitemapsSection: some View { - Section(header: Text("Sitemaps")) { - ForEach(sitemaps, id: \.name) { sitemap in - menuEntry( - image: sitemapIcon(for: sitemap), - goTo: .sitemap(sitemap.name) - ) { - HStack { - Text(sitemap.label) - if sitemap.name == sitemapForWatch { - Spacer() - Image(systemSymbol: .applewatchWatchface) - } - } - } - .onTapGesture(count: 2) { toggleWatchSitemap(sitemap) } - } - } - } - - var systemSection: some View { - Section(header: Text("System")) { - systemMenuEntry(image: .gear, text: "settings", goTo: .settings) - if Preferences.shared.getNotificationConnection() != nil, - !Preferences.shared.currentHomePreferences.demomode { - systemMenuEntry(image: .bell, text: "notifications", goTo: .notifications) - } - systemMenuEntry(image: .house, text: "Manage Homes", goTo: .homeSelection) - } - } - - var body: some View { - VStack { - List { - mainSection - sitemapsSection - tilesSection - systemSection - } - .listStyle(.inset) - - Spacer() - ConnectionView() - .padding(.bottom, 5) - } - .listStyle(.inset) - .task { - let activeConnection = networkTracker.activeConnection - await updateSitemapsAndUITiles(activeConnection: activeConnection) - sitemapForWatch = Preferences.shared.currentHomePreferences.sitemapForWatch - } - .onReceive(networkTracker.$activeConnection) { activeConnection in - Task { - await updateSitemapsAndUITiles(activeConnection: activeConnection) - } - } - } - - private func menuEntry(image: Image, text: Text, goTo target: TargetController) -> some View { - HStack { - image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: iconWidth, height: iconWidth) - text - } - .onTapGesture { - dismiss() - onDismiss(target) - } - } - - private func menuEntry(image: some View, - goTo target: TargetController, - @ViewBuilder label: () -> some View) -> some View { - HStack { - image - .aspectRatio(contentMode: .fit) - .frame(width: iconWidth, height: iconWidth) - label() - } - .contentShape(Rectangle()) // entire row tappable - .onTapGesture { - dismiss() - onDismiss(target) - } - } - - func systemMenuEntry(image: SFSymbol, text: String, goTo target: TargetController) -> some View { - menuEntry(image: Image(systemSymbol: image), goTo: target) { - Text(LocalizedStringKey(text)) - .accessibilityLabel(text) - } - } - - func sitemapIcon(for sitemap: OpenHABSitemap) -> some View { - Group { - if sitemap.icon.isEmpty { - Image("openHABIcon").resizable() - } else { - let url = Endpoint.iconForDrawer( - rootUrl: networkTracker.activeConnection?.configuration.url ?? "", - icon: sitemap.icon - ).url - KFImage(url) - .placeholder { Image("openHABIcon").resizable() } - .resizable() - } - } - .aspectRatio(contentMode: .fit) - } - - func toggleWatchSitemap(_ sitemap: OpenHABSitemap) { - Preferences.shared.modifyActiveHome { prefs in - if sitemap.name == sitemapForWatch { - sitemapForWatch = nil - prefs.sitemapForWatch = "" - prefs.sitemapForWatchLabel = "" - } else { - sitemapForWatch = sitemap.name - prefs.sitemapForWatch = sitemap.name - prefs.sitemapForWatchLabel = sitemap.label - } - } - } - - private func updateSitemapsAndUITiles(activeConnection: ConnectionInfo?) async { - guard let activeConnection else { return } - - do { - let openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) - - do { - sitemaps = try await openAPIService.openHABSitemaps() - if sitemaps.last?.name == "_default", sitemaps.count > 1 { - sitemaps = Array(sitemaps.dropLast()) - } - let sortSitemapsBy = Preferences.shared.currentHomePreferences.sortSitemapsBy - switch SortSitemapsOrder(rawValue: sortSitemapsBy) ?? .label { - case .label: - sitemaps.sort { $0.label < $1.label } - case .name: - sitemaps.sort { $0.name < $1.name } - } - - } catch { - Logger.drawerView.error("Failed to fetch sitemaps: \(error.localizedDescription)") - sitemaps = [] - } - - do { - uiTiles = try await openAPIService.getUITiles() - Logger.drawerView.info("Fetched UI tiles successfully") - } catch { - Logger.drawerView.error("Failed to fetch UI tiles: \(error.localizedDescription)") - uiTiles = [] - } - - } catch { - Logger.drawerView.error("Failed to initialize OpenAPIService: \(error.localizedDescription)") - sitemaps = [] - uiTiles = [] - } - } -} - -#Preview("WebView Active") { - let networkTracker = MainActorNetworkTracker.shared - let currentViewState = CurrentViewState() - currentViewState.isWebViewActive = true - return DrawerView { _ in } - .environmentObject(networkTracker) - .environmentObject(currentViewState) -} - -#Preview("WebView Inactive") { - let networkTracker = MainActorNetworkTracker.shared - let currentViewState = CurrentViewState() - currentViewState.isWebViewActive = false - return DrawerView { _ in } - .environmentObject(networkTracker) - .environmentObject(currentViewState) -} diff --git a/openHAB/FrameUITableViewCell.swift b/openHAB/FrameUITableViewCell.swift deleted file mode 100644 index a52efb6d6..000000000 --- a/openHAB/FrameUITableViewCell.swift +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import UIKit - -class FrameUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { - required init?(coder: NSCoder) { - super.init(coder: coder) - - selectionStyle = .none - separatorInset = .zero - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - selectionStyle = .none - separatorInset = .zero - } - - override func displayWidget() { - textLabel?.textColor = .ohSecondaryLabel - textLabel?.font = .preferredFont(forTextStyle: .callout) - textLabel?.text = widget.label.uppercased() - contentView.sizeToFit() - } -} diff --git a/openHAB/GenericUITableViewCell.swift b/openHAB/GenericUITableViewCell.swift deleted file mode 100644 index 2b9f91b8e..000000000 --- a/openHAB/GenericUITableViewCell.swift +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Kingfisher -import OpenHABCore -import UIKit - -protocol GenericCellCacheProtocol: UITableViewCell { - func invalidateCache() -} - -@MainActor -protocol GenericUITableViewCellTouchEventDelegate: AnyObject { - func touchDown() - func touchUp() -} - -class GenericUITableViewCell: UITableViewCell { - private var _widget: OpenHABWidget! - - // optional event callback if table cells neeed to notify on touch up or down events - weak var touchEventDelegate: (any GenericUITableViewCellTouchEventDelegate)? - - var widget: OpenHABWidget! { - get { - _widget - } - set(widget) { - _widget = widget - - if _widget.linkedPage != nil { - accessoryType = .disclosureIndicator - selectionStyle = .blue - // self.userInteractionEnabled = YES; - } else { - accessoryType = .none - selectionStyle = .none - // self.userInteractionEnabled = NO; - } - - customTextLabel?.textColor = !(_widget.labelcolor.isEmpty) ? UIColor(fromString: _widget.labelcolor) : .ohLabel - customDetailTextLabel?.textColor = !(_widget.valuecolor.isEmpty) ? UIColor(fromString: _widget.valuecolor) : .ohSecondaryLabel - } - } - - @IBOutlet private(set) var customTextLabel: UILabel! - @IBOutlet private(set) var customDetailTextLabel: UILabel! - @IBOutlet private(set) var customDetailTextLabelConstraint: NSLayoutConstraint! - - required init?(coder: NSCoder) { - super.init(coder: coder) - initialize() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - initialize() - } - - func initialize() { - selectionStyle = .none - separatorInset = .zero - } - - // This is to fix possible different sizes of user icons - we fix size and position of UITableViewCell icons - override func layoutSubviews() { - super.layoutSubviews() - imageView?.frame = CGRect(x: 13, y: 5, width: 32, height: 32) - } - - func displayWidget() { - customTextLabel?.text = widget?.labelText - customDetailTextLabel?.text = widget?.labelValue ?? "" - customDetailTextLabel?.sizeToFit() - - if customDetailTextLabel != nil, customDetailTextLabelConstraint != nil { - if accessoryType == .none { - // If accessory is disabled, set detailTextLabel (widget value) constraint 20px to the right for padding to the right side of table view - customDetailTextLabelConstraint.constant = 20.0 - } else { - // If accessory is enabled, set detailTextLabel (widget value) constraint 5px to the right - customDetailTextLabelConstraint.constant = 5.0 - } - } - } - - override func prepareForReuse() { - super.prepareForReuse() - imageView?.kf.cancelDownloadTask() - imageView?.image = nil - } -} diff --git a/openHAB/IdleTimerService.swift b/openHAB/IdleTimerService.swift new file mode 100644 index 000000000..ff8bef9d0 --- /dev/null +++ b/openHAB/IdleTimerService.swift @@ -0,0 +1,80 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Combine +import OpenHABCore +import SwiftUI +import UIKit +import AsyncAlgorithms + +/// Service for managing the device's idle timer based on user preferences +@MainActor +class IdleTimerService { + static let shared = IdleTimerService() + + private var cancellables = Set() + + private init() { + observePreferences() + observeAppLifecycle() + } + + private func observePreferences() { + // Observe changes to idle timer preference + Task { + let channel = await Preferences.shared.applicationPreferencesChannel + for await preferences in channel { + configure(idleOff: preferences.idleOff) + } + } + } + + private func observeAppLifecycle() { + // Disable idle timer when entering background + NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification) + .sink { [weak self] _ in + self?.disableIdleTimer() + } + .store(in: &cancellables) + + // Re-enable based on preferences when becoming active + NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) + .sink { [weak self] _ in + Task { @MainActor in + let preferences = await Preferences.shared.applicationPreferences + self?.configure(idleOff: preferences.idleOff) + } + } + .store(in: &cancellables) + } + + /// Configure the idle timer based on user preference + func configure(idleOff: Bool) { + UIApplication.shared.isIdleTimerDisabled = idleOff + } + + /// Disable the idle timer (used when entering background) + func disableIdleTimer() { + UIApplication.shared.isIdleTimerDisabled = false + } +} + +// MARK: - SwiftUI View Extension + +extension View { + /// Automatically manages the idle timer based on user preferences + func idleTimerManagement() -> some View { + self.onAppear { + // Initialize the service + _ = IdleTimerService.shared + } + } +} diff --git a/openHAB/LoggerView.swift b/openHAB/LoggerView.swift index a31ed8e7b..1b0263610 100644 --- a/openHAB/LoggerView.swift +++ b/openHAB/LoggerView.swift @@ -32,14 +32,14 @@ struct LoggerView: View { .padding() } else if logs.isEmpty { Text("No logs found") - .foregroundColor(.gray) + .foregroundStyle(.gray) .padding() } else { List(logs, id: \.id) { log in VStack(alignment: .leading, spacing: 1) { Text(formattedDate(log.timestamp)) .font(.caption.monospacedDigit()) - .foregroundColor(.gray) + .foregroundStyle(.gray) Text(log.message) .font(.body) diff --git a/openHAB/Main.storyboard b/openHAB/Main.storyboard deleted file mode 100644 index 46c53046d..000000000 --- a/openHAB/Main.storyboard +++ /dev/null @@ -1,784 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/openHAB/MapViewTableViewCell.swift b/openHAB/MapViewTableViewCell.swift deleted file mode 100644 index 6c20ae739..000000000 --- a/openHAB/MapViewTableViewCell.swift +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import MapKit -import OpenHABCore - -class MapViewTableViewCell: GenericUITableViewCell { - private var mapView: MKMapView! - - override var widget: OpenHABWidget! { - get { - super.widget - } - set(widget) { - let oldLocationCoordinate: CLLocationCoordinate2D? = self.widget?.coordinate - let oldLocationTitle = self.widget?.labelText ?? "" - let newLocationCoordinate: CLLocationCoordinate2D? = widget?.coordinate - let newLocationTitle = widget?.labelText - - super.widget = widget - - if !(oldLocationCoordinate?.latitude == newLocationCoordinate?.latitude && oldLocationCoordinate?.longitude == newLocationCoordinate?.longitude && (oldLocationTitle == newLocationTitle)) { - mapView.removeAnnotations(mapView.annotations) - - if widget?.item?.stateAsLocation() != nil { - if let widget { - mapView.addAnnotation(widget) - } - mapView.setRegion(MKCoordinateRegion(center: (widget?.coordinate)!, latitudinalMeters: 1000.0, longitudinalMeters: 1000.0), animated: false) - } - } - } - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - mapView = MKMapView(frame: CGRect.zero) - mapView.layer.cornerRadius = 4.0 - mapView.layer.masksToBounds = true - contentView.addSubview(mapView) - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - mapView = MKMapView(frame: CGRect.zero) - mapView.layer.cornerRadius = 4.0 - mapView.layer.masksToBounds = true - contentView.addSubview(mapView) - } - - override func layoutSubviews() { - super.layoutSubviews() - - mapView.frame = contentView.bounds.insetBy(dx: 13.0, dy: 8.0) - } -} diff --git a/openHAB/NewImageUITableViewCell.swift b/openHAB/NewImageUITableViewCell.swift deleted file mode 100644 index e6bc96283..000000000 --- a/openHAB/NewImageUITableViewCell.swift +++ /dev/null @@ -1,264 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Combine -import OpenHABCore -import os.log -import UIKit - -enum ImageType { - case link(url: URL?) - case embedded(image: UIImage?) - case empty -} - -class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { - // Shared image cache across all cells - keyed by widget ID - // Using NSCache for thread-safety and automatic memory management - private static let sharedImageCache = NSCache() - - private var mainImageView: ScaleAspectFitImageView! - private var refreshTimer: Timer? - private var currentRefreshInterval: TimeInterval = 0 - private var chartStyle: ChartStyle = .light - private var activeTask: Task? - private var displayedWidgetId: String? // Track which widget this cell is currently displaying - - var didLoad: (() -> Void)? - - var openHABRootUrl: String? - - private var shouldCache: Bool { - widget?.refresh == 0 - } - - private var widgetPayload: ImageType { - guard let widget else { return .empty } - - switch widget.type { - case .chart: - guard let openHABRootUrl else { - Logger.widgets.error("Missing openHABRootUrl in NewImageUITableViewCell") - return .empty - } - return .link(url: Endpoint.chart( - rootUrl: openHABRootUrl, - period: widget.period, - type: widget.item?.type, - service: widget.service, - name: widget.item?.name, - legend: widget.legend, - theme: chartStyle, - forceAsItem: widget.forceAsItem, - yAxisDecimalPattern: widget.yAxisDecimalPattern - ).url) - case .image: - if let item = widget.item { - return widgetPayload(fromItem: item) - } - return .link(url: URL(string: widget.url)) - default: - return .empty - } - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - mainImageView = ScaleAspectFitImageView() - - contentView.addSubview(mainImageView) - - let positionGuide = contentView // contentView.layoutMarginsGuide if more margin would be appreciated - - mainImageView.translatesAutoresizingMaskIntoConstraints = false // enable autolayout - - NSLayoutConstraint.activate([ - mainImageView.leftAnchor.constraint(equalTo: positionGuide.leftAnchor), - mainImageView.rightAnchor.constraint(equalTo: positionGuide.rightAnchor), - mainImageView.topAnchor.constraint(equalTo: positionGuide.topAnchor), - mainImageView.bottomAnchor.constraint(equalTo: positionGuide.bottomAnchor) - ]) - - chartStyle = OHInterfaceStyle.current == .light ? ChartStyle.light : ChartStyle.dark - } - - override func willMove(toSuperview newSuperview: UIView?) { - super.willMove(toSuperview: newSuperview) - - if newSuperview == nil { - refreshTimer?.invalidate() - } - } - - override func prepareForReuse() { - super.prepareForReuse() - - // Cancel any active image loading task to prevent race conditions where a task - // started for a previous widget updates the shared cache or UI for a newly assigned widget - activeTask?.cancel() - activeTask = nil - - // Invalidate timer to prevent refreshes for wrong widget - refreshTimer?.invalidate() - refreshTimer = nil - currentRefreshInterval = 0 - - // Clear displayed widget ID to ensure clean state - displayedWidgetId = nil - - // Reset chart style - chartStyle = OHInterfaceStyle.current == .light ? ChartStyle.light : ChartStyle.dark - } - - override func displayWidget() { - let widgetId = widget?.id ?? "" - // Set displayedWidgetId before cache check to ensure consistency - displayedWidgetId = widgetId - - // Check shared cache for this widget's image - if let cachedImage = Self.sharedImageCache.object(forKey: widgetId as NSString) { - // Found in shared cache - use it immediately without reloading - mainImageView?.image = cachedImage - } else { - // Not in cache - need to load - mainImageView?.image = nil - loadImage() - } - - // Handle refresh timer - only update if the interval has changed - let newRefreshInterval = widget.refresh != 0 ? TimeInterval(Double(widget.refresh) / 1000) : 0 - - if newRefreshInterval != currentRefreshInterval { - // Refresh interval changed, update the timer - refreshTimer?.invalidate() - refreshTimer = nil - currentRefreshInterval = newRefreshInterval - - if newRefreshInterval > 0.09 { - Logger.widgets.info("Scheduling image refresh every \(newRefreshInterval) seconds") - refreshTimer = Timer.scheduledTimer( - timeInterval: newRefreshInterval, - target: self, - selector: #selector(NewImageUITableViewCell.refreshImage(_:)), - userInfo: nil, - repeats: true - ) - } - } - } - - func loadImage() { - let widgetId = widget?.id ?? "" - switch widgetPayload { - case let .embedded(image): - if let image { - // Only update cache and UI if still displaying the same widget - if displayedWidgetId == widgetId { - Self.sharedImageCache.setObject(image, forKey: widgetId as NSString) - mainImageView.image = image - didLoad?() - } - } - case let .link(url): - guard let url else { return } - loadRemoteImage(withURL: url) - default: - Logger.widgets.debug("Failed to determine widget payload.") - } - } - - private func widgetPayload(fromItem item: OpenHABItem) -> ImageType { - switch item.type { - case .image: - Logger.widgets.debug("Image base64Encoded.") - guard let data = item.state?.components(separatedBy: ",")[safe: 1], let decodedData = Data(base64Encoded: data, options: .ignoreUnknownCharacters) else { - return .empty - } - return .embedded(image: UIImage(data: decodedData)) - case .stringItem: - return .link(url: URL(string: item.state ?? "")) - default: - return .empty - } - } - - private func loadRemoteImage(withURL url: URL) { - let widgetId = widget?.id ?? "" - Logger.widgets.debug("Image URL: \(url.absoluteString)") - - if activeTask != nil { - activeTask?.cancel() - activeTask = nil - } - - activeTask = Task { - do { - guard let config = await NetworkTracker.shared.activeConnection?.configuration else { - Logger.widgets.warning("No openHAB connection found.") - throw HTTPClientError.noConfiguration - } - let client = HTTPClient(connectionConfiguration: config) - let (data, _): (Data, URLResponse) = try await client.doRequest(baseURL: url, timeout: 10.0, type: .data, cacheingPolicy: !shouldCache ? .reloadIgnoringCacheData : .useProtocolCachePolicy) - await MainActor.run { - guard let image = UIImage(data: data) else { return } - // Only store in cache and update UI if this cell is still displaying the same widget - if self.displayedWidgetId == widgetId { - Self.sharedImageCache.setObject(image, forKey: widgetId as NSString) - self.mainImageView?.image = image - self.didLoad?() - } - } - } catch { - Logger.widgets.info("Downloading image failed: \(error.localizedDescription)") - } - } - } - - @objc - func refreshImage(_ timer: Timer?) { - // swiftformat:disable:next redundantSelf - Logger.widgets.info("Refreshing image on \(Double(self.widget.refresh) / 1000) seconds schedule") - loadImage() - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - chartStyle = OHInterfaceStyle.current == .light ? ChartStyle.light : ChartStyle.dark - if widget.type == .chart { - loadImage() - } - } -} - -extension NewImageUITableViewCell: GenericCellCacheProtocol { - /// Clears the entire shared image cache (call when sitemap changes, etc.) - static func clearSharedCache() { - sharedImageCache.removeAllObjects() - } - - func invalidateCache() { - refreshTimer?.invalidate() - refreshTimer = nil - currentRefreshInterval = 0 - // Clear this widget from shared cache - if let widgetId = displayedWidgetId { - Self.sharedImageCache.removeObject(forKey: widgetId as NSString) - } - displayedWidgetId = nil - } -} diff --git a/openHAB/NotificationCenterDelegateImpl.swift b/openHAB/NotificationCenterDelegateImpl.swift index 79efe2efb..3cba3b45a 100644 --- a/openHAB/NotificationCenterDelegateImpl.swift +++ b/openHAB/NotificationCenterDelegateImpl.swift @@ -138,16 +138,19 @@ final class NotificationCenterDelegateImpl: NSObject, UNUserNotificationCenterDe SwiftMessages.hideAll() } - // ✅ Ensure this runs on the MainActor @MainActor func notifyNotificationListeners(action: String?, cloudUserId: String? = nil) { // Wake up screen saver immediately on incoming notification interaction NotificationCenter.default.post(name: .wakeScreenSaver, object: nil) - if let navigationController = AppDelegate.appDelegate.window?.rootViewController as? UINavigationController, - let rootViewController = navigationController.viewControllers.first as? OpenHABRootViewController { - rootViewController.handleNotification(action: action, cloudUserId: cloudUserId) - } + var userInfo: [String: Any] = [:] + if let action { userInfo["action"] = action } + if let cloudUserId { userInfo["cloudUserId"] = cloudUserId } + NotificationCenter.default.post( + name: .openHABHandleNotificationAction, + object: nil, + userInfo: userInfo + ) } } diff --git a/openHAB/NotificationTableViewCell.swift b/openHAB/NotificationTableViewCell.swift deleted file mode 100644 index 302128f0f..000000000 --- a/openHAB/NotificationTableViewCell.swift +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import os.log -import UIKit - -class NotificationTableViewCell: UITableViewCell { - @IBOutlet private(set) var customTextLabel: UILabel! - @IBOutlet private(set) var customDetailTextLabel: UILabel! - - required init?(coder: NSCoder) { - Logger.widgets.info("NotificationTableViewCell initWithCoder") - super.init(coder: coder) - separatorInset = .zero - } - - // This is to fix possible different sizes of user icons - we fix size and position of UITableViewCell icons - override func layoutSubviews() { - super.layoutSubviews() - imageView?.frame = CGRect(x: 14, y: 6, width: 30, height: 30) - } -} diff --git a/openHAB/OpenHABApp.swift b/openHAB/OpenHABApp.swift new file mode 100644 index 000000000..051905f0e --- /dev/null +++ b/openHAB/OpenHABApp.swift @@ -0,0 +1,47 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import OpenHABCore +import SwiftUI + +@main +struct OpenHABApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @Environment(\.scenePhase) private var scenePhase + @State private var isMigrationComplete = false + + var body: some Scene { + WindowGroup { + if isMigrationComplete { + OpenHABTabRootView() + } else { + SplashView() + .task { + await Preferences.migratePreferences() + isMigrationComplete = true + // Defer non-essential initialization to after first frame renders + try? await Task.sleep(for: .milliseconds(100)) + appDelegate.performDeferredSetup() + } + } + } + .onChange(of: scenePhase) { _, newPhase in + switch newPhase { + case .inactive: + NotificationCenter.default.post(name: .disableScreenSaver, object: nil) + case .active: + appDelegate.startScreenSaverMonitoring() + default: + break + } + } + } +} diff --git a/openHAB/OpenHABNavigationController.swift b/openHAB/OpenHABNavigationController.swift deleted file mode 100644 index 0e07a83f4..000000000 --- a/openHAB/OpenHABNavigationController.swift +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -// Copyright (c) 2010-2025 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import UIKit - -/// This is a wrapper around UINavigationController that allows the status bar to be hidden or shown. -/// It is used to control the status bar for the entire app and is loaded from the Main storyboard entry point. -class OpenHABNavigationController: UINavigationController { - override var childForStatusBarHidden: UIViewController? { nil } - - override var prefersStatusBarHidden: Bool { - Preferences.shared.hideStatusBar - } - - override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade } -} diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift deleted file mode 100644 index b3f12e336..000000000 --- a/openHAB/OpenHABRootViewController.swift +++ /dev/null @@ -1,920 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import AVFoundation -import Combine -import FirebaseCrashlytics -import Foundation -import OpenHABCore -import os.log -import SafariServices -import SFSafeSymbols -import SideMenu -import SwiftMessages -import SwiftUI -import UIKit - -enum TargetController { - case webview - case settings - case sitemap(String) - case notifications - case browser(String) - case tile(String) - case homeSelection -} - -protocol ModalHandler: AnyObject { - func modalDismissed(to: TargetController) -} - -// swiftlint:disable type_body_length -class OpenHABRootViewController: UIViewController { - var currentView: OpenHABViewController! - var isDemoMode = false - var cancellables = Set() - private let currentViewState = CurrentViewState() - private var streamTask: Task? - - private var apsRegistrationData: [AnyHashable: Any]? - - private var networkStatusButton: UIButton = .init(type: .custom) - - private lazy var webViewController: OpenHABWebViewController = { - let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main) - var viewController = storyboard.instantiateViewController(withIdentifier: "OpenHABWebViewController") as! OpenHABWebViewController - return viewController - }() - - private lazy var sitemapViewController: OpenHABSitemapViewController = { - let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main) - var viewController = storyboard.instantiateViewController(withIdentifier: "OpenHABPageViewController") as! OpenHABSitemapViewController - return viewController - }() - - private var activeConnection: ConnectionInfo? - private let synthesizer = AVSpeechSynthesizer() - - override func viewDidLoad() { - super.viewDidLoad() - Logger.viewController.info("OpenHABRootViewController viewDidLoad") - setupSideMenu() - addConnectionStatusIndication() - - NotificationCenter.default.addObserver(self, selector: #selector(OpenHABRootViewController.handleApsRegistration(_:)), name: NSNotification.Name("apsRegistered"), object: nil) - - if Crashlytics.crashlytics().didCrashDuringPreviousExecution(), !Preferences.shared.sendCrashReports { - let alertController = UIAlertController(title: NSLocalizedString("crash_detected", comment: "").capitalized, message: NSLocalizedString("crash_reporting_info", comment: ""), preferredStyle: .alert) - alertController.addAction( - UIAlertAction(title: NSLocalizedString("activate", comment: ""), style: .default) { _ in - Preferences.shared.sendCrashReports = true - Crashlytics.crashlytics().sendUnsentReports() - } - ) - alertController.addAction( - UIAlertAction(title: NSLocalizedString("privacy_policy", comment: ""), style: .default) { [weak self] _ in - let webViewController = SFSafariViewController(url: URL.privacyPolicy) - webViewController.configuration.barCollapsingEnabled = true - self?.present(webViewController, animated: true) - } - ) - alertController.addAction( - UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .default) { _ in - Crashlytics.crashlytics().deleteUnsentReports() - } - ) - present(alertController, animated: true) - } - - #if DEBUG - if ProcessInfo.processInfo.environment["UITest"] != nil { - // this is here to continue to make existing tests work, need to look at this later - Preferences.shared.modifyActiveHome { homePreferences in - homePreferences.demomode = true - } - } - // setup accessibilityIdentifiers for UITest - navigationItem.rightBarButtonItem?.accessibilityIdentifier = "HamburgerButton" - #endif - // save this so we know if its changed later - isDemoMode = Preferences.shared.currentHomePreferences.demomode - switchToSavedView() - setupTracker() - startSSEListening() - } - - override func viewWillAppear(_ animated: Bool) { - Logger.viewController.info("OpenHABRootController viewWillAppear") - super.viewWillAppear(animated) - navigationController?.navigationBar.prefersLargeTitles = true - // if we have turned demo mode off/on, reset view - if isDemoMode != Preferences.shared.currentHomePreferences.demomode { - switchToSavedView() - isDemoMode = Preferences.shared.currentHomePreferences.demomode - } - } - - private func startSSEListening() { - Task { - await ItemEventStream.startMonitoringNetwork() - } - Logger.viewController.debug("Starting SSE") - streamTask = Task { [weak self] in - guard let self else { return } - for await msg in await ItemEventStream.shared.stream() { - await MainActor.run { self.handleSSEMessage(msg) } - } - } - } - - private func handleSSEMessage(_ msg: StreamOutput) { - switch msg { - case .connected: - Logger.viewController.debug("SSE Connected") - case let .disconnected(err): - Logger.viewController.debug("SSE Disconnected: \(err?.localizedDescription ?? "nil")") - case let .event(sm): - switch sm { - case let .state(item, state): - Logger.viewController.debug("SSE Item \(item): \(state)") - handleNotificationInternal(state) - case let .ready(uuid, _): - Logger.viewController.debug("SSE Session UUID: \(uuid)") - case let .unknown(raw): - Logger.viewController.debug("SSE Unknown: \(raw)") - default: - break - } - } - } - - private func addConnectionStatusIndication() { - MainActorNetworkTracker.shared.$status - .receive(on: DispatchQueue.main) - .sink { [weak self] status in - guard let self, let currentView else { - return - } - Logger.viewController.info("OpenHABWebViewController tracker status \(status.rawValue)") - let retryButtonTitle: String = NSLocalizedString("retry", comment: "retry connection") - switch status { - case .started: - currentView.showPopupMessage(seconds: -1, title: NSLocalizedString("no_connection_will_reconnect", comment: ""), message: "", theme: .warning, buttonTitle: retryButtonTitle) { - Task { - await NetworkTracker.shared.restartTracking() - } - } - case .connecting: - currentView.showPopupMessage(seconds: 60, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) - case .connected: - currentView.hidePopupMessages() - case .stopped: - let error: String = NSLocalizedString("error", comment: "") - let no_network: String = NSLocalizedString("network_not_available", comment: "") - currentView.showPopupMessage(seconds: -1, title: error, message: no_network, theme: .error, buttonTitle: retryButtonTitle) { - Task { - await NetworkTracker.shared.restartTracking() - } - } - } - } - .store(in: &cancellables) - } - - private func setupTracker() { - let serverInfo = Preferences.shared.$currentHomePreferences - - // Register for certificate trust notifications - NotificationCenter.default.addObserver( - forName: .evaluateServerTrust, - object: nil, - queue: nil - ) { [weak self] notification in - guard - let summary = notification.userInfo?["summary"] as? String, - let domain = notification.userInfo?["domain"] as? String, - let delegate = notification.object as? HTTPClientDelegate - else { - return - } - - Task { @MainActor in - self?.handleCertificateTrust( - summary: summary, - domain: domain, - delegate: delegate, - messageTemplateKey: "ssl_certificate_invalid" - ) - } - } - - NotificationCenter.default.addObserver( - forName: .evaluateCertificateMismatch, - object: nil, - queue: nil - ) { [weak self] notification in - guard - let summary = notification.userInfo?["summary"] as? String, - let domain = notification.userInfo?["domain"] as? String, - let delegate = notification.object as? HTTPClientDelegate - - else { - return - } - - Task { @MainActor in - self?.handleCertificateTrust( - summary: summary, - domain: domain, - delegate: delegate, - messageTemplateKey: "ssl_certificate_no_match" - ) - } - } - - NotificationCenter.default.addObserver( - forName: .acceptedServerCertificatesChanged, - object: nil, - queue: nil - ) { _ in - Task { @MainActor in - await WatchMessageService.singleton.syncPreferencesToWatch() - await NetworkTracker.shared.restartTracking() - } - } - - serverInfo.debounce(for: .milliseconds(500), scheduler: RunLoop.main) // ensures if multiple values are saved, we get called once - .sink { homeSettings in - let localConnectionConfig = homeSettings.localConnectionConfig - let remoteConnectionConfig = homeSettings.remoteConnectionConfig - let demomode = homeSettings.demomode - let sseCommandItem = homeSettings.sseCommandItem - - Task { - if demomode { - await NetworkTracker.shared.startTracking(connectionConfigurations: [ - ConnectionConfiguration( - url: "https://demo.openhab.org", - username: "", - password: "", - priority: 0 - ) - ]) - } else { - await NetworkTracker.shared.startTracking(connectionConfigurations: [ - localConnectionConfig, - remoteConnectionConfig - ]) - await ItemEventStream.trackItems(sseCommandItem.isEmpty ? [] : [sseCommandItem]) - } - } - } - .store(in: &cancellables) - - MainActorNetworkTracker.shared.$activeConnection - .receive(on: DispatchQueue.main) - .sink { [weak self] activeConnection in - if let activeConnection { - self?.activeConnection = activeConnection - } - } - .store(in: &cancellables) - } - - private func setupSideMenu() { - let hamburgerButtonItem: UIBarButtonItem - let imageConfig = UIImage.SymbolConfiguration(textStyle: .largeTitle) - let buttonImage = UIImage(systemSymbol: .line3Horizontal, withConfiguration: imageConfig) - let button = UIButton(type: .custom) - button.setImage(buttonImage, for: .normal) - button.addTarget(self, action: #selector(OpenHABRootViewController.rightDrawerButtonPress(_:)), for: .touchUpInside) - hamburgerButtonItem = UIBarButtonItem(customView: button) - hamburgerButtonItem.customView?.heightAnchor.constraint(equalToConstant: 30).isActive = true - navigationItem.setRightBarButton(hamburgerButtonItem, animated: true) - - // Define the menus - - let presentationStyle: SideMenuPresentationStyle = .viewSlideOutMenuIn - presentationStyle.presentingEndAlpha = 1 - presentationStyle.onTopShadowOpacity = 0.5 - var settings = SideMenuSettings() - settings.presentationStyle = presentationStyle - settings.statusBarEndAlpha = 0 - - SideMenuManager.default.rightMenuNavigationController?.settings = settings - - let networkTracker = MainActorNetworkTracker.shared - let drawerView = DrawerView { mode in - self.handleDismiss(mode: mode) - } - .environmentObject(networkTracker) - .environmentObject(currentViewState) - let hostingController = UIHostingController(rootView: drawerView) - let menu = SideMenuNavigationController(rootViewController: hostingController) - - SideMenuManager.default.rightMenuNavigationController = menu - - // Enable gestures. The left and/or right menus must be set up above for these to work. - // Note that these continue to work on the Navigation Controller independent of the View Controller it displays! - SideMenuManager.default.addPanGestureToPresent(toView: navigationController!.navigationBar) - SideMenuManager.default.addScreenEdgePanGesturesToPresent(toView: navigationController!.view, forMenu: .right) - } - - private func openTileURL(_ urlString: String) { - // Use SFSafariViewController in SwiftUI with UIViewControllerRepresentable - // Dependent on $OPENHAB_CONF/services/runtime.cfg - // Can either be an absolute URL, a path (sometimes malformed) - guard !urlString.isEmpty else { return } - - let url: URL? - if urlString.hasPrefix("http") || urlString.hasPrefix("https") { - url = URL(string: urlString) - } else { - guard let rootUrl = activeConnection?.configuration.url else { - Logger.viewController.error("openTileURL failed: no active connection URL") - return - } - url = Endpoint.resource(openHABRootUrl: rootUrl, path: urlString.prepare()).url - } - openURL(url: url) - } - - private func openURL(url: URL?) { - if let url { - let config = SFSafariViewController.Configuration() - config.entersReaderIfAvailable = true - let vc = SFSafariViewController(url: url, configuration: config) - present(vc, animated: true) - } - } - - private func handleDismiss(mode: TargetController) { - switch mode { - case .webview: - // Handle webview navigation or state update - Logger.viewController.debug("Dismissed to WebView") - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) - switchView(target: .webview) - case .settings: - Logger.viewController.debug("Dismissed to Settings") - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .settings) - } - case let .sitemap(sitemap): - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .sitemap(sitemap)) - } - case .notifications: - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .notifications) - } - case let .browser(urlString): - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .browser(urlString)) - } - case let .tile(urlString): - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .tile(urlString)) - } - case .homeSelection: - Logger.viewController.debug("Dismissed to Home Selection") - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .homeSelection) - } - } - } - - @objc - func rightDrawerButtonPress(_ sender: Any?) { - showSideMenu() - } - - @objc - func handleApsRegistration(_ note: Notification?) { - Logger.viewController.info("handleApsRegistration") - apsRegistrationData = note?.userInfo - subscribeToOpenhabConnectionChanges() - } - - private func subscribeToOpenhabConnectionChanges() { - struct UuidWithConnection: Hashable, Equatable { - let uuid: UUID - let connection: ConnectionConfiguration // not only URL, because auth and certs might be relevant for establishing the connection - } - - let storedOpenHabConnections = Preferences.shared.$storedHomes - .debounce(for: .seconds(1), scheduler: RunLoop.main) // avoid overexcited registrations / deregistrations in batch updates - .map { updatedPreferences in // we want to recognize changes in the OpenHab URLs for any of the homes - Set(updatedPreferences.compactMap { storedWithUuid in - let (uuid, homeConfig) = storedWithUuid - guard let connection = Preferences.shared.getNotificationConnection(of: homeConfig) else { return nil } - return UuidWithConnection(uuid: uuid, connection: connection) - }) - } - - // create a tuple that lets us inspect the previous value - let connectionsWithPreviousValues = storedOpenHabConnections - .scan((previous: Set(), current: Set())) { previous, current in - (previous: previous.current, current: current) - } - - let differences = connectionsWithPreviousValues.map { (previous, current) in // diff set of previous and current OpenHab URLs - (newValues: current.subtracting(previous), deletedValues: previous.subtracting(current)) - } - - let openhabConnectionSubscription = differences.sink { [weak self] diff in - Logger.viewController.info("openhabConnectionSubscription updated") - for newHome in diff.newValues { - Logger.viewController.info("openhabConnectionSubscription uuid \(newHome.uuid) registering for push notifications ") - self?.registerHome(uuid: newHome.uuid, connection: newHome.connection) - } - for deletedHome in diff.deletedValues { - // TODO: implement deregistration - Logger.viewController.warning("APNS Deregistration is missing (wanted to deregister \(deletedHome.connection.url))") - } - } - - cancellables.insert(openhabConnectionSubscription) - } - - private func registerHome(uuid: UUID, connection: ConnectionConfiguration) { - guard let apsRegistrationData else { - Logger.viewController.fault("Cannot register homes for push notifications, no notification registration data available") - return - } - guard let deviceId = apsRegistrationData["deviceId"] as? String, - let deviceToken = apsRegistrationData["deviceToken"] as? String, - let deviceName = apsRegistrationData["deviceName"] as? String else { - return - } - Logger.viewController.info("Registering notifications with \(connection.url)") - _ = registerHome(uuid, connection, deviceToken, deviceId, deviceName) - } - - private func registerHome(_ uuid: UUID, _ config: ConnectionConfiguration, _ deviceToken: String, _ deviceId: String, _ deviceName: String) -> Task { - Task { - do { - let client = HTTPClient(connectionConfiguration: config) - if let cloudUserId = try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) { - Preferences.shared.setCloudUserId(cloudUserId, for: uuid) - Logger.viewController.info("my.openHAB registration succeeded with cloudUserId \(cloudUserId)") - } - Logger.viewController.info("my.openHAB registration succeeded without cloudUserId") - } catch { - Logger.viewController.error("my.openHAB registration failed \(error.localizedDescription)") - } - } - } - - func handleNotification(action: String?, cloudUserId: String?) { - guard let action else { return } - - Logger.viewController.info("handleNotification cloudUserId: \(cloudUserId ?? "")") - - Task { - if let cloudUserId, let targetHome = Preferences.shared.storedHome(forCloudUserId: cloudUserId), Preferences.shared.currentHomePreferences.remoteConnectionConfig.cloudUserId != cloudUserId { - // if we need to switch homes, disconnnect the tracking first, and update preferences - await NetworkTracker.shared.stopTracking() - Logger.viewController.info("Switching to home \(targetHome.id)") - Preferences.shared.switchActiveHome(to: targetHome.id) - } - // if the app was woken from a fully stopped state, network tracking might not be active yet - await NetworkTracker.shared.startTracking(connectionConfigurations: - [ - Preferences.shared.currentHomePreferences.localConnectionConfig, - Preferences.shared.currentHomePreferences.remoteConnectionConfig - ] - ) - _ = await NetworkTracker.shared.waitForActiveConnection() - handleNotificationInternal(action) - } - } - - private func handleNotificationInternal(_ action: String?) { - Logger.viewController.info("handleNotificationInternal: \(action ?? "")") - - guard let action else { return } - let actionParts = action.split(separator: ":") - let cmd = actionParts.dropFirst().joined(separator: ":") - - switch actionParts[0] { - case "ui": - uiCommandAction(cmd) - case "command": - sendCommandAction(cmd) - case "http": - httpCommandAction(action) - case "app": - appCommandAction(cmd) - case "rule": - ruleCommandAction(cmd) - case "device": - deviceAction(cmd) - default: - return - } - } - - // Helper function to safely call the completion handler on the main thread - private func callCompletionHandler(_ completionHandler: (() -> Void)?) { - if let completionHandler { - DispatchQueue.main.async { - completionHandler() - } - } - } - - private func uiCommandAction(_ command: String) { - Logger.viewController.info("navigateCommandAction: \(command)") - let regexPattern = /^(\/basicui\/app\\?.*|\/.*|.*)$/ - if let firstMatch = command.firstMatch(of: regexPattern) { - let path = String(firstMatch.1) - Logger.viewController.info("navigateCommandAction path: \(path)") - if path.starts(with: "/basicui/app?") { - Logger.viewController.info("Navigating to sitemap target") - let defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap - guard let urlComponents = URLComponents(string: path) else { - Logger.viewController.warning("No parameters for specifying sitemap or widget to navigate to") - if currentView != sitemapViewController { - switchView(target: .sitemap(defaultSitemap)) - } - return - } - let queryItems = urlComponents.queryItems - let sitemap = queryItems?.first { $0.name == "sitemap" }?.value - let widgetId = queryItems?.first { $0.name == "w" }?.value - if currentView != sitemapViewController { - switchView(target: .sitemap(sitemap ?? defaultSitemap)) - } - if let sitemap { - Task { @MainActor in - await sitemapViewController.pushSitemap(name: sitemap, path: widgetId) - } - } - } else { - Logger.viewController.info("Navigating to webview target") - if currentView != webViewController { - switchView(target: .webview) - } - if path.starts(with: "/") { - Task { - // have the webview load this path itself - webViewController.loadWebView(force: true, path: path) - } - } else { - // have the mainUI handle the navigation - webViewController.navigateCommand(path) - } - } - } else { - Logger.viewController.error("Invalid regex: \(command)") - } - } - - private func sendCommandAction(_ action: String) { - let components = action.split(separator: ":") - guard components.count == 2 else { - return - } - - let itemName = String(components[0]) - let itemCommand = String(components[1]) - Task { - do { - Logger.viewController.info("Sending command") - try await NetworkTracker.shared.send(to: itemName, command: itemCommand) - } catch NetworkTrackerError.noActiveConnection { - displayErrorNotification("Could not find server") - } catch { - displayErrorNotification("Failed to establish a connection: \(error.localizedDescription)") - Logger.viewController.error("Could not send data \(error.localizedDescription)") - } - } - } - - private func displayErrorNotification(_ message: String) { - let content = UNMutableNotificationContent() - content.title = "Could not send command" - content.body = message - content.sound = UNNotificationSound.default - - // Create the request - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - - // Schedule the request with the notification center - // no error handler because it only printed and tended to crash in swift6 - UNUserNotificationCenter.current().add(request) - } - - private func httpCommandAction(_ command: String) { - if let url = URL(string: command) { - let vc = SFSafariViewController(url: url) - present(vc, animated: true) - } - } - - private func appCommandAction(_ command: String) { - let pairs = command.split(separator: ",") - for pair in pairs { - let keyValue = pair.split(separator: "=", maxSplits: 1) - if keyValue[0] == "ios" { - if let url = URL(string: String(keyValue[1])) { - Logger.viewController.error("appCommandAction opening \(String(keyValue[0])) \(String(keyValue[1]))") - UIApplication.shared.open(url) - return - } - } - } - } - - private func deviceAction(_ action: String) { - let cmdParts = action.split(separator: ":") - if cmdParts.isEmpty { return } - let command = cmdParts[0].lowercased() - let arg1 = cmdParts.count > 1 ? cmdParts[1].lowercased() : "" - switch command { - case "screensaver": - switch arg1 { - case "activate": - NotificationCenter.default.post(name: .activateScreenSaver, object: nil) - case "disable": - NotificationCenter.default.post(name: .disableScreenSaver, object: nil) - case "wake": - NotificationCenter.default.post(name: .wakeScreenSaver, object: nil) - default: - break - } - case "idletimer": - switch arg1 { - case "enable": - UIApplication.shared.isIdleTimerDisabled = false - case "disable": - UIApplication.shared.isIdleTimerDisabled = true - default: - break - } - case "brightness": - if let value = Double(arg1) { - let target = min(max(value, 0.0), 1.0) - UIScreen.main.brightness = target - } - case "tts": - func normalizeVoiceName(from input: String) -> String { - input - .lowercased() - .components(separatedBy: CharacterSet.alphanumerics.inverted) - .joined() - } - - let utterance = AVSpeechUtterance(string: arg1) - if cmdParts.count > 3 { - Logger.viewController.debug("Filtering voice \(cmdParts[2]) \(cmdParts[3])") - let voice = AVSpeechSynthesisVoice.speechVoices().filter { $0.language.lowercased() == cmdParts[2].lowercased() && normalizeVoiceName(from: $0.name) == normalizeVoiceName(from: String(cmdParts[3])) } - if !voice.isEmpty { - Logger.viewController.debug("Setting custom voice \(voice[0].name)") - utterance.voice = voice[0] - } - } else if cmdParts.count > 2 { - utterance.voice = AVSpeechSynthesisVoice(language: String(cmdParts[2])) - } - synthesizer.speak(utterance) - default: - break - } - } - - private func ruleCommandAction(_ command: String) { - let components = command.split(separator: ":", maxSplits: 2) - - guard !components.isEmpty else { - Logger.viewController.warning("No rule to execute found in action") - return - } - - let uuid = String(components[0]) - let propertiesString = if components.count > 1 { String(components[1]) } else { "" } - - let propertyPairs = propertiesString.split(separator: ",") - var properties: [String: String] = [:] - - for pair in propertyPairs { - let keyValue = pair.split(separator: "=", maxSplits: 1) - if keyValue.count == 2 { - let key = String(keyValue[0]) - let value = String(keyValue[1]) - properties[key] = value - } - } - Task { - do { - Logger.viewController.error("Sending command") - try await NetworkTracker.shared.runNow(ruleUID: uuid, payload: properties) - Logger.viewController.info("Request succeeded") - } catch let error as NetworkTrackerError { - displayErrorNotification("\(error.localizedDescription)") - } catch { - Logger.viewController.error("Could not send data \(error.localizedDescription)") - displayErrorNotification("Request to server failed: \(error.localizedDescription)") - } - } - } - - func showSideMenu() { - Logger.viewController.info("OpenHABRootViewController showSideMenu") - if let menu = SideMenuManager.default.rightMenuNavigationController { - // don't try and push an already visible menu less you crash the app - dismiss(animated: false) { - var topMostViewController: UIViewController? = - UIApplication.shared.connectedScenes.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }.last { $0.isKeyWindow }?.rootViewController - - while let presentedViewController = topMostViewController?.presentedViewController { - topMostViewController = presentedViewController - } - - guard let presenter = topMostViewController else { - // swiftformat:disable:next redundantSelf - Logger.viewController.error("No valid view controller found to present side menu") - return - } - - // Avoid trying to present the menu on itself - if presenter == menu { - // swiftformat:disable:next redundantSelf - Logger.viewController.error("Cannot present side menu on itself") - return - } - - presenter.present(menu, animated: true) - } - } - } - - private func addView(viewController: UIViewController) { - addChild(viewController) - view.insertSubview(viewController.view, belowSubview: networkStatusButton) - viewController.view.frame = view.bounds - viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - viewController.didMove(toParent: self) - } - - private func removeView(viewController: UIViewController) { - viewController.willMove(toParent: nil) - viewController.view.removeFromSuperview() - viewController.removeFromParent() - } - - private func switchView(target: TargetController) { - let targetView: OpenHABViewController - - switch target { - case let .sitemap(sitemap): - Preferences.shared.modifyActiveHome { preferences in - preferences.defaultSitemap = sitemap - } - targetView = sitemapViewController - case .webview: - targetView = webViewController - default: - return - } - - if currentView != targetView { - if let currentView { - removeView(viewController: currentView) - } - addView(viewController: targetView) - currentView = targetView - - // Update webview active state - currentViewState.isWebViewActive = (targetView == webViewController) - - // Don't save our view in demo mode - if !Preferences.shared.currentHomePreferences.demomode { - Preferences.shared.modifyActiveHome { - $0.defaultView = currentView.viewName() - } - } - } else { - // if we hit the menu item again while on the view, trigger a reload - currentView.reloadView() - } - - // Make sure we reset any views that may be pushed - navigationController?.popToRootViewController(animated: true) - } - - private func switchToSavedView() { - if Preferences.shared.currentHomePreferences.demomode { - switchView(target: .sitemap("demo")) - } else { - let defaultView = Preferences.shared.currentHomePreferences.defaultView - let defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap - Logger.viewController.info("OpenHABRootViewController switchToSavedView \(defaultView == "sitemap" ? "sitemap/\(defaultSitemap)" : "web")") - switchView(target: defaultView == "sitemap" ? .sitemap(defaultSitemap) : .webview) - } - } - - @MainActor - @objc func handleCertificateTrust(_ notification: Notification, message: String) { - guard let summary = notification.userInfo?["summary"] as? String, - let domain = notification.userInfo?["domain"] as? String, - let delegate = notification.object as? HTTPClientDelegate else { return } - let title = NSLocalizedString("ssl_certificate_warning", comment: "") - let message = String(format: NSLocalizedString(message, comment: ""), summary, domain) - DispatchQueue.main.async { - // Show alert to user - let alert = UIAlertController( - title: title, - message: message, - preferredStyle: .alert - ) - - alert.addAction(UIAlertAction(title: "Always", style: .default) { _ in - delegate.completeEvaluation(.permitAlways) - }) - - alert.addAction(UIAlertAction(title: "Once", style: .default) { _ in - delegate.completeEvaluation(.permitOnce) - }) - - alert.addAction(UIAlertAction(title: "Deny", style: .cancel) { _ in - delegate.completeEvaluation(.deny) - }) - - self.present(alert, animated: true) - } - } - - @MainActor - @objc - func handleCertificateTrust(summary: String, domain: String, delegate: HTTPClientDelegate, messageTemplateKey: String) { - let title = NSLocalizedString("ssl_certificate_warning", comment: "") - let message = String(format: NSLocalizedString(messageTemplateKey, comment: ""), summary, domain) - - let alert = UIAlertController( - title: title, - message: message, - preferredStyle: .alert - ) - - alert.addAction(UIAlertAction(title: "Always", style: .default) { _ in - delegate.completeEvaluation(.permitAlways) - }) - - alert.addAction(UIAlertAction(title: "Once", style: .default) { _ in - delegate.completeEvaluation(.permitOnce) - }) - - alert.addAction(UIAlertAction(title: "Deny", style: .cancel) { _ in - delegate.completeEvaluation(.deny) - }) - - present(alert, animated: true) - } -} - -// swiftlint:enable type_body_length - -// MARK: - UISideMenuNavigationControllerDelegate - -extension OpenHABRootViewController: SideMenuNavigationControllerDelegate { - nonisolated func sideMenuWillAppear(menu: SideMenuNavigationController, animated: Bool) { - Logger.viewController.info("OpenHABRootViewController sideMenuWillAppear") - } -} - -// MARK: - ModalHandler - -extension OpenHABRootViewController: ModalHandler { - nonisolated func modalDismissed(to: TargetController) { - Task { @MainActor in - switch to { - case .sitemap: - switchView(target: to) - case .settings: - let hostingController = UIHostingController(rootView: SettingsView()) - navigationController?.pushViewController(hostingController, animated: true) - case .notifications: - let hostingController = UIHostingController(rootView: NotificationsView()) - navigationController?.pushViewController(hostingController, animated: true) - case .webview: - switchView(target: to) - case .browser: - break - case let .tile(urlString): - openTileURL(urlString) - case .homeSelection: - let hostingController = UIHostingController(rootView: HomeSelectionView()) - navigationController?.pushViewController(hostingController, animated: true) - } - } - } -} diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift deleted file mode 100644 index 9ee2a644a..000000000 --- a/openHAB/OpenHABSitemapViewController.swift +++ /dev/null @@ -1,963 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import AVFoundation -import AVKit -import Combine -import Foundation -import Kingfisher -import OpenAPIRuntime -import OpenHABCore -import os.log -import SafariServices -import SFSafeSymbols -import SwiftMessages -import SwiftUI -import UIKit - -class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDelegate { - var pageUrl = "" - private var iconType: IconType = .svg - var openHABRootUrl = "" - - private var activeConnectionInfo: ConnectionInfo? - - private var defaultSitemap = "" - private var pageId = "" - private var idleOff = false - private var showSearchField = false - private var sitemaps: [OpenHABSitemap] = [] - private var currentPage: OpenHABPage? - private var pageNetworkStatus: NetworkStatus? - private var pageNetworkStatusAvailable = false - private var refreshControl: UIRefreshControl? - private var filteredPage: OpenHABPage? - private let searchController = UISearchController(searchResultsController: nil) - private var isUserInteracting = false - private var isWaitingToReload = false - private var isNavigatingToSelection = false - private var isNavigatingToLinkedPage = false - - private var pageHandlingTask: Task? - - private var pageLoader: PageLoader? - - var relevantPage: OpenHABPage? { - if isFiltering { - filteredPage - } else { - currentPage - } - } - - var sitemapViewController: OpenHABSitemapViewController? - - // MARK: - Private instance methods - - var searchBarIsEmpty: Bool { - // Returns true if the text is empty or nil - searchController.searchBar.text?.isEmpty ?? true - } - - var isFiltering: Bool { - searchController.isActive && !searchBarIsEmpty - } - - private var openAPIService: OpenAPIService? - - @IBOutlet private var widgetTableView: UITableView! - - override func viewDidLoad() { - super.viewDidLoad() - Logger.sitemapViewController.info("OpenHABSitemapViewController viewDidLoad") - - registerTableViewCells() - configureTableView() - widgetTableView.tableFooterView = UIView() - - refreshControl = UIRefreshControl() - refreshControl?.addTarget(self, action: #selector(handleRefresh(_:)), for: .valueChanged) - widgetTableView.refreshControl = refreshControl - - // Load showSearchField settings - showSearchField = Preferences.shared.applicationPreferences.showSearchField - - if showSearchField { - // Setup search controller - searchController.searchResultsUpdater = self - searchController.obscuresBackgroundDuringPresentation = false - searchController.searchBar.autocapitalizationType = .none - searchController.searchBar.delegate = self - searchController.delegate = self - searchController.searchBar.placeholder = NSLocalizedString("search_items", comment: "") - definesPresentationContext = true - - // Assign to navigation item (must be in navigation stack) - navigationItem.searchController = searchController - navigationItem.hidesSearchBarWhenScrolling = false - } else { - navigationItem.searchController = nil - } - - // Setup active connection - guard let config = activeConnectionInfo?.configuration else { return } - do { - openAPIService = try OpenAPIService(connectionConfiguration: config) - } catch { - Logger.sitemapViewController.error("Failed to create OpenAPIService: \(error.localizedDescription)") - } - - if let service = openAPIService { - pageLoader = PageLoader(service: service, pageId: "", defaultSitemap: "") - } - - #if DEBUG - widgetTableView.accessibilityIdentifier = "OpenHABSitemapViewControllerWidgetTableView" - #endif - } - - override func viewDidAppear(_ animated: Bool) { - Logger.sitemapViewController.info("OpenHABSitemapViewController viewDidAppear") - super.viewDidAppear(animated) - - // Load showSearchField settings - showSearchField = Preferences.shared.applicationPreferences.showSearchField - - if showSearchField { - if parent?.navigationItem.searchController !== searchController { - parent?.navigationItem.searchController = searchController - parent?.navigationItem.hidesSearchBarWhenScrolling = true - } - } else { - parent?.navigationItem.searchController = nil - } - } - - override func viewWillAppear(_ animated: Bool) { - Logger.sitemapViewController.info("OpenHABSitemapViewController viewWillAppear") - super.viewWillAppear(animated) - - navigationController?.navigationBar.prefersLargeTitles = true - - // Load settings into local properties - loadSettings() - // Disable idle timeout if configured in settings - if idleOff { - UIApplication.shared.isIdleTimerDisabled = true - } - - // if pageUrl is empty, it means we are the first opened OpenHABSitemapViewController - if pageUrl.isEmpty { - sitemapViewController = self -// if navigationController?.viewControllers.first == self { - // This is the first sitemap opened - if currentPage != nil { - currentPage?.widgets = [] - widgetTableView.reloadData() - // Clear shared image cache when sitemap changes - NewImageUITableViewCell.clearSharedCache() - } - Logger.sitemapViewController.info("OpenHABSitemapViewController pageUrl is empty, this is first launch") - startWatchingActiveServer() - } else { - // Skip restarting if polling task is still active (e.g., returning from SelectionView) - if let task = pageHandlingTask, !task.isCancelled { - Logger.sitemapViewController.info("OpenHABSitemapViewController polling still active, skipping restart") - } else if !pageNetworkStatusChanged() || !pageId.isEmpty { - // swiftformat:disable:next redundantSelf - Logger.sitemapViewController.info("OpenHABSitemapViewController pageUrl \(self.pageUrl)") - startPageHandling() - startWatchingActiveServer() - } else { - Logger.sitemapViewController.info("OpenHABSitemapViewController network status changed while it was not appearing") - restart() - } - } - - ImageDownloader.default.authenticationChallengeResponder = self - } - - override func viewWillDisappear(_ animated: Bool) { - Logger.sitemapViewController.info("OpenHABSitemapViewController viewWillDisappear") - - trackerCancellables.removeAll() - // Keep polling alive when pushing to SelectionView or LinkedPage to preserve scroll position - if !isNavigatingToSelection, !isNavigatingToLinkedPage { - stopAllTasks() - } - isNavigatingToSelection = false - isNavigatingToLinkedPage = false - - super.viewWillDisappear(animated) - - if #unavailable(iOS 13.0) { - if animated, !searchController.isActive, !searchController.isEditing, navigationController.map({ $0.viewControllers.last != self }) ?? false, - let searchBarSuperview = searchController.searchBar.superview, - let searchBarHeightConstraint = searchBarSuperview.constraints.first(where: { - $0.firstAttribute == .height - && $0.secondItem == nil - && $0.secondAttribute == .notAnAttribute - && $0.constant > 0 - }) { - UIView.performWithoutAnimation { - searchBarHeightConstraint.constant = 0 - searchBarSuperview.superview?.layoutIfNeeded() - } - } - } - parent?.navigationItem.searchController = nil - } - - @objc - override func didEnterBackground(_ notification: Notification?) { - super.didEnterBackground(notification) - Logger.sitemapViewController.info("OpenHABSitemapViewController didEnterBackground") - } - - @objc - override func didBecomeActive(_ notification: Notification?) { - super.didBecomeActive(notification) - Logger.sitemapViewController.info("OpenHABSitemapViewController didBecomeActive") - if isViewLoaded, view.window != nil, !pageUrl.isEmpty { - if !pageNetworkStatusChanged() { - Logger.sitemapViewController.info("OpenHABSitemapViewController isViewLoaded, restarting network activity") - startPageHandling() - } else { - Logger.sitemapViewController.info("OpenHABSitemapViewController network status changed while it was inactive") - restart() - } - } - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - widgetTableView.reloadData() - } - - func startWatchingActiveServer() { - let task = Task { - for await activeConnection in MainActorNetworkTracker.shared.$activeConnection.values { - if let activeConnection { - await MainActor.run { - Logger.sitemapViewController.info("OpenHABSitemapViewController tracker URL \(activeConnection.configuration.url)") - self.openHABRootUrl = activeConnection.configuration.url - self.activeConnectionInfo = activeConnection - self.selectSitemap() - } - break - } - } - } - activeTasks.insert(task) // Store the task for cancellation - } - - func stopAllTasks() { - for task in activeTasks { - task.cancel() - } - activeTasks.removeAll() - pageHandlingTask?.cancel() - pageHandlingTask = nil - } - - override func reloadView() { - defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap - Logger.sitemapViewController.debug("Reload view") - selectSitemap() - } - - override func viewName() -> String { - "sitemap" - } -} - -extension OpenHABSitemapViewController: GenericUITableViewCellTouchEventDelegate { - func touchDown() { - isUserInteracting = true - } - - func touchUp() { - isUserInteracting = false - if isWaitingToReload { - widgetTableView.reloadData() - refreshControl?.endRefreshing() - } - isWaitingToReload = false - } -} - -extension OpenHABSitemapViewController { - func configureTableView() { - widgetTableView.dataSource = self - widgetTableView.delegate = self - } - - func registerTableViewCells() { - widgetTableView.register(cellType: MapViewTableViewCell.self) - widgetTableView.register(cellType: NewImageUITableViewCell.self) - widgetTableView.register(cellType: VideoUITableViewCell.self) - } - - @objc - func handleRefresh(_ refreshControl: UIRefreshControl?) { - startPageHandling() - widgetTableView.reloadData() - widgetTableView.layoutIfNeeded() - } - - func restart() { - if sitemapViewController == self { - Logger.sitemapViewController.info("I am a rootViewController!") - - } else { - sitemapViewController?.pageUrl = "" - navigationController?.popToRootViewController(animated: true) - } - } - - func relevantWidget(indexPath: IndexPath) -> OpenHABWidget? { - relevantPage?.widgets[safe: indexPath.row] - } - - func updateWidgetTableView() { - UIView.performWithoutAnimation { - widgetTableView.beginUpdates() - widgetTableView.endUpdates() - } - } - - func updateUI(with page: OpenHABPage) { - currentPage = page - - if showSearchField, isFiltering { - filterContentForSearchText(searchController.searchBar.text) - } - - currentPage?.sendCommand = { [weak self] item, command in - self?.sendCommand(item, commandToSend: command) - } - - // isUserInteracting fixes https://github.com/openhab/openhab-ios/issues/646 where reloading while the user is interacting can have unintended consequences - if !isUserInteracting { - widgetTableView.reloadData() - refreshControl?.endRefreshing() - } else { - isWaitingToReload = true - } - - let pageTitle = currentPage?.title.components(separatedBy: "[")[0] - if let pageTitle, !pageTitle.isEmpty { - parent?.navigationItem.title = pageTitle - } else if !defaultSitemap.isEmpty { - parent?.navigationItem.title = defaultSitemap - } else { - parent?.navigationItem.title = "Sitemap" - } - } - - // Select sitemap - func selectSitemap() { - Task { - do { - guard let activeConnection = MainActorNetworkTracker.shared.activeConnection else { - throw OpenHABSitemapError.noActiveConnection - } - Logger.sitemapViewController.debug("Running selectSitemap for URL: \(activeConnection.configuration.url)") - - openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) - sitemaps = try await openAPIService?.openHABSitemaps() ?? [] - - guard let openAPIService else { - Logger.sitemapViewController.error("Failed to load openAPIService") - return - } - await pageLoader?.updateAPIService(newService: openAPIService) - - switch sitemaps.count { - case 2...: - if !self.defaultSitemap.isEmpty { - if let sitemapToOpen = sitemap(byName: self.defaultSitemap) { - if self.currentPage?.pageId != sitemapToOpen.name { - self.currentPage?.widgets.removeAll() // NOTE: remove all widgets to ensure cells get invalidated - } - pageUrl = sitemapToOpen.homepageLink - startPageHandling() - } else { - showSideMenu() - } - } else { - showSideMenu() - } - case 1: - pageUrl = sitemaps[0].homepageLink - startPageHandling() - case ...0: - showPopupMessage(seconds: 5, title: NSLocalizedString("warning", comment: ""), message: NSLocalizedString("empty_sitemap", comment: ""), theme: .warning) - showSideMenu() - default: break - } - widgetTableView.reloadData() - } catch _ as OpenAPIServiceError { - Logger.sitemapViewController.debug("OpenAPIService Error on OpenHABSitemapViewController") - } catch let error as OpenHABSitemapError { - Logger.sitemapViewController.error("OpenHABSitemap Error: \(error.localizedDescription)") - DispatchQueue.main.async { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: error.localizedDescription, - theme: .error - ) - } - } catch { - Logger.sitemapViewController.error("\(error.localizedDescription)") - DispatchQueue.main.async { - if let urlError = error as? URLError, urlError.code == .clientCertificateRejected { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: NSLocalizedString("ssl_certificate_error", comment: ""), - theme: .error - ) - } else { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: error.localizedDescription, - theme: .error - ) - } - } - } - } - } - - // This is mainly used for navigating to a specific sitemap and path from notifications - func pushSitemap(name: String, path: String?) async { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { - Logger.sitemapViewController.error("pushSitemap: No active connection available") - return - } - - guard name != pageId || path != nil else { - Logger.sitemapViewController.info("pushSitemap: Already at the required sitemap") - return - } - - Logger.sitemapViewController.info("pushSitemap: pushing page") - - guard let baseUrl = URL(string: activeConnection.configuration.url) else { - Logger.sitemapViewController.error("pushSitemap: Invalid base URL") - return - } - - var url = baseUrl.appendingPathComponent("rest") - .appendingPathComponent("sitemaps") - .appendingPathComponent(name) - - if let subpath = path { - url.appendPathComponent(subpath) - } - - guard let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController else { - Logger.sitemapViewController.error("pushSitemap: Failed to instantiate OpenHABSitemapViewController") - return - } - - if let pageId = path { - newViewController.pageId = pageId - } - newViewController.pageUrl = url.absoluteString - newViewController.openHABRootUrl = activeConnection.configuration.url - navigationController?.pushViewController(newViewController, animated: true) - } - - func startPageHandling() { - pageHandlingTask?.cancel() - - guard !pageUrl.isEmpty else { - Logger.sitemapViewController.error("startPageHandling: Cannot run with empty pageUrl") - return - } - - Logger.sitemapViewController.info("🚀 Starting page load and long polling flow...") - - pageHandlingTask = Task { - do { - // Initial page load - - guard let configuration = MainActorNetworkTracker.shared.activeConnection?.configuration else { - throw NetworkTrackerError.noActiveConnection - } - - if openAPIService == nil { - openAPIService = try OpenAPIService(connectionConfiguration: configuration) - } - - let initialPage = try await openAPIService?.pollDataForPage( - sitemapname: defaultSitemap, - pageId: pageId, - longPolling: false - ) - - // Alternative 2 to be tested. - // await pageLoader?.updatePageConfig(newPageId: pageId, newSitemap: defaultSitemap) - // guard let page = try await pageLoader?.fetchPage(longPolling: true) else { return } - // - try Task.checkCancellation() - if let page = initialPage { - await MainActor.run { - self.updateUI(with: page) - } - } - - // Start long polling loop - while !Task.isCancelled { - let page = try await openAPIService?.pollDataForPage( - sitemapname: defaultSitemap, - pageId: pageId, - longPolling: true - ) - try Task.checkCancellation() - - if let page { - await MainActor.run { - self.updateUI(with: page) - } - } - } - } catch is CancellationError { - Logger.sitemapViewController.info("🔁 pageHandlingTask was cancelled") - } catch let error as DecodingError { - Logger.sitemapViewController.error("DecodingError \(error.localizedDescription)") - } catch let error as ClientError { - if let urlError = error.underlyingError as? URLError { - switch urlError.code { - case .cancelled: - Logger.sitemapViewController.info("Task was cancelled - URLError code: .cancelled") - case .timedOut: - Logger.sitemapViewController.info("Task timed out - URLError code: .timedOut") - default: - Logger.sitemapViewController.info("URLError: \(urlError.localizedDescription)") - } - } else { - Logger.sitemapViewController.error("\(error.localizedDescription)") - } - } catch let openAPIError as OpenAPIServiceError { - Logger.sitemapViewController.info("On pageHandling \(openAPIError)") - } catch { - Logger.sitemapViewController.error("❌ pageHandlingTask error: \(error.localizedDescription)") - await MainActor.run { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: error.localizedDescription, - theme: .error - ) - } - } - } - } - - // load settings into local properties - func loadSettings() { - defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap - idleOff = Preferences.shared.idleOff - iconType = IconType(rawValue: Preferences.shared.currentHomePreferences.iconType) ?? .svg - #if DEBUG - // always use demo sitemap for UITest - if ProcessInfo.processInfo.environment["UITest"] != nil { - defaultSitemap = "demo" - iconType = .svg - } - #endif - } - - // Find and return sitemap by it's name if any - func sitemap(byName sitemapName: String?) -> OpenHABSitemap? { - for sitemap in sitemaps where sitemap.name == sitemapName { - return sitemap - } - return nil - } - - @discardableResult - func pageNetworkStatusChanged() -> Bool { - Logger.sitemapViewController.info("OpenHABSitemapViewController pageNetworkStatusChange") - - guard !pageUrl.isEmpty else { return false } - - let currentStatus = MainActorNetworkTracker.shared.status - - // First run - if !pageNetworkStatusAvailable { - pageNetworkStatus = currentStatus - pageNetworkStatusAvailable = true - return false - } - - if pageNetworkStatus == currentStatus { - return false - } else { - pageNetworkStatus = currentStatus - return true - } - } - - func filterContentForSearchText(_ searchText: String?, scope: String = "All") { - guard let searchText else { return } - - filteredPage = currentPage?.filter { - $0.label.lowercased().contains(searchText.lowercased()) && $0.type != .frame - } - filteredPage?.sendCommand = { [weak self] item, command in - self?.sendCommand(item, commandToSend: command) - } - widgetTableView.reloadData() - } - - func sendCommand(_ item: OpenHABItem?, commandToSend command: String?) { - if let item, let command { - sendCommand(itemname: item.name, command: command) - } - } - - func sendCommand(itemname: String, command: String) { - let sourcePrefix = sitemapSourcePrefix() - let deviceId = UIDevice.current.identifierForVendor?.uuidString - Task { - try await openAPIService?.sendItemCommand( - itemname: itemname, - command: command, - sourcePrefix: sourcePrefix, - deviceId: deviceId - ) - } - } - - private func sitemapSourcePrefix() -> String? { - guard !defaultSitemap.isEmpty else { return nil } - let suffix = pageId.isEmpty ? "" : ":\(pageId)" - return "org.openhab.ui.basic$\(defaultSitemap)\(suffix)" - } -} - -// MARK: - UISearchResultsUpdating - -extension OpenHABSitemapViewController: UISearchResultsUpdating { - func updateSearchResults(for searchController: UISearchController) { - Logger.sitemapViewController.info("Search updated: \(searchController.searchBar.text ?? "")") - filterContentForSearchText(searchController.searchBar.text) - } -} - -// MARK: - UISearchBarDelegate - -extension OpenHABSitemapViewController: UISearchBarDelegate { - func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { - searchBar.resignFirstResponder() - } -} - -// MARK: - ColorPickerCellDelegate - -extension OpenHABSitemapViewController: @preconcurrency ColorPickerCellDelegate { - func didPressColorButton(_ cell: ColorPickerCell?) { - let colorPickerViewController = storyboard?.instantiateViewController(withIdentifier: "ColorPickerViewController") as? ColorPickerViewController - if let cell { - let widget = relevantPage?.widgets[widgetTableView.indexPath(for: cell)?.row ?? 0] - colorPickerViewController?.title = widget?.labelText - colorPickerViewController?.widget = widget - } - if let colorPickerViewController { - navigationController?.pushViewController(colorPickerViewController, animated: true) - } - } -} - -// MARK: - UITableViewDelegate, UITableViewDataSource - -extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - relevantPage?.widgets.count ?? 0 - } - - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - 44.0 - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let widget: OpenHABWidget? = relevantPage?.widgets[indexPath.row] - switch widget?.type { - case .frame: - return widget?.label.count ?? 0 > 0 ? 35.0 : 0 - case .image, .chart, .video: - return UITableView.automaticDimension - case .webview, .mapview: - if let height = widget?.height { - // calculate webview/mapview height and return it. Limited to UIScreen.main.bounds.height - let heightValue = height * 44 - Logger.sitemapViewController.info("Webview/Mapview height would be \(heightValue)") - return min(UIScreen.main.bounds.height, CGFloat(heightValue)) - } else { - // return default height for webview/mapview as 8 rows - return 44.0 * 8 - } - default: return 44.0 - } - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let widget: OpenHABWidget = relevantWidget(indexPath: indexPath) else { - // this should never be the case - let cell = tableView.dequeueReusableCell(for: indexPath) as GenericUITableViewCell - cell.displayWidget() - cell.touchEventDelegate = self - cell.separatorInset = UIEdgeInsets(top: 0, left: 60, bottom: 0, right: 0) - return cell - } - - let provider = WidgetCellFactory.provider(for: widget) - let cell = provider.dequeue(from: tableView, at: indexPath) - provider.configure(cell: cell, for: widget, controller: self) - - let logicColor = !(widget.iconColor.isEmpty) ? UIColor(fromString: widget.iconColor) : .ohBlack - let iconColor = logicColor.semanticColorToHex() ?? "#000000" - // No icon will be displazed for cells that conform to NoIconDisplayableCell protocol - if !(cell is any NoIconDisplayableCell) { - if !widget.icon.isEmpty { - if let urlc = Endpoint.icon( - rootUrl: openHABRootUrl, - version: MainActorNetworkTracker.shared.activeConnection?.version ?? 2, - icon: widget.icon, - state: widget.iconState(), - iconType: iconType, - iconColor: iconColor, - staticIcon: widget.staticIcon - )?.url { - Logger.sitemapViewController.info("URL for icon: \(urlc.absoluteString, privacy: .public)") - // Only apply color preprocessing for non-iconify icons - let processorIconColor = urlc.host == "api.iconify.design" ? nil : iconColor - cell.imageView?.kf.setImage( - with: KF.ImageResource(downloadURL: urlc), // , cacheKey: urlc.path + (urlc.query ?? "")), - placeholder: nil, - options: [.processor(OpenHABImageProcessor(iconColor: processorIconColor))] - ) { result in - switch result { - case .success: - DispatchQueue.main.async { - cell.setNeedsLayout() - } - case let .failure(error): - Logger.sitemapViewController.error("Image loading failed for widget \(widget.label, privacy: .public) : \(error.localizedDescription, privacy: .public)") - } - } - } - } - } - - if cell is FrameUITableViewCell { - cell.backgroundColor = .ohSystemGroupedBackground - } else { - cell.backgroundColor = .ohSecondarySystemGroupedBackground - } - - if let cell = cell as? GenericUITableViewCell { - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = self - } - - // Check if this is not the last row in the widgets list - if indexPath.row < (relevantPage?.widgets.count ?? 1) - 1 { - let nextWidget: OpenHABWidget? = relevantPage?.widgets[indexPath.row + 1] - if let type = nextWidget?.type, type.isAny(of: .frame, .image, .video, .webview, .chart) { - cell.separatorInset = UIEdgeInsets.zero - } else if !(widget.type == .frame) { - cell.separatorInset = UIEdgeInsets(top: 0, left: 60, bottom: 0, right: 0) - } - } - - return cell - } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - // Prevent the cell from inheriting the Table View's margin settings - cell.preservesSuperviewLayoutMargins = false - - // Explictly set your cell's layout margins - cell.layoutMargins = .zero - - (cell as? VideoUITableViewCell)?.play() - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if let index = widgetTableView.indexPathForSelectedRow { - widgetTableView.deselectRow(at: index, animated: false) - } - - guard let widget: OpenHABWidget = relevantWidget(indexPath: indexPath) else { return } - - if let linkedPage = widget.linkedPage { - Logger.sitemapViewController.info("Selected linked page: \(linkedPage.link)") - let newViewController = (storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController)! - newViewController.title = linkedPage.title.components(separatedBy: "[")[0] - newViewController.pageId = linkedPage.pageId - newViewController.pageUrl = linkedPage.link - newViewController.openHABRootUrl = openHABRootUrl - isNavigatingToLinkedPage = true - navigationController?.pushViewController(newViewController, animated: true) - } else if widget.type == .selection { - let selectionItemState = widget.item?.state - Logger.sitemapViewController.info("Selected selection widget in status: \(selectionItemState ?? "unknown")") - let hostingController = UIHostingController( - rootView: SelectionView( - labelText: widget.labelText, - mappings: widget.mappingsOrItemOptions, - selectionItemState: selectionItemState - ) { selectedMappingIndex in - let selectedMapping: OpenHABWidgetMapping = widget.mappingsOrItemOptions[selectedMappingIndex] - self.sendCommand(widget.item, commandToSend: selectedMapping.command) - } onDismiss: { [weak self] in - self?.navigationController?.popViewController(animated: true) - } - ) - hostingController.title = widget.labelText - isNavigatingToSelection = true - navigationController?.pushViewController(hostingController, animated: true) - } else if widget.type == .input { - let hint = widget.inputHint - let textExtractor: ((UIAlertController) -> String?)? - let textFieldAdder: ((UITextField) -> Void)? - - switch hint { - case .date, .time, .datetime: - // value setting is handeled by the cell itself - textExtractor = nil - textFieldAdder = nil - case .number: - textFieldAdder = { textField in - textField.text = widget.state - textField.clearButtonMode = .always - textField.delegate = self - textField.keyboardType = .numbersAndPunctuation - } - // replace expected decimal separator - textExtractor = { $0.textFields?[0].text?.replacingOccurrences(of: NSLocale.current.decimalSeparator ?? "", with: ".") } - case .text: - textFieldAdder = { textField in - textField.text = widget.state - textField.clearButtonMode = .always - textField.keyboardType = .default - } - textExtractor = { $0.textFields?[0].text } - case .unknown: - textExtractor = nil - textFieldAdder = nil - } - guard let textExtractor, let textFieldAdder else { - return - } - - // TODO: proper texts instead of hardcoded values - let alert = UIAlertController( - title: "Enter new value", - message: "Current value for \((widget.labelText.orEmpty.isEmpty ? "Unknown" : widget.labelText.orEmpty)) is \((widget.labelValue.orEmpty.isEmpty ? "Unknown" : widget.labelValue.orEmpty))", - preferredStyle: .alert - ) - alert.addTextField(configurationHandler: textFieldAdder) - let sendAction = UIAlertAction(title: "Set value", style: .destructive) { [weak self] _ in - if let input = textExtractor(alert), !input.isEmpty { - self?.sendCommand(widget.item, commandToSend: input) - } - } - alert.addAction(sendAction) - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - alert.preferredAction = sendAction - present(alert, animated: true) - } - } - - func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - if let cell = cell as? any GenericCellCacheProtocol { - // invalidate cache only if the cell is not visible or the datasource is empty (eg. sitemap change) - if tableView.indexPathsForVisibleRows == nil || !tableView.indexPathsForVisibleRows!.contains(indexPath) || currentPage == nil || currentPage!.widgets.isEmpty { - cell.invalidateCache() - } - } - } - - func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - if let cell = tableView.cellForRow(at: indexPath) as? GenericUITableViewCell, cell.widget.type == .text, let text = cell.widget?.labelValue ?? cell.widget?.labelText, !text.isEmpty { - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in - let copy = UIAction(title: NSLocalizedString("copy_label", comment: ""), image: UIImage(systemSymbol: .squareAndArrowUp)) { _ in - UIPasteboard.general.string = text - } - - return UIMenu(title: "", children: [copy]) - } - } - - return nil - } -} - -extension OpenHABSitemapViewController: UITextFieldDelegate { - func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - let decimalSeparator = NSLocale.current.decimalSeparator ?? "" - let oldString = (textField.text ?? "") - let wholeNumberRegex = /^-?[0-9]*$/ - - // check for deletion - return string.isEmpty - // check for new negative sign - || ( - !string.starts(with: "-") // new string does not add negative sign - || range.location == 0 // new string adds negative sign to beginning - && ( - !oldString.starts(with: "-") // old string does not contain negative sign - || range.length > 0 - ) - ) // new string replaces negative sign in old string - // check for old negative sign - && ( - oldString.isEmpty - || !oldString.starts(with: "-") // old string does not start with negative sign - || range.location > 0 // new string starts after negative sign in old string - || range.length > 0 - ) // new string replaces negative sign in old string - // check for decimal signs - && ( - string.firstRange(of: wholeNumberRegex) != nil // new string is whole number - || ( - string.replacing(decimalSeparator, with: "", maxReplacements: 1) - .firstRange(of: wholeNumberRegex) != nil // new string is valid decimal number - && !(oldString as NSString).replacingCharacters(in: range, with: "").contains(decimalSeparator) - ) - ) // old string without replaced range not yet contains decimal separator - } -} - -// MARK: Kingfisher authentication with NSURLCredential - -extension OpenHABSitemapViewController: AuthenticationChallengeResponsible { - func downloader(_ downloader: ImageDownloader, - didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await onReceiveSessionChallenge(with: challenge) - } - - func downloader(_ downloader: ImageDownloader, - task: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await onReceiveSessionTaskChallenge(with: challenge) - } -} diff --git a/openHAB/OpenHABViewController.swift b/openHAB/OpenHABViewController.swift deleted file mode 100644 index 5c16188f9..000000000 --- a/openHAB/OpenHABViewController.swift +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Combine -import OpenHABCore -import SideMenu -import SwiftMessages -import UIKit - -class OpenHABViewController: UIViewController { - var trackerCancellables = Set() - - var activeTasks = Set>() - - override func viewDidLoad() { - super.viewDidLoad() - NotificationCenter.default.addObserver(self, selector: #selector(OpenHABViewController.didEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(OpenHABViewController.didBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil) - CertificateManagers.clientCertificateManager.delegate = self - CertificateManagers.serverCertificateManager.delegate = self - } - - func showPopupMessage(seconds: Double, title: String, message: String, theme: Theme, - viewTapAction: (() -> Void)? = nil, - buttonTitle: String = NSLocalizedString("dismiss", comment: ""), - buttonAction: (() -> Void)? = nil) { - var config = SwiftMessages.Config() - if seconds >= 0 { - config.duration = .seconds(seconds: seconds) - } else { - config.duration = .forever - } - config.presentationStyle = .bottom - config.presentationContext = .view(view) - SwiftMessages.hideAll() - SwiftMessages.show(config: config) { - let view = MessageView.viewFromNib(layout: .cardView) - // ... configure the view - view.configureTheme(theme) - view.configureContent(title: title, body: message) - view.button?.setTitle(buttonTitle, for: .normal) - view.buttonTapHandler = { _ in - SwiftMessages.hide() - buttonAction?() - } - view.tapHandler = { _ in - viewTapAction?() - } - return view - } - } - - func hidePopupMessages() { - SwiftMessages.hideAll() - } - - func showSideMenu() { - if let rc = parent as? OpenHABRootViewController { - rc.showSideMenu() - } - } - - @objc - func didEnterBackground(_ notification: Notification?) { - UIApplication.shared.isIdleTimerDisabled = false - } - - @objc - func didBecomeActive(_ notification: Notification?) { - // re disable idle off timer - if Preferences.shared.idleOff { - UIApplication.shared.isIdleTimerDisabled = true - } - } - - // To be overridden by sub classes - - func reloadView() {} - - func viewName() -> String { - "default" - } -} - -// MARK: - ServerCertificateManagerDelegate - -extension OpenHABViewController: ServerCertificateManagerDelegate { - // delegate should ask user for a decision on what to do with invalid certificate - @MainActor - func evaluateServerTrust(summary certificateSummary: String?, forDomain domain: String?) async -> ServerCertificateManager.EvaluateResult { - await withCheckedContinuation { continuation in - let title = NSLocalizedString("ssl_certificate_warning", comment: "") - let message = String(format: NSLocalizedString("ssl_certificate_invalid", comment: ""), certificateSummary ?? "", domain ?? "") - let alertView = UIAlertController(title: title, message: message, preferredStyle: .alert) - - alertView.addAction(UIAlertAction(title: NSLocalizedString("abort", comment: ""), style: .default) { _ in - continuation.resume(returning: .deny) - }) - alertView.addAction(UIAlertAction(title: NSLocalizedString("once", comment: ""), style: .default) { _ in - continuation.resume(returning: .permitOnce) - }) - alertView.addAction(UIAlertAction(title: NSLocalizedString("always", comment: ""), style: .default) { _ in - continuation.resume(returning: .permitAlways) - }) - - self.present(alertView, animated: true, completion: nil) - } - } - - // certificate received from openHAB doesn't match our record, ask user for a decision - @MainActor - func evaluateCertificateMismatch(summary certificateSummary: String?, forDomain domain: String?) async -> OpenHABCore.ServerCertificateManager.EvaluateResult { - await withCheckedContinuation { continuation in - let title = NSLocalizedString("ssl_certificate_warning", comment: "") - let message = String(format: NSLocalizedString("ssl_certificate_no_match", comment: ""), certificateSummary ?? "", domain ?? "") - let alertView = UIAlertController(title: title, message: message, preferredStyle: .alert) - - alertView.addAction(UIAlertAction(title: NSLocalizedString("abort", comment: ""), style: .default) { _ in - continuation.resume(returning: .deny) - }) - alertView.addAction(UIAlertAction(title: NSLocalizedString("once", comment: ""), style: .default) { _ in - continuation.resume(returning: .permitOnce) - }) - alertView.addAction(UIAlertAction(title: NSLocalizedString("always", comment: ""), style: .default) { _ in - continuation.resume(returning: .permitAlways) - }) - - self.present(alertView, animated: true, completion: nil) - } - } - - @MainActor - func acceptedServerCertificatesChanged() { - // User's decision about trusting server certificates has changed. Send updates to the paired watch. - Task { - await WatchMessageService.singleton.syncPreferencesToWatch() - } - } -} - -// MARK: - ClientCertificateManagerDelegate - -@MainActor -extension OpenHABViewController: ClientCertificateManagerDelegate { - // Ask user whether to import the certificate - func askForClientCertificateImport(_ clientCertificateManager: ClientCertificateManager?) async -> Bool { - let shouldImport = await withCheckedContinuation { continuation in - let alertController = UIAlertController( - title: NSLocalizedString("certificate_import_title", comment: ""), - message: NSLocalizedString("certificate_import_text", comment: ""), - preferredStyle: .alert - ) - - let okay = UIAlertAction(title: NSLocalizedString("okay", comment: ""), style: .default) { _ in - continuation.resume(returning: true) - } - - let cancel = UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel) { _ in - continuation.resume(returning: false) - } - - alertController.addAction(okay) - alertController.addAction(cancel) - - self.present(alertController, animated: true) - } - if shouldImport { - await clientCertificateManager!.clientCertificateAccepted(password: nil) - return true - } else { - clientCertificateManager!.clientCertificateRejected() - return false - } - } - - // Ask user for password to decode PKCS#12 - func askForCertificatePassword(_ clientCertificateManager: ClientCertificateManager?) async -> String? { - await withCheckedContinuation { continuation in - let alertController = UIAlertController( - title: NSLocalizedString("certificate_import_title", comment: ""), - message: NSLocalizedString("certificate_import_password", comment: ""), - preferredStyle: .alert - ) - - alertController.addTextField { textField in - textField.placeholder = NSLocalizedString("password", comment: "") - textField.isSecureTextEntry = true - } - - let okay = UIAlertAction(title: NSLocalizedString("okay", comment: ""), style: .default) { _ in - let password = alertController.textFields?.first?.text - continuation.resume(returning: password) - } - - let cancel = UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel) { _ in - continuation.resume(returning: nil) - } - - alertController.addAction(okay) - alertController.addAction(cancel) - - self.present(alertController, animated: true) - } - } - - // Show alert if certificate import failed - func alertClientCertificateError(_ clientCertificateManager: ClientCertificateManager?, errMsg: String) async { - let alertController = UIAlertController( - title: NSLocalizedString("certificate_import_title", comment: ""), - message: errMsg, - preferredStyle: .alert - ) - - let okay = UIAlertAction(title: NSLocalizedString("okay", comment: ""), style: .default) - alertController.addAction(okay) - - present(alertController, animated: true) - } -} diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift deleted file mode 100644 index 37f4ba29d..000000000 --- a/openHAB/OpenHABWebViewController.swift +++ /dev/null @@ -1,639 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Combine -import OpenHABCore -import os.log -import SafariServices -import SideMenu -import SwiftMessages -import UIKit -import WebKit - -class OpenHABWebViewController: OpenHABViewController { - private var currentTarget = "" - private var openHABTrackedRootUrl = "" - private var activeConnectionInfo: ConnectionInfo? - private var activeConfig: ConnectionConfiguration? { activeConnectionInfo?.configuration } - private var hideNavigationBar = false - private var activityIndicator: UIActivityIndicatorView! - private var sseTimer: Timer? - private var commandQueue: [String] = [] - private var acceptsCommands = false - private var views: [UUID: WKWebView] = [:] - // TODO: remove myOhViews when we drop iOS 16 support - private var myOhViews: [UUID: WKWebView] = [:] - private var etagChecker: ETagChecker? - private var etagCheckerConfigURL: String? // Track which config the checker was created for - private var lastLoadedURL: String? // Track the last successfully loaded URL from didFinish - - private var js = """ - (function() { - // Main UI Callbacks - window.OHApp = { - exitToApp : function(){ - window.webkit.messageHandlers.mainUi.postMessage('exitToApp'); - }, - goFullscreen : function(){ - window.webkit.messageHandlers.mainUi.postMessage('goFullscreen'); - }, - sseConnected : function(connected) { - window.webkit.messageHandlers.mainUi.postMessage('sseConnected-' + connected); - }, - ready : function() { - window.webkit.messageHandlers.mainUi.postMessage('ready'); - }, - } - - // Detect Path changes in SPA - function notifyPathChange() { - window.webkit.messageHandlers.pathChanged.postMessage(window.location.pathname); - } - - const originalPushState = history.pushState; - history.pushState = function() { - originalPushState.apply(this, arguments); - notifyPathChange(); - }; - - const originalReplaceState = history.replaceState; - history.replaceState = function() { - originalReplaceState.apply(this, arguments); - notifyPathChange(); - }; - - window.addEventListener('popstate', notifyPathChange); - - // Notify initial path on load - notifyPathChange(); - })(); - """ - - override open var shouldAutorotate: Bool { - true - } - - private var webView: WKWebView = .init(frame: .zero) - - override func viewDidLoad() { - super.viewDidLoad() - navigationController?.interactivePopGestureRecognizer?.isEnabled = true - attachWebViewToLayout(webView) - activityIndicator = UIActivityIndicatorView() - activityIndicator.center = view.center - activityIndicator.hidesWhenStopped = true - activityIndicator.style = UIActivityIndicatorView.Style.large - - view.addSubview(activityIndicator) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - setHideNavigationBar(shouldHide: hideNavigationBar, animated: animated) - navigationController?.navigationBar.prefersLargeTitles = false - parent?.navigationItem.title = "Main View" - MainActorNetworkTracker.shared.$activeConnection - .receive(on: DispatchQueue.main) - .sink { activeConnection in - if let activeConnection { - let activeConfiguration = activeConnection.configuration - Logger.viewController.info("OpenHABWebViewController openHAB URL = \(activeConfiguration.url)") - self.openHABTrackedRootUrl = activeConfiguration.url - self.activeConnectionInfo = activeConnection - self.loadWebView(force: false) - } - } - .store(in: &trackerCancellables) - - // Listen for app becoming active to check for content updates - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationDidBecomeActive), - name: UIApplication.didBecomeActiveNotification, - object: nil - ) - - startTracker() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - // Show the navigation bar on other view controllers - // do not change the "navigationBarHidden" flag to restore on reappearing - navigationController?.setNavigationBarHidden(false, animated: animated) - navigationController?.navigationBar.prefersLargeTitles = true - trackerCancellables.removeAll() - - NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil) - } - - func startTracker() { - if currentTarget.isEmpty { - showActivityIndicator(show: true) - } - } - - @objc private func applicationDidBecomeActive() { - // When app returns from background, check if content has changed - Logger.viewController.info("App became active, checking for content updates") - loadWebView(force: false) - } - - @MainActor - func loadWebView(force: Bool = false, path: String? = nil) { - Logger.viewController.info("loadWebView tracked URL: \(self.activeConfig?.url ?? "") forced \(force ? "true" : "false")") - guard let activeConfig else { return } - // TODO: Check whether credentials are truly put into newTarget - let authStr = "\(activeConfig.username):\(activeConfig.password)" - let newTarget = "\(activeConfig.url):\(authStr)" - - // If force reload, skip ETag check and always reload - if force { - Task { - await performLoadWebView(newTarget: newTarget, path: path, force: true) - } - return - } - - // Check ETag before loading (even if target hasn't changed - content might have updated) - Task { - await loadWebViewWithETagCheck(newTarget: newTarget, path: path) - } - } - - @MainActor - private func performLoadWebView(newTarget: String, path: String?, force: Bool) async { - guard let activeConfig else { return } - currentTarget = newTarget - let url = URL(string: activeConfig.url) - - if let modifiedUrl = modifyUrl(orig: url, path: path) { - acceptsCommands = false - var request = URLRequest(url: modifiedUrl) - - // When force reloading, bypass ALL caches (both URLRequest and WKWebView) - if force { - // Clear WKWebView's internal cache - let dataStore = webView.configuration.websiteDataStore - let websiteDataTypes: Set = [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache] - let date = Date(timeIntervalSince1970: 0) - - Logger.viewController.info("Force reload: clearing WKWebView cache") - await dataStore.removeData(ofTypes: websiteDataTypes, modifiedSince: date) - - // Set aggressive cache policy for URLRequest - request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - } - - // TODO: remove this check once iOS 16 is dropped - let isCloudConnection = activeConfig.isCloudConnection - // create new (or resuse existing) - let newWebview = webView(for: Preferences.shared.currentHomePreferences.id, isCloudConnection: isCloudConnection) - if newWebview != webView { - // Detach old instance - webView.stopLoading() - webView.navigationDelegate = nil - webView.uiDelegate = nil - webView.removeFromSuperview() - newWebview.navigationDelegate = self - newWebview.uiDelegate = self - webView = newWebview - attachWebViewToLayout(newWebview) - } - Logger.viewController.info("Loading URL: \(modifiedUrl)") - webView.load(request) - } - } - - @MainActor - private func loadWebViewWithETagCheck(newTarget: String, path: String?) async { - guard let activeConfig, - let url = URL(string: activeConfig.url), - let fullURL = modifyUrl(orig: url, path: path) else { - Logger.viewController.info("ETag check skipped: invalid configuration") - await performLoadWebView(newTarget: newTarget, path: path, force: false) - return - } - - // Create checker if needed (lazy initialization) or if config changed - let configKey = "\(activeConfig.url):\(activeConfig.username)" - if etagChecker == nil || etagCheckerConfigURL != configKey { - let httpClient = HTTPClient(baseURL: nil, connectionConfiguration: activeConfig) - etagChecker = ETagChecker(httpClient: httpClient) - etagCheckerConfigURL = configKey - Logger.viewController.debug("Created new ETagChecker for config: \(configKey)") - } - - guard let checker = etagChecker else { - await performLoadWebView(newTarget: newTarget, path: path, force: false) - return - } - - // Check if content changed - let result = await checker.checkIfChanged(url: fullURL) - - switch result { - case .unchanged: - // When ETag is unchanged, the base resource (HTML/JS) hasn't changed - // Compare base URLs (origin) only, since paths are handled by client-side routing - let normalizedTarget = normalizeURLForComparison(fullURL.absoluteString, includeBasePath: false) - let normalizedLoaded = normalizeURLForComparison(lastLoadedURL, includeBasePath: false) - - Logger.viewController.debug("ETag unchanged - comparing base URLs: loaded=\(normalizedLoaded ?? "nil") vs target=\(normalizedTarget ?? "nil")") - - if let normalizedTarget, let normalizedLoaded, normalizedLoaded == normalizedTarget { - Logger.viewController.info("ETag unchanged and same base URL, skipping load") - currentTarget = newTarget - showActivityIndicator(show: false) - // Don't load - same server, same content version, already displayed - } else { - Logger.viewController.info("ETag unchanged but different base URL, loading \(fullURL.absoluteString)") - await performLoadWebView(newTarget: newTarget, path: path, force: false) - } - - case .changed: - Logger.viewController.info("ETag changed, loading \(fullURL.absoluteString)") - await performLoadWebView(newTarget: newTarget, path: path, force: false) - - case let .failed(error): - Logger.viewController.info("ETag check failed: \(error.localizedDescription), loading anyway") - await performLoadWebView(newTarget: newTarget, path: path, force: false) - } - } - - /// Normalizes URLs for comparison - /// - Parameters: - /// - urlString: The URL string to normalize - /// - includeBasePath: If true, includes the path component; if false, returns only the base URL (origin) - /// - Returns: Normalized URL string - private func normalizeURLForComparison(_ urlString: String?, includeBasePath: Bool = false) -> String? { - guard let urlString, let url = URL(string: urlString) else { return nil } - - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) - - // Always remove fragment (everything after #) - components?.fragment = nil - - // For base URL comparison (when includeBasePath == false), remove the path entirely - if !includeBasePath { - components?.path = "" - components?.query = nil - } - - guard var normalized = components?.url?.absoluteString else { return nil } - - // Remove trailing slash for consistent comparison - if normalized.hasSuffix("/") { - normalized = String(normalized.dropLast()) - } - - return normalized - } - - func modifyUrl(orig: URL?, path: String? = nil) -> URL? { - // better way to clone/copy ? - guard let urlString = orig?.absoluteString, var url = URL(string: urlString) else { return orig } - // Use cloud proxy URL if available (resolved from /api/v1/proxyurl) - if let proxyURL = activeConnectionInfo?.proxyURL { - url = proxyURL - } - if let path { - url = appendPathToURL(baseURL: url, path: path) ?? url - } else if !Preferences.shared.currentHomePreferences.defaultMainUIPath.isEmpty { - url = appendPathToURL(baseURL: url, path: Preferences.shared.currentHomePreferences.defaultMainUIPath) ?? url - } - return url - } - - // swift really makes you work to construct simple URLs, uhg..... - func appendPathToURL(baseURL: URL, path: String) -> URL? { - guard var urlComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { - return nil - } - // Split the user path into path and query components - if let questionMarkRange = path.range(of: "?") { - // Separate path and query - let pathComponent = String(path[.. String { - "web" - } - - func navigateCommand(_ command: String) { - if acceptsCommands { - navigateCommandInternal(command) - } else { - commandQueue.append(command) - } - } - - private func navigateCommandInternal(_ command: String) { - // let jsCode = "window.OHApp.navigate === 'function' && window.OHApp.navigate('\(command)')" - let jsCode = "window.MainUI.handleCommand('\(command)')" - webView.evaluateJavaScript(jsCode) { (_, error) in - if let error { - Logger.viewController.error("navigateCommandInternal failed \(error.localizedDescription)") - } else { - Logger.viewController.info("navigateCommandInternal Success") - } - } - } - - private func executeQueuedCommands() { - while !commandQueue.isEmpty { - let command = commandQueue.removeFirst() - navigateCommandInternal(command) - } - } - - func webView(for id: UUID, isCloudConnection: Bool) -> WKWebView { - // TODO: remove all iOS < 17 code when we drop iOS 16 support - if #unavailable(iOS 17) { - if isCloudConnection, let myExsiting = myOhViews[id] { - Logger.viewController.info("Reusing cloud webview for id:\(id.uuidString)") - return myExsiting - } - } - if let existing = views[id] { - Logger.viewController.info("Reusing webview for id:\(id.uuidString)") - return existing - } - let config = WKWebViewConfiguration() - config.allowsInlineMediaPlayback = true - config.mediaTypesRequiringUserActionForPlayback = [] - // adds: window.webkit.messageHandlers.xxxx.postMessage to JS env - config.userContentController.add(self, name: "mainUi") - config.userContentController.add(self, name: "pathChanged") - config.userContentController.addUserScript(WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: false)) - - // iOS 17 allows Sandboxed profiles, which is fantastic, iOS 16 does not and agressively caches everything - if #available(iOS 17, *) { - config.websiteDataStore = WKWebsiteDataStore(forIdentifier: id) - } else if isCloudConnection { - // for cloud connections, create an instance that does not persist or share states (private) - config.websiteDataStore = .nonPersistent() - } - - let webview = WKWebView(frame: .zero, configuration: config) - webview.navigationDelegate = self - webview.uiDelegate = self - webview.scrollView.bounces = false - // support dark mode and avoid white flashing when loading - webview.isOpaque = false - webview.backgroundColor = UIColor.clear - if UIDevice.current.userInterfaceIdiom == .pad { - // since ios 13 Safari sets the user agent to desktop mode on iPads so the view renders correctly with larger screens - webview.customUserAgent = "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1" - } - if #available(iOS 16.4, *) { - webview.isInspectable = true - } - - // Avoid safe-area content insets which can leave a small gap at the bottom on iPad until a reload. - webview.scrollView.contentInsetAdjustmentBehavior = .never - webview.scrollView.contentInset = .zero - webview.scrollView.scrollIndicatorInsets = .zero - - if #unavailable(iOS 17) { - if isCloudConnection { - myOhViews[id] = webview - return webview - } - } - views[id] = webview - return webview - } - - func attachWebViewToLayout(_ webView: WKWebView) { - if webView.superview !== view { - view.addSubview(webView) - } - webView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - webView.topAnchor.constraint(equalTo: view.topAnchor), - webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - webView.trailingAnchor.constraint(equalTo: view.trailingAnchor) - ]) - view.setNeedsLayout() - view.layoutIfNeeded() - } -} - -extension OpenHABWebViewController: WKScriptMessageHandler { - @MainActor - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - Logger.viewController.info("WKScriptMessage \(message.name)") - if message.name == "pathChanged", let newPath = message.body as? String { - Logger.viewController.debug("Path changed to: \(newPath)") - Task { @MainActor in - Preferences.shared.currentWebViewPath = newPath - } - } - if message.name == "mainUi", let callbackName = message.body as? String { - Logger.viewController.info("WKScriptMessage \(callbackName)") - switch callbackName { - case "exitToApp": - showSideMenu() - case "goFullscreen": - // check to make sure we are actually the top view before hiding the nav button - if isViewLoaded, view.window != nil { - setHideNavigationBar(shouldHide: true) - } - case "sseConnected-true": - Logger.viewController.info("WKScriptMessage sseConnected is true") - hidePopupMessages() - sseTimer?.invalidate() - acceptsCommands = true - executeQueuedCommands() - case "sseConnected-false": - Logger.viewController.info("WKScriptMessage sseConnected is false") - sseTimer?.invalidate() - sseTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { [weak self] _ in - guard let self else { return } - Task { @MainActor in - self.showPopupMessage(seconds: 20, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) - self.acceptsCommands = false - } - } - default: break - } - } - } -} - -extension OpenHABWebViewController: WKNavigationDelegate { - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { - guard let url = navigationAction.request.url else { return .allow } - Logger.viewController.info("decidePolicyFor - url: \(url.absoluteString)") - - if navigationAction.navigationType == .linkActivated { - await UIApplication.shared.open(url) - return .cancel // Stop in WebView - } - return .allow - } - - func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy { - if let response = navigationResponse.response as? HTTPURLResponse { - Logger.viewController.info("navigationResponse: \(response.statusCode)") - - if response.statusCode >= 400 { - pageLoadError(message: "\(response.statusCode)") - return .cancel - } - } - return .allow - } - - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation?) { - Logger.viewController.info("didStartProvisionalNavigation - webView.url: \(String(describing: webView.url?.description))") - showActivityIndicator(show: true) - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation?, withError error: any Error) { - Logger.viewController.error("didFail - webView.url: \(String(describing: webView.url?.description))") - - setHideNavigationBar(shouldHide: false) - if let urlError = error as? URLError, urlError.code == .cancelled { - return // Ignore cancelled requests - } - - pageLoadError(message: error.localizedDescription) - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - Logger.viewController.info("didFinish - webView.url: \(String(describing: webView.url?.description))") - - // Track the successfully loaded URL for ETag comparison - lastLoadedURL = webView.url?.absoluteString - - setHideNavigationBar(shouldHide: true) - showActivityIndicator(show: false) - hidePopupMessages() - acceptsCommands = true - // watch for URL changes so we can store the last visited path - if let webviewURL = webView.url { - let url = URL(string: webviewURL.path, relativeTo: URL(string: openHABTrackedRootUrl)) - if let path = url?.path { - let string = openHABTrackedRootUrl - Logger.viewController.info("navigation change base: \(string) path: \(path)") - Task { @MainActor in - Preferences.shared.currentWebViewPath = path.hasSuffix("/") ? path : path + "/" - } - } - } - } - - func webView(_ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - Logger.viewController.info("Challenge.protectionSpace.authenticationMethod: \(String(describing: challenge.protectionSpace.authenticationMethod))") - - if let url = modifyUrl(orig: URL(string: openHABTrackedRootUrl)), challenge.protectionSpace.host == url.host { - if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust { - guard let serverTrust = challenge.protectionSpace.serverTrust else { - return (.performDefaultHandling, nil) - } - let credential = URLCredential(trust: serverTrust) - return (.useCredential, credential) - } else { - if challenge.protectionSpace.authenticationMethod.isAny(of: NSURLAuthenticationMethodHTTPBasic, NSURLAuthenticationMethodDefault) { - return await onReceiveSessionTaskChallenge(with: challenge) - } else { - return await onReceiveSessionChallenge(with: challenge) - } - } - } - return (.performDefaultHandling, nil) - } - - func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - Logger.viewController.warning("webViewWebContentProcessDidTerminate - reloading view") - reloadView() - } - - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { - setHideNavigationBar(shouldHide: false) - reloadView() - } -} - -extension OpenHABWebViewController: WKUIDelegate { - func webView(_ webView: WKWebView, - createWebViewWith configuration: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, - windowFeatures: WKWindowFeatures) -> WKWebView? { - let schemes = ["http", "https"] - if navigationAction.targetFrame == nil, - let url = navigationAction.request.url, - let scheme = url.scheme, - schemes.contains(scheme) { - let svc = SFSafariViewController(url: url) - present(svc, animated: true, completion: nil) - } - - return nil - } - - func webView(_ webView: WKWebView, - decideMediaCapturePermissionsFor origin: WKSecurityOrigin, - initiatedBy frame: WKFrameInfo, - type: WKMediaCaptureType) async -> WKPermissionDecision { - Preferences.shared.currentHomePreferences.alwaysAllowWebRTC ? .grant : .prompt - } -} diff --git a/openHAB/PlayerView.swift b/openHAB/PlayerView.swift deleted file mode 100644 index 5aa64af34..000000000 --- a/openHAB/PlayerView.swift +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -// See https://developer.apple.com/documentation/avfoundation/avplayerlayer -// A convenient way of using AVPlayerLayer as the backing layer for a UIView - -import AVFoundation -import AVKit - -class PlayerView: UIView { - // Override UIView property - override static var layerClass: AnyClass { - AVPlayerLayer.self - } - - var player: AVPlayer? { - get { - playerLayer.player - } - set { - playerLayer.player = newValue - } - } - - var playerLayer: AVPlayerLayer { - layer as! AVPlayerLayer - } -} diff --git a/openHAB/RollershutterCell.swift b/openHAB/RollershutterCell.swift deleted file mode 100644 index 6ea900216..000000000 --- a/openHAB/RollershutterCell.swift +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import os.log -import UIKit - -class RollershutterCell: GenericUITableViewCell { - private let feedbackGenerator = UIImpactFeedbackGenerator(style: .light) - - @IBOutlet private var upButton: UIButton! - @IBOutlet private var stopButton: UIButton! - @IBOutlet private var downButton: UIButton! - @IBOutlet private var customDetailText: UILabel! - - required init?(coder: NSCoder) { - Logger.widgets.info("RollershutterCell initWithCoder") - super.init(coder: coder) - initialize() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - Logger.widgets.info("RollershutterCell initWithStyle") - super.init(style: style, reuseIdentifier: reuseIdentifier) - initialize() - } - - override func initialize() { - selectionStyle = .none - separatorInset = .zero - } - - override func displayWidget() { - customTextLabel?.text = widget.labelText - customDetailText?.text = widget.labelValue ?? "" - upButton?.addTarget(self, action: .upButtonPressed, for: .touchUpInside) - stopButton?.addTarget(self, action: .stopButtonPressed, for: .touchUpInside) - downButton?.addTarget(self, action: .downButtonPressed, for: .touchUpInside) - } - - @objc - func upButtonPressed() { - Logger.widgets.info("up button pressed") - widget.sendCommand("UP") - feedbackGenerator.impactOccurred() - } - - @objc - func stopButtonPressed() { - Logger.widgets.info("stop button pressed") - widget.sendCommand("STOP") - feedbackGenerator.impactOccurred() - } - - @objc - func downButtonPressed() { - Logger.widgets.info("down button pressed") - widget.sendCommand("DOWN") - feedbackGenerator.impactOccurred() - } -} - -// inspired by: Selectors in swift: A better approach using extensions -// https://medium.com/@abhimuralidharan/selectors-in-swift-a-better-approach-using-extensions-aa6b0416e850 -private extension Selector { - static let upButtonPressed = #selector(RollershutterCell.upButtonPressed) - static let stopButtonPressed = #selector(RollershutterCell.stopButtonPressed) - static let downButtonPressed = #selector(RollershutterCell.downButtonPressed) -} diff --git a/openHAB/ScaleAspectFitImageView.swift b/openHAB/ScaleAspectFitImageView.swift deleted file mode 100644 index e48391342..000000000 --- a/openHAB/ScaleAspectFitImageView.swift +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import UIKit - -class ScaleAspectFitImageView: UIImageView { - private var aspectRatioConstraint: NSLayoutConstraint? - override var image: UIImage? { - didSet { - updateAspectRatioConstraint() - } - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - setup() - } - - override init(frame: CGRect) { - super.init(frame: frame) - setup() - } - - override init(image: UIImage!) { - super.init(image: image) - setup() - } - - override init(image: UIImage!, highlightedImage: UIImage?) { - super.init(image: image, highlightedImage: highlightedImage) - setup() - } - - private func setup() { - contentMode = .scaleAspectFit - updateAspectRatioConstraint() - } - - /// Removes any pre-existing aspect ratio constraint, and adds a new one based on the current image - private func updateAspectRatioConstraint() { - if let constraint = aspectRatioConstraint { - removeConstraint(constraint) - } - aspectRatioConstraint = nil - - if let imageSize = image?.size, imageSize.height != 0 { - let aspectRatio = imageSize.width / imageSize.height - let constraint = NSLayoutConstraint( - item: self, - attribute: .width, - relatedBy: .equal, - toItem: self, - attribute: .height, - multiplier: aspectRatio, - constant: 0 - ) - - constraint.priority = UILayoutPriority(rawValue: 999) - addConstraint(constraint) - aspectRatioConstraint = constraint - } - } -} diff --git a/openHAB/SegmentedUITableViewCell.swift b/openHAB/SegmentedUITableViewCell.swift deleted file mode 100644 index a1773d459..000000000 --- a/openHAB/SegmentedUITableViewCell.swift +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import os.log -import UIKit - -class SegmentedUITableViewCell: GenericUITableViewCell { - private let feedbackGenerator = UIImpactFeedbackGenerator(style: .light) - - // @IBOutlet private var customTextLabel: UILabel! - @IBOutlet private var widgetSegmentControl: UISegmentedControl! - - required init?(coder: NSCoder) { - super.init(coder: coder) - - selectionStyle = .none - separatorInset = .zero - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - selectionStyle = .none - separatorInset = .zero - } - - override func displayWidget() { - customTextLabel?.text = widget.labelText - customDetailTextLabel?.text = widget.labelValue ?? "" - - widgetSegmentControl.apportionsSegmentWidthsByContent = true - widgetSegmentControl.removeAllSegments() - widgetSegmentControl.apportionsSegmentWidthsByContent = true - - for (index, mapping) in (widget?.mappingsOrItemOptions ?? []).enumerated() { - widgetSegmentControl.insertSegment(withTitle: mapping.label, at: index, animated: false) - } - - widgetSegmentControl.isMomentary = widget.mappingsOrItemOptions.count == 1 || widget.item?.state == "NULL" - widgetSegmentControl.selectedSegmentIndex = widgetSegmentControl.isMomentary ? -1 : Int(widget.mappingIndex(byCommand: widget.item?.state) ?? -1) - widgetSegmentControl.addTarget(self, action: #selector(SegmentedUITableViewCell.pickOne(_:)), for: .valueChanged) - } - - @objc - func pickOne(_ sender: Any?) { - guard let segmentedControl = sender as? UISegmentedControl, let mapping = widget.mappingsOrItemOptions[safe: segmentedControl.selectedSegmentIndex] else { - return - } - - Logger.widgets.info("Segment pressed \(segmentedControl.selectedSegmentIndex)") - widget.sendCommand(mapping.command) - feedbackGenerator.impactOccurred() - } -} diff --git a/openHAB/SelectionUITableViewCell.swift b/openHAB/SelectionUITableViewCell.swift deleted file mode 100644 index f9aad025b..000000000 --- a/openHAB/SelectionUITableViewCell.swift +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import UIKit - -class SelectionUITableViewCell: GenericUITableViewCell { - override var widget: OpenHABWidget! { - get { - super.widget - } - set(widget) { - super.widget = widget - accessoryType = .disclosureIndicator - selectionStyle = .blue - } - } - - override func displayWidget() { - super.customTextLabel?.text = widget.labelText - if let selectedMapping = widget.mappingIndex(byCommand: widget.item?.state) { - if let widgetMapping = widget?.mappingsOrItemOptions[Int(selectedMapping)] { - customDetailTextLabel?.text = widgetMapping.label - } - } else { - customDetailTextLabel?.text = "" - } - } -} diff --git a/openHAB/SetpointCell.swift b/openHAB/SetpointCell.swift deleted file mode 100644 index 49b69d822..000000000 --- a/openHAB/SetpointCell.swift +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import os.log -import UIKit - -class SetpointCell: GenericUITableViewCell { - private let setpointService: SetPointService - - @IBOutlet private var downButton: UIButton! - @IBOutlet private var upButton: UIButton! - required init?(coder: NSCoder) { - setpointService = SetPointService() - - super.init(coder: coder) - - selectionStyle = .none - separatorInset = .zero - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - setpointService = SetPointService() - super.init(style: style, reuseIdentifier: reuseIdentifier) - } - - override func awakeFromNib() { - super.awakeFromNib() - Task { @MainActor in - customDetailTextLabel.font = UIFont.monospacedDigitSystemFont( - ofSize: customDetailTextLabel.font.pointSize, - weight: .regular - ) - } - } - - override func displayWidget() { - downButton.addTarget(self, action: #selector(SetpointCell.decreaseValue), for: .touchUpInside) - upButton.addTarget(self, action: #selector(SetpointCell.increaseValue), for: .touchUpInside) - - super.displayWidget() - } - - private func handleUpDown(down: Bool) { - var numberState = widget?.stateValueAsNumberState - let currentValue = numberState?.value ?? widget.minValue - - let limitedNewValue = setpointService.calculateNewValue( - currentValue: currentValue, - step: widget.step, - minValue: widget.minValue, - maxValue: widget.maxValue, - isDecreasing: down - ) - - guard limitedNewValue != currentValue else { - // nothing to update, skip sending value - return - } - - if numberState != nil { - numberState?.value = limitedNewValue - } else { - numberState = NumberState(value: limitedNewValue) - } - - widget.sendItemUpdate(state: numberState) - } - - @objc - func decreaseValue(_ sender: Any?) { - handleUpDown(down: true) - } - - @objc - func increaseValue(_ sender: Any?) { - handleUpDown(down: false) - } -} diff --git a/openHAB/SettingsView/ScreenSaverSettingsView.swift b/openHAB/SettingsView/ScreenSaverSettingsView.swift deleted file mode 100644 index 202bd08c7..000000000 --- a/openHAB/SettingsView/ScreenSaverSettingsView.swift +++ /dev/null @@ -1,214 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import SwiftUI -import UIKit - -struct ScreenSaverSettingsView: View { - @State private var config = ScreenSaverConfiguration() - - var body: some View { - Form { - Section { - Toggle("Enable Screen Saver", isOn: Binding( - get: { config.isEnabled }, - set: { config.isEnabled = $0 } - )) - } - - Section("Appearance") { - Toggle("Show Time", isOn: Binding( - get: { config.showsTime }, - set: { newVal in config.showsTime = newVal } - )) - - Toggle("Show Date", isOn: Binding( - get: { config.showsDate }, - set: { newVal in config.showsDate = newVal } - )) - - Toggle("Show Seconds", isOn: Binding( - get: { config.showsSeconds }, - set: { config.showsSeconds = $0 } - )) - - Toggle("24-Hour Clock", isOn: Binding( - get: { config.uses24HourTime }, - set: { config.uses24HourTime = $0 } - )) - - let fontOptions: [String] = ["", "Arial", "Helvetica Neue", "Courier New", "Menlo", "Avenir Next"] - Picker("Font", selection: Binding( - get: { config.fontName ?? "" }, - set: { config.fontName = $0.isEmpty ? nil : $0 } - )) { - ForEach(fontOptions, id: \.self) { name in - Text(name.isEmpty ? "Default" : name).tag(name) - } - } - } - .disabled(!config.isEnabled) - - Section("Timing") { - Stepper(value: Binding( - get: { Int(config.idleInterval) }, - set: { config.idleInterval = TimeInterval($0) } - ), in: 5 ... 600, step: 5) { - Text("Idle Interval: \(Int(config.idleInterval)) s") - } - - Stepper(value: Binding( - get: { Int(config.movementInterval) }, - set: { config.movementInterval = TimeInterval($0) } - ), in: 2 ... 60, step: 1) { - Text("Movement Interval: \(Int(config.movementInterval)) s") - } - } - .disabled(!config.isEnabled) - - Section("Font Size") { - VStack(alignment: .leading) { - Text("Clock Size: \(Int(config.timeFontSizeRatio * 100)) %") - .font(.caption) - Slider(value: Binding( - get: { Double(config.timeFontSizeRatio) }, - set: { config.timeFontSizeRatio = CGFloat($0) } - ), in: 0.05 ... 0.4, step: 0.01) - } - - VStack(alignment: .leading) { - Text("Date relative: \(Int(config.dateFontRelativeSize * 100)) %") - .font(.caption) - Slider(value: Binding( - get: { Double(config.dateFontRelativeSize) }, - set: { config.dateFontRelativeSize = CGFloat($0) } - ), in: 0.1 ... 1.0, step: 0.05) - } - } - .disabled(!config.isEnabled) - - Section("Animation") { - VStack(alignment: .leading) { - Text("Fade Duration: \(String(format: "%.1f", config.fadeDuration)) s") - .font(.caption) - Slider(value: Binding( - get: { config.fadeDuration }, - set: { config.fadeDuration = $0 } - ), in: 0.1 ... 3.0, step: 0.1) - } - } - .disabled(!config.isEnabled) - - Section("Brightness") { - Toggle("Enable Dimming", isOn: Binding( - get: { config.enablesAutoDimming }, - set: { config.enablesAutoDimming = $0 } - )) - - VStack(alignment: .leading) { - Text("Dim Level: \(Int(config.dimLevel * 100)) %") - .font(.caption) - Slider(value: Binding( - get: { Double(config.dimLevel * 100) }, - set: { config.dimLevel = CGFloat($0) / 100 } - ), in: 0 ... 100, step: 1) - } - .disabled(!config.enablesAutoDimming) - - Toggle("Restore Previous Brightness on Wake", isOn: Binding( - get: { config.restoresBrightness }, - set: { config.restoresBrightness = $0 } - )).disabled(!config.enablesAutoDimming) - - VStack(alignment: .leading) { - Text("Restore Brightness: \(Int(config.wakeBrightnessLevel * 100)) %") - .font(.caption) - Slider(value: Binding( - get: { Double(config.wakeBrightnessLevel * 100) }, - set: { config.wakeBrightnessLevel = CGFloat($0) / 100 } - ), in: 0 ... 100, step: 1) - } - .disabled(!config.enablesAutoDimming || config.restoresBrightness) - } - .disabled(!config.isEnabled) - - Section { - Button("Test Screen Saver") { - if let keyWindow = UIApplication.shared.keyWindowActiveScene { - // Ensure the manager knows about the current key window in case monitoring was not started yet. - ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) - } - ScreenSaverManager.shared.presentSaver(configuration: config) - } - } - } - .navigationTitle("Screen Saver") - .onDisappear { - ScreenSaverManager.shared.updateConfiguration(config) - // Persist to Preferences - Preferences.shared.screensaverEnabled = config.isEnabled - Preferences.shared.screensaverShowsTime = config.showsTime - Preferences.shared.screensaverShowsDate = config.showsDate - Preferences.shared.screensaverIdleInterval = config.idleInterval - Preferences.shared.screensaverMovementInterval = config.movementInterval - Preferences.shared.screensaverFontName = config.fontName ?? "" - Preferences.shared.screensaverTimeFontRatio = Double(config.timeFontSizeRatio) - Preferences.shared.screensaverDateFontRatio = Double(config.dateFontRelativeSize) - Preferences.shared.screensaverEnableDimming = config.enablesAutoDimming - Preferences.shared.screensaverDimLevel = Double(config.dimLevel) - Preferences.shared.screensaverWakeBrightness = Double(config.wakeBrightnessLevel) - Preferences.shared.screensaverShowsSeconds = config.showsSeconds - Preferences.shared.screensaverUse24Hour = config.uses24HourTime - Preferences.shared.screensaverFadeDuration = config.fadeDuration - Preferences.shared.screensaverRestoreBrightness = config.restoresBrightness - } - .task { @MainActor in - var config = ScreenSaverConfiguration() - config.isEnabled = Preferences.shared.screensaverEnabled - config.showsTime = Preferences.shared.screensaverShowsTime - config.showsDate = Preferences.shared.screensaverShowsDate - config.idleInterval = Preferences.shared.screensaverIdleInterval - config.movementInterval = Preferences.shared.screensaverMovementInterval - config.fontName = Preferences.shared.screensaverFontName.isEmpty ? nil : Preferences.shared.screensaverFontName - config.timeFontSizeRatio = CGFloat(Preferences.shared.screensaverTimeFontRatio) - config.dateFontRelativeSize = CGFloat(Preferences.shared.screensaverDateFontRatio) - config.enablesAutoDimming = Preferences.shared.screensaverEnableDimming - config.dimLevel = CGFloat(Preferences.shared.screensaverDimLevel) - config.wakeBrightnessLevel = CGFloat(Preferences.shared.screensaverWakeBrightness) - config.showsSeconds = Preferences.shared.screensaverShowsSeconds - config.uses24HourTime = Preferences.shared.screensaverUse24Hour - config.fadeDuration = Preferences.shared.screensaverFadeDuration - config.restoresBrightness = Preferences.shared.screensaverRestoreBrightness - changeConfig(config) - } - } - - private func changeConfig(_ config: ScreenSaverConfiguration) { - self.config = config - } -} - -extension UIApplication { - var keyWindowActiveScene: UIWindow? { - connectedScenes - .compactMap { $0 as? UIWindowScene } - .first { $0.activationState == .foregroundActive }? - .windows - .first { $0.isKeyWindow } - } -} - -#Preview { - NavigationStack { - ScreenSaverSettingsView() - } -} diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift deleted file mode 100644 index 70317ba35..000000000 --- a/openHAB/SettingsView/SettingsView.swift +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import FirebaseCrashlytics -import OpenHABCore -import os -import SwiftUI - -struct SettingsView: View { - @State var settingsDemomode = false - @State var settingsIdleOff = true - @State var settingsRealTimeSliders = true - @State var settingsShowSearchField = true - @State var settingsSendCrashReports = false - @State var settingsIconType: IconType = .svg - @State var settingsSortSitemapsBy: SortSitemapsOrder = .label - @State var settingsDefaultMainUIPath = "" - @State var settingsAlwaysAllowWebRTC = true - @State var settingsSitemapForWatch = "" - - @State var sitemaps: [OpenHABSitemap] = [] - @State var settingsLocalConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") - @State var settingsRemoteConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") - @State var settingsHomeName = "" - @State var viewAppearedOnce = false - @State var settingsSSECommandItem = "" - - @Environment(\.dismiss) private var dismiss - - var body: some View { - Form { - ConnectionSettingsView( - settingsDemomode: $settingsDemomode, - localConnectionConfiguration: $settingsLocalConnectionConfiguration, - remoteConnectionConfiguration: $settingsRemoteConnectionConfiguration - ) - - ApplicationSettingsView( - settingsIdleOff: $settingsIdleOff, - settingsSSECommandItem: $settingsSSECommandItem - ) - - MainUISettingsView( - settingsAlwaysAllowWebRTC: $settingsAlwaysAllowWebRTC, - settingsDefaultMainUIPath: $settingsDefaultMainUIPath - ) - - SitemapSettingsView( - settingsRealTimeSliders: $settingsRealTimeSliders, - settingsShowSearchField: $settingsShowSearchField, - settingsIconType: $settingsIconType, - settingsSortSitemapsBy: $settingsSortSitemapsBy, - settingsSitemapForWatch: $settingsSitemapForWatch, - sitemaps: $sitemaps - ) - - DebugSettingsView( - settingsSendCrashReports: $settingsSendCrashReports - ) - - AboutSettingsView() - } - .formStyle(.grouped) - .navigationBarBackButtonHidden(true) - .navigationTitle("\(settingsHomeName) Settings") - .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - Button("Save") { - saveSettings() - NotificationCenter.default.post(name: NSNotification.Name("org.openhab.preferences.saved"), object: nil) - dismiss() - } - } - ToolbarItemGroup(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - } - } - .task { - if !viewAppearedOnce { - viewAppearedOnce = true - loadSettings() - let activeConfiguration = settingsLocalConnectionConfiguration - await updateSitemaps(activeConfiguration: activeConfiguration) - } - } - } - - private func updateSitemaps(activeConfiguration: ConnectionConfiguration) async { - do { - let openAPIService = try OpenAPIService(connectionConfiguration: activeConfiguration) - - sitemaps = try await openAPIService.openHABSitemaps() - if sitemaps.last?.name == "_default", sitemaps.count > 1 { - sitemaps = Array(sitemaps.dropLast()) - } - - // Sort the sitemaps according to Settings selection. - switch SortSitemapsOrder(rawValue: Preferences.shared.currentHomePreferences.sortSitemapsBy) ?? .label { - case .label: sitemaps.sort { $0.label < $1.label } - case .name: sitemaps.sort { $0.name < $1.name } - } - } catch { - Logger.settingsView.error("\(error.localizedDescription)") - sitemaps = [] - } - } - - private func loadSettings() { - #if !DEBUG - Logger.settingsView.debug("Loading Settings") - #endif - settingsDemomode = Preferences.shared.currentHomePreferences.demomode - settingsIdleOff = Preferences.shared.idleOff - settingsRealTimeSliders = Preferences.shared.currentHomePreferences.realTimeSliders - settingsShowSearchField = Preferences.shared.applicationPreferences.showSearchField - settingsSendCrashReports = Preferences.shared.sendCrashReports - settingsIconType = IconType(rawValue: Preferences.shared.currentHomePreferences.iconType) ?? .svg - settingsSortSitemapsBy = SortSitemapsOrder(rawValue: Preferences.shared.currentHomePreferences.sortSitemapsBy) ?? .label - settingsDefaultMainUIPath = Preferences.shared.currentHomePreferences.defaultMainUIPath - settingsAlwaysAllowWebRTC = Preferences.shared.currentHomePreferences.alwaysAllowWebRTC - settingsSitemapForWatch = Preferences.shared.currentHomePreferences.sitemapForWatch - settingsLocalConnectionConfiguration = Preferences.shared.currentHomePreferences.localConnectionConfig - settingsRemoteConnectionConfiguration = Preferences.shared.currentHomePreferences.remoteConnectionConfig - settingsHomeName = Preferences.shared.currentHomePreferences.homeName - settingsSSECommandItem = Preferences.shared.currentHomePreferences.sseCommandItem - } - - func saveSettings() { - Preferences.shared.modifyActiveHome { @MainActor homePreferences in - homePreferences.demomode = settingsDemomode - homePreferences.realTimeSliders = settingsRealTimeSliders - homePreferences.iconType = settingsIconType.rawValue - homePreferences.sortSitemapsBy = settingsSortSitemapsBy.rawValue - homePreferences.defaultMainUIPath = settingsDefaultMainUIPath - homePreferences.alwaysAllowWebRTC = settingsAlwaysAllowWebRTC - homePreferences.sitemapForWatch = settingsSitemapForWatch - homePreferences.sitemapForWatchLabel = sitemaps.first { $0.name == settingsSitemapForWatch }?.label ?? "unknown" - homePreferences.localConnectionConfig = settingsLocalConnectionConfiguration - homePreferences.remoteConnectionConfig = settingsRemoteConnectionConfiguration - homePreferences.sseCommandItem = settingsSSECommandItem - } - Preferences.shared.idleOff = settingsIdleOff - Preferences.shared.sendCrashReports = settingsSendCrashReports - - Preferences.shared.modifyApplicationPreferences { @MainActor applicationPreferences in - applicationPreferences.showSearchField = settingsShowSearchField - } - - // Apply global UI changes immediately (status bar visibility) - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) - .first?.rootViewController? - .setNeedsStatusBarAppearanceUpdate() - } -} - -extension UIApplication { - var firstKeyWindow: UIWindow? { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .filter { $0.activationState == .foregroundActive } - .first?.keyWindow - } -} - -#Preview { - struct PreviewWrapper: View { - @State var settingsDemomode = false - @State var settingsIdleOff = true - @State var settingsRealTimeSliders = true - @State var settingsShowSearchField = true - @State var settingsSendCrashReports = false - @State var settingsIconType: IconType = .svg - @State var settingsSortSitemapsBy: SortSitemapsOrder = .label - @State var settingsDefaultMainUIPath = "/overview/" - @State var settingsAlwaysAllowWebRTC = true - @State var settingsSitemapForWatch = "home" - @State var sitemaps: [OpenHABSitemap] = [ - OpenHABSitemap( - name: "home", - icon: "", - label: "Home", - link: "http://192.168.1.100/rest/sitemaps/home", - page: nil - ), - OpenHABSitemap( - name: "office", - icon: "", - label: "Office", - link: "http://192.168.1.100/rest/sitemaps/office", - page: nil - ) - ] - @State var localConnectionConfiguration = ConnectionConfiguration( - url: "http://192.168.2.1", - username: "user", - password: "password123" - ) - @State var remoteConnectionConfiguration = ConnectionConfiguration( - url: "http://192.168.2.1", - username: "user", - password: "password123" - ) - - var body: some View { - NavigationStack { - SettingsView( - settingsDemomode: settingsDemomode, - settingsIdleOff: settingsIdleOff, - settingsRealTimeSliders: settingsRealTimeSliders, - settingsShowSearchField: settingsShowSearchField, - settingsSendCrashReports: settingsSendCrashReports, - settingsIconType: settingsIconType, - settingsSortSitemapsBy: settingsSortSitemapsBy, - settingsDefaultMainUIPath: settingsDefaultMainUIPath, - settingsAlwaysAllowWebRTC: settingsAlwaysAllowWebRTC, - settingsSitemapForWatch: settingsSitemapForWatch, - sitemaps: sitemaps, - settingsLocalConnectionConfiguration: localConnectionConfiguration, - settingsRemoteConnectionConfiguration: remoteConnectionConfiguration - ) - } - .onAppear { - // Mock behavior of updateSitemaps - if settingsSitemapForWatch.isEmpty, let first = sitemaps.first { - settingsSitemapForWatch = first.name - } - } - } - } - return PreviewWrapper() -} diff --git a/openHAB/SettingsView/SitemapSettingsView.swift b/openHAB/SettingsView/SitemapSettingsView.swift deleted file mode 100644 index 743560a59..000000000 --- a/openHAB/SettingsView/SitemapSettingsView.swift +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Kingfisher -import OpenHABCore -import os -import SwiftUI - -struct SitemapSettingsView: View { - @Binding var settingsRealTimeSliders: Bool - @Binding var settingsShowSearchField: Bool - @Binding var settingsIconType: IconType - @Binding var settingsSortSitemapsBy: SortSitemapsOrder - @Binding var settingsSitemapForWatch: String - @Binding var sitemaps: [OpenHABSitemap] - - @State private var showingCacheAlert = false - - var body: some View { - Section(header: Text(LocalizedStringKey("sitemap_settings"))) { - Toggle(isOn: $settingsRealTimeSliders) { - Text("Real-time Sliders") - } - - Toggle(isOn: $settingsShowSearchField) { - Text("Show Search Field") - } - - Button { - clearWebsiteCache() - showingCacheAlert = true - } label: { - NavigationLink("Clear Image Cache", destination: EmptyView()) - } - .foregroundColor(Color(uiColor: .label)) - .alert("cache_cleared", isPresented: $showingCacheAlert) { - Button("OK", role: .cancel) {} - } - - Picker(selection: $settingsIconType) { - ForEach(IconType.allCases, id: \.self) { icontype in - Text(verbatim: "\(icontype)").tag(icontype) - } - } label: { - Text("Icon Type") - } - - Picker(selection: $settingsSortSitemapsBy) { - ForEach(SortSitemapsOrder.allCases, id: \.self) { sortsitemaporder in - Text(verbatim: "\(sortsitemaporder)").tag(sortsitemaporder) - } - } label: { - Text("Sort sitemaps by") - } - - Picker("Sitemap For Apple Watch", selection: $settingsSitemapForWatch) { - if sitemaps.isEmpty { - Text("No sitemaps available").tag("").foregroundColor(.secondary) - } else { - ForEach(sitemaps, id: \.name) { sitemap in - Text(sitemap.label).tag(sitemap.name) - } - } - } - .disabled(sitemaps.isEmpty) - } - } - - func clearWebsiteCache() { - #if !DEBUG - Logger.settingsView.debug("Clearing image cache") - #endif - KingfisherManager.shared.cache.clearMemoryCache() - KingfisherManager.shared.cache.clearDiskCache() - KingfisherManager.shared.cache.cleanExpiredDiskCache() - } -} - -#Preview { - struct PreviewWrapper: View { - @State var realTimeSliders = true - @State var showSearchField = true - @State var iconType: IconType = .svg - @State var sortSitemapsBy: SortSitemapsOrder = .label - @State var sitemapForWatch = "Home" - @State var sitemaps: [OpenHABSitemap] = [ - OpenHABSitemap( - name: "home", - icon: "", - label: "Home", - link: "http://192.168.1.100/rest/sitemaps/home", - page: nil - ), - OpenHABSitemap( - name: "office", - icon: "", - label: "Office", - link: "http://192.168.1.100/rest/sitemaps/office", - page: nil - ) - ] - var body: some View { - NavigationStack { - Form { - SitemapSettingsView( - settingsRealTimeSliders: $realTimeSliders, - settingsShowSearchField: $showSearchField, - settingsIconType: $iconType, - settingsSortSitemapsBy: $sortSitemapsBy, - settingsSitemapForWatch: $sitemapForWatch, - sitemaps: $sitemaps - ) - } - } - } - } - return PreviewWrapper() -} diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift new file mode 100644 index 000000000..3734074db --- /dev/null +++ b/openHAB/SitemapPageViewModel.swift @@ -0,0 +1,746 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +@preconcurrency import Combine +import OpenAPIRuntime +import OpenHABCore +import os.log +import SwiftUI +import UIKit + +private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "org.openhab.app", category: "SitemapPageViewModel") + +enum SitemapPageError: LocalizedError { + case noActiveConnection + case serviceUnavailable + case noData + + var errorDescription: String? { + switch self { + case .noActiveConnection: + "No active connection available." + case .serviceUnavailable: + "Service unavailable." + case .noData: + "No page data received." + } + } +} + +enum CommandLifecycleSummary: Equatable { + case idle + case sending(count: Int) + case failed(count: Int) +} + +@MainActor +class SitemapPageViewModel: ObservableObject { + @Published var currentPage: OpenHABPage? + @Published var searchText = "" + @Published var error: (any LocalizedError)? + @Published var isLoading = true + @Published var isUpdating = false + @Published var openHABRootUrl: String? + @Published var showSearchField = false + @Published private(set) var commandStates: [String: WidgetCommandLifecycleState] = [:] + + let networkTracker = MainActorNetworkTracker.shared + private var openAPIService: OpenAPIService? + private var activeConnectionInfo: ConnectionInfo? + private var pageHandlingTask: Task? + private var connectionObserverTask: Task? + private let commandDispatcher = WidgetCommandDispatcher() + private var defaultSitemap = "" + private var defaultSitemapLabel = "" + private var fallbackTitle = "" + @Published var pageId = "" + private var isLinkedPage = false + private var pageNetworkStatus: NetworkStatus? + private var pageNetworkStatusAvailable = false + private var activePageHandlingKey: String? + private var activePageHandlingID: UUID? + private var commandStateResetTasks: [String: Task] = [:] + private var commandStateVersions: [String: Int] = [:] + + /// Cache of current widget objects by widgetId for in-place updates + private var currentWidgetMap: [String: OpenHABWidget] = [:] + + var relevantWidgets: [OpenHABWidget] { + let widgets = currentPage?.widgets ?? [] + guard !searchText.isEmpty else { return widgets } + return widgets.filter { + $0.label.lowercased().contains(searchText.lowercased()) && $0.type != .frame + } + } + + var pageTitle: String { + // Strip bracket content from title (e.g., "Living Room[2]" becomes "Living Room") + let title = currentPage?.title.components(separatedBy: "[")[0].trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !title.isEmpty { + return title + } else if !fallbackTitle.isEmpty { + return fallbackTitle + } else if !defaultSitemapLabel.isEmpty { + return defaultSitemapLabel + } else { + // Return empty — SitemapPageView shows a redacted placeholder title when loading + return "" + } + } + + var isLinked: Bool { + isLinkedPage + } + + var commandLifecycleSummary: CommandLifecycleSummary { + let failedCount = commandStates.values.reduce(into: 0) { result, state in + if case .failed = state { + result += 1 + } + } + if failedCount > 0 { + return .failed(count: failedCount) + } + + let sendingCount = commandStates.values.reduce(into: 0) { result, state in + if case .sending = state { + result += 1 + } + } + if sendingCount > 0 { + return .sending(count: sendingCount) + } + return .idle + } + + init() { + loadSettings() + // Observe connection changes (skip initial value) — initial load is triggered by .task in the view + connectionObserverTask = Task { [weak self] in + guard let tracker = self?.networkTracker else { return } + for await connection in tracker.$activeConnection.values.dropFirst() { + await MainActor.run { [weak self] in + self?.handleActiveConnectionChange(connection) + } + } + } + } + + init(pageUrl: String, title: String, pageId: String = "") { + loadSettings() + isLinkedPage = true + fallbackTitle = title + defaultSitemapLabel = title + + // Set openHABRootUrl from current active connection for charts/images + openHABRootUrl = networkTracker.activeConnection?.configuration.url + + // Extract pageId from URL if not provided + if pageId.isEmpty { + if let urlComponents = URLComponents(string: pageUrl), + let extractedPageId = urlComponents.queryItems?.first(where: { $0.name == "sitemap" })?.value { + self.pageId = extractedPageId + } else if let lastPathComponent = URL(string: pageUrl)?.lastPathComponent { + self.pageId = lastPathComponent + } + } else { + self.pageId = pageId + } + } + + /// Initializes the view model with a fixed set of widgets, without loading or polling + init(pageUrl: String = "", title: String = "Preview Page", pageId: String = "", widgets: [OpenHABWidget]) { + isLinkedPage = !pageUrl.isEmpty + fallbackTitle = title + self.pageId = pageId + currentPage = OpenHABPage( + pageId: pageId.isEmpty ? UUID().uuidString : pageId, + title: title, + link: pageUrl, + leaf: false, + widgets: widgets, + icon: "" + ) + } + + deinit { + connectionObserverTask?.cancel() + pageHandlingTask?.cancel() + commandStateResetTasks.values.forEach { $0.cancel() } + commandStateResetTasks.removeAll() + } +} + +@MainActor +extension SitemapPageViewModel { + func loadSettings() { + Task { + defaultSitemap = await Preferences.shared.currentHomePreferences.defaultSitemap + showSearchField = await Preferences.shared.applicationPreferences.showSearchField + } + } + + func stopPageHandling() { + pageHandlingTask?.cancel() + pageHandlingTask = nil + activePageHandlingKey = nil + activePageHandlingID = nil + } + + func startPageHandling(forceRestart: Bool = false, reason: String = "manual") { + let requestedKey = "\(defaultSitemap)|\(pageId)" + if !forceRestart, + let activeTask = pageHandlingTask, + !activeTask.isCancelled, + activePageHandlingKey == requestedKey { + logger.info("Skipping duplicate page handling start for \(requestedKey, privacy: .public), reason: \(reason, privacy: .public)") + return + } + + pageHandlingTask?.cancel() + error = nil // Clear any previous errors when starting a new page handling session + isLoading = true // Show redacted view immediately + + let runID = UUID() + activePageHandlingID = runID + activePageHandlingKey = requestedKey + + logger.info("🚀 Starting page load and long polling flow (reason: \(reason, privacy: .public), run: \(runID.uuidString, privacy: .public), key: \(requestedKey, privacy: .public))") + + pageHandlingTask = Task { + defer { + if activePageHandlingID == runID { + pageHandlingTask = nil + activePageHandlingID = nil + } + } + + do { + guard await ensureSitemapAvailableForHandling() else { return } + guard let activeConnection = await waitForConnectionForHandling() else { return } + + try setupServiceIfNeeded(activeConnection: activeConnection) + + if defaultSitemapLabel.isEmpty { + await fetchSitemapLabel() + } + + try await loadInitialPageForHandling(runID: runID) + isLoading = false + try await runLongPollingLoop(runID: runID) + } catch { + handlePageHandlingError(error) + } + } + } + + private func ensureSitemapAvailableForHandling() async -> Bool { + if defaultSitemap.isEmpty { + await discoverAndSelectSitemap() + } + guard !defaultSitemap.isEmpty else { + logger.error("startPageHandling: Cannot run with empty sitemap after discovery") + isLoading = false + return false + } + return true + } + + private func waitForConnectionForHandling() async -> ConnectionInfo? { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { + logger.error("Failed to establish connection within timeout") + isLoading = false + return nil + } + activeConnectionInfo = activeConnection + openHABRootUrl = activeConnection.configuration.url + return activeConnection + } + + private func setupServiceIfNeeded(activeConnection: ConnectionInfo) throws { + if openAPIService == nil { + openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) + } + } + + private func loadInitialPageForHandling(runID: UUID) async throws { + let initialPage = try await openAPIService?.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: false + ) + + try Task.checkCancellation() + guard activePageHandlingID == runID else { + logger.info("Ignoring stale initial page result for run \(runID.uuidString, privacy: .public)") + return + } + + if let page = initialPage { + updateUI(with: page) + } + } + + private func runLongPollingLoop(runID: UUID) async throws { + while !Task.isCancelled { + let page = try await openAPIService?.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: true + ) + try Task.checkCancellation() + guard activePageHandlingID == runID else { + logger.info("Ignoring stale long-poll result for run \(runID.uuidString, privacy: .public)") + return + } + + if let page { + updateUI(with: page) + } + } + } + + @MainActor + private func updateUI(with page: OpenHABPage) { + let newWidgets = page.widgets + + // Check if list structure changed (count, order, or IDs) + let currentWidgets = currentPage?.widgets ?? [] + let structureChanged = currentWidgets.count != newWidgets.count + || !zip(currentWidgets, newWidgets).allSatisfy { $0.widgetId == $1.widgetId } + + // Update existing widget properties in-place to preserve @ObservedObject + // references and avoid image/chart flickering from body re-evaluation + for newWidget in newWidgets { + if let existing = currentWidgetMap[newWidget.widgetId] { + existing.label = newWidget.label + existing.icon = newWidget.icon + existing.state = newWidget.state + existing.item = newWidget.item + existing.iconColor = newWidget.iconColor + existing.labelcolor = newWidget.labelcolor + existing.valuecolor = newWidget.valuecolor + existing.url = newWidget.url + existing.period = newWidget.period + existing.service = newWidget.service + existing.legend = newWidget.legend + existing.refresh = newWidget.refresh + existing.height = newWidget.height + existing.forceAsItem = newWidget.forceAsItem + existing.mappings = newWidget.mappings + existing.widgets = newWidget.widgets + existing.linkedPage = newWidget.linkedPage + existing.visibility = newWidget.visibility + existing.staticIcon = newWidget.staticIcon + } + } + + // Only replace currentPage when structure or title changed + if structureChanged || currentPage?.title != page.title || currentPage == nil { + injectSendCommand(for: page.widgets) + currentPage = page + // Rebuild the widget map + currentWidgetMap = Dictionary(uniqueKeysWithValues: page.widgets.map { ($0.widgetId, $0) }) + } else { + // Inject sendCommand into existing widgets without replacing the page + injectSendCommand(for: currentPage?.widgets ?? []) + } + } + + func reload() async { + do { + isLoading = true + try await setupConnection() + // Fetch sitemap label if we don't have it yet + if defaultSitemapLabel.isEmpty { + await fetchSitemapLabel() + } + try await loadCurrentPage() + } catch { + self.error = error as? any LocalizedError + } + isLoading = false + } + + private func setupConnection() async throws { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { + throw SitemapPageError.noActiveConnection + } + + activeConnectionInfo = activeConnection + openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) + } + + private func loadCurrentPage() async throws { + guard let service = openAPIService else { throw SitemapPageError.serviceUnavailable } + + guard let page = try await service.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: false + ) else { + throw SitemapPageError.noData + } + + injectSendCommand(for: page.widgets) + currentPage = page + } + + private func injectSendCommand(for widgets: [OpenHABWidget]) { + for widget in widgets { + widget.sendCommand = { [weak self] item, command in + self?.sendCommand(item, commandToSend: command) + } + + // If widget has nested children (e.g., frames/groups), inject recursively + injectSendCommand(for: widget.widgets) + } + } + + @MainActor + func pushSitemap(name: String, path: String?) async { + defaultSitemap = name + defaultSitemapLabel = "" // Clear old label so it gets fetched for the new sitemap + pageId = path ?? "" + error = nil // Clear any previous errors when switching sitemaps + startPageHandling(forceRestart: true, reason: "push-sitemap") + } + + private func fetchSitemapLabel() async { + guard let service = openAPIService else { + logger.error("OpenAPI service not available for fetching sitemap label") + return + } + + do { + let sitemaps = try await service.openHABSitemaps() + + // Find the sitemap matching our defaultSitemap name and get its label + if let sitemap = sitemaps.first(where: { $0.name == defaultSitemap }) { + defaultSitemapLabel = sitemap.label + // swiftformat:disable:next redundantSelf + logger.info("Found label '\(self.defaultSitemapLabel)' for sitemap '\(self.defaultSitemap)'") + } else { + // swiftformat:disable:next redundantSelf + logger.warning("Could not find sitemap '\(self.defaultSitemap)' in available sitemaps") + } + } catch { + logger.warning("Failed to fetch sitemap label: \(error)") + // Don't set error here as this is not critical - we can continue without the label + } + } + + private func discoverAndSelectSitemap() async { + do { + try await setupConnection() + guard let service = openAPIService else { + logger.error("Could not setup service for sitemap discovery") + return + } + + let sitemaps = try await service.openHABSitemaps() + + // Filter out _default sitemap if there are multiple sitemaps available + let filteredSitemaps = sitemaps.count > 1 ? sitemaps.filter { $0.name != "_default" } : sitemaps + + let defaultSitemap = defaultSitemap + + switch filteredSitemaps.count { + case 1: + // Auto-select the only available sitemap + self.defaultSitemap = filteredSitemaps[0].name + defaultSitemapLabel = filteredSitemaps[0].label + // swiftformat:disable:next redundantSelf + logger.info("Auto-selected single sitemap: \(self.defaultSitemap)") + + // Save as default for future launches + await Preferences.shared.modifyActiveHome { homePreferences in + homePreferences.defaultSitemap = defaultSitemap + } + case 2...: + // Multiple sitemaps available - select the first one + self.defaultSitemap = filteredSitemaps[0].name + defaultSitemapLabel = filteredSitemaps[0].label + // swiftformat:disable:next redundantSelf + logger.info("Auto-selected first sitemap from \(filteredSitemaps.count) available: \(self.defaultSitemap)") + + // Save as default for future launches + await Preferences.shared.modifyActiveHome { homePreferences in + homePreferences.defaultSitemap = defaultSitemap + } + default: + logger.error("No sitemaps available") + error = SitemapPageError.serviceUnavailable + } + } catch { + logger.error("Failed to discover sitemaps: \(error)") + self.error = error as? any LocalizedError ?? SitemapPageError.serviceUnavailable + } + } + + func handleActiveConnectionChange(_ activeConnection: ConnectionInfo?) { + guard let activeConnection else { return } + + logger.info("SitemapPageViewModel tracker URL \(activeConnection.configuration.url)") + + // Skip if already connected to this URL — avoids restarting long-polling + // when the NetworkTracker re-evaluates to the same connection + let connectionDidChange = openHABRootUrl != activeConnection.configuration.url + let hasRunningPageTask = pageHandlingTask != nil && pageHandlingTask?.isCancelled == false + let networkStatusDidChange = pageNetworkStatusChanged() + guard connectionDidChange || (networkStatusDidChange && !hasRunningPageTask) else { + return + } + + Task { + await handleActiveConnection(activeConnection) + } + } + + @discardableResult + private func pageNetworkStatusChanged() -> Bool { + logger.info("SitemapPageViewModel pageNetworkStatusChange") + + let currentStatus = MainActorNetworkTracker.shared.status + + // First run + if !pageNetworkStatusAvailable { + pageNetworkStatus = currentStatus + pageNetworkStatusAvailable = true + return false + } + + if pageNetworkStatus == currentStatus { + return false + } else { + pageNetworkStatus = currentStatus + return true + } + } + + private func handleActiveConnection(_ connection: ConnectionInfo) async { + let previousURL = activeConnectionInfo?.configuration.url + let newURL = connection.configuration.url + let connectionDidChange = previousURL != newURL + + // Save the active connection information + activeConnectionInfo = connection + openHABRootUrl = newURL + + do { + // Setup the OpenAPI service based on the new connection + openAPIService = try OpenAPIService(connectionConfiguration: connection.configuration) + // Restart when connection changed, or when polling is currently inactive. + let shouldRestart = connectionDidChange + || pageHandlingTask == nil + || pageHandlingTask?.isCancelled == true + if shouldRestart { + startPageHandling(forceRestart: true, reason: connectionDidChange ? "connection-changed" : "connection-recovered") + } + } catch { + self.error = error as? any LocalizedError + } + } + + func selectSitemap() async { + startPageHandling(forceRestart: true, reason: "select-sitemap") + } + + // MARK: - Command Sending + + func sendCommand(_ command: String?, + for widget: OpenHABWidget, + policy: WidgetCommandPolicy = .immediate, + phase: WidgetCommandPhase = .change, + key: String? = nil, + fallbackItem: OpenHABItem? = nil) { + commandDispatcher.send( + command, + for: widget, + policy: policy, + phase: phase, + key: key, + fallbackItem: fallbackItem + ) + } + + func cancelPendingCommand(for widget: OpenHABWidget, key: String? = nil) { + commandDispatcher.cancelPending(for: widget, key: key) + } + + func cancelPendingCommand(for item: OpenHABItem, key: String? = nil) { + commandDispatcher.cancelPending(for: item, key: key) + } + + func sendCommand(_ item: OpenHABItem?, commandToSend command: String?) { + commandDispatcher.send(command, for: item, policy: .immediate, phase: .change) { [weak self] itemname, command in + self?.sendCommand(itemname: itemname, command: command) + } + } + + func sendCommand(itemname: String, command: String) { + let version = nextCommandVersion(for: itemname) + setCommandState(.sending, for: itemname) + let deviceId = UIDevice.current.identifierForVendor?.uuidString + Task { [weak self] in + guard let self else { return } + do { + try await openAPIService?.sendItemCommand( + itemname: itemname, + command: command, + sourcePrefix: nil, + deviceId: deviceId + ) + logger.info("Successfully sent command \(command) to \(itemname)") + handleCommandSuccess(for: itemname, version: version) + } catch { + logger.info("Failed to send command\(command) to \(itemname): \(error.localizedDescription)") + handleCommandFailure(for: itemname, version: version, errorDescription: error.localizedDescription) + } + } + } + + func sendToUpdate(item: OpenHABItem?, + state: NumberState?, + policy: WidgetCommandPolicy = .immediate, + phase: WidgetCommandPhase = .change, + key: String? = nil) { + guard let item, let state else { + logger.info("ItemUpdate for Item or State = nil") + return + } + let command: String = if item.isOfTypeOrGroupType(.numberWithDimension) { + // For number items, include unit (if present) in command + state.toString(locale: Locale(identifier: "US")) + } else { + // For all other items, send the plain value + state.stringValue + } + commandDispatcher.send(command, for: item, policy: policy, phase: phase, key: key) { [weak self] itemname, command in + self?.sendCommand(itemname: itemname, command: command) + } + } +} + +@MainActor +private extension SitemapPageViewModel { + func handlePageHandlingError(_ error: any Error) { + if error is CancellationError { + logger.info("🔁 pageHandlingTask was cancelled") + isLoading = false + isUpdating = false + return + } + + if let decodingError = error as? DecodingError { + guard !Task.isCancelled else { + logger.info("Task cancelled, ignoring DecodingError") + isLoading = false + isUpdating = false + return + } + logger.error("Decoding error: \(decodingError.localizedDescription)") + self.error = SitemapPageError.serviceUnavailable + isLoading = false + isUpdating = false + return + } + + if let clientError = error as? ClientError { + if let urlError = clientError.underlyingError as? URLError, urlError.code == .cancelled { + logger.info("Task cancelled (URLError: cancelled)") + } else if let urlError = clientError.underlyingError as? URLError, urlError.code == .timedOut { + logger.info("Task timed out (URLError: timedOut)") + } else { + guard !Task.isCancelled else { + logger.info("Task cancelled, ignoring ClientError") + isLoading = false + isUpdating = false + return + } + logger.error("ClientError: \(clientError.localizedDescription)") + self.error = SitemapPageError.serviceUnavailable + } + isLoading = false + isUpdating = false + return + } + + if let openAPIError = error as? OpenAPIServiceError { + logger.error("OpenAPIServiceError: \(openAPIError.localizedDescription)") + isLoading = false + isUpdating = false + return + } + + guard !Task.isCancelled else { + logger.info("Task cancelled, ignoring error") + isLoading = false + isUpdating = false + return + } + logger.error("❌ Unhandled pageHandlingTask error: \(error.localizedDescription)") + self.error = SitemapPageError.serviceUnavailable + isLoading = false + isUpdating = false + } + + func nextCommandVersion(for itemname: String) -> Int { + let newVersion = (commandStateVersions[itemname] ?? 0) + 1 + commandStateVersions[itemname] = newVersion + return newVersion + } + + func setCommandState(_ state: WidgetCommandLifecycleState, for itemname: String) { + commandStateResetTasks[itemname]?.cancel() + commandStateResetTasks[itemname] = nil + + switch state { + case .idle: + commandStates.removeValue(forKey: itemname) + case .sending, .failed: + commandStates[itemname] = state + } + } + + func handleCommandSuccess(for itemname: String, version: Int) { + guard commandStateVersions[itemname] == version else { return } + scheduleCommandStateReset(for: itemname, version: version, after: .milliseconds(450)) + } + + func handleCommandFailure(for itemname: String, version: Int, errorDescription: String) { + guard commandStateVersions[itemname] == version else { return } + setCommandState(.failed(message: errorDescription), for: itemname) + } + + func scheduleCommandStateReset(for itemname: String, version: Int, after delay: Duration) { + commandStateResetTasks[itemname]?.cancel() + commandStateResetTasks[itemname] = Task { @MainActor [weak self] in + try? await Task.sleep(for: delay) + guard let self else { return } + guard commandStateVersions[itemname] == version else { return } + setCommandState(.idle, for: itemname) + } + } +} + +extension Published.Publisher where Output: Sendable { + func stream() -> AsyncStream { + AsyncStream { continuation in + let cancellable = self.sink { value in + continuation.yield(value) + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + } +} diff --git a/openHAB/SliderUITableViewCell.swift b/openHAB/SliderUITableViewCell.swift deleted file mode 100644 index 4aa378295..000000000 --- a/openHAB/SliderUITableViewCell.swift +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import os.log -import UIKit - -class SliderUITableViewCell: GenericUITableViewCell { - private var step: Float = 1.0 - - private var widgetValue: Double { - adj(Double(widgetSlider?.value ?? Float(widget.minValue))) - } - - @IBOutlet private var widgetSlider: UISlider! - @IBOutlet private var customDetailText: UILabel! - - required init?(coder: NSCoder) { - super.init(coder: coder) - initialize() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - initialize() - } - - // swiftlint:disable:next type_contents_order - override func initialize() { - selectionStyle = .none - separatorInset = .zero - if let widget { - step = Float(widget.step) - } else { - step = 1.0 - } - } - - @IBAction private func sliderValueChanged(_ sender: UISlider) { - customDetailText?.text = widgetValue.valueText(step: widget.step) - // Calling sliderDidChange leads to interference with other cells. - // sliderDidChange(toValue: widgetValue) - } - - @IBAction private func sliderTouchUp(_ sender: UISlider) { - sliderDidChange(toValue: widgetValue) - touchEventDelegate?.touchUp() - } - - @IBAction private func sliderTouchDown(_ sender: UISlider) { - touchEventDelegate?.touchDown() - } - - @IBAction private func sliderTouchOutside(_ sender: UISlider) { - sliderTouchUp(sender) - } - - private func adj(_ raw: Double) -> Double { - var valueAdjustedToStep = Double(floor(Float(((raw - widget.minValue))) / step) * step) - valueAdjustedToStep += widget.minValue - return valueAdjustedToStep.clamped(to: widget.minValue ... widget.maxValue) - } - - private func adjustedValue() -> Double { - if let item = widget.item { - adj(item.stateAsDouble()) - } else { - widget.minValue - } - } - - override func displayWidget() { - // guard !isInTransition else { return } - - if let item = widget.item, item.isOfTypeOrGroupType(.color) { - widgetSlider?.minimumValue = 0.0 - widgetSlider?.maximumValue = 100.0 - step = 1.0 - widgetSlider.value = Float(item.state?.parseAsBrightness() ?? 0) - } else { - // Fix "The stepSize must be 0, or a factor of the valueFrom-valueTo range" exception - widgetSlider?.minimumValue = Float(widget.minValue) - widgetSlider?.maximumValue = Float(widget.maxValue) - let widgetValue = adjustedValue() - widgetSlider?.value = Float(widgetValue) - step = Float(widget.step) - - // if there is a formatted value in widget label, take it. Otherwise display local value - if let labelValue = widget?.labelValue { - customDetailText?.text = labelValue - } else { - customDetailText?.text = widgetValue.valueText(step: Double(step)) - } - } - customTextLabel?.text = widget.labelText - } - - private func sliderDidChange(toValue value: Double) { - Logger.widgets.info("Slider new value = \(value)") - widget.sendCommand(value.valueText(step: Double(step))) - } -} diff --git a/openHAB/SliderWithSwitchSupportUITableViewCell.swift b/openHAB/SliderWithSwitchSupportUITableViewCell.swift deleted file mode 100644 index ccc480166..000000000 --- a/openHAB/SliderWithSwitchSupportUITableViewCell.swift +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import AVFoundation -import AVKit -import Combine -import OpenHABCore -import os.log - -class SliderWithSwitchSupportUITableViewCell: GenericUITableViewCell { - private var step: Float = 1.0 - - private var widgetValue: Double { - adj(Double(widgetSlider?.value ?? Float(widget.minValue))) - } - - @IBOutlet private var widgetSlider: UISlider! - @IBOutlet private var widgetSwitch: UISwitch! - @IBOutlet private var customDetailText: UILabel! - - required init?(coder: NSCoder) { - super.init(coder: coder) - initialize() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - initialize() - } - - @IBAction private func sliderValueChanged(_ sender: UISlider) { - customDetailText?.text = widgetValue.valueText(step: widget.step) - // Calling sliderDidChange leads to interference with other cells. - // sliderDidChange(toValue: widgetValue) - } - - @IBAction private func sliderTouchUp(_ sender: UISlider) { - sliderDidChange(toValue: widgetValue) - touchEventDelegate?.touchUp() - } - - @IBAction private func sliderTouchDown(_ sender: UISlider) { - touchEventDelegate?.touchDown() - } - - @IBAction private func sliderTouchOutside(_ sender: UISlider) { - sliderTouchUp(sender) - } - - override func initialize() { - selectionStyle = .none - separatorInset = .zero - if let widget { - step = Float(widget.step) - } else { - step = 1.0 - } - } - - private func adj(_ raw: Double) -> Double { - var valueAdjustedToStep = Double(floor(Float(((raw - widget.minValue))) / step) * step) - valueAdjustedToStep += widget.minValue - return valueAdjustedToStep.clamped(to: widget.minValue ... widget.maxValue) - } - - private func adjustedValue() -> Double { - if let item = widget.item { - adj(item.stateAsDouble()) - } else { - widget.minValue - } - } - - override func displayWidget() { - // guard !isInTransition else { return } - - customTextLabel?.text = widget.labelText - var state = widget.state - // if state is nil or empty using the item state ( OH 1.x compatability ) - if state.isEmpty { - state = (widget.item?.state) ?? "" - } - widgetSwitch?.isOn = state.parseAsBool() - widgetSwitch?.addTarget(self, action: .switchChange, for: .valueChanged) - super.displayWidget() - - if let item = widget.item, item.isOfTypeOrGroupType(.color) { - widgetSlider?.minimumValue = 0.0 - widgetSlider?.maximumValue = 100.0 - step = 1.0 - widgetSlider.value = Float(item.state?.parseAsBrightness() ?? 0) - } else { - // Fix "The stepSize must be 0, or a factor of the valueFrom-valueTo range" exception - widgetSlider?.minimumValue = Float(widget.minValue) - widgetSlider?.maximumValue = Float(widget.maxValue) - let widgetValue = adjustedValue() - widgetSlider?.value = Float(widgetValue) - step = Float(widget.step) - - // if there is a formatted value in widget label, take it. Otherwise display local value - if let labelValue = widget?.labelValue { - customDetailText?.text = labelValue - } else { - customDetailText?.text = widgetValue.valueText(step: Double(step)) - } - } - customTextLabel?.text = widget.labelText - } - - private func sliderDidChange(toValue value: Double) { - Logger.widgets.info("Slider new value = \(value)") - widget.sendCommand(value.valueText(step: Double(step))) - } - - @objc - func switchChange() { - if (widgetSwitch?.isOn)! { - Logger.widgets.info("Switch to ON") - widget.sendCommand("ON") - } else { - Logger.widgets.info("Switch to OFF") - widget.sendCommand("OFF") - } - } -} - -private extension Selector { - static let switchChange = #selector(SwitchUITableViewCell.switchChange) -} diff --git a/openHAB/SpinnerViewController.swift b/openHAB/SpinnerViewController.swift deleted file mode 100644 index db86de35e..000000000 --- a/openHAB/SpinnerViewController.swift +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import UIKit - -class SpinnerViewController: UIViewController { - private var spinner: UIActivityIndicatorView = if #available(iOS 13.0, *) { - .init(style: .large) - } else { - .init(style: .gray) - } - - override func loadView() { - view = UIView() - view.backgroundColor = UIColor(white: 0, alpha: 0.7) - - spinner.translatesAutoresizingMaskIntoConstraints = false - spinner.startAnimating() - view.addSubview(spinner) - - spinner.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true - spinner.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true - } -} diff --git a/openHAB/ColorPickerView.swift b/openHAB/SwiftUI/ColorPickerView.swift similarity index 100% rename from openHAB/ColorPickerView.swift rename to openHAB/SwiftUI/ColorPickerView.swift diff --git a/openHAB/HomeSelectionView.swift b/openHAB/SwiftUI/HomeSelectionView.swift similarity index 90% rename from openHAB/HomeSelectionView.swift rename to openHAB/SwiftUI/HomeSelectionView.swift index 2bd3ce6a6..69f40ca18 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/SwiftUI/HomeSelectionView.swift @@ -48,7 +48,7 @@ struct HomeSelectionView: View { if Preferences.shared.currentHomePreferences.id == home, !showEditOptions { Spacer() Image(systemSymbol: .checkmark) - .foregroundColor(.blue) + .foregroundStyle(.blue) } else if !showEditOptions { Spacer() // make more of the cell clickable } @@ -132,7 +132,7 @@ struct HomeSelectionView: View { .tint(.blue) } } - .onAppear(perform: loadHomesList) + .task(loadHomesList) .navigationBarTitle("Manage Homes") .toolbar { if showEditOptions { @@ -174,21 +174,25 @@ struct HomeSelectionView: View { } private func select(home: UUID) { - Preferences.shared.switchActiveHome(to: home) - dismiss() + Task { + await Preferences.shared.switchActiveHome(to: home) + dismiss() + } } - private func loadHomesList() { - homes = Preferences.shared.listStoredHomes() + private func loadHomesList() async { + await homes = Preferences.shared.listStoredHomes() } private func delete(home toDelete: UUID?) { guard let toDelete else { return } - Logger.selectionView.info("delete home settings for \(toDelete.uuidString)") - Preferences.shared.deleteStoredHome(toDelete) - loadHomesList() + Task { + Logger.selectionView.info("delete home settings for \(toDelete.uuidString)") + await Preferences.shared.deleteStoredHome(toDelete) + await loadHomesList() + } } private func rename(home toRename: UUID?) { @@ -197,12 +201,16 @@ struct HomeSelectionView: View { } let newName = newHomeName Logger.selectionView.info("rename home \(toRename.uuidString) to \(newName)") - Preferences.shared.renameHome(toRename, newHomeName: newName) + Task { + await Preferences.shared.renameHome(toRename, newHomeName: newName) + } } private func addHome() { - Preferences.shared.createAndLoadNewStoredSettings(homeName: newHomeName) - loadHomesList() + Task { + await Preferences.shared.createAndLoadNewStoredSettings(homeName: newHomeName) + await loadHomesList() + } } } diff --git a/openHAB/NoIconDisplayableCell.swift b/openHAB/SwiftUI/NoIconDisplayableCell.swift similarity index 93% rename from openHAB/NoIconDisplayableCell.swift rename to openHAB/SwiftUI/NoIconDisplayableCell.swift index 832865f43..e5a9cb87a 100644 --- a/openHAB/NoIconDisplayableCell.swift +++ b/openHAB/SwiftUI/NoIconDisplayableCell.swift @@ -10,5 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 // No icon will be displazed for cells that conform to NoIconDisplayableCell protocol +import OpenHABCore +import SwiftUI protocol NoIconDisplayableCell {} diff --git a/openHAB/NotificationsView.swift b/openHAB/SwiftUI/NotificationsView.swift similarity index 96% rename from openHAB/NotificationsView.swift rename to openHAB/SwiftUI/NotificationsView.swift index e99d45192..85e57128d 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/SwiftUI/NotificationsView.swift @@ -29,14 +29,14 @@ struct NotificationRow: View { } .resizable() .frame(width: 40, height: 40) - .cornerRadius(8) + .clipShape(.rect(cornerRadius: 8)) VStack(alignment: .leading) { Text(notification.message ?? "") .font(.body) if let timeStamp = notification.created { Text(dateString(from: timeStamp)) .font(.caption) - .foregroundColor(.gray) + .foregroundStyle(.gray) } } } @@ -139,7 +139,7 @@ extension NotificationsView where Tracker == MainActorNetworkTracker { _notifications = State(initialValue: notifications) loadNotifications = { do { - guard let config = Preferences.shared.getNotificationConnection() else { + guard let config = await Preferences.shared.getNotificationConnection() else { Logger.notificationService.warning("No openHAB configuration found.") return [] } diff --git a/openHAB/SwiftUI/RootView/AppServicesViewModel.swift b/openHAB/SwiftUI/RootView/AppServicesViewModel.swift new file mode 100644 index 000000000..58e78580e --- /dev/null +++ b/openHAB/SwiftUI/RootView/AppServicesViewModel.swift @@ -0,0 +1,641 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import AsyncAlgorithms +import AVFoundation +import Combine +import FirebaseCrashlytics +import Kingfisher +import OpenHABCore +import os.log +import SafariServices +import SwiftUI + +enum TargetController { + case webview + case settings + case sitemap(String) + case notifications + case browser(String) + case tile(String) + case homeSelection +} + +enum NavigationCommand: Equatable { + case switchToWebView(path: String?) + case switchToSitemap(name: String, widgetId: String?) +} + +@MainActor +class AppServicesViewModel: ObservableObject { + // MARK: - Published state + + @Published var certificateAlert: CertificateAlertState? + @Published var crashReportAlert = false + @Published var navigationCommand: NavigationCommand? + + // MARK: - Private state + + private var cancellables = Set() + private var streamTask: Task? + private var apsDeviceToken: String? + private var apsDeviceId: String? + private var apsDeviceName: String? + private var activeConnection: ConnectionInfo? + private let synthesizer = AVSpeechSynthesizer() + + struct CertificateAlertState: Identifiable { + let id = UUID() + let title: String + let message: String + let delegate: HTTPClientDelegate + } + + init() { + setupTracker() + startSSEListening() + setupCrashReportCheck() + setupNotificationHandling() + + NotificationCenter.default.addObserver( + forName: NSNotification.Name("apsRegistered"), + object: nil, + queue: nil + ) { [weak self] note in + let deviceToken = note.userInfo?["deviceToken"] as? String + let deviceId = note.userInfo?["deviceId"] as? String + let deviceName = note.userInfo?["deviceName"] as? String + Task { @MainActor in + self?.handleApsRegistration(deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) + } + } + } + + // MARK: - SSE + + private func startSSEListening() { + Task { + await ItemEventStream.startMonitoringNetwork() + } + Logger.viewController.debug("Starting SSE") + streamTask = Task { [weak self] in + guard let self else { return } + for await msg in await ItemEventStream.shared.stream() { + await MainActor.run { self.handleSSEMessage(msg) } + } + } + } + + private func handleSSEMessage(_ msg: StreamOutput) { + switch msg { + case .connected: + Logger.viewController.debug("SSE Connected") + case let .disconnected(err): + Logger.viewController.debug("SSE Disconnected: \(err?.localizedDescription ?? "nil")") + case let .event(sm): + switch sm { + case let .state(item, state): + Logger.viewController.debug("SSE Item \(item): \(state)") + handleNotificationInternal(state) + case let .ready(uuid, _): + Logger.viewController.debug("SSE Session UUID: \(uuid)") + case let .unknown(raw): + Logger.viewController.debug("SSE Unknown: \(raw)") + default: + break + } + } + } + + // MARK: - Network Tracker + + private func setupTracker() { + NotificationCenter.default.addObserver( + forName: .evaluateServerTrust, + object: nil, + queue: nil + ) { [weak self] notification in + guard + let summary = notification.userInfo?["summary"] as? String, + let domain = notification.userInfo?["domain"] as? String, + let delegate = notification.object as? HTTPClientDelegate + else { return } + Task { @MainActor in + self?.handleCertificateTrust( + summary: summary, + domain: domain, + delegate: delegate, + messageTemplateKey: "ssl_certificate_invalid" + ) + } + } + + NotificationCenter.default.addObserver( + forName: .evaluateCertificateMismatch, + object: nil, + queue: nil + ) { [weak self] notification in + guard + let summary = notification.userInfo?["summary"] as? String, + let domain = notification.userInfo?["domain"] as? String, + let delegate = notification.object as? HTTPClientDelegate + else { return } + Task { @MainActor in + self?.handleCertificateTrust( + summary: summary, + domain: domain, + delegate: delegate, + messageTemplateKey: "ssl_certificate_no_match" + ) + } + } + + NotificationCenter.default.addObserver( + forName: .acceptedServerCertificatesChanged, + object: nil, + queue: nil + ) { _ in + Task { @MainActor in + await WatchMessageService.singleton.syncPreferencesToWatch() + await NetworkTracker.shared.restartTracking() + } + } + + // Subscribe to home preferences changes using AsyncChannel + let trackerTask = Task { @MainActor [weak self] in + guard let self else { return } + + // Get the AsyncChannel for currentHomePreferences from the actor + let preferencesChannel = await Preferences.shared.currentHomePreferencesChannel + + // Process initial value + let initialSettings = await Preferences.shared.currentHomePreferences + let localConnectionConfig = initialSettings.localConnectionConfig + let remoteConnectionConfig = initialSettings.remoteConnectionConfig + let demomode = initialSettings.demomode + let sseCommandItem = initialSettings.sseCommandItem + + if demomode { + await NetworkTracker.shared.startTracking(connectionConfigurations: [ + ConnectionConfiguration( + url: "https://demo.openhab.org", + username: "", + password: "", + priority: 0 + ) + ]) + } else { + await NetworkTracker.shared.startTracking(connectionConfigurations: [ + localConnectionConfig, + remoteConnectionConfig + ]) + await ItemEventStream.trackItems(sseCommandItem.isEmpty ? [] : [sseCommandItem]) + } + + // Listen for changes with debouncing + for await homeSettings in preferencesChannel.debounce(for: .milliseconds(500)) { + let localConnectionConfig = homeSettings.localConnectionConfig + let remoteConnectionConfig = homeSettings.remoteConnectionConfig + let demomode = homeSettings.demomode + let sseCommandItem = homeSettings.sseCommandItem + + if demomode { + await NetworkTracker.shared.startTracking(connectionConfigurations: [ + ConnectionConfiguration( + url: "https://demo.openhab.org", + username: "", + password: "", + priority: 0 + ) + ]) + } else { + await NetworkTracker.shared.startTracking(connectionConfigurations: [ + localConnectionConfig, + remoteConnectionConfig + ]) + await ItemEventStream.trackItems(sseCommandItem.isEmpty ? [] : [sseCommandItem]) + } + } + } + + cancellables.insert(AnyCancellable { trackerTask.cancel() }) + + MainActorNetworkTracker.shared.$activeConnection + .receive(on: DispatchQueue.main) + .sink { [weak self] activeConnection in + if let activeConnection { + self?.activeConnection = activeConnection + } + } + .store(in: &cancellables) + } + + // MARK: - Certificate Trust + + private func handleCertificateTrust(summary: String, domain: String, delegate: HTTPClientDelegate, messageTemplateKey: String) { + let title = NSLocalizedString("ssl_certificate_warning", comment: "") + let message = String(format: NSLocalizedString(messageTemplateKey, comment: ""), summary, domain) + certificateAlert = CertificateAlertState(title: title, message: message, delegate: delegate) + } + + func certificateAlertAction(_ result: CertificateEvaluateResult) { + certificateAlert?.delegate.completeEvaluation(result) + certificateAlert = nil + } + + // MARK: - Crash Report + + private func setupCrashReportCheck() { + Task { + if Crashlytics.crashlytics().didCrashDuringPreviousExecution(), !(await Preferences.shared.applicationPreferences.sendCrashReports) { + crashReportAlert = true + } + } + } + + func enableCrashReporting() { + Task { + await Preferences.shared.modifyApplicationPreferences(modificationFunction: { applicationPreferences in + applicationPreferences.sendCrashReports = true + }) + Crashlytics.crashlytics().sendUnsentReports() + } + } + + func deleteCrashReports() { + Crashlytics.crashlytics().deleteUnsentReports() + } + + // MARK: - APS Registration + + private func handleApsRegistration(deviceToken: String?, deviceId: String?, deviceName: String?) { + Logger.viewController.info("handleApsRegistration") + apsDeviceToken = deviceToken + apsDeviceId = deviceId + apsDeviceName = deviceName + subscribeToOpenhabConnectionChanges() + } + + private func subscribeToOpenhabConnectionChanges() { + struct UuidWithConnection: Hashable, Equatable { + let uuid: UUID + let connection: ConnectionConfiguration + } + + // Cancel any existing subscription task + let subscriptionTask = Task { @MainActor [weak self] in + guard let self else { return } + + // Get the AsyncChannel for storedHomes from the actor + let storedHomesChannel = await Preferences.shared.storedHomesChannel + + var previousConnections = Set() + + // Process initial value + let initialHomes = await Preferences.shared.storedHomes + var initialConnections = Set() + for (uuid, homeConfig) in initialHomes { + if let connection = await Preferences.shared.getNotificationConnection(of: homeConfig) { + initialConnections.insert(UuidWithConnection(uuid: uuid, connection: connection)) + } + } + + // Register initial homes + for newHome in initialConnections { + Logger.viewController.info("openhabConnectionSubscription uuid \(newHome.uuid) registering for push notifications (initial)") + self.registerHome(uuid: newHome.uuid, connection: newHome.connection) + } + previousConnections = initialConnections + + // Listen for changes with debouncing + for await updatedHomes in storedHomesChannel.debounce(for: .seconds(1)) { + Logger.viewController.info("openhabConnectionSubscription updated") + + // Map to connections using manual iteration for async calls + var currentConnections = Set() + for (uuid, homeConfig) in updatedHomes { + if let connection = await Preferences.shared.getNotificationConnection(of: homeConfig) { + currentConnections.insert(UuidWithConnection(uuid: uuid, connection: connection)) + } + } + + // Calculate differences + let newValues = currentConnections.subtracting(previousConnections) + let deletedValues = previousConnections.subtracting(currentConnections) + + // Register new homes + for newHome in newValues { + Logger.viewController.info("openhabConnectionSubscription uuid \(newHome.uuid) registering for push notifications") + self.registerHome(uuid: newHome.uuid, connection: newHome.connection) + } + + // Log deleted homes (deregistration not implemented) + for deletedHome in deletedValues { + Logger.viewController.warning("APNS Deregistration is missing (wanted to deregister \(deletedHome.connection.url))") + } + + previousConnections = currentConnections + } + } + + // Store the task in cancellables for proper cleanup + // We can wrap it in an AnyCancellable for compatibility + cancellables.insert(AnyCancellable { subscriptionTask.cancel() }) + } + + private func registerHome(uuid: UUID, connection: ConnectionConfiguration) { + guard let deviceId = apsDeviceId, + let deviceToken = apsDeviceToken, + let deviceName = apsDeviceName else { + Logger.viewController.fault("Cannot register homes for push notifications, no notification registration data available") + return + } + Logger.viewController.info("Registering notifications with \(connection.url)") + _ = registerHome(uuid, connection, deviceToken, deviceId, deviceName) + } + + private func registerHome(_ uuid: UUID, _ config: ConnectionConfiguration, _ deviceToken: String, _ deviceId: String, _ deviceName: String) -> Task { + Task { + do { + let client = HTTPClient(connectionConfiguration: config) + if let cloudUserId = try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) { + await Preferences.shared.setCloudUserId(cloudUserId, for: uuid) + Logger.viewController.info("my.openHAB registration succeeded with cloudUserId \(cloudUserId)") + } + Logger.viewController.info("my.openHAB registration succeeded without cloudUserId") + } catch { + Logger.viewController.error("my.openHAB registration failed \(error.localizedDescription)") + } + } + } + + // MARK: - Notification Handling + + private func setupNotificationHandling() { + NotificationCenter.default.addObserver( + forName: .openHABHandleNotificationAction, + object: nil, + queue: nil + ) { [weak self] notification in + let action = notification.userInfo?["action"] as? String + let cloudUserId = notification.userInfo?["cloudUserId"] as? String + Task { @MainActor in + self?.handleNotification(action: action, cloudUserId: cloudUserId) + } + } + } + + func handleNotification(action: String?, cloudUserId: String?) { + guard let action else { return } + + Logger.viewController.info("handleNotification cloudUserId: \(cloudUserId ?? "")") + + Task { + if let cloudUserId, + let targetHome = await Preferences.shared.storedHome(forCloudUserId: cloudUserId), + await Preferences.shared.currentHomePreferences.remoteConnectionConfig.cloudUserId != cloudUserId { + await NetworkTracker.shared.stopTracking() + Logger.viewController.info("Switching to home \(targetHome.id)") + await Preferences.shared.switchActiveHome(to: targetHome.id) + } + + let currentPreferences = await Preferences.shared.currentHomePreferences + await NetworkTracker.shared.startTracking(connectionConfigurations: + [ + currentPreferences.localConnectionConfig, + currentPreferences.remoteConnectionConfig + ] + ) + _ = await NetworkTracker.shared.waitForActiveConnection() + handleNotificationInternal(action) + } + } + + private func handleNotificationInternal(_ action: String?) { + Logger.viewController.info("handleNotificationInternal: \(action ?? "")") + + guard let action else { return } + let actionParts = action.split(separator: ":") + let cmd = actionParts.dropFirst().joined(separator: ":") + + switch actionParts[0] { + case "ui": + uiCommandAction(cmd) + case "command": + sendCommandAction(cmd) + case "http": + httpCommandAction(action) + case "app": + appCommandAction(cmd) + case "rule": + ruleCommandAction(cmd) + case "device": + deviceAction(cmd) + default: + return + } + } + + private func uiCommandAction(_ command: String) { + Logger.viewController.info("navigateCommandAction: \(command)") + let regexPattern = /^(\/basicui\/app\\?.*|\/.*|.*)$/ + if let firstMatch = command.firstMatch(of: regexPattern) { + let path = String(firstMatch.1) + Logger.viewController.info("navigateCommandAction path: \(path)") + if path.starts(with: "/basicui/app?") { + Logger.viewController.info("Navigating to sitemap target") + Task { @MainActor in + let defaultSitemap = await Preferences.shared.currentHomePreferences.defaultSitemap + guard let urlComponents = URLComponents(string: path) else { + Logger.viewController.warning("No parameters for specifying sitemap or widget to navigate to") + navigationCommand = .switchToSitemap(name: defaultSitemap, widgetId: nil) + return + } + let queryItems = urlComponents.queryItems + let sitemap = queryItems?.first { $0.name == "sitemap" }?.value + let widgetId = queryItems?.first { $0.name == "w" }?.value + navigationCommand = .switchToSitemap(name: sitemap ?? defaultSitemap, widgetId: widgetId) + } + } else { + Logger.viewController.info("Navigating to webview target") + if path.starts(with: "/") { + navigationCommand = .switchToWebView(path: path) + } else { + navigationCommand = .switchToWebView(path: path) + } + } + } else { + Logger.viewController.error("Invalid regex: \(command)") + } + } + + private func sendCommandAction(_ action: String) { + let components = action.split(separator: ":") + guard components.count == 2 else { return } + + let itemName = String(components[0]) + let itemCommand = String(components[1]) + let deviceId = UIDevice.current.identifierForVendor?.uuidString + Task { + do { + Logger.viewController.info("Sending command") + try await NetworkTracker.shared.send(to: itemName, command: itemCommand, deviceId: deviceId) + } catch NetworkTrackerError.noActiveConnection { + displayErrorNotification("Could not find server") + } catch { + displayErrorNotification("Failed to establish a connection: \(error.localizedDescription)") + Logger.viewController.error("Could not send data \(error.localizedDescription)") + } + } + } + + private func displayErrorNotification(_ message: String) { + let content = UNMutableNotificationContent() + content.title = "Could not send command" + content.body = message + content.sound = UNNotificationSound.default + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + + private func httpCommandAction(_ command: String) { + if let url = URL(string: command) { + let vc = SFSafariViewController(url: url) + UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) + } + } + + private func appCommandAction(_ command: String) { + let pairs = command.split(separator: ",") + for pair in pairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + if keyValue[0] == "ios" { + if let url = URL(string: String(keyValue[1])) { + Logger.viewController.error("appCommandAction opening \(String(keyValue[0])) \(String(keyValue[1]))") + UIApplication.shared.open(url) + return + } + } + } + } + + private func deviceAction(_ action: String) { + let cmdParts = action.split(separator: ":") + if cmdParts.isEmpty { return } + let command = cmdParts[0].lowercased() + let arg1 = cmdParts.count > 1 ? cmdParts[1].lowercased() : "" + switch command { + case "screensaver": + switch arg1 { + case "activate": + NotificationCenter.default.post(name: .activateScreenSaver, object: nil) + case "disable": + NotificationCenter.default.post(name: .disableScreenSaver, object: nil) + case "wake": + NotificationCenter.default.post(name: .wakeScreenSaver, object: nil) + default: + break + } + case "idletimer": + switch arg1 { + case "enable": + UIApplication.shared.isIdleTimerDisabled = false + case "disable": + UIApplication.shared.isIdleTimerDisabled = true + default: + break + } + case "brightness": + if let value = Double(arg1) { + let target = min(max(value, 0.0), 1.0) + UIScreen.main.brightness = target + } + case "tts": + func normalizeVoiceName(from input: String) -> String { + input + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .joined() + } + + let utterance = AVSpeechUtterance(string: arg1) + if cmdParts.count > 3 { + Logger.viewController.debug("Filtering voice \(cmdParts[2]) \(cmdParts[3])") + let voice = AVSpeechSynthesisVoice.speechVoices().filter { $0.language.lowercased() == cmdParts[2].lowercased() && normalizeVoiceName(from: $0.name) == normalizeVoiceName(from: String(cmdParts[3])) } + if !voice.isEmpty { + Logger.viewController.debug("Setting custom voice \(voice[0].name)") + utterance.voice = voice[0] + } + } else if cmdParts.count > 2 { + utterance.voice = AVSpeechSynthesisVoice(language: String(cmdParts[2])) + } + synthesizer.speak(utterance) + default: + break + } + } + + private func ruleCommandAction(_ command: String) { + let components = command.split(separator: ":", maxSplits: 2) + guard !components.isEmpty else { + Logger.viewController.warning("No rule to execute found in action") + return + } + + let uuid = String(components[0]) + let propertiesString = if components.count > 1 { String(components[1]) } else { "" } + + let propertyPairs = propertiesString.split(separator: ",") + var properties: [String: String] = [:] + + for pair in propertyPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + if keyValue.count == 2 { + let key = String(keyValue[0]) + let value = String(keyValue[1]) + properties[key] = value + } + } + Task { + do { + Logger.viewController.error("Sending command") + try await NetworkTracker.shared.runNow(ruleUID: uuid, payload: properties) + Logger.viewController.info("Request succeeded") + } catch let error as NetworkTrackerError { + displayErrorNotification("\(error.localizedDescription)") + } catch { + Logger.viewController.error("Could not send data \(error.localizedDescription)") + displayErrorNotification("Request to server failed: \(error.localizedDescription)") + } + } + } +} + +// MARK: - Kingfisher authentication + +extension AppServicesViewModel: AuthenticationChallengeResponsible { + nonisolated func downloader(_ downloader: ImageDownloader, + didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await onReceiveSessionChallenge(with: challenge) + } + + nonisolated func downloader(_ downloader: ImageDownloader, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await onReceiveSessionTaskChallenge(with: challenge) + } +} + +// MARK: - Notification name for routing + +extension Notification.Name { + static let openHABHandleNotificationAction = Notification.Name("openHABHandleNotificationAction") +} diff --git a/openHABWatch/Views/Utils/Color+Extension.swift b/openHAB/SwiftUI/RootView/MainWebTab.swift similarity index 70% rename from openHABWatch/Views/Utils/Color+Extension.swift rename to openHAB/SwiftUI/RootView/MainWebTab.swift index 54fc8cab9..c91760502 100644 --- a/openHABWatch/Views/Utils/Color+Extension.swift +++ b/openHAB/SwiftUI/RootView/MainWebTab.swift @@ -12,12 +12,10 @@ import OpenHABCore import SwiftUI -extension Color { - init(fromString string: String) { - self.init(UIColor(fromString: string)) - } - - init(hex: String) { - self.init(UIColor(hex: hex)) +struct MainWebTab: View { + let viewModel: OpenHABWebViewModel + + var body: some View { + OpenHABWebView(viewModel: viewModel) } } diff --git a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift new file mode 100644 index 000000000..7d028535c --- /dev/null +++ b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift @@ -0,0 +1,235 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Kingfisher +import OpenHABCore +import os.log +import SafariServices +import SFSafeSymbols +import SwiftUI + +enum AppTab: String, CaseIterable, Hashable { + case main + case sitemaps + case tiles + case system + + var title: String { + switch self { + case .main: "Home" + case .sitemaps: "Sitemaps" + case .tiles: "Tiles" + case .system: "System" + } + } + + var systemImage: String { + switch self { + case .main: "house" + case .sitemaps: "map" + case .tiles: "square.grid.2x2" + case .system: "gear" + } + } +} + +struct OpenHABTabRootView: View { + @StateObject private var appServices = AppServicesViewModel() + @StateObject private var networkTracker = MainActorNetworkTracker.shared + + @State private var selectedTab: AppTab + @State private var isDemoMode: Bool + @State private var enabledTabs: [AppTab] + + @State private var sitemapsResetTrigger = 0 + @State private var tilesResetTrigger = 0 + @State private var systemResetTrigger = 0 + @State private var sitemapNavigationCommand: SitemapNavigationCommand? + + private let webViewModel = OpenHABWebViewModel() + + private var tabSelectionBinding: Binding { + Binding( + get: { selectedTab }, + set: { newTab in + if newTab == selectedTab { + resetTab(newTab) + } + selectedTab = newTab + } + ) + } + + init() { + let saved = Preferences.shared.currentHomePreferences.lastSelectedTab + _selectedTab = State(initialValue: AppTab(rawValue: saved) ?? .main) + _isDemoMode = State(initialValue: Preferences.shared.currentHomePreferences.demomode) + _enabledTabs = State(initialValue: Self.computeEnabledTabs(config: Preferences.shared.currentHomePreferences.tabConfiguration)) + + #if DEBUG + if ProcessInfo.processInfo.environment["UITest"] != nil { + Preferences.shared.modifyActiveHome { homePreferences in + homePreferences.demomode = true + } + } + #endif + } + + private static func computeEnabledTabs(config: [TabEntry]) -> [AppTab] { + let tabs = config.compactMap { entry -> AppTab? in + guard entry.enabled || entry.id == AppTab.system.rawValue else { return nil } + return AppTab(rawValue: entry.id) + } + // Ensure system tab is always present + if !tabs.contains(.system) { + return tabs + [.system] + } + return tabs + } + + var body: some View { + TabView(selection: tabSelectionBinding) { + ForEach(enabledTabs, id: \.self) { tab in + Tab(tab.title, systemImage: tab.systemImage, value: tab) { + AnyView(tabContentView(for: tab)) + } + } + } + .tabViewStyle(.sidebarAdaptable) + .tabBarMinimizeBehavior(.onScrollDown) + .environmentObject(networkTracker) + .onChange(of: selectedTab) { oldTab, newTab in + Task { + await Preferences.shared.modifyActiveHome { prefs in + prefs.lastSelectedTab = newTab.rawValue + } + } + } + .onReceive(Preferences.shared.$currentHomePreferences) { _ in + Task { + let newTabs = Self.computeEnabledTabs(config: await Preferences.shared.currentHomePreferences.tabConfiguration) + if enabledTabs != newTabs { + enabledTabs = newTabs + // If current tab was disabled, switch to first available + if !enabledTabs.contains(selectedTab) { + selectedTab = enabledTabs.first ?? .system + } + } + } + } + .onAppear { + ImageDownloader.default.authenticationChallengeResponder = appServices + // Switch to sitemaps in demo mode + if Preferences.shared.currentHomePreferences.demomode { + selectedTab = .sitemaps + sitemapNavigationCommand = SitemapNavigationCommand(name: "demo", widgetId: nil) + } + } + .onReceive(appServices.$navigationCommand) { command in + guard let command else { return } + handleNavigationCommand(command) + // Reset the command after handling + Task { @MainActor in + appServices.navigationCommand = nil + } + } + // Certificate trust alert + .alert( + appServices.certificateAlert?.title ?? "", + isPresented: Binding( + get: { appServices.certificateAlert != nil }, + set: { if !$0 { appServices.certificateAlertAction(.deny) } } + ) + ) { + Button("Always") { + appServices.certificateAlertAction(.permitAlways) + } + Button("Once") { + appServices.certificateAlertAction(.permitOnce) + } + Button("Deny", role: .cancel) { + appServices.certificateAlertAction(.deny) + } + } message: { + Text(appServices.certificateAlert?.message ?? "") + } + // Crash report alert + .alert( + NSLocalizedString("crash_detected", comment: "").capitalized, + isPresented: $appServices.crashReportAlert + ) { + Button(NSLocalizedString("activate", comment: "")) { + appServices.enableCrashReporting() + } + Button(NSLocalizedString("privacy_policy", comment: "")) { + let vc = SFSafariViewController(url: URL.privacyPolicy) + vc.configuration.barCollapsingEnabled = true + UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) + } + Button(NSLocalizedString("cancel", comment: ""), role: .cancel) { + appServices.deleteCrashReports() + } + } message: { + Text(NSLocalizedString("crash_reporting_info", comment: "")) + } + .certificateManagementAlerts() // Handles certificates + .idleTimerManagement() // Manages idle timer + .statusBar(hidden: Preferences.shared.applicationPreferences.hideStatusBar) // Replace navigation controller + } + + @ViewBuilder + private func tabContentView(for tab: AppTab) -> some View { + switch tab { + case .main: + MainWebTab(viewModel: webViewModel) + case .sitemaps: + SitemapsTab(resetTrigger: sitemapsResetTrigger, navigationCommand: $sitemapNavigationCommand) + case .tiles: + TilesTab(resetTrigger: tilesResetTrigger) + case .system: + SystemTab(resetTrigger: systemResetTrigger) + } + } + + private func resetTab(_ tab: AppTab) { + switch tab { + case .main: + Task { + await webViewModel.loadWebView(force: true) + } + case .sitemaps: + sitemapsResetTrigger += 1 + case .tiles: + tilesResetTrigger += 1 + case .system: + systemResetTrigger += 1 + } + } + + private func handleNavigationCommand(_ command: NavigationCommand) { + switch command { + case let .switchToWebView(path): + selectedTab = .main + if let path { + Task { + if path.starts(with: "/") { + await webViewModel.loadWebView(force: true, path: path) + } else { + webViewModel.navigateCommand(path) + } + } + } + case let .switchToSitemap(name, widgetId): + selectedTab = .sitemaps + sitemapNavigationCommand = SitemapNavigationCommand(name: name, widgetId: widgetId) + } + } +} diff --git a/openHAB/SwiftUI/RootView/REFACTORING_SUMMARY.md b/openHAB/SwiftUI/RootView/REFACTORING_SUMMARY.md new file mode 100644 index 000000000..6ae8c3c2d --- /dev/null +++ b/openHAB/SwiftUI/RootView/REFACTORING_SUMMARY.md @@ -0,0 +1,252 @@ +# Preferences Concurrency Refactoring Summary + +## Overview +This refactoring eliminates unnecessary `@MainActor` annotations from the `Preferences` actor and provides a clean `@MainActor` observable wrapper (`PreferencesObserver`) for SwiftUI views. + +## Key Changes + +### 1. **Removed `@MainActor` from Data Structures** +- ✅ `HomePreferences` → Now `Sendable` struct (no longer `@MainActor`) +- ✅ `ApplicationPreferences` → Now `Sendable` struct (no longer `@MainActor`) +- ✅ `TabEntry` → Was already `Sendable` + +**Benefit**: These are now truly thread-safe value types that can be passed between actors without isolation concerns. + +### 2. **Removed `@MainActor` from Property Wrappers** +- ✅ `@UserDefault` property wrapper +- ✅ `@UserDefaultObject` property wrapper +- ✅ `PreferencesAccess` enum methods + +**Rationale**: Since `Preferences` is an actor, property access is already isolated by the actor. The `@MainActor` annotations were creating unnecessary constraints and concurrency conflicts. + +**Note**: `sharedDefaults` (UserDefaults) is marked as `nonisolated(unsafe)` because `UserDefaults` is not `Sendable`, but in practice it's thread-safe for read/write operations. + +### 3. **Removed `@MainActor` from Preferences Extensions** +All extension methods on `Preferences` are now properly actor-isolated: +- ✅ `listStoredHomes()` +- ✅ `createAndLoadNewStoredSettings()` +- ✅ `renameHome()` +- ✅ `setCloudUserId()` +- ✅ `deleteStoredHome()` +- ✅ `switchActiveHome()` +- ✅ `modifyActiveHome()` - No longer requires `@MainActor` closure +- ✅ `modifyApplicationPreferences()` - No longer requires `@MainActor` closure +- ✅ `firstStoredHome()` +- ✅ `storedHome(forCloudUserId:)` +- ✅ `getNotificationConnection()` + +### 4. **Updated Migration Methods** +- ✅ `migratePreferences()` → Now `async` and `nonisolated` +- ✅ `migrateToSharedDefaultsIfRequired()` → Now `async` +- ✅ `migrateToMultipleHomesIfRequired()` → Now `async` + +**Benefit**: Migration can now be called from any context and properly awaits actor-isolated operations. + +### 5. **Created `PreferencesObserver` for SwiftUI** + +**New class**: `PreferencesObserver` - A `@MainActor` observable wrapper + +```swift +@MainActor +@Observable +public final class PreferencesObserver { + public static let shared = PreferencesObserver() + + public private(set) var currentHomePreferences: HomePreferences + public private(set) var applicationPreferences: ApplicationPreferences + + // ... implementation +} +``` + +**Purpose**: +- Provides a `@MainActor`-isolated observable object for SwiftUI views +- Automatically syncs with the `Preferences` actor +- Eliminates the need for `Task` wrappers in SwiftUI code +- Uses Combine publishers to receive updates from the actor + +### 6. **Updated `SystemTab.swift`** + +**Before**: +```swift +.onReceive(Preferences.shared.$currentHomePreferences) { _ in + Task { + updateNotificationVisibility() + } +} + +private func updateNotificationVisibility() { + showNotifications = Preferences.shared.getNotificationConnection() != nil + && !Preferences.shared.currentHomePreferences.demomode +} +``` + +**After**: +```swift +@State private var preferencesObserver = PreferencesObserver.shared + +.onChange(of: preferencesObserver.currentHomePreferences) { _, _ in + Task { + await updateNotificationVisibility() + } +} + +private func updateNotificationVisibility() async { + let notificationConnection = await preferencesObserver.getNotificationConnection() + showNotifications = notificationConnection != nil + && !preferencesObserver.currentHomePreferences.demomode +} +``` + +**Benefits**: +- ✅ No more awkward `onReceive` + `Task` combination +- ✅ Clear async/await pattern +- ✅ Type-safe with `@Observable` and SwiftUI's `onChange` +- ✅ Preferences operations run on actor, UI updates on main actor + +## Architecture Benefits + +### Before +``` +┌─────────────────────┐ +│ Preferences actor │ +│ with @MainActor │ ← Forced everything onto main thread +│ property wrappers │ +└─────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ SwiftUI Views │ +│ with Task {} │ ← Awkward concurrency gymnastics +└─────────────────────┘ +``` + +### After +``` +┌─────────────────────┐ +│ Preferences actor │ +│ (background-safe) │ ← Can run on any thread +└─────────────────────┘ + │ + ▼ (publishers) +┌─────────────────────┐ +│ PreferencesObserver │ +│ @MainActor │ ← Clean bridge to UI +└─────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ SwiftUI Views │ +│ (clean code) │ ← Simple, synchronous access +└─────────────────────┘ +``` + +## Usage Patterns + +### ✅ In SwiftUI Views +```swift +@State private var preferencesObserver = PreferencesObserver.shared + +var body: some View { + Text(preferencesObserver.currentHomePreferences.homeName) + .onChange(of: preferencesObserver.currentHomePreferences) { _, newValue in + // React to changes + } +} +``` + +### ✅ In Background Tasks +```swift +Task { + await Preferences.shared.modifyActiveHome { home in + home.demomode = false + } +} +``` + +### ✅ Direct Actor Access +```swift +func updateSettings() async { + let demoMode = await Preferences.shared.currentHomePreferences.demomode + // Use demoMode... +} +``` + +## Migration Guide for Other Files + +If you have other files using `Preferences`, update them as follows: + +### Pattern 1: SwiftUI Views observing preferences +**Before**: +```swift +.onReceive(Preferences.shared.$someProperty) { newValue in + Task { + // Do something + } +} +``` + +**After**: +```swift +@State private var preferencesObserver = PreferencesObserver.shared + +.onChange(of: preferencesObserver.currentHomePreferences) { _, newValue in + Task { + await updateSomething() + } +} +``` + +### Pattern 2: Accessing preferences in async context +**Before**: +```swift +Task { @MainActor in + let value = Preferences.shared.someProperty +} +``` + +**After**: +```swift +Task { + let value = await Preferences.shared.someProperty +} +``` + +### Pattern 3: Modifying preferences +**Before**: +```swift +Task { @MainActor in + Preferences.shared.modifyActiveHome { home in + home.demomode = false + } +} +``` + +**After**: +```swift +Task { + await Preferences.shared.modifyActiveHome { home in + home.demomode = false + } +} +``` + +## Testing Considerations + +- All preference access is now properly actor-isolated +- Tests may need to be updated to use `await` when accessing `Preferences.shared` +- `PreferencesObserver` should be tested separately for its reactive behavior +- Migration methods are now `async` and should be awaited in tests + +## Potential Issues to Watch For + +1. **Other files** may still have `@MainActor` requirements that conflict with the new actor-based approach +2. **Published values**: The `$currentHomePreferences` publisher still works but now bridges from actor to main actor through `PreferencesObserver` +3. **Migration calls**: Update all calls to `Preferences.migratePreferences()` to use `await` + +## Next Steps + +1. Search codebase for other uses of `Preferences.shared` that may need updating +2. Update any tests that access preferences +3. Consider adding more properties to `PreferencesObserver` if needed by SwiftUI views +4. Monitor for any concurrency warnings in Xcode diff --git a/openHAB/SwiftUI/RootView/SafariView.swift b/openHAB/SwiftUI/RootView/SafariView.swift new file mode 100644 index 000000000..f33a35940 --- /dev/null +++ b/openHAB/SwiftUI/RootView/SafariView.swift @@ -0,0 +1,25 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import SafariServices +import SwiftUI + +struct SafariView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: Context) -> SFSafariViewController { + let config = SFSafariViewController.Configuration() + config.entersReaderIfAvailable = true + return SFSafariViewController(url: url, configuration: config) + } + + func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {} +} diff --git a/openHAB/SwiftUI/RootView/SitemapsTab.swift b/openHAB/SwiftUI/RootView/SitemapsTab.swift new file mode 100644 index 000000000..0db368bfc --- /dev/null +++ b/openHAB/SwiftUI/RootView/SitemapsTab.swift @@ -0,0 +1,171 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Kingfisher +import OpenHABCore +import os.log +import SFSafeSymbols +import SwiftUI + +struct SitemapNavigationCommand: Equatable { + let name: String + let widgetId: String? + let id = UUID() +} + +struct SitemapsTab: View { + var resetTrigger: Int = 0 + @Binding var navigationCommand: SitemapNavigationCommand? + + @State private var sitemaps: [OpenHABSitemap] = [] + @State private var selectedSitemap: SelectedSitemapIdentifier? + @State private var sitemapForWatch: String? + @StateObject private var viewModel = SitemapPageViewModel() + + private struct SelectedSitemapIdentifier: Identifiable, Hashable { + let id = UUID() + let name: String + } + + @EnvironmentObject private var networkTracker: MainActorNetworkTracker + + @ScaledMetric private var iconWidth = 24.0 + + var body: some View { + NavigationStack { + sitemapList + .navigationTitle("Sitemaps") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(item: $selectedSitemap) { sitemapName in + SitemapNavigationView(viewModel: viewModel) + } + } + .task { + sitemapForWatch = await Preferences.shared.currentHomePreferences.sitemapForWatch + await fetchSitemaps(activeConnection: networkTracker.activeConnection) + let defaultSitemap = await Preferences.shared.currentHomePreferences.defaultSitemap + if !defaultSitemap.isEmpty, sitemaps.contains(where: { $0.name == defaultSitemap }) { + selectSitemap(defaultSitemap) + } + } + .onReceive(networkTracker.$activeConnection) { activeConnection in + Task { + await fetchSitemaps(activeConnection: activeConnection) + } + } + .onChange(of: resetTrigger) { _, _ in + selectedSitemap = nil + } + .onChange(of: navigationCommand) { _, command in + guard let command else { return } + selectedSitemap = SelectedSitemapIdentifier(name: command.name) + Task { + await Preferences.shared.modifyActiveHome { preferences in + preferences.defaultSitemap = command.name + } + await viewModel.pushSitemap(name: command.name, path: command.widgetId) + } + navigationCommand = nil + } + } + + private var sitemapList: some View { + List { + ForEach(sitemaps, id: \.name) { sitemap in + Button { + selectSitemap(sitemap.name) + } label: { + HStack { + sitemapIcon(for: sitemap) + .frame(width: iconWidth, height: iconWidth) + Text(sitemap.label) + if sitemap.name == sitemapForWatch { + Spacer() + Image(systemSymbol: .applewatchWatchface) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onTapGesture(count: 2) { toggleWatchSitemap(sitemap) } + } + } + } + + private func selectSitemap(_ name: String) { + selectedSitemap = SelectedSitemapIdentifier(name: name) + Task { + await Preferences.shared.modifyActiveHome { preferences in + preferences.defaultSitemap = name + } + await viewModel.pushSitemap(name: name, path: nil) + } + } + + private func fetchSitemaps(activeConnection: ConnectionInfo?) async { + guard let activeConnection else { return } + do { + let openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) + var fetched = try await openAPIService.openHABSitemaps() + if fetched.last?.name == "_default", fetched.count > 1 { + fetched = Array(fetched.dropLast()) + } + let sortSitemapsBy = await Preferences.shared.currentHomePreferences.sortSitemapsBy + switch SortSitemapsOrder(rawValue: sortSitemapsBy) ?? .label { + case .label: + fetched.sort { $0.label < $1.label } + case .name: + fetched.sort { $0.name < $1.name } + } + sitemaps = fetched + } catch { + Logger.drawerView.error("Failed to fetch sitemaps: \(error.localizedDescription)") + sitemaps = [] + } + } + + private func sitemapIcon(for sitemap: OpenHABSitemap) -> some View { + Group { + if sitemap.icon.isEmpty { + Image("openHABIcon").resizable() + } else { + let url = Endpoint.iconForDrawer( + rootUrl: networkTracker.activeConnection?.configuration.url ?? "", + icon: sitemap.icon + ).url + KFImage(url) + .placeholder { Image("openHABIcon").resizable() } + .resizable() + } + } + .aspectRatio(contentMode: .fit) + } + + private func toggleWatchSitemap(_ sitemap: OpenHABSitemap) { + let sitemapForWatchName, sitemapForWatchLabel: String + if sitemap.name == sitemapForWatch { + sitemapForWatch = nil + sitemapForWatchName = "" + sitemapForWatchLabel = "" + } else { + sitemapForWatch = sitemap.name + sitemapForWatchName = sitemap.name + sitemapForWatchLabel = sitemap.label + } + Task { + await Preferences.shared.modifyActiveHome { prefs in + prefs.sitemapForWatch = sitemapForWatchName + prefs.sitemapForWatchLabel = sitemapForWatchLabel + } + } + } +} + diff --git a/openHAB/SwiftUI/RootView/SystemTab.swift b/openHAB/SwiftUI/RootView/SystemTab.swift new file mode 100644 index 000000000..ec480e343 --- /dev/null +++ b/openHAB/SwiftUI/RootView/SystemTab.swift @@ -0,0 +1,108 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import OpenHABCore +import SFSafeSymbols +import SwiftUI + +// Display the connected URL +struct ConnectionView: View { + @StateObject private var networkTracker = MainActorNetworkTracker.shared + + var body: some View { + HStack { + if let activeConnection = networkTracker.activeConnection { + Image(systemSymbol: .cloudFill) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + Text(activeConnection.configuration.url).font(.footnote) + } else { + Image(systemSymbol: .exclamationmarkIcloudFill) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + Text("connecting").font(.footnote) + } + } + } +} + +struct SystemTab: View { + var resetTrigger: Int = 0 + + @State private var showNotifications = false + @State private var path = NavigationPath() + @State private var preferencesObserver = PreferencesObserver.shared + + var body: some View { + NavigationStack(path: $path) { + List { + Section { + NavigationLink { + SettingsView() + } label: { + Label { + Text(LocalizedStringKey("settings")) + } icon: { + Image(systemSymbol: .gear) + } + } + + if showNotifications { + NavigationLink { + NotificationsView() + } label: { + Label { + Text(LocalizedStringKey("notifications")) + } icon: { + Image(systemSymbol: .bell) + } + } + } + + NavigationLink { + HomeSelectionView() + } label: { + Label { + Text("Manage Homes") + } icon: { + Image(systemSymbol: .house) + } + } + } + } + .navigationTitle("System") + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + ConnectionView() + .padding(.bottom, 8) + } + } + .task { + await updateNotificationVisibility() + } + .onChange(of: preferencesObserver.currentHomePreferences) { _, _ in + Task { + await updateNotificationVisibility() + } + } + .onChange(of: resetTrigger) { _, _ in + path = NavigationPath() + } + } + + private func updateNotificationVisibility() async { + let notificationConnection = await preferencesObserver.getNotificationConnection() + showNotifications = notificationConnection != nil + && !preferencesObserver.currentHomePreferences.demomode + } +} diff --git a/openHAB/SwiftUI/RootView/TilesTab.swift b/openHAB/SwiftUI/RootView/TilesTab.swift new file mode 100644 index 000000000..4ac5bab61 --- /dev/null +++ b/openHAB/SwiftUI/RootView/TilesTab.swift @@ -0,0 +1,109 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import OpenHABCore +import os.log +import SFSafeSymbols +import SwiftUI +@preconcurrency import WebKit + +struct TilesTab: View { + var resetTrigger: Int = 0 + + @State private var uiTiles: [OpenHABUiTile] = [] + @State private var path = NavigationPath() + + @EnvironmentObject private var networkTracker: MainActorNetworkTracker + + @ScaledMetric private var iconWidth = 24.0 + + var body: some View { + NavigationStack(path: $path) { + List { + ForEach(uiTiles, id: \.url) { tile in + Button { + openTile(tile) + } label: { + HStack { + ImageView(url: tile.imageUrl) + .aspectRatio(contentMode: .fit) + .frame(width: iconWidth, height: iconWidth) + Text(tile.name) + } + } + .buttonStyle(.plain) + } + } + .navigationTitle("Tiles") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(for: URL.self) { url in + TileWebView(url: url) + .ignoresSafeArea(edges: .bottom) + .navigationBarTitleDisplayMode(.inline) + } + } + .task { + await fetchTiles(activeConnection: networkTracker.activeConnection) + } + .onReceive(networkTracker.$activeConnection) { activeConnection in + Task { + await fetchTiles(activeConnection: activeConnection) + } + } + .onChange(of: resetTrigger) { _, _ in + path = NavigationPath() + } + } + + private func openTile(_ tile: OpenHABUiTile) { + let urlString = tile.url + guard !urlString.isEmpty else { return } + + let url: URL? + if urlString.hasPrefix("http") || urlString.hasPrefix("https") { + url = URL(string: urlString) + } else { + guard let rootUrl = networkTracker.activeConnection?.configuration.url else { + Logger.viewController.error("openTileURL failed: no active connection URL") + return + } + url = Endpoint.resource(openHABRootUrl: rootUrl, path: urlString.prepare()).url + } + + if let url { + path.append(url) + } + } + + private func fetchTiles(activeConnection: ConnectionInfo?) async { + guard let activeConnection else { return } + do { + let openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) + uiTiles = try await openAPIService.getUITiles() + Logger.drawerView.info("Fetched UI tiles successfully") + } catch { + Logger.drawerView.error("Failed to fetch UI tiles: \(error.localizedDescription)") + uiTiles = [] + } + } +} + +private struct TileWebView: UIViewRepresentable { + let url: URL + + func makeUIView(context: Context) -> WKWebView { + let webView = WKWebView() + webView.load(URLRequest(url: url)) + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) {} +} diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift new file mode 100644 index 000000000..5efaf6787 --- /dev/null +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -0,0 +1,266 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import os.log +import SwiftUI + +struct ButtonGridButton: View { + @ObservedObject var widget: OpenHABWidget + let parentItem: OpenHABItem? + + @State private var isPressed = false + @EnvironmentObject var viewModel: SitemapPageViewModel + @State private var triggerFeedback = false + + private let logger = Logger(subsystem: "org.openhab", category: "ButtonGridButton") + + private var hasPressRelease: Bool { + if let releaseCommand = widget.releaseCommand, !releaseCommand.isEmpty { + return true + } + return false + } + + var body: some View { + let displayState = widget.displayState + Button { + // Only handle tap for non-press-release buttons; + // press-release buttons are handled entirely by the gesture + if !hasPressRelease { + triggerFeedback.toggle() + handleButtonPress() + } + } label: { + HStack { + if !widget.icon.isEmpty { + IconView(widget: widget) + .frame(width: 16, height: 16) + } else { + Text(widget.label) + .ohTextToken(.rowValueCompact) + .foregroundStyle(.primary) + } + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isChecked(displayState: displayState) ? Color.accentColor : Color.secondary.opacity(0.1)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isChecked(displayState: displayState) ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) + ) + .scaleEffect(isPressed ? 0.95 : 1.0) + } + .buttonStyle(PlainButtonStyle()) + .disabled(widget.readOnly ?? false) + .sensoryHeavyFeedbackIfAvailable(trigger: triggerFeedback) + .onPressGesture( + onPress: { + handleTouchDown() + }, + onRelease: { + handleTouchUp() + } + ) + } + + private func handleButtonPress() { + if let command = widget.command, !command.isEmpty { + logger.info("Sending command: \(command)") + sendCommand(command) + } + } + + private func handleTouchDown() { + guard !isPressed else { return } + isPressed = true + // For press-release buttons, send command on press + if hasPressRelease, let command = widget.command { + triggerFeedback.toggle() + logger.info("Sending press command: \(command)") + sendCommand(command, policy: .pressRelease, phase: .press) + } + } + + private func handleTouchUp() { + guard isPressed else { return } + isPressed = false + // For press-release buttons, send release command on release + if let releaseCommand = widget.releaseCommand, !releaseCommand.isEmpty { + logger.info("Sending release command: \(releaseCommand)") + sendCommand(releaseCommand, policy: .pressRelease, phase: .release) + } + } + + private func sendCommand(_ command: String, + policy: WidgetCommandPolicy = .immediate, + phase: WidgetCommandPhase = .change) { + viewModel.sendCommand( + command, + for: widget, + policy: policy, + phase: phase, + fallbackItem: parentItem + ) + } + + private func isChecked(displayState: WidgetDisplayState) -> Bool { + if let stateless = widget.stateless, stateless { return false } + return displayState.effectiveState == widget.command + } +} + +private struct PressGestureModifier: ViewModifier { + let onPress: () -> Void + let onRelease: () -> Void + @State private var pressed = false + + func body(content: Content) -> some View { + content.gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if !pressed { + pressed = true + onPress() + } + } + .onEnded { _ in + pressed = false + onRelease() + } + ) + } +} + +struct ButtonGridRowView: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + + private let logger = Logger(subsystem: "org.openhab", category: "ButtonGridRowView") + + // Maximum number of columns based on screen width + private let maxColumns = 12 + + private var buttons: [OpenHABWidget] { + let childButtons = widget.widgets // .filter(\.visibility) + let mappingButtons = widget.mappings.enumerated().map { (index, mapping) in + mapping.toWidget(widgetId: "\(widget.widgetId)-mappings-\(index)", item: widget.item) + } + return childButtons + mappingButtons + } + + private var showLabelAndIcon: Bool { + !widget.label.isEmpty && widget.labelSource == .sitemapDefinition + } + + private var gridRows: Int { + buttons.map { $0.row ?? 1 }.max() ?? 1 + } + + private var gridColumns: Int { + min(buttons.map { $0.column ?? 1 }.max() ?? 1, maxColumns) + } + + var body: some View { + let displayState = widget.displayState + VStack(alignment: .leading, spacing: 8) { + if showLabelAndIcon { + HStack { + IconView(widget: widget) + .frame(width: 32, height: 32) + + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText + Text(labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + Spacer() + } + } + HStack { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: gridColumns), spacing: 8) { + ForEach(0 ..< gridRows, id: \.self) { row in + ForEach(0 ..< gridColumns, id: \.self) { column in + let button = buttonForPosition(row: row, column: column) + + if let button, + button.visibility { + ButtonGridButton(widget: button, parentItem: widget.item) + .id(viewModel.pageId + button.widgetId) + } else { + // Empty cell to maintain grid structure + Rectangle() + .fill(Color.clear) + .frame(height: 44) + } + } + } + } + } + } + } + + private func buttonForPosition(row: Int, column: Int) -> OpenHABWidget? { + buttons.first { button in + // OpenHAB uses 1-based indexing, convert to 0-based + (button.row ?? 1) - 1 == row && (button.column ?? 1) - 1 == column + } + } +} + +// Extension to convert OpenHABWidgetMapping to OpenHABWidget +extension OpenHABWidgetMapping { + func toWidget(widgetId: String, item: OpenHABItem?) -> OpenHABWidget { + let widget = OpenHABWidget() + widget.widgetId = widgetId + widget.id = widgetId + widget.label = label + widget.command = command + widget.item = item + widget.type = .button + widget.visibility = true + widget.row = row + widget.column = column + widget.releaseCommand = releaseCommand + widget.stateless = true + widget.icon = icon ?? "" + return widget + } +} + +/// A SwiftUI View extension to handle press and release gesture events. +/// Used specifically for button grid buttons that need to send commands on press and release +/// (supporting the releaseCommand feature from openHAB). +extension View { + func onPressGesture(onPress: @escaping () -> Void, + onRelease: @escaping () -> Void) -> some View { + modifier(PressGestureModifier(onPress: onPress, onRelease: onRelease)) + } +} + +#Preview { + if let widget = PreviewConstants.openHABSitemapPage!.widgets.first(where: { $0.type == .buttongrid }) { + VStack { + ButtonGridRowView(widget: widget) + .padding() + Spacer() + } + .environmentObject(SitemapPageViewModel()) + } else { + Text("No button grid widget found") + } +} diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift new file mode 100644 index 000000000..d08079597 --- /dev/null +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -0,0 +1,133 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import os.log +import SwiftUI + +struct ColorPickerRowView: View { + @ObservedObject var widget: OpenHABWidget + @State private var selectedColor: Color = .white + @State private var lastImmediateSendAt: Date = .distantPast + @EnvironmentObject var viewModel: SitemapPageViewModel + + private var colorCommandKey: String { + "color-\(widget.widgetId)" + } + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetColorPickerView") + + var body: some View { + let displayState = widget.displayState + HStack { + IconView(widget: widget) + .frame(width: 32, height: 32) + + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText + Text(labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + Spacer() + + ColorPicker("Color", selection: $selectedColor, supportsOpacity: false) + .labelsHidden() + .onChange(of: selectedColor) { newColor in + sendColorCommand(newColor) + } + .disabled(widget.readOnly ?? false) + + if let labelValue = displayState.labelValue, !labelValue.isEmpty { + Text(labelValue) + .ohTextToken(.rowValueCompact) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + } + } + .onAppear { + let state = displayState.effectiveState + if !state.isEmpty { + selectedColor = parseColor(from: state) ?? .white + } + } + .onChange(of: displayState.effectiveState) { newState in + guard !newState.isEmpty else { return } + selectedColor = parseColor(from: newState) ?? .white + } + .onDisappear { + if let item = widget.item { + viewModel.cancelPendingCommand(for: item, key: colorCommandKey) + } else { + viewModel.cancelPendingCommand(for: widget, key: colorCommandKey) + } + } + } + + private func sendColorCommand(_ color: Color) { + let uiColor = UIColor(color) + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + + uiColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + + let hueValue = Int(hue * 360) + let saturationValue = Int(saturation * 100) + let brightnessValue = Int(brightness * 100) + + let command = "\(hueValue),\(saturationValue),\(brightnessValue)" + logger.info("Sending color command: \(command)") + + // Keep real-time feedback while dragging: throttle immediate sends. + let now = Date() + if now.timeIntervalSince(lastImmediateSendAt) >= 0.2 { + lastImmediateSendAt = now + viewModel.sendCommand( + command, + for: widget, + policy: .immediate + ) + } + + // Also debounce to ensure the final value is sent after interaction settles. + viewModel.sendCommand( + command, + for: widget, + policy: WidgetCommandDefaults.colorPicker, + key: colorCommandKey + ) + } + + private func parseColor(from state: String) -> Color? { + let components = state.split(separator: ",") + guard components.count >= 3, + let hue = Double(components[0]), + let saturation = Double(components[1]), + let brightness = Double(components[2]) else { + return nil + } + + return Color(hue: hue / 360.0, saturation: saturation / 100.0, brightness: brightness / 100.0) + } +} + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[15] + VStack { + ColorPickerRowView(widget: widget) + .padding() + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift new file mode 100644 index 000000000..2209cb8c0 --- /dev/null +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -0,0 +1,219 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import os.log +import SFSafeSymbols +import SwiftUI + +struct CustomSliderView: View { + @Binding var value: Double + let range: ClosedRange + let step: Double + let onEditingChanged: () -> Void + + @State private var lastSendTime: Date = .distantPast + + var body: some View { + GeometryReader { geometry in + let width = geometry.size.width + let height = geometry.size.height + let normalized = CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound)) + let xPos = normalized * width + + ZStack(alignment: .leading) { + Color.clear + + Circle() + .frame(width: 20, height: 20) + .foregroundStyle(.white) + .shadow(radius: 1) + .overlay(Circle().stroke(Color.gray.opacity(0.6), lineWidth: 1)) + .position(x: xPos, y: height / 2) + .gesture( + DragGesture() + .onChanged { gesture in + let location = gesture.location.x.clamped(to: 0 ... width) + let raw = Double(location / width) * (range.upperBound - range.lowerBound) + range.lowerBound + let stepped = (raw / step).rounded() * step + value = stepped.clamped(to: range) + let now = Date() + if now.timeIntervalSince(lastSendTime) > 0.2 { + lastSendTime = now + onEditingChanged() + } + } + .onEnded { _ in + onEditingChanged() + } + ) + } + } + } +} + +struct ColorTemperaturePickerRowView: View { + @ObservedObject var widget: OpenHABWidget + @State private var selectedTemperature: Double = 2700 // Default warm white + @EnvironmentObject var viewModel: SitemapPageViewModel + + private var colorTemperatureCommandKey: String { + "color-temperature-\(widget.widgetId)" + } + + private let logger = Logger(subsystem: "org.openhab", category: "ColorTemperaturePickerRowView") + + // Use widget's min/max values, similar to Android implementation + private var minTemperature: Double { + max(widget.minValue, 1000) + } + + private var maxTemperature: Double { + min(widget.maxValue, 10000) + } + + var body: some View { + let displayState = widget.displayState + HStack(alignment: .top) { + IconView(widget: widget) + .frame(width: 32, height: 32) + + VStack(spacing: 8) { + HStack { + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText + Text(labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + Spacer() + + // Temperature value display + HStack { + Text("\(Int(selectedTemperature))K") + .ohTextToken(.rowValueCompact) + .foregroundStyle(.secondary) + + Text(" - ") + .ohTextToken(.rowValueCompact) + .foregroundStyle(.secondary) + + // Temperature description + Text(temperatureDescription) + .ohTextToken(.secondary) + .foregroundStyle(.secondary) + } + } + + // Color temperature slider with gradient background + HStack { + // Warm indicator + Image(systemSymbol: .sunMinFill) + .foregroundStyle(.orange) + .ohTextToken(.secondary) + + // Slider with custom gradient track + ZStack(alignment: .leading) { + // Gradient background representing color temperature spectrum + // Using realistic color temperature colors like Android app + LinearGradient( + colors: colorTemperatureGradient(), + startPoint: .leading, + endPoint: .trailing + ) + .frame(height: 10) + .clipShape(.rect(cornerRadius: 3)) + + // Actual slider + CustomSliderView( + value: $selectedTemperature, + range: minTemperature ... maxTemperature, + step: 100 + ) { + sendTemperatureCommand() + } + .frame(height: 28) + .disabled(widget.readOnly ?? false) + } + + // Cool indicator + Image(systemSymbol: .snowflake) + .foregroundStyle(.blue) + .ohTextToken(.secondary) + } + } + } + .onAppear { + selectedTemperature = loadCurrentTemperature(state: displayState.effectiveState) ?? 2700 + } + .onChange(of: displayState.effectiveState) { newState in + selectedTemperature = loadCurrentTemperature(state: newState) ?? 2700 + } + .onDisappear { + if let item = widget.item { + viewModel.cancelPendingCommand(for: item, key: colorTemperatureCommandKey) + } else { + viewModel.cancelPendingCommand(for: widget, key: colorTemperatureCommandKey) + } + } + } + + private var temperatureDescription: String { + switch selectedTemperature { + case 1000 ..< 2000: "Candlelight" + case 2000 ..< 2700: "Very Warm" + case 2700 ..< 3000: "Warm White" + case 3000 ..< 4000: "Soft White" + case 4000 ..< 5000: "Cool White" + case 5000 ..< 6500: "Daylight" + case 6500 ..< 8000: "Cool Daylight" + default: "Very Cool" + } + } + + // Generate gradient colors similar to Android implementation + private func colorTemperatureGradient(steps: Int = 20) -> [Color] { + stride(from: minTemperature, through: maxTemperature, by: (maxTemperature - minTemperature) / Double(steps)).map { Color(temperature: $0) } + } + + private func loadCurrentTemperature(state: String?) -> Double? { + guard let state, !state.isEmpty else { return nil } + + // Parse color temperature directly from Kelvin value (like Android app) + let kelvin = state.parseAsNumber().value + return kelvin.clamped(to: minTemperature ... maxTemperature) + } + + private func sendTemperatureCommand() { + // Send temperature directly as Kelvin value (like Android app) + let command = "\(Int(selectedTemperature))" + + logger.info("Sending color temperature command: \(command)K") + viewModel.sendCommand( + command, + for: widget, + policy: WidgetCommandDefaults.slider, + key: colorTemperatureCommandKey + ) + } +} + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[13] + VStack { + ColorTemperaturePickerRowView(widget: widget) + .padding() + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift new file mode 100644 index 000000000..1bc22e0d2 --- /dev/null +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -0,0 +1,116 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import os.log +import SwiftUI + +struct DatePickerInputRowView: View { + @ObservedObject var widget: OpenHABWidget + @State private var selectedDate = Date() + @EnvironmentObject var viewModel: SitemapPageViewModel + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetDatePickerInputView") + + private var datePickerComponents: DatePickerComponents { + switch widget.inputHint { + case .date: .date + case .time: .hourAndMinute + case .dateTime: [.date, .hourAndMinute] + default: [.date, .hourAndMinute] + } + } + + private var useWheelStyle: Bool { + widget.inputHint == .time + } + + var body: some View { + let displayState = widget.displayState + HStack { + IconView(widget: widget) + .frame(width: 32, height: 32) + + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText + Text(labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + Spacer() + + DatePicker( + selection: $selectedDate, + displayedComponents: datePickerComponents + ) { + EmptyView() + } + .onChange(of: selectedDate) { newDate in + sendDateCommand(newDate) + } + .disabled(widget.readOnly ?? false) + } + .onAppear { + let state = displayState.effectiveState + if !state.isEmpty { + selectedDate = parseDate(from: state) ?? Date() + } + } + } + + private func sendDateCommand(_ date: Date) { + let formatter = DateFormatter() + + switch widget.inputHint { + case .date: + formatter.dateFormat = "yyyy-MM-dd" + case .time: + formatter.dateFormat = "HH:mm" + case .dateTime: + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + default: + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + } + + let command = formatter.string(from: date) + logger.info("Sending date command: \(command)") + viewModel.sendCommand(command, for: widget) + } + + private func parseDate(from state: String) -> Date? { + let formatters = [ + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd", + "HH:mm" + ] + + for format in formatters { + let formatter = DateFormatter() + formatter.dateFormat = format + if let date = formatter.date(from: state) { + return date + } + } + return nil + } +} + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[13] + VStack { + DatePickerInputRowView(widget: widget) + .padding() + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} diff --git a/openHAB/SwiftUI/Rows/FrameRowView.swift b/openHAB/SwiftUI/Rows/FrameRowView.swift new file mode 100644 index 000000000..19e11b652 --- /dev/null +++ b/openHAB/SwiftUI/Rows/FrameRowView.swift @@ -0,0 +1,37 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import SwiftUI + +struct FrameRowView: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + + var body: some View { + let displayState = widget.displayState + HStack { + Text(displayState.labelText.uppercased()) + .ohTextToken(.section) + .foregroundStyle(.secondary) + Spacer() + } + } +} + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[6] + List([widget]) { widget in + FrameRowView(widget: widget) + } + .environmentObject(SitemapPageViewModel()) +} diff --git a/openHAB/SwiftUI/Rows/GenericRowView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift new file mode 100644 index 000000000..47da07130 --- /dev/null +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -0,0 +1,47 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import SwiftUI + +struct GenericRowView: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + + var body: some View { + let displayState = widget.displayState + HStack { + IconView(widget: widget) + .frame(width: 32, height: 32) + + Text(displayState.labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + + Spacer() + + if let value = displayState.labelValue { + Text(value) + .ohTextToken(.rowValue) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + } + } + } +} + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[6] + List([widget]) { widget in + GenericRowView(widget: widget) + } + .environmentObject(SitemapPageViewModel()) +} diff --git a/openHAB/SwiftUI/Rows/ImageRowView.swift b/openHAB/SwiftUI/Rows/ImageRowView.swift new file mode 100644 index 000000000..70c6e3cb2 --- /dev/null +++ b/openHAB/SwiftUI/Rows/ImageRowView.swift @@ -0,0 +1,117 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import Kingfisher +import OpenHABCore +import os.log +import SwiftUI + +struct ImageRowView: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + @Environment(\.colorScheme) var colorScheme + @State private var refreshTimer: Timer? + @State private var forceRefreshKey = UUID() + + private let logger = Logger(subsystem: "org.openhab", category: "ImageRowView") + + private var imageURL: URL? { + guard !widget.url.isEmpty else { return nil } + return URL(string: widget.url) + } + + private var shouldCache: Bool { + widget.refresh == 0 + } + + private var chartStyle: ChartStyle { + colorScheme == .light ? .light : .dark + } + + var body: some View { + let displayState = widget.displayState + VStack(alignment: .leading, spacing: 8) { + if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { + let labelText = displayState.labelText + Text(labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + switch widget.generateImageResult(rootUrl: viewModel.openHABRootUrl ?? "", chartStyle: chartStyle) { + case let .embedded(data: data): + let provider = RawImageDataProvider(data: data, cacheKey: shouldCache ? widget.widgetId : "\(widget.widgetId)-\(forceRefreshKey)") + KFImage(source: .provider(provider)) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 300) + .clipShape(.rect(cornerRadius: 8)) + case let .link(url): + KFImage(url) + .resizable() + .cacheMemoryOnly(!shouldCache) + .forceRefresh(shouldCache ? false : true) + .cacheOriginalImage(!shouldCache ? false : true) + .id(shouldCache ? url?.absoluteString : "\(url?.absoluteString ?? "")-\(forceRefreshKey)") + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 300) + .clipShape(.rect(cornerRadius: 8)) + case .empty: + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(height: 200) + .overlay( + Text("No Image URL") + .foregroundStyle(.secondary) + ) + .clipShape(.rect(cornerRadius: 8)) + } + + // Only show labelValue for image widgets, not charts + if widget.type == .image, let labelValue = displayState.labelValue, !labelValue.isEmpty { + Text(labelValue) + .ohTextToken(.rowValueCompact) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + } + } + .onAppear { + setupRefreshTimer() + } + .onDisappear { + stopRefreshTimer() + } + .onChange(of: widget.refresh) { _ in + setupRefreshTimer() + } + } + + private func setupRefreshTimer() { + stopRefreshTimer() + + guard widget.refresh != 0 else { return } + + let refreshInterval = TimeInterval(Double(widget.refresh) / 1000) + guard refreshInterval > 0.09 else { return } + + logger.info("Scheduling image refresh every \(refreshInterval) seconds") + refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in + Task { @MainActor in + logger.info("Refreshing image on \(refreshInterval) seconds schedule") + forceRefreshKey = UUID() + } + } + } + + private func stopRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = nil + } +} diff --git a/openHAB/SwiftUI/Rows/MapRowView.swift b/openHAB/SwiftUI/Rows/MapRowView.swift new file mode 100644 index 000000000..7dd0e0f5b --- /dev/null +++ b/openHAB/SwiftUI/Rows/MapRowView.swift @@ -0,0 +1,98 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import CoreLocation +import MapKit +import OpenHABCore +import SwiftUI + +struct MapRowViewLegacy: View { + @ObservedObject var widget: OpenHABWidget + + private var region: MKCoordinateRegion { + let coordinate = CLLocationCoordinate2DIsValid(widget.coordinate) ? widget.coordinate : CLLocationCoordinate2D(latitude: 0, longitude: 0) + return MKCoordinateRegion( + center: coordinate, + latitudinalMeters: 1000.0, + longitudinalMeters: 1000.0 + ) + } + + var body: some View { + let displayState = widget.displayState + VStack(alignment: .leading, spacing: 8) { + if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { + let labelText = displayState.labelText + Text(labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + Map(coordinateRegion: .constant(region), annotationItems: CLLocationCoordinate2DIsValid(widget.coordinate) ? [widget.coordinate] : []) { location in + MapMarker(coordinate: location, tint: .red) + } + .frame(height: widget.preferredRowHeight) + .clipShape(.rect(cornerRadius: 8)) + } + } +} + +@available(iOS 17.0, *) +private struct MapRowViewNew: View { + @ObservedObject var widget: OpenHABWidget + @State private var cameraPosition = MapCameraPosition.region( + MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 0, longitude: 0), + latitudinalMeters: 1000, + longitudinalMeters: 1000 + ) + ) + @EnvironmentObject var viewModel: SitemapPageViewModel + + var body: some View { + VStack { + if CLLocationCoordinate2DIsValid(widget.coordinate) { + Map(position: $cameraPosition) { + Marker("", coordinate: widget.coordinate) + } + .frame(height: widget.preferredRowHeight) + .onAppear { + cameraPosition = .region( + MKCoordinateRegion( + center: widget.coordinate, + latitudinalMeters: 1000, + longitudinalMeters: 1000 + ) + ) + } + } + } + } +} + +struct MapRowView: View { + @ObservedObject var widget: OpenHABWidget + + var body: some View { + if #available(iOS 17.0, *) { + MapRowViewNew(widget: widget) + } else { + MapRowViewLegacy(widget: widget) + } + } +} + +extension CLLocationCoordinate2D: @retroactive Identifiable { + var id: String { + "\(latitude),\(longitude)" + } +} diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift new file mode 100644 index 000000000..c1c4a8b55 --- /dev/null +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -0,0 +1,118 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import os.log +import SFSafeSymbols +import SwiftUI + +enum RollerShutterCommand: String { + case up = "UP" + case down = "DOWN" + case stop = "STOP" +} + +struct RollershutterRowView: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + @State private var triggerUpFeedback = false + @State private var triggerStopFeedback = false + @State private var triggerDownFeedback = false + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetRollershutterView") + + var body: some View { + let displayState = widget.displayState + VStack(alignment: .leading, spacing: 8) { + HStack { + IconView(widget: widget) + .frame(width: 32, height: 32) + + VStack(alignment: .leading, spacing: 2) { + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText + Text(labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + } + + Spacer() + + Button { + triggerUpFeedback.toggle() + logger.info("\("up button pressed")") + viewModel.sendCommand(RollerShutterCommand.up.rawValue, for: widget) + } label: { + Image(systemSymbol: .chevronUp) + .font(.title2) + .foregroundStyle(Color(UIColor.systemBlue)) + } + .buttonStyle(.plain) + .sensoryHeavyFeedbackIfAvailable(trigger: triggerUpFeedback) + + Button { + triggerStopFeedback.toggle() + logger.info("\("stop button pressed")") + viewModel.sendCommand(RollerShutterCommand.stop.rawValue, for: widget) + } label: { + Image(systemSymbol: .stop) + .font(.title2) + .foregroundStyle(Color(UIColor.systemBlue)) + } + .buttonStyle(.plain) + .sensoryHeavyFeedbackIfAvailable(trigger: triggerStopFeedback) + + Button { + triggerDownFeedback.toggle() + logger.info("\("down button pressed")") + viewModel.sendCommand(RollerShutterCommand.down.rawValue, for: widget) + } label: { + Image(systemSymbol: .chevronDown) + .font(.title2) + .foregroundStyle(Color(UIColor.systemBlue)) + } + .buttonStyle(.plain) + .sensoryHeavyFeedbackIfAvailable(trigger: triggerDownFeedback) + } + } + } +} + +extension View { + @ViewBuilder + func sensoryHeavyFeedbackIfAvailable(trigger: Bool) -> some View { + if #available(iOS 17.0, *) { + sensoryFeedback(.impact(weight: .heavy, intensity: 0.9), trigger: trigger) + } else { + self + } + } + + @ViewBuilder + func sensoryStopFeedbackIfAvailable(trigger: Bool) -> some View { + if #available(iOS 17.0, *) { + sensoryFeedback(.impact(flexibility: .rigid), trigger: trigger) + } else { + self + } + } +} + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[5] + VStack { + RollershutterRowView(widget: widget) + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift new file mode 100644 index 000000000..e0e0c33b1 --- /dev/null +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -0,0 +1,531 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import os.log +import SFSafeSymbols +import SwiftUI + +// swiftlint:disable:next file_types_order +struct SegmentedRowView: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + @Environment(\.colorScheme) var colorScheme + + /// Optional SF Symbol fallback for IconView (useful for previews) + var fallbackSymbol: SFSymbol? + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetSegmentedView") + + @State private var selectedIndex: Int? + @State private var pressedIndex: Int? + @State private var singlePressed = false + + var body: some View { + let displayState = widget.displayState + let mappings = displayState.mappings + HStack(spacing: 0) { + IconView(widget: widget, fallbackSymbol: fallbackSymbol) + .frame(width: 32, height: 32) + + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText + Text(labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .padding(.leading, 8) + .layoutPriority(1) + } + + if let detailTextLabel = displayState.labelValue, !detailTextLabel.isEmpty { + Spacer(minLength: 8) + Text(detailTextLabel) + .ohTextToken(.rowValue) + .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) + .layoutPriority(1) + } + + if !mappings.isEmpty { + if displayState.hasPressReleaseMappings { + // Press-release buttons for mappings with releaseCommand + if !(displayState.labelValue?.isEmpty == false) { + Spacer(minLength: 8) + } + pressReleaseButtons(mappings: mappings) + .fixedSize(horizontal: true, vertical: false) + .padding(.leading, 8) + + } else if mappings.count == 1 { + if displayState.labelValue.isNilOrEmpty { + Spacer(minLength: 8) + } + singleMappingButton(displayState: displayState, mappings: mappings) + .fixedSize(horizontal: true, vertical: false) + .padding(.leading, 8) + } else { + // Button-based segmented control with animated selection indicator + segmentedButtons(mappings: mappings) + .frame(minWidth: 75) + .padding(.leading, 8) + .layoutPriority(1) + } + } + } + .onAppear { + selectedIndex = selectedIndex(for: displayState.effectiveState, mappings: mappings) + } + .onChange(of: displayState.effectiveState) { newState in + selectedIndex = selectedIndex(for: newState, mappings: mappings) + } + } + + /// Button-based segmented control with animated selection indicator + @ViewBuilder + private func segmentedButtons(mappings: [OpenHABWidgetMapping]) -> some View { + HStack(spacing: 0) { + ForEach(0 ..< mappings.count, id: \.self) { index in + segmentButton(at: index, mappings: mappings) + } + } + .background( + GeometryReader { geometry in + // Layer 1: Dark gray background + RoundedRectangle(cornerRadius: 7) + .fill(Color(uiColor: colorScheme == .dark ? .systemGray4 : .systemGray5)) + // Layer 2: Selection indicator (lighter, more visible) + if let selectedIndex, !mappings.isEmpty { + let segmentWidth = geometry.size.width / CGFloat(mappings.count) + RoundedRectangle(cornerRadius: 6) + .fill( + colorScheme == .dark + ? Color(uiColor: .systemGray2) + : Color(uiColor: .systemBackground) + ) + .frame(width: segmentWidth - 4, height: geometry.size.height - 4) + .offset(x: CGFloat(selectedIndex) * segmentWidth + 2, y: 2) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: selectedIndex) + } + } + ) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(Color.secondary.opacity(0.3), lineWidth: 0.5) + ) + .fixedSize(horizontal: false, vertical: true) + } + + @ViewBuilder + private func pressReleaseButtons(mappings: [OpenHABWidgetMapping]) -> some View { + HStack(spacing: 8) { + ForEach(mappings.indices, id: \.self) { index in + pressReleaseButton(for: mappings[index], at: index) + } + } + } + + /// Whether the single mapping button is selected (item state matches the mapping command) + private func isSingleMappingSelected(displayState: WidgetDisplayState, mappings: [OpenHABWidgetMapping]) -> Bool { + guard let mapping = mappings.first else { return false } + return displayState.effectiveState == mapping.command + } + + @ViewBuilder + private func singleMappingButton(displayState: WidgetDisplayState, mappings: [OpenHABWidgetMapping]) -> some View { + let mapping = mappings[0] + let isSelected = isSingleMappingSelected(displayState: displayState, mappings: mappings) + + Text(mapping.label) + .ohTextToken(.control) + .bold() + .padding(.horizontal, 8) + .padding(.vertical, 5) + .frame(minWidth: 50) + .foregroundStyle(.primary) + .background( + RoundedRectangle(cornerRadius: 6) + .fill( + (singlePressed || isSelected) + ? (colorScheme == .dark ? Color(uiColor: .systemGray2) : Color(uiColor: .systemBackground)) + : Color.clear + ) + ) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if singlePressed == false { + singlePressed = true + logger.info("Segment mapping pressed, command: \(mapping.command)") + viewModel.sendCommand(mapping.command, for: widget) + } + } + .onEnded { _ in + singlePressed = false + } + ) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(Color(uiColor: colorScheme == .dark ? .systemGray4 : .systemGray5)) + ) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(Color.secondary.opacity(0.3), lineWidth: 0.5) + ) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected) + .animation(.easeInOut(duration: 0.1), value: singlePressed) + } + + // MARK: - Helper Methods + + private func selectedIndex(for state: String, mappings: [OpenHABWidgetMapping]) -> Int? { + mappings.firstIndex { $0.command == state } + } + + @ViewBuilder + private func segmentButton(at index: Int, mappings: [OpenHABWidgetMapping]) -> some View { + let mapping = mappings[index] + + Button { + logger.info("Segment tapped: \(index), command: \(mapping.command)") + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + selectedIndex = index + } + viewModel.sendCommand(mapping.command, for: widget) + } label: { + Text(mapping.label) + .ohTextToken(.control) + .bold() + .padding(.vertical, 5) + .frame(minWidth: 30, maxWidth: 120) + .foregroundStyle(.primary) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func pressReleaseButton(for mapping: OpenHABWidgetMapping, at index: Int) -> some View { + let isPressed = pressedIndex == index + Text(mapping.label) + .ohTextToken(.control) + .bold() + .padding(.horizontal, 8) + .padding(.vertical, 5) + .frame(minWidth: 50) + .foregroundStyle(.primary) + .background( + RoundedRectangle(cornerRadius: 7) + .fill( + isPressed + ? Color(uiColor: colorScheme == .dark ? .systemGray2 : .systemBackground) + : Color(uiColor: colorScheme == .dark ? .systemGray4 : .systemGray5) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(Color.secondary.opacity(0.3), lineWidth: 0.5) + ) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if pressedIndex != index { + pressedIndex = index + // Send command on press + logger.info("Sending press command: \(mapping.command)") + viewModel.sendCommand( + mapping.command, + for: widget, + policy: .pressRelease, + phase: .press + ) + } + } + .onEnded { _ in + pressedIndex = nil + // Send release command on release + if let releaseCommand = mapping.releaseCommand, !releaseCommand.isEmpty { + logger.info("Sending release command: \(releaseCommand)") + viewModel.sendCommand( + releaseCommand, + for: widget, + policy: .pressRelease, + phase: .release + ) + } + } + ) + } +} + +// MARK: - Preview Helpers + +#if DEBUG +/// Wrapper for consistent preview list styling matching SitemapPageView +struct PreviewList: View { + @ViewBuilder let content: () -> Content + + var body: some View { + List { + content() + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + } + .listStyle(.plain) + .listRowSpacing(0) + .environment(\.defaultMinListRowHeight, 32) + .environmentObject(SitemapPageViewModel()) + } +} + +private extension SegmentedRowView { + static func createPreviewWidget(label: String, + detailLabel: String? = nil, + icon: String = "switch", + mappings: [OpenHABWidgetMapping], + selectedState: String? = nil) -> OpenHABWidget { + let widget = OpenHABWidget() + widget.widgetId = UUID().uuidString + if let detailLabel, !detailLabel.isEmpty { + widget.label = "\(label) [\(detailLabel)]" + } else { + widget.label = label + } + widget.type = .switchWidget + widget.icon = icon + widget.mappings = mappings + + let item = OpenHABItem( + name: "Preview_\(label.replacingOccurrences(of: " ", with: "_"))", + type: "String", + state: selectedState ?? mappings.first?.command ?? "", + link: "", + label: detailLabel ?? label, + groupType: nil, + stateDescription: nil, + commandDescription: nil, + members: [], + category: nil, + options: nil + ) + widget.item = item + + return widget + } +} +#endif + +// MARK: - Previews + +#Preview("Short Labels") { + PreviewList { + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Light Switch", + detailLabel: "1", + mappings: [ + OpenHABWidgetMapping(command: "ON", label: "ON"), + OpenHABWidgetMapping(command: "OFF", label: "OFF") + ], + selectedState: "ON" + ), + fallbackSymbol: .switch2 + ) + } +} + +#Preview("Charts Period") { + PreviewList { + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Charts Period", + mappings: [ + OpenHABWidgetMapping(command: "D", label: "Day"), + OpenHABWidgetMapping(command: "W", label: "Week"), + OpenHABWidgetMapping(command: "M", label: "M"), + OpenHABWidgetMapping(command: "4h", label: "4h") + ], + selectedState: "D" + ), + fallbackSymbol: .chartBarFill + ) + } +} + +#Preview("Long Labels") { + PreviewList { + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Temperature Control", + detailLabel: "3", + mappings: [ + OpenHABWidgetMapping(command: "manual", label: "Manual"), + OpenHABWidgetMapping(command: "calendar", label: "Calendar"), + OpenHABWidgetMapping(command: "automatic", label: "Automatic") + ], + selectedState: "automatic" + ), + fallbackSymbol: .thermometerMedium + ) + } +} + +#Preview("Multiple Segments (4)") { + PreviewList { + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Fan Speed", + detailLabel: "4", + mappings: [ + OpenHABWidgetMapping(command: "0", label: "Off"), + OpenHABWidgetMapping(command: "1", label: "Low"), + OpenHABWidgetMapping(command: "2", label: "Med"), + OpenHABWidgetMapping(command: "3", label: "High") + ], + selectedState: "3" + ), + fallbackSymbol: .fanOscillation + ) + } +} + +#Preview("PressRelease") { + PreviewList { + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "All Shutters", + detailLabel: "NA", + mappings: [ + OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF") + ], + selectedState: "DOWN" + ), + fallbackSymbol: .romanShadeClosed + ) + } +} + +#Preview("Single Mapping") { + PreviewList { + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Scene", + mappings: [ + OpenHABWidgetMapping(command: "RUN", label: "Run") + ], + selectedState: "RUN" + ), + fallbackSymbol: .theatermasksFill + ) + } +} + +#Preview("All Scenarios") { + PreviewList { + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Light", + detailLabel: "1", + mappings: [ + OpenHABWidgetMapping(command: "ON", label: "ON"), + OpenHABWidgetMapping(command: "OFF", label: "OFF") + ], + selectedState: "ON" + ), + fallbackSymbol: .lightbulbFill + ) + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Climate Mode", + detailLabel: "2", + mappings: [ + OpenHABWidgetMapping(command: "m", label: "Manual"), + OpenHABWidgetMapping(command: "a", label: "Auto"), + OpenHABWidgetMapping(command: "s", label: "Schedule") + ], + selectedState: "a" + ), + fallbackSymbol: .thermometerMedium + ) + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Fan Speed", + detailLabel: "2", + mappings: [ + OpenHABWidgetMapping(command: "0", label: "Off"), + OpenHABWidgetMapping(command: "1", label: "Low"), + OpenHABWidgetMapping(command: "2", label: "Med"), + OpenHABWidgetMapping(command: "3", label: "High") + ], + selectedState: "2" + ), + fallbackSymbol: .fanOscillation + ) + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Charts Period", + mappings: [ + OpenHABWidgetMapping(command: "D", label: "Day"), + OpenHABWidgetMapping(command: "W", label: "Week"), + OpenHABWidgetMapping(command: "M", label: "M"), + OpenHABWidgetMapping(command: "4h", label: "4h") + ], + selectedState: "D" + ), + fallbackSymbol: .chartBarFill + ) + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "All Shutters", + detailLabel: "NA", + mappings: [ + OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF"), + OpenHABWidgetMapping(command: "UP", label: " UP ", releaseCommand: "OFF") + + ], + selectedState: "DOWN" + ), + fallbackSymbol: .romanShadeClosed + ) + + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Office Shutter", + detailLabel: "NA", + mappings: [ + OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF"), + OpenHABWidgetMapping(command: "UP", label: "UP", releaseCommand: "OFF") + ], + selectedState: "DOWN" + ), + fallbackSymbol: .romanShadeClosed + ) + + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Scene", + mappings: [ + OpenHABWidgetMapping(command: "RUN", label: "DOWN") + ], + selectedState: "DOWN" + ), + fallbackSymbol: .theatermasksFill + ) + } +} + +#Preview("From PreviewConstants") { + PreviewList { + SegmentedRowView( + widget: PreviewConstants.openHABSitemapPage!.widgets[4], + fallbackSymbol: .switch2 + ) + } +} diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift new file mode 100644 index 000000000..285bca913 --- /dev/null +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -0,0 +1,93 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import os.log +import SFSafeSymbols +import SwiftUI + +struct SelectionRowView: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + + private let logger = Logger(subsystem: "org.openhab", category: "SelectionRowView") + + private var mappings: [OpenHABWidgetMapping] { + widget.mappingsOrItemOptions + } + + var body: some View { + let displayState = widget.displayState + ZStack { + rowContent(displayState: displayState) + .frame(maxWidth: .infinity, alignment: .leading) + .animation(nil, value: displayState.effectiveState) + + Menu { + ForEach(mappings.indices, id: \.self) { index in + let mapping = mappings[index] + let isSelected = displayState.effectiveState == mapping.command + Button { + logger.info("Selection changed to: \(mapping.label)") + viewModel.sendCommand(mapping.command, for: widget) + } label: { + if isSelected { + Label(mapping.label, systemSymbol: .checkmark) + } else { + Text(mapping.label) + } + } + } + } label: { + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(widget.readOnly ?? false) + } + } + + @ViewBuilder + private func rowContent(displayState: WidgetDisplayState) -> some View { + HStack { + IconView(widget: widget) + .frame(width: 32, height: 32) + + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText + Text(labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + Spacer() + + if let valueText = selectedValueText(displayState: displayState), !valueText.isEmpty { + Text(valueText) + .ohTextToken(.rowValue) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + } + + // Show disclosure indicator to indicate tappable selection + Image(systemSymbol: .chevronUpChevronDown) + .ohTextToken(.secondary) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + + /// Returns the label of the currently selected mapping, or the widget's labelValue as fallback. + private func selectedValueText(displayState: WidgetDisplayState) -> String? { + displayState.selectedLabel ?? displayState.labelValue + } +} diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift new file mode 100644 index 000000000..9da1a738c --- /dev/null +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -0,0 +1,137 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import os.log +import SFSafeSymbols +import SwiftUI + +struct SetpointRowView: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + @State private var triggerFeedback = false + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetSetpointView") + private let setpointService = SetPointService() + + var body: some View { + let displayState = widget.displayState + let currentValue = currentValue(displayState: displayState) + HStack { + IconView(widget: widget) + .frame(width: 32, height: 32) + + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText + Text(labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + Spacer() + + HStack(spacing: 12) { + Button { + triggerFeedback.toggle() + decreaseValue(displayState: displayState) + } label: { + Image(systemSymbol: .chevronDown) + .font(.body) + .foregroundStyle(currentValue <= displayState.minValue ? Color(.systemGray2) : Color(UIColor.systemBlue)) + } + .buttonStyle(.plain) + .disabled(currentValue <= displayState.minValue) + .sensoryHeavyFeedbackIfAvailable(trigger: triggerFeedback) + .disabled(widget.readOnly ?? false) + + Text(formattedValue(displayState: displayState)) + .ohTextToken(.rowValue) + .monospacedDigit() + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + + Button { + triggerFeedback.toggle() + increaseValue(displayState: displayState) + } label: { + Image(systemSymbol: .chevronUp) + .font(.body) + .foregroundStyle(currentValue >= displayState.maxValue ? Color(.systemGray2) : Color(UIColor.systemBlue)) + } + .buttonStyle(.plain) + .disabled(currentValue >= displayState.maxValue) + .sensoryHeavyFeedbackIfAvailable(trigger: triggerFeedback) + .disabled(widget.readOnly ?? false) + } + } + } + + private func decreaseValue(displayState: WidgetDisplayState) { + handleUpDown(isDecreasing: true, displayState: displayState) + } + + private func increaseValue(displayState: WidgetDisplayState) { + handleUpDown(isDecreasing: false, displayState: displayState) + } + + private func handleUpDown(isDecreasing: Bool, displayState: WidgetDisplayState) { + var numberState = widget.stateValueAsNumberState + let currentValue = numberState?.value ?? displayState.minValue + + let limitedNewValue = setpointService.calculateNewValue( + currentValue: currentValue, + step: displayState.step, + minValue: displayState.minValue, + maxValue: displayState.maxValue, + isDecreasing: isDecreasing + ) + + guard limitedNewValue != currentValue else { + // nothing to update, skip sending value + return + } + + // Use widget's unit as fallback when creating NumberState + numberState = numberState ?? NumberState( + value: limitedNewValue, + unit: widget.unit, + format: widget.item?.stateDescription?.numberPattern + ) + numberState?.value = limitedNewValue + + logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(numberState?.description ?? String(limitedNewValue))") + viewModel.sendToUpdate(item: widget.item, state: numberState, policy: .immediate) + } + + private func currentValue(displayState: WidgetDisplayState) -> Double { + widget.stateValueAsNumberState?.value ?? displayState.minValue + } + + private func formattedValue(displayState: WidgetDisplayState) -> String { + if let numberState = widget.stateValueAsNumberState { + return numberState.toString(locale: Locale.current) + } + let text = currentValue(displayState: displayState).valueText(step: displayState.step) + if let unit = widget.unit, !unit.isEmpty { + return "\(text) \(unit)" + } + return text + } +} + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[3] + VStack { + SetpointRowView(widget: widget) + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift new file mode 100644 index 000000000..d45ef18c6 --- /dev/null +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -0,0 +1,304 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import SFSafeSymbols +import SwiftUI + +struct SliderRowView: View { + @ObservedObject var widget: OpenHABWidget + var fallbackSymbol: SFSymbol? + + @EnvironmentObject var viewModel: SitemapPageViewModel + + /// Pending value while user is dragging; nil when not actively changing + @State private var pendingValue: Double? + @State private var isEditing = false + + private var sliderCommandKey: String { + "slider-\(widget.widgetId)" + } + + var body: some View { + let displayState = widget.displayState + let currentValue = currentValue(displayState: displayState) + HStack { + if widget.switchSupport { + Button { + viewModel.sendCommand(currentValue <= displayState.minValue ? "ON" : "OFF", for: widget) + } label: { + labelContent(displayState: displayState) + } + .buttonStyle(.plain) + .disabled(widget.readOnly ?? false) + } else { + labelContent(displayState: displayState) + } + + Slider(value: valueBinding(displayState: displayState), in: sliderRange(displayState: displayState), step: widget.step) { editing in + isEditing = editing + if !editing { + // Always send the final value on release + if let value = pendingValue { + if let item = widget.item { + viewModel.cancelPendingCommand(for: item, key: sliderCommandKey) + } else { + viewModel.cancelPendingCommand(for: widget, key: sliderCommandKey) + } + sendSliderUpdate( + value, + policy: .finalOnly, + phase: .release, + key: sliderCommandKey + ) + } + // Keep pendingValue set until server responds to avoid visual jump + // Fallback: clear after delay if server doesn't respond + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(2000)) + if !isEditing, pendingValue != nil { + pendingValue = nil + } + } + } + } + .disabled(widget.readOnly ?? false) + } + .onChange(of: displayState.adjustedValue) { _ in + // Clear pending value only when server confirms our value (not intermediate responses) + if !isEditing, let pending = pendingValue, + abs(displayState.adjustedValue - pending) < max(widget.step * 0.5, 0.01) { + pendingValue = nil + } + } + .onAppear { + pendingValue = nil + isEditing = false + } + } + + @ViewBuilder + private func labelContent(displayState: WidgetDisplayState) -> some View { + let currentValueText = currentValueText(displayState: displayState) + HStack { + IconView(widget: widget, fallbackSymbol: fallbackSymbol) + .frame(width: 32, height: 32) + + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText + Text(labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + Spacer() + + // Show current slider value (pendingValue while dragging, otherwise widget value) + Text(pendingValue != nil ? currentValueText : (displayState.labelValue ?? currentValueText)) + .ohTextToken(.rowValueCallout) + .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) + } + .contentShape(Rectangle()) + } + + private func sendSliderUpdate(_ newValue: Double, + policy: WidgetCommandPolicy, + phase: WidgetCommandPhase = .change, + key: String?) { + var numberState = widget.stateValueAsNumberState + numberState = numberState ?? NumberState( + value: newValue, + unit: widget.unit, + format: widget.item?.stateDescription?.numberPattern + ) + numberState?.value = newValue + viewModel.sendToUpdate(item: widget.item, state: numberState, policy: policy, phase: phase, key: key) + } + + private func sliderRange(displayState: WidgetDisplayState) -> ClosedRange { + displayState.minValue ... displayState.maxValue + } + + private func currentValue(displayState: WidgetDisplayState) -> Double { + pendingValue ?? displayState.adjustedValue + } + + private func currentValueText(displayState: WidgetDisplayState) -> String { + let currentValue = currentValue(displayState: displayState) + if let numberPattern = widget.item?.stateDescription?.numberPattern, !numberPattern.isEmpty { + let formatted = NumberState( + value: currentValue, + unit: widget.unit, + format: numberPattern + ).toString(locale: Locale.current) + if !formatted.isEmpty { + return formatted + } + } + return currentValue.valueText(step: widget.step) + } + + private func valueBinding(displayState: WidgetDisplayState) -> Binding { + Binding( + get: { pendingValue ?? displayState.adjustedValue }, + set: { newValue in + pendingValue = newValue + + // Send updates during drag if enabled (throttled) + if widget.shouldUseSliderUpdatesDuringMove() { + sendSliderUpdate( + newValue, + policy: WidgetCommandDefaults.slider, + key: sliderCommandKey + ) + } + } + ) + } +} + +// MARK: - Preview Helpers + +#if DEBUG +private extension SliderRowView { + static func createPreviewWidget(label: String, + value: Double? = nil, + minValue: Double = 0.0, + maxValue: Double = 100.0, + step: Double = 1.0, + icon: String = "slider", + switchSupport: Bool = false) -> OpenHABWidget { + let widget = OpenHABWidget() + widget.widgetId = UUID().uuidString + widget.type = .slider + widget.icon = icon + widget.minValue = minValue + widget.maxValue = maxValue + widget.step = step + widget.switchSupport = switchSupport + + if let value { + widget.label = "\(label) [\(Int(value))]" + } else { + widget.label = label + } + + let item = OpenHABItem( + name: "Preview_\(label.replacingOccurrences(of: " ", with: "_"))", + type: "Dimmer", + state: value.map { String($0) } ?? "NULL", + link: "", + label: label, + groupType: nil, + stateDescription: nil, + commandDescription: nil, + members: [], + category: nil, + options: nil + ) + widget.item = item + + return widget + } +} +#endif + +// MARK: - Previews + +#Preview("Default Range (0-100)") { + PreviewList { + SliderRowView( + widget: SliderRowView.createPreviewWidget( + label: "Brightness", + value: 75 + ), + fallbackSymbol: .sliderHorizontal3 + ) + } +} + +#Preview("Custom Range (minValue)") { + PreviewList { + SliderRowView( + widget: SliderRowView.createPreviewWidget( + label: "Temperature", + value: 21, + minValue: 16, + maxValue: 28, + step: 0.5 + ), + fallbackSymbol: .thermometerMedium + ) + } +} + +#Preview("With Switch Support") { + PreviewList { + SliderRowView( + widget: SliderRowView.createPreviewWidget( + label: "Dimmer", + value: 50, + switchSupport: true + ), + fallbackSymbol: .lightbulbFill + ) + } +} + +#Preview("All Scenarios") { + PreviewList { + SliderRowView( + widget: SliderRowView.createPreviewWidget( + label: "Brightness", + value: 75 + ), + fallbackSymbol: .sliderHorizontal3 + ) + SliderRowView( + widget: SliderRowView.createPreviewWidget( + label: "Temperature", + value: 21, + minValue: 16, + maxValue: 28, + step: 0.5 + ), + fallbackSymbol: .thermometerMedium + ) + SliderRowView( + widget: SliderRowView.createPreviewWidget( + label: "Volume", + value: 30, + minValue: 0, + maxValue: 100, + icon: "soundvolume" + ), + fallbackSymbol: .speakerWave2Fill + ) + SliderRowView( + widget: SliderRowView.createPreviewWidget( + label: "Dimmer", + value: 50, + switchSupport: true + ), + fallbackSymbol: .lightbulbFill + ) + } +} + +#Preview("From PreviewConstants") { + PreviewList { + SliderRowView( + widget: PreviewConstants.openHABSitemapPage!.widgets[3], + fallbackSymbol: .sliderHorizontal3 + ) + } +} diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift new file mode 100644 index 000000000..db3650d41 --- /dev/null +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -0,0 +1,80 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import os.log +import SwiftUI + +struct SwitchRowView: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + @State private var localIsOn: Bool? + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetSwitchView") + + var body: some View { + let displayState = widget.displayState + HStack { + IconView(widget: widget) + .frame(width: 32, height: 32) + + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText + Text(labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + Spacer() + + if let labelValue = displayState.labelValue, !labelValue.isEmpty { + Text(labelValue) + .ohTextToken(.rowValueCompact) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + } + + Toggle("", isOn: Binding( + get: { isOn(displayState: displayState) }, + set: { newValue in + localIsOn = newValue + let newState = newValue ? "ON" : "OFF" + if newValue { + logger.info("\("Switch to ON")") + } else { + logger.info("\("Switch to OFF")") + } + viewModel.sendCommand(newState, for: widget) + } + )) + .labelsHidden() + .disabled(widget.readOnly ?? false) + } + .contentShape(Rectangle()) + .onChange(of: displayState.effectiveState) { _ in + // Sync local state when server state changes + localIsOn = nil + } + } + + private func isOn(displayState: WidgetDisplayState) -> Bool { + localIsOn ?? displayState.isOn + } +} + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[2] + VStack { + SwitchRowView(widget: widget) + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift new file mode 100644 index 000000000..97e95c1b6 --- /dev/null +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -0,0 +1,73 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import os.log +import SwiftUI + +struct TextInputRowView: View { + @ObservedObject var widget: OpenHABWidget + @State private var inputText = "" + @FocusState private var isTextFieldFocused: Bool + @EnvironmentObject var viewModel: SitemapPageViewModel + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetTextInputView") + + var body: some View { + let displayState = widget.displayState + HStack { + IconView(widget: widget) + .frame(width: 32, height: 32) + + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText + Text(labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + Spacer() + + TextField("Enter text", text: $inputText) + .multilineTextAlignment(widget.inputHint == .number ? .trailing : .leading) + .textFieldStyle(.roundedBorder) + .focused($isTextFieldFocused) + .onSubmit { + sendTextCommand() + } + .disabled(widget.readOnly ?? false) + } + .onAppear { + inputText = displayState.effectiveState + } + .onChange(of: displayState.effectiveState) { newState in + if !isTextFieldFocused { + inputText = newState + } + } + } + + private func sendTextCommand() { + logger.info("Sending text command: \(inputText)") + viewModel.sendCommand(inputText, for: widget) + isTextFieldFocused = false + } +} + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[17] + VStack { + TextInputRowView(widget: widget) + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} diff --git a/openHAB/SwiftUI/Rows/TextRowView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift new file mode 100644 index 000000000..953781fe2 --- /dev/null +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -0,0 +1,58 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import SFSafeSymbols +import SwiftUI + +struct TextRowView: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + + var body: some View { + let displayState = widget.displayState + HStack { + IconView(widget: widget) + .frame(width: 32, height: 32) + + Text(displayState.labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + + Spacer() + + if let value = displayState.labelValue { + Text(value) + .ohTextToken(.rowValue) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + } + } + .contextMenu { + if let text = displayState.labelValue ?? (displayState.labelText.isEmpty ? nil : displayState.labelText), !text.isEmpty { + Button { + UIPasteboard.general.string = text + } label: { + Label("Copy", systemSymbol: .squareAndArrowUp) + } + } + } + } +} + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[3] + VStack { + TextRowView(widget: widget) + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} diff --git a/openHAB/SwiftUI/Rows/VideoRowView.swift b/openHAB/SwiftUI/Rows/VideoRowView.swift new file mode 100644 index 000000000..6b1a9319b --- /dev/null +++ b/openHAB/SwiftUI/Rows/VideoRowView.swift @@ -0,0 +1,238 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import AVKit +import CommonUI +import OpenHABCore +import os.log +import SwiftUI +import UIKit + +enum VideoEncoding: String { + case hls, mjpeg +} + +struct VideoRowView: View { + @ObservedObject var widget: OpenHABWidget + @State private var player: AVPlayer? + @State private var mjpegPlayer: SimpleMJPEGPlayer? + @State private var mjpegImage: UIImage? + @State private var aspectRatio: CGFloat = 16.0 / 9.0 + @State private var isLoading = false + @State private var currentStreamUrl: URL? + @State private var imageObservationTimer: Timer? + @State private var playerObserver: NSKeyValueObservation? + @EnvironmentObject var viewModel: SitemapPageViewModel + + private let logger = Logger(subsystem: "org.openhab", category: "VideoRowView") + + private var videoURL: URL? { + guard !widget.url.isEmpty else { return nil } + return URL(string: widget.url) + } + + private var isMJPEG: Bool { + widget.encoding.lowercased() == VideoEncoding.mjpeg.rawValue + } + + var body: some View { + let displayState = widget.displayState + VStack(alignment: .leading, spacing: 8) { + if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { + let labelText = displayState.labelText + Text(labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + if let videoURL { + ZStack { + if isMJPEG { + // MJPEG display using UIImageView + if let mjpegImage { + Image(uiImage: mjpegImage) + .resizable() + .aspectRatio(aspectRatio, contentMode: .fit) + .frame(maxWidth: .infinity) + .frame(height: 200) + .clipShape(.rect(cornerRadius: 8)) + } else { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(maxWidth: .infinity) + .frame(height: 200) + .aspectRatio(aspectRatio, contentMode: .fit) + .clipShape(.rect(cornerRadius: 8)) + } + } else { + // HLS/other video formats using VideoPlayer + VideoPlayer(player: player) + .frame(maxWidth: .infinity) + .frame(height: 200) + .aspectRatio(aspectRatio, contentMode: .fit) + .clipShape(.rect(cornerRadius: 8)) + } + + if isLoading { + ProgressView() + .scaleEffect(1.2) + .progressViewStyle(CircularProgressViewStyle()) + } + } + .frame(maxWidth: .infinity) + .onAppear { + setupVideo(url: videoURL) + } + .onDisappear { + cleanup() + } + .onChange(of: widget.url) { newValue in + if !newValue.isEmpty, let newURL = URL(string: newValue) { + setupVideo(url: newURL) + } else { + cleanup() + } + } + } else { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(maxWidth: .infinity) + .frame(height: 200) + .overlay( + Text("No Video URL") + .foregroundStyle(.secondary) + ) + .clipShape(.rect(cornerRadius: 8)) + } + + if let labelValue = displayState.labelValue, !labelValue.isEmpty { + Text(labelValue) + .ohTextToken(.rowValueCompact) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + } + } + } + + @MainActor + private func setupVideo(url: URL) { + // Avoid redundant setup if URL hasn't changed + if currentStreamUrl?.absoluteString == url.absoluteString { + return + } + + // Clean up previous setup + cleanup() + currentStreamUrl = url + isLoading = true + + if isMJPEG { + setupMJPEG(url: url) + } else { + setupHLS(url: url) + } + } + + @MainActor + private func setupMJPEG(url: URL) { + // Create a dummy UIImageView for the SimpleMJPEGPlayer + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + + mjpegPlayer = VideoStreamManager.shared.getOrCreateStream( + for: url, + imageView: imageView, + onFirstFrame: { newAspectRatio in + Task { @MainActor in + aspectRatio = newAspectRatio + isLoading = false + } + }, + onError: { error in + Task { @MainActor in + logger.debug("MJPEG stream error: \(error.localizedDescription)") + isLoading = false + } + } + ) + + // Observe image changes on the UIImageView + startImageObservation(imageView: imageView) + } + + @MainActor + private func startImageObservation(imageView: UIImageView) { + imageObservationTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { _ in + DispatchQueue.main.async { + if mjpegPlayer == nil { + imageObservationTimer?.invalidate() + imageObservationTimer = nil + return + } + + if let image = imageView.image { + mjpegImage = image + if isLoading { + isLoading = false + } + } + } + } + } + + private func setupHLS(url: URL) { + let playerItem = AVPlayerItem(url: url) + player = AVPlayer(playerItem: playerItem) + + // Observe player readiness + playerObserver = playerItem.observe(\.status, options: [.new, .old]) { item, _ in + Task { @MainActor in + switch item.status { + case .readyToPlay: + isLoading = false + if item.presentationSize != .zero { + let newAspectRatio = item.presentationSize.width / item.presentationSize.height + aspectRatio = newAspectRatio + } + // Auto-play when ready + player?.play() + case .failed: + isLoading = false + logger.debug("HLS player failed: \(item.error?.localizedDescription ?? "Unknown error")") + default: + break + } + } + } + } + + private func cleanup() { + // Clean up timer + imageObservationTimer?.invalidate() + imageObservationTimer = nil + + // Clean up HLS observer + playerObserver = nil + + // Release MJPEG stream + if let currentStreamUrl, isMJPEG { + VideoStreamManager.shared.releaseStream(for: currentStreamUrl) + } + + // Clean up HLS player + player?.pause() + player = nil + + mjpegPlayer = nil + mjpegImage = nil + currentStreamUrl = nil + isLoading = false + } +} diff --git a/openHAB/SwiftUI/Rows/WebRowView.swift b/openHAB/SwiftUI/Rows/WebRowView.swift new file mode 100644 index 000000000..65405f639 --- /dev/null +++ b/openHAB/SwiftUI/Rows/WebRowView.swift @@ -0,0 +1,72 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import os.log +import SwiftUI +import WebKit + +struct WidgetWebViewContainer: View { + @ObservedObject var widget: OpenHABWidget + + var body: some View { + let displayState = widget.displayState + VStack(alignment: .leading, spacing: 8) { + if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { + let labelText = displayState.labelText + Text(labelText) + .ohTextToken(.rowLabel) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + WebRowView(widget: widget) + .frame(height: widget.preferredRowHeight) + .clipShape(.rect(cornerRadius: 8)) + + if let labelValue = displayState.labelValue, !labelValue.isEmpty { + Text(labelValue) + .ohTextToken(.rowValueCompact) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + } + } + } +} + +struct WebRowView: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + @State private var page = WebPage() + + private var webURL: URL? { + guard !widget.url.isEmpty else { return nil } + return URL(string: widget.url) + } + + var body: some View { + WebView(page) + .webViewBackForwardNavigationGestures(.disabled) + .webViewMagnificationGestures(.enabled) + .webViewTextSelection(.enabled) + .onAppear { + if let webURL { + let request = URLRequest(url: webURL) + let _ = page.load(request) + } + } + .onChange(of: widget.url) { _, newURL in + if let url = URL(string: newURL) { + let request = URLRequest(url: url) + let _ = page.load(request) + } + } + } +} diff --git a/openHAB/ScreenSaver/ScreenSaverConfiguration.swift b/openHAB/SwiftUI/ScreenSaver/ScreenSaverConfiguration.swift similarity index 100% rename from openHAB/ScreenSaver/ScreenSaverConfiguration.swift rename to openHAB/SwiftUI/ScreenSaver/ScreenSaverConfiguration.swift diff --git a/openHAB/ScreenSaver/ScreenSaverManager.swift b/openHAB/SwiftUI/ScreenSaver/ScreenSaverManager.swift similarity index 100% rename from openHAB/ScreenSaver/ScreenSaverManager.swift rename to openHAB/SwiftUI/ScreenSaver/ScreenSaverManager.swift diff --git a/openHAB/ScreenSaver/ScreenSaverView.swift b/openHAB/SwiftUI/ScreenSaver/ScreenSaverView.swift similarity index 98% rename from openHAB/ScreenSaver/ScreenSaverView.swift rename to openHAB/SwiftUI/ScreenSaver/ScreenSaverView.swift index ed1d98808..cf27ec6df 100644 --- a/openHAB/ScreenSaver/ScreenSaverView.swift +++ b/openHAB/SwiftUI/ScreenSaver/ScreenSaverView.swift @@ -78,14 +78,14 @@ struct ScreenSaverView: View { Text(dateString(for: context.date)) .font(dateFont(for: geometry.size)) .monospacedDigit() - .foregroundColor(.white.opacity(0.85 * alphaFactor)) + .foregroundStyle(.white.opacity(0.85 * alphaFactor)) } if configuration.showsTime { Text(timeString(for: context.date)) .font(timeFont(for: geometry.size)) .monospacedDigit() - .foregroundColor(.white.opacity(alphaFactor)) + .foregroundStyle(.white.opacity(alphaFactor)) } } .opacity(fadeOpacity) diff --git a/openHAB/SettingsView/AboutSettingsView.swift b/openHAB/SwiftUI/SettingsView/AboutSettingsView.swift similarity index 100% rename from openHAB/SettingsView/AboutSettingsView.swift rename to openHAB/SwiftUI/SettingsView/AboutSettingsView.swift diff --git a/openHAB/SettingsView/AnimatedSecureTextField.swift b/openHAB/SwiftUI/SettingsView/AnimatedSecureTextField.swift similarity index 97% rename from openHAB/SettingsView/AnimatedSecureTextField.swift rename to openHAB/SwiftUI/SettingsView/AnimatedSecureTextField.swift index 2becc48e8..4d7086553 100644 --- a/openHAB/SettingsView/AnimatedSecureTextField.swift +++ b/openHAB/SwiftUI/SettingsView/AnimatedSecureTextField.swift @@ -22,7 +22,7 @@ struct AnimatedSecureTextField: View { isSecure = !isSecure } label: { Image(systemSymbol: isSecure ? .eyeSlash : .eyeFill) - .foregroundColor(.gray) + .foregroundStyle(.gray) } Spacer() HStack { diff --git a/openHAB/SettingsView/ApplicationSettingsView.swift b/openHAB/SwiftUI/SettingsView/ApplicationSettingsView.swift similarity index 84% rename from openHAB/SettingsView/ApplicationSettingsView.swift rename to openHAB/SwiftUI/SettingsView/ApplicationSettingsView.swift index 110c4cf2e..894f27ad6 100644 --- a/openHAB/SettingsView/ApplicationSettingsView.swift +++ b/openHAB/SwiftUI/SettingsView/ApplicationSettingsView.swift @@ -16,6 +16,7 @@ import UIKit struct ApplicationSettingsView: View { @Binding var settingsIdleOff: Bool + @Binding var settingsHideStatusBar: Bool @Binding var settingsSSECommandItem: String @State private var selectedItemName: String? @@ -28,10 +29,10 @@ struct ApplicationSettingsView: View { ScreenSaverSettingsView() } - Toggle("Hide Status Bar", isOn: Binding( - get: { Preferences.shared.hideStatusBar }, - set: { Preferences.shared.hideStatusBar = $0; UIApplication.shared.keyWindowActiveScene?.rootViewController?.setNeedsStatusBarAppearanceUpdate() } - )) + Toggle("Hide Status Bar", isOn: $settingsHideStatusBar) + .onChange(of: settingsHideStatusBar) { _ in + UIApplication.shared.keyWindowActiveScene?.rootViewController?.setNeedsStatusBarAppearanceUpdate() + } NavigationLink("Client Certificates") { ClientCertificatesView() @@ -67,11 +68,13 @@ struct ApplicationSettingsView: View { #Preview { struct PreviewWrapper: View { @State private var idleOff = false + @State private var hideStatusBar = false @State private var sseCommandItem = "" var body: some View { Form { ApplicationSettingsView( settingsIdleOff: $idleOff, + settingsHideStatusBar: $hideStatusBar, settingsSSECommandItem: $sseCommandItem ) } diff --git a/openHAB/SettingsView/BonjourDiscoverySheet.swift b/openHAB/SwiftUI/SettingsView/BonjourDiscoverySheet.swift similarity index 98% rename from openHAB/SettingsView/BonjourDiscoverySheet.swift rename to openHAB/SwiftUI/SettingsView/BonjourDiscoverySheet.swift index 9a8a9f000..1cfbc262f 100644 --- a/openHAB/SettingsView/BonjourDiscoverySheet.swift +++ b/openHAB/SwiftUI/SettingsView/BonjourDiscoverySheet.swift @@ -39,7 +39,7 @@ struct BonjourDiscoverySheet: View { HStack { Spacer() Text("no_servers_found") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .padding(.vertical, 8) Spacer() } diff --git a/openHAB/SettingsView/ClientCertificatesView.swift b/openHAB/SwiftUI/SettingsView/ClientCertificatesView.swift similarity index 100% rename from openHAB/SettingsView/ClientCertificatesView.swift rename to openHAB/SwiftUI/SettingsView/ClientCertificatesView.swift diff --git a/openHAB/SettingsView/ConnectionSettingsView.swift b/openHAB/SwiftUI/SettingsView/ConnectionSettingsView.swift similarity index 100% rename from openHAB/SettingsView/ConnectionSettingsView.swift rename to openHAB/SwiftUI/SettingsView/ConnectionSettingsView.swift diff --git a/openHAB/SettingsView/DebugSettingsView.swift b/openHAB/SwiftUI/SettingsView/DebugSettingsView.swift similarity index 92% rename from openHAB/SettingsView/DebugSettingsView.swift rename to openHAB/SwiftUI/SettingsView/DebugSettingsView.swift index a653dc890..e2ec7b16e 100644 --- a/openHAB/SettingsView/DebugSettingsView.swift +++ b/openHAB/SwiftUI/SettingsView/DebugSettingsView.swift @@ -10,6 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 import Combine +import CommonUI import OpenHABCore import os.log import SafariServices @@ -24,9 +25,9 @@ struct DebugSettingsView: View { var body: some View { Toggle("Crash Reporting", isOn: $settingsSendCrashReports) .task { @MainActor in - updateSettingsSendCrashReports(Preferences.shared.sendCrashReports) + await updateSettingsSendCrashReports(Preferences.shared.applicationPreferences.sendCrashReports) } - .onChange(of: settingsSendCrashReports) { newValue in + .onChange(of: settingsSendCrashReports) { oldValue, newValue in #if !DEBUG Logger.settingsView.debug("Detected change on settingsSendCrashReports") #endif @@ -57,7 +58,7 @@ struct DebugSettingsView: View { } Section(header: Text(LocalizedStringKey("debug"))) { NavigationLink { - LoggerView() + LogsViewer() } label: { Text("Logs") } diff --git a/openHAB/SettingsView/ItemSelectionView.swift b/openHAB/SwiftUI/SettingsView/ItemSelectionView.swift similarity index 63% rename from openHAB/SettingsView/ItemSelectionView.swift rename to openHAB/SwiftUI/SettingsView/ItemSelectionView.swift index 80337d8fc..1928f09f6 100644 --- a/openHAB/SettingsView/ItemSelectionView.swift +++ b/openHAB/SwiftUI/SettingsView/ItemSelectionView.swift @@ -37,29 +37,9 @@ struct ItemSelectionView: View { var body: some View { VStack { if isLoading { - Spacer() - ProgressView("Loading Items…") - Spacer() + loadingView } else { - TextField("Search", text: $searchText) - .textFieldStyle(.roundedBorder) - .padding(.horizontal) - - List { - ForEach(filteredItems, id: \.name) { item in - Button { - selectedItemName = (selectedItemName == item.name) ? nil : item.name - } label: { - HStack { - Text(item.name) - Spacer() - if selectedItemName == item.name { - Image(systemSymbol: .checkmark) - } - } - } - } - } + loadedView } } .navigationTitle("Items") @@ -74,4 +54,39 @@ struct ItemSelectionView: View { } } } + + @ViewBuilder + private var loadingView: some View { + Spacer() + ProgressView("Loading Items…") + Spacer() + } + + @ViewBuilder + private var loadedView: some View { + TextField("Search", text: $searchText) + .textFieldStyle(.roundedBorder) + .padding(.horizontal) + + List { + ForEach(filteredItems, id: \.name) { item in + itemRow(item) + } + } + } + + @ViewBuilder + private func itemRow(_ item: OpenHABItem) -> some View { + Button { + selectedItemName = (selectedItemName == item.name) ? nil : item.name + } label: { + HStack { + Text(item.name) + Spacer() + if selectedItemName == item.name { + Image(systemSymbol: .checkmark) + } + } + } + } } diff --git a/openHAB/SettingsView/MainUISettingsView.swift b/openHAB/SwiftUI/SettingsView/MainUISettingsView.swift similarity index 98% rename from openHAB/SettingsView/MainUISettingsView.swift rename to openHAB/SwiftUI/SettingsView/MainUISettingsView.swift index ac6d6fe07..ea6a42ac5 100644 --- a/openHAB/SettingsView/MainUISettingsView.swift +++ b/openHAB/SwiftUI/SettingsView/MainUISettingsView.swift @@ -67,7 +67,7 @@ struct MainUISettingsView: View { } label: { NavigationLink("Clear Web Cache", destination: EmptyView()) } - .foregroundColor(Color(uiColor: .label)) + .foregroundStyle(Color(uiColor: .label)) .alert("cache_cleared", isPresented: $showingCacheAlert) { Button("OK", role: .cancel) {} } diff --git a/openHAB/SwiftUI/SettingsView/ScreenSaverSettingsView.swift b/openHAB/SwiftUI/SettingsView/ScreenSaverSettingsView.swift new file mode 100644 index 000000000..c969d5b6f --- /dev/null +++ b/openHAB/SwiftUI/SettingsView/ScreenSaverSettingsView.swift @@ -0,0 +1,249 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import OpenHABCore +import SwiftUI +import UIKit + +struct ScreenSaverSettingsView: View { + @State private var config = ScreenSaverConfiguration() + private let fontOptions: [String] = ["", "Arial", "Helvetica Neue", "Courier New", "Menlo", "Avenir Next"] + + var body: some View { + Form { + enableSection + appearanceSection + timingSection + fontSection + animationSection + brightnessSection + testSection + } + .navigationTitle("Screen Saver") + .onDisappear { + let config = config //copy to make isolated var sendable + Task { @MainActor in + ScreenSaverManager.shared.updateConfiguration(config) + // Persist to Preferences + await Preferences.shared.modifyScreenSaverPreferences { prefs in + prefs.isEnabled = config.isEnabled + prefs.showsTime = config.showsTime + prefs.showsDate = config.showsDate + prefs.idleInterval = config.idleInterval + prefs.movementInterval = config.movementInterval + prefs.fontName = config.fontName ?? "" + prefs.timeFontRatio = Double(config.timeFontSizeRatio) + prefs.dateFontRatio = Double(config.dateFontRelativeSize) + prefs.enableDimming = config.enablesAutoDimming + prefs.dimLevel = Double(config.dimLevel) + prefs.wakeBrightness = Double(config.wakeBrightnessLevel) + prefs.showsSeconds = config.showsSeconds + prefs.use24Hour = config.uses24HourTime + prefs.fadeDuration = config.fadeDuration + prefs.restoreBrightness = config.restoresBrightness + } + } + } + .task { @MainActor in + let prefs = await Preferences.shared.screensaverPreferences + var config = ScreenSaverConfiguration() + config.isEnabled = prefs.isEnabled + config.showsTime = prefs.showsTime + config.showsDate = prefs.showsDate + config.idleInterval = prefs.idleInterval + config.movementInterval = prefs.movementInterval + config.fontName = prefs.fontName.isEmpty ? nil : prefs.fontName + config.timeFontSizeRatio = CGFloat(prefs.timeFontRatio) + config.dateFontRelativeSize = CGFloat(prefs.dateFontRatio) + config.enablesAutoDimming = prefs.enableDimming + config.dimLevel = CGFloat(prefs.dimLevel) + config.wakeBrightnessLevel = CGFloat(prefs.wakeBrightness) + config.showsSeconds = prefs.showsSeconds + config.uses24HourTime = prefs.use24Hour + config.fadeDuration = prefs.fadeDuration + config.restoresBrightness = prefs.restoreBrightness + changeConfig(config) + } + } + + private var fontBinding: Binding { + Binding( + get: { config.fontName ?? "" }, + set: { config.fontName = $0.isEmpty ? nil : $0 } + ) + } + + private var idleIntervalBinding: Binding { + Binding( + get: { Int(config.idleInterval) }, + set: { config.idleInterval = TimeInterval($0) } + ) + } + + private var movementIntervalBinding: Binding { + Binding( + get: { Int(config.movementInterval) }, + set: { config.movementInterval = TimeInterval($0) } + ) + } + + private var timeFontSizeBinding: Binding { + Binding( + get: { Double(config.timeFontSizeRatio) }, + set: { config.timeFontSizeRatio = CGFloat($0) } + ) + } + + private var dateFontSizeBinding: Binding { + Binding( + get: { Double(config.dateFontRelativeSize) }, + set: { config.dateFontRelativeSize = CGFloat($0) } + ) + } + + private var dimLevelBinding: Binding { + Binding( + get: { Double(config.dimLevel * 100) }, + set: { config.dimLevel = CGFloat($0) / 100 } + ) + } + + private var wakeBrightnessBinding: Binding { + Binding( + get: { Double(config.wakeBrightnessLevel * 100) }, + set: { config.wakeBrightnessLevel = CGFloat($0) / 100 } + ) + } + + @ViewBuilder + private var enableSection: some View { + Section { + Toggle("Enable Screen Saver", isOn: $config.isEnabled) + } + } + + @ViewBuilder + private var appearanceSection: some View { + Section("Appearance") { + Toggle("Show Time", isOn: $config.showsTime) + Toggle("Show Date", isOn: $config.showsDate) + Toggle("Show Seconds", isOn: $config.showsSeconds) + Toggle("24-Hour Clock", isOn: $config.uses24HourTime) + Picker("Font", selection: fontBinding) { + ForEach(fontOptions, id: \.self) { name in + Text(name.isEmpty ? "Default" : name).tag(name) + } + } + } + .disabled(!config.isEnabled) + } + + @ViewBuilder + private var timingSection: some View { + Section("Timing") { + Stepper(value: idleIntervalBinding, in: 5 ... 600, step: 5) { + Text("Idle Interval: \(Int(config.idleInterval)) s") + } + + Stepper(value: movementIntervalBinding, in: 2 ... 60, step: 1) { + Text("Movement Interval: \(Int(config.movementInterval)) s") + } + } + .disabled(!config.isEnabled) + } + + @ViewBuilder + private var fontSection: some View { + Section("Font Size") { + VStack(alignment: .leading) { + Text("Clock Size: \(Int(config.timeFontSizeRatio * 100)) %") + .font(.caption) + Slider(value: timeFontSizeBinding, in: 0.05 ... 0.4, step: 0.01) + } + + VStack(alignment: .leading) { + Text("Date relative: \(Int(config.dateFontRelativeSize * 100)) %") + .font(.caption) + Slider(value: dateFontSizeBinding, in: 0.1 ... 1.0, step: 0.05) + } + } + .disabled(!config.isEnabled) + } + + @ViewBuilder + private var animationSection: some View { + Section("Animation") { + VStack(alignment: .leading) { + Text("Fade Duration: \(String(format: "%.1f", config.fadeDuration)) s") + .font(.caption) + Slider(value: $config.fadeDuration, in: 0.1 ... 3.0, step: 0.1) + } + } + .disabled(!config.isEnabled) + } + + @ViewBuilder + private var brightnessSection: some View { + Section("Brightness") { + Toggle("Enable Dimming", isOn: $config.enablesAutoDimming) + + VStack(alignment: .leading) { + Text("Dim Level: \(Int(config.dimLevel * 100)) %") + .font(.caption) + Slider(value: dimLevelBinding, in: 0 ... 100, step: 1) + } + .disabled(!config.enablesAutoDimming) + + Toggle("Restore Previous Brightness on Wake", isOn: $config.restoresBrightness) + .disabled(!config.enablesAutoDimming) + + VStack(alignment: .leading) { + Text("Restore Brightness: \(Int(config.wakeBrightnessLevel * 100)) %") + .font(.caption) + Slider(value: wakeBrightnessBinding, in: 0 ... 100, step: 1) + } + .disabled(!config.enablesAutoDimming || config.restoresBrightness) + } + .disabled(!config.isEnabled) + } + + @ViewBuilder + private var testSection: some View { + Section { + Button("Test Screen Saver") { + if let keyWindow = UIApplication.shared.keyWindowActiveScene { + ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) + } + ScreenSaverManager.shared.presentSaver(configuration: config) + } + } + } + + private func changeConfig(_ config: ScreenSaverConfiguration) { + self.config = config + } +} + +extension UIApplication { + var keyWindowActiveScene: UIWindow? { + connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .windows + .first { $0.isKeyWindow } + } +} + +#Preview { + NavigationStack { + ScreenSaverSettingsView() + } +} diff --git a/openHAB/SettingsView/ServerCertificatesView.swift b/openHAB/SwiftUI/SettingsView/ServerCertificatesView.swift similarity index 95% rename from openHAB/SettingsView/ServerCertificatesView.swift rename to openHAB/SwiftUI/SettingsView/ServerCertificatesView.swift index 1c61209c2..6d2d98c18 100644 --- a/openHAB/SettingsView/ServerCertificatesView.swift +++ b/openHAB/SwiftUI/SettingsView/ServerCertificatesView.swift @@ -77,7 +77,7 @@ struct ServerCertificatesView: View { List { if viewModel.certificates.isEmpty { Text("No accepted server certificates") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } else { ForEach(viewModel.certificates, id: \.domain) { certificate in VStack(alignment: .leading, spacing: 4) { @@ -86,11 +86,11 @@ struct ServerCertificatesView: View { if let summary = certificate.summary { Text(summary) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } Text("Added: \(certificate.dateAdded, formatter: viewModel.dateFormatter)") .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } .padding(.vertical, 2) } diff --git a/openHAB/SwiftUI/SettingsView/SettingsView.swift b/openHAB/SwiftUI/SettingsView/SettingsView.swift new file mode 100644 index 000000000..254878026 --- /dev/null +++ b/openHAB/SwiftUI/SettingsView/SettingsView.swift @@ -0,0 +1,287 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import FirebaseCrashlytics +import OpenHABCore +import os +import SwiftUI + + private struct SettingsSnapshot: Equatable { + var demomode: Bool + var idleOff: Bool + var hideStatusBar: Bool + var realTimeSliders: Bool + var showSearchField: Bool + var sendCrashReports: Bool + var iconType: IconType + var sortSitemapsBy: SortSitemapsOrder + var defaultMainUIPath: String + var alwaysAllowWebRTC: Bool + var sitemapForWatch: String + var localConnectionConfig: ConnectionConfiguration + var remoteConnectionConfig: ConnectionConfiguration + var homeName: String + var sseCommandItem: String + var tabConfiguration: [TabEntry] + } + +struct SettingsView: View { + @State private var settingsDemomode = false + @State private var settingsIdleOff = true + @State private var settingsHideStatusBar = false + @State private var settingsRealTimeSliders = true + @State private var settingsShowSearchField = true + @State private var settingsSendCrashReports = false + @State private var settingsIconType: IconType = .svg + @State private var settingsSortSitemapsBy: SortSitemapsOrder = .label + @State private var settingsDefaultMainUIPath = "" + @State private var settingsAlwaysAllowWebRTC = true + @State private var settingsSitemapForWatch = "" + + @State private var sitemaps: [OpenHABSitemap] = [] + @State private var settingsLocalConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") + @State private var settingsRemoteConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") + @State private var settingsHomeName = "" + @State private var viewAppearedOnce = false + @State private var settingsSSECommandItem = "" + @State private var settingsTabConfiguration: [TabEntry] = TabEntry.defaultConfiguration + + @State private var initialSnapshot: SettingsSnapshot? + @State private var isDirty = false + + @Environment(\.dismiss) private var dismiss + + private var currentSnapshot: SettingsSnapshot { + SettingsSnapshot( + demomode: settingsDemomode, + idleOff: settingsIdleOff, + hideStatusBar: settingsHideStatusBar, + realTimeSliders: settingsRealTimeSliders, + showSearchField: settingsShowSearchField, + sendCrashReports: settingsSendCrashReports, + iconType: settingsIconType, + sortSitemapsBy: settingsSortSitemapsBy, + defaultMainUIPath: settingsDefaultMainUIPath, + alwaysAllowWebRTC: settingsAlwaysAllowWebRTC, + sitemapForWatch: settingsSitemapForWatch, + localConnectionConfig: settingsLocalConnectionConfiguration, + remoteConnectionConfig: settingsRemoteConnectionConfiguration, + homeName: settingsHomeName, + sseCommandItem: settingsSSECommandItem, + tabConfiguration: settingsTabConfiguration + ) + } + + var body: some View { + Form { + ConnectionSettingsView( + settingsDemomode: $settingsDemomode, + localConnectionConfiguration: $settingsLocalConnectionConfiguration, + remoteConnectionConfiguration: $settingsRemoteConnectionConfiguration + ) + + ApplicationSettingsView( + settingsIdleOff: $settingsIdleOff, + settingsHideStatusBar: $settingsHideStatusBar, + settingsSSECommandItem: $settingsSSECommandItem + ) + + TabCustomizationSection(tabConfiguration: $settingsTabConfiguration) + + MainUISettingsView( + settingsAlwaysAllowWebRTC: $settingsAlwaysAllowWebRTC, + settingsDefaultMainUIPath: $settingsDefaultMainUIPath + ) + + SitemapSettingsView( + settingsRealTimeSliders: $settingsRealTimeSliders, + settingsShowSearchField: $settingsShowSearchField, + settingsIconType: $settingsIconType, + settingsSortSitemapsBy: $settingsSortSitemapsBy, + settingsSitemapForWatch: $settingsSitemapForWatch, + sitemaps: $sitemaps + ) + + DebugSettingsView( + settingsSendCrashReports: $settingsSendCrashReports + ) + + AboutSettingsView() + } + .formStyle(.grouped) + .navigationBarBackButtonHidden(isDirty) + .navigationTitle("\(settingsHomeName) Settings") + .toolbar { + if isDirty { + ToolbarItem(placement: .confirmationAction) { + Button { + saveSettings() + dismiss() + } label: { + Image(systemName: "checkmark") + } + } + ToolbarItem(placement: .cancellationAction) { + Button { + restoreFromSnapshot() + } label: { + Image(systemName: "xmark") + } + } + } + } + .task { + if !viewAppearedOnce { + viewAppearedOnce = true + await loadSettings() + initialSnapshot = currentSnapshot + let activeConfiguration = settingsLocalConnectionConfiguration + await updateSitemaps(activeConfiguration: activeConfiguration) + } + } + .onChange(of: currentSnapshot) { _, newSnapshot in + guard let initialSnapshot else { return } + withAnimation { + isDirty = newSnapshot != initialSnapshot + } + } + } + + private func restoreFromSnapshot() { + guard let snapshot = initialSnapshot else { return } + settingsDemomode = snapshot.demomode + settingsIdleOff = snapshot.idleOff + settingsHideStatusBar = snapshot.hideStatusBar + settingsRealTimeSliders = snapshot.realTimeSliders + settingsShowSearchField = snapshot.showSearchField + settingsSendCrashReports = snapshot.sendCrashReports + settingsIconType = snapshot.iconType + settingsSortSitemapsBy = snapshot.sortSitemapsBy + settingsDefaultMainUIPath = snapshot.defaultMainUIPath + settingsAlwaysAllowWebRTC = snapshot.alwaysAllowWebRTC + settingsSitemapForWatch = snapshot.sitemapForWatch + settingsLocalConnectionConfiguration = snapshot.localConnectionConfig + settingsRemoteConnectionConfiguration = snapshot.remoteConnectionConfig + settingsHomeName = snapshot.homeName + settingsSSECommandItem = snapshot.sseCommandItem + settingsTabConfiguration = snapshot.tabConfiguration + } + + private func updateSitemaps(activeConfiguration: ConnectionConfiguration) async { + do { + let openAPIService = try OpenAPIService(connectionConfiguration: activeConfiguration) + + sitemaps = try await openAPIService.openHABSitemaps() + if sitemaps.last?.name == "_default", sitemaps.count > 1 { + sitemaps = Array(sitemaps.dropLast()) + } + + // Sort the sitemaps according to Settings selection. + switch await SortSitemapsOrder(rawValue: Preferences.shared.currentHomePreferences.sortSitemapsBy) ?? .label { + case .label: sitemaps.sort { $0.label < $1.label } + case .name: sitemaps.sort { $0.name < $1.name } + } + } catch { + Logger.settingsView.error("\(error.localizedDescription)") + sitemaps = [] + } + } + + private func loadSettings() async { + #if !DEBUG + Logger.settingsView.debug("Loading Settings") + #endif + let appPrefs = await Preferences.shared.applicationPreferences + settingsDemomode = await Preferences.shared.currentHomePreferences.demomode + settingsIdleOff = appPrefs.idleOff + settingsHideStatusBar = appPrefs.hideStatusBar + settingsRealTimeSliders = await Preferences.shared.currentHomePreferences.realTimeSliders + settingsShowSearchField = appPrefs.showSearchField + settingsSendCrashReports = appPrefs.sendCrashReports + settingsIconType = IconType(rawValue: await Preferences.shared.currentHomePreferences.iconType) ?? .svg + settingsSortSitemapsBy = SortSitemapsOrder(rawValue: await Preferences.shared.currentHomePreferences.sortSitemapsBy) ?? .label + settingsDefaultMainUIPath = await Preferences.shared.currentHomePreferences.defaultMainUIPath + settingsAlwaysAllowWebRTC = await Preferences.shared.currentHomePreferences.alwaysAllowWebRTC + settingsSitemapForWatch = await Preferences.shared.currentHomePreferences.sitemapForWatch + settingsLocalConnectionConfiguration = await Preferences.shared.currentHomePreferences.localConnectionConfig + settingsRemoteConnectionConfiguration = await Preferences.shared.currentHomePreferences.remoteConnectionConfig + settingsHomeName = await Preferences.shared.currentHomePreferences.homeName + settingsSSECommandItem = await Preferences.shared.currentHomePreferences.sseCommandItem + settingsTabConfiguration = await Preferences.shared.currentHomePreferences.tabConfiguration + } + + func saveSettings() { + let settingsDemomode = settingsDemomode + let settingsRealTimeSliders = settingsRealTimeSliders + let settingsIconType = settingsIconType.rawValue + let settingsSortSitemapsBy = settingsSortSitemapsBy.rawValue + let settingsDefaultMainUIPath = settingsDefaultMainUIPath + let settingsAlwaysAllowWebRTC = settingsAlwaysAllowWebRTC + let settingsSitemapForWatch = settingsSitemapForWatch + let sitemapForWatchLabel = sitemaps.first { $0.name == settingsSitemapForWatch }?.label ?? "unknown" + let settingsLocalConnectionConfiguration = settingsLocalConnectionConfiguration + let settingsRemoteConnectionConfiguration = settingsRemoteConnectionConfiguration + let settingsSSECommandItem = settingsSSECommandItem + let settingsTabConfiguration = settingsTabConfiguration + let settingsIdleOff = settingsIdleOff + let settingsHideStatusBar = settingsHideStatusBar + let settingsSendCrashReports = settingsSendCrashReports + let settingsShowSearchField = settingsShowSearchField + + Task { @MainActor in + await Preferences.shared.modifyActiveHome { homePreferences in + homePreferences.demomode = settingsDemomode + homePreferences.realTimeSliders = settingsRealTimeSliders + homePreferences.iconType = settingsIconType + homePreferences.sortSitemapsBy = settingsSortSitemapsBy + homePreferences.defaultMainUIPath = settingsDefaultMainUIPath + homePreferences.alwaysAllowWebRTC = settingsAlwaysAllowWebRTC + homePreferences.sitemapForWatch = settingsSitemapForWatch + homePreferences.sitemapForWatchLabel = sitemapForWatchLabel + homePreferences.localConnectionConfig = settingsLocalConnectionConfiguration + homePreferences.remoteConnectionConfig = settingsRemoteConnectionConfiguration + homePreferences.sseCommandItem = settingsSSECommandItem + homePreferences.tabConfiguration = settingsTabConfiguration + } + + await Preferences.shared.modifyApplicationPreferences { applicationPreferences in + applicationPreferences.idleOff = settingsIdleOff + applicationPreferences.hideStatusBar = settingsHideStatusBar + applicationPreferences.sendCrashReports = settingsSendCrashReports + applicationPreferences.showSearchField = settingsShowSearchField + } + + // Apply global UI changes immediately (status bar visibility) + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first?.rootViewController? + .setNeedsStatusBarAppearanceUpdate() + + NotificationCenter.default.post(name: NSNotification.Name("org.openhab.preferences.saved"), object: nil) + } + } +} + +extension UIApplication { + var firstKeyWindow: UIWindow? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .first?.keyWindow + } +} + +#Preview { + NavigationStack { + SettingsView() + } +} diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SwiftUI/SettingsView/SingleConnectionSettingsView.swift similarity index 97% rename from openHAB/SettingsView/SingleConnectionSettingsView.swift rename to openHAB/SwiftUI/SettingsView/SingleConnectionSettingsView.swift index e17b4b453..cae47e877 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SwiftUI/SettingsView/SingleConnectionSettingsView.swift @@ -81,7 +81,7 @@ struct SingleConnectionSettingsView: View { Image(systemSymbol: .wifiCircle) } .buttonStyle(.plain) - .foregroundColor(.accentColor) + .foregroundStyle(Color.accentColor) .disabled(connectionConfig.url.isEmpty) .help("Test Connection") } @@ -97,9 +97,9 @@ struct SingleConnectionSettingsView: View { if let message = connectionTestMessage, let success = connectionTestSuccess { HStack(spacing: 4) { Image(systemSymbol: success ? .checkmarkCircle : .xmarkOctagon) - .foregroundColor(success ? .green : .red) + .foregroundStyle(success ? .green : .red) Text(message) - .foregroundColor(success ? .green : .red) + .foregroundStyle(success ? .green : .red) .font(.caption2) } .transition(.opacity) diff --git a/openHAB/SwiftUI/SettingsView/SitemapSettingsView.swift b/openHAB/SwiftUI/SettingsView/SitemapSettingsView.swift new file mode 100644 index 000000000..94c5236cf --- /dev/null +++ b/openHAB/SwiftUI/SettingsView/SitemapSettingsView.swift @@ -0,0 +1,183 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Kingfisher +import OpenHABCore +import os +import SwiftUI + +struct SitemapSettingsView: View { + @Binding var settingsRealTimeSliders: Bool + @Binding var settingsShowSearchField: Bool + @Binding var settingsIconType: IconType + @Binding var settingsSortSitemapsBy: SortSitemapsOrder + @Binding var settingsSitemapForWatch: String + @Binding var sitemaps: [OpenHABSitemap] + + @State private var showingCacheAlert = false + @State var cacheSizeResult: Result? + + var body: some View { + Section(header: Text(LocalizedStringKey("sitemap_settings"))) { + realtimeSliderToggle + searchFieldToggle + cacheButton + iconTypePicker + sortOrderPicker + watchSitemapPicker + } + } + + @ViewBuilder + private var realtimeSliderToggle: some View { + Toggle(isOn: $settingsRealTimeSliders) { + Text("Real-time Sliders") + } + } + + @ViewBuilder + private var searchFieldToggle: some View { + Toggle(isOn: $settingsShowSearchField) { + Text("Show Search Field") + } + } + + @ViewBuilder + private var cacheButton: some View { + Button { + KingfisherManager.shared.cache.calculateDiskStorageSize { result in + Task { @MainActor in + cacheSizeResult = result + showingCacheAlert = true + } + } + } label: { + NavigationLink("Check & Clear Image Cache", destination: EmptyView()) + } + .foregroundStyle(Color(uiColor: .label)) + .alert( + "Image Cache", + isPresented: $showingCacheAlert, + presenting: cacheSizeResult, + actions: cacheAlertActions, + message: cacheAlertMessage + ) + } + + @ViewBuilder + private var iconTypePicker: some View { + Picker(selection: $settingsIconType) { + ForEach(IconType.allCases, id: \.self) { icontype in + Text(verbatim: "\(icontype)").tag(icontype) + } + } label: { + Text("Icon Type") + } + } + + @ViewBuilder + private var sortOrderPicker: some View { + Picker(selection: $settingsSortSitemapsBy) { + ForEach(SortSitemapsOrder.allCases, id: \.self) { sortsitemaporder in + Text(verbatim: "\(sortsitemaporder)").tag(sortsitemaporder) + } + } label: { + Text("Sort sitemaps by") + } + } + + @ViewBuilder + private var watchSitemapPicker: some View { + Picker("Sitemap For Apple Watch", selection: $settingsSitemapForWatch) { + if sitemaps.isEmpty { + Text("No sitemaps available").tag("").foregroundStyle(.secondary) + } else { + ForEach(sitemaps, id: \.name) { sitemap in + Text(sitemap.label).tag(sitemap.name) + } + } + } + .disabled(sitemaps.isEmpty) + } + + @ViewBuilder + private func cacheAlertActions(_ result: Result) -> some View { + switch result { + case .success: + Button("Clear") { + clearWebsiteCache() + } + Button("Cancel", role: .cancel) {} + case .failure: + Button("OK") {} + } + } + + @ViewBuilder + private func cacheAlertMessage(_ result: Result) -> some View { + switch result { + case let .success(size): + Text("Size: \(size / 1_048_576) MB") + case let .failure(error): + Text(error.localizedDescription) + } + } + + func clearWebsiteCache() { + #if !DEBUG + Logger.settingsView.debug("Clearing image cache") + #endif + KingfisherManager.shared.cache.clearMemoryCache() + KingfisherManager.shared.cache.clearDiskCache() + KingfisherManager.shared.cache.cleanExpiredDiskCache() + } +} + +#Preview { + struct PreviewWrapper: View { + @State var realTimeSliders = true + @State var showSearchField = true + @State var iconType: IconType = .svg + @State var sortSitemapsBy: SortSitemapsOrder = .label + @State var sitemapForWatch = "Home" + @State var sitemaps: [OpenHABSitemap] = [ + OpenHABSitemap( + name: "home", + icon: "", + label: "Home", + link: "http://192.168.1.100/rest/sitemaps/home", + page: nil + ), + OpenHABSitemap( + name: "office", + icon: "", + label: "Office", + link: "http://192.168.1.100/rest/sitemaps/office", + page: nil + ) + ] + var body: some View { + NavigationStack { + Form { + SitemapSettingsView( + settingsRealTimeSliders: $realTimeSliders, + settingsShowSearchField: $showSearchField, + settingsIconType: $iconType, + settingsSortSitemapsBy: $sortSitemapsBy, + settingsSitemapForWatch: $sitemapForWatch, + sitemaps: $sitemaps + ) + } + } + } + } + return PreviewWrapper() +} diff --git a/openHAB/SwiftUI/SettingsView/TabCustomizationSection.swift b/openHAB/SwiftUI/SettingsView/TabCustomizationSection.swift new file mode 100644 index 000000000..2e2bfe9b7 --- /dev/null +++ b/openHAB/SwiftUI/SettingsView/TabCustomizationSection.swift @@ -0,0 +1,63 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import OpenHABCore +import SwiftUI + +struct TabCustomizationSection: View { + @Binding var tabConfiguration: [TabEntry] + + var body: some View { + Section(header: Text("Tabs")) { + ForEach(Array(tabConfiguration.enumerated()), id: \.element.id) { index, entry in + HStack { + Image(systemName: "line.3.horizontal") + .foregroundStyle(.secondary) + .font(.callout) + Image(systemName: systemImage(for: entry.id)) + .frame(width: 24) + .foregroundStyle(entry.enabled || entry.id == "system" ? .primary : .secondary) + Text(displayName(for: entry.id)) + .foregroundStyle(entry.enabled || entry.id == "system" ? .primary : .secondary) + Spacer() + if entry.id != "system" { + Toggle("", isOn: $tabConfiguration[index].enabled) + .labelsHidden() + } + } + } + .onMove { source, destination in + tabConfiguration.move(fromOffsets: source, toOffset: destination) + } + } + .environment(\.editMode, .constant(.active)) + } + + private func displayName(for id: String) -> String { + switch id { + case "main": "Home" + case "sitemaps": "Sitemaps" + case "tiles": "Tiles" + case "system": "System" + default: id.capitalized + } + } + + private func systemImage(for id: String) -> String { + switch id { + case "main": "house" + case "sitemaps": "map" + case "tiles": "square.grid.2x2" + case "system": "gear" + default: "questionmark" + } + } +} diff --git a/openHAB/SwiftUI/SitemapView/EmbeddingRowView.swift b/openHAB/SwiftUI/SitemapView/EmbeddingRowView.swift new file mode 100644 index 000000000..416fdbf85 --- /dev/null +++ b/openHAB/SwiftUI/SitemapView/EmbeddingRowView.swift @@ -0,0 +1,79 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import OpenHABCore +import SwiftUI + +struct EmbeddingRowView: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + @State private var showInputAlert = false + @State private var inputWidget: OpenHABWidget? + @State private var inputText = "" + + /// Insets for different widget types - Frame headers are more compact + private var rowInsets: EdgeInsets { + if widget.type == .frame { + // Frame headers: ~35pt total in UIKit + let hasLabel = !(widget.label.isEmpty) + return hasLabel + ? EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16) + : EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) + } + // Regular rows: use smaller vertical padding to match UIKit density + return EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16) + } + + /// Background color matching UIKit: Frame rows use systemGroupedBackground, others use secondarySystemGroupedBackground + private var rowBackground: Color { + if widget.type == .frame { + return Color(UIColor.ohSystemGroupedBackground) + } + return Color(UIColor.ohSecondarySystemGroupedBackground) + } + + var body: some View { + Group { + if let linkedPage = widget.linkedPage { + NavigationLink(destination: SitemapPageView(viewModel: SitemapPageViewModel(pageUrl: linkedPage.link, title: linkedPage.title))) { + RowViewFactory.view(for: widget) + } + .buttonStyle(.plain) + } else if widget.type == .input { + Button { + inputWidget = widget + showInputAlert = true + } label: { + RowViewFactory.view(for: widget) + } + .buttonStyle(.plain) + } else { + RowViewFactory.view(for: widget) + } + } + .contentShape(Rectangle()) + .listRowInsets(rowInsets) + .listRowBackground(rowBackground) + .alert("Input", isPresented: $showInputAlert) { + if let widget = inputWidget { + TextField("Enter value", text: $inputText) + Button("Cancel", role: .cancel) {} + Button("OK") { + // Handle input submission + showInputAlert = false + if let item = widget.item { + viewModel.sendCommand(item, commandToSend: inputText) + } + } + } + } + } +} diff --git a/openHAB/SwiftUI/SitemapView/IconURLView.swift b/openHAB/SwiftUI/SitemapView/IconURLView.swift new file mode 100644 index 000000000..a148cee07 --- /dev/null +++ b/openHAB/SwiftUI/SitemapView/IconURLView.swift @@ -0,0 +1,52 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Kingfisher +import OpenHABCore +import os.log +import SwiftUI + +/// A SwiftUI view that displays widget icons with openHAB-specific styling and caching +struct IconURLView: View { + @State var iconURL: URL + + let size: CGSize + + private let logger = Logger(subsystem: "org.openhab", category: "IconURLView") + + var body: some View { + ZStack { + // No icon or failed to load - show empty space + Rectangle() + .fill(Color.clear) + .frame(width: size.width, height: size.height) + + KFImage(iconURL) + .retry(maxCount: 3, interval: .seconds(5)) + .resizable() + .setProcessor(OpenHABImageProcessor()) + .onFailure { error in + logger.error("Icon loading failed for URL : \(iconURL): \(error.localizedDescription)") + } + .onSuccess { _ in + logger.info("Loading succeeded for URL : \(iconURL)") + } + .fade(duration: 0.25) + .cancelOnDisappear(true) + .aspectRatio(contentMode: .fit) + .frame(width: size.width, height: size.height) + } + } +} + +#Preview { + IconURLView(iconURL: URL(string: "https://github.com/onevcat/Flower-Data-Set/raw/master/rose/rose-1.jpg")!, size: CGSize(width: 100, height: 100)) +} diff --git a/openHAB/SwiftUI/SitemapView/IconView.swift b/openHAB/SwiftUI/SitemapView/IconView.swift new file mode 100644 index 000000000..a3372a857 --- /dev/null +++ b/openHAB/SwiftUI/SitemapView/IconView.swift @@ -0,0 +1,173 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Combine +import Kingfisher +import OpenHABCore +import os.log +import SFSafeSymbols +import SwiftUI + +/// Thread-safe actor for tracking cached icon keys +actor IconCacheTracker { + static let shared = IconCacheTracker() + private var cachedKeys: [String] = [] + + func addCacheKey(_ key: String) { + if !cachedKeys.contains(key) { + cachedKeys.append(key) + } + } + + func getCachedKeys() -> [String] { + cachedKeys + } + + func clearCache() { + cachedKeys.removeAll() + } + + func getCacheCount() -> Int { + cachedKeys.count + } +} + +/// A SwiftUI view that displays widget icons with openHAB-specific styling and caching +struct IconView: View { + @ObservedObject var widget: OpenHABWidget + @ObservedObject private var networkTracker = MainActorNetworkTracker.shared + @Environment(\.colorScheme) private var colorScheme + @EnvironmentObject var viewModel: SitemapPageViewModel + + let size: CGSize + let iconType: IconType = .svg + /// Optional SF Symbol to show as fallback when network icon is unavailable (useful for previews) + let fallbackSymbol: SFSymbol? + + private let logger = Logger(subsystem: "org.openhab", category: "IconView") + + @State private var currentImage: UIImage? + + /// Icon color converted to hex, using ohBlack as default (adapts to light/dark mode) + private var iconColorHex: String { + let logicColor = !widget.iconColor.isEmpty ? UIColor(fromString: widget.iconColor) : .ohBlack + return logicColor.semanticColorToHex() ?? "#000000" + } + + private var iconURL: URL? { + guard !widget.icon.isEmpty else { return nil } + + guard + let activeConnection = networkTracker.activeConnection, + !activeConnection.configuration.url.isEmpty else { + logger.debug("No active connection to fetch icon") + return nil + } + + return Endpoint.icon( + rootUrl: activeConnection.configuration.url, + version: activeConnection.version, + icon: widget.icon, + state: widget.iconState(), + iconType: iconType, + iconColor: iconColorHex, + staticIcon: widget.staticIcon + )?.url + } + + var body: some View { + ZStack { + // No icon URL - show fallback symbol if available + if let fallbackSymbol { + Image(systemSymbol: fallbackSymbol) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size.width * 0.75, height: size.height * 0.75) + .foregroundStyle(.primary) + } else { + Rectangle() + .fill(Color.clear) + .frame(width: size.width, height: size.height) + } + + if let iconURL { + KFImage(iconURL) + .retry(maxCount: 3, interval: .seconds(5)) + .resizable() + .setProcessor(OpenHABImageProcessor(iconColor: processorIconColor(for: iconURL))) + .onFailure { error in + logger.error("Icon loading failed for widget \(widget.label): \(error.localizedDescription)") + logger.error("Failed URL: \(iconURL.absoluteString)") + } + .onSuccess { result in + currentImage = result.image + if result.cacheType != .none { + let cacheKey = iconURL.absoluteString + Task { + await IconCacheTracker.shared.addCacheKey(cacheKey) + } + } + } + .placeholder { _ in + // Workaround to show current image before new image is displayed. See https://github.com/onevcat/Kingfisher/issues/2028 + Image(uiImage: currentImage ?? .init()).resizable() + } + .cancelOnDisappear(true) + .aspectRatio(contentMode: .fit) + .frame(width: size.width, height: size.height) + .id("\(viewModel.pageId)-\(widget.id)-\(colorScheme)") + } + } + } + + /// Returns the icon color for SVG preprocessing, or nil for iconify icons (they handle their own colors) + private func processorIconColor(for url: URL) -> String? { + // Don't apply color preprocessing for iconify icons + guard url.host != "api.iconify.design" else { return nil } + return iconColorHex + } +} + +// MARK: - Convenience Extensions + +extension IconView { + /// Creates a widget icon view with standard size (32x32, matching UIKit cells) + init(widget: OpenHABWidget, fallbackSymbol: SFSymbol? = nil) { + self.init( + widget: widget, + size: CGSize(width: 32, height: 32), + fallbackSymbol: fallbackSymbol + ) + } +} + +// MARK: - Widget Type Extensions + +extension IconView { + /// Determines if a widget type should show an icon (equivalent to NoIconDisplayableCell protocol) + static func shouldShowIcon(for widget: OpenHABWidget) -> Bool { + // These widget types should not show icons (equivalent to NoIconDisplayableCell) + switch widget.type { + case .frame, .image, .chart, .video, .webview: + false + default: + !widget.icon.isEmpty + } + } +} + +#Preview { + let widget = OpenHABWidget() + widget.icon = "switch" + widget.label = "Test Switch" + return IconView(widget: widget, fallbackSymbol: .switch2) + .environmentObject(SitemapPageViewModel()) +} diff --git a/openHAB/SwiftUI/SitemapView/ImageView.swift b/openHAB/SwiftUI/SitemapView/ImageView.swift new file mode 100644 index 000000000..a7f585091 --- /dev/null +++ b/openHAB/SwiftUI/SitemapView/ImageView.swift @@ -0,0 +1,45 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Combine +import Kingfisher +import OpenHABCore +import os.log +import SafariServices +import SFSafeSymbols +import SwiftUI + +struct ImageView: View { + let url: String + + @EnvironmentObject var networkTracker: MainActorNetworkTracker + + var body: some View { + if !url.isEmpty { + switch url { + case _ where url.hasPrefix("data:image"): + let provider = Base64ImageDataProvider(base64String: url.deletingPrefix("data:image/png;base64,"), cacheKey: UUID().uuidString) + return KFImage(source: .provider(provider)).resizable() + case _ where url.hasPrefix("http"): + return KFImage(URL(string: url)).resizable() + default: + let builtURL = Endpoint.resource( + openHABRootUrl: networkTracker.activeConnection?.configuration.url ?? "", + path: url.prepare() + ).url + return KFImage(builtURL).resizable() + } + } else { + // This will always fallback to placeholder + return KFImage(URL(string: "bundle://openHABIcon")).placeholder { Image("openHABIcon").resizable() } + } + } +} diff --git a/openHAB/SwiftUI/SitemapView/OpenHABWebView.swift b/openHAB/SwiftUI/SitemapView/OpenHABWebView.swift new file mode 100644 index 000000000..eaa9b36f9 --- /dev/null +++ b/openHAB/SwiftUI/SitemapView/OpenHABWebView.swift @@ -0,0 +1,418 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Combine +import OpenHABCore +import os.log +import SafariServices +import SwiftUI +import SwiftMessages +import WebKit + +@MainActor +@Observable +class OpenHABWebViewModel { + private var currentTarget = "" + private var openHABTrackedRootUrl = "" + private var activeConnectionInfo: ConnectionInfo? + private var activeConfig: ConnectionConfiguration? { activeConnectionInfo?.configuration } + + var page = WebPage() + var pageConfiguration = WebPage.Configuration() + var hideNavigationBar = false + var isLoading = false + var commandQueue: [String] = [] + var acceptsCommands = false + var etagChecker: ETagChecker? + var etagCheckerConfigURL: String? + var lastLoadedURL: String? + var currentPath: String = "" + + private var trackerCancellables = Set() + private var sseTimer: Timer? + + private var js = """ + (function() { + // Main UI Callbacks + window.OHApp = { + exitToApp : function(){ + window.webkit.messageHandlers.mainUi.postMessage('exitToApp'); + }, + goFullscreen : function(){ + window.webkit.messageHandlers.mainUi.postMessage('goFullscreen'); + }, + sseConnected : function(connected) { + window.webkit.messageHandlers.mainUi.postMessage('sseConnected-' + connected); + }, + ready : function() { + window.webkit.messageHandlers.mainUi.postMessage('ready'); + }, + } + + // Detect Path changes in SPA + function notifyPathChange() { + window.webkit.messageHandlers.pathChanged.postMessage(window.location.pathname); + } + + const originalPushState = history.pushState; + history.pushState = function() { + originalPushState.apply(this, arguments); + notifyPathChange(); + }; + + const originalReplaceState = history.replaceState; + history.replaceState = function() { + originalReplaceState.apply(this, arguments); + notifyPathChange(); + }; + + window.addEventListener('popstate', notifyPathChange); + + // Notify initial path on load + notifyPathChange(); + })(); + """ + + init() { + setupWebPage() + observeNetworkChanges() + observeAppLifecycle() + } + + private func setupWebPage() { + pageConfiguration.loadsSubresources = true + pageConfiguration.defaultNavigationPreferences.allowsContentJavaScript = true + + // Use default data store for persistence + pageConfiguration.websiteDataStore = .default() + + page = WebPage(configuration: pageConfiguration, navigationDecider: WebNavigationDecider(viewModel: self)) + + // Set custom user agent for iPad + if UIDevice.current.userInterfaceIdiom == .pad { + page.customUserAgent = "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1" + } + } + + private func observeNetworkChanges() { + MainActorNetworkTracker.shared.$activeConnection + .receive(on: DispatchQueue.main) + .sink { [weak self] activeConnection in + guard let self else { return } + if let activeConnection { + let activeConfiguration = activeConnection.configuration + Logger.viewController.info("OpenHABWebView openHAB URL = \(activeConfiguration.url)") + self.openHABTrackedRootUrl = activeConfiguration.url + self.activeConnectionInfo = activeConnection + Task { + await self.loadWebView(force: false) + } + } + } + .store(in: &trackerCancellables) + } + + private func observeAppLifecycle() { + NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Logger.viewController.info("App became active, checking for content updates") + Task { + await self?.loadWebView(force: false) + } + } + } + + func loadWebView(force: Bool = false, path: String? = nil) async { + Logger.viewController.info("loadWebView tracked URL: \(self.activeConfig?.url ?? "") forced \(force ? "true" : "false")") + guard let activeConfig else { return } + + let authStr = "\(activeConfig.username):\(activeConfig.password)" + let newTarget = "\(activeConfig.url):\(authStr)" + + if force { + await performLoadWebView(newTarget: newTarget, path: path, force: true) + return + } + + await loadWebViewWithETagCheck(newTarget: newTarget, path: path) + } + + private func performLoadWebView(newTarget: String, path: String?, force: Bool) async { + guard let activeConfig else { return } + currentTarget = newTarget + + guard let url = URL(string: activeConfig.url), + let modifiedUrl = await modifyUrl(orig: url, path: path) else { return } + + acceptsCommands = false + var request = URLRequest(url: modifiedUrl) + + if force { + // Clear cache for force reload + let dataStore = pageConfiguration.websiteDataStore + let websiteDataTypes: Set = [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache] + let date = Date(timeIntervalSince1970: 0) + + Logger.viewController.info("Force reload: clearing WebView cache") + await dataStore.removeData(ofTypes: websiteDataTypes, modifiedSince: date) + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + } + + Logger.viewController.info("Loading URL: \(modifiedUrl)") + isLoading = true + let _ = page.load(request) + } + + private func loadWebViewWithETagCheck(newTarget: String, path: String?) async { + guard let activeConfig, + let url = URL(string: activeConfig.url), + let fullURL = await modifyUrl(orig: url, path: path) else { + Logger.viewController.info("ETag check skipped: invalid configuration") + await performLoadWebView(newTarget: newTarget, path: path, force: false) + return + } + + let configKey = "\(activeConfig.url):\(activeConfig.username)" + if etagChecker == nil || etagCheckerConfigURL != configKey { + let httpClient = HTTPClient(baseURL: nil, connectionConfiguration: activeConfig) + etagChecker = ETagChecker(httpClient: httpClient) + etagCheckerConfigURL = configKey + Logger.viewController.debug("Created new ETagChecker for config: \(configKey)") + } + + guard let checker = etagChecker else { + await performLoadWebView(newTarget: newTarget, path: path, force: false) + return + } + + let result = await checker.checkIfChanged(url: fullURL) + + switch result { + case .unchanged: + let normalizedTarget = normalizeURLForComparison(fullURL.absoluteString, includeBasePath: false) + let normalizedLoaded = normalizeURLForComparison(lastLoadedURL, includeBasePath: false) + + Logger.viewController.debug("ETag unchanged - comparing base URLs: loaded=\(normalizedLoaded ?? "nil") vs target=\(normalizedTarget ?? "nil")") + + if let normalizedTarget, let normalizedLoaded, normalizedLoaded == normalizedTarget { + Logger.viewController.info("ETag unchanged and same base URL, skipping load") + currentTarget = newTarget + isLoading = false + } else { + Logger.viewController.info("ETag unchanged but different base URL, loading \(fullURL.absoluteString)") + await performLoadWebView(newTarget: newTarget, path: path, force: false) + } + + case .changed: + Logger.viewController.info("ETag changed, loading \(fullURL.absoluteString)") + await performLoadWebView(newTarget: newTarget, path: path, force: false) + + case let .failed(error): + Logger.viewController.info("ETag check failed: \(error.localizedDescription), loading anyway") + await performLoadWebView(newTarget: newTarget, path: path, force: false) + } + } + + private func normalizeURLForComparison(_ urlString: String?, includeBasePath: Bool = false) -> String? { + guard let urlString, let url = URL(string: urlString) else { return nil } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.fragment = nil + + if !includeBasePath { + components?.path = "" + components?.query = nil + } + + guard var normalized = components?.url?.absoluteString else { return nil } + + if normalized.hasSuffix("/") { + normalized = String(normalized.dropLast()) + } + + return normalized + } + + func modifyUrl(orig: URL?, path: String? = nil) async -> URL? { + guard let urlString = orig?.absoluteString, var url = URL(string: urlString) else { return orig } + + if let proxyURL = activeConnectionInfo?.proxyURL { + url = proxyURL + } + + if let path { + url = appendPathToURL(baseURL: url, path: path) ?? url + } else if await !Preferences.shared.currentHomePreferences.defaultMainUIPath.isEmpty { + url = appendPathToURL(baseURL: url, path: await Preferences.shared.currentHomePreferences.defaultMainUIPath) ?? url + } + + return url + } + + func appendPathToURL(baseURL: URL, path: String) -> URL? { + guard var urlComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + return nil + } + + if let questionMarkRange = path.range(of: "?") { + let pathComponent = String(path[.. WebPage.NavigationPreferences? { + guard let url = navigationAction.request.url else { return nil } + Logger.viewController.info("decidePolicyFor - url: \(url.absoluteString)") + + // Handle link activation (open in Safari) + if navigationAction.navigationType == .linkActivated { + await UIApplication.shared.open(url) + return nil // Cancel navigation in WebView + } + + // Allow navigation with JavaScript enabled + var preferences = WebPage.NavigationPreferences() + preferences.allowsContentJavaScript = true + return preferences + } + + @MainActor + func decidePolicyFor(navigationResponse: WebPage.NavigationResponse) async -> Bool { + if let response = navigationResponse.response as? HTTPURLResponse { + Logger.viewController.info("navigationResponse: \(response.statusCode)") + return response.statusCode < 400 + } + return true + } +} + +struct OpenHABWebView: View { + @State var viewModel: OpenHABWebViewModel + + var body: some View { + ZStack { + WebView(viewModel.page) + .webViewBackForwardNavigationGestures(.disabled) + .webViewMagnificationGestures(.enabled) + .webViewTextSelection(.enabled) + //.webViewContentBackground(.color(.systemBackground)) + + if viewModel.isLoading { + ProgressView() + .controlSize(.large) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.1)) + } + } + .toolbar(viewModel.hideNavigationBar ? .hidden : .visible, for: .navigationBar) + .onChange(of: viewModel.page.isLoading) { _, newValue in + handleLoadingStateChange(newValue) + } + .onAppear { + Task { + await viewModel.loadWebView(force: false) + } + } + } + + private func handleLoadingStateChange(_ isLoading: Bool) { + viewModel.isLoading = isLoading + + if isLoading { + viewModel.hideNavigationBar = false + } else { + viewModel.hideNavigationBar = true + viewModel.acceptsCommands = true + + // Track the loaded URL + if let url = viewModel.page.url { + viewModel.lastLoadedURL = url.absoluteString + viewModel.handlePathChanged(url.path) + } + } + } +} diff --git a/openHAB/RTFTextView.swift b/openHAB/SwiftUI/SitemapView/RTFTextView.swift similarity index 100% rename from openHAB/RTFTextView.swift rename to openHAB/SwiftUI/SitemapView/RTFTextView.swift diff --git a/openHAB/SwiftUI/SitemapView/RowViewFactory.swift b/openHAB/SwiftUI/SitemapView/RowViewFactory.swift new file mode 100644 index 000000000..f293c28cf --- /dev/null +++ b/openHAB/SwiftUI/SitemapView/RowViewFactory.swift @@ -0,0 +1,57 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import OpenHABCore +import SwiftUI + +enum RowViewFactory { + @MainActor @ViewBuilder + static func view(for widget: OpenHABWidget) -> some View { + switch widget.renderingKind { + case .segmentedSwitch: + SegmentedRowView(widget: widget) + case .toggleSwitch: + SwitchRowView(widget: widget) + case .rollershutterSwitch: + RollershutterRowView(widget: widget) + case .slider: // SliderRowView also handles switchSupport + SliderRowView(widget: widget) + case .dateInput: + DatePickerInputRowView(widget: widget) + case .textInput: + TextInputRowView(widget: widget) + case .text: + TextRowView(widget: widget) + case .frame: + FrameRowView(widget: widget) + case .setpoint: + SetpointRowView(widget: widget) + case .selection: + SelectionRowView(widget: widget) + case .colorPicker: + ColorPickerRowView(widget: widget) + case .image, .chart: + ImageRowView(widget: widget) + case .video: + VideoRowView(widget: widget) + case .webview: + WidgetWebViewContainer(widget: widget) + case .mapview: + MapRowView(widget: widget) + case .colorTemperaturePicker: + ColorTemperaturePickerRowView(widget: widget) + case .buttonGrid: + ButtonGridRowView(widget: widget) + case .generic: + GenericRowView(widget: widget) + } + } +} diff --git a/openHAB/SelectionView.swift b/openHAB/SwiftUI/SitemapView/SelectionView.swift similarity index 62% rename from openHAB/SelectionView.swift rename to openHAB/SwiftUI/SitemapView/SelectionView.swift index d5216d78c..213b9d7ac 100644 --- a/openHAB/SelectionView.swift +++ b/openHAB/SwiftUI/SitemapView/SelectionView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SFSafeSymbols @@ -18,35 +19,36 @@ struct SelectionView: View { let labelText: String? var mappings: [OpenHABWidgetMapping] // List of mappings (instead of AnyHashable, we use a concrete type) @State var selectionItemState: String? // To track the selected item state + var valuecolor = "" // Color for the selected value indicator var onSelection: (Int) -> Void // Closure to handle selection var onDismiss: (() -> Void)? // Closure to handle dismissal after selection var body: some View { List(0 ..< mappings.count, id: \.self) { index in let mapping = mappings[index] - HStack { - Text(mapping.label) - Spacer() - if selectionItemState == mapping.command { - Image(systemSymbol: .checkmark) - .foregroundColor(.blue) - } - } - .contentShape(.interaction, Rectangle()) // Ensures entire row is tappable - .onTapGesture { + Button { selectionItemState = mappings[index].command Logger.selectionView.info("Selected mapping \(index)") onSelection(index) onDismiss?() + } label: { + HStack { + Text(mapping.label) + Spacer() + if selectionItemState == mapping.command { + Image(systemSymbol: .checkmark) + .foregroundStyle(valuecolor.isEmpty ? .blue : Color(fromString: valuecolor)) + } + } + .contentShape(Rectangle()) } - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) + .buttonStyle(.plain) } .navigationTitle(labelText ?? "Select Mapping") // Navigation title } } -#Preview { +#Preview("Default") { SelectionView( labelText: "Test Label", mappings: [ @@ -60,3 +62,19 @@ struct SelectionView: View { print("Dismissing selection view") } } + +#Preview("With Valuecolor") { + SelectionView( + labelText: "Test Label", + mappings: [ + OpenHABWidgetMapping(command: "ON", label: "On"), + OpenHABWidgetMapping(command: "OFF", label: "Off") + ], + selectionItemState: "OFF", + valuecolor: "red" + ) { selectedMappingIndex in + print("Selected mapping at index \(selectedMappingIndex)") + } onDismiss: { + print("Dismissing selection view") + } +} diff --git a/openHAB/SwiftUI/SitemapView/SitemapNavigationView.swift b/openHAB/SwiftUI/SitemapView/SitemapNavigationView.swift new file mode 100644 index 000000000..c9ba3ac2a --- /dev/null +++ b/openHAB/SwiftUI/SitemapView/SitemapNavigationView.swift @@ -0,0 +1,170 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import SFSafeSymbols +import SwiftUI + +struct SitemapNavigationView: View { + @StateObject var viewModel = SitemapPageViewModel() + @State private var isSearchPresented = false + @FocusState private var isLegacySearchFocused: Bool + + var body: some View { + sitemapContent + } + + @ViewBuilder + private var sitemapContent: some View { + let page = SitemapPageView(viewModel: viewModel) + .navigationTitle(viewModel.pageTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if !isCommandLifecycleIdle { + ToolbarItem(placement: .navigationBarLeading) { + commandLifecycleIndicator + } + } + if viewModel.showSearchField { + ToolbarItem(placement: .navigationBarTrailing) { + //if #available(iOS 17.0, *) { + Button { + isSearchPresented = true + } label: { + Image(systemSymbol: .magnifyingglass) + } + .accessibilityLabel("Search") + /*} else { + Button { + isSearchPresented = true + isLegacySearchFocused = true + } label: { + Image(systemSymbol: .magnifyingglass) + } + .accessibilityLabel("Search") + }*/ + } + } + } + + if viewModel.showSearchField { + if #available(iOS 17.0, *) { + if isSearchPresented { + page + .searchable( + text: $viewModel.searchText, + isPresented: $isSearchPresented, + placement: .navigationBarDrawer(displayMode: .always), + prompt: Text(NSLocalizedString("search_items", comment: "")) + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } else { + page + } + } else { + page + .safeAreaInset(edge: .bottom) { + if isSearchPresented { + legacySearchBar + } + } + } + } else { + page + } + } + + private var legacySearchBar: some View { + HStack(spacing: 8) { + Image(systemSymbol: .magnifyingglass) + .foregroundStyle(.secondary) + .font(.footnote) + + TextField(NSLocalizedString("search_items", comment: ""), text: $viewModel.searchText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($isLegacySearchFocused) + .font(.footnote) + + if !viewModel.searchText.isEmpty { + Button { + viewModel.searchText = "" + } label: { + Image(systemSymbol: .xmarkCircleFill) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + + Button { + isSearchPresented = false + isLegacySearchFocused = false + } label: { + Image(systemSymbol: .xmark) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + Color(.secondarySystemBackground).opacity(0.6), + in: RoundedRectangle(cornerRadius: 10) + ) + .padding(.horizontal, 12) + .padding(.bottom, 6) + } + + @ViewBuilder + private var commandLifecycleIndicator: some View { + switch viewModel.commandLifecycleSummary { + case .idle: + EmptyView() + case .sending: + ProgressView() + .controlSize(.small) + .accessibilityLabel("Sending command") + case let .failed(count): + HStack(spacing: 4) { + Image(systemSymbol: .exclamationmarkTriangleFill) + Text("\(count)") + } + .foregroundStyle(.red) + .font(.caption) + .accessibilityLabel("Command failures: \(count)") + } + } + + private var isCommandLifecycleIdle: Bool { + if case .idle = viewModel.commandLifecycleSummary { + return true + } + return false + } + + init(viewModel: SitemapPageViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + init() { + _viewModel = StateObject(wrappedValue: SitemapPageViewModel()) + } +} + +#Preview { + let previewViewModel = SitemapPageViewModel( + pageUrl: PreviewConstants.openHABSitemapPage?.link ?? "", + title: PreviewConstants.openHABSitemapPage?.title ?? "Preview Page" + ) + SitemapNavigationView(viewModel: previewViewModel) +} diff --git a/openHAB/SwiftUI/SitemapView/SitemapPageView.swift b/openHAB/SwiftUI/SitemapView/SitemapPageView.swift new file mode 100644 index 000000000..5f7fb753e --- /dev/null +++ b/openHAB/SwiftUI/SitemapView/SitemapPageView.swift @@ -0,0 +1,111 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import SwiftUI + +struct SitemapPageView: View { + @StateObject var viewModel = SitemapPageViewModel() + @State private var idleTimerDisabled = false + + private var isLinkedPage: Bool { + viewModel.isLinked + } + + var body: some View { + Group { + if viewModel.isLoading, viewModel.relevantWidgets.isEmpty { + // Show skeleton/placeholder rows while loading + List { + ForEach(Self.placeholderWidgets, id: \.id) { widget in + EmbeddingRowView(widget: widget) + .redacted(reason: .placeholder) + .disabled(true) + } + } + } else { + List(viewModel.relevantWidgets) { widget in + EmbeddingRowView(widget: widget) + } + } + } + .environmentObject(viewModel) + .listStyle(.plain) + .listRowSpacing(0) + .environment(\.defaultMinListRowHeight, 32) + .refreshable { + await viewModel.reload() + } + .task { + viewModel.startPageHandling() + } + .onAppear { + Task { + // Disable idle timer if configured in settings + if await Preferences.shared.applicationPreferences.idleOff { + UIApplication.shared.isIdleTimerDisabled = true + idleTimerDisabled = true + } + } + } + .onDisappear { + viewModel.stopPageHandling() + // Re-enable idle timer when leaving the view + if idleTimerDisabled { + UIApplication.shared.isIdleTimerDisabled = false + } + } + .navigationTitle(viewModel.pageTitle) + .navigationBarTitleDisplayMode(.large) + .alert("Error", isPresented: Binding( + get: { viewModel.error != nil }, + set: { if !$0 { viewModel.error = nil } } + ), actions: { + Button("OK", role: .cancel) {} + }, message: { + if let error = viewModel.error { + Text(error.localizedDescription) + } + }) + } + + init(viewModel: SitemapPageViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } +} + +extension SitemapPageView { + /// Lightweight placeholder widgets for skeleton loading state — no dependency on PreviewConstants + static let placeholderWidgets: [OpenHABWidget] = (0 ..< 6).map { i in + OpenHABWidget( + widgetId: "placeholder_\(i)", + label: "Placeholder [100]", + icon: "none", + type: .text, + url: nil, period: nil, minValue: nil, maxValue: nil, step: nil, + refresh: nil, height: nil, isLeaf: nil, iconColor: nil, + labelColor: nil, valueColor: nil, service: nil, state: nil, + text: nil, legend: nil, inputHint: nil, encoding: nil, + item: nil, linkedPage: nil, mappings: [], widgets: [], + visibility: true, switchSupport: nil, forceAsItem: nil, + labelSource: .unknown, releaseOnly: nil + ) + } +} + +#Preview { + let previewViewModel = SitemapPageViewModel( + title: "Preview Page", + widgets: SitemapPageView.placeholderWidgets + ) + SitemapPageView(viewModel: previewViewModel) +} diff --git a/openHAB/SwiftUI/SplashView.swift b/openHAB/SwiftUI/SplashView.swift new file mode 100644 index 000000000..017f03d8a --- /dev/null +++ b/openHAB/SwiftUI/SplashView.swift @@ -0,0 +1,26 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import SwiftUI + +/// Lightweight splash screen shown while preferences migration runs, +/// matching the launch screen appearance. +struct SplashView: View { + var body: some View { + Image("launchImage") + .resizable() + .aspectRatio(contentMode: .fit) + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.white) + .ignoresSafeArea() + } +} diff --git a/openHAB/Throttler.swift b/openHAB/SwiftUI/Throttler.swift similarity index 100% rename from openHAB/Throttler.swift rename to openHAB/SwiftUI/Throttler.swift diff --git a/openHAB/VideoStreamManager.swift b/openHAB/SwiftUI/VideoStreamManager.swift similarity index 100% rename from openHAB/VideoStreamManager.swift rename to openHAB/SwiftUI/VideoStreamManager.swift diff --git a/openHAB/SwitchUITableViewCell.swift b/openHAB/SwitchUITableViewCell.swift deleted file mode 100644 index 891af5283..000000000 --- a/openHAB/SwitchUITableViewCell.swift +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import os.log -import UIKit - -class SwitchUITableViewCell: GenericUITableViewCell { - @IBOutlet private var widgetSwitch: UISwitch! - - required init?(coder: NSCoder) { - super.init(coder: coder) - initialize() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - initialize() - } - - override func initialize() { - selectionStyle = .none - separatorInset = .zero - } - - override func displayWidget() { - customTextLabel?.text = widget.labelText - var state = widget.state - // if state is nil or empty using the item state ( OH 1.x compatability ) - if state.isEmpty { - state = (widget.item?.state) ?? "" - } - customDetailTextLabel?.text = widget.labelValue ?? "" - widgetSwitch?.isOn = state.parseAsBool() - widgetSwitch?.addTarget(self, action: .switchChange, for: .valueChanged) - super.displayWidget() - } - - @objc - func switchChange() { - if (widgetSwitch?.isOn)! { - Logger.widgets.info("Switch to ON") - widget.sendCommand("ON") - } else { - Logger.widgets.info("Switch to OFF") - widget.sendCommand("OFF") - } - } -} - -private extension Selector { - static let switchChange = #selector(SwitchUITableViewCell.switchChange) -} diff --git a/openHAB/TextInputUITableViewCell.swift b/openHAB/TextInputUITableViewCell.swift deleted file mode 100644 index 07e7e8a35..000000000 --- a/openHAB/TextInputUITableViewCell.swift +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import UIKit - -class TextInputUITableViewCell: GenericUITableViewCell { - override var widget: OpenHABWidget! { - get { - super.widget - } - set(widget) { - super.widget = widget - accessoryType = .disclosureIndicator - selectionStyle = .blue - } - } -} diff --git a/openHAB/UICircleButton.swift b/openHAB/UICircleButton.swift deleted file mode 100644 index 61ab35346..000000000 --- a/openHAB/UICircleButton.swift +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import os.log -import UIKit - -class UICircleButton: UIButton { - required init?(coder: NSCoder) { - super.init(coder: coder) - - layer.borderWidth = 2 - layer.borderColor = UIColor(white: 0, alpha: 0.05).cgColor - - layer.cornerRadius = bounds.size.width / 2.0 - } -} diff --git a/openHAB/UIKIT_MIGRATION_ANALYSIS.md b/openHAB/UIKIT_MIGRATION_ANALYSIS.md new file mode 100644 index 000000000..07c69a39e --- /dev/null +++ b/openHAB/UIKIT_MIGRATION_ANALYSIS.md @@ -0,0 +1,277 @@ +# UIKit to SwiftUI Migration Analysis + +## Current State + +The openHAB iOS app has already undergone significant SwiftUI migration: + +### ✅ Already Migrated to SwiftUI +1. **Root View** - `OpenHABTabRootView` is now the app's root view (set in `AppDelegate`) +2. **Web Views** - Migrated from `OpenHABWebViewController` to `OpenHABWebView` (SwiftUI) +3. **Tab Navigation** - Using SwiftUI `TabView` with `.sidebarAdaptable` style + +### 📋 Remaining UIKit Components + +#### 1. `OpenHABViewController` (OpenHABViewController.swift) +**Status**: Can be removed with refactoring + +**Current Purpose**: +- Base class providing common functionality +- Certificate management delegation +- Popup message display (SwiftMessages) +- Idle timer management +- Protocol: `OpenHABViewable` + +**Dependencies**: None (no longer has subclasses after WebView migration) + +**Migration Path**: +- ✅ Certificate management → Move to SwiftUI environment or dedicated service +- ✅ Popup messages → Replace with SwiftUI alerts/toasts +- ✅ Idle timer → Move to app-level service +- ✅ Protocol methods → Can be replaced with SwiftUI patterns + +#### 2. `OpenHABNavigationController` (OpenHABNavigationController.swift) +**Status**: Can be removed + +**Current Purpose**: +- Wrapper around `UINavigationController` +- Controls status bar visibility based on preferences + +**Why It Can Be Removed**: +- The app now uses SwiftUI's `NavigationStack` and `TabView` +- No longer instantiated in `AppDelegate` (replaced with `UIHostingController`) +- Status bar control can be done via SwiftUI modifiers + +**Migration Path**: +- Use SwiftUI's `.statusBar(hidden:)` modifier +- Observe preferences and apply modifier at root level + +## Recommended Migration Plan + +### Phase 1: Extract Shared Services (Immediate) + +Create SwiftUI-compatible services to replace `OpenHABViewController` functionality: + +#### 1. **CertificateManagementService** +```swift +@MainActor +@Observable +class CertificateManagementService { + static let shared = CertificateManagementService() + + var serverCertificateAlert: CertificateAlert? + var clientCertificateAlert: CertificateAlert? + + init() { + CertificateManagers.clientCertificateManager.delegate = self + CertificateManagers.serverCertificateManager.delegate = self + } +} + +// Use with SwiftUI alerts +.alert(item: $certificateService.serverCertificateAlert) { alert in + // Display alert +} +``` + +#### 2. **PopupMessageService** +```swift +@MainActor +@Observable +class PopupMessageService { + static let shared = PopupMessageService() + + var currentMessage: PopupMessage? + + func show(title: String, message: String, duration: TimeInterval) { + currentMessage = PopupMessage(title: title, message: message, duration: duration) + } +} + +// Use with SwiftUI overlays +.overlay { + if let message = messageService.currentMessage { + PopupMessageView(message: message) + } +} +``` + +#### 3. **IdleTimerService** +```swift +@MainActor +class IdleTimerService { + static let shared = IdleTimerService() + + func configure(idleOff: Bool) { + UIApplication.shared.isIdleTimerDisabled = idleOff + } +} +``` + +### Phase 2: Remove UIKit Components + +#### Step 1: Remove `OpenHABNavigationController` +- Already unused in the app +- Safe to delete immediately + +**Files to delete:** +- `OpenHABNavigationController.swift` + +**Add to root SwiftUI view:** +```swift +OpenHABTabRootView() + .statusBar(hidden: Preferences.shared.applicationPreferences.hideStatusBar) +``` + +#### Step 2: Migrate Certificate Management +- Move delegate implementations to new `CertificateManagementService` +- Use SwiftUI `.alert()` modifiers instead of UIAlertController +- Add to `OpenHABTabRootView` environment + +#### Step 3: Replace Popup Messages +- Option A: Create SwiftUI toast/banner view +- Option B: Keep SwiftMessages but wrap in a SwiftUI-friendly service +- Option C: Use native SwiftUI alerts/sheets + +#### Step 4: Remove `OpenHABViewController` +- After migrating all functionality to services +- Remove `OpenHABViewable` protocol + +**Files to delete:** +- `OpenHABViewController.swift` + +### Phase 3: Status Bar Handling + +Replace `OpenHABNavigationController` status bar logic with SwiftUI: + +```swift +// In OpenHABTabRootView or App root +@StateObject private var preferences = PreferencesObserver.shared + +var body: some View { + TabView { + // ... tabs + } + .statusBar(hidden: preferences.applicationPreferences.hideStatusBar) + .statusBarAnimation(.fade) +} +``` + +## Implementation Example + +Here's how the certificate management could look in pure SwiftUI: + +```swift +// CertificateManagementService.swift +@MainActor +@Observable +class CertificateManagementService { + static let shared = CertificateManagementService() + + struct CertificateAlert: Identifiable { + let id = UUID() + let title: String + let message: String + let completion: (EvaluateResult) -> Void + } + + var currentAlert: CertificateAlert? + + private init() { + CertificateManagers.serverCertificateManager.delegate = self + CertificateManagers.clientCertificateManager.delegate = self + } +} + +extension CertificateManagementService: ServerCertificateManagerDelegate { + func evaluateServerTrust(summary certificateSummary: String?, forDomain domain: String?) async -> ServerCertificateManager.EvaluateResult { + await withCheckedContinuation { continuation in + let title = NSLocalizedString("ssl_certificate_warning", comment: "") + let message = String(format: NSLocalizedString("ssl_certificate_invalid", comment: ""), + certificateSummary ?? "", domain ?? "") + + currentAlert = CertificateAlert(title: title, message: message) { result in + continuation.resume(returning: result) + } + } + } + + // ... other delegate methods +} + +// In OpenHABTabRootView: +.alert(item: $certificateService.currentAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + primaryButton: .default(Text("Always")) { + alert.completion(.permitAlways) + }, + secondaryButton: .cancel(Text("Deny")) { + alert.completion(.deny) + } + ) +} +``` + +## Benefits of Complete Migration + +1. **Simplified Codebase** + - Remove ~200 lines of UIKit boilerplate + - No more UIKit bridging + - Unified SwiftUI architecture + +2. **Better Maintainability** + - Single UI paradigm throughout app + - Easier to understand and modify + - Modern Swift patterns + +3. **Future-Proof** + - Built on Apple's latest frameworks + - Better platform integration + - Easier to adopt new iOS features + +4. **Performance** + - Native SwiftUI rendering + - Reduced bridging overhead + - Better memory management + +## Testing Checklist + +After migration, test: + +- ✅ Server certificate warnings display correctly +- ✅ Client certificate import flow works +- ✅ Status bar shows/hides based on preferences +- ✅ Idle timer behavior is correct +- ✅ Popup messages display properly +- ✅ App lifecycle events (background/foreground) work +- ✅ Watch connectivity still functions + +## Files to Delete + +Once migration is complete: + +1. `OpenHABViewController.swift` +2. `OpenHABNavigationController.swift` +3. `OpenHABWebViewController.swift` (already removed) + +## Estimated Effort + +- **Phase 1 (Services)**: 2-3 hours +- **Phase 2 (Remove UIKit)**: 1-2 hours +- **Phase 3 (Status Bar)**: 30 minutes +- **Testing**: 2-3 hours + +**Total**: ~6-9 hours + +## Conclusion + +**Yes, we can remove both `OpenHABViewController` and `OpenHABNavigationController`!** + +The migration is straightforward because: +1. No active subclasses of `OpenHABViewController` remain +2. `OpenHABNavigationController` is already unused +3. All functionality can be migrated to SwiftUI-native patterns +4. The app is already primarily SwiftUI-based + +The main work involves creating service classes to handle certificate management, popup messages, and idle timer functionality in a SwiftUI-compatible way. diff --git a/openHAB/UIKIT_REMOVAL_GUIDE.md b/openHAB/UIKIT_REMOVAL_GUIDE.md new file mode 100644 index 000000000..f23080692 --- /dev/null +++ b/openHAB/UIKIT_REMOVAL_GUIDE.md @@ -0,0 +1,230 @@ +# Complete UIKit Removal - Implementation Guide + +## Overview + +This guide shows how to complete the migration from UIKit to pure SwiftUI by removing `OpenHABViewController` and `OpenHABNavigationController`. + +## What's Been Created + +### 1. **CertificateManagementService.swift** ✅ +A complete SwiftUI-native service that handles all certificate management: +- Server certificate trust evaluation +- Client certificate import +- Password prompts for PKCS#12 +- Error alerts +- SwiftUI alert modifiers + +### 2. **IdleTimerService.swift** ✅ +A service that manages the device idle timer: +- Observes user preferences +- Handles app lifecycle events +- Provides SwiftUI view modifier + +## Implementation Steps + +### Step 1: Update OpenHABTabRootView + +Add the certificate management alerts and idle timer management to your root view: + +```swift +// In OpenHABTabRootView.swift + +var body: some View { + TabView(selection: tabSelectionBinding) { + // ... existing tab content + } + .tabViewStyle(.sidebarAdaptable) + .tabBarMinimizeBehavior(.onScrollDown) + .environmentObject(networkTracker) + // ADD THESE MODIFIERS: + .certificateManagementAlerts() // Handles all certificate alerts + .idleTimerManagement() // Manages idle timer + .statusBar(hidden: Preferences.shared.applicationPreferences.hideStatusBar) // Replace navigation controller status bar logic + // ... rest of your modifiers +} +``` + +### Step 2: Remove Old Files + +Once Step 1 is complete and tested, safely delete: + +1. **OpenHABViewController.swift** + - All functionality now in services + - No subclasses remain after WebView migration + +2. **OpenHABNavigationController.swift** + - Already unused in AppDelegate + - Status bar control moved to SwiftUI modifier + +3. **OpenHABWebViewController.swift** + - Already removed in WebView migration + +### Step 3: Update Any Remaining References + +Search your project for any lingering references: + +```bash +# Search for OpenHABViewController references +grep -r "OpenHABViewController" . + +# Search for OpenHABNavigationController references +grep -r "OpenHABNavigationController" . + +# Search for OpenHABViewable protocol +grep -r "OpenHABViewable" . +``` + +Remove or update any found references. + +## Testing Checklist + +After implementation, verify: + +### Certificate Management +- [ ] Server certificate warnings appear correctly +- [ ] Can choose "Always", "Once", or "Deny" for server certificates +- [ ] Certificate mismatch warnings display properly +- [ ] Client certificate import prompt works +- [ ] Password prompt for PKCS#12 certificates works +- [ ] Certificate import errors display correctly + +### Idle Timer +- [ ] Screen stays awake when preference is enabled +- [ ] Screen sleeps normally when preference is disabled +- [ ] Idle timer disables when app enters background +- [ ] Idle timer re-enables based on preference when app becomes active + +### Status Bar +- [ ] Status bar hides when preference is enabled +- [ ] Status bar shows when preference is disabled +- [ ] Status bar animates smoothly + +### General +- [ ] App launches successfully +- [ ] All tabs work correctly +- [ ] Navigation works as expected +- [ ] No crashes or memory leaks + +## Code Changes Summary + +### Before (UIKit-based): +```swift +// OpenHABViewController.swift - ~200 lines +class OpenHABViewController: UIViewController, OpenHABViewable { + func reloadView() {} + func showPopupMessage(...) {} + func evaluateServerTrust(...) async -> Result {} + func askForClientCertificateImport(...) async -> Bool {} + // etc... +} + +// OpenHABNavigationController.swift - ~40 lines +class OpenHABNavigationController: UINavigationController { + override var prefersStatusBarHidden: Bool { ... } +} +``` + +### After (SwiftUI-based): +```swift +// CertificateManagementService.swift +@Observable class CertificateManagementService { + var serverCertificateAlert: ServerCertificateAlert? + // Handles all certificate interactions via SwiftUI alerts +} + +// IdleTimerService.swift +class IdleTimerService { + func configure(idleOff: Bool) + // Manages idle timer lifecycle +} + +// In OpenHABTabRootView +.certificateManagementAlerts() +.idleTimerManagement() +.statusBar(hidden: ...) +``` + +## Benefits + +1. **Simpler Architecture** + - No UIViewController inheritance hierarchy + - Direct SwiftUI patterns + - Clear separation of concerns + +2. **Less Code** + - ~240 lines of UIKit code removed + - ~200 lines of SwiftUI service code added + - Net reduction in complexity + +3. **Better Testability** + - Services are isolated and testable + - No UIViewController dependencies + - Observable state for easy mocking + +4. **Modern Swift** + - Swift Concurrency throughout + - @Observable macro + - SwiftUI-native patterns + +5. **Future-Proof** + - Pure SwiftUI app + - Easier to adopt new features + - Better performance + +## Troubleshooting + +### Issue: Certificate alerts not showing +**Solution**: Ensure `.certificateManagementAlerts()` is added to your root view + +### Issue: Idle timer not working +**Solution**: Verify `.idleTimerManagement()` is in your view hierarchy and preferences are set correctly + +### Issue: Status bar not hiding +**Solution**: Check that `.statusBar(hidden:)` modifier is present and preference value is correct + +### Issue: Crash on certificate operation +**Solution**: Verify `CertificateManagementService.shared` is initialized early (it auto-initializes on first access) + +## Migration Rollback Plan + +If issues arise, you can temporarily revert: + +1. Keep the old UIKit files in the project +2. Comment out the new service modifiers +3. Re-enable old code paths +4. Debug and fix issues +5. Re-apply migration + +However, with the provided services and testing checklist, this shouldn't be necessary. + +## Additional Notes + +### SwiftMessages Integration +The old `showPopupMessage` method used SwiftMessages for temporary notifications. If you still need this functionality: + +**Option 1**: Create a SwiftUI toast view +**Option 2**: Use native SwiftUI alerts/sheets +**Option 3**: Keep SwiftMessages and wrap it in a service (similar to certificate service) + +### Navigation Bar Hiding +The WebView handles its own navigation bar hiding via the `hideNavigationBar` property. This is managed in `OpenHABWebView` and doesn't conflict with the root status bar setting. + +## Next Steps + +1. Implement Step 1 (add modifiers to OpenHABTabRootView) +2. Test thoroughly using the checklist +3. Execute Step 2 (delete old files) +4. Search for and clean up any remaining references (Step 3) +5. Celebrate having a pure SwiftUI app! 🎉 + +## Questions? + +If you encounter any issues during migration: +1. Check the testing checklist +2. Review the troubleshooting section +3. Verify all code changes were applied correctly +4. Check console logs for any errors + +--- + +**Estimated Time**: 1-2 hours for implementation + 2-3 hours for testing = 3-5 hours total diff --git a/openHAB/UITableViewCellExtension.swift b/openHAB/UIKit/UITableViewCellExtension.swift similarity index 88% rename from openHAB/UITableViewCellExtension.swift rename to openHAB/UIKit/UITableViewCellExtension.swift index 808276af5..a9575899c 100644 --- a/openHAB/UITableViewCellExtension.swift +++ b/openHAB/UIKit/UITableViewCellExtension.swift @@ -9,6 +9,11 @@ // // SPDX-License-Identifier: EPL-2.0 +// DEPRECATED: This file is part of the legacy UIKit OpenHABSitemapViewController +// implementation that has been replaced by SwiftUI views (SitemapNavigationView, SitemapPageView). +// This entire file can be safely deleted. +// See DELETION_CHECKLIST.md for complete list of files to remove. + import Foundation import Kingfisher import OpenHABCore diff --git a/openHAB/UITableView.swift b/openHAB/UITableView.swift deleted file mode 100644 index 01e10ff26..000000000 --- a/openHAB/UITableView.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import UIKit - -extension UITableView { - final func dequeueReusableCell(for indexPath: IndexPath, cellType: T.Type = T.self) -> T { - guard let cell = dequeueReusableCell(withIdentifier: cellType.reuseIdentifier, for: indexPath) as? T else { - fatalError("Unable to Dequeue Reusable Table View Cell") - } - return cell - } - - final func register(cellType: (some UITableViewCell).Type) { - register(cellType.self, forCellReuseIdentifier: cellType.reuseIdentifier) - } -} diff --git a/openHAB/UIViewController+Localization.swift b/openHAB/UIViewController+Localization.swift deleted file mode 100644 index 4dde36526..000000000 --- a/openHAB/UIViewController+Localization.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Combine -import Foundation -import UIKit - -extension UIViewController { - @IBInspectable var localizationKey: String { - get { - "" - } - set { - title = NSLocalizedString(newValue, comment: "").uppercased() - } - } -} diff --git a/openHAB/VideoUITableViewCell.swift b/openHAB/VideoUITableViewCell.swift deleted file mode 100644 index 1d85ac93c..000000000 --- a/openHAB/VideoUITableViewCell.swift +++ /dev/null @@ -1,289 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import AVFoundation -import AVKit -import OpenHABCore -import os.log - -enum VideoEncoding: String { - case hls, mjpeg -} - -class VideoUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { - private var activityIndicator: UIActivityIndicatorView = if #available(iOS 13.0, *) { - .init(style: .medium) - } else { - .init(style: .gray) - } - - var didLoad: (() -> Void)? - - private var url: URL? { - didSet { - guard oldValue?.absoluteString != url?.absoluteString else { return } - prepareToPlay() - } - } - - private var playerView: PlayerView! - private var mainImageView: UIImageView! - private var playerObserver: NSKeyValueObservation? - private var aspectRatioConstraint: NSLayoutConstraint? - private var mjpegPlayer: SimpleMJPEGPlayer? - private var currentAspectRatio: CGFloat? - private var currentStreamUrl: URL? - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - activityIndicator.hidesWhenStopped = true - playerView = PlayerView() - playerView.isHidden = true // Start hidden, will be shown when needed - contentView.addSubview(playerView) - mainImageView = UIImageView() - mainImageView.contentMode = .scaleAspectFit - mainImageView.isHidden = true // Start hidden, will be shown when needed - contentView.addSubview(mainImageView) - contentView.addSubview(activityIndicator) - - activityIndicator.translatesAutoresizingMaskIntoConstraints = false // enable autolayout - playerView.translatesAutoresizingMaskIntoConstraints = false // enable autolayout - playerView.contentMode = .scaleAspectFit - - let marginGuide = contentView // contentView.layoutMarginsGuide if more margin would be appreciated - NSLayoutConstraint.activate([ - playerView.leftAnchor.constraint(equalTo: marginGuide.leftAnchor), - playerView.rightAnchor.constraint(equalTo: marginGuide.rightAnchor), - playerView.topAnchor.constraint(equalTo: marginGuide.topAnchor), - playerView.bottomAnchor.constraint(equalTo: marginGuide.bottomAnchor) - ]) - - mainImageView.translatesAutoresizingMaskIntoConstraints = false // enable autolayout - NSLayoutConstraint.activate([ - mainImageView.leftAnchor.constraint(equalTo: marginGuide.leftAnchor), - mainImageView.rightAnchor.constraint(equalTo: marginGuide.rightAnchor), - mainImageView.topAnchor.constraint(equalTo: marginGuide.topAnchor), - mainImageView.bottomAnchor.constraint(equalTo: marginGuide.bottomAnchor) - ]) - - let bottomSpacingConstraint = activityIndicator.bottomAnchor.constraint(greaterThanOrEqualTo: marginGuide.bottomAnchor, constant: 15) - bottomSpacingConstraint.priority = UILayoutPriority.defaultHigh - NSLayoutConstraint.activate([ - activityIndicator.centerXAnchor.constraint(equalTo: marginGuide.centerXAnchor), - activityIndicator.centerYAnchor.constraint(equalTo: marginGuide.centerYAnchor), - activityIndicator.topAnchor.constraint(greaterThanOrEqualTo: marginGuide.topAnchor, constant: 15), - bottomSpacingConstraint - ]) - - NotificationCenter.default.addObserver(self, selector: #selector(stopPlayback), name: UIApplication.didEnterBackgroundNotification, object: nil) - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func willMove(toSuperview newSuperview: UIView?) { - super.willMove(toSuperview: newSuperview) - - if newSuperview == nil { - stopPlayback() - // Release stream reference when cell is removed - if let currentStreamUrl { - VideoStreamManager.shared.releaseStream(for: currentStreamUrl) - self.currentStreamUrl = nil - } - } - } - - override func prepareForReuse() { - super.prepareForReuse() - - // Clean up any previous state - stopPlayback() - if let currentStreamUrl { - VideoStreamManager.shared.releaseStream(for: currentStreamUrl) - self.currentStreamUrl = nil - } - mjpegPlayer = nil - currentAspectRatio = nil - - // Reset view states - mainImageView.image = nil - mainImageView.isHidden = true - playerView.isHidden = true - activityIndicator.stopAnimating() - activityIndicator.isHidden = true - - // Remove aspect ratio constraint - if let aspectRatioConstraint { - aspectRatioConstraint.isActive = false - self.aspectRatioConstraint = nil - } - } - - override func displayWidget() { - let newUrl = URL(string: widget.url) - - // Handle MJPEG streams with VideoStreamManager - if widget.encoding.lowercased() == VideoEncoding.mjpeg.rawValue { - // Stop any HLS playback and hide player view - playerView?.playerLayer.player = nil - playerView.isHidden = true - mainImageView.isHidden = false - - // Only process if URL has changed, similar to HLS handling - if currentStreamUrl?.absoluteString != newUrl?.absoluteString { - // Release previous stream if URL changed - if let currentStreamUrl { - VideoStreamManager.shared.releaseStream(for: currentStreamUrl) - } - - if let newUrl { - currentStreamUrl = newUrl - mjpegPlayer = VideoStreamManager.shared.getOrCreateStream( - for: newUrl, - imageView: mainImageView, - onFirstFrame: { [weak self] aspectRatio in - guard let self else { return } - activityIndicator.isHidden = true - if currentAspectRatio != aspectRatio { - updateAspectRatio(forView: mainImageView, aspectRatio: aspectRatio) - currentAspectRatio = aspectRatio - didLoad?() - } - }, - onError: { [weak self] error in - guard let self else { return } - Logger.widgets.error("Failed to start MJPEG stream: \(error.localizedDescription)") - activityIndicator.isHidden = true - activityIndicator.stopAnimating() - } - ) - - // Set initial aspect ratio for MJPEG - updateAspectRatio(forView: mainImageView, aspectRatio: 16.0 / 9.0) - - // Start activity indicator - bringSubviewToFront(activityIndicator) - activityIndicator.isHidden = false - activityIndicator.startAnimating() - bringSubviewToFront(mainImageView) - } else { - currentStreamUrl = nil - } - } - } else { - // Handle HLS and other video formats - // Clear any MJPEG stream and hide image view - if let currentStreamUrl { - VideoStreamManager.shared.releaseStream(for: currentStreamUrl) - self.currentStreamUrl = nil - } - mjpegPlayer = nil - mainImageView.image = nil - mainImageView.isHidden = true - playerView.isHidden = false - - if url?.absoluteString != newUrl?.absoluteString { - url = newUrl - } - let targetView = playerView! - updateAspectRatio(forView: targetView, aspectRatio: 16.0 / 9.0) - } - } - - func play() { - switch widget.encoding.lowercased() { - case VideoEncoding.mjpeg.rawValue: - // MJPEG streams are already managed by VideoStreamManager in displayWidget() - break - default: - playerView.player?.play() - } - } - - private func prepareToPlay() { - bringSubviewToFront(activityIndicator) - activityIndicator.isHidden = false - activityIndicator.startAnimating() - stopPlayback(andResetUrl: false) - - guard let url else { - stopPlayback() - return - } - - if widget.encoding.lowercased() != VideoEncoding.mjpeg.rawValue { - Logger.videoProcessing.info("Loading HLS video from: \(url.absoluteString)") - bringSubviewToFront(playerView) - let playerItem = AVPlayerItem(asset: AVAsset(url: url)) - playerObserver = playerItem.observe(\.status, options: [.new, .old]) { [weak self] playerItem, _ in - guard let self else { return } - - switch playerItem.status { - case .failed: - Logger.widgets.debug("Failed to load video with URL: \(url.absoluteString)") - Task { @MainActor in - self.url = nil - } - case .readyToPlay: - Logger.widgets.debug("Loaded video with URL: \(url.absoluteString)") - default: return - } - Task { @MainActor in - self.activityIndicator.isHidden = true - if playerItem.status == .readyToPlay, playerItem.presentationSize != .zero { - let aspectRatio = playerItem.presentationSize.width / playerItem.presentationSize.height - self.updateAspectRatio(forView: self.playerView, aspectRatio: aspectRatio) - self.didLoad?() - } - } - } - playerView?.playerLayer.player = AVPlayer(playerItem: playerItem) - } - } - - // Add or update the aspect ratio constraint for the given view - private func updateAspectRatio(forView view: UIView, aspectRatio: CGFloat) { - // Remove the old aspect ratio constraint if it exists - if let oldConstraint = aspectRatioConstraint { - oldConstraint.isActive = false - aspectRatioConstraint = nil - } - - // Force layout to process constraint removal before adding new one - view.layoutIfNeeded() - - // Add a new aspect ratio constraint - let constraint = view.widthAnchor.constraint(equalTo: view.heightAnchor, multiplier: aspectRatio) - constraint.priority = UILayoutPriority(rawValue: 998) // Lower than UIImageView's 999 - constraint.isActive = true - aspectRatioConstraint = constraint - } - - @objc - private func stopPlayback(andResetUrl reset: Bool = true) { - // For MJPEG streams, don't stop the shared stream - just clear our reference - if widget?.encoding.lowercased() == VideoEncoding.mjpeg.rawValue { - mjpegPlayer = nil - } else { - // For HLS and other formats, stop as usual - if reset { - url = nil - } - playerObserver = nil - playerView?.playerLayer.player = nil - } - currentAspectRatio = nil - } -} diff --git a/openHAB/WatchMessageService.swift b/openHAB/WatchMessageService.swift index 0bd488e23..0684f3272 100644 --- a/openHAB/WatchMessageService.swift +++ b/openHAB/WatchMessageService.swift @@ -9,7 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 -import Combine +import AsyncAlgorithms import Foundation import OpenHABCore import os.log @@ -24,7 +24,7 @@ class WatchMessageService: NSObject, WCSessionDelegate { private var cachedWatchPreferences: [String: Data] = [:] private let lock = NSLock() - private var preferencesSubscription: AnyCancellable? + private var preferencesTask: Task? // This method gets called when the watch requests the data // ⚠️ This is called off the main thread. Do NOT touch @MainActor stuff. @@ -68,14 +68,29 @@ class WatchMessageService: NSObject, WCSessionDelegate { // MARK: - Sync Preferences @MainActor - func subscribeToPreferences() async { - preferencesSubscription = Preferences.shared.$currentHomePreferences - .debounce(for: .seconds(1), scheduler: RunLoop.main) - .sink { _ in } receiveValue: { homeSettings in - Task { @MainActor in - await self.syncPreferencesToWatch(homeSettings) - } + func subscribeToPreferences() { + // Cancel any existing subscription + preferencesTask?.cancel() + + preferencesTask = Task { @MainActor in + // Get the AsyncChannel for currentHomePreferences from the actor + let preferencesChannel = await Preferences.shared.currentHomePreferencesChannel + + // Sync initial value immediately + let initialValue = await Preferences.shared.currentHomePreferences + await syncPreferencesToWatch(initialValue) + + // Listen for changes from the AsyncChannel with debouncing + for await homeSettings in preferencesChannel.debounce(for: .seconds(1)) { + await syncPreferencesToWatch(homeSettings) } + } + } + + @MainActor + func stopSubscription() { + preferencesTask?.cancel() + preferencesTask = nil } @MainActor @@ -84,7 +99,7 @@ class WatchMessageService: NSObject, WCSessionDelegate { Logger.preferences.warning("WCSession not activated; skipping sync.") return } - let settings = homeSettings ?? Preferences.shared.currentHomePreferences + let settings = if let homeSettings { homeSettings } else { await Preferences.shared.currentHomePreferences } let prefs = WatchPreferences(fromPreferences: settings) let context = prefs.encodedWatchPreferences() diff --git a/openHAB/WebUITableViewCell.swift b/openHAB/WebUITableViewCell.swift deleted file mode 100644 index 8db4a8b44..000000000 --- a/openHAB/WebUITableViewCell.swift +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import os.log -import WebKit - -class WebUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { - private var url: URL? - - private var widgetWebView: WKWebView! - - required init?(coder: NSCoder) { - super.init(coder: coder) - - selectionStyle = .none - separatorInset = .zero - - let configuration = WKWebViewConfiguration() - configuration.allowsInlineMediaPlayback = true - configuration.mediaTypesRequiringUserActionForPlayback = [] - widgetWebView = WKWebView(frame: contentView.frame, configuration: configuration) - contentView.addSubview(widgetWebView) - - widgetWebView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - widgetWebView.leftAnchor.constraint(equalTo: contentView.leftAnchor), - widgetWebView.rightAnchor.constraint(equalTo: contentView.rightAnchor), - widgetWebView.topAnchor.constraint(equalTo: contentView.topAnchor), - widgetWebView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) - ]) - } - - override func awakeFromNib() { - super.awakeFromNib() - MainActor.assumeIsolated { // See explanation https://www.massicotte.org/awakefromnib - widgetWebView.navigationDelegate = self - widgetWebView.uiDelegate = self - } - } - - override func displayWidget() { - // swiftformat:disable redundantSelf - Logger.widgets.info("webview loading url \(self.widget.url)") - // swiftformat:enable redundantSelf - let urlString = widget.url.lowercased().hasPrefix("http://") || widget.url.lowercased().hasPrefix("https://") ? widget.url : Preferences.shared.currentHomePreferences.localConnectionConfig.url + widget.url - os_log("webview final URL: %{PUBLIC}@", log: .default, type: .info, urlString) - guard url?.absoluteString != urlString else { - Logger.widgets.info("webview URL has not changed, abort loading") - return - } - - if let url = URL(string: urlString) { - self.url = url - let request = URLRequest(url: url) - widgetWebView?.scrollView.isScrollEnabled = false - widgetWebView?.scrollView.bounces = false - widgetWebView?.load(request) - } - } - - func setFrame(_ frame: CGRect) { - Logger.widgets.info("setFrame") - super.frame = frame - widgetWebView?.reload() - } -} - -extension WebUITableViewCell: GenericCellCacheProtocol { - func invalidateCache() { - url = nil - widgetWebView?.stopLoading() - } -} - -extension WebUITableViewCell: WKNavigationDelegate { - func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { - // swiftformat:disable:next redundantSelf - Logger.widgets.info("webview started loading with URL: \(self.widget.url)") - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - // swiftformat:disable:next redundantSelf - Logger.widgets.info("webview finished load with URL: \(self.widget.url)") - } - - func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy { - if let response = navigationResponse.response as? HTTPURLResponse, response.statusCode >= 400 { - Logger.widgets.debug("webview failed with status code: \(response.statusCode)") - url = nil - } - return .allow - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { - Logger.widgets.debug("webview failed with error: \(error.localizedDescription)") - url = nil - } - - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { - Logger.widgets.debug("webview failed with error: \(error.localizedDescription)") - url = nil - } - - // Signature changed on transfer from completion handler to async / from didRecieve to respondTo - func webView(_ webView: WKWebView, - respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await onReceiveSessionChallenge(with: challenge) - } -} - -extension WebUITableViewCell: WKUIDelegate { - func webView(_ webView: WKWebView, - decideMediaCapturePermissionsFor origin: WKSecurityOrigin, - initiatedBy frame: WKFrameInfo, - type: WKMediaCaptureType) async -> WKPermissionDecision { - .grant - } -} diff --git a/openHAB/openHAB-Info.plist b/openHAB/openHAB-Info.plist index c20d06688..cd3a3297d 100644 --- a/openHAB/openHAB-Info.plist +++ b/openHAB/openHAB-Info.plist @@ -93,10 +93,6 @@ UILaunchStoryboardName MainLaunchScreen - UIMainStoryboardFile - Main - UIMainStoryboardFile~ipad - Main UIRequiredDeviceCapabilities armv7 diff --git a/openHABIntents/OpenHABIntentHelper.swift b/openHABIntents/OpenHABIntentHelper.swift index 61096da55..a93069d2b 100644 --- a/openHABIntents/OpenHABIntentHelper.swift +++ b/openHABIntents/OpenHABIntentHelper.swift @@ -19,7 +19,7 @@ public enum OpenHABIntentHelper { if let home, let homeId = home.uuid { // TODO: fuzzy matching / account for potential renaming? // TODO: accept potential mismatches if item name is unique - let homePrefs = Preferences.shared.storedHomes.first { $0.key == homeId } + let homePrefs = await Preferences.shared.storedHomes.first { $0.key == homeId } if homePrefs != nil { return .success(with: home) } else { @@ -31,8 +31,9 @@ public enum OpenHABIntentHelper { let homeIdsWithMatchingItems = allItems.map(\.key).filter { uuid in allItems[uuid]?.filtered(by: item).isEmpty != true } + let storedHomes = await Preferences.shared.storedHomes let potentialHomes = homeIdsWithMatchingItems - .compactMap { Preferences.shared.storedHomes[$0] } + .compactMap { storedHomes[$0] } .map { OpenHABHome(homeId: $0.id, homeName: $0.homeName) } if potentialHomes.count == 1 { return .success(with: potentialHomes[0]) @@ -44,8 +45,8 @@ public enum OpenHABIntentHelper { } } - static func getHomeOptions() -> INObjectCollection { - INObjectCollection(items: Preferences.shared.storedHomes.map { OpenHABHome(homeId: $0.value.id, homeName: $0.value.homeName) }) + static func getHomeOptions() async -> INObjectCollection { + await INObjectCollection(items: Preferences.shared.storedHomes.map { OpenHABHome(homeId: $0.value.id, homeName: $0.value.homeName) }) } static func getItemOptions(home: OpenHABHome?, searchTerm: String? = nil, itemTypes: [OpenHABItem.ItemType]? = nil) async -> INObjectCollection { diff --git a/openHABTestsSwift/OpenHABGeneralTests.swift b/openHABTestsSwift/OpenHABGeneralTests.swift index 81ec1439a..fac06be42 100644 --- a/openHABTestsSwift/OpenHABGeneralTests.swift +++ b/openHABTestsSwift/OpenHABGeneralTests.swift @@ -25,8 +25,9 @@ class OpenHABGeneralTests: XCTestCase { return String(format: "%.\(digits)f", widgetValue) } - XCTAssertEqual(1000.0.valueText(step: 0.01), "1000.00") - XCTAssertEqual(1000.0.valueText(step: 1), "1000") + let value = 1000.0 + XCTAssertEqual(value.valueText(step: 0.01), "1000.00") + XCTAssertEqual(value.valueText(step: 1), "1000") XCTAssertEqual(valueTextWithoutFormatter(1000.0, step: 5.23), "1000.00") } diff --git a/openHABTestsSwift/OpenHABWatchTests.swift b/openHABTestsSwift/OpenHABWatchTests.swift deleted file mode 100644 index 59be41379..000000000 --- a/openHABTestsSwift/OpenHABWatchTests.swift +++ /dev/null @@ -1,279 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -@testable import openHAB -import XCTest - -class OpenHABWatchTests: XCTestCase { - let jsonInput = """ - { - "name": "watch", - "label": "Watch", - "link": "https://192.168.0.1:8080/rest/sitemaps/watch", - "homepage": { - "id": "watch", - "title": "Watch", - "link": "https://192.168.0.1:8080/rest/sitemaps/watch/watch", - "leaf": false, - "timeout": false, - "widgets": [ - { - "widgetId": "00", - "type": "Switch", - "label": "Haustür", - "icon": "lock", - "mappings": [ - ], - "item": { - "link": "https://192.168.0.1:8080/rest/items/KeyMatic_Open", - "state": "OFF", - "stateDescription": { - "readOnly": false, - "options": [ - ] - }, - "editable": false, - "type": "Switch", - "name": "KeyMatic_Open", - "label": "Haustuer", - "category": "lock", - "tags": [ - ], - "groupNames": [ - ] - }, - "widgets": [ - ] - }, - { - "widgetId": "01", - "type": "Switch", - "label": "Garagentor", - "icon": "garage", - "mappings": [ - ], - "item": { - "link": "https://192.168.0.1:8080/rest/items/Garagentor_Taster", - "state": "OFF", - "stateDescription": { - "readOnly": false, - "options": [ - ] - }, - "editable": false, - "type": "Switch", - "name": "Garagentor_Taster", - "label": "Garagentor", - "category": "garage", - "tags": [ - ], - "groupNames": [ - ] - }, - "widgets": [ - ] - }, - { - "widgetId": "02", - "type": "Switch", - "label": "Garagentür [verriegelt]", - "icon": "lock", - "mappings": [ - ], - "item": { - "link": "https://192.168.0.1:8080/rest/items/KeyMatic_Garage_State", - "state": "OFF", - "transformedState": "verriegelt", - "stateDescription": { - "pattern": "", - "readOnly": false, - "options": [ - ] - }, - "editable": false, - "type": "Switch", - "name": "KeyMatic_Garage_State", - "label": "Garagentuer entriegelt", - "category": "lock", - "tags": [ - ], - "groupNames": [ - ] - }, - "widgets": [ - ] - }, - { - "widgetId": "03", - "type": "Switch", - "label": "Küchenlicht", - "icon": "switch", - "mappings": [ - ], - "item": { - "link": "https://192.168.0.1:8080/rest/items/Licht_EG_Kueche", - "state": "OFF", - "stateDescription": { - "readOnly": false, - "options": [ - ] - }, - "editable": false, - "type": "Switch", - "name": "Licht_EG_Kueche", - "label": "Kuechenlampe", - "tags": [ - ], - "groupNames": [ - "gEG", - "Lichter", - "Simulation" - ] - }, - "widgets": [ - ] - }, - { - "widgetId": "04", - "type": "Switch", - "label": "Bewässerung", - "icon": "switch", - "mappings": [ - ], - "item": { - "link": "https://192.168.0.1:8080/rest/items/HK_Bewaesserung", - "state": "OFF", - "editable": false, - "type": "Switch", - "name": "HK_Bewaesserung", - "label": "Bewaesserung", - "tags": [ - "Lighting" - ], - "groupNames": [ - ] - }, - "widgets": [ - ] - }, - { - "widgetId": "05", - "type": "Switch", - "label": "Pumpe", - "icon": "switch", - "mappings": [ - ], - "item": { - "link": "https://192.168.0.1:8080/rest/items/Pumpe_Garten", - "state": "OFF", - "stateDescription": { - "readOnly": false, - "options": [ - ] - }, - "editable": false, - "type": "Switch", - "name": "Pumpe_Garten", - "label": "Pumpe", - "tags": [ - ], - "groupNames": [ - "Garten" - ] - }, - "widgets": [ - ] - } - ] - } - } - """ - - let decoder = JSONDecoder() - - override func setUp() { - super.setUp() - decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) - - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDown() { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - // Pre-Decodable JSON parsing - func testSiteMapForWatchParsing() { - let data = Data(jsonInput.utf8) - do { - let json = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.mutableContainers) - guard let jsonDict: NSDictionary = json as? NSDictionary else { - XCTFail("Not able to parse") - return - } - let homepageDict = jsonDict.object(forKey: "homepage") as! NSDictionary - if homepageDict.isEmpty { - XCTFail("Not finding homepage") - return - } - let widgetsDict = homepageDict.object(forKey: "widgets") as! NSMutableArray - if widgetsDict.isEmpty { - XCTFail("widgets not found") - return - } - } catch { - XCTFail("Failed parsing") - } - } - - // Parsing to [Item] - func testSiteMapForWatchParsingWithDecodable() { - var items: [Item] = [] - - let data = Data(jsonInput.utf8) - do { - let codingData = try decoder.decode(OpenHABSitemap.CodingData.self, from: data) - XCTAssert(codingData.label == "Watch", "OpenHABSitemap properly parsed") - XCTAssert(codingData.page.widgets?[0].type == "Switch", "widget properly parsed") - let widgets = try require(codingData.page.widgets) - items = widgets.compactMap { Item(with: $0.item) } - XCTAssert(items[0].name == "KeyMatic_Open", "Construction of items failed") - } catch { - XCTFail("Whoops, an error occured: \(error)") - } - } - - // Decodable parsing to Frame - func testSiteMapForWatchParsingWithDecodabletoFrame() { - var frame: Frame - - let data = Data(jsonInput.utf8) - do { - let codingData = try decoder.decode(OpenHABSitemap.CodingData.self, from: data) - frame = Frame(with: codingData)! - XCTAssert(frame.items[0].name == "KeyMatic_Open", "Parsing of Frame failed") - } catch { - XCTFail("Whoops, an error occured: \(error)") - } - } - - // Decodable parsing to Sitemap - func testSiteMapForWatchParsingWithDecodabletoSitemap() { - let data = Data(jsonInput.utf8) - do { - let codingData = try decoder.decode(OpenHABSitemap.CodingData.self, from: data) - let sitemap = try require(Sitemap(with: codingData)) - XCTAssert(sitemap.frames[0].items[0].name == "KeyMatic_Open", "Parsing of Frame failed") - } catch { - XCTFail("Whoops, an error occured: \(error)") - } - } -} diff --git a/openHABUITests/OpenHABUITests.swift b/openHABUITests/OpenHABUITests.swift index 77c073d9d..58d32a026 100644 --- a/openHABUITests/OpenHABUITests.swift +++ b/openHABUITests/OpenHABUITests.swift @@ -35,12 +35,12 @@ class OpenHABUITests: XCTestCase { let app = XCUIApplication() app.activate() - let hamburgerButton = app.navigationBars.buttons["HamburgerButton"] - hamburgerButton.tap() - sleep(3) + // Navigate using tab bar instead of hamburger menu + let tabBar = app.tabBars if runWebViewAndSitemap { - app.staticTexts["Home"].tap() + // Home tab (WebView / MainUI) + tabBar.buttons["Home"].tap() sleep(10) snapshot("0_MainUI") @@ -77,9 +77,10 @@ class OpenHABUITests: XCTestCase { webViewsQuery.links.allElementsBoundByIndex[1].tap() sleep(2) - app.webViews.staticTexts["square_arrow_right"].tap() + // Switch to Sitemaps tab + tabBar.buttons["Sitemaps"].tap() + sleep(3) - app.staticTexts["Main Menu"].tap() app.cells.containing(.staticText, identifier: "Widget Overview").firstMatch.tap() sleep(10) snapshot("4_MainSitemap") @@ -90,10 +91,11 @@ class OpenHABUITests: XCTestCase { app.navigationBars.buttons.element(boundBy: 0).tap() sleep(2) - - hamburgerButton.tap() - sleep(3) } + + // Switch to System tab for settings + tabBar.buttons["System"].tap() + sleep(2) app.staticTexts["settings"].tap() sleep(2) snapshot("7_Settings_Demo") diff --git a/openHABWatch/Base.lproj/Interface.storyboard b/openHABWatch/Base.lproj/Interface.storyboard deleted file mode 100644 index 26f8ff19f..000000000 --- a/openHABWatch/Base.lproj/Interface.storyboard +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index d4aaedf28..35461e882 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -10,6 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 import Combine +import CommonUI import Foundation import OpenHABCore import os.log @@ -44,9 +45,7 @@ final class UserData: ObservableObject { let sitemapPage = try data.decoded(as: Components.Schemas.PageDTO.self) openHABSitemapPage = OpenHABPage(sitemapPage) widgets = openHABSitemapPage?.widgets ?? [] - openHABSitemapPage?.sendCommand = { [weak self] item, command in - Task { await self?.sendCommand(item, command: command) } - } + decorateWidgetsWithSendCommand(widgets) } catch { Logger.userData.error("Should not throw \(error.localizedDescription)") } @@ -224,12 +223,16 @@ final class UserData: ObservableObject { } func updateNetwork() async { - guard let connection1 = AppSettings.shared.localConnectionConfig, - let connection2 = AppSettings.shared.remoteConnectionConfig else { + let connections = [ + AppSettings.shared.localConnectionConfig, + AppSettings.shared.remoteConnectionConfig + ].compactMap(\.self) + + guard !connections.isEmpty else { Logger.userData.warning("No connections defined") return } - await NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) + await NetworkTracker.shared.startTracking(connectionConfigurations: connections) } func startPageHandling(sitemapName: String, pageId: String = "", force: Bool = false) { @@ -268,8 +271,22 @@ final class UserData: ObservableObject { do { isLoadingSitemap = true - // Wait for NetworkTracker to establish a connection (no fallback hacks needed!) - guard let connectionInfo = await NetworkTracker.shared.waitForActiveConnection() else { + // Always ensure tracking is running before waiting. + // startTracking is idempotent for unchanged active configurations. + await self.updateNetwork() + + var connectionInfo = await NetworkTracker.shared.activeConnection + if connectionInfo == nil { + connectionInfo = await NetworkTracker.shared.waitForActiveConnection() + } + + if connectionInfo == nil { + Logger.userData.warning("No active connection on first attempt, restarting network tracking once") + await self.updateNetwork() + connectionInfo = await NetworkTracker.shared.waitForActiveConnection() + } + + guard let connectionInfo else { Logger.userData.error("No active connection available after timeout") await MainActor.run { self.errorDescription = NSLocalizedString("settings_not_received", comment: "") @@ -286,10 +303,6 @@ final class UserData: ObservableObject { try Task.checkCancellation() await MainActor.run { - // Set command handler BEFORE assigning to @Published property to prevent race condition - initialPage?.sendCommand = { [weak self] item, command in - Task { await self?.sendCommand(item, command: command) } - } self.openHABSitemapPage = initialPage let newWidgets = initialPage?.widgets ?? [] self.updateWidgets(with: newWidgets) @@ -310,13 +323,11 @@ final class UserData: ObservableObject { try Task.checkCancellation() await MainActor.run { - // Set command handler BEFORE assigning to @Published property to prevent race condition - if let page { - page.sendCommand = { [weak self] item, command in - Task { await self?.sendCommand(item, command: command) } - } + // Only update page object when title changes to avoid + // firing objectWillChange and resetting scroll position + if self.openHABSitemapPage?.title != page?.title { + self.openHABSitemapPage = page } - self.openHABSitemapPage = page let newWidgets = page?.widgets ?? [] self.updateWidgets(with: newWidgets) if !newWidgets.isEmpty { @@ -373,6 +384,8 @@ final class UserData: ObservableObject { func sendCommand(_ item: OpenHABItem?, command: String?) async { guard let item, let command else { return } do { + // Watch commands currently rely on server defaults for `source`. + // Explicit source formatting can be rejected by some deployments. try await NetworkTracker.shared.send(to: item, command: command) } catch { Logger.userData.error("Failed to send command '\(command)' to '\(item.name)': \(error.localizedDescription)") @@ -388,30 +401,64 @@ final class UserData: ObservableObject { /// Updates existing widget instances instead of replacing them to preserve @ObservedObject references private func updateWidgets(with newWidgets: [OpenHABWidget]) { - // Build a map of existing widgets by ID for quick lookup - var existingWidgetsMap = Dictionary(uniqueKeysWithValues: widgets.map { ($0.widgetId, $0) }) - var updatedWidgets: [OpenHABWidget] = [] + let existingWidgetsMap = Dictionary(uniqueKeysWithValues: widgets.map { ($0.widgetId, $0) }) + + // Check if the widget list structure changed (count, order, or IDs) + let structureChanged = widgets.count != newWidgets.count + || !zip(widgets, newWidgets).allSatisfy { $0.widgetId == $1.widgetId } for newWidget in newWidgets { if let existingWidget = existingWidgetsMap[newWidget.widgetId] { - // Update existing widget's properties to preserve the instance + // Update existing widget's properties in place — this triggers + // per-row re-renders via @ObservedObject without rebuilding the list existingWidget.label = newWidget.label + existingWidget.type = newWidget.type existingWidget.icon = newWidget.icon existingWidget.state = newWidget.state existingWidget.item = newWidget.item - existingWidget.stateEnumBinding = newWidget.stateEnumBinding existingWidget.iconColor = newWidget.iconColor existingWidget.labelcolor = newWidget.labelcolor existingWidget.valuecolor = newWidget.valuecolor - // Add other properties as needed - updatedWidgets.append(existingWidget) - existingWidgetsMap.removeValue(forKey: newWidget.widgetId) - } else { - // New widget, add it - updatedWidgets.append(newWidget) + existingWidget.url = newWidget.url + existingWidget.period = newWidget.period + existingWidget.service = newWidget.service + existingWidget.legend = newWidget.legend + existingWidget.refresh = newWidget.refresh + existingWidget.height = newWidget.height + existingWidget.forceAsItem = newWidget.forceAsItem + existingWidget.mappings = newWidget.mappings + existingWidget.widgets = newWidget.widgets + existingWidget.linkedPage = newWidget.linkedPage + existingWidget.visibility = newWidget.visibility } } - widgets = updatedWidgets + // Wire sendCommand closures directly to UserData rather than copying + // from the page's decorated widgets. The page object is a local variable + // that gets deallocated after each poll cycle (when the title doesn't + // change), which kills the [weak page] closures set by + // decorateWithSendCommand. By capturing [weak self] (UserData) instead, + // the closures remain alive for the lifetime of the view hierarchy. + let allWidgets = structureChanged + ? newWidgets.map { existingWidgetsMap[$0.widgetId] ?? $0 } + : widgets + decorateWidgetsWithSendCommand(allWidgets) + + // Only reassign the @Published array when the list structure actually + // changed (widgets added, removed, or reordered). This avoids a full + // ScrollView rebuild that resets the scroll position. + if structureChanged { + widgets = allWidgets + } + } + + /// Sets sendCommand closures on widgets that go directly to UserData, + /// bypassing the OpenHABPage closure chain and its weak-reference lifetime issues. + private func decorateWidgetsWithSendCommand(_ widgets: [OpenHABWidget]) { + for widget in widgets { + widget.sendCommand = { [weak self] item, command in + Task { await self?.sendCommand(item, command: command) } + } + } } } diff --git a/openHABWatch/Extension/openHABWatch Extension/ButtonTableRowController.swift b/openHABWatch/Extension/openHABWatch Extension/ButtonTableRowController.swift deleted file mode 100644 index 7cd9bb999..000000000 --- a/openHABWatch/Extension/openHABWatch Extension/ButtonTableRowController.swift +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import os.log -import WatchKit - -class ButtonTableRowController: NSObject { - var item: Item? - var interfaceController: InterfaceController? - - @IBOutlet private var buttonSwitch: WKInterfaceSwitch! - - @IBAction private func doSwitchButtonPressed(_ value: Bool) { - guard let item else { return } - let command = value ? "ON" : "OFF" - switchOpenHabItem(for: item, command: command) - } - - func setInterfaceController(interfaceController: InterfaceController) { - self.interfaceController = interfaceController - } - - func setItem(item: Item) { - self.item = item - buttonSwitch.setTitle(item.label) - buttonSwitch.setOn(item.state == "ON") - } - - private func toggleButtonColor(button: WKInterfaceButton) { - button.setBackgroundColor(UIColor.darkGray) - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .milliseconds(250)) { - button.setBackgroundColor(UIColor.lightGray) - } - } - - private func switchOpenHabItem(for item: Item, command: String) { - interfaceController!.displayActivityImage() - OpenHabService.singleton.switchOpenHabItem(for: item, command: command) { (data, response, error) in - self.interfaceController!.hideActivityImage() - guard let data, error == nil else { // check for fundamental networking error - self.interfaceController!.displayAlert(message: "error=\(String(describing: error))") - return - } - - if let httpStatus = response as? HTTPURLResponse, httpStatus.statusCode != 200 { // check for http errors - let message = "statusCode should be 200, but is \(httpStatus.statusCode)\n" + - "response = \(String(describing: response))" - self.interfaceController!.displayAlert(message: message) - } - - let responseString = String(data: data, encoding: .utf8) - Logger.watchService.debug("responseString = \(String(describing: responseString))") - } - } -} diff --git a/openHABWatch/Extension/openHABWatch Extension/InterfaceController.swift b/openHABWatch/Extension/openHABWatch Extension/InterfaceController.swift deleted file mode 100644 index 260dd6f9a..000000000 --- a/openHABWatch/Extension/openHABWatch Extension/InterfaceController.swift +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import OpenHABCore -import os.log -import WatchKit - -class InterfaceController: WKInterfaceController { - @IBOutlet private var activityImage: WKInterfaceImage! - @IBOutlet private var buttonTable: WKInterfaceTable! - - override func awake(withContext context: Any?) { - super.awake(withContext: context) - - activityImage.setImageNamed("Activity") - activityImage.setHidden(true) - } - - override func willActivate() { - // This method is called when watch view controller is about to be visible to user - super.willActivate() - - refresh(Preferences.sitemap) - // load the current Sitemap - OpenHabService.singleton.readSitemap { (sitemap, errorString) in - if errorString != "" { - // Timeouts happen when the app is in background state. - // This shouldn't popup an error message. - if AppState.singleton.active { - self.displayAlert(message: errorString) - return - } - } - Preferences.sitemap = sitemap - self.refresh(sitemap) - } - } - - fileprivate func refresh(_ sitemap: Sitemap) { - if sitemap.frames.isEmpty { - return - } - - buttonTable.setNumberOfRows(sitemap.frames[0].items.count, withRowType: "buttonRow") - for i in 0 ..< buttonTable.numberOfRows { - let row = buttonTable.rowController(at: i) as! ButtonTableRowController - row.setInterfaceController(interfaceController: self) - row.setItem(item: sitemap.frames[0].items[i]) - } - } - - func displayAlert(message: String) { - DispatchQueue.main.async { - let okAction = WKAlertAction(title: "Ok", style: .default) { - Logger.watchInterface.debug("OK action pressed") - } - self.presentAlert(withTitle: "Fehler", message: message, preferredStyle: .actionSheet, actions: [okAction]) - } - } - - func displayActivityImage() { - activityImage.setHidden(false) - activityImage.startAnimatingWithImages(in: NSRange(1 ... 15), duration: 1.0, repeatCount: 0) - } - - func hideActivityImage() { - activityImage.setHidden(true) - activityImage.stopAnimating() - } -} diff --git a/openHABWatch/Extension/openHABWatch Extension/NotificationController.swift b/openHABWatch/Extension/openHABWatch Extension/NotificationController.swift deleted file mode 100644 index 42bd94db7..000000000 --- a/openHABWatch/Extension/openHABWatch Extension/NotificationController.swift +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import UserNotifications -import WatchKit - -class NotificationController: WKUserNotificationInterfaceController { - override init() { - // Initialize variables here. - super.init() - - // Configure interface objects here. - } -} diff --git a/openHABWatch/Extension/openHABWatch Extension/PrefsInterfaceController.swift b/openHABWatch/Extension/openHABWatch Extension/PrefsInterfaceController.swift deleted file mode 100644 index 7f5028e5e..000000000 --- a/openHABWatch/Extension/openHABWatch Extension/PrefsInterfaceController.swift +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation -import WatchKit - -class PrefsInterfaceController: WKInterfaceController { - @IBOutlet private var versionLabel: WKInterfaceLabel! - @IBOutlet private var localUrlLabel: WKInterfaceLabel! - @IBOutlet private var remoteUrlLabel: WKInterfaceLabel! - @IBOutlet private var usernameLabel: WKInterfaceLabel! - @IBOutlet private var sitemapLabel: WKInterfaceLabel! - - override func willActivate() { - // This method is called when watch view controller is about to be visible to user - super.willActivate() - - displayTheApplicationVersionNumber() - - localUrlLabel.setText(Preferences.localUrl) - remoteUrlLabel.setText(Preferences.remoteUrl) - sitemapLabel.setText(Preferences.sitemapName) - usernameLabel.setText(Preferences.username) - } - - func displayTheApplicationVersionNumber() { - let versionNumber: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String - let buildNumber: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String - - versionLabel.setText("V\(versionNumber).\(buildNumber)") - } -} diff --git a/openHABWatch/Model/OpenHABWidgetExtension.swift b/openHABWatch/Model/OpenHABWidgetExtension.swift index 4122d0309..b8e3ee0e1 100644 --- a/openHABWatch/Model/OpenHABWidgetExtension.swift +++ b/openHABWatch/Model/OpenHABWidgetExtension.swift @@ -14,8 +14,23 @@ import OpenHABCore import os.log import SFSafeSymbols import SwiftUI +import UIKit extension OpenHABWidget { + @MainActor + func iconRenderModel(fallbackSymbol: SFSymbol? = nil) -> IconRenderModel { + let logicColor = !iconColor.isEmpty ? UIColor(fromString: iconColor) : .ohBlack + let encodedIconColor = logicColor.semanticColorToHex() ?? "#FFFFFF" + return IconRenderModel( + icon: icon, + iconState: iconState(), + iconColorHex: encodedIconColor, + staticIcon: staticIcon, + stateToken: item?.state ?? state, + fallbackSymbol: fallbackSymbol + ) + } + @ViewBuilder @MainActor func makeView(settings: AppSettings) -> some View { if linkedPage != nil { Image(systemSymbol: .chevronRight) diff --git a/openHABWatch/Model/WidgetRowFactory.swift b/openHABWatch/Model/WidgetRowFactory.swift new file mode 100644 index 000000000..6694a4be1 --- /dev/null +++ b/openHABWatch/Model/WidgetRowFactory.swift @@ -0,0 +1,74 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import OpenHABCore +import SwiftUI + +enum WidgetRowFactory { + @MainActor + @ViewBuilder + static func make(widget: OpenHABWidget, settings: AppSettings) -> some View { + let displayState = widget.displayState + let stateToken = displayState.effectiveState + + switch widget.renderingKind { + case .segmentedSwitch: + SegmentRow(widget: widget, stateToken: stateToken) + case .toggleSwitch: + SwitchRow(widget: widget, stateToken: stateToken) + case .rollershutterSwitch: + RollershutterRow(widget: widget) + case .slider: + SliderRow(widget: widget, stateToken: stateToken) + case .setpoint: + SetpointRow(widget: widget, stateToken: stateToken) + case .frame: + FrameRow(title: displayState.labelText) + case .text: + TextRow(widget: widget, hasLinkedPage: widget.linkedPage != nil) + case .image: + if widget.item != nil { + ImageRawRow(widget: widget) + } else { + EquatableView(content: ImageRow(url: URL(string: widget.url), refresh: widget.refresh)) + } + case .chart: + let url = Endpoint.chart( + rootUrl: settings.openHABRootUrl, + period: widget.period, + type: widget.item?.type ?? .none, + service: widget.service, + name: widget.item?.name, + legend: widget.legend, + theme: .dark, + forceAsItem: widget.forceAsItem + ).url + EquatableView(content: ImageRow(url: url, refresh: widget.refresh)) + case .mapview: + MapViewRow(widget: widget) + case .colorPicker: + ColorPickerRow(widget: widget, stateToken: stateToken) + case .selection: + SelectionRow( + widget: widget, + mappings: displayState.mappings, + title: displayState.labelText.isEmpty ? "Select" : displayState.labelText, + initialSelectedIndex: displayState.selectedIndex, + labelValue: displayState.labelValue + ) + case .video, .webview, .dateInput, .textInput, .colorTemperaturePicker, .buttonGrid: + // Not yet implemented for watchOS + GenericRow(widget: widget) + case .generic: + GenericRow(widget: widget) + } + } +} diff --git a/openHABWatch/Model/WidgetRowViewModel.swift b/openHABWatch/Model/WidgetRowViewModel.swift new file mode 100644 index 000000000..04710f82e --- /dev/null +++ b/openHABWatch/Model/WidgetRowViewModel.swift @@ -0,0 +1,60 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Foundation +import Observation +import OpenHABCore +import UIKit + +@MainActor +@Observable +final class WidgetRowViewModel { + var mappings: [OpenHABWidgetMapping] = [] + var selectedIndex: Int? + var hasPressReleaseMappings = false + var labelText = "" + var labelValue: String? + var selectedLabel: String? + var effectiveState = "" + var isOn = false + var adjustedValue = 0.0 + var minValue = 0.0 + var maxValue = 100.0 + var step = 1.0 + var switchSupport = false + var hasLinkedPage = false + var numberState: NumberState? + var colorState: UIColor? + + init(widget: OpenHABWidget) { + update(from: widget) + } + + func update(from widget: OpenHABWidget) { + let displayState = widget.displayState + mappings = displayState.mappings + hasPressReleaseMappings = displayState.hasPressReleaseMappings + labelText = displayState.labelText + labelValue = displayState.labelValue + selectedIndex = displayState.selectedIndex + selectedLabel = displayState.selectedLabel + effectiveState = displayState.effectiveState + isOn = displayState.isOn + adjustedValue = displayState.adjustedValue + minValue = displayState.minValue + maxValue = displayState.maxValue + step = displayState.step + switchSupport = displayState.switchSupport + hasLinkedPage = displayState.hasLinkedPage + numberState = widget.stateValueAsNumberState + colorState = widget.item?.stateAsUIColor() + } +} diff --git a/openHABWatch/OpenHABWatch.swift b/openHABWatch/OpenHABWatch.swift index 24874281a..b7d6f7bc6 100644 --- a/openHABWatch/OpenHABWatch.swift +++ b/openHABWatch/OpenHABWatch.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import Kingfisher import OpenHABCore import SFSafeSymbols diff --git a/openHABWatch/Preview Content/PreviewNavigationContainer.swift b/openHABWatch/Preview Content/PreviewNavigationContainer.swift new file mode 100644 index 000000000..e7ac5606c --- /dev/null +++ b/openHABWatch/Preview Content/PreviewNavigationContainer.swift @@ -0,0 +1,64 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +#if DEBUG +import OpenHABCore +import SwiftUI + +struct InteractiveStateTokenPreview: View { + let widget: OpenHABWidget + let states: [String] + private let content: (OpenHABWidget, String) -> Content + @State private var selectedStateIndex = 0 + + private var currentToken: String { + let token = states[safe: selectedStateIndex] + return token ?? widget.item?.state ?? widget.state + } + + var body: some View { + VStack(spacing: 8) { + content(widget, currentToken) + Picker("State", selection: $selectedStateIndex) { + ForEach(0 ..< states.count, id: \.self) { index in + Text(states[index]).tag(index) + } + } + .labelsHidden() + .accessibilityLabel("Preview state") + } + } + + init(widget: OpenHABWidget, + states: [String], + @ViewBuilder content: @escaping (OpenHABWidget, String) -> Content) { + self.widget = widget + self.states = states.isEmpty ? [widget.item?.state ?? widget.state] : states + self.content = content + } +} + +struct PreviewNavigationContainer: View { + @State private var settings = AppSettings() + private let content: Content + + var body: some View { + NavigationStack { + content + } + .environmentObject(settings) + } + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } +} +#endif diff --git a/openHABWatch/Preview Content/PreviewWidgetFactory.swift b/openHABWatch/Preview Content/PreviewWidgetFactory.swift new file mode 100644 index 000000000..107b58cff --- /dev/null +++ b/openHABWatch/Preview Content/PreviewWidgetFactory.swift @@ -0,0 +1,240 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +#if DEBUG +import Foundation +import OpenHABCore + +enum PreviewWidgetFactory { + static func slider(label: String, + value: Double? = nil, + minValue: Double = 0.0, + maxValue: Double = 100.0, + step: Double = 1.0, + icon: String = "slider", + switchSupport: Bool = false) -> OpenHABWidget { + makeWidget( + type: .slider, + label: label, + valueText: value.map { String(Int($0)) }, + state: value.map { String($0) } ?? "NULL", + itemType: "Dimmer", + icon: icon, + minValue: minValue, + maxValue: maxValue, + step: step, + switchSupport: switchSupport + ) + } + + static func switchWidget(label: String, + state: String = "OFF", + icon: String = "switch") -> OpenHABWidget { + makeWidget( + type: .switchWidget, + label: label, + valueText: state, + state: state, + itemType: "Switch", + icon: icon + ) + } + + static func setpoint(label: String, + value: Double, + minValue: Double = 0.0, + maxValue: Double = 100.0, + step: Double = 1.0, + unit: String? = nil, + icon: String = "temperature") -> OpenHABWidget { + let widget = makeWidget( + type: .setpoint, + label: label, + valueText: String(value), + state: String(value), + itemType: "Number", + icon: icon, + minValue: minValue, + maxValue: maxValue, + step: step + ) + widget.unit = unit + return widget + } + + static func selection(label: String, + options: [(String, String)], + selectedIndex: Int = 0, + icon: String = "selection") -> OpenHABWidget { + let mappings = options.map { OpenHABWidgetMapping(command: $0.0, label: $0.1) } + let selectedCommand = options[safe: selectedIndex]?.0 ?? "" + let widget = makeWidget( + type: .selection, + label: label, + valueText: options[safe: selectedIndex]?.1, + state: selectedCommand, + itemType: "String", + icon: icon + ) + widget.mappings = mappings + return widget + } + + static func segmented(label: String, + mappings: [OpenHABWidgetMapping], + selectedState: String? = nil, + icon: String = "switch") -> OpenHABWidget { + let widget = makeWidget( + type: .switchWidget, + label: label, + valueText: nil, + state: selectedState ?? mappings.first?.command ?? "", + itemType: "String", + icon: icon + ) + widget.mappings = mappings + return widget + } + + static func rollershutter(label: String, + state: String = "STOP", + icon: String = "rollershutter") -> OpenHABWidget { + makeWidget( + type: .switchWidget, + label: label, + valueText: state, + state: state, + itemType: "Rollershutter", + icon: icon + ) + } + + static func colorpicker(label: String, + state: String = "0,100,100", + icon: String = "colorwheel") -> OpenHABWidget { + makeWidget( + type: .colorpicker, + label: label, + valueText: nil, + state: state, + itemType: "Color", + icon: icon + ) + } + + static func frame(label: String) -> OpenHABWidget { + makeWidget( + type: .frame, + label: label, + valueText: nil, + state: "", + itemType: "Group", + icon: "frame" + ) + } + + static func mapview(label: String, + state: String = "0,0", + icon: String = "map") -> OpenHABWidget { + makeWidget( + type: .mapview, + label: label, + valueText: nil, + state: state, + itemType: "Location", + icon: icon + ) + } + + static func imageRaw(label: String, + base64: String, + icon: String = "image") -> OpenHABWidget { + makeWidget( + type: .image, + label: label, + valueText: nil, + state: "image/png,\(base64)", + itemType: "Image", + icon: icon + ) + } + + static func text(label: String, + valueText: String? = nil, + icon: String = "text") -> OpenHABWidget { + makeWidget( + type: .text, + label: label, + valueText: valueText, + state: valueText ?? "", + itemType: "String", + icon: icon + ) + } + + static func generic(label: String, + valueText: String? = nil, + icon: String = "item") -> OpenHABWidget { + makeWidget( + type: .unknown, + label: label, + valueText: valueText, + state: valueText ?? "", + itemType: "String", + icon: icon + ) + } + + // swiftlint:disable:next function_parameter_count + private static func makeWidget(type: OpenHABWidget.WidgetType, + label: String, + valueText: String?, + state: String, + itemType: String, + icon: String, + minValue: Double = 0.0, + maxValue: Double = 100.0, + step: Double = 1.0, + switchSupport: Bool = false) -> OpenHABWidget { + let widget = OpenHABWidget() + widget.widgetId = UUID().uuidString + widget.type = type + widget.icon = icon + widget.minValue = minValue + widget.maxValue = maxValue + widget.step = step + widget.switchSupport = switchSupport + + if let valueText { + widget.label = "\(label) [\(valueText)]" + } else { + widget.label = label + } + + let item = OpenHABItem( + name: "Preview_\(label.replacingOccurrences(of: " ", with: "_"))", + type: itemType, + state: state, + link: "", + label: label, + groupType: nil, + stateDescription: nil, + commandDescription: nil, + members: [], + category: nil, + options: nil + ) + widget.item = item + + return widget + } +} +#endif diff --git a/openHABWatch/Views/PreferencesSwiftUIView.swift b/openHABWatch/Views/PreferencesSwiftUIView.swift index 0b2cd41fb..b19319db8 100644 --- a/openHABWatch/Views/PreferencesSwiftUIView.swift +++ b/openHABWatch/Views/PreferencesSwiftUIView.swift @@ -39,7 +39,7 @@ struct CompactLabeledContentStyle: LabeledContentStyle { Spacer() configuration.content .font(.footnote) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } // .padding(.vertical, 4) // Reduces vertical space } diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index b4d6aa0c3..b5a0af56e 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -9,64 +9,110 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore -import os.log import SFSafeSymbols import SwiftUI struct ColorPickerRow: View { - @ObservedObject var widget: OpenHABWidget - @ObservedObject var settings = AppSettings.shared + let widget: OpenHABWidget + let stateToken: String + @EnvironmentObject var settings: AppSettings + @State private var viewModel: WidgetRowViewModel + @State private var commandSender = WidgetCommandDispatcher() var body: some View { - let uiColor = widget.item?.stateAsUIColor() + let uiColor = viewModel.colorState return VStack(spacing: 0) { HStack { - IconView(widget: widget, settings: settings) - TextLabelView(widget: widget) + WatchIconView(model: widget.iconRenderModel(), settings: settings) + WatchLabelText(text: viewModel.labelText) Spacer() } HStack { Spacer() IconWithAction( systemSymbol: .chevronDownCircleFill, + accessibilityLabel: "Decrease brightness", action: downButtonPressed ) Spacer() - NavigationLink(destination: ColorSelection()) { + NavigationLink(destination: ColorSelection(widget: widget)) { Circle() - .fill(Color(uiColor!)) + .fill(uiColor.map { Color($0) } ?? Color.gray.opacity(0.4)) .frame(width: 35, height: 35) + .overlay { + if uiColor == nil { + Circle() + .stroke(Color.white.opacity(0.4), lineWidth: 1) + } + } } + .accessibilityLabel("Select color") Spacer() IconWithAction( systemSymbol: .chevronUpCircleFill, + accessibilityLabel: "Increase brightness", action: upButtonPressed ) Spacer() } } + .onChange(of: stateToken, initial: false) { _, _ in + viewModel.update(from: widget) + } + } + + init(widget: OpenHABWidget) { + self.widget = widget + stateToken = widget.item?.state ?? widget.state + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } + + init(widget: OpenHABWidget, stateToken: String) { + self.widget = widget + self.stateToken = stateToken + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } func upButtonPressed() { - Logger.rowViews.info("ON button pressed") - widget.sendCommand("ON") + commandSender.send("ON", for: widget, policy: .immediate) } func downButtonPressed() { - Logger.rowViews.info("OFF button pressed") - widget.sendCommand("OFF") + commandSender.send("OFF", for: widget, policy: .immediate) } } #Preview { - let widget = UserData(preview: true).widgets[10] - ColorPickerRow(widget: widget) - .environmentObject(AppSettings()) + let widget = PreviewWidgetFactory.colorpicker( + label: "Color", + state: "120,100,100", + icon: "colorwheel" + ) + PreviewNavigationContainer { + ColorPickerRow(widget: widget, stateToken: widget.item?.state ?? widget.state) + } +} + +#Preview("Interactive State Token") { + let widget = PreviewWidgetFactory.colorpicker( + label: "Color", + state: "120,100,100", + icon: "colorwheel" + ) + PreviewNavigationContainer { + InteractiveStateTokenPreview( + widget: widget, + states: ["0,100,100", "120,100,100", "240,100,100", "NULL"] + ) { targetWidget, stateToken in + ColorPickerRow(widget: targetWidget, stateToken: stateToken) + } + } } diff --git a/openHABWatch/Views/Rows/FrameRow.swift b/openHABWatch/Views/Rows/FrameRow.swift index bf83155c9..c6f9a5665 100644 --- a/openHABWatch/Views/Rows/FrameRow.swift +++ b/openHABWatch/Views/Rows/FrameRow.swift @@ -9,25 +9,23 @@ // // SPDX-License-Identifier: EPL-2.0 -import OpenHABCore import SwiftUI struct FrameRow: View { - @ObservedObject var widget: OpenHABWidget - @EnvironmentObject var settings: AppSettings + let title: String var body: some View { HStack { - Text(widget.labelText?.uppercased() ?? "") - .font(.callout) - .lineLimit(1) + Text(title.uppercased()) + .watchTextStyle(.section) Spacer() } } } #Preview { - let widget = UserData(preview: true).widgets[6] - FrameRow(widget: widget) - .environmentObject(AppSettings()) + let widget = PreviewWidgetFactory.frame(label: "Environment") + PreviewNavigationContainer { + FrameRow(title: widget.labelText ?? "") + } } diff --git a/openHABWatch/Views/Rows/GenericRow.swift b/openHABWatch/Views/Rows/GenericRow.swift index a17349464..52a097f11 100644 --- a/openHABWatch/Views/Rows/GenericRow.swift +++ b/openHABWatch/Views/Rows/GenericRow.swift @@ -9,27 +9,29 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore -import os.log import SwiftUI struct GenericRow: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget @ObservedObject var settings = AppSettings.shared var body: some View { HStack { - IconView(widget: widget, settings: settings) - TextLabelView(widget: widget) + WatchIconView(model: widget.iconRenderModel(), settings: settings) + WatchLabelText(text: widget.labelText ?? widget.label) Spacer() - DetailTextLabelView(widget: widget) + DetailTextLabelView(text: widget.labelValue, valueColor: widget.valuecolor) widget.makeView(settings: settings) } + .accessibilityLabel(widget.labelText ?? "") } } #Preview { - let widget = UserData(preview: true).widgets[6] - GenericRow(widget: widget) - .environmentObject(AppSettings()) + let widget = PreviewWidgetFactory.generic(label: "Unsupported Widget", valueText: "N/A") + PreviewNavigationContainer { + GenericRow(widget: widget) + } } diff --git a/openHABWatch/Views/Rows/ImageRawRow.swift b/openHABWatch/Views/Rows/ImageRawRow.swift index 7d7637770..d8e1988d3 100644 --- a/openHABWatch/Views/Rows/ImageRawRow.swift +++ b/openHABWatch/Views/Rows/ImageRawRow.swift @@ -16,22 +16,36 @@ import SwiftUI struct ImageRawRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings + @State private var viewModel: WidgetRowViewModel var body: some View { - if let data = widget.item?.state?.components(separatedBy: ",")[safe: 1], - let decodedData = Data(base64Encoded: data, options: .ignoreUnknownCharacters), - let image = UIImage(data: decodedData) { - Image(uiImage: image) - .resizable() - .scaledToFit() - } else { - EmptyView() + Group { + if let data = viewModel.effectiveState.components(separatedBy: ",")[safe: 1], + let decodedData = Data(base64Encoded: data, options: .ignoreUnknownCharacters), + let image = UIImage(data: decodedData) { + Image(uiImage: image) + .resizable() + .scaledToFit() + } + } + .onAppear { + viewModel.update(from: widget) + } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) } } + + init(widget: OpenHABWidget) { + self.widget = widget + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } } #Preview { - let widget = UserData(preview: true).widgets[4] - ImageRawRow(widget: widget) - .environmentObject(AppSettings()) + let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" + let widget = PreviewWidgetFactory.imageRaw(label: "Camera", base64: base64) + PreviewNavigationContainer { + ImageRawRow(widget: widget) + } } diff --git a/openHABWatch/Views/Rows/ImageRow.swift b/openHABWatch/Views/Rows/ImageRow.swift index d64cf0a07..3935b7252 100644 --- a/openHABWatch/Views/Rows/ImageRow.swift +++ b/openHABWatch/Views/Rows/ImageRow.swift @@ -10,6 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 import Combine +import CommonUI import Kingfisher import OpenHABCore import os.log diff --git a/openHABWatch/Views/Rows/MapViewRow.swift b/openHABWatch/Views/Rows/MapViewRow.swift index 6867ee2db..ae9172be5 100644 --- a/openHABWatch/Views/Rows/MapViewRow.swift +++ b/openHABWatch/Views/Rows/MapViewRow.swift @@ -15,6 +15,7 @@ import SwiftUI struct MapViewRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings + @State private var viewModel: WidgetRowViewModel var body: some View { VStack { @@ -23,11 +24,24 @@ struct MapViewRow: View { .padding() // .frame(height: 300) } + .accessibilityLabel(viewModel.labelText) + .onAppear { + viewModel.update(from: widget) + } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) + } + } + + init(widget: OpenHABWidget) { + self.widget = widget + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } } #Preview { - let widget = UserData(preview: true).widgets[9] - MapViewRow(widget: widget) - .environmentObject(AppSettings()) + let widget = PreviewWidgetFactory.mapview(label: "Location", state: "51.5074,0.1278") + PreviewNavigationContainer { + MapViewRow(widget: widget) + } } diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index c83c30f14..969a591e6 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -9,44 +9,48 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import SFSafeSymbols import SwiftUI struct RollershutterRow: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget @EnvironmentObject var settings: AppSettings + @State private var commandSender = WidgetCommandDispatcher() var body: some View { VStack(spacing: -5) { HStack { - IconView(widget: widget, settings: settings) - TextLabelView(widget: widget) + WatchIconView(model: widget.iconRenderModel(), settings: settings) + WatchLabelText(text: widget.labelText ?? widget.label) Spacer() } HStack { Spacer() - IconWithAction(systemSymbol: .chevronUpCircleFill) { - widget.sendCommand("UP") + IconWithAction(systemSymbol: .chevronUpCircleFill, accessibilityLabel: "Move up") { + commandSender.send("UP", for: widget, policy: .immediate) } Spacer() - IconWithAction(systemSymbol: .square) { - widget.sendCommand("STOP") + IconWithAction(systemSymbol: .square, accessibilityLabel: "Stop") { + commandSender.send("STOP", for: widget, policy: .immediate) } Spacer() - IconWithAction(systemSymbol: .chevronDownCircleFill) { - widget.sendCommand("DOWN") + IconWithAction(systemSymbol: .chevronDownCircleFill, accessibilityLabel: "Move down") { + commandSender.send("DOWN", for: widget, policy: .immediate) } Spacer() } .frame(height: 50) } + .accessibilityLabel(widget.labelText ?? "") } } #Preview { - let widget = UserData(preview: true).widgets[5] - RollershutterRow(widget: widget) - .environmentObject(AppSettings()) + let widget = PreviewWidgetFactory.rollershutter(label: "Blinds", state: "STOP") + PreviewNavigationContainer { + RollershutterRow(widget: widget) + } } diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index a8a144946..8ea2797a6 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -9,58 +9,134 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore -import os.log import SwiftUI struct SegmentRow: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget + let stateToken: String @EnvironmentObject var settings: AppSettings - @State private var pendingValue: String? - - var valueBinding: Binding { - .init( - get: { - guard case let .segmented(value) = widget.stateEnumBinding else { return 0 } - return value - }, - set: { newValue in - Logger.rowViews.debug("Picker new value = \(newValue)") - widget.stateEnumBinding = .segmented(newValue) - if let selectedCommand = widget.mappingsOrItemOptions[safe: newValue]?.command { - pendingValue = selectedCommand - Logger.rowViews.debug("Selected command: \(selectedCommand)") - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(500)) - if pendingValue == selectedCommand { // Ensure no new updates came in - widget.sendCommand(selectedCommand) - pendingValue = nil + @State private var pressedIndex: Int? + @State private var singlePressed = false + @State private var viewModel: WidgetRowViewModel + @State private var commandSender = WidgetCommandDispatcher() + + private var currentIndex: Int? { + viewModel.selectedIndex + } + + var body: some View { + Group { + if viewModel.hasPressReleaseMappings { + pressReleaseContent + } else if viewModel.mappings.count == 1 { + singleMappingContent + } else { + multiSegmentContent + } + } + .onChange(of: stateToken, initial: false) { _, _ in + viewModel.update(from: widget) + } + } + + // MARK: - Press-Release + + @ViewBuilder + private var pressReleaseContent: some View { + if viewModel.mappings.count <= 2 { + HStack { + WatchIconView(model: widget.iconRenderModel(), settings: settings) + WatchLabelText(text: viewModel.labelText) + Spacer() + pressReleaseButtons + .layoutPriority(1) + } + } else { + VStack(alignment: .leading, spacing: 4) { + iconTitleRow + HStack { + Spacer() + pressReleaseButtons + } + } + } + } + + @ViewBuilder + private var pressReleaseButtons: some View { + HStack(spacing: 8) { + ForEach(viewModel.mappings.indices, id: \.self) { index in + let mapping = viewModel.mappings[index] + inlineButton(label: mapping.label, isPressed: pressedIndex == index) + .overlay { + GeometryReader { geometry in + Color.clear + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let bounds = CGRect(origin: .zero, size: geometry.size) + guard bounds.contains(value.startLocation) else { return } + if pressedIndex != index { + pressedIndex = index + commandSender.sendPress(mapping.command, for: widget) + } + } + .onEnded { _ in + guard pressedIndex == index else { return } + pressedIndex = nil + commandSender.sendRelease(mapping.releaseCommand, for: widget) + } + ) } } - } } + } + } + + // MARK: - Shared Components + + @ViewBuilder + private var iconTitleRow: some View { + HStack { + WatchIconView(model: widget.iconRenderModel(), settings: settings) + WatchLabelText(text: viewModel.labelText) + Spacer() + } + } + + private var selectedIndexBinding: Binding { + Binding( + get: { viewModel.selectedIndex }, + set: { viewModel.selectedIndex = $0 } ) } - var body: some View { + // MARK: - Multi-Segment (existing NavigationLink) + + @ViewBuilder + private var multiSegmentContent: some View { HStack { HStack { - IconView(widget: widget, settings: settings) - TextLabelView(widget: widget, lineLimit: 1) + WatchIconView(model: widget.iconRenderModel(), settings: settings) + WatchLabelText(text: viewModel.labelText) Spacer() } - NavigationLink(destination: LazyView(SegmentSelectionView(widget: widget))) { + NavigationLink(destination: LazyView(SegmentSelectionView( + widget: widget, + stateToken: stateToken, + selectedIndex: selectedIndexBinding + ))) { HStack { - if let selectedIndex = widget.mappingsOrItemOptions.indices.first(where: { index in - guard case let .segmented(value) = widget.stateEnumBinding else { return false } - return value == index - }) { - Text(widget.mappingsOrItemOptions[selectedIndex].label) - .foregroundColor(.secondary) - .lineLimit(1) + if let currentIndex, currentIndex >= 0, currentIndex < viewModel.mappings.count { + Text(viewModel.mappings[currentIndex].label) + .foregroundStyle(.secondary) + .watchTextStyle(.secondary) } Image(systemSymbol: .chevronRight) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .font(.caption) } .padding(.horizontal, 8) @@ -70,13 +146,263 @@ struct SegmentRow: View { .buttonStyle(.plain) } } + + // MARK: - Single Mapping var + + @ViewBuilder + private var singleMappingContent: some View { + let mapping = viewModel.mappings[0] + HStack { + WatchIconView(model: widget.iconRenderModel(), settings: settings) + WatchLabelText(text: viewModel.labelText) + Spacer() + singleButton(for: mapping) + .layoutPriority(1) + } + } + + init(widget: OpenHABWidget) { + self.widget = widget + stateToken = widget.item?.state ?? widget.state + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } + + init(widget: OpenHABWidget, stateToken: String) { + self.widget = widget + self.stateToken = stateToken + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } + + // MARK: - Single Mapping func + + @ViewBuilder + private func singleButton(for mapping: OpenHABWidgetMapping) -> some View { + inlineButton(label: mapping.label, isPressed: singlePressed) + .overlay { + GeometryReader { geometry in + let bounds = CGRect(origin: .zero, size: geometry.size) + Color.clear + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + singlePressed = bounds.contains(value.location) + } + .onEnded { value in + if singlePressed, bounds.contains(value.location) { + commandSender.send(mapping.command, for: widget, policy: .immediate) + } + singlePressed = false + } + ) + } + } + } + + // MARK: - Shared Components + + @ViewBuilder + private func inlineButton(label: String, isPressed: Bool) -> some View { + Text(label) + .watchTextStyle(.control) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.gray.opacity(isPressed ? 0.6 : 0.3)) + ) + .contentShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityLabel(label) + .accessibilityAddTraits(.isButton) + } +} + +#Preview("Short Labels") { + PreviewNavigationContainer { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Light Switch", + mappings: [ + OpenHABWidgetMapping(command: "ON", label: "ON"), + OpenHABWidgetMapping(command: "OFF", label: "OFF") + ], + selectedState: "ON", + icon: "switch" + ) + ) + } + } +} + +#Preview("Long Labels") { + PreviewNavigationContainer { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Temperature Control", + mappings: [ + OpenHABWidgetMapping(command: "manual", label: "Manual"), + OpenHABWidgetMapping(command: "calendar", label: "Calendar"), + OpenHABWidgetMapping(command: "automatic", label: "Automatic") + ], + selectedState: "automatic", + icon: "temperature" + ) + ) + } + } +} + +#Preview("Multiple Segments (4)") { + PreviewNavigationContainer { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Fan Speed", + mappings: [ + OpenHABWidgetMapping(command: "0", label: "Off"), + OpenHABWidgetMapping(command: "1", label: "Low"), + OpenHABWidgetMapping(command: "2", label: "Med"), + OpenHABWidgetMapping(command: "3", label: "High") + ], + selectedState: "3", + icon: "fan" + ) + ) + } + } +} + +#Preview("PressRelease") { + PreviewNavigationContainer { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "All Shutters", + mappings: [ + OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF"), + OpenHABWidgetMapping(command: "UP", label: "UP", releaseCommand: "OFF") + ], + selectedState: "DOWN", + icon: "rollershutter" + ) + ) + } + } } -#Preview { - let widget = UserData(preview: true).widgets[4] - return Group { - SegmentRow(widget: widget) - SegmentRow(widget: widget) +#Preview("Single Mapping") { + PreviewNavigationContainer { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Scene", + mappings: [ + OpenHABWidgetMapping(command: "RUN", label: "Run") + ], + selectedState: "RUN", + icon: "scene" + ) + ) + } + } +} + +#Preview("All Scenarios") { + PreviewNavigationContainer { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Light", + mappings: [ + OpenHABWidgetMapping(command: "ON", label: "ON"), + OpenHABWidgetMapping(command: "OFF", label: "OFF") + ], + selectedState: "ON", + icon: "light" + ) + ) + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Climate Mode", + mappings: [ + OpenHABWidgetMapping(command: "m", label: "Manual"), + OpenHABWidgetMapping(command: "a", label: "Auto"), + OpenHABWidgetMapping(command: "s", label: "Schedule") + ], + selectedState: "a", + icon: "temperature" + ) + ) + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Fan Speed", + mappings: [ + OpenHABWidgetMapping(command: "0", label: "Off"), + OpenHABWidgetMapping(command: "1", label: "Low"), + OpenHABWidgetMapping(command: "2", label: "Med"), + OpenHABWidgetMapping(command: "3", label: "High") + ], + selectedState: "2", + icon: "fan" + ) + ) + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Charts Period", + mappings: [ + OpenHABWidgetMapping(command: "D", label: "Day"), + OpenHABWidgetMapping(command: "W", label: "Week"), + OpenHABWidgetMapping(command: "M", label: "M"), + OpenHABWidgetMapping(command: "4h", label: "4h") + ], + selectedState: "D", + icon: "chart" + ) + ) + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "All Shutters", + mappings: [ + OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF"), + OpenHABWidgetMapping(command: "UP", label: "UP", releaseCommand: "OFF") + ], + selectedState: "DOWN", + icon: "rollershutter" + ) + ) + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Scene", + mappings: [ + OpenHABWidgetMapping(command: "RUN", label: "RUN") + ], + selectedState: "RUN", + icon: "scene" + ) + ) + } + } +} + +#Preview("Interactive State Token") { + let widget = PreviewWidgetFactory.segmented( + label: "Climate Mode", + mappings: [ + OpenHABWidgetMapping(command: "manual", label: "Manual"), + OpenHABWidgetMapping(command: "auto", label: "Auto"), + OpenHABWidgetMapping(command: "schedule", label: "Schedule") + ], + selectedState: "auto", + icon: "temperature" + ) + PreviewNavigationContainer { + InteractiveStateTokenPreview( + widget: widget, + states: ["manual", "auto", "schedule"] + ) { targetWidget, stateToken in + SegmentRow(widget: targetWidget, stateToken: stateToken) + } } - .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index cba2bd75b..fd87588fe 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -10,76 +10,152 @@ // SPDX-License-Identifier: EPL-2.0 import OpenHABCore -import os.log import SwiftUI struct SegmentSelectionView: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget + let stateToken: String + @Binding var selectedIndex: Int? @Environment(\.dismiss) private var dismiss - @State private var pendingValue: String? + @State private var pressedIndex: Int? + @State private var viewModel: WidgetRowViewModel + @State private var commandSender = WidgetCommandDispatcher() var body: some View { ScrollView { LazyVStack(spacing: 12) { - ForEach(0 ..< widget.mappingsOrItemOptions.count, id: \.self) { index in - Button { - selectOption(at: index) - } label: { - HStack { - Text(widget.mappingsOrItemOptions[index].label) - .foregroundColor(.primary) - .multilineTextAlignment(.leading) - Spacer() - if isSelected(index: index) { - Image(systemSymbol: .checkmark) - .foregroundColor(.accentColor) - .font(.caption.weight(.bold)) - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: 8) - .fill(isSelected(index: index) ? Color.accentColor.opacity(0.2) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.secondary.opacity(0.3), lineWidth: 1) - ) + ForEach(0 ..< viewModel.mappings.count, id: \.self) { index in + let mapping = viewModel.mappings[index] + + if viewModel.hasPressReleaseMappings { + // Press-release button for mappings with releaseCommand + pressReleaseButton(for: mapping, at: index) + } else { + // Standard button for regular mappings + standardButton(for: mapping, at: index) } - .buttonStyle(PlainButtonStyle()) } } .padding() } - .navigationTitle("Select Option") + .navigationTitle(viewModel.labelText) .navigationBarTitleDisplayMode(.inline) + .onAppear { + selectedIndex = viewModel.selectedIndex + } + .onChange(of: stateToken, initial: false) { _, _ in + viewModel.update(from: widget) + selectedIndex = viewModel.selectedIndex + } + } + + init(widget: OpenHABWidget, stateToken: String, selectedIndex: Binding) { + self.widget = widget + self.stateToken = stateToken + _selectedIndex = selectedIndex + let viewModel = WidgetRowViewModel(widget: widget) + _viewModel = State(wrappedValue: viewModel) + } + + @ViewBuilder + private func standardButton(for mapping: OpenHABWidgetMapping, at index: Int) -> some View { + Button { + selectOption(at: index) + } label: { + optionLabel(for: mapping, at: index, isPressed: false) + } + .buttonStyle(PlainButtonStyle()) + } + + @ViewBuilder + private func pressReleaseButton(for mapping: OpenHABWidgetMapping, at index: Int) -> some View { + optionLabel(for: mapping, at: index, isPressed: pressedIndex == index) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if pressedIndex != index { + pressedIndex = index + commandSender.sendPress(mapping.command, for: widget) + } + } + .onEnded { _ in + pressedIndex = nil + commandSender.sendRelease(mapping.releaseCommand, for: widget) + } + ) + } + + @ViewBuilder + private func optionLabel(for mapping: OpenHABWidgetMapping, at index: Int, isPressed: Bool) -> some View { + HStack { + Text(mapping.label) + .foregroundStyle(.primary) + .watchTextStyle(.label) + Spacer() + if isSelected(index: index), !viewModel.hasPressReleaseMappings { + Image(systemSymbol: .checkmark) + .foregroundStyle(Color.accentColor) + .font(.caption.weight(.bold)) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .fill(backgroundColor(for: index, isPressed: isPressed)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + .accessibilityLabel(mapping.label) + .accessibilityAddTraits(.isButton) + } + + private func backgroundColor(for index: Int, isPressed: Bool) -> Color { + if isPressed { + Color.accentColor.opacity(0.4) + } else if isSelected(index: index) { + Color.accentColor.opacity(0.2) + } else { + Color.clear + } } private func isSelected(index: Int) -> Bool { - guard case let .segmented(value) = widget.stateEnumBinding else { return false } - return value == index + selectedIndex == index } private func selectOption(at index: Int) { - widget.stateEnumBinding = .segmented(index) - if let selectedCommand = widget.mappingsOrItemOptions[safe: index]?.command { - pendingValue = selectedCommand - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(300)) - if pendingValue == selectedCommand { - widget.sendCommand(selectedCommand) - pendingValue = nil - dismiss() - } - } + selectedIndex = index + if let selectedCommand = viewModel.mappings[safe: index]?.command { + commandSender.send( + selectedCommand, + for: widget, + policy: WidgetCommandDefaults.immediate, + key: "segment-selection" + ) + dismiss() } } } #Preview { - let widget = UserData(preview: true).widgets[4] - return NavigationStack { - SegmentSelectionView(widget: widget) + @Previewable @State var selectedIndex: Int? = 0 + let widget = PreviewWidgetFactory.segmented( + label: "Climate Mode", + mappings: [ + OpenHABWidgetMapping(command: "manual", label: "Manual"), + OpenHABWidgetMapping(command: "auto", label: "Auto"), + OpenHABWidgetMapping(command: "schedule", label: "Schedule") + ], + selectedState: "auto", + icon: "temperature" + ) + return PreviewNavigationContainer { + SegmentSelectionView( + widget: widget, + stateToken: widget.item?.state ?? widget.state, + selectedIndex: $selectedIndex + ) } - .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SelectionRow.swift b/openHABWatch/Views/Rows/SelectionRow.swift new file mode 100644 index 000000000..e1407fd7f --- /dev/null +++ b/openHABWatch/Views/Rows/SelectionRow.swift @@ -0,0 +1,171 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import SFSafeSymbols +import SwiftUI + +/// Selection list view for picking from available options +struct SelectionListView: View { + let widget: OpenHABWidget + let mappings: [OpenHABWidgetMapping] + let title: String + @Binding var selectedIndex: Int? + @Environment(\.dismiss) private var dismiss + @State private var commandSender = WidgetCommandDispatcher() + + var body: some View { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(0 ..< mappings.count, id: \.self) { index in + let mapping = mappings[index] + Button { + selectOption(at: index) + } label: { + HStack { + Text(mapping.label) + .foregroundStyle(.primary) + .watchTextStyle(.label) + Spacer() + if selectedIndex == index { + Image(systemSymbol: .checkmark) + .foregroundStyle(Color.accentColor) + .font(.caption.weight(.bold)) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .fill(selectedIndex == index ? Color.accentColor.opacity(0.2) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding() + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } + + private func selectOption(at index: Int) { + selectedIndex = index + if let selectedCommand = mappings[safe: index]?.command { + commandSender.send( + selectedCommand, + for: widget, + policy: .immediate, + key: "selection-list" + ) + dismiss() + } + } +} + +struct SelectionRow: View { + let widget: OpenHABWidget + let mappings: [OpenHABWidgetMapping] + let title: String + let initialSelectedIndex: Int? + let labelValue: String? + @EnvironmentObject var settings: AppSettings + @State private var selectedIndex: Int? + + /// Returns the label of the currently selected mapping + private var selectedValueText: String? { + if let index = selectedIndex, + index >= 0, + index < mappings.count { + return mappings[index].label + } + return labelValue + } + + private var selectedIndexBinding: Binding { + Binding( + get: { selectedIndex }, + set: { selectedIndex = $0 } + ) + } + + var body: some View { + HStack { + HStack { + WatchIconView(model: widget.iconRenderModel(), settings: settings) + WatchLabelText(text: title) + Spacer() + } + NavigationLink(destination: LazyView(SelectionListView( + widget: widget, + mappings: mappings, + title: title, + selectedIndex: selectedIndexBinding + ))) { + HStack(spacing: 4) { + if let valueText = selectedValueText { + Text(valueText) + .foregroundStyle(.secondary) + .watchTextStyle(.secondary) + } + Image(systemSymbol: .chevronUpChevronDown) + .foregroundStyle(.secondary) + .font(.caption2) + } + } + .buttonStyle(.plain) + } + .onAppear { + selectedIndex = initialSelectedIndex + } + .onChange(of: initialSelectedIndex) { _, newValue in + selectedIndex = newValue + } + } +} + +#Preview { + let widget = PreviewWidgetFactory.selection( + label: "Mode", + options: [("auto", "Auto"), ("manual", "Manual"), ("away", "Away")], + selectedIndex: 1 + ) + PreviewNavigationContainer { + SelectionRow( + widget: widget, + mappings: widget.mappingsOrItemOptions, + title: widget.labelText ?? "Select", + initialSelectedIndex: widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) }, + labelValue: widget.labelValue + ) + } +} + +#Preview("Selection List") { + @Previewable @State var selectedIndex: Int? = 0 + let widget = PreviewWidgetFactory.selection( + label: "Mode", + options: [("auto", "Auto"), ("manual", "Manual"), ("away", "Away")], + selectedIndex: 0 + ) + PreviewNavigationContainer { + SelectionListView( + widget: widget, + mappings: widget.mappingsOrItemOptions, + title: widget.labelText ?? "Select", + selectedIndex: $selectedIndex + ) + } +} diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index f653dadfd..e08e4d02c 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -9,81 +9,148 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SFSafeSymbols import SwiftUI struct SetpointRow: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget + let stateToken: String @EnvironmentObject var settings: AppSettings + private let setpointService = SetPointService() + private let logger = Logger(subsystem: "org.openhab.watch", category: "SetpointRow") + @State private var viewModel: WidgetRowViewModel + @State private var localValue: Double? + @State private var commandSender = WidgetCommandDispatcher() - private var isIntStep: Bool { - widget.step.truncatingRemainder(dividingBy: 1) == 0 + private var currentValue: Double { + localValue ?? viewModel.numberState?.value ?? viewModel.minValue } - private var stateFormat: String { - isIntStep ? "%ld" : "%.01f" + private var valueText: String { + formattedValue( + for: currentValue, + locale: Locale.current + ) } var body: some View { VStack(spacing: 5) { HStack { - IconView(widget: widget, settings: settings) - TextLabelView(widget: widget) + WatchIconView(model: widget.iconRenderModel(), settings: settings) + Text(viewModel.labelText) + .watchTextStyle(.label) Spacer() } HStack { Spacer() - IconWithAction( - systemSymbol: .chevronDownCircleFill, - action: decreaseValue - ) + Button(action: decreaseValue) { + Image(systemSymbol: .chevronDownCircleFill) + .font(.system(size: 25)) + .foregroundStyle(currentValue <= viewModel.minValue ? Color.gray : Color.blue) + } + .buttonStyle(.plain) + .disabled(currentValue <= viewModel.minValue) + .accessibilityLabel("Decrease \(viewModel.labelText)") + .accessibilityHint("Lowers by \(viewModel.step.valueText(step: viewModel.step))") Spacer() - DetailTextLabelView(widget: widget) - .font(.headline) + Text(valueText) + .watchTextStyle(.emphasis) + .accessibilityLabel("\(viewModel.labelText) value") + .accessibilityValue(valueText) Spacer() - IconWithAction( - systemSymbol: .chevronUpCircleFill, - action: increaseValue - ) + Button(action: increaseValue) { + Image(systemSymbol: .chevronUpCircleFill) + .font(.system(size: 25)) + .foregroundStyle(currentValue >= viewModel.maxValue ? Color.gray : Color.blue) + } + .buttonStyle(.plain) + .disabled(currentValue >= viewModel.maxValue) + .accessibilityLabel("Increase \(viewModel.labelText)") + .accessibilityHint("Raises by \(viewModel.step.valueText(step: viewModel.step))") Spacer() } } + .onChange(of: stateToken, initial: false) { _, _ in + viewModel.update(from: widget) + localValue = nil + } } - private func handleUpDown(down: Bool) { - var numberState = widget.stateValueAsNumberState - let stateValue = numberState?.value ?? widget.minValue - let newValue: Double = switch down { - case true: - stateValue - widget.step - case false: - stateValue + widget.step - } - if newValue >= widget.minValue, newValue <= widget.maxValue { - numberState?.value = newValue - widget.sendItemUpdate(state: numberState) + init(widget: OpenHABWidget, stateToken: String) { + self.widget = widget + self.stateToken = stateToken + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } + + private func handleUpDown(isDecreasing: Bool) { + let limitedNewValue = setpointService.calculateNewValue( + currentValue: currentValue, + step: viewModel.step, + minValue: viewModel.minValue, + maxValue: viewModel.maxValue, + isDecreasing: isDecreasing + ) + + guard limitedNewValue != currentValue else { + // nothing to update, skip sending value + return } + + localValue = limitedNewValue + let numberState = NumberState( + value: limitedNewValue, + unit: widget.unit, + format: widget.item?.stateDescription?.numberPattern + ) + + logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(numberState.description)") + commandSender.sendItemUpdate(numberState, for: widget) } func decreaseValue() { - handleUpDown(down: true) + handleUpDown(isDecreasing: true) } func increaseValue() { - handleUpDown(down: false) + handleUpDown(isDecreasing: false) + } + + private func formattedValue(for value: Double, locale: Locale) -> String { + if let numberPattern = widget.item?.stateDescription?.numberPattern, + !numberPattern.isEmpty { + return NumberState( + value: value, + unit: widget.unit, + format: numberPattern + ).toString(locale: locale) + } + let fallback = value.valueText(step: viewModel.step) + if let unit = widget.unit, !unit.isEmpty { + return "\(fallback) \(unit)" + } + return fallback } } #Preview { - let widget = UserData(preview: true).widgets[3] - SetpointRow(widget: widget) - .environmentObject(AppSettings()) + let widget = PreviewWidgetFactory.setpoint( + label: "Temperature", + value: 21, + minValue: 16, + maxValue: 28, + step: 0.5, + unit: "°C" + ) + PreviewNavigationContainer { + SetpointRow(widget: widget, stateToken: widget.item?.state ?? widget.state) + } } diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 7a6304c80..537928450 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -9,28 +9,67 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log +import SFSafeSymbols import SwiftUI struct SliderRow: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget + let stateToken: String @EnvironmentObject var settings: AppSettings + var fallbackSymbol: SFSymbol? @State private var pendingValue: Double? + @State private var viewModel: WidgetRowViewModel + @State private var commandSender = WidgetCommandDispatcher() + + private var currentValue: Double { + pendingValue ?? viewModel.adjustedValue + } + + private var currentValueText: String { + formattedValue(for: currentValue, locale: Locale.current) + } + var valueBinding: Binding { .init( get: { - pendingValue ?? widget.adjustedValue + pendingValue ?? viewModel.adjustedValue }, set: { newValue in Logger.rowViews.info("SliderRow new value = \(newValue)") pendingValue = newValue - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(500)) - if pendingValue == newValue { // Ensure no new updates came in - widget.sendCommand(newValue.valueText(step: widget.step)) - pendingValue = nil - } + commandSender.send( + formattedValue(for: newValue, locale: Locale(identifier: "US")), + for: widget, + policy: WidgetCommandDefaults.slider, + key: "slider-value" + ) + } + ) + } + + private var stateBinding: Binding { + Binding( + get: { + viewModel.adjustedValue > viewModel.minValue + }, + set: { newValue in + if newValue { + commandSender.send( + formattedValue(for: viewModel.maxValue, locale: Locale(identifier: "US")), + for: widget, + policy: .immediate, + key: "slider-toggle" + ) + } else { + commandSender.send( + formattedValue(for: viewModel.minValue, locale: Locale(identifier: "US")), + for: widget, + policy: .immediate, + key: "slider-toggle" + ) } } ) @@ -38,24 +77,157 @@ struct SliderRow: View { var body: some View { VStack(spacing: 3) { - HStack { - IconView(widget: widget, settings: settings) - TextLabelView(widget: widget) - Spacer() - DetailTextLabelView(widget: widget) - }.padding(.top, 8) - - Slider(value: valueBinding, in: widget.minValue ... widget.maxValue, step: widget.step) + if viewModel.switchSupport { + Toggle(isOn: stateBinding) { + HStack { + WatchIconView(model: widget.iconRenderModel(fallbackSymbol: fallbackSymbol), settings: settings) + VStack(alignment: .leading) { + WatchLabelText(text: viewModel.labelText) + if pendingValue != nil { + Text(currentValueText) + .watchTextStyle(.secondary) + .foregroundStyle(.secondary) + } else { + DetailTextLabelView(text: viewModel.labelValue, valueColor: widget.valuecolor) + } + } + } + } + .padding(.trailing) + .cornerRadius(5) + } else { + HStack { + WatchIconView(model: widget.iconRenderModel(fallbackSymbol: fallbackSymbol), settings: settings) + WatchLabelText(text: viewModel.labelText) + Spacer() + if pendingValue != nil { + Text(currentValueText) + .watchTextStyle(.secondary) + .foregroundStyle(.secondary) + } else { + DetailTextLabelView(text: viewModel.labelValue, valueColor: widget.valuecolor) + } + }.padding(.top, 8) + } + + Slider(value: valueBinding, in: viewModel.minValue ... viewModel.maxValue, step: viewModel.step) .labelsHidden() + .accessibilityLabel(viewModel.labelText) + .accessibilityValue(currentValueText) + } + .accessibilityElement(children: .contain) + .onChange(of: stateToken, initial: false) { _, _ in + viewModel.update(from: widget) + pendingValue = nil + } + } + + init(widget: OpenHABWidget, stateToken: String, fallbackSymbol: SFSymbol? = nil) { + self.widget = widget + self.stateToken = stateToken + self.fallbackSymbol = fallbackSymbol + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } + + private func formattedValue(for value: Double, locale: Locale) -> String { + if let numberPattern = widget.item?.stateDescription?.numberPattern, + !numberPattern.isEmpty { + return NumberState( + value: value, + unit: widget.unit, + format: numberPattern + ).toString(locale: locale) } + return value.valueText(step: viewModel.step) } } -#Preview { - let widget = UserData(preview: true).widgets[3] - return Group { - SliderRow(widget: widget) - SliderRow(widget: widget) +// MARK: - Previews + +#Preview("Default Range") { + PreviewNavigationContainer { + SliderRow( + widget: PreviewWidgetFactory.slider( + label: "Brightness", + value: 75 + ), + stateToken: "75", + fallbackSymbol: .sliderHorizontal3 + ) + } +} + +#Preview("Custom Range (minValue)") { + PreviewNavigationContainer { + SliderRow( + widget: PreviewWidgetFactory.slider( + label: "Temperature", + value: 16, + minValue: 16, + maxValue: 28, + step: 0.5 + ), + stateToken: "16", + fallbackSymbol: .thermometerMedium + ) + } +} + +#Preview("With Switch Support") { + PreviewNavigationContainer { + SliderRow( + widget: PreviewWidgetFactory.slider( + label: "Dimmer", + value: 50, + switchSupport: true + ), + stateToken: "50", + fallbackSymbol: .lightbulbFill + ) + } +} + +#Preview("All Scenarios") { + PreviewNavigationContainer { + List { + SliderRow( + widget: PreviewWidgetFactory.slider( + label: "Brightness", + value: 75 + ), + stateToken: "75", + fallbackSymbol: .sliderHorizontal3 + ) + SliderRow( + widget: PreviewWidgetFactory.slider( + label: "Temperature", + value: 21, + minValue: 16, + maxValue: 28, + step: 0.5 + ), + stateToken: "21", + fallbackSymbol: .thermometerMedium + ) + SliderRow( + widget: PreviewWidgetFactory.slider( + label: "Dimmer", + value: 50, + switchSupport: true + ), + stateToken: "50", + fallbackSymbol: .lightbulbFill + ) + } + } +} + +#Preview("From UserData") { + PreviewNavigationContainer { + SliderRow( + widget: UserData(preview: true).widgets[3], + stateToken: "0", + fallbackSymbol: .sliderHorizontal3 + ) } - .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift deleted file mode 100644 index 61ac7a5d3..000000000 --- a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import os.log -import SwiftUI - -struct SliderWithSwitchSupportRow: View { - @ObservedObject var widget: OpenHABWidget - @EnvironmentObject var settings: AppSettings - @State private var pendingValue: Double? - - var body: some View { - let valueBinding = Binding( - get: { - pendingValue ?? widget.adjustedValue - }, - set: { newValue in - Logger.rowViews.info("SliderWithSwitchSupportRow new value = \(newValue)") - pendingValue = newValue - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(500)) - if pendingValue == newValue { // Ensure no new updates came in - widget.sendCommand(newValue.valueText(step: widget.step)) - pendingValue = nil - } - } - } - ) - - let stateBinding = Binding( - get: { - if widget.adjustedValue > widget.minValue { - true - } else { - false - } - }, - set: { - if $0 { - Logger.rowViews.info("Switch to ON") - widget.sendCommand(widget.maxValue.valueText(step: widget.step)) - } else { - Logger.rowViews.info("Switch to OFF") - widget.sendCommand(widget.minValue.valueText(step: widget.step)) - } - } - ) - - return - VStack(spacing: 3) { - Toggle(isOn: stateBinding) { - HStack { - IconView(widget: widget, settings: settings) - VStack { - TextLabelView(widget: widget) - DetailTextLabelView(widget: widget) - } - } - } - .padding(.trailing) - .cornerRadius(5) - - Slider(value: valueBinding, in: widget.minValue ... widget.maxValue, step: widget.step) - .labelsHidden() - } - } -} - -#Preview { - let widget = UserData(preview: true).widgets[3] - return Group { - SliderRow(widget: widget) - SliderRow(widget: widget) - } - .environmentObject(AppSettings()) -} diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index ee30fac09..2158159d1 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -9,59 +9,66 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore -import os.log import SwiftUI struct SwitchRow: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget @EnvironmentObject var settings: AppSettings + let stateToken: String + @State private var localIsOn: Bool? + @State private var commandSender = WidgetCommandDispatcher() - // https://stackoverflow.com/questions/59395501/do-something-when-toggle-state-changes - var stateBinding: Binding { - .init( - get: { widget.stateEnumBinding.boolState }, - set: { - if $0 { - Logger.rowViews.info("Switch to ON") - widget.sendCommand("ON") - } else { - Logger.rowViews.info("Switch to OFF") - widget.sendCommand("OFF") - } - widget.stateEnumBinding = .switcher($0) - } - ) + private var isOn: Bool { + localIsOn ?? stateToken.parseAsBool() } var body: some View { - Toggle(isOn: stateBinding) { + Toggle(isOn: Binding( + get: { isOn }, + set: { newValue in + localIsOn = newValue + if newValue { + commandSender.send("ON", for: widget, policy: .immediate) + } else { + commandSender.send("OFF", for: widget, policy: .immediate) + } + } + )) { HStack { - IconView(widget: widget, settings: settings) + WatchIconView(model: widget.iconRenderModel(), settings: settings) VStack { - TextLabelView(widget: widget, lineLimit: 1) - DetailTextLabelView(widget: widget) + WatchLabelText(text: widget.labelText ?? widget.label) + DetailTextLabelView(text: widget.labelValue, valueColor: widget.valuecolor) } } } .padding(.trailing) .cornerRadius(5) + .accessibilityValue(isOn ? "On" : "Off") + .onChange(of: stateToken) { + localIsOn = nil + } } } #Preview { - let widget = UserData(preview: true).widgets[2] - SwitchRow(widget: widget) - .environmentObject(AppSettings()) + let widget = PreviewWidgetFactory.switchWidget(label: "Outdoor Light", state: "ON") + PreviewNavigationContainer { + SwitchRow(widget: widget, stateToken: widget.item?.state ?? "OFF") + } } #Preview { - let widget = UserData(preview: true).widgets[2] + let widget = PreviewWidgetFactory.switchWidget(label: "Outdoor Light", state: "OFF") let mockSettings = { let obj = AppSettings() obj.openHABRootUrl = PreviewConstants.remoteURLString return obj }() - SwitchRow(widget: widget) - .environmentObject(mockSettings) + NavigationStack { + SwitchRow(widget: widget, stateToken: widget.item?.state ?? "OFF") + } + .environmentObject(mockSettings) } diff --git a/openHABWatch/Views/Rows/TextRow.swift b/openHABWatch/Views/Rows/TextRow.swift new file mode 100644 index 000000000..860b5f05b --- /dev/null +++ b/openHABWatch/Views/Rows/TextRow.swift @@ -0,0 +1,42 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import CommonUI +import OpenHABCore +import SFSafeSymbols +import SwiftUI + +struct TextRow: View { + let widget: OpenHABWidget + @EnvironmentObject var settings: AppSettings + let hasLinkedPage: Bool + + var body: some View { + HStack { + WatchIconView(model: widget.iconRenderModel(), settings: settings) + WatchLabelText(text: widget.labelText ?? widget.label) + Spacer() + DetailTextLabelView(text: widget.labelValue, valueColor: widget.valuecolor) + if hasLinkedPage { + Image(systemSymbol: .chevronRight) + .foregroundStyle(.secondary) + .font(.caption) + } + } + } +} + +#Preview { + let widget = PreviewWidgetFactory.text(label: "Energy Usage", valueText: "450 W") + PreviewNavigationContainer { + TextRow(widget: widget, hasLinkedPage: false) + } +} diff --git a/openHABWatch/Views/SitemapPageView.swift b/openHABWatch/Views/SitemapPageView.swift index 9516786df..a9ff7cfcf 100644 --- a/openHABWatch/Views/SitemapPageView.swift +++ b/openHABWatch/Views/SitemapPageView.swift @@ -21,57 +21,11 @@ struct WidgetRowView: View { var body: some View { if let linkedPage = widget.linkedPage { NavigationLink(value: linkedPage) { - rowWidget(widget: widget) + WidgetRowFactory.make(widget: widget, settings: settings) } .buttonStyle(.plain) } else { - rowWidget(widget: widget) - } - } - - @ViewBuilder func rowWidget(widget: OpenHABWidget) -> some View { - switch widget.stateEnum { - case .switcher: - SwitchRow(widget: widget) - case .slider: - if widget.switchSupport { - SliderRow(widget: widget) - } else { - SliderWithSwitchSupportRow(widget: widget) - } - case .segmented: - SegmentRow(widget: widget) - case .rollershutter: - RollershutterRow(widget: widget) - case .setpoint: - SetpointRow(widget: widget) - case .frame: - FrameRow(widget: widget) - case .image: - // Encoded image - if widget.item != nil { - ImageRawRow(widget: widget) - } else { - EquatableView(content: ImageRow(url: URL(string: widget.url), refresh: widget.refresh)) - } - case .chart: - let url = Endpoint.chart( - rootUrl: settings.openHABRootUrl, - period: widget.period, - type: widget.item?.type ?? .none, - service: widget.service, - name: widget.item?.name, - legend: widget.legend, - theme: .dark, - forceAsItem: widget.forceAsItem - ).url - EquatableView(content: ImageRow(url: url, refresh: widget.refresh)) - case .mapview: - MapViewRow(widget: widget) - case .colorpicker: - ColorPickerRow(widget: widget) - default: - GenericRow(widget: widget) + WidgetRowFactory.make(widget: widget, settings: settings) } } } @@ -124,7 +78,7 @@ struct SitemapPageView: View { .progressViewStyle(CircularProgressViewStyle(tint: .secondary)) .scaleEffect(0.7) Text("Updating...") - .font(.caption2) + .watchTextStyle(.secondary) .foregroundStyle(.secondary) Spacer() } @@ -139,7 +93,7 @@ struct SitemapPageView: View { VStack { Spacer() Text("No widgets available.") - .font(.footnote) + .watchTextStyle(.detail) .foregroundStyle(.secondary) Spacer() } diff --git a/openHABWatch/Views/Utils/ColorSelection.swift b/openHABWatch/Views/Utils/ColorSelection.swift index d5c6cb97f..bc98b615d 100644 --- a/openHABWatch/Views/Utils/ColorSelection.swift +++ b/openHABWatch/Views/Utils/ColorSelection.swift @@ -50,22 +50,49 @@ enum DragState { } struct ColorSelection: View { + @ObservedObject var widget: OpenHABWidget @GestureState var thumb: DragState = .inactive - @State var hue = 0.5 - @State var xpos: Double = 100 - @State var ypos: Double = 100 + @State private var hue = 0.0 + @State private var saturation = 1.0 + @State private var brightness = 1.0 + @State private var xpos: Double = 100 + @State private var ypos: Double = 100 + @State private var dragStart: CGPoint? + @State private var commandSender = WidgetCommandDispatcher() + + private let handleRadius = 12.5 var body: some View { - let spectrum = Gradient(colors: [.red, .yellow, .green, .blue, .purple, .red]) + // Use a clockwise spectrum to match the handle's hue direction. + let spectrum = Gradient(colors: [.red, .purple, .blue, .green, .yellow, .red]) - let conic = AngularGradient(gradient: spectrum, center: .center, angle: .degrees(0)) + // Rotate so hue = 0 aligns with top (matching the handle math). + let conic = AngularGradient(gradient: spectrum, center: .center, angle: .degrees(-90)) return GeometryReader { (geometry: GeometryProxy) in - Circle() - .size(geometry.size) - .fill(conic) - .overlay(generateHandle(geometry: geometry)) + ZStack(alignment: .topLeading) { + Circle() + .size(geometry.size) + .fill(conic) + .overlay(generateHandle(geometry: geometry)) + + Circle() + .fill(Color(hue: hue, saturation: saturation, brightness: brightness)) + .frame(width: 16, height: 16) + .overlay( + Circle() + .stroke(Color.white.opacity(0.6), lineWidth: 1) + ) + .padding(6) + } + .onAppear { + initializeFromState(geometry.size) + } + .onChange(of: widget.item?.state ?? "") { _, newState in + guard !newState.isEmpty else { return } + updateFromState(newState, geometry.size) + } } } @@ -75,38 +102,52 @@ struct ColorSelection: View { } /// Prevent the draggable element from going beyond the circle - func limitCircle(_ point: CGPoint, _ geometry: CGSize, _ state: CGSize) -> (CGPoint) { - let x1 = point.x + state.width - geometry.width / 2 - let y1 = point.y + state.height - geometry.height / 2 + func limitCircle(_ point: CGPoint, _ geometry: CGSize) -> CGPoint { + let center = CGPoint(x: geometry.width / 2, y: geometry.height / 2) + let x1 = Double(point.x - center.x) + let y1 = Double(point.y - center.y) let theta = atan2(x1, y1) // Circle limit.width = limit.height - let radius = min(sqrt(x1 * x1 + y1 * y1), geometry.width / 2) - return CGPoint(x: sin(theta) * radius + geometry.width / 2, y: cos(theta) * radius + geometry.width / 2) + let maxRadius = max(0.0, Double(min(geometry.width, geometry.height) / 2)) + let radius = min(sqrt(x1 * x1 + y1 * y1), maxRadius) + return CGPoint( + x: CGFloat(sin(theta) * radius) + center.x, + y: CGFloat(cos(theta) * radius) + center.y + ) } /// Creates the `Handle` and adds the drag gesture to it. func generateHandle(geometry: GeometryProxy) -> some View { - /// [Reference]: https://developer.apple.com/documentation/swiftui/gestures/composing_swiftui_gestures "Composing SwiftUI Gestures " - let longPressDrag = LongPressGesture(minimumDuration: 0.05) - .sequenced(before: DragGesture()) + let drag = DragGesture(minimumDistance: 0) .updating($thumb) { value, state, _ in - switch value { - // Long press begins. - case .first(true): - state = .pressing - // Long press confirmed, dragging may begin. - case .second(true, let drag): - state = .dragging(translation: drag?.translation ?? .zero) - // Dragging ended or the long press cancelled. - default: - state = .inactive + state = .dragging(translation: value.translation) + } + .onChanged { value in + let start = dragStart ?? CGPoint(x: xpos, y: ypos) + if dragStart == nil { + dragStart = start } + let proposed = CGPoint( + x: start.x + value.translation.width, + y: start.y + value.translation.height + ) + let limited = limitCircle(proposed, geometry.size) + xpos = Double(limited.x) + ypos = Double(limited.y) + updateColorFromPosition(in: geometry.size, send: true) } .onEnded { value in - guard case .second(true, let drag?) = value else { return } - Logger.rowViews.info("Translation x y = \(drag.translation.width), \(drag.translation.height)") - xpos += Double(drag.translation.width) - ypos += Double(drag.translation.height) + Logger.rowViews.info("Translation x y = \(value.translation.width), \(value.translation.height)") + let start = dragStart ?? CGPoint(x: xpos, y: ypos) + let proposed = CGPoint( + x: start.x + value.translation.width, + y: start.y + value.translation.height + ) + let limited = limitCircle(proposed, geometry.size) + xpos = Double(limited.x) + ypos = Double(limited.y) + dragStart = nil + updateColorFromPosition(in: geometry.size, send: true, force: true) } // MARK: Customize Handle Here @@ -114,14 +155,79 @@ struct ColorSelection: View { // Add the gestures and visuals to the handle return Circle() .overlay(thumb.isDragging ? Circle().stroke(Color.white, lineWidth: 2) : nil) - .foregroundColor(.white) + .foregroundStyle(.white) .frame(width: 25, height: 25, alignment: .center) - .position(limitCircle(CGPoint(x: xpos, y: ypos), geometry.size, thumb.translation)) + .position(limitCircle(CGPoint(x: xpos, y: ypos), geometry.size)) .animation(.interactiveSpring(), value: thumb.isDragging) - .gesture(longPressDrag) + .gesture(drag) + } + + private func initializeFromState(_ size: CGSize) { + if let state = widget.item?.state, !state.isEmpty { + updateFromState(state, size) + } else { + updateHandlePosition(in: size) + } + } + + private func updateFromState(_ state: String, _ size: CGSize) { + let components = state.split(separator: ",") + guard components.count >= 3, + let hueValue = Double(components[0]), + let saturationValue = Double(components[1]), + let brightnessValue = Double(components[2]) else { + return + } + hue = hueValue / 360.0 + saturation = saturationValue / 100.0 + brightness = brightnessValue / 100.0 + updateHandlePosition(in: size) + } + + private func updateHandlePosition(in size: CGSize) { + let radius = min(size.width, size.height) / 2 + let usableRadius = max(0.0, Double(radius)) + // Reverse the 180° offset when placing the handle. + let angle = (hue - 0.5) * 2.0 * Double.pi + let dist = max(0.0, min(1.0, saturation)) * usableRadius + let center = CGPoint(x: size.width / 2, y: size.height / 2) + // Match limitCircle's angle convention (x = sin, y = cos). + xpos = Double(center.x) + sin(angle) * dist + ypos = Double(center.y) + cos(angle) * dist + } + + private func updateColorFromPosition(in size: CGSize, send: Bool, force: Bool = false) { + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let dx = Double(xpos) - Double(center.x) + let dy = Double(ypos) - Double(center.y) + // Match limitCircle's angle convention (atan2(x, y)). + let angle = atan2(dx, dy) + let normalizedAngle = angle >= 0 ? angle : (2.0 * Double.pi + angle) + hue = normalizedAngle / (2.0 * Double.pi) + // Align with wheel orientation (current visual mapping is 180° offset). + hue = (hue + 0.5).truncatingRemainder(dividingBy: 1.0) + let radius = sqrt(dx * dx + dy * dy) + let maxRadius = max(1.0, Double(min(size.width, size.height) / 2)) + saturation = max(0.0, min(1.0, radius / maxRadius)) + if send { + sendColorCommand(force: force) + } + } + + private func sendColorCommand(force: Bool) { + let hueValue = Int(hue * 360.0) + let saturationValue = Int(saturation * 100.0) + let brightnessValue = Int(brightness * 100.0) + let command = "\(hueValue),\(saturationValue),\(brightnessValue)" + if force { + commandSender.cancelPending(for: widget, key: "color-wheel") + commandSender.send(command, for: widget, policy: .immediate, key: "color-wheel-final") + return + } + commandSender.send(command, for: widget, policy: WidgetCommandDefaults.colorPicker, key: "color-wheel") } } #Preview { - ColorSelection() + ColorSelection(widget: UserData(preview: true).widgets[10]) } diff --git a/openHABWatch/Views/Utils/DetailTextLabelView.swift b/openHABWatch/Views/Utils/DetailTextLabelView.swift index 6ad3aa0b7..08a3eb52b 100644 --- a/openHABWatch/Views/Utils/DetailTextLabelView.swift +++ b/openHABWatch/Views/Utils/DetailTextLabelView.swift @@ -9,23 +9,27 @@ // // SPDX-License-Identifier: EPL-2.0 -import OpenHABCore +import CommonUI import SwiftUI struct DetailTextLabelView: View { - @ObservedObject var widget: OpenHABWidget + let text: String? + let valueColor: String var body: some View { - if let label = widget.labelValue { - Text(label) - .font(.footnote) - .lineLimit(1) - .foregroundColor(!widget.valuecolor.isEmpty ? Color(fromString: widget.valuecolor) : .secondary) + if let text { + Text(text) + .watchTextStyle(.detail) + .foregroundStyle(!valueColor.isEmpty ? Color(fromString: valueColor) : .secondary) } } + + init(text: String?, valueColor: String = "") { + self.text = text + self.valueColor = valueColor + } } #Preview { - let widget = UserData(preview: true).widgets[2] - DetailTextLabelView(widget: widget) + DetailTextLabelView(text: "450 W", valueColor: "#00AEEF") } diff --git a/openHABWatch/Views/Utils/IconView.swift b/openHABWatch/Views/Utils/IconView.swift deleted file mode 100644 index f88d4323b..000000000 --- a/openHABWatch/Views/Utils/IconView.swift +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Kingfisher -import OpenHABCore -import os.log -import SFSafeSymbols -import SwiftUI - -struct IconView: View { - @ObservedObject var widget: OpenHABWidget - @ObservedObject var settings = AppSettings.shared - - var iconColor: String { - let logicColor = !(widget.iconColor.isEmpty) ? UIColor(fromString: widget.iconColor) : .ohBlack - return logicColor.semanticColorToHex() ?? "#FFFFFF" - } - - var iconURL: URL? { - // Skip loading number icons as they don't exist/aren't useful - if widget.icon == "number" { - return nil - } - - return Endpoint.icon( - rootUrl: settings.openHABRootUrl, - version: settings.openHABVersion, - icon: widget.icon, - state: widget.iconState(), - iconType: settings.iconType, - iconColor: iconColor, - staticIcon: widget.staticIcon - )?.url - } - - var body: some View { - if let iconURL { - // Only apply color preprocessing for non-iconify icons - let processorIconColor = iconURL.host == "api.iconify.design" ? nil : iconColor - KFImage.url(iconURL) - .onFailure { _ in - Logger.rowViews.debug("Failed to load image : \(iconURL.absoluteString)") - } - .onFailureView { - Rectangle() - .foregroundStyle(.background) - } - .setProcessor(OpenHABImageProcessor(iconColor: processorIconColor)) - .cacheOriginalImage(false) - .loadTransition(.opacity, animation: .easeInOut(duration: 0.25)) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 20, height: 20) - .id("\(iconURL.absoluteString)-\(widget.item?.state ?? "")-\(widget.iconColor)") - } else { - Rectangle() - .foregroundStyle(.background) - .frame(width: 20, height: 20) - } - } -} - -#Preview { - let testURL = URL(string: "https://picsum.photos/20")! - KFImage(testURL) - .resizable() - .frame(width: 20, height: 20) - - // Set localTestingURL to your local openHAB server for preview testing - let localTestingURL = "http://192.168.2.10:8080" - - let endpoint = Endpoint.icon(rootUrl: localTestingURL, version: 4, icon: "switch", state: "2", iconType: .png, iconColor: "blue") - KFImage(endpoint?.url) - .setProcessor(OpenHABImageProcessor(iconColor: endpoint?.url?.host == "api.iconify.design" ? nil : "blue")) - .resizable() - .frame(width: 20, height: 20) - - let settings = AppSettings(debug: true, openHABRootUrl: localTestingURL) - let widget = UserData(preview: true).widgets[4] - IconView( - widget: widget, - settings: settings - ) - - let endpoint2 = Endpoint.icon(rootUrl: localTestingURL, version: 3, icon: "f7:alarm", state: "", iconType: .svg, iconColor: "yellow") - KFImage(endpoint2?.url) - .setProcessor(OpenHABImageProcessor(iconColor: endpoint2?.url?.host == "api.iconify.design" ? nil : "yellow")) - .resizable() - .frame(width: 20, height: 20) - - let widget2 = UserData(preview: true).widgets[11] - IconView( - widget: widget2, - settings: settings - ) -} diff --git a/openHABWatch/Views/Utils/IconWithAction.swift b/openHABWatch/Views/Utils/IconWithAction.swift index 6444a1582..0cdb16834 100644 --- a/openHABWatch/Views/Utils/IconWithAction.swift +++ b/openHABWatch/Views/Utils/IconWithAction.swift @@ -14,19 +14,23 @@ import SwiftUI struct IconWithAction: View { var systemSymbol: SFSymbol + var accessibilityLabel: String var action: () -> Void + var body: some View { - Image(systemSymbol: systemSymbol) - .font(.system(size: 25)) - .colorMultiply(.blue) - .saturation(0.8) - .onTapGesture { - action() - } + Button(action: action) { + Image(systemSymbol: systemSymbol) + .font(.system(size: 25)) + .colorMultiply(.blue) + .saturation(0.8) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(.isButton) } } #Preview { - IconWithAction(systemSymbol: - .chevronUpCircleFill) {} + IconWithAction(systemSymbol: .chevronUpCircleFill, accessibilityLabel: "Increase") {} } diff --git a/openHABWatch/Views/Utils/WatchIconView.swift b/openHABWatch/Views/Utils/WatchIconView.swift new file mode 100644 index 000000000..fa74e5838 --- /dev/null +++ b/openHABWatch/Views/Utils/WatchIconView.swift @@ -0,0 +1,120 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Kingfisher +import OpenHABCore +import os.log +import SFSafeSymbols +import SwiftUI + +struct IconRenderModel: Equatable { + var icon: String + var iconState: String? + var iconColorHex: String + var staticIcon: Bool? + var stateToken: String + var fallbackSymbol: SFSymbol? +} + +struct WatchIconView: View { + let model: IconRenderModel + @ObservedObject var settings = AppSettings.shared + @ObservedObject private var networkTracker = MainActorNetworkTracker.shared + + var iconURL: URL? { + guard !model.icon.isEmpty, + let activeConnection = networkTracker.activeConnection, + !activeConnection.configuration.url.isEmpty else { return nil } + // Skip loading number icons as they don't exist/aren't useful + if model.icon == "number" { + return nil + } + + return Endpoint.icon( + rootUrl: activeConnection.configuration.url, + version: activeConnection.version, + icon: model.icon, + state: model.iconState, + iconType: settings.iconType, + iconColor: model.iconColorHex, + staticIcon: model.staticIcon + )?.url + } + + var body: some View { + Group { + if let iconURL { + // Only apply color preprocessing for non-iconify icons + let processorIconColor = iconURL.host == "api.iconify.design" ? nil : model.iconColorHex + KFImage.url(iconURL) + .onFailure { _ in + Logger.rowViews.debug("Failed to load image : \(iconURL.absoluteString)") + } + .onFailureView { + Rectangle() + .foregroundStyle(.background) + } + .setProcessor(OpenHABImageProcessor(iconColor: processorIconColor)) + .cacheOriginalImage(false) + .loadTransition(.opacity, animation: .easeInOut(duration: 0.25)) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .id("\(iconURL.absoluteString)-\(model.stateToken)-\(model.iconColorHex)") + } else if let fallbackSymbol = model.fallbackSymbol { + Image(systemSymbol: fallbackSymbol) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 15, height: 15) + .foregroundStyle(.primary) + } else { + Rectangle() + .foregroundStyle(.background) + .frame(width: 20, height: 20) + } + } + } +} + +#Preview { + let testURL = URL(string: "https://picsum.photos/20")! + KFImage(testURL) + .resizable() + .frame(width: 20, height: 20) + + // Set localTestingURL to your local openHAB server for preview testing + let localTestingURL = "http://192.168.2.10:8080" + + let endpoint = Endpoint.icon(rootUrl: localTestingURL, version: 4, icon: "switch", state: "2", iconType: .png, iconColor: "blue") + KFImage(endpoint?.url) + .setProcessor(OpenHABImageProcessor(iconColor: endpoint?.url?.host == "api.iconify.design" ? nil : "blue")) + .resizable() + .frame(width: 20, height: 20) + + let settings = AppSettings(debug: true, openHABRootUrl: localTestingURL) + let widget = UserData(preview: true).widgets[4] + WatchIconView( + model: widget.iconRenderModel(), + settings: settings + ) + + let endpoint2 = Endpoint.icon(rootUrl: localTestingURL, version: 3, icon: "f7:alarm", state: "", iconType: .svg, iconColor: "yellow") + KFImage(endpoint2?.url) + .setProcessor(OpenHABImageProcessor(iconColor: endpoint2?.url?.host == "api.iconify.design" ? nil : "yellow")) + .resizable() + .frame(width: 20, height: 20) + + let widget2 = UserData(preview: true).widgets[11] + WatchIconView( + model: widget2.iconRenderModel(), + settings: settings + ) +} diff --git a/openHABWatch/Views/Utils/WatchTypography.swift b/openHABWatch/Views/Utils/WatchTypography.swift new file mode 100644 index 000000000..d030c0a5b --- /dev/null +++ b/openHABWatch/Views/Utils/WatchTypography.swift @@ -0,0 +1,111 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import SwiftUI + +struct WatchLabelText: View { + let text: String + + var body: some View { + Text(text) + .watchTextStyle(.label) + } +} + +enum WatchTextStyle { + case label + case detail + case section + case control + case secondary + case emphasis +} + +private struct WatchTextModifier: ViewModifier { + let style: WatchTextStyle + + func body(content: Content) -> some View { + switch style { + case .label: + content + .font(WatchTypography.labelFont) + .lineLimit(WatchTypography.labelLineLimit) + .minimumScaleFactor(WatchTypography.labelMinScale) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + case .detail: + content + .font(WatchTypography.detailFont) + .lineLimit(WatchTypography.detailLineLimit) + .minimumScaleFactor(WatchTypography.detailMinScale) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + case .section: + content + .font(WatchTypography.sectionFont) + .lineLimit(WatchTypography.sectionLineLimit) + .minimumScaleFactor(WatchTypography.sectionMinScale) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + case .control: + content + .font(WatchTypography.controlFont) + .lineLimit(WatchTypography.controlLineLimit) + .minimumScaleFactor(WatchTypography.controlMinScale) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + case .secondary: + content + .font(WatchTypography.secondaryFont) + .lineLimit(WatchTypography.secondaryLineLimit) + .minimumScaleFactor(WatchTypography.secondaryMinScale) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + case .emphasis: + content + .font(WatchTypography.emphasisFont) + .lineLimit(1) + .minimumScaleFactor(0.8) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + } + } +} + +enum WatchTypography { + static let labelFont: Font = .caption + static let labelLineLimit = 2 + static let labelMinScale: CGFloat = 0.85 + + static let detailFont: Font = .footnote + static let detailLineLimit = 1 + static let detailMinScale: CGFloat = 0.85 + + static let sectionFont: Font = .callout + static let sectionLineLimit = 1 + static let sectionMinScale: CGFloat = 0.85 + + static let controlFont: Font = .caption + static let controlLineLimit = 1 + static let controlMinScale: CGFloat = 0.8 + + static let secondaryFont: Font = .caption2 + static let secondaryLineLimit = 1 + static let secondaryMinScale: CGFloat = 0.8 + + static let emphasisFont: Font = .headline +} + +extension View { + func watchTextStyle(_ style: WatchTextStyle) -> some View { + modifier(WatchTextModifier(style: style)) + } +} diff --git a/openHABWatch/it.lproj/Interface.strings b/openHABWatch/it.lproj/Interface.strings deleted file mode 100644 index 6f6372118..000000000 --- a/openHABWatch/it.lproj/Interface.strings +++ /dev/null @@ -1,3 +0,0 @@ - -/* Class = "WKInterfaceLabel"; text = "Alert Label"; ObjectID = "IdU-wH-bcW"; */ -"IdU-wH-bcW.text" = "Alert Label"; diff --git a/openHABWatch/nb.lproj/Interface.strings b/openHABWatch/nb.lproj/Interface.strings deleted file mode 100644 index 6f6372118..000000000 --- a/openHABWatch/nb.lproj/Interface.strings +++ /dev/null @@ -1,3 +0,0 @@ - -/* Class = "WKInterfaceLabel"; text = "Alert Label"; ObjectID = "IdU-wH-bcW"; */ -"IdU-wH-bcW.text" = "Alert Label"; diff --git a/openHABWatchSwiftUI Watch AppTests/OpenHABWatchAppTests.swift b/openHABWatchSwiftUI Watch AppTests/OpenHABWatchAppTests.swift deleted file mode 100644 index 9cc9c4fb3..000000000 --- a/openHABWatchSwiftUI Watch AppTests/OpenHABWatchAppTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -// Copyright (c) 2010-2023 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -@testable import openHABWatch -import XCTest - -final class OpenHABWatchAppTests: XCTestCase { - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Tests marked async will run the test method on an arbitrary thread managed by the Swift runtime. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - measure { - // Put the code you want to measure the time of here. - } - } -} diff --git a/openHABWatchTests/OpenHABWatchAppTests.swift b/openHABWatchTests/OpenHABWatchAppTests.swift new file mode 100644 index 000000000..529a51818 --- /dev/null +++ b/openHABWatchTests/OpenHABWatchAppTests.swift @@ -0,0 +1,45 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Foundation +import OpenHABCore +import Testing + +// swiftlint:disable line_length +struct OpenHABWatchAppTests { + private let watchSitemapJSON = Data(""" + {"name":"watch","label":"watch","link":"https://192.168.2.15:8444/rest/sitemaps/watch","homepage":{"id":"watch","title":"watch","link":"https://192.168.2.15:8444/rest/sitemaps/watch/watch","leaf":false,"timeout":false,"widgets":[{"widgetId":"00","type":"Frame","label":"Ground floor","icon":"frame","mappings":[],"widgets":[{"widgetId":"0000","type":"Switch","label":"Licht Oberlicht","icon":"switch","mappings":[],"item":{"link":"https://192.168.2.15:8444/rest/items/lcnLightSwitch14_1","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch14_1","label":"Licht Oberlicht","tags":["Lighting"],"groupNames":["G_PresenceSimulation","gLcn"]},"widgets":[]}]}]}} + """.utf8) + + @Test("Watch sitemap JSON decodes into SitemapDTO") + func decodeWatchSitemapDTO() throws { + let decoder = JSONDecoder() + let dto = try decoder.decode(Components.Schemas.SitemapDTO.self, from: watchSitemapJSON) + + #expect(dto.name == "watch") + #expect(dto.homepage?.link == "https://192.168.2.15:8444/rest/sitemaps/watch/watch") + #expect(dto.homepage?.widgets?.first?._type == "Frame") + } + + @Test("Watch sitemap DTO maps into OpenHABPage model") + func mapWatchSitemapPageModel() throws { + let decoder = JSONDecoder() + let dto = try decoder.decode(Components.Schemas.SitemapDTO.self, from: watchSitemapJSON) + let page = try #require(OpenHABPage(dto.homepage)) + + #expect(page.title == "watch") + #expect(page.link == "https://192.168.2.15:8444/rest/sitemaps/watch/watch") + #expect(page.widgets.first?.type == .frame) + #expect(page.widgets.first?.widgets.first?.item?.name == "lcnLightSwitch14_1") + } +} + +// swiftlint:enable line_length diff --git a/openHABWatchTests/WidgetCommandDispatcherTests.swift b/openHABWatchTests/WidgetCommandDispatcherTests.swift new file mode 100644 index 000000000..aca74e19d --- /dev/null +++ b/openHABWatchTests/WidgetCommandDispatcherTests.swift @@ -0,0 +1,111 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Foundation +import OpenHABCore +@testable import openHABWatch +import Testing + +private final class CommandRecorder { + var commands: [String] = [] +} + +private func makeWidget(widgetId: String, recorder: CommandRecorder) -> OpenHABWidget { + let item = OpenHABItem( + name: "TestItem", + type: "Switch", + state: "OFF", + link: "", + label: nil, + groupType: nil, + stateDescription: nil, + commandDescription: nil, + members: [], + category: nil, + options: nil + ) + let widget = OpenHABWidget(icon: "switch") + widget.widgetId = widgetId + widget.item = item + widget.sendCommand = { _, command in + recorder.commands.append(command ?? "") + } + return widget +} + +struct WidgetCommandDispatcherTests { + @Test("Immediate policy sends command once") + @MainActor + func immediatePolicySendsOnce() async { + let recorder = CommandRecorder() + let widget = makeWidget(widgetId: "immediate", recorder: recorder) + let sender = WidgetCommandDispatcher() + + sender.send("ON", for: widget, policy: .immediate) + + #expect(recorder.commands == ["ON"]) + } + + @Test("Debounce policy keeps only final command") + @MainActor + func debounceKeepsFinalCommand() async throws { + let recorder = CommandRecorder() + let widget = makeWidget(widgetId: "debounce", recorder: recorder) + let sender = WidgetCommandDispatcher() + + sender.send("ON", for: widget, policy: .debounce(.milliseconds(80))) + sender.send("OFF", for: widget, policy: .debounce(.milliseconds(80))) + + try await Task.sleep(for: .milliseconds(200)) + #expect(recorder.commands == ["OFF"]) + } + + @Test("Pending debounced command can be cancelled") + @MainActor + func cancelPendingPreventsDispatch() async throws { + let recorder = CommandRecorder() + let widget = makeWidget(widgetId: "cancel", recorder: recorder) + let sender = WidgetCommandDispatcher() + + sender.send("ON", for: widget, policy: .debounce(.milliseconds(120))) + sender.cancelPending(for: widget) + + try await Task.sleep(for: .milliseconds(220)) + #expect(recorder.commands.isEmpty) + } + + @Test("Press and release commands dispatch in order") + @MainActor + func pressReleaseDispatchesInOrder() async { + let recorder = CommandRecorder() + let widget = makeWidget(widgetId: "press-release", recorder: recorder) + let sender = WidgetCommandDispatcher() + + sender.sendPress("UP", for: widget) + sender.sendRelease("STOP", for: widget) + + #expect(recorder.commands == ["UP", "STOP"]) + } + + @Test("Item update dispatches command and ignores nil state") + @MainActor + func itemUpdateDispatchesAndSkipsNil() async { + let recorder = CommandRecorder() + let widget = makeWidget(widgetId: "item-update", recorder: recorder) + let sender = WidgetCommandDispatcher() + + sender.sendItemUpdate(NumberState(value: 21), for: widget) + sender.sendItemUpdate(nil, for: widget) + + #expect(recorder.commands.count == 1) + #expect(recorder.commands.first?.contains("21") == true) + } +}