Files
VanAligneiOS/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift
T
fototeddyandClaude Sonnet 5 bfe2b00a5c Add Live Activity for level sensor (surfaces on CarPlay automatically)
Replaces the ActivityKit boilerplate with a real lock screen/Dynamic
Island UI driven by live pitch/roll readings, toggled from the
leveling device's detail screen. Since iOS 17 shows any running Live
Activity on CarPlay's dashboard without a dedicated CarPlay app entitlement,
this covers the requested CarPlay use case without one.

Also fixes the widget extension never being embedded into the app
(missing Embed Foundation Extensions build phase and target
dependency), which meant no widget or Live Activity could ever have
rendered regardless of code changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:39:01 +02:00

138 lines
5.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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
}