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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user