Compare commits

9 Commits
Author SHA1 Message Date
fototeddyandClaude Sonnet 5 2638438494 Add landscape layouts for level display, vehicle view, and alignment assistant
Rotating the phone into landscape while leveling used to leave a
portrait-shaped column of controls that needed scrolling to take in.
All three level-integration screens now branch on verticalSizeClass:
the bubble/vehicle status in the device screen and the full alignment
assistant (advice, readings, and wedge heights) lay out side-by-side
instead of stacked, sized to fit without scrolling.

The device screen also measures its actual available height via a
GeometryReader and feeds it to the level display so the bubble/vehicle
graphic scales to fill the landscape screen rather than being capped at
a fixed size, and the connection-status section moves to the bottom of
the list in landscape so it doesn't compete with the display for space.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:33:35 +02:00
fototeddyandClaude Sonnet 5 0fdef337fc Simplify CarPlay Live Activity to letter+arrow glance indicator
Replaces the full sentence ("Heck steht höher, links steht höher") in
the .small activity family with a compact "H ↑ L" style readout: a
direction letter per axis (only shown when outside tolerance) around a
single up arrow, since only the higher side is ever named anyway. Text
now shares the same green/orange/red scale as the bubble glyph (factored
into one levelColour helper instead of two duplicated thresholds), and
uses a scalable font style with minimumScaleFactor so it fits whatever
size CarPlay actually grants the card - there's no API to query the
physical display size from Live Activity content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:08:40 +02:00
fototeddyandClaude Sonnet 5 1b06ac36d1 Debounce Live Activity ending on BLE disconnect, auto-resume on reconnect
A dropped BLE connection ended the Live Activity immediately, which
flickered off and on during the brief, routine disconnects that happen
constantly while driving. It now waits 12s before actually ending, and
if the sensor reconnects within that window the pending end is simply
cancelled. If the disconnect does outlast the grace period and the
activity really ends, reconnecting afterwards restarts it automatically
with the last known reading, since that wasn't a deliberate user stop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 12:00:49 +02:00
fototeddyandClaude Sonnet 5 46a4da5e37 Fix Live Activity in CarPlay: show real bubble and text via .small family
CarPlay renders Live Activities through the .small activity family
(shared with the Watch Smart Stack), not the lock-screen content view.
Without it, CarPlay fell back to the Dynamic Island's compactLeading/
compactTrailing, which only had the generic "level" SF Symbol and an
empty trailing view — hence the wrong icon and missing text.

Adds .supplementalActivityFamilies([.small]) and a compact layout that
reuses the real LevelBubbleGlyph plus a one-line instruction for that
family, while the lock screen keeps its existing two-line view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:37:50 +02:00
fototeddyandClaude Sonnet 5 45aa7cf984 Add auswählbare Fahrzeuggrafik am Neigungsmesser, neues California-Set
Jedes Grafikset (Seiten-/Heckansicht) ist jetzt ein VehicleGraphicStyle
mit eigenem Seitenverhältnis, wählbar in der Neigungsmesser-Einrichtung
und angewendet in Fahrzeug-Ansicht und Ausrichtungs-Assistent. Die
bisherigen VehicleSide/VehicleRear-Assets heißen jetzt VansterSide/
VansterRear, damit sie eindeutig ihrem Set zuzuordnen sind; California
kommt als zweites Set mit eigener Seitenansicht dazu (Heckansicht
vorerst von Vanster übernommen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:06:35 +02:00
fototeddyandClaude Sonnet 5 f175372318 Vereinfache Fahrzeug-Symbolauswahl auf sechs Icons, Default suv.side
Ersetzt die zehn wählbaren Symbole durch eine kürzere, konsistentere
Auswahl (car, car.side, suv.side, truck.pickup.side, box.truck, bus)
und setzt suv.side als neuen Standard für neu angelegte Fahrzeuge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 10:04:48 +02:00
fototeddyandClaude Sonnet 5 4a8a40eea2 Minimize Dynamic Island, show direction letters instead of signs, fix inverted pitch bubble
- Dynamic Island now shows only the level icon (compact/minimal); the
  bubble, instruction text, and numbers move to the shared lock
  screen/CarPlay ActivityView, which iOS renders identically on both
  surfaces (no separate CarPlay layout API exists for this).
- Replace +/- signs with a leading direction letter (H/F for Heck-
  Front, R/L for rechts-links) via a new shared LevelDirectionFormatting
  helper, used by the level screen, alignment assistant, and dashboard
  card. The generic "Messwerte" list keeps raw signed values.
- Live Activity's instruction now reads as two lines ("Heck steht
  höher 1.8°" / "links steht höher 0.9°") instead of a sentence plus a
  separate number column.
- Fix the bubble level graphic's pitch axis being inverted (front/rear
  reversed) in all three copies (app, watch, Live Activity widget) —
  confirmed against the known-correct VehicleTiltView orientation.
- Dashboard card shows pitch and roll side by side, large, without the
  "Längsneigung"/"Querneigung" labels, for the leveling device.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:03:37 +02:00
fototeddyandClaude Sonnet 5 9a3b5cb472 Fix device signing/companion ID, duplicate-device crash, background updates, and add level bubble to Live Activity
- 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>
2026-09-04 23:50:08 +02:00
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
31 changed files with 1055 additions and 278 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

@@ -0,0 +1,10 @@
{
"images" : [
{
"filename" : "CaliforniaSide.png",
"idiom" : "universal"
}
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "template-rendering-intent" : "template" }
}
@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VehicleRear.png", "filename" : "VansterRear.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VehicleSide.png", "filename" : "VansterSide.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 253 KiB

+31 -4
View File
@@ -240,6 +240,10 @@ final class BluetoothManager: NSObject {
// MARK: - Sonstiges // MARK: - Sonstiges
private let store: DeviceStore private let store: DeviceStore
/// Von aussen gesetzt, sobald die App bereit ist. Optional, damit
/// `BluetoothManager` nichts über Live Activities wissen muss, wenn keine
/// läuft.
var activityManager: LevelActivityManager?
private let isDemo = DemoData.isEnabled private let isDemo = DemoData.isEnabled
/// Wieviele Messpunkte je Gerät im Verlauf behalten werden. /// Wieviele Messpunkte je Gerät im Verlauf behalten werden.
private let historyLimit = 720 private let historyLimit = 720
@@ -513,7 +517,11 @@ final class BluetoothManager: NSObject {
// MARK: - Ablauf auf der Funk-Queue // MARK: - Ablauf auf der Funk-Queue
private func applyConfiguration(_ devices: [ManagedDevice]) { private func applyConfiguration(_ devices: [ManagedDevice]) {
managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) }) // Mit `uniqueKeysWithValues` stürzt das bei doppelt eingerichteten
// Geräten (gleiches Peripheral, aus Versehen zweimal hinzugefügt) ab.
// Der letzte Eintrag gewinnt besser eine überschriebene Dublette als
// ein Absturz der ganzen Funk-Schicht.
managed = Dictionary(devices.map { ($0.peripheralID, $0) }, uniquingKeysWith: { _, latest in latest })
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören. // Verbindungen zu Geräten lösen, die nicht mehr dazugehören.
let wanted = Set(devices.filter { shouldStayConnected($0) }.map(\.peripheralID)) let wanted = Set(devices.filter { shouldStayConnected($0) }.map(\.peripheralID))
@@ -889,10 +897,23 @@ extension BluetoothManager: CBCentralManagerDelegate {
queue: queue, queue: queue,
onUpdate: { [weak self] snapshot in self?.record(snapshot) }, onUpdate: { [weak self] snapshot in self?.record(snapshot) },
onStateChange: { [weak self] state in onStateChange: { [weak self] state in
self?.publish { self?.linkStates[device.id] = state } self?.publish {
self?.linkStates[device.id] = state
if state == .live {
// Verbindung wieder da: ein anstehendes Ende
// verwerfen oder eine zwischenzeitlich doch schon
// beendete Live Activity wieder aufnehmen.
self?.activityManager?.handleReconnect(
deviceID: device.id,
state: self?.levelStates[device.id] ?? LevelState())
}
}
}, },
onLevelState: { [weak self] state in onLevelState: { [weak self] state in
self?.publish { self?.levelStates[device.id] = state } self?.publish {
self?.levelStates[device.id] = state
self?.activityManager?.update(deviceID: device.id, state: state)
}
}, },
onDeviceOrientation: { [weak self] orientation in onDeviceOrientation: { [weak self] orientation in
// Im Gerät steht, wie der Sensor eingebaut ist für alle // Im Gerät steht, wie der Sensor eingebaut ist für alle
@@ -959,7 +980,13 @@ extension BluetoothManager: CBCentralManagerDelegate {
.map { Date().timeIntervalSince($0) } ?? 0 .map { Date().timeIntervalSince($0) } ?? 0
if let device = managed[peripheral.identifier] { if let device = managed[peripheral.identifier] {
publish { self.linkStates[device.id] = .searching } let deviceName = store.devices.first { $0.id == device.id }?.name ?? ""
publish {
self.linkStates[device.id] = .searching
// Erst nach einer Gnadenfrist wirklich beenden sonst
// flackerte die Live Activity bei jedem kurzen Funkloch.
self.activityManager?.handleDisconnect(deviceID: device.id, deviceName: deviceName)
}
if lifetime >= stableConnection { if lifetime >= stableConnection {
// Die Verbindung stand und ist weggefallen - im Fahrzeug der // Die Verbindung stand und ist weggefallen - im Fahrzeug der
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das // Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das
+10 -1
View File
@@ -5,14 +5,18 @@ struct CamperMonitorApp: App {
@State private var store: DeviceStore @State private var store: DeviceStore
@State private var bluetooth: BluetoothManager @State private var bluetooth: BluetoothManager
@State private var watch: PhoneWatchLink @State private var watch: PhoneWatchLink
@State private var levelActivity: LevelActivityManager
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
init() { init() {
let store = DeviceStore() let store = DeviceStore()
let bluetooth = BluetoothManager(store: store) let bluetooth = BluetoothManager(store: store)
let levelActivity = LevelActivityManager()
bluetooth.activityManager = levelActivity
_store = State(initialValue: store) _store = State(initialValue: store)
_bluetooth = State(initialValue: bluetooth) _bluetooth = State(initialValue: bluetooth)
_watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth)) _watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth))
_levelActivity = State(initialValue: levelActivity)
} }
var body: some Scene { var body: some Scene {
@@ -21,6 +25,7 @@ struct CamperMonitorApp: App {
.environment(store) .environment(store)
.environment(bluetooth) .environment(bluetooth)
.environment(watch) .environment(watch)
.environment(levelActivity)
.task { watch.activate() } .task { watch.activate() }
} }
.onChange(of: scenePhase) { _, phase in .onChange(of: scenePhase) { _, phase in
@@ -38,7 +43,11 @@ struct CamperMonitorApp: App {
case .active: case .active:
bluetooth.start() bluetooth.start()
case .background: case .background:
if !watch.wantsLiveUpdates { bluetooth.stop() } // Läuft gerade eine Live Activity, muss der Neigungsmesser
// auch mit dunklem Bildschirm weiter Werte liefern sonst
// friert die Anzeige auf Sperrbildschirm und CarPlay beim
// ersten Wegdrücken der App ein.
if !watch.wantsLiveUpdates && !levelActivity.isActive { bluetooth.stop() }
default: default:
break break
} }
@@ -0,0 +1,119 @@
import ActivityKit
import Foundation
import Observation
/// Startet, aktualisiert und beendet die Live Activity des Neigungsmessers.
///
/// Es läuft höchstens eine Aktivität gleichzeitig mehr als einen
/// Neigungsmesser gibt es im aktiven Profil ohnehin nicht. Seit iOS 26 zeigt
/// CarPlay eine laufende Live Activity automatisch im Dashboard an; ein
/// eigenes CarPlay-App-Target braucht es dafür nicht die Inhalte kommen
/// aber nicht von der Sperrbildschirm-Ansicht, sondern von der
/// `.small`-Aktivitätsfamilie (siehe `VanAligneiOSWidgetLiveActivity`).
@Observable
final class LevelActivityManager {
private(set) var trackedDeviceID: UUID?
@ObservationIgnored private var activity: Activity<LevelActivityAttributes>?
/// Wie lange nach einem Verbindungsabbruch gewartet wird, bevor die
/// Aktivität wirklich endet. Verbindungen fallen im Fahrzeug regelmässig
/// kurz weg ohne diese Gnadenfrist flackerte die Anzeige bei jedem
/// kurzen Funkloch aus und wieder ein.
static let disconnectGrace: TimeInterval = 12
@ObservationIgnored private var pendingEnd: Task<Void, Never>?
/// Gerät, dessen Aktivität wegen einer länger anhaltenden Trennung
/// tatsächlich beendet wurde bei der nächsten Wiederverbindung wird sie
/// automatisch neu gestartet, damit das kein bewusster Nutzer-Stop war.
@ObservationIgnored private var deviceToResume: (id: UUID, name: String)?
/// Ob gerade irgendeine Aktivität läuft die App muss dafür im
/// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die
/// Anzeige beim ersten Sperren des Bildschirms ein.
var isActive: Bool { trackedDeviceID != nil }
func isActive(for deviceID: UUID) -> Bool {
trackedDeviceID == deviceID && activity != nil
}
func start(deviceID: UUID, deviceName: String, state: LevelState) {
pendingEnd?.cancel()
pendingEnd = nil
deviceToResume = nil
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
endActivity()
let attributes = LevelActivityAttributes(deviceName: deviceName)
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
do {
activity = try Activity.request(attributes: attributes, content: content)
trackedDeviceID = deviceID
} catch {
// Kann z.B. an fehlender Nutzerfreigabe liegen dann bleibt es
// einfach bei der Anzeige in der App, es gibt sonst nichts zu tun.
}
}
/// Wird bei jeder neuen Messung aufgerufen, unabhängig davon, ob gerade
/// eine Aktivität läuft kein Aufwand ohne aktive Anzeige.
func update(deviceID: UUID, state: LevelState) {
guard trackedDeviceID == deviceID, let activity else { return }
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
Task { await activity.update(content) }
}
/// Bewusstes Beenden durch den Nutzer anders als bei einem
/// Verbindungsabbruch soll das bei der nächsten Verbindung nicht
/// automatisch wieder aufleben.
func end() {
pendingEnd?.cancel()
pendingEnd = nil
deviceToResume = nil
endActivity()
}
/// BLE-Verbindung weg: nicht sofort beenden, sondern erst nach einer
/// kurzen Gnadenfrist meldet sich das Gerät vorher zurück
/// (`handleReconnect`), passiert gar nichts.
func handleDisconnect(deviceID: UUID, deviceName: String) {
guard trackedDeviceID == deviceID, activity != nil, pendingEnd == nil else { return }
pendingEnd = Task { [weak self] in
try? await Task.sleep(for: .seconds(Self.disconnectGrace))
guard let self, !Task.isCancelled, self.trackedDeviceID == deviceID else { return }
self.deviceToResume = (deviceID, deviceName)
self.endActivity()
self.pendingEnd = nil
}
}
/// BLE-Verbindung wieder da: ein noch anstehendes Ende verwerfen, oder
/// falls die Gnadenfrist schon abgelaufen und die Aktivität wirklich
/// beendet war sie mit dem letzten bekannten Stand neu starten.
func handleReconnect(deviceID: UUID, state: LevelState) {
if pendingEnd != nil, trackedDeviceID == deviceID {
pendingEnd?.cancel()
pendingEnd = nil
return
}
guard let resume = deviceToResume, resume.id == deviceID else { return }
deviceToResume = nil
start(deviceID: deviceID, deviceName: resume.name, state: state)
}
private func endActivity() {
guard let activity else { return }
trackedDeviceID = nil
self.activity = nil
Task { await activity.end(nil, dismissalPolicy: .immediate) }
}
private static func contentState(from state: LevelState) -> LevelActivityAttributes.ContentState {
LevelActivityAttributes.ContentState(
pitch: state.pitch,
roll: state.roll,
isLevel: state.isLevel,
instruction: state.instruction,
isCalibrated: state.isCalibrated,
updatedAt: Date()
)
}
}
+7
View File
@@ -94,7 +94,14 @@ final class DeviceStore {
// MARK: - Geräte // MARK: - Geräte
/// Legt ein Gerät im gerade gewählten Profil an. /// Legt ein Gerät im gerade gewählten Profil an.
///
/// Dasselbe Peripheral doppelt im selben Profil anzulegen, würde später
/// beim Aufbau der Funk-Konfiguration abstürzen (die dortige Zuordnung
/// nach Peripheral-ID verlangt Eindeutigkeit) etwa bei einem
/// Doppel-Tipp auf Sichern. Ein zweiter Eintrag ersetzt darum den
/// ersten, statt sich danebenzustellen.
func add(_ device: ConfiguredDevice, victronKey: String? = nil) { func add(_ device: ConfiguredDevice, victronKey: String? = nil) {
devices.removeAll { $0.profileID == device.profileID && $0.peripheralID == device.peripheralID }
devices.append(device) devices.append(device)
if let victronKey { if let victronKey {
setVictronKey(victronKey, for: device.id) setVictronKey(victronKey, for: device.id)
+134 -64
View File
@@ -11,6 +11,7 @@ struct AlignmentAssistantView: View {
@Environment(BluetoothManager.self) private var bluetooth @Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var assistant = AlignmentAssistant() @State private var assistant = AlignmentAssistant()
@State private var didAnnounceTarget = false @State private var didAnnounceTarget = false
@@ -18,48 +19,18 @@ struct AlignmentAssistantView: View {
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() } private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
/// iPhone im Querformat meldet eine kompakte Höhe das ist das
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
private var isLandscape: Bool { verticalSizeClass == .compact }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { Group {
VStack(spacing: 24) { if isLandscape {
Picker("Darstellung", selection: $displayStyle) { landscapeLayout
ForEach(LevelDisplayStyle.allCases) { style in } else {
Text(style.title).tag(style) portraitLayout
}
}
.pickerStyle(.segmented)
.padding(.top, 8)
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxWidth: 320)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll)
}
if !isLive { disconnectedBanner }
adviceBanner
readings
if let best = assistant.best, let gain = assistant.improvementAtBest,
let seconds = assistant.timeSinceBest {
bestPointCard(best: best, gain: gain, seconds: seconds)
}
wedgeSection
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
assistant.reset()
didAnnounceTarget = false
}
.buttonStyle(.bordered)
.padding(.bottom, 24)
} }
.padding(.horizontal)
.frame(maxWidth: .infinity)
} }
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
.navigationTitle("Ausrichtungs-Assistent") .navigationTitle("Ausrichtungs-Assistent")
@@ -95,10 +66,94 @@ struct AlignmentAssistantView: View {
} }
} }
// MARK: - Layouts
private var portraitLayout: some View {
ScrollView {
VStack(spacing: 24) {
picker.padding(.top, 8)
display
if !isLive { disconnectedBanner }
adviceBanner
readings
if let best = assistant.best, let gain = assistant.improvementAtBest,
let seconds = assistant.timeSinceBest {
bestPointCard(best: best, gain: gain, seconds: seconds)
}
wedgeSection
resetButton
.padding(.bottom, 24)
}
.padding(.horizontal)
.frame(maxWidth: .infinity)
}
}
/// Anzeige links, alles zum Rangieren Nötige rechts ohne Scrollen, denn
/// im Querformat schaut man beiläufig hin, nicht in Ruhe. Die
/// Bestpunkt-Karte und der ausführliche Verbindungs-Hinweis bleiben dafür
/// dem Hochformat vorbehalten; die Keilhöhen bleiben in jedem Fall sichtbar.
private var landscapeLayout: some View {
HStack(alignment: .top, spacing: 16) {
VStack(spacing: 8) {
picker
display
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
VStack(spacing: 8) {
if !isLive { compactDisconnectedBanner }
adviceBanner
readings
wedgeSection
resetButton
}
.frame(maxWidth: .infinity)
}
.padding(12)
}
// MARK: - Bausteine // MARK: - Bausteine
private var isLive: Bool { bluetooth.linkStates[device.id] == .live } private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
private var picker: some View {
Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var display: some View {
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxWidth: isLandscape ? 220 : 320, maxHeight: isLandscape ? 160 : .infinity)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll,
style: device.vehicleGraphicStyle, compact: isLandscape)
}
}
private var resetButton: some View {
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
assistant.reset()
didAnnounceTarget = false
}
.buttonStyle(.bordered)
}
/// Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne /// Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne
/// Hinweis sähe das aus, als hinge die App man rangiert dann nach einem /// Hinweis sähe das aus, als hinge die App man rangiert dann nach einem
/// Wert, der längst nicht mehr gilt. /// Wert, der längst nicht mehr gilt.
@@ -120,40 +175,54 @@ struct AlignmentAssistantView: View {
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16)) .background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16))
} }
/// Kurzform für Querformat: derselbe Hinweis in einer Zeile.
private var compactDisconnectedBanner: some View {
Label("Nicht verbunden Anzeige steht still", systemImage: "antenna.radiowaves.left.and.right.slash")
.font(.caption.weight(.medium))
.foregroundStyle(.orange)
.lineLimit(1)
.minimumScaleFactor(0.8)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 10))
}
private var adviceBanner: some View { private var adviceBanner: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: assistant.hasReachedTarget Image(systemName: assistant.hasReachedTarget
? "checkmark.circle.fill" : assistant.trend.symbol) ? "checkmark.circle.fill" : assistant.trend.symbol)
.font(.title) .font(isLandscape ? .title2 : .title)
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor) .foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
Text(assistant.advice) Text(assistant.advice)
.font(.title3.weight(.medium)) .font(isLandscape ? .subheadline.weight(.medium) : .title3.weight(.medium))
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.padding() .padding(isLandscape ? 10 : 16)
.background(assistant.hasReachedTarget ? Color.green.opacity(0.15) .background(assistant.hasReachedTarget ? Color.green.opacity(0.15)
: Color(.secondarySystemGroupedBackground), : Color(.secondarySystemGroupedBackground),
in: .rect(cornerRadius: 16)) in: .rect(cornerRadius: 16))
} }
private var readings: some View { private var readings: some View {
HStack(spacing: 12) { HStack(spacing: isLandscape ? 8 : 12) {
reading("Längs", state.pitch) reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", state.roll) reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
reading("Gesamt", assistant.current?.deviation) if !isLandscape {
reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation))
}
} }
} }
private func reading(_ title: String, _ value: Double?) -> some View { private func reading(_ title: String, _ text: String) -> some View {
VStack(spacing: 4) { VStack(spacing: isLandscape ? 1 : 4) {
Text(value.map { String(format: "%.1f°", $0) } ?? "") Text(text)
.font(.title2.weight(.semibold).monospacedDigit()) .font((isLandscape ? Font.callout : .title2).weight(.semibold).monospacedDigit())
Text(title) Text(title)
.font(.caption) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 12) .padding(.vertical, isLandscape ? 6 : 12)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12)) .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
} }
@@ -187,23 +256,25 @@ struct AlignmentAssistantView: View {
trackWidth: width, wheelbase: base) trackWidth: width, wheelbase: base)
}() }()
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: isLandscape ? 6 : 10) {
Label("Auffahrkeile", systemImage: "triangle.fill") Label("Auffahrkeile", systemImage: "triangle.fill")
.font(.headline) .font(isLandscape ? .subheadline.weight(.semibold) : .headline)
if profile?.trackWidth == nil || profile?.wheelbase == nil { if profile?.trackWidth == nil || profile?.wheelbase == nil {
Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt " Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
+ "sich beim Fahrzeug hinterlegen.") + "sich beim Fahrzeug hinterlegen.")
.font(.callout)
.foregroundStyle(.secondary)
} else if let lift, !lift.isNegligible {
WheelLiftPlan(lift: lift)
.frame(maxWidth: .infinity)
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if let lift, !lift.isNegligible {
WheelLiftPlan(lift: lift, isCompact: isLandscape)
.frame(maxWidth: .infinity)
if !isLandscape {
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
.font(.caption)
.foregroundStyle(.secondary)
}
} else { } else {
Text("Keine Keile nötig.") Text("Keine Keile nötig.")
.font(.callout) .font(.callout)
@@ -211,7 +282,7 @@ struct AlignmentAssistantView: View {
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding() .padding(isLandscape ? 10 : 16)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16)) .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
} }
@@ -229,4 +300,3 @@ struct AlignmentAssistantView: View {
#endif #endif
} }
} }
+17
View File
@@ -13,6 +13,8 @@ struct DeviceCard: View {
if isShowingLastSettings, let snapshot { if isShowingLastSettings, let snapshot {
lastSettings(for: snapshot) lastSettings(for: snapshot)
} else if let snapshot, snapshot.primaryMetric != nil, device.role == .leveling {
levelReadout(for: snapshot)
} else if let snapshot, let primary = snapshot.primaryMetric { } else if let snapshot, let primary = snapshot.primaryMetric {
HStack(alignment: .firstTextBaseline, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(primary.formatted) Text(primary.formatted)
@@ -115,6 +117,21 @@ struct DeviceCard: View {
} }
} }
/// Für die Nivellierung: Front/Heck und links/rechts nebeneinander, groß,
/// ohne "Längsneigung"/"Querneigung"-Beschriftung die Richtung steht ja
/// schon im vorangestellten Buchstaben.
private func levelReadout(for snapshot: DeviceSnapshot) -> some View {
let pitch = snapshot.metrics.first(where: { $0.key == "pitch" })?.value
let roll = snapshot.metrics.first(where: { $0.key == "roll" })?.value
return HStack(spacing: 24) {
Text(LevelDirectionFormatting.pitchTile(pitch))
Text(LevelDirectionFormatting.rollTile(roll))
}
.font(.system(size: 36, weight: .semibold, design: .rounded))
.contentTransition(.numericText())
.foregroundStyle(snapshot.isStale ? .secondary : .primary)
}
private func secondaryValues(for snapshot: DeviceSnapshot) -> some View { private func secondaryValues(for snapshot: DeviceSnapshot) -> some View {
let others = snapshot.metrics let others = snapshot.metrics
.filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil } .filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil }
+95 -79
View File
@@ -7,6 +7,7 @@ struct DeviceDetailView: View {
@Environment(DeviceStore.self) private var store @Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth @Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var editedName = "" @State private var editedName = ""
/// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der /// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der
@@ -35,106 +36,121 @@ struct DeviceDetailView: View {
store.devices.first { $0.id == device.id } ?? device store.devices.first { $0.id == device.id } ?? device
} }
/// iPhone im Querformat meldet eine kompakte Höhe das ist das
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
private var isLandscape: Bool { verticalSizeClass == .compact }
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] } private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching } private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] } private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] }
var body: some View { var body: some View {
List { // Die Höhe der Libelle/Fahrzeug-Anzeige im Querformat richtet sich
statusSection // nach der tatsächlich verfügbaren Bildschirmhöhe, nicht nach einem
// if needsKeyAttention { keyPrompt } // festen Wert deshalb misst ein GeometryReader die Liste von aussen.
GeometryReader { geometry in
List {
// Im Querformat soll die Libelle/Fahrzeug-Ansicht sofort
// sichtbar sein, ohne erst am Verbindungsstatus
// vorbeizuscrollen der rutscht dort ganz ans Ende.
if !isLandscape { statusSection }
// if needsKeyAttention { keyPrompt }
// if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus { // if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
// FridgeControls(device: currentDevice, state: fridge) // FridgeControls(device: currentDevice, state: fridge)
// } // }
if currentDevice.role == .leveling { if currentDevice.role == .leveling {
LevelControls(device: currentDevice, LevelControls(device: currentDevice,
state: bluetooth.levelStates[device.id] ?? LevelState()) state: bluetooth.levelStates[device.id] ?? LevelState(),
} availableHeight: geometry.size.height)
}
if let snapshot, !snapshot.metrics.isEmpty { if let snapshot, !snapshot.metrics.isEmpty {
Section("Messwerte") { Section("Messwerte") {
ForEach(snapshot.metrics) { metric in ForEach(snapshot.metrics) { metric in
LabeledContent(metric.label) { LabeledContent(metric.label) {
Text(metric.formattedWithUnit) Text(metric.formattedWithUnit)
.monospacedDigit() .monospacedDigit()
.foregroundStyle(metric.value == nil ? .secondary : .primary) .foregroundStyle(metric.value == nil ? .secondary : .primary)
}
} }
} }
} }
}
if samples.count > 1, let primary = snapshot?.primaryMetric { if samples.count > 1, let primary = snapshot?.primaryMetric {
Section("Verlauf \(primary.label)") { Section("Verlauf \(primary.label)") {
Chart(samples) { sample in Chart(samples) { sample in
AreaMark(x: .value("Zeit", sample.time), AreaMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value)) y: .value(primary.label, sample.value))
.foregroundStyle(.tint.opacity(0.15)) .foregroundStyle(.tint.opacity(0.15))
LineMark(x: .value("Zeit", sample.time), LineMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value)) y: .value(primary.label, sample.value))
.foregroundStyle(.tint) .foregroundStyle(.tint)
.interpolationMethod(.monotone) .interpolationMethod(.monotone)
} }
.chartYAxisLabel(primary.unit) .chartYAxisLabel(primary.unit)
.frame(height: 180) .frame(height: 180)
.padding(.vertical, 8) .padding(.vertical, 8)
}
}
if let snapshot, !snapshot.cellVoltages.isEmpty {
cellSection(snapshot.cellVoltages)
}
if let snapshot, !snapshot.info.isEmpty {
Section("Gerät") {
ForEach(snapshot.info) { item in
LabeledContent(item.label, value: item.value)
} }
} }
}
/* if let snapshot, snapshot.temperatures.count > 1 { if let snapshot, !snapshot.cellVoltages.isEmpty {
Section("Temperaturen") { cellSection(snapshot.cellVoltages)
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in }
LabeledContent("Fühler \(index + 1)") {
Text(String(format: "%.0f °C", value)).monospacedDigit() if let snapshot, !snapshot.info.isEmpty {
Section("Gerät") {
ForEach(snapshot.info) { item in
LabeledContent(item.label, value: item.value)
} }
} }
} }
}
if currentDevice.role.transport == .advertisement { if isLandscape { statusSection }
if showsTechnicalDetails { diagnosticsSection }
} else if showsTechnicalDetails {
bmsDiagnosticsSection
}
settingsSection*/ /* if let snapshot, snapshot.temperatures.count > 1 {
} Section("Temperaturen") {
.navigationTitle(currentDevice.name) ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
.navigationBarTitleDisplayMode(.inline) LabeledContent("Fühler \(index + 1)") {
.onDisappear { Text(String(format: "%.0f °C", value)).monospacedDigit()
saveName() }
// Die Kühlbox wird nur verbunden, solange man sie ansieht jede }
// Verbindung meldet sich an ihrem Display an. }
//bluetooth.endSession(for: currentDevice) }
}
.onAppear { if currentDevice.role.transport == .advertisement {
editedName = currentDevice.name if showsTechnicalDetails { diagnosticsSection }
keyInput = store.victronKeyText(for: device.id) ?? "" } else if showsTechnicalDetails {
//bluetooth.beginSession(for: currentDevice) bmsDiagnosticsSection
} }
.confirmationDialog("Gerät entfernen?",
isPresented: $showDeleteConfirmation, settingsSection*/
titleVisibility: .visible) { }
Button("Entfernen", role: .destructive) { .navigationTitle(currentDevice.name)
store.remove(device) .navigationBarTitleDisplayMode(.inline)
bluetooth.refreshConfiguration() .onDisappear {
dismiss() saveName()
// Die Kühlbox wird nur verbunden, solange man sie ansieht jede
// Verbindung meldet sich an ihrem Display an.
//bluetooth.endSession(for: currentDevice)
}
.onAppear {
editedName = currentDevice.name
keyInput = store.victronKeyText(for: device.id) ?? ""
//bluetooth.beginSession(for: currentDevice)
}
.confirmationDialog("Gerät entfernen?",
isPresented: $showDeleteConfirmation,
titleVisibility: .visible) {
Button("Entfernen", role: .destructive) {
store.remove(device)
bluetooth.refreshConfiguration()
dismiss()
}
} message: {
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
} }
} message: {
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
} }
} }
+40
View File
@@ -25,6 +25,8 @@ struct LevelSetupView: View {
store.devices.first { $0.id == device.id } ?? device store.devices.first { $0.id == device.id } ?? device
} }
private let graphicColumns = [GridItem(.adaptive(minimum: 76), spacing: 12)]
var body: some View { var body: some View {
List { List {
Section { Section {
@@ -80,6 +82,44 @@ struct LevelSetupView: View {
} footer: { } footer: {
Text(calibrationHint) Text(calibrationHint)
} }
Section {
LazyVGrid(columns: graphicColumns, spacing: 12) {
ForEach(VehicleGraphicStyle.allCases) { style in
Button {
var updated = currentDevice
updated.vehicleGraphicStyle = style
store.update(updated)
} label: {
VStack(spacing: 4) {
Image(style.sideImageName)
.resizable()
.scaledToFit()
.padding(10)
.frame(width: 60, height: 60)
.background(currentDevice.vehicleGraphicStyle == style
? Color.accentColor.opacity(0.18)
: Color.secondary.opacity(0.08),
in: .rect(cornerRadius: 12))
.foregroundStyle(currentDevice.vehicleGraphicStyle == style
? Color.accentColor : Color.primary)
Text(style.title)
.font(.caption2)
.foregroundStyle(currentDevice.vehicleGraphicStyle == style
? Color.accentColor : Color.secondary)
}
}
.buttonStyle(.plain)
.accessibilityLabel(style.title)
}
}
.padding(.vertical, 4)
} header: {
Text("Fahrzeuggrafik")
} footer: {
Text("Wird in der Fahrzeug-Darstellung des Neigungsmessers verwendet. "
+ "Weitere Grafiken folgen.")
}
} }
.navigationTitle("Neigungsmesser einrichten") .navigationTitle("Neigungsmesser einrichten")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
+122 -32
View File
@@ -76,8 +76,9 @@ struct LevelBubble: View {
.fill(bubbleColor) .fill(bubbleColor)
.frame(width: bubble, height: bubble) .frame(width: bubble, height: bubble)
// Positiver Pitch heisst: das Heck steht höher, die Blase // Positiver Pitch heisst: das Heck steht höher, die Blase
// wandert also nach oben in der Ansicht nach hinten. // wandert also nach unten in der Ansicht nach hinten.
.offset(x: offsetX, y: -offsetY) // Muss zur "Fahrzeug"-Ansicht (VehicleTiltView) passen.
.offset(x: offsetX, y: offsetY)
.animation(.spring(duration: 0.35), value: offsetX) .animation(.spring(duration: 0.35), value: offsetX)
.animation(.spring(duration: 0.35), value: offsetY) .animation(.spring(duration: 0.35), value: offsetY)
.opacity(pitch == nil && roll == nil ? 0.25 : 1) .opacity(pitch == nil && roll == nil ? 0.25 : 1)
@@ -104,45 +105,76 @@ struct LevelBubble: View {
struct LevelControls: View { struct LevelControls: View {
let device: ConfiguredDevice let device: ConfiguredDevice
let state: LevelState let state: LevelState
/// Höhe der umgebenden Liste, von `DeviceDetailView` per `GeometryReader`
/// gemessen. Nur im Querformat gebraucht, um die Anzeige auf
/// Bildschirmhöhe zu bringen statt sie auf einen festen Wert zu kappen.
var availableHeight: CGFloat?
@Environment(BluetoothManager.self) private var bluetooth @Environment(BluetoothManager.self) private var bluetooth
@Environment(DeviceStore.self) private var store @Environment(DeviceStore.self) private var store
@Environment(LevelActivityManager.self) private var levelActivity
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var showAssistant = false @State private var showAssistant = false
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble @AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
/// iPhone im Querformat meldet eine kompakte Höhe das ist das
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
private var isLandscape: Bool { verticalSizeClass == .compact }
/// Verfügbare Höhe abzüglich grober Reserve für Listenränder und die
/// eigene vertikale Auffüllung genug, um praktisch den ganzen
/// Bildschirm zu nutzen, ohne über den unteren Rand hinauszuschiessen.
private var landscapeContentHeight: CGFloat {
max(150, (availableHeight ?? 350) - 56)
}
var body: some View { var body: some View {
Section { Section {
VStack(spacing: 16) { if isLandscape {
Picker("Darstellung", selection: $displayStyle) { HStack(alignment: .center, spacing: 20) {
ForEach(LevelDisplayStyle.allCases) { style in display
Text(style.title).tag(style) .frame(maxWidth: .infinity)
VStack(spacing: 12) {
picker
if let instruction = state.instruction {
Label(instruction,
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
.font(.subheadline.weight(.semibold))
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
.multilineTextAlignment(.center)
}
HStack(spacing: 16) {
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
}
}
.frame(maxWidth: .infinity)
}
.frame(height: landscapeContentHeight)
.padding(.vertical, 4)
} else {
VStack(spacing: 16) {
picker
display
if let instruction = state.instruction {
Label(instruction,
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
.font(.headline)
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
.multilineTextAlignment(.center)
}
HStack(spacing: 24) {
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
} }
} }
.pickerStyle(.segmented) .frame(maxWidth: .infinity)
.padding(.vertical, 8)
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxHeight: 220)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll)
}
if let instruction = state.instruction {
Label(instruction,
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
.font(.headline)
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
.multilineTextAlignment(.center)
}
HStack(spacing: 24) {
reading("Längs", state.pitch)
reading("Quer", state.roll)
}
} }
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
} }
Section { Section {
@@ -177,6 +209,29 @@ struct LevelControls: View {
} }
} }
Section {
Toggle(isOn: liveActivityBinding) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Live Activity")
Text("Sperrbildschirm, Dynamic Island und CarPlay")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "widget.small")
}
}
// Ohne laufende Messwerte gäbe es nichts anzuzeigen und beim
// Umschalten hätte die Activity sofort einen veralteten Stand.
.disabled(!isLive || !state.hasReading)
} footer: {
Text(!isLive
? "Braucht laufende Messwerte. Der Neigungsmesser ist gerade nicht verbunden."
: "Zeigt die Neigung, solange sie läuft auch bei gesperrtem Bildschirm. "
+ "Ist das iPhone mit CarPlay verbunden, erscheint sie automatisch auch dort.")
}
// Einbaulage und Nullpunkt liegen auf einer eigenen Seite. Direkt unter // Einbaulage und Nullpunkt liegen auf einer eigenen Seite. Direkt unter
// der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt. // der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt.
Section { Section {
@@ -194,17 +249,52 @@ struct LevelControls: View {
} }
} }
private var picker: some View {
Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var display: some View {
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxHeight: isLandscape ? landscapeContentHeight : 220)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll,
style: device.vehicleGraphicStyle, compact: isLandscape,
compactPanelHeight: max(60, landscapeContentHeight - 40))
}
}
private var isLive: Bool { bluetooth.linkStates[device.id] == .live } private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
private var liveActivityBinding: Binding<Bool> {
Binding(
get: { levelActivity.isActive(for: device.id) },
set: { isOn in
if isOn {
levelActivity.start(deviceID: device.id, deviceName: device.name, state: state)
} else {
levelActivity.end()
}
}
)
}
/// Was auf der Einrichtungsseite zu holen ist der Nullpunkt zuerst, denn /// Was auf der Einrichtungsseite zu holen ist der Nullpunkt zuerst, denn
/// ohne ihn stimmt die Anzeige nicht. /// ohne ihn stimmt die Anzeige nicht.
private var setupSummary: String { private var setupSummary: String {
state.isKnownUncalibrated ? "Nullpunkt fehlt" : device.sensorOrientation.summary state.isKnownUncalibrated ? "Nullpunkt fehlt" : device.sensorOrientation.summary
} }
private func reading(_ title: String, _ value: Double?) -> some View { private func reading(_ title: String, _ text: String) -> some View {
VStack(spacing: 2) { VStack(spacing: 2) {
Text(value.map { String(format: "%.1f°", $0) } ?? "") Text(text)
.font(.title2.weight(.semibold).monospacedDigit()) .font(.title2.weight(.semibold).monospacedDigit())
Text(title) Text(title)
.font(.caption) .font(.caption)
+1 -1
View File
@@ -145,7 +145,7 @@ private struct ProfileEditView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var name = "" @State private var name = ""
@State private var symbol = "box.truck" @State private var symbol = "suv.side"
@State private var trackWidth = "" @State private var trackWidth = ""
@State private var wheelbase = "" @State private var wheelbase = ""
+35 -27
View File
@@ -15,31 +15,39 @@ import SwiftUI
struct VehicleTiltView: View { struct VehicleTiltView: View {
let pitch: Double? let pitch: Double?
let roll: Double? let roll: Double?
var style: VehicleGraphicStyle = .vanster
/// Für Querformat: beide Ansichten nebeneinander statt untereinander,
/// kleinere Schrift, ohne Überhöhungs-Hinweis muss ohne Scrollen in die
/// Bildschirmhöhe passen.
var compact = false
/// Bildhöhe je Panel im Querformat vom Aufrufer an die tatsächlich
/// verfügbare Bildschirmhöhe angepasst, statt fest verdrahtet.
var compactPanelHeight: CGFloat = 62
var body: some View { var body: some View {
VStack(spacing: 20) { if compact {
tiltPanel(image: "VehicleSide", HStack(alignment: .top, spacing: 16) {
angle: pitch, tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
title: "Längs", lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
lowerLabel: "Front", tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
upperLabel: "Heck", lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
aspect: VehicleTilt.sideAspect) }
} else {
VStack(spacing: 20) {
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
tiltPanel(image: "VehicleRear", // Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
angle: roll, Text(String(format: "Neigung %.0f-fach überhöht dargestellt "
title: "Quer", + "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
lowerLabel: "links", VehicleTilt.exaggeration))
upperLabel: "rechts", .font(.caption2)
aspect: VehicleTilt.rearAspect) .foregroundStyle(.secondary)
.multilineTextAlignment(.center)
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten. .frame(maxWidth: .infinity)
Text(String(format: "Neigung %.0f-fach überhöht dargestellt " }
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
VehicleTilt.exaggeration))
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
} }
} }
@@ -49,13 +57,13 @@ struct VehicleTiltView: View {
lowerLabel: String, lowerLabel: String,
upperLabel: String, upperLabel: String,
aspect: Double) -> some View { aspect: Double) -> some View {
VStack(spacing: 8) { VStack(spacing: compact ? 4 : 8) {
HStack { HStack {
Text(title) Text(title)
.font(.subheadline.weight(.medium)) .font(compact ? .caption2.weight(.medium) : .subheadline.weight(.medium))
Spacer() Spacer()
Text(angle.map { String(format: "%.1f°", $0) } ?? "") Text(angle.map { String(format: "%.1f°", $0) } ?? "")
.font(.subheadline.weight(.semibold).monospacedDigit()) .font((compact ? Font.caption2 : .subheadline).weight(.semibold).monospacedDigit())
.foregroundStyle(VehicleTilt.colour(for: angle)) .foregroundStyle(VehicleTilt.colour(for: angle))
} }
@@ -75,14 +83,14 @@ struct VehicleTiltView: View {
.animation(.spring(duration: 0.4), value: angle) .animation(.spring(duration: 0.4), value: angle)
.opacity(angle == nil ? 0.3 : 1) .opacity(angle == nil ? 0.3 : 1)
} }
.frame(height: 110) .frame(height: compact ? compactPanelHeight : 110)
HStack { HStack {
Text(lowerLabel) Text(lowerLabel)
Spacer() Spacer()
Text(upperLabel) Text(upperLabel)
} }
.font(.caption2) .font(compact ? .system(size: 9) : .caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VehicleRear.png", "filename" : "VansterRear.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VehicleSide.png", "filename" : "VansterSide.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

+2 -2
View File
@@ -31,9 +31,9 @@ struct WatchBubble: View {
.fill(color) .fill(color)
.frame(width: bubble, height: bubble) .frame(width: bubble, height: bubble)
// Positiver Pitch heisst: das Heck steht höher, die Blase // Positiver Pitch heisst: das Heck steht höher, die Blase
// wandert nach oben in der Ansicht also nach hinten. // wandert nach unten in der Ansicht also nach hinten.
.offset(x: clamped(roll) / range * travel, .offset(x: clamped(roll) / range * travel,
y: -clamped(pitch) / range * travel) y: clamped(pitch) / range * travel)
.animation(.spring(duration: 0.3), value: pitch) .animation(.spring(duration: 0.3), value: pitch)
.animation(.spring(duration: 0.3), value: roll) .animation(.spring(duration: 0.3), value: roll)
.opacity(pitch == nil && roll == nil ? 0.25 : 1) .opacity(pitch == nil && roll == nil ? 0.25 : 1)
@@ -14,9 +14,9 @@ struct WatchVehicleView: View {
/// Die angeschriebenen Gradzahlen bleiben echt. /// Die angeschriebenen Gradzahlen bleiben echt.
var body: some View { var body: some View {
VStack(spacing: 8) { VStack(spacing: 8) {
panel(image: "VehicleSide", angle: pitch, title: "Längs", panel(image: "VansterSide", angle: pitch, title: "Längs",
lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect) lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect)
panel(image: "VehicleRear", angle: roll, title: "Quer", panel(image: "VansterRear", angle: roll, title: "Quer",
lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect) lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect)
} }
} }
+4
View File
@@ -105,6 +105,8 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
//var fridgeZoneMode: FridgeZoneMode = .automatic //var fridgeZoneMode: FridgeZoneMode = .automatic
/// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt. /// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt.
var sensorOrientation = SensorOrientation.identity var sensorOrientation = SensorOrientation.identity
/// Nur für den Neigungsmesser: welche Fahrzeuggrafik die Fahrzeug-Ansicht zeigt.
var vehicleGraphicStyle = VehicleGraphicStyle.vanster
init(id: UUID = UUID(), init(id: UUID = UUID(),
name: String, name: String,
@@ -135,5 +137,7 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
// ?? .automatic // ?? .automatic
sensorOrientation = try container.decodeIfPresent(SensorOrientation.self, sensorOrientation = try container.decodeIfPresent(SensorOrientation.self,
forKey: .sensorOrientation) ?? .identity forKey: .sensorOrientation) ?? .identity
vehicleGraphicStyle = try container.decodeIfPresent(VehicleGraphicStyle.self,
forKey: .vehicleGraphicStyle) ?? .vanster
} }
} }
+2 -3
View File
@@ -15,7 +15,7 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
init(id: UUID = UUID(), init(id: UUID = UUID(),
name: String, name: String,
symbol: String = "box.truck", symbol: String = "suv.side",
trackWidth: Double? = nil, trackWidth: Double? = nil,
wheelbase: Double? = nil) { wheelbase: Double? = nil) {
self.id = id self.id = id
@@ -47,7 +47,6 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
/// Alle Namen gegen NSImage(systemSymbolName:) geprüft ein nicht /// Alle Namen gegen NSImage(systemSymbolName:) geprüft ein nicht
/// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen. /// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen.
static let symbols = [ static let symbols = [
"box.truck", "truck.pickup.side", "bus", "bus.doubledecker", "car", "car.side", "suv.side", "truck.pickup.side", "box.truck", "bus",
"car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2",
] ]
} }
+52
View File
@@ -0,0 +1,52 @@
import Foundation
/// Welche Silhouette die Fahrzeug-Ansicht des Neigungsmessers zeigt.
///
/// Jeder Fall ist ein zusammengehöriges Bilderset aus Seiten- und
/// Heckansicht in Assets.xcassets, beide als Vorlage ("template")
/// exportiert, damit `foregroundStyle` sie einfärben kann. Im Picker wird
/// nur die Seitenansicht gezeigt.
enum VehicleGraphicStyle: String, CaseIterable, Identifiable, Codable, Sendable {
case vanster
case california
var id: String { rawValue }
var title: String {
switch self {
case .vanster: return "Vanster"
case .california: return "California"
}
}
var sideImageName: String {
switch self {
case .vanster: return "VansterSide"
case .california: return "CaliforniaSide"
}
}
/// Für California liegt noch keine eigene Heckansicht vor, deshalb
/// vorerst die von Vanster.
var rearImageName: String {
switch self {
case .vanster, .california: return "VansterRear"
}
}
/// Seitenverhältnis der jeweiligen Bilder ohne das würde eine Grafik
/// mit anderen Proportionen in der gemeinsamen Bildhülle verzerrt oder
/// ungewollt verkleinert erscheinen.
var sideAspect: Double {
switch self {
case .vanster: return VehicleTilt.sideAspect
case .california: return 1
}
}
var rearAspect: Double {
switch self {
case .vanster, .california: return VehicleTilt.rearAspect
}
}
}
@@ -0,0 +1,23 @@
import ActivityKit
import Foundation
/// Inhalt der Neigungsmesser-Live-Activity.
///
/// Die App und die Widget-Extension kompilieren diese Datei je für sich in
/// ihr eigenes Modul sie darf deshalb nicht von `Shared/` abhängen, sonst
/// müsste die Extension auch Bluetooth- und WatchConnectivity-Code mitbauen.
/// Werte wie `isLevel` und `instruction` kommen darum schon fertig berechnet
/// aus `LevelState` an, die Extension zeigt nur an.
struct LevelActivityAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var pitch: Double?
var roll: Double?
var isLevel: Bool
var instruction: String?
var isCalibrated: Bool
var updatedAt: Date
}
/// Name des Neigungsmessers, wie in der App vergeben.
var deviceName: String
}
@@ -0,0 +1,30 @@
import Foundation
/// Formatiert Neigungswerte mit vorangestelltem Richtungsbuchstaben statt
/// Vorzeichen "H 1.8°" statt "1.8°", "L 0.9°" statt "-0.9°".
///
/// Gemeinsam für App und Live-Activity-Widget, deshalb hier statt in
/// `Shared/`, das die Extension nicht mitkompiliert (siehe
/// `LevelActivityAttributes.swift`).
enum LevelDirectionFormatting {
/// "1.8°" Betrag ohne Vorzeichen, für Stellen, die die Richtung schon
/// im Wort oder Buchstaben ausdrücken.
static func magnitude(_ value: Double?) -> String {
guard let value else { return "" }
return String(format: "%.1f°", abs(value))
}
/// Längsneigung als Kachel: positiv (Heck höher) "H", negativ (Front
/// höher) "F". Muss zum Vorzeichen von `LevelState.pitch` passen.
static func pitchTile(_ value: Double?) -> String {
guard let value else { return "" }
return "\(value >= 0 ? "H" : "F") \(magnitude(value))"
}
/// Querneigung als Kachel: positiv (rechts höher) "R", negativ (links
/// höher) "L". Muss zum Vorzeichen von `LevelState.roll` passen.
static func rollTile(_ value: Double?) -> String {
guard let value else { return "" }
return "\(value >= 0 ? "R" : "L") \(magnitude(value))"
}
}
+7 -1
View File
@@ -32,5 +32,11 @@ gefüllte Form, in der die Details erhalten bleiben.
```bash ```bash
swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift
sips --resampleHeight 600 heckansicht.png --out /tmp/rear.png sips --resampleHeight 600 heckansicht.png --out /tmp/rear.png
/tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VehicleRear.imageset/VehicleRear.png /tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VansterRear.imageset/VansterRear.png
``` ```
Jedes Fahrzeug-Grafikset (siehe `VehicleGraphicStyle`) hat sein eigenes
Bildpaar `<Name>Side`/`<Name>Rear`. Hat die Vorlage schon einen
transparenten Hintergrund, geht die Quelldatei direkt ins Skript. Liegt sie
auf weißem Grund, muss der Rand vorher weg sonst wird die ganze Fläche
mitgefärbt.
+60 -18
View File
@@ -22,6 +22,9 @@
9DB55BAF3048B29B00CFD174 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DB55BAE3048B29B00CFD174 /* WidgetKit.framework */; }; 9DB55BAF3048B29B00CFD174 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DB55BAE3048B29B00CFD174 /* WidgetKit.framework */; };
9DB55BB13048B29B00CFD174 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DB55BB03048B29B00CFD174 /* SwiftUI.framework */; }; 9DB55BB13048B29B00CFD174 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DB55BB03048B29B00CFD174 /* SwiftUI.framework */; };
AA0000000000000000000027 /* VanAligneWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* VanAligneWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; AA0000000000000000000027 /* VanAligneWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* VanAligneWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
AA0000000000000000000043 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000042 /* ActivityKit.framework */; };
AA0000000000000000000044 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000042 /* ActivityKit.framework */; };
AA0000000000000000000047 /* VanAligneiOSWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9DB55BAC3048B29B00CFD174 /* VanAligneiOSWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -32,6 +35,13 @@
remoteGlobalIDString = AA0000000000000000000022; remoteGlobalIDString = AA0000000000000000000022;
remoteInfo = CamperMonitorWatch; remoteInfo = CamperMonitorWatch;
}; };
AA0000000000000000000045 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA0000000000000000000009 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 9DB55BAB3048B29B00CFD174;
remoteInfo = VanAligneiOSWidgetExtension;
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
@@ -46,6 +56,17 @@
name = "Embed Watch Content"; name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
AA0000000000000000000048 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
AA0000000000000000000047 /* VanAligneiOSWidgetExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -59,6 +80,7 @@
AA0000000000000000000001 /* VanAligne.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanAligne.app; sourceTree = BUILT_PRODUCTS_DIR; }; AA0000000000000000000001 /* VanAligne.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanAligne.app; sourceTree = BUILT_PRODUCTS_DIR; };
AA0000000000000000000016 /* VanAligneWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanAligneWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; AA0000000000000000000016 /* VanAligneWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanAligneWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
AA0000000000000000000032 /* VanAligneComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VanAligneComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; }; AA0000000000000000000032 /* VanAligneComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VanAligneComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; };
AA0000000000000000000042 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -100,6 +122,11 @@
path = CamperMonitorComplication; path = CamperMonitorComplication;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
AA0000000000000000000041 /* SharedActivity */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = SharedActivity;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -109,6 +136,7 @@
files = ( files = (
9DB55BB13048B29B00CFD174 /* SwiftUI.framework in Frameworks */, 9DB55BB13048B29B00CFD174 /* SwiftUI.framework in Frameworks */,
9DB55BAF3048B29B00CFD174 /* WidgetKit.framework in Frameworks */, 9DB55BAF3048B29B00CFD174 /* WidgetKit.framework in Frameworks */,
AA0000000000000000000044 /* ActivityKit.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -116,6 +144,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
AA0000000000000000000043 /* ActivityKit.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -141,6 +170,7 @@
children = ( children = (
9DB55BAE3048B29B00CFD174 /* WidgetKit.framework */, 9DB55BAE3048B29B00CFD174 /* WidgetKit.framework */,
9DB55BB03048B29B00CFD174 /* SwiftUI.framework */, 9DB55BB03048B29B00CFD174 /* SwiftUI.framework */,
AA0000000000000000000042 /* ActivityKit.framework */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -152,6 +182,7 @@
AA0000000000000000000017 /* CamperMonitorWatch */, AA0000000000000000000017 /* CamperMonitorWatch */,
AA0000000000000000000033 /* CamperMonitorComplication */, AA0000000000000000000033 /* CamperMonitorComplication */,
AA0000000000000000000018 /* Shared */, AA0000000000000000000018 /* Shared */,
AA0000000000000000000041 /* SharedActivity */,
AA0000000000000000000031 /* Config */, AA0000000000000000000031 /* Config */,
9DB55BB23048B29B00CFD174 /* VanAligneiOSWidget */, 9DB55BB23048B29B00CFD174 /* VanAligneiOSWidget */,
9DB55BAD3048B29B00CFD174 /* Frameworks */, 9DB55BAD3048B29B00CFD174 /* Frameworks */,
@@ -198,6 +229,7 @@
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
9DB55BB23048B29B00CFD174 /* VanAligneiOSWidget */, 9DB55BB23048B29B00CFD174 /* VanAligneiOSWidget */,
AA0000000000000000000041 /* SharedActivity */,
); );
name = VanAligneiOSWidgetExtension; name = VanAligneiOSWidgetExtension;
packageProductDependencies = ( packageProductDependencies = (
@@ -214,15 +246,18 @@
AA0000000000000000000003 /* Frameworks */, AA0000000000000000000003 /* Frameworks */,
AA0000000000000000000008 /* Resources */, AA0000000000000000000008 /* Resources */,
AA0000000000000000000026 /* Embed Watch Content */, AA0000000000000000000026 /* Embed Watch Content */,
AA0000000000000000000048 /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
AA0000000000000000000029 /* PBXTargetDependency */, AA0000000000000000000029 /* PBXTargetDependency */,
AA0000000000000000000046 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
AA0000000000000000000002 /* CamperMonitor */, AA0000000000000000000002 /* CamperMonitor */,
AA0000000000000000000018 /* Shared */, AA0000000000000000000018 /* Shared */,
AA0000000000000000000041 /* SharedActivity */,
); );
name = VanAligne; name = VanAligne;
productName = CamperMonitor; productName = CamperMonitor;
@@ -398,6 +433,11 @@
target = AA0000000000000000000022 /* VanAligneWatch */; target = AA0000000000000000000022 /* VanAligneWatch */;
targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */; targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */;
}; };
AA0000000000000000000046 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 9DB55BAB3048B29B00CFD174 /* VanAligneiOSWidgetExtension */;
targetProxy = AA0000000000000000000045 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@@ -435,7 +475,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = V5C6Q86XJR;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNDECLARED_SELECTOR = YES;
@@ -454,7 +494,7 @@
); );
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.VanAligneiOSWidget; PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.VanAligneiOSWidget;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -501,7 +541,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = V5C6Q86XJR;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNDECLARED_SELECTOR = YES;
@@ -520,7 +560,7 @@
); );
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.VanAligneiOSWidget; PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.VanAligneiOSWidget;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -596,13 +636,14 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = P8J6283TT5; DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist"; INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro"; INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
@@ -612,7 +653,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS; PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -628,13 +669,14 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = P8J6283TT5; DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist"; INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro"; INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
@@ -644,7 +686,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS; PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -659,18 +701,18 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = P8J6283TT5; DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro"; INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = fototeddy.VanAligneiOS; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.s0.fototeddy.VanAligneiOS;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.watchkitapp; PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos; SDKROOT = watchos;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -691,18 +733,18 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = P8J6283TT5; DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro"; INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = fototeddy.VanAligneiOS; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.s0.fototeddy.VanAligneiOS;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.watchkitapp; PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos; SDKROOT = watchos;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -722,7 +764,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = V5C6Q86XJR;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist"; INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung; INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
@@ -732,7 +774,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.watchkitapp.levelwidget; PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.watchkitapp.levelwidget;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = watchos; SDKROOT = watchos;
@@ -753,7 +795,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = V5C6Q86XJR;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist"; INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung; INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
@@ -763,7 +805,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.watchkitapp.levelwidget; PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.watchkitapp.levelwidget;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = watchos; SDKROOT = watchos;
@@ -9,72 +9,280 @@ import ActivityKit
import WidgetKit import WidgetKit
import SwiftUI import SwiftUI
struct VanAligneiOSWidgetAttributes: ActivityAttributes { private struct LevelIcon: View {
public struct ContentState: Codable, Hashable { let isLevel: Bool
// Dynamic stateful properties about your activity go here! let hasReading: Bool
var emoji: String
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)))
} }
// Fixed non-changing properties about your activity go here! var body: some View {
var name: String 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 VanAligneiOSWidgetLiveActivity: Widget { struct VanAligneiOSWidgetLiveActivity: Widget {
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
ActivityConfiguration(for: VanAligneiOSWidgetAttributes.self) { context in ActivityConfiguration(for: LevelActivityAttributes.self) { context in
// Lock screen/banner UI goes here LevelActivityContent(attributes: context.attributes, state: context.state)
VStack { .activityBackgroundTint(Color(white: 0.08))
Text("Hello \(context.state.emoji)") .activitySystemActionForegroundColor(.white)
}
.activityBackgroundTint(Color.cyan)
.activitySystemActionForegroundColor(Color.black)
} dynamicIsland: { context in } 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 { DynamicIsland {
// Expanded UI goes here. Compose the expanded UI through
// various regions, like leading/trailing/center/bottom
DynamicIslandExpandedRegion(.leading) { DynamicIslandExpandedRegion(.leading) {
Text("Leading") LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
} .font(.title3)
DynamicIslandExpandedRegion(.trailing) {
Text("Trailing")
} }
DynamicIslandExpandedRegion(.bottom) { DynamicIslandExpandedRegion(.bottom) {
Text("Bottom \(context.state.emoji)") Text(context.state.instruction ?? context.attributes.deviceName)
// more content .font(.caption)
.foregroundStyle(context.state.isLevel ? .green : .primary)
.lineLimit(1)
} }
} compactLeading: { } compactLeading: {
Text("L") LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
} compactTrailing: { } compactTrailing: {
Text("T \(context.state.emoji)") EmptyView()
} minimal: { } minimal: {
Text(context.state.emoji) LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
} }
.widgetURL(URL(string: "http://www.apple.com")) .keylineTint(context.state.isLevel ? .green : .orange)
.keylineTint(Color.red) }
.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)
} }
} }
} }
extension VanAligneiOSWidgetAttributes { /// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus
fileprivate static var preview: VanAligneiOSWidgetAttributes { /// ein Blick-Symbol statt eines ganzen Satzes zum Lesen im Vorbeifahren.
VanAligneiOSWidgetAttributes(name: "World") ///
/// 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)
} }
} }
extension VanAligneiOSWidgetAttributes.ContentState { /// Sperrbildschirm- und Banner-Ansicht.
fileprivate static var smiley: VanAligneiOSWidgetAttributes.ContentState { private struct LockScreenLevelView: View {
VanAligneiOSWidgetAttributes.ContentState(emoji: "😀") let attributes: LevelActivityAttributes
} let state: LevelActivityAttributes.ContentState
fileprivate static var starEyes: VanAligneiOSWidgetAttributes.ContentState { /// Längszeile, z.B. "Heck steht höher 1.8°" nur wenn ausserhalb der
VanAligneiOSWidgetAttributes.ContentState(emoji: "🤩") /// 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)
}
} }
#Preview("Notification", as: .content, using: VanAligneiOSWidgetAttributes.preview) { 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() VanAligneiOSWidgetLiveActivity()
} contentStates: { } contentStates: {
VanAligneiOSWidgetAttributes.ContentState.smiley LevelActivityAttributes.ContentState.level
VanAligneiOSWidgetAttributes.ContentState.starEyes LevelActivityAttributes.ContentState.tilted
} }