Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 72 additions & 11 deletions Profundum/Profundum/Views/DiveDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct DiveDetailView: View {
@State private var samples: [DiveSample] = []
@State private var tags: [String] = []
@State private var gasMixes: [GasMix] = []
@State private var deviceSettings: [DiveDeviceSettings] = []
@State private var stats: DiveStats?
@State private var showEditSheet = false
@State private var loadedTeammateIds: [String] = []
Expand Down Expand Up @@ -87,6 +88,43 @@ struct DiveDetailView: View {
return filtered.isEmpty ? samples : filtered
}

/// Gas mixes scoped to the selected device.
private var scopedGasMixes: [GasMix] {
guard let deviceId = selectedDeviceId else { return gasMixes }
let filtered = gasMixes.filter { $0.deviceId == deviceId }
return filtered.isEmpty ? gasMixes : filtered
}

/// Per-device settings for the selected computer.
private var activeDeviceSettings: DiveDeviceSettings? {
if let deviceId = selectedDeviceId {
return deviceSettings.first(where: { $0.deviceId == deviceId })
}
return deviceSettings.first(where: { $0.isPrimary }) ?? deviceSettings.first
}

/// Footnote when computers disagree on GF or deco model.
private var deviceSettingsConflictNote: String? {
guard deviceSettings.count > 1 else { return nil }
let gfPairs = Set(deviceSettings.compactMap { setting -> String? in
guard let low = setting.gfLow, let high = setting.gfHigh else { return nil }
return "\(low)/\(high)"
})
let models = Set(deviceSettings.compactMap(\.decoModel))
guard gfPairs.count > 1 || models.count > 1 else { return nil }

let deviceName: String
if let deviceId = selectedDeviceId, let name = devicesWithSamples[deviceId] {
deviceName = name
} else if let primary = deviceSettings.first(where: { $0.isPrimary }),
let name = devicesWithSamples[primary.deviceId] {
deviceName = name
} else {
deviceName = "primary computer"
}
return "Varies by computer — showing \(deviceName)"
}

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Expand All @@ -107,8 +145,11 @@ struct DiveDetailView: View {

statsSection

// Deco / GF info
if dive.decoModel != nil || dive.gfLow != nil || dive.endGf99 != nil {
// Deco / GF info (device settings with dive-level fallback for
// dives imported before per-device settings existed)
if (activeDeviceSettings?.decoModel ?? dive.decoModel) != nil
|| (activeDeviceSettings?.gfLow ?? dive.gfLow) != nil
|| dive.endGf99 != nil {
Divider()
decoSection
}
Expand Down Expand Up @@ -138,7 +179,7 @@ struct DiveDetailView: View {
}

// Gas mixes
if !gasMixes.isEmpty {
if !scopedGasMixes.isEmpty {
Divider()
gasMixSection
}
Expand Down Expand Up @@ -526,7 +567,7 @@ struct DiveDetailView: View {
showAtPlusFive: showAtPlusFive,
showDeltaFive: showDeltaFive,
showSurfGf: showSurfGf,
gasMixes: gasMixes,
gasMixes: scopedGasMixes,
showPpo2: showPpo2,
showTankPressure: showTankPressure,
pressureUnit: appState.pressureUnit,
Expand All @@ -548,7 +589,7 @@ struct DiveDetailView: View {
samples: chartSamples,
depthUnit: appState.depthUnit,
temperatureUnit: appState.temperatureUnit,
gasMixes: gasMixes,
gasMixes: scopedGasMixes,
pressureUnit: appState.pressureUnit,
bottomEndT: stats?.bottomEndT,
decoStartT: stats?.decoStartT,
Expand All @@ -562,7 +603,7 @@ struct DiveDetailView: View {
samples: chartSamples,
depthUnit: appState.depthUnit,
temperatureUnit: appState.temperatureUnit,
gasMixes: gasMixes,
gasMixes: scopedGasMixes,
pressureUnit: appState.pressureUnit,
bottomEndT: stats?.bottomEndT,
decoStartT: stats?.decoStartT,
Expand All @@ -573,7 +614,13 @@ struct DiveDetailView: View {
}
#endif
.sheet(isPresented: $showReplaySheet) {
ReplayProfileSheet(dive: dive, gasMixes: gasMixes, stats: stats, samples: samples)
ReplayProfileSheet(
dive: dive,
gasMixes: scopedGasMixes,
stats: stats,
samples: chartSamples,
deviceSettings: activeDeviceSettings
)
}
}

Expand All @@ -595,17 +642,24 @@ struct DiveDetailView: View {
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 12) {
if let model = dive.decoModel {
if let model = activeDeviceSettings?.decoModel ?? dive.decoModel {
StatCard(title: "Deco Model", value: model.capitalized)
}
if let gfLow = dive.gfLow, let gfHigh = dive.gfHigh {
if let gfLow = activeDeviceSettings?.gfLow ?? dive.gfLow,
let gfHigh = activeDeviceSettings?.gfHigh ?? dive.gfHigh {
StatCard(title: "GF Setting", value: "\(gfLow)/\(gfHigh)")
}
if let endGf = dive.endGf99 {
StatCard(title: "End GF99", value: String(format: "%.0f%%", endGf),
color: endGf > 85 ? .orange : nil)
}
}

if let note = deviceSettingsConflictNote {
Text(note)
.font(.caption)
.foregroundColor(.secondary)
}
}
}

Expand Down Expand Up @@ -779,7 +833,7 @@ struct DiveDetailView: View {
Text("Gas Mixes")
.font(.headline)

ForEach(gasMixes) { mix in
ForEach(scopedGasMixes) { mix in
HStack {
Text(gasMixLabel(mix))
.font(.body)
Expand Down Expand Up @@ -949,11 +1003,18 @@ struct DiveDetailView: View {
samples = detail.samples
tags = detail.tags
gasMixes = detail.gasMixes
deviceSettings = detail.deviceSettings
loadedTeammateIds = detail.teammateIds
loadedEquipmentIds = detail.equipmentIds
sourceDeviceMap = detail.sourceDeviceMap
if !hasPickedDevice {
selectedDeviceId = dive.deviceId
let multiDevice = Set(detail.samples.compactMap(\.deviceId)).count > 1
if multiDevice {
selectedDeviceId = detail.deviceSettings.first(where: { $0.isPrimary })?.deviceId
?? dive.deviceId
} else {
selectedDeviceId = dive.deviceId
}
hasPickedDevice = true
}

Expand Down
21 changes: 18 additions & 3 deletions Profundum/Profundum/Views/ReplayProfileSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ struct ReplayProfileSheet: View {
let gasMixes: [GasMix]
let stats: DiveStats?
let samples: [DiveSample]
let deviceSettings: DiveDeviceSettings?

init(
dive: Dive,
gasMixes: [GasMix],
stats: DiveStats?,
samples: [DiveSample],
deviceSettings: DiveDeviceSettings? = nil
) {
self.dive = dive
self.gasMixes = gasMixes
self.stats = stats
self.samples = samples
self.deviceSettings = deviceSettings
}

// MARK: - Mode

Expand Down Expand Up @@ -640,16 +655,16 @@ struct ReplayProfileSheet: View {
}

// Deco model
if let model = dive.decoModel?.lowercased() {
if let model = (deviceSettings?.decoModel ?? dive.decoModel)?.lowercased() {
if model.contains("thalmann") {
selectedModel = .thalmannElDca
} else {
selectedModel = .buhlmannZhl16c
}
}

gfLow = dive.gfLow ?? 30
gfHigh = dive.gfHigh ?? 70
gfLow = deviceSettings?.gfLow ?? dive.gfLow ?? 30
gfHigh = deviceSettings?.gfHigh ?? dive.gfHigh ?? 70
gfLowText = "\(gfLow)"
gfHighText = "\(gfHigh)"

Expand Down
20 changes: 20 additions & 0 deletions apple/DivelogCore/Sources/Database/DivelogDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,26 @@ public final class DivelogDatabase: Sendable {
try db.execute(sql: "ALTER TABLE dives ADD COLUMN deco_start_t_override_sec INTEGER")
}

// Migration 18: Per-device deco/settings for multi-computer dives
migrator.registerMigration("018_dive_device_settings") { db in
try db.execute(sql: """
CREATE TABLE dive_device_settings (
dive_id TEXT NOT NULL REFERENCES dives(id) ON DELETE CASCADE,
device_id TEXT NOT NULL REFERENCES devices(id),
gf_low INTEGER,
gf_high INTEGER,
deco_model TEXT,
salinity TEXT,
surface_pressure_bar REAL,
is_primary INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (dive_id, device_id)
)
""")
try db.execute(sql: """
CREATE INDEX idx_dive_device_settings_dive ON dive_device_settings(dive_id)
""")
}

try migrator.migrate(dbQueue)
}
}
110 changes: 110 additions & 0 deletions apple/DivelogCore/Sources/Models/DiveDeviceSettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import Foundation
import GRDB

/// Per-computer deco and environment settings for a multi-device dive.
public struct DiveDeviceSettings: Equatable, Sendable {
public var diveId: String
public var deviceId: String
public var gfLow: Int?
public var gfHigh: Int?
public var decoModel: String?
public var salinity: String?
public var surfacePressureBar: Float?
public var isPrimary: Bool

public init(
diveId: String,
deviceId: String,
gfLow: Int? = nil,
gfHigh: Int? = nil,
decoModel: String? = nil,
salinity: String? = nil,
surfacePressureBar: Float? = nil,
isPrimary: Bool = false
) {
self.diveId = diveId
self.deviceId = deviceId
self.gfLow = gfLow
self.gfHigh = gfHigh
self.decoModel = decoModel
self.salinity = salinity
self.surfacePressureBar = surfacePressureBar
self.isPrimary = isPrimary
}
}

// MARK: - GRDB Conformance

extension DiveDeviceSettings: Codable, FetchableRecord, PersistableRecord {
public static let databaseTableName = "dive_device_settings"

enum CodingKeys: String, CodingKey {
case diveId = "dive_id"
case deviceId = "device_id"
case gfLow = "gf_low"
case gfHigh = "gf_high"
case decoModel = "deco_model"
case salinity
case surfacePressureBar = "surface_pressure_bar"
case isPrimary = "is_primary"
}
}

// MARK: - Import Helpers

extension DiveDeviceSettings {
/// Inserts or replaces per-device settings from a parsed dive computer import.
static func upsert(
diveId: String,
deviceId: String,
gfLow: Int?,
gfHigh: Int?,
decoModel: String?,
salinity: String?,
surfacePressureBar: Float?,
isPrimary: Bool,
db: Database
) throws {
let settings = DiveDeviceSettings(
diveId: diveId,
deviceId: deviceId,
gfLow: gfLow,
gfHigh: gfHigh,
decoModel: decoModel,
salinity: salinity,
surfacePressureBar: surfacePressureBar,
isPrimary: isPrimary
)
try settings.insert(db, onConflict: .replace)
}

/// Creates a per-device settings row when missing (re-import backfill).
static func backfillIfMissing(
diveId: String,
deviceId: String,
gfLow: Int?,
gfHigh: Int?,
decoModel: String?,
salinity: String?,
surfacePressureBar: Float?,
isPrimary: Bool,
db: Database
) throws {
let exists = try Self
.filter(Column("dive_id") == diveId)
.filter(Column("device_id") == deviceId)
.fetchCount(db) > 0
guard !exists else { return }
try upsert(
diveId: diveId,
deviceId: deviceId,
gfLow: gfLow,
gfHigh: gfHigh,
decoModel: decoModel,
salinity: salinity,
surfacePressureBar: surfacePressureBar,
isPrimary: isPrimary,
db: db
)
}
}
Loading
Loading