forked from fritob/Camper-Monitor
CamperMonitor (Haupt-Repo) und VanAligneiOS (aus dem gemergten solar-integration-Branch) liefen unter zwei verschiedenen internen Namen, obwohl die App nach aussen längst einheitlich "VanControl Pro" heisst. Jetzt durchgängig VanControl: - Ordner: CamperMonitor/, CamperMonitorWatch/, CamperMonitorComplication/, VanAligneiOSWidget/ → VanControl/, VanControlWatch/, VanControlComplication/, VanControlWidget/ - Xcode-Projekt: CamperMonitor.xcodeproj → VanControl.xcodeproj, alle Targets/Schemes/Produktnamen entsprechend umbenannt - Bundle-Identifier auf Wunsch mitgeändert: de.s0.fototeddy.VanControl* (App noch nicht veröffentlicht); dabei auch die WKCompanionAppBundleIdentifier-Werte korrigiert, die noch das alte de.fritob-Präfix statt des tatsächlichen de.s0.fototeddy-Präfixes trugen - Swift-Dateien/Typen: CamperMonitorApp → VanControlApp, VanAligneiOSWidget* → VanControlWidget* - Config/*-Info.plist umbenannt, README.md/Tools/README.md/run-tests.sh auf die neuen Pfade angepasst Bewusst unverändert: firmware/vanalign und alle Bezüge auf "VanAlign" als Namen der Neigungsmesser-Hardware (eigenständiges Produkt, kein App-Name) sowie der komplette Android/-Ordner. Build (App, Watch, Debug) und Protokoll-Testlauf grün. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
289 lines
11 KiB
Swift
289 lines
11 KiB
Swift
//
|
||
// VanControlWidgetLiveActivity.swift
|
||
// VanControlWidget
|
||
//
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
|
||
private let levelTolerance: Double = 0.5
|
||
|
||
/// Grün bis Toleranz, orange bis 2°, sonst rot – dieselbe Skala für Blase
|
||
/// und Text, wie in der App (`VehicleTilt.colour`). Hier dupliziert, weil
|
||
/// die Extension `Shared/` nicht mitkompiliert (siehe
|
||
/// `LevelActivityAttributes.swift`).
|
||
private func levelColour(forDeviation deviation: Double?) -> Color {
|
||
guard let deviation else { return .white.opacity(0.4) }
|
||
if deviation <= levelTolerance { return .green }
|
||
if deviation <= 2 { return .orange }
|
||
return .red
|
||
}
|
||
|
||
/// 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
|
||
|
||
private var hasReading: Bool { pitch != nil || roll != nil }
|
||
|
||
private var bubbleColor: Color {
|
||
guard let pitch, let roll else { return .white.opacity(0.4) }
|
||
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
|
||
}
|
||
|
||
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(levelTolerance / 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 VanControlWidgetLiveActivity: 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
|
||
/// ein Blick-Symbol statt eines ganzen Satzes – zum Lesen im Vorbeifahren.
|
||
///
|
||
/// Gezeigt wird nur, welche Seite höher steht: Buchstabe (Längsneigung),
|
||
/// Pfeil, Buchstabe (Querneigung) – z.B. "H ↑ L" für "Heck und links stehen
|
||
/// höher". Ein einzelner Pfeil in der Mitte reicht, weil ohnehin nur die
|
||
/// höher stehende Seite benannt wird ("höher" ist implizit "nach oben").
|
||
private struct CompactLevelView: View {
|
||
let attributes: LevelActivityAttributes
|
||
let state: LevelActivityAttributes.ContentState
|
||
|
||
private var hasReading: Bool { state.pitch != nil || state.roll != nil }
|
||
|
||
/// "H"/"F", nur wenn ausserhalb der Toleranz.
|
||
private var pitchLetter: String? {
|
||
guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil }
|
||
return pitch >= 0 ? "H" : "F"
|
||
}
|
||
|
||
/// "R"/"L", nur wenn ausserhalb der Toleranz.
|
||
private var rollLetter: String? {
|
||
guard let roll = state.roll, abs(roll) > levelTolerance else { return nil }
|
||
return roll >= 0 ? "R" : "L"
|
||
}
|
||
|
||
/// Dieselbe Skala wie die Blase, über die grössere der beiden Neigungen.
|
||
private var statusColor: Color {
|
||
guard let pitch = state.pitch, let roll = state.roll else { return .white.opacity(0.4) }
|
||
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: 14) {
|
||
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40)
|
||
|
||
Group {
|
||
if !hasReading {
|
||
Text("–")
|
||
} else if state.isLevel {
|
||
Image(systemName: "checkmark.circle.fill")
|
||
} else {
|
||
HStack(spacing: 6) {
|
||
if let pitchLetter { Text(pitchLetter) }
|
||
Image(systemName: "arrow.up")
|
||
if let rollLetter { Text(rollLetter) }
|
||
}
|
||
}
|
||
}
|
||
// Skalierbarer Textstil statt fester Punktgrösse: CarPlay-
|
||
// Bildschirme unterscheiden sich in Grösse, die exakten Masse
|
||
// eines Displays sind aber nicht abfragbar. `minimumScaleFactor`
|
||
// lässt die Schrift so gross wie möglich, aber so klein wie
|
||
// nötig werden, um in den vom System zugeteilten Platz zu passen.
|
||
.font(.title2.weight(.bold))
|
||
.minimumScaleFactor(0.5)
|
||
.lineLimit(1)
|
||
.foregroundStyle(statusColor)
|
||
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 8)
|
||
}
|
||
}
|
||
|
||
/// Sperrbildschirm- und Banner-Ansicht.
|
||
private struct LockScreenLevelView: View {
|
||
let attributes: LevelActivityAttributes
|
||
let state: LevelActivityAttributes.ContentState
|
||
|
||
/// 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) > levelTolerance 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) > levelTolerance 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) {
|
||
VanControlWidgetLiveActivity()
|
||
} contentStates: {
|
||
LevelActivityAttributes.ContentState.level
|
||
LevelActivityAttributes.ContentState.tilted
|
||
}
|