// // VanAligneiOSWidgetLiveActivity.swift // VanAligneiOSWidget // // Created by Christopher Helberg on 02.09.26. // import ActivityKit import WidgetKit import SwiftUI private struct LevelIcon: View { let isLevel: Bool let hasReading: Bool var body: some View { Image(systemName: hasReading ? (isLevel ? "checkmark.circle.fill" : "level") : "level") .foregroundStyle(hasReading ? (isLevel ? .green : .orange) : .secondary) } } /// Verkleinerte Libelle für Sperrbildschirm/CarPlay und die erweiterte /// Dynamic Island – dieselbe Optik wie `LevelBubble` in der App, aber ohne /// deren Abhängigkeit auf `Shared/Models/LevelState.swift`, das in der /// Extension nicht mitkompiliert wird. private struct LevelBubbleGlyph: View { let pitch: Double? let roll: Double? let isLevel: Bool var diameter: CGFloat = 54 /// Bis zu welcher Neigung die Blase ausschlägt, wie in der App-Libelle. private let range: Double = 6 /// Muss mit `LevelState.levelTolerance` übereinstimmen. private let tolerance: Double = 0.5 private var hasReading: Bool { pitch != nil || roll != nil } private var bubbleColor: Color { guard let pitch, let roll else { return .white.opacity(0.4) } let deviation = max(abs(pitch), abs(roll)) if deviation <= tolerance { return .green } if deviation <= 2 { return .orange } return .red } var body: some View { let radius = diameter / 2 let bubble = diameter * 0.22 let travel = radius - bubble / 2 - 3 let offsetX = clamped(roll) / range * travel let offsetY = clamped(pitch) / range * travel let toleranceRadius = max(tolerance / range * travel, bubble * 0.55) ZStack { Circle() .fill(Color.white.opacity(0.1)) Circle() .strokeBorder(Color.white.opacity(0.3), lineWidth: 1) Circle() .strokeBorder(isLevel ? Color.green : Color.white.opacity(0.25), lineWidth: isLevel ? 2 : 1) .frame(width: toleranceRadius * 2, height: toleranceRadius * 2) Circle() .fill(bubbleColor) .frame(width: bubble, height: bubble) // Positiver Pitch heisst: Heck steht höher, die Blase wandert // also nach unten – wie bei der echten Wasserwaage in der App. .offset(x: offsetX, y: offsetY) .opacity(hasReading ? 1 : 0.3) } .frame(width: diameter, height: diameter) } private func clamped(_ value: Double?) -> Double { guard let value else { return 0 } return min(max(value, -range), range) } } struct VanAligneiOSWidgetLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: LevelActivityAttributes.self) { context in LevelActivityContent(attributes: context.attributes, state: context.state) .activityBackgroundTint(Color(white: 0.08)) .activitySystemActionForegroundColor(.white) } dynamicIsland: { context in // Die Dynamic Island bleibt bewusst so klein wie möglich – Libelle // und Zahlenwerte gehören auf Sperrbildschirm/CarPlay, hier reicht // ein Blick auf Icon und, aufgeklappt, die Kurzanweisung. DynamicIsland { DynamicIslandExpandedRegion(.leading) { LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) .font(.title3) } DynamicIslandExpandedRegion(.bottom) { Text(context.state.instruction ?? context.attributes.deviceName) .font(.caption) .foregroundStyle(context.state.isLevel ? .green : .primary) .lineLimit(1) } } compactLeading: { LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) } compactTrailing: { EmptyView() } minimal: { LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) } .keylineTint(context.state.isLevel ? .green : .orange) } .supplementalActivityFamilies([.small]) } } /// Wählt je nach Darstellungsgrösse zwischen Sperrbildschirm- und /// Kompaktansicht. /// /// Ohne `.small`-Familie fällt CarPlay (wie die Smart Stack der Uhr) auf /// `compactLeading`/`compactTrailing` der Dynamic Island zurück – dort steht /// nur das generische Symbol und, weil `compactTrailing` leer bleibt, gar /// kein Text. Erst die `.small`-Familie liefert CarPlay Libelle und Text. private struct LevelActivityContent: View { let attributes: LevelActivityAttributes let state: LevelActivityAttributes.ContentState @Environment(\.activityFamily) private var activityFamily var body: some View { switch activityFamily { case .small: CompactLevelView(attributes: attributes, state: state) default: LockScreenLevelView(attributes: attributes, state: state) } } } /// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus /// eine Textzeile, ohne die längere Zwei-Zeilen-Aufschlüsselung des /// Sperrbildschirms. private struct CompactLevelView: View { let attributes: LevelActivityAttributes let state: LevelActivityAttributes.ContentState var body: some View { HStack(spacing: 12) { LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40) Text(state.instruction ?? (state.pitch == nil && state.roll == nil ? "Keine Messwerte" : attributes.deviceName)) .font(.callout.weight(.semibold)) .foregroundStyle(state.isLevel ? .green : .white) .lineLimit(1) .minimumScaleFactor(0.8) Spacer(minLength: 0) } .padding(.horizontal, 12) .padding(.vertical, 8) .foregroundStyle(.white) } } /// Sperrbildschirm- und Banner-Ansicht. private struct LockScreenLevelView: View { let attributes: LevelActivityAttributes let state: LevelActivityAttributes.ContentState /// Muss mit `LevelState.levelTolerance` übereinstimmen. private let tolerance: Double = 0.5 /// Längszeile, z.B. "Heck steht höher 1.8°" – nur wenn ausserhalb der /// Toleranz, genau wie bei `LevelState.instruction` in der App. private var pitchLine: String? { guard let pitch = state.pitch, abs(pitch) > tolerance else { return nil } let label = pitch >= 0 ? "Heck steht höher" : "Front steht höher" return "\(label) \(LevelDirectionFormatting.magnitude(pitch))" } /// Querzeile, z.B. "links steht höher 0.9°". private var rollLine: String? { guard let roll = state.roll, abs(roll) > tolerance else { return nil } let label = roll >= 0 ? "rechts steht höher" : "links steht höher" return "\(label) \(LevelDirectionFormatting.magnitude(roll))" } var body: some View { HStack(spacing: 20) { LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 64) VStack(alignment: .leading, spacing: 6) { Text(attributes.deviceName) .font(.caption) .foregroundStyle(.secondary) if pitchLine == nil && rollLine == nil { Text(state.pitch == nil && state.roll == nil ? "Keine Messwerte" : "Steht eben") .font(.title3.weight(.semibold)) .foregroundStyle(state.isLevel ? .green : .white) } else { if let pitchLine { Text(pitchLine).font(.title3.weight(.semibold)) } if let rollLine { Text(rollLine).font(.title3.weight(.semibold)) } } } Spacer(minLength: 0) } .padding(16) .foregroundStyle(.white) } } extension LevelActivityAttributes { fileprivate static var preview: LevelActivityAttributes { LevelActivityAttributes(deviceName: "Nivellierung") } } extension LevelActivityAttributes.ContentState { fileprivate static var level: LevelActivityAttributes.ContentState { LevelActivityAttributes.ContentState(pitch: 0.1, roll: -0.2, isLevel: true, instruction: "Steht eben", isCalibrated: true, updatedAt: .now) } fileprivate static var tilted: LevelActivityAttributes.ContentState { LevelActivityAttributes.ContentState(pitch: 2.4, roll: -1.1, isLevel: false, instruction: "Heck steht höher, links steht höher", isCalibrated: true, updatedAt: .now) } } #Preview("Notification", as: .content, using: LevelActivityAttributes.preview) { VanAligneiOSWidgetLiveActivity() } contentStates: { LevelActivityAttributes.ContentState.level LevelActivityAttributes.ContentState.tilted }