- Assign the shared development team to the widget extension and watch complication targets, and correct the watch app's companion bundle identifier so on-device installs succeed after the bundle ID rename to de.s0.fototeddy.VanAligneiOS. - Guard against two ConfiguredDevices sharing a peripheralID (e.g. a double-tapped "Sichern"), which crashed BluetoothManager's Dictionary(uniqueKeysWithValues:) on launch. - Keep Bluetooth running in the background while a Live Activity is active, so lock screen/CarPlay keep receiving fresh readings instead of freezing at the last value before backgrounding. - Render the actual bubble-level graphic (not just a static icon) in the Live Activity's lock screen view and expanded Dynamic Island. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
195 lines
7.3 KiB
Swift
195 lines
7.3 KiB
Swift
//
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
/// 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 oben – 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
|
||
LockScreenLevelView(attributes: context.attributes, state: context.state)
|
||
.activityBackgroundTint(Color(white: 0.08))
|
||
.activitySystemActionForegroundColor(.white)
|
||
|
||
} dynamicIsland: { context in
|
||
DynamicIsland {
|
||
DynamicIslandExpandedRegion(.leading) {
|
||
LevelBubbleGlyph(pitch: context.state.pitch, roll: context.state.roll,
|
||
isLevel: context.state.isLevel, diameter: 44)
|
||
}
|
||
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) {
|
||
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel)
|
||
|
||
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
|
||
}
|