Skip to content

Commit 56db82a

Browse files
committed
Feature: Add Kiosk Mode screensaver views
Part 3 of Kiosk Mode implementation. Depends on PRs home-assistant#4197 and home-assistant#4198. Adds screensaver functionality with multiple display modes: Files added (~52K): - ClockScreensaverView.swift: Clock display with multiple styles (large, minimal, digital, analog) - CustomURLScreensaverView.swift: Display custom HA dashboard as screensaver - EntityStateProvider.swift: Fetches and formats entity states for display - PhotoManager.swift: Manages photo loading from device, iCloud, and HA media - PhotoScreensaverView.swift: Photo slideshow with transitions - ScreensaverViewController.swift: UIKit controller coordinating screensaver presentation Features: - Multiple screensaver modes: clock, photos, dim, blank, custom URL - Clock styles: large, minimal, digital, analog with entity display - Photo sources: device albums, iCloud, Home Assistant media - Pixel shift for burn-in prevention - Day/night brightness scheduling
1 parent 108fb07 commit 56db82a

File tree

7 files changed

+1706
-0
lines changed

7 files changed

+1706
-0
lines changed

HomeAssistant.xcodeproj/project.pbxproj

Lines changed: 33 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
import Combine
2+
import Shared
3+
import SwiftUI
4+
5+
// MARK: - Clock Screensaver View
6+
7+
/// A screensaver view displaying time with optional date and HA entity data
8+
public struct ClockScreensaverView: View {
9+
@ObservedObject private var manager = KioskModeManager.shared
10+
@ObservedObject private var entityProvider = EntityStateProvider.shared
11+
@State private var currentTime = Date()
12+
@State private var pixelShiftOffset: CGSize = .zero
13+
14+
private let showEntities: Bool
15+
private let timeTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
16+
17+
public init(showEntities: Bool = false) {
18+
self.showEntities = showEntities
19+
}
20+
21+
public var body: some View {
22+
GeometryReader { _ in
23+
ZStack {
24+
// Background
25+
Color.black
26+
.edgesIgnoringSafeArea(.all)
27+
28+
// Clock content
29+
VStack(spacing: 20) {
30+
Spacer()
31+
32+
clockDisplay
33+
.offset(pixelShiftOffset)
34+
35+
if manager.settings.clockShowDate {
36+
dateDisplay
37+
.offset(pixelShiftOffset)
38+
}
39+
40+
if showEntities && !manager.settings.clockEntities.isEmpty {
41+
entityDisplay
42+
.offset(pixelShiftOffset)
43+
.padding(.top, 40)
44+
}
45+
46+
Spacer()
47+
}
48+
.frame(maxWidth: .infinity, maxHeight: .infinity)
49+
}
50+
}
51+
.onAppear {
52+
if showEntities {
53+
let entityIds = manager.settings.clockEntities.map(\.entityId)
54+
entityProvider.watchEntities(entityIds)
55+
}
56+
}
57+
.onDisappear {
58+
if showEntities {
59+
entityProvider.stopWatching()
60+
}
61+
}
62+
.onReceive(timeTimer) { _ in
63+
currentTime = Date()
64+
}
65+
.onReceive(NotificationCenter.default.publisher(for: .kioskPixelShiftTick)) { _ in
66+
applyPixelShift()
67+
}
68+
}
69+
70+
// MARK: - Clock Display
71+
72+
@ViewBuilder
73+
private var clockDisplay: some View {
74+
switch manager.settings.clockStyle {
75+
case .large:
76+
largeClockDisplay
77+
78+
case .minimal:
79+
minimalClockDisplay
80+
81+
case .analog:
82+
analogClockDisplay
83+
84+
case .digital:
85+
digitalClockDisplay
86+
}
87+
}
88+
89+
private var largeClockDisplay: some View {
90+
Text(timeString)
91+
.font(.system(size: 120, weight: .thin, design: .rounded))
92+
.foregroundColor(.white)
93+
.monospacedDigit()
94+
.accessibilityLabel("Current time: \(accessibleTimeString)")
95+
}
96+
97+
private var minimalClockDisplay: some View {
98+
Text(timeString)
99+
.font(.system(size: 80, weight: .ultraLight, design: .default))
100+
.foregroundColor(.white.opacity(0.9))
101+
.monospacedDigit()
102+
.accessibilityLabel("Current time: \(accessibleTimeString)")
103+
}
104+
105+
private var digitalClockDisplay: some View {
106+
Text(timeString)
107+
.font(.system(size: 100, weight: .medium, design: .monospaced))
108+
.foregroundColor(.green)
109+
.monospacedDigit()
110+
.accessibilityLabel("Current time: \(accessibleTimeString)")
111+
}
112+
113+
private var analogClockDisplay: some View {
114+
// Analog clock face
115+
AnalogClockView(date: currentTime)
116+
.frame(width: 300, height: 300)
117+
.accessibilityLabel("Analog clock showing \(accessibleTimeString)")
118+
}
119+
120+
private var timeString: String {
121+
let formatter = DateFormatter()
122+
let use24Hour = manager.settings.clockUse24HourFormat
123+
let showSeconds = manager.settings.clockShowSeconds
124+
125+
if use24Hour {
126+
formatter.dateFormat = showSeconds ? "HH:mm:ss" : "HH:mm"
127+
} else {
128+
formatter.dateFormat = showSeconds ? "h:mm:ss a" : "h:mm a"
129+
}
130+
return formatter.string(from: currentTime)
131+
}
132+
133+
private var accessibleTimeString: String {
134+
let formatter = DateFormatter()
135+
formatter.timeStyle = .short
136+
return formatter.string(from: currentTime)
137+
}
138+
139+
// MARK: - Date Display
140+
141+
private var dateDisplay: some View {
142+
Text(dateString)
143+
.font(.system(size: 28, weight: .light, design: .rounded))
144+
.foregroundColor(.white.opacity(0.7))
145+
.accessibilityLabel("Date: \(dateString)")
146+
}
147+
148+
private var dateString: String {
149+
let formatter = DateFormatter()
150+
formatter.dateFormat = "EEEE, MMMM d"
151+
return formatter.string(from: currentTime)
152+
}
153+
154+
// MARK: - Entity Display
155+
156+
private var entityDisplay: some View {
157+
HStack(spacing: 40) {
158+
ForEach(manager.settings.clockEntities.prefix(4)) { entity in
159+
EntityValueView(config: entity)
160+
}
161+
}
162+
}
163+
164+
// MARK: - Pixel Shift
165+
166+
private func applyPixelShift() {
167+
guard manager.settings.pixelShiftEnabled else { return }
168+
169+
let amount = manager.settings.pixelShiftAmount
170+
171+
withAnimation(.easeInOut(duration: 1.0)) {
172+
pixelShiftOffset = CGSize(
173+
width: CGFloat.random(in: -amount...amount),
174+
height: CGFloat.random(in: -amount...amount)
175+
)
176+
}
177+
}
178+
}
179+
180+
// MARK: - Analog Clock View
181+
182+
struct AnalogClockView: View {
183+
let date: Date
184+
185+
private var calendar: Calendar { Calendar.current }
186+
187+
private var hours: Int {
188+
calendar.component(.hour, from: date) % 12
189+
}
190+
191+
private var minutes: Int {
192+
calendar.component(.minute, from: date)
193+
}
194+
195+
private var seconds: Int {
196+
calendar.component(.second, from: date)
197+
}
198+
199+
var body: some View {
200+
ZStack {
201+
// Clock face
202+
Circle()
203+
.stroke(Color.white.opacity(0.3), lineWidth: 2)
204+
.accessibilityHidden(true)
205+
206+
// Hour markers
207+
ForEach(0..<12) { hour in
208+
Rectangle()
209+
.fill(Color.white.opacity(0.6))
210+
.frame(width: hour % 3 == 0 ? 3 : 1, height: hour % 3 == 0 ? 15 : 8)
211+
.offset(y: -130)
212+
.rotationEffect(.degrees(Double(hour) * 30))
213+
.accessibilityHidden(true)
214+
}
215+
216+
// Hour hand
217+
Rectangle()
218+
.fill(Color.white)
219+
.frame(width: 4, height: 70)
220+
.offset(y: -35)
221+
.rotationEffect(.degrees(Double(hours) * 30 + Double(minutes) * 0.5))
222+
.accessibilityHidden(true)
223+
224+
// Minute hand
225+
Rectangle()
226+
.fill(Color.white)
227+
.frame(width: 3, height: 100)
228+
.offset(y: -50)
229+
.rotationEffect(.degrees(Double(minutes) * 6))
230+
.accessibilityHidden(true)
231+
232+
// Second hand
233+
Rectangle()
234+
.fill(Color.red)
235+
.frame(width: 1, height: 110)
236+
.offset(y: -55)
237+
.rotationEffect(.degrees(Double(seconds) * 6))
238+
.accessibilityHidden(true)
239+
240+
// Center dot
241+
Circle()
242+
.fill(Color.white)
243+
.frame(width: 10, height: 10)
244+
.accessibilityHidden(true)
245+
}
246+
.accessibilityElement(children: .ignore)
247+
}
248+
}
249+
250+
// MARK: - Entity Value View
251+
252+
struct EntityValueView: View {
253+
let config: ClockEntityConfig
254+
@ObservedObject private var entityProvider = EntityStateProvider.shared
255+
256+
private var entityState: EntityState? {
257+
entityProvider.state(for: config.entityId)
258+
}
259+
260+
var body: some View {
261+
VStack(spacing: 8) {
262+
// Icon
263+
iconView
264+
.font(.system(size: 24))
265+
.foregroundColor(.white.opacity(0.7))
266+
.accessibilityHidden(true)
267+
268+
// Value
269+
Text(displayValue)
270+
.font(.system(size: 32, weight: .medium, design: .rounded))
271+
.foregroundColor(.white)
272+
.monospacedDigit()
273+
.accessibilityHidden(true)
274+
275+
// Label
276+
Text(displayLabel)
277+
.font(.caption)
278+
.foregroundColor(.white.opacity(0.5))
279+
.lineLimit(1)
280+
.accessibilityHidden(true)
281+
282+
// Unit (if separate from value)
283+
if config.showUnit, let unit = entityState?.unitOfMeasurement, !unit.isEmpty {
284+
Text(unit)
285+
.font(.caption2)
286+
.foregroundColor(.white.opacity(0.4))
287+
.accessibilityHidden(true)
288+
}
289+
}
290+
.accessibilityElement(children: .combine)
291+
.accessibilityLabel(accessibilityDescription)
292+
}
293+
294+
private var accessibilityDescription: String {
295+
var description = displayLabel
296+
description += ": \(displayValue)"
297+
if config.showUnit, let unit = entityState?.unitOfMeasurement, !unit.isEmpty {
298+
description += " \(unit)"
299+
}
300+
return description
301+
}
302+
303+
@ViewBuilder
304+
private var iconView: some View {
305+
let iconName = config.icon ?? entityState?.icon
306+
if let iconName {
307+
Image(systemName: IconMapper.sfSymbol(from: iconName, default: "sensor.fill"))
308+
} else {
309+
Image(systemName: "sensor.fill")
310+
}
311+
}
312+
313+
private var displayValue: String {
314+
entityState?.value ?? "--"
315+
}
316+
317+
private var displayLabel: String {
318+
config.label ?? entityState?.friendlyName ?? config.entityId
319+
}
320+
}
321+
322+
// MARK: - Preview
323+
324+
#Preview("Large Clock") {
325+
ClockScreensaverView(showEntities: false)
326+
}
327+
328+
#Preview("Clock with Entities") {
329+
ClockScreensaverView(showEntities: true)
330+
}

0 commit comments

Comments
 (0)