// // VanAligneiOSWidgetLiveActivity.swift // VanAligneiOSWidget // // Created by Christopher Helberg on 02.09.26. // import ActivityKit import WidgetKit import SwiftUI /// Formatiert Neigungswerte wie in der App – °, eine Nachkommastelle, "–" /// wenn kein Messwert da ist. Eigene Kopie, da die Extension nicht von /// `Shared/Models/Metric.swift` abhängt. private func degreesText(_ value: Double?) -> String { guard let value else { return "–" } return String(format: "%.1f°", value) } 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) } } struct VanAligneiOSWidgetLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: LevelActivityAttributes.self) { context in LockScreenLevelView(attributes: context.attributes, state: context.state) .activityBackgroundTint(Color(white: 0.08)) .activitySystemActionForegroundColor(.white) } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) .font(.title2) } DynamicIslandExpandedRegion(.trailing) { VStack(alignment: .trailing, spacing: 2) { Text(degreesText(context.state.pitch)).monospacedDigit() Text(degreesText(context.state.roll)).monospacedDigit() } .font(.headline) } DynamicIslandExpandedRegion(.bottom) { Text(context.state.instruction ?? context.attributes.deviceName) .font(.subheadline) .foregroundStyle(context.state.isLevel ? .green : .primary) } } compactLeading: { LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) } compactTrailing: { Text(degreesText(context.state.pitch)).monospacedDigit() } minimal: { LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) } .keylineTint(context.state.isLevel ? .green : .orange) } } } /// Sperrbildschirm- und Banner-Ansicht. Dieselbe Ansicht landet automatisch /// auch im CarPlay-Dashboard, sobald das iPhone verbunden ist – ein eigenes /// CarPlay-App-Target ist dafür nicht nötig. private struct LockScreenLevelView: View { let attributes: LevelActivityAttributes let state: LevelActivityAttributes.ContentState var body: some View { HStack(spacing: 16) { LevelIcon(isLevel: state.isLevel, hasReading: state.pitch != nil) .font(.system(size: 32)) .frame(width: 40) VStack(alignment: .leading, spacing: 4) { Text(attributes.deviceName) .font(.caption) .foregroundStyle(.secondary) Text(state.instruction ?? "Keine Messwerte") .font(.headline) .foregroundStyle(state.isLevel ? .green : .white) .lineLimit(2) } Spacer() VStack(alignment: .trailing, spacing: 2) { reading("Längs", state.pitch) reading("Quer", state.roll) } } .padding(16) .foregroundStyle(.white) } private func reading(_ title: String, _ value: Double?) -> some View { HStack(spacing: 6) { Text(title) .font(.caption2) .foregroundStyle(.secondary) Text(degreesText(value)) .font(.subheadline.monospacedDigit()) } } } 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 }