Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2638438494 | ||
|
|
0fdef337fc | ||
|
|
1b06ac36d1 | ||
|
|
46a4da5e37 | ||
|
|
45aa7cf984 | ||
|
|
f175372318 | ||
|
|
4a8a40eea2 | ||
|
|
9a3b5cb472 | ||
|
|
bfe2b00a5c |
|
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 |
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,49 +19,19 @@ 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")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else if let lift, !lift.isNegligible {
|
} else if let lift, !lift.isNegligible {
|
||||||
WheelLiftPlan(lift: lift)
|
WheelLiftPlan(lift: lift, isCompact: isLandscape)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
if !isLandscape {
|
||||||
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
|
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
|
||||||
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
|
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,13 +36,24 @@ 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 {
|
||||||
|
// Die Höhe der Libelle/Fahrzeug-Anzeige im Querformat richtet sich
|
||||||
|
// nach der tatsächlich verfügbaren Bildschirmhöhe, nicht nach einem
|
||||||
|
// festen Wert – deshalb misst ein GeometryReader die Liste von aussen.
|
||||||
|
GeometryReader { geometry in
|
||||||
List {
|
List {
|
||||||
statusSection
|
// 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 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 {
|
||||||
@@ -50,7 +62,8 @@ struct DeviceDetailView: View {
|
|||||||
|
|
||||||
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 {
|
||||||
@@ -94,6 +107,8 @@ struct DeviceDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isLandscape { statusSection }
|
||||||
|
|
||||||
/* if let snapshot, snapshot.temperatures.count > 1 {
|
/* if let snapshot, snapshot.temperatures.count > 1 {
|
||||||
Section("Temperaturen") {
|
Section("Temperaturen") {
|
||||||
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
||||||
@@ -137,6 +152,7 @@ struct DeviceDetailView: View {
|
|||||||
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
|
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Abschnitte
|
// MARK: - Abschnitte
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,29 +105,59 @@ 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)
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
|
|
||||||
switch displayStyle {
|
VStack(spacing: 12) {
|
||||||
case .bubble:
|
picker
|
||||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
if let instruction = state.instruction {
|
||||||
.frame(maxHeight: 220)
|
Label(instruction,
|
||||||
case .vehicle:
|
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||||||
VehicleTiltView(pitch: state.pitch, roll: state.roll)
|
.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 {
|
if let instruction = state.instruction {
|
||||||
Label(instruction,
|
Label(instruction,
|
||||||
@@ -137,13 +168,14 @@ struct LevelControls: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 24) {
|
HStack(spacing: 24) {
|
||||||
reading("Längs", state.pitch)
|
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
||||||
reading("Quer", state.roll)
|
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button {
|
Button {
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|
||||||
|
|||||||
@@ -15,22 +15,29 @@ 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 {
|
||||||
|
if compact {
|
||||||
|
HStack(alignment: .top, spacing: 16) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
tiltPanel(image: "VehicleSide",
|
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
|
||||||
angle: pitch,
|
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
|
||||||
title: "Längs",
|
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
|
||||||
lowerLabel: "Front",
|
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
|
||||||
upperLabel: "Heck",
|
|
||||||
aspect: VehicleTilt.sideAspect)
|
|
||||||
|
|
||||||
tiltPanel(image: "VehicleRear",
|
|
||||||
angle: roll,
|
|
||||||
title: "Quer",
|
|
||||||
lowerLabel: "links",
|
|
||||||
upperLabel: "rechts",
|
|
||||||
aspect: VehicleTilt.rearAspect)
|
|
||||||
|
|
||||||
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
|
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
|
||||||
Text(String(format: "Neigung %.0f-fach überhöht dargestellt – "
|
Text(String(format: "Neigung %.0f-fach überhöht dargestellt – "
|
||||||
@@ -42,6 +49,7 @@ struct VehicleTiltView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func tiltPanel(image: String,
|
private func tiltPanel(image: String,
|
||||||
angle: Double?,
|
angle: Double?,
|
||||||
@@ -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 |
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixed non-changing properties about your activity go here!
|
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
|
||||||
var name: String
|
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 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
|
||||||
|
|
||||||
|
/// 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))"
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate static var starEyes: VanAligneiOSWidgetAttributes.ContentState {
|
/// Querzeile, z.B. "links steht höher 0.9°".
|
||||||
VanAligneiOSWidgetAttributes.ContentState(emoji: "🤩")
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview("Notification", as: .content, using: VanAligneiOSWidgetAttributes.preview) {
|
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) {
|
||||||
VanAligneiOSWidgetLiveActivity()
|
VanAligneiOSWidgetLiveActivity()
|
||||||
} contentStates: {
|
} contentStates: {
|
||||||
VanAligneiOSWidgetAttributes.ContentState.smiley
|
LevelActivityAttributes.ContentState.level
|
||||||
VanAligneiOSWidgetAttributes.ContentState.starEyes
|
LevelActivityAttributes.ContentState.tilted
|
||||||
}
|
}
|
||||||
|
|||||||