Add landscape layouts for level display, vehicle view, and alignment assistant
Rotating the phone into landscape while leveling used to leave a portrait-shaped column of controls that needed scrolling to take in. All three level-integration screens now branch on verticalSizeClass: the bubble/vehicle status in the device screen and the full alignment assistant (advice, readings, and wedge heights) lay out side-by-side instead of stacked, sized to fit without scrolling. The device screen also measures its actual available height via a GeometryReader and feeds it to the level display so the bubble/vehicle graphic scales to fill the landscape screen rather than being capped at a fixed size, and the connection-status section moves to the bottom of the list in landscape so it doesn't compete with the display for space. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
21f5d8de62
commit
8d975d2c4d
@@ -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
|
||||
@@ -38,6 +39,15 @@ struct DeviceDetailView: View {
|
||||
/// Nur solange die App läuft: siehe `DeviceHistory`.
|
||||
@State private var selectedHistoryMetricKey: String?
|
||||
|
||||
/// 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 snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
|
||||
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
|
||||
private var deviceHistory: DeviceHistory { bluetooth.history[device.id] ?? [:] }
|
||||
@@ -62,119 +72,131 @@ struct DeviceDetailView: View {
|
||||
}
|
||||
|
||||
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 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 currentDevice.role == .leveling {
|
||||
LevelControls(device: currentDevice,
|
||||
state: bluetooth.levelStates[device.id] ?? LevelState(),
|
||||
availableHeight: geometry.size.height)
|
||||
}
|
||||
}
|
||||
|
||||
if let metric = selectedMetric, samples.count > 1 {
|
||||
Section {
|
||||
if chartableMetrics.count > 1 {
|
||||
Picker("Messgrösse", selection: Binding(
|
||||
get: { metric.key },
|
||||
set: { selectedHistoryMetricKey = $0 }
|
||||
)) {
|
||||
ForEach(chartableMetrics) { candidate in
|
||||
Text(candidate.label).tag(candidate.key)
|
||||
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)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
Chart(samples) { sample in
|
||||
AreaMark(x: .value("Zeit", sample.time),
|
||||
y: .value(metric.label, sample.value))
|
||||
.foregroundStyle(.tint.opacity(0.15))
|
||||
LineMark(x: .value("Zeit", sample.time),
|
||||
y: .value(metric.label, sample.value))
|
||||
.foregroundStyle(.tint)
|
||||
.interpolationMethod(.monotone)
|
||||
}
|
||||
.chartYAxisLabel(metric.unit)
|
||||
.frame(height: 180)
|
||||
.padding(.vertical, 8)
|
||||
} header: {
|
||||
Text("Verlauf – \(metric.label)")
|
||||
} footer: {
|
||||
Text("Nur für die laufende Sitzung – wird beim Neustart der App verworfen.")
|
||||
}
|
||||
}
|
||||
|
||||
if let snapshot, !snapshot.cellVoltages.isEmpty {
|
||||
cellSection(snapshot.cellVoltages)
|
||||
}
|
||||
|
||||
if let snapshot, !snapshot.info.isEmpty {
|
||||
Section("Gerät") {
|
||||
ForEach(snapshot.info) { item in
|
||||
LabeledContent(item.label, value: item.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* if let snapshot, snapshot.temperatures.count > 1 {
|
||||
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 metric = selectedMetric, samples.count > 1 {
|
||||
Section {
|
||||
if chartableMetrics.count > 1 {
|
||||
Picker("Messgrösse", selection: Binding(
|
||||
get: { metric.key },
|
||||
set: { selectedHistoryMetricKey = $0 }
|
||||
)) {
|
||||
ForEach(chartableMetrics) { candidate in
|
||||
Text(candidate.label).tag(candidate.key)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
Chart(samples) { sample in
|
||||
AreaMark(x: .value("Zeit", sample.time),
|
||||
y: .value(metric.label, sample.value))
|
||||
.foregroundStyle(.tint.opacity(0.15))
|
||||
LineMark(x: .value("Zeit", sample.time),
|
||||
y: .value(metric.label, sample.value))
|
||||
.foregroundStyle(.tint)
|
||||
.interpolationMethod(.monotone)
|
||||
}
|
||||
.chartYAxisLabel(metric.unit)
|
||||
.frame(height: 180)
|
||||
.padding(.vertical, 8)
|
||||
} header: {
|
||||
Text("Verlauf – \(metric.label)")
|
||||
} footer: {
|
||||
Text("Nur für die laufende Sitzung – wird beim Neustart der App verworfen.")
|
||||
}
|
||||
}
|
||||
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user