forked from fritob/Camper-Monitor
Solar-Integration und dev_watch-Geräte zusammenführen
solar-integration (aus dem separaten VanAligneiOS-Repo) und dev_watch haben unabhängige Git-Historien, decken aber überlappende und sich ergänzende Funktionen ab. Übernommen aus solar-integration: Votronic- Solar-ESP-Anbindung samt Geräterolle, die Live-Activity/Widget-Extension fürs Sperrbildschirm/Dynamic-Island/CarPlay, das Querformat-Layout für Libelle/Fahrzeug-Ansicht und Ausrichtungs-Assistent, sowie die mehreren Fahrzeuggrafik-Stile (Vanster/California). Beibehalten aus dev_watch: alle zusätzlichen Geräteprotokolle (Daly-/JBD-BMS, Alpicool- Kühlbox, WattCycle, Victron), die dort zwischenzeitlich entstanden. Die Xcode-Projektdatei wurde von Hand um die neue Widget-Extension samt SharedActivity-Gruppe erweitert (Datei-synchronisierte Gruppen, kein App-Group-Entitlement nötig). Build für App, Watch und Widget-Extension geprüft (Debug und Release). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
812874baef
commit
e9b9c5bcd5
@@ -195,6 +195,8 @@ private struct ConfigureDeviceView: View {
|
||||
}
|
||||
} else if discovery.isLevelSensor {
|
||||
role = .leveling
|
||||
} else if discovery.isVotronicSolarESPSensor {
|
||||
role = .solar
|
||||
} else if let name = discovery.name?.lowercased(),
|
||||
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
|
||||
role = .fridge
|
||||
@@ -206,6 +208,8 @@ private struct ConfigureDeviceView: View {
|
||||
// ohnehin darüber unter "Gefunden als".
|
||||
if discovery.isLevelSensor {
|
||||
name = "Nivellierung"
|
||||
} else if discovery.isVotronicSolarESPSensor {
|
||||
name = role.title
|
||||
} else if let advertised = discovery.name, advertised.count <= 20,
|
||||
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
|
||||
name = advertised
|
||||
|
||||
@@ -11,6 +11,7 @@ struct AlignmentAssistantView: View {
|
||||
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
@State private var assistant = AlignmentAssistant()
|
||||
@State private var didAnnounceTarget = false
|
||||
@@ -18,48 +19,18 @@ struct AlignmentAssistantView: View {
|
||||
|
||||
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 {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Picker("Darstellung", selection: $displayStyle) {
|
||||
ForEach(LevelDisplayStyle.allCases) { style in
|
||||
Text(style.title).tag(style)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
Group {
|
||||
if isLandscape {
|
||||
landscapeLayout
|
||||
} else {
|
||||
portraitLayout
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Ausrichtungs-Assistent")
|
||||
@@ -95,10 +66,94 @@ struct AlignmentAssistantView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layouts
|
||||
|
||||
private var portraitLayout: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
picker.padding(.top, 8)
|
||||
|
||||
display
|
||||
|
||||
if !isLive { disconnectedBanner }
|
||||
|
||||
adviceBanner
|
||||
|
||||
readings
|
||||
|
||||
if let best = assistant.best, let gain = assistant.improvementAtBest,
|
||||
let seconds = assistant.timeSinceBest {
|
||||
bestPointCard(best: best, gain: gain, seconds: seconds)
|
||||
}
|
||||
|
||||
wedgeSection
|
||||
|
||||
resetButton
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Anzeige links, alles zum Rangieren Nötige rechts – ohne Scrollen, denn
|
||||
/// im Querformat schaut man beiläufig hin, nicht in Ruhe. Die
|
||||
/// Bestpunkt-Karte und der ausführliche Verbindungs-Hinweis bleiben dafür
|
||||
/// dem Hochformat vorbehalten; die Keilhöhen bleiben in jedem Fall sichtbar.
|
||||
private var landscapeLayout: some View {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
VStack(spacing: 8) {
|
||||
picker
|
||||
display
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
if !isLive { compactDisconnectedBanner }
|
||||
adviceBanner
|
||||
readings
|
||||
wedgeSection
|
||||
resetButton
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
// MARK: - Bausteine
|
||||
|
||||
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
|
||||
/// Hinweis sähe das aus, als hinge die App – man rangiert dann nach einem
|
||||
/// Wert, der längst nicht mehr gilt.
|
||||
@@ -120,40 +175,54 @@ struct AlignmentAssistantView: View {
|
||||
.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 {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: assistant.hasReachedTarget
|
||||
? "checkmark.circle.fill" : assistant.trend.symbol)
|
||||
.font(.title)
|
||||
.font(isLandscape ? .title2 : .title)
|
||||
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
|
||||
Text(assistant.advice)
|
||||
.font(.title3.weight(.medium))
|
||||
.font(isLandscape ? .subheadline.weight(.medium) : .title3.weight(.medium))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding()
|
||||
.padding(isLandscape ? 10 : 16)
|
||||
.background(assistant.hasReachedTarget ? Color.green.opacity(0.15)
|
||||
: Color(.secondarySystemGroupedBackground),
|
||||
in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var readings: some View {
|
||||
HStack(spacing: 12) {
|
||||
reading("Längs", state.pitch)
|
||||
reading("Quer", state.roll)
|
||||
reading("Gesamt", assistant.current?.deviation)
|
||||
HStack(spacing: isLandscape ? 8 : 12) {
|
||||
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
||||
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
||||
if !isLandscape {
|
||||
reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reading(_ title: String, _ value: Double?) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(value.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.font(.title2.weight(.semibold).monospacedDigit())
|
||||
private func reading(_ title: String, _ text: String) -> some View {
|
||||
VStack(spacing: isLandscape ? 1 : 4) {
|
||||
Text(text)
|
||||
.font((isLandscape ? Font.callout : .title2).weight(.semibold).monospacedDigit())
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.vertical, isLandscape ? 6 : 12)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
|
||||
}
|
||||
|
||||
@@ -187,23 +256,25 @@ struct AlignmentAssistantView: View {
|
||||
trackWidth: width, wheelbase: base)
|
||||
}()
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: isLandscape ? 6 : 10) {
|
||||
Label("Auffahrkeile", systemImage: "triangle.fill")
|
||||
.font(.headline)
|
||||
.font(isLandscape ? .subheadline.weight(.semibold) : .headline)
|
||||
|
||||
if profile?.trackWidth == nil || profile?.wheelbase == nil {
|
||||
Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
|
||||
+ "sich beim Fahrzeug hinterlegen.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let lift, !lift.isNegligible {
|
||||
WheelLiftPlan(lift: lift)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
|
||||
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let lift, !lift.isNegligible {
|
||||
WheelLiftPlan(lift: lift, isCompact: isLandscape)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
if !isLandscape {
|
||||
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
|
||||
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Keine Keile nötig.")
|
||||
.font(.callout)
|
||||
@@ -211,7 +282,7 @@ struct AlignmentAssistantView: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.padding(isLandscape ? 10 : 16)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
@@ -229,4 +300,3 @@ struct AlignmentAssistantView: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ struct DeviceDetailView: View {
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
@State private var editedName = ""
|
||||
/// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der
|
||||
@@ -37,104 +38,126 @@ struct DeviceDetailView: View {
|
||||
|
||||
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
|
||||
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
|
||||
|
||||
/// 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 }
|
||||
|
||||
/// Nur der Neigungsmesser bekommt die Querformat-Sonderbehandlung
|
||||
/// (Verbindungsstatus ans Ende, Anzeige auf Bildschirmhöhe) – andere
|
||||
/// Sensoren behalten ihre bisherige Reihenfolge und Grösse.
|
||||
private var showsCompactLevelLayout: Bool { isLandscape && currentDevice.role == .leveling }
|
||||
|
||||
private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
statusSection
|
||||
if needsKeyAttention { keyPrompt }
|
||||
// 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 {
|
||||
// Im Querformat soll die Libelle/Fahrzeug-Ansicht sofort
|
||||
// sichtbar sein, ohne erst am Verbindungsstatus
|
||||
// vorbeizuscrollen – der rutscht dort ganz ans Ende. Gilt
|
||||
// nur für den Neigungsmesser, siehe `showsCompactLevelLayout`.
|
||||
if !showsCompactLevelLayout { statusSection }
|
||||
if needsKeyAttention { keyPrompt }
|
||||
|
||||
if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
|
||||
FridgeControls(device: currentDevice, state: fridge)
|
||||
}
|
||||
if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
|
||||
FridgeControls(device: currentDevice, state: fridge)
|
||||
}
|
||||
|
||||
if currentDevice.role == .leveling {
|
||||
LevelControls(device: currentDevice,
|
||||
state: bluetooth.levelStates[device.id] ?? LevelState())
|
||||
}
|
||||
if currentDevice.role == .leveling {
|
||||
LevelControls(device: currentDevice,
|
||||
state: bluetooth.levelStates[device.id] ?? LevelState(),
|
||||
availableHeight: geometry.size.height)
|
||||
}
|
||||
|
||||
if let snapshot, !snapshot.metrics.isEmpty {
|
||||
Section("Messwerte") {
|
||||
ForEach(snapshot.metrics) { metric in
|
||||
LabeledContent(metric.label) {
|
||||
Text(metric.formattedWithUnit)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(metric.value == nil ? .secondary : .primary)
|
||||
if let snapshot, !snapshot.metrics.isEmpty {
|
||||
Section("Messwerte") {
|
||||
ForEach(snapshot.metrics) { metric in
|
||||
LabeledContent(metric.label) {
|
||||
Text(metric.formattedWithUnit)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(metric.value == nil ? .secondary : .primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if samples.count > 1, let primary = snapshot?.primaryMetric {
|
||||
Section("Verlauf – \(primary.label)") {
|
||||
Chart(samples) { sample in
|
||||
AreaMark(x: .value("Zeit", sample.time),
|
||||
y: .value(primary.label, sample.value))
|
||||
.foregroundStyle(.tint.opacity(0.15))
|
||||
LineMark(x: .value("Zeit", sample.time),
|
||||
y: .value(primary.label, sample.value))
|
||||
.foregroundStyle(.tint)
|
||||
.interpolationMethod(.monotone)
|
||||
}
|
||||
.chartYAxisLabel(primary.unit)
|
||||
.frame(height: 180)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
if let snapshot, !snapshot.cellVoltages.isEmpty {
|
||||
cellSection(snapshot.cellVoltages)
|
||||
}
|
||||
|
||||
if let snapshot, !snapshot.info.isEmpty {
|
||||
Section("Gerät") {
|
||||
ForEach(snapshot.info) { item in
|
||||
LabeledContent(item.label, value: item.value)
|
||||
if samples.count > 1, let primary = snapshot?.primaryMetric {
|
||||
Section("Verlauf – \(primary.label)") {
|
||||
Chart(samples) { sample in
|
||||
AreaMark(x: .value("Zeit", sample.time),
|
||||
y: .value(primary.label, sample.value))
|
||||
.foregroundStyle(.tint.opacity(0.15))
|
||||
LineMark(x: .value("Zeit", sample.time),
|
||||
y: .value(primary.label, sample.value))
|
||||
.foregroundStyle(.tint)
|
||||
.interpolationMethod(.monotone)
|
||||
}
|
||||
.chartYAxisLabel(primary.unit)
|
||||
.frame(height: 180)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let snapshot, snapshot.temperatures.count > 1 {
|
||||
Section("Temperaturen") {
|
||||
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
||||
LabeledContent("Fühler \(index + 1)") {
|
||||
Text(String(format: "%.0f °C", value)).monospacedDigit()
|
||||
if let snapshot, !snapshot.cellVoltages.isEmpty {
|
||||
cellSection(snapshot.cellVoltages)
|
||||
}
|
||||
|
||||
if let snapshot, !snapshot.info.isEmpty {
|
||||
Section("Gerät") {
|
||||
ForEach(snapshot.info) { item in
|
||||
LabeledContent(item.label, value: item.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if currentDevice.role.transport == .advertisement {
|
||||
if showsTechnicalDetails { diagnosticsSection }
|
||||
} else if showsTechnicalDetails {
|
||||
bmsDiagnosticsSection
|
||||
}
|
||||
if showsCompactLevelLayout { statusSection }
|
||||
|
||||
settingsSection
|
||||
}
|
||||
.navigationTitle(currentDevice.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onDisappear {
|
||||
saveName()
|
||||
// Die Kühlbox wird nur verbunden, solange man sie ansieht – jede
|
||||
// Verbindung meldet sich an ihrem Display an.
|
||||
bluetooth.endSession(for: currentDevice)
|
||||
}
|
||||
.onAppear {
|
||||
editedName = currentDevice.name
|
||||
keyInput = store.victronKeyText(for: device.id) ?? ""
|
||||
bluetooth.beginSession(for: currentDevice)
|
||||
}
|
||||
.confirmationDialog("Gerät entfernen?",
|
||||
isPresented: $showDeleteConfirmation,
|
||||
titleVisibility: .visible) {
|
||||
Button("Entfernen", role: .destructive) {
|
||||
store.remove(device)
|
||||
bluetooth.refreshConfiguration()
|
||||
dismiss()
|
||||
if let snapshot, snapshot.temperatures.count > 1 {
|
||||
Section("Temperaturen") {
|
||||
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
||||
LabeledContent("Fühler \(index + 1)") {
|
||||
Text(String(format: "%.0f °C", value)).monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if currentDevice.role.transport == .advertisement {
|
||||
if showsTechnicalDetails { diagnosticsSection }
|
||||
} else if showsTechnicalDetails {
|
||||
bmsDiagnosticsSection
|
||||
}
|
||||
|
||||
settingsSection
|
||||
}
|
||||
.navigationTitle(currentDevice.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onDisappear {
|
||||
saveName()
|
||||
// Die Kühlbox wird nur verbunden, solange man sie ansieht – jede
|
||||
// Verbindung meldet sich an ihrem Display an.
|
||||
bluetooth.endSession(for: currentDevice)
|
||||
}
|
||||
.onAppear {
|
||||
editedName = currentDevice.name
|
||||
keyInput = store.victronKeyText(for: device.id) ?? ""
|
||||
bluetooth.beginSession(for: currentDevice)
|
||||
}
|
||||
.confirmationDialog("Gerät entfernen?",
|
||||
isPresented: $showDeleteConfirmation,
|
||||
titleVisibility: .visible) {
|
||||
Button("Entfernen", role: .destructive) {
|
||||
store.remove(device)
|
||||
bluetooth.refreshConfiguration()
|
||||
dismiss()
|
||||
}
|
||||
} message: {
|
||||
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
|
||||
}
|
||||
} message: {
|
||||
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,45 +104,76 @@ struct LevelBubble: View {
|
||||
struct LevelControls: View {
|
||||
let device: ConfiguredDevice
|
||||
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(DeviceStore.self) private var store
|
||||
@Environment(LevelActivityManager.self) private var levelActivity
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@State private var showAssistant = false
|
||||
@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 {
|
||||
Section {
|
||||
VStack(spacing: 16) {
|
||||
Picker("Darstellung", selection: $displayStyle) {
|
||||
ForEach(LevelDisplayStyle.allCases) { style in
|
||||
Text(style.title).tag(style)
|
||||
if isLandscape {
|
||||
HStack(alignment: .center, spacing: 20) {
|
||||
display
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
picker
|
||||
if let instruction = state.instruction {
|
||||
Label(instruction,
|
||||
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
HStack(spacing: 16) {
|
||||
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
||||
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.frame(height: landscapeContentHeight)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
VStack(spacing: 16) {
|
||||
picker
|
||||
|
||||
display
|
||||
|
||||
if let instruction = state.instruction {
|
||||
Label(instruction,
|
||||
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||||
.font(.headline)
|
||||
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
HStack(spacing: 24) {
|
||||
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
||||
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
switch displayStyle {
|
||||
case .bubble:
|
||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(maxHeight: 220)
|
||||
case .vehicle:
|
||||
VehicleTiltView(pitch: state.pitch, roll: state.roll)
|
||||
}
|
||||
|
||||
if let instruction = state.instruction {
|
||||
Label(instruction,
|
||||
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||||
.font(.headline)
|
||||
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
HStack(spacing: 24) {
|
||||
reading("Längs", state.pitch)
|
||||
reading("Quer", state.roll)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -177,6 +208,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
|
||||
// der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt.
|
||||
Section {
|
||||
@@ -194,17 +248,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 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
|
||||
/// ohne ihn stimmt die Anzeige nicht.
|
||||
private var setupSummary: String {
|
||||
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) {
|
||||
Text(value.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
Text(text)
|
||||
.font(.title2.weight(.semibold).monospacedDigit())
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
|
||||
@@ -15,31 +15,39 @@ import SwiftUI
|
||||
struct VehicleTiltView: View {
|
||||
let pitch: 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 {
|
||||
VStack(spacing: 20) {
|
||||
tiltPanel(image: "VehicleSide",
|
||||
angle: pitch,
|
||||
title: "Längs",
|
||||
lowerLabel: "Front",
|
||||
upperLabel: "Heck",
|
||||
aspect: VehicleTilt.sideAspect)
|
||||
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) {
|
||||
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
|
||||
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
|
||||
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
|
||||
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
|
||||
|
||||
tiltPanel(image: "VehicleRear",
|
||||
angle: roll,
|
||||
title: "Quer",
|
||||
lowerLabel: "links",
|
||||
upperLabel: "rechts",
|
||||
aspect: VehicleTilt.rearAspect)
|
||||
|
||||
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
|
||||
Text(String(format: "Neigung %.0f-fach überhöht dargestellt – "
|
||||
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
|
||||
VehicleTilt.exaggeration))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
|
||||
Text(String(format: "Neigung %.0f-fach überhöht dargestellt – "
|
||||
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
|
||||
VehicleTilt.exaggeration))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,13 +57,13 @@ struct VehicleTiltView: View {
|
||||
lowerLabel: String,
|
||||
upperLabel: String,
|
||||
aspect: Double) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
VStack(spacing: compact ? 4 : 8) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.font(compact ? .caption2.weight(.medium) : .subheadline.weight(.medium))
|
||||
Spacer()
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -75,14 +83,14 @@ struct VehicleTiltView: View {
|
||||
.animation(.spring(duration: 0.4), value: angle)
|
||||
.opacity(angle == nil ? 0.3 : 1)
|
||||
}
|
||||
.frame(height: 110)
|
||||
.frame(height: compact ? compactPanelHeight : 110)
|
||||
|
||||
HStack {
|
||||
Text(lowerLabel)
|
||||
Spacer()
|
||||
Text(upperLabel)
|
||||
}
|
||||
.font(.caption2)
|
||||
.font(compact ? .system(size: 9) : .caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user