cue-ios: measurement browser — load, compare, reference overlay from saved data
This commit is contained in:
parent
b2493ffb54
commit
019723d245
|
|
@ -12,139 +12,6 @@ enum Tab: String, CaseIterable, Identifiable {
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Enums mirroring protocol.rs
|
|
||||||
|
|
||||||
enum Rtia: UInt8, CaseIterable, Identifiable {
|
|
||||||
case r200 = 0, r1k, r5k, r10k, r20k, r40k, r80k, r160k, extDe0
|
|
||||||
|
|
||||||
var id: UInt8 { rawValue }
|
|
||||||
|
|
||||||
var label: String {
|
|
||||||
switch self {
|
|
||||||
case .r200: "200\u{2126}"
|
|
||||||
case .r1k: "1k\u{2126}"
|
|
||||||
case .r5k: "5k\u{2126}"
|
|
||||||
case .r10k: "10k\u{2126}"
|
|
||||||
case .r20k: "20k\u{2126}"
|
|
||||||
case .r40k: "40k\u{2126}"
|
|
||||||
case .r80k: "80k\u{2126}"
|
|
||||||
case .r160k: "160k\u{2126}"
|
|
||||||
case .extDe0: "Ext 3k\u{2126} (DE0)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Rcal: UInt8, CaseIterable, Identifiable {
|
|
||||||
case r200 = 0, r3k
|
|
||||||
|
|
||||||
var id: UInt8 { rawValue }
|
|
||||||
|
|
||||||
var label: String {
|
|
||||||
switch self {
|
|
||||||
case .r200: "200\u{2126} (RCAL0-RCAL1)"
|
|
||||||
case .r3k: "3k\u{2126} (RCAL0-AIN0)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Electrode: UInt8, CaseIterable, Identifiable {
|
|
||||||
case fourWire = 0, threeWire
|
|
||||||
|
|
||||||
var id: UInt8 { rawValue }
|
|
||||||
|
|
||||||
var label: String {
|
|
||||||
switch self {
|
|
||||||
case .fourWire: "4-wire (AIN)"
|
|
||||||
case .threeWire: "3-wire (CE0/RE0/SE0)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum LpRtia: UInt8, CaseIterable, Identifiable {
|
|
||||||
case r200 = 0, r1k, r2k, r3k, r4k, r6k, r8k, r10k, r12k, r16k
|
|
||||||
case r20k, r24k, r30k, r32k, r40k, r48k, r64k, r85k, r96k
|
|
||||||
case r100k, r120k, r128k, r160k, r196k, r256k, r512k
|
|
||||||
|
|
||||||
var id: UInt8 { rawValue }
|
|
||||||
|
|
||||||
var label: String {
|
|
||||||
switch self {
|
|
||||||
case .r200: "200\u{2126}"
|
|
||||||
case .r1k: "1k\u{2126}"
|
|
||||||
case .r2k: "2k\u{2126}"
|
|
||||||
case .r3k: "3k\u{2126}"
|
|
||||||
case .r4k: "4k\u{2126}"
|
|
||||||
case .r6k: "6k\u{2126}"
|
|
||||||
case .r8k: "8k\u{2126}"
|
|
||||||
case .r10k: "10k\u{2126}"
|
|
||||||
case .r12k: "12k\u{2126}"
|
|
||||||
case .r16k: "16k\u{2126}"
|
|
||||||
case .r20k: "20k\u{2126}"
|
|
||||||
case .r24k: "24k\u{2126}"
|
|
||||||
case .r30k: "30k\u{2126}"
|
|
||||||
case .r32k: "32k\u{2126}"
|
|
||||||
case .r40k: "40k\u{2126}"
|
|
||||||
case .r48k: "48k\u{2126}"
|
|
||||||
case .r64k: "64k\u{2126}"
|
|
||||||
case .r85k: "85k\u{2126}"
|
|
||||||
case .r96k: "96k\u{2126}"
|
|
||||||
case .r100k: "100k\u{2126}"
|
|
||||||
case .r120k: "120k\u{2126}"
|
|
||||||
case .r128k: "128k\u{2126}"
|
|
||||||
case .r160k: "160k\u{2126}"
|
|
||||||
case .r196k: "196k\u{2126}"
|
|
||||||
case .r256k: "256k\u{2126}"
|
|
||||||
case .r512k: "512k\u{2126}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Data types mirroring protocol.rs
|
|
||||||
|
|
||||||
struct EisPoint: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
var freqHz: Float
|
|
||||||
var magOhms: Float
|
|
||||||
var phaseDeg: Float
|
|
||||||
var zReal: Float
|
|
||||||
var zImag: Float
|
|
||||||
var rtiaMagBefore: Float
|
|
||||||
var rtiaMagAfter: Float
|
|
||||||
var revMag: Float
|
|
||||||
var revPhase: Float
|
|
||||||
var pctErr: Float
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LsvPoint: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
var vMv: Float
|
|
||||||
var iUa: Float
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AmpPoint: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
var tMs: Float
|
|
||||||
var iUa: Float
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ClPoint: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
var tMs: Float
|
|
||||||
var iUa: Float
|
|
||||||
var phase: UInt8
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ClResult {
|
|
||||||
var iFreeUa: Float
|
|
||||||
var iTotalUa: Float
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PhResult {
|
|
||||||
var vOcpMv: Float
|
|
||||||
var ph: Float
|
|
||||||
var tempC: Float
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - App State
|
// MARK: - App State
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
|
|
@ -160,8 +27,8 @@ final class AppState {
|
||||||
var freqStart: String = "1000"
|
var freqStart: String = "1000"
|
||||||
var freqStop: String = "200000"
|
var freqStop: String = "200000"
|
||||||
var ppd: String = "10"
|
var ppd: String = "10"
|
||||||
var rtia: Rtia = .r5k
|
var rtia: Rtia = .r5K
|
||||||
var rcal: Rcal = .r3k
|
var rcal: Rcal = .r3K
|
||||||
var electrode: Electrode = .fourWire
|
var electrode: Electrode = .fourWire
|
||||||
|
|
||||||
// LSV
|
// LSV
|
||||||
|
|
@ -170,7 +37,7 @@ final class AppState {
|
||||||
var lsvStartV: String = "0"
|
var lsvStartV: String = "0"
|
||||||
var lsvStopV: String = "500"
|
var lsvStopV: String = "500"
|
||||||
var lsvScanRate: String = "50"
|
var lsvScanRate: String = "50"
|
||||||
var lsvRtia: LpRtia = .r10k
|
var lsvRtia: LpRtia = .r10K
|
||||||
|
|
||||||
// Amperometry
|
// Amperometry
|
||||||
var ampPoints: [AmpPoint] = []
|
var ampPoints: [AmpPoint] = []
|
||||||
|
|
@ -179,7 +46,7 @@ final class AppState {
|
||||||
var ampVHold: String = "200"
|
var ampVHold: String = "200"
|
||||||
var ampInterval: String = "100"
|
var ampInterval: String = "100"
|
||||||
var ampDuration: String = "60"
|
var ampDuration: String = "60"
|
||||||
var ampRtia: LpRtia = .r10k
|
var ampRtia: LpRtia = .r10K
|
||||||
|
|
||||||
// Chlorine
|
// Chlorine
|
||||||
var clPoints: [ClPoint] = []
|
var clPoints: [ClPoint] = []
|
||||||
|
|
@ -191,7 +58,7 @@ final class AppState {
|
||||||
var clTotalV: String = "-200"
|
var clTotalV: String = "-200"
|
||||||
var clDepT: String = "5000"
|
var clDepT: String = "5000"
|
||||||
var clMeasT: String = "5000"
|
var clMeasT: String = "5000"
|
||||||
var clRtia: LpRtia = .r10k
|
var clRtia: LpRtia = .r10K
|
||||||
|
|
||||||
// pH
|
// pH
|
||||||
var phResult: PhResult? = nil
|
var phResult: PhResult? = nil
|
||||||
|
|
@ -219,13 +86,11 @@ final class AppState {
|
||||||
let fe = Float(freqStop) ?? 200000
|
let fe = Float(freqStop) ?? 200000
|
||||||
let p = UInt16(ppd) ?? 10
|
let p = UInt16(ppd) ?? 10
|
||||||
status = "Applying: \(fs)-\(fe) Hz, \(p) PPD"
|
status = "Applying: \(fs)-\(fe) Hz, \(p) PPD"
|
||||||
// BLEManager sends: set_sweep, set_rtia, set_rcal, set_electrode, get_config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startSweep() {
|
func startSweep() {
|
||||||
eisPoints.removeAll()
|
eisPoints.removeAll()
|
||||||
status = "Starting sweep..."
|
status = "Starting sweep..."
|
||||||
// BLEManager sends: get_temp, start_sweep
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startLSV() {
|
func startLSV() {
|
||||||
|
|
@ -233,33 +98,28 @@ final class AppState {
|
||||||
let vs = Float(lsvStartV) ?? 0
|
let vs = Float(lsvStartV) ?? 0
|
||||||
let ve = Float(lsvStopV) ?? 500
|
let ve = Float(lsvStopV) ?? 500
|
||||||
status = "Starting LSV: \(vs)-\(ve) mV"
|
status = "Starting LSV: \(vs)-\(ve) mV"
|
||||||
// BLEManager sends: get_temp, start_lsv
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startAmp() {
|
func startAmp() {
|
||||||
ampPoints.removeAll()
|
ampPoints.removeAll()
|
||||||
ampRunning = true
|
ampRunning = true
|
||||||
status = "Starting amperometry..."
|
status = "Starting amperometry..."
|
||||||
// BLEManager sends: get_temp, start_amp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopAmp() {
|
func stopAmp() {
|
||||||
ampRunning = false
|
ampRunning = false
|
||||||
status = "Stopping amperometry..."
|
status = "Stopping amperometry..."
|
||||||
// BLEManager sends: stop_amp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startChlorine() {
|
func startChlorine() {
|
||||||
clPoints.removeAll()
|
clPoints.removeAll()
|
||||||
clResult = nil
|
clResult = nil
|
||||||
status = "Starting chlorine measurement..."
|
status = "Starting chlorine measurement..."
|
||||||
// BLEManager sends: get_temp, start_cl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startPh() {
|
func startPh() {
|
||||||
phResult = nil
|
phResult = nil
|
||||||
status = "Starting pH measurement..."
|
status = "Starting pH measurement..."
|
||||||
// BLEManager sends: get_temp, start_ph
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setReference() {
|
func setReference() {
|
||||||
|
|
@ -302,7 +162,6 @@ final class AppState {
|
||||||
func collectRefs() {
|
func collectRefs() {
|
||||||
collectingRefs = true
|
collectingRefs = true
|
||||||
status = "Starting reference collection..."
|
status = "Starting reference collection..."
|
||||||
// BLEManager sends: start_refs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearRefs() {
|
func clearRefs() {
|
||||||
|
|
@ -314,14 +173,12 @@ final class AppState {
|
||||||
clRef = nil
|
clRef = nil
|
||||||
phRef = nil
|
phRef = nil
|
||||||
status = "Refs cleared"
|
status = "Refs cleared"
|
||||||
// BLEManager sends: clear_refs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startClean() {
|
func startClean() {
|
||||||
let v = Float(cleanV) ?? 1200
|
let v = Float(cleanV) ?? 1200
|
||||||
let d = Float(cleanDur) ?? 30
|
let d = Float(cleanDur) ?? 30
|
||||||
status = String(format: "Cleaning: %.0f mV for %.0fs", v, d)
|
status = String(format: "Cleaning: %.0f mV for %.0fs", v, d)
|
||||||
// BLEManager sends: start_clean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasCurrentRef: Bool {
|
var hasCurrentRef: Bool {
|
||||||
|
|
@ -345,4 +202,74 @@ final class AppState {
|
||||||
case .sessions: false
|
case .sessions: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Measurement loading
|
||||||
|
|
||||||
|
func loadMeasurement(_ measurement: Measurement) {
|
||||||
|
guard let id = measurement.id,
|
||||||
|
let type = MeasurementType(rawValue: measurement.type) else { return }
|
||||||
|
do {
|
||||||
|
switch type {
|
||||||
|
case .eis:
|
||||||
|
eisPoints = try Storage.shared.fetchTypedPoints(measurementId: id, as: EisPoint.self)
|
||||||
|
tab = .eis
|
||||||
|
status = "Loaded EIS (\(eisPoints.count) pts)"
|
||||||
|
case .lsv:
|
||||||
|
lsvPoints = try Storage.shared.fetchTypedPoints(measurementId: id, as: LsvPoint.self)
|
||||||
|
tab = .lsv
|
||||||
|
status = "Loaded LSV (\(lsvPoints.count) pts)"
|
||||||
|
case .amp:
|
||||||
|
ampPoints = try Storage.shared.fetchTypedPoints(measurementId: id, as: AmpPoint.self)
|
||||||
|
tab = .amp
|
||||||
|
status = "Loaded Amp (\(ampPoints.count) pts)"
|
||||||
|
case .chlorine:
|
||||||
|
clPoints = try Storage.shared.fetchTypedPoints(measurementId: id, as: ClPoint.self)
|
||||||
|
if let summary = measurement.resultSummary {
|
||||||
|
clResult = try JSONDecoder().decode(ClResult.self, from: summary)
|
||||||
|
}
|
||||||
|
tab = .chlorine
|
||||||
|
status = "Loaded Chlorine (\(clPoints.count) pts)"
|
||||||
|
case .ph:
|
||||||
|
if let summary = measurement.resultSummary {
|
||||||
|
phResult = try JSONDecoder().decode(PhResult.self, from: summary)
|
||||||
|
}
|
||||||
|
tab = .ph
|
||||||
|
status = "Loaded pH result"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
status = "Load failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAsReference(_ measurement: Measurement) {
|
||||||
|
guard let id = measurement.id,
|
||||||
|
let type = MeasurementType(rawValue: measurement.type) else { return }
|
||||||
|
do {
|
||||||
|
switch type {
|
||||||
|
case .eis:
|
||||||
|
eisRef = try Storage.shared.fetchTypedPoints(measurementId: id, as: EisPoint.self)
|
||||||
|
status = "EIS reference loaded (\(eisRef?.count ?? 0) pts)"
|
||||||
|
case .lsv:
|
||||||
|
lsvRef = try Storage.shared.fetchTypedPoints(measurementId: id, as: LsvPoint.self)
|
||||||
|
status = "LSV reference loaded (\(lsvRef?.count ?? 0) pts)"
|
||||||
|
case .amp:
|
||||||
|
ampRef = try Storage.shared.fetchTypedPoints(measurementId: id, as: AmpPoint.self)
|
||||||
|
status = "Amp reference loaded (\(ampRef?.count ?? 0) pts)"
|
||||||
|
case .chlorine:
|
||||||
|
let pts = try Storage.shared.fetchTypedPoints(measurementId: id, as: ClPoint.self)
|
||||||
|
if let summary = measurement.resultSummary {
|
||||||
|
let result = try JSONDecoder().decode(ClResult.self, from: summary)
|
||||||
|
clRef = (pts, result)
|
||||||
|
status = "Chlorine reference loaded"
|
||||||
|
}
|
||||||
|
case .ph:
|
||||||
|
if let summary = measurement.resultSummary {
|
||||||
|
phRef = try JSONDecoder().decode(PhResult.self, from: summary)
|
||||||
|
status = String(format: "pH reference loaded (%.2f)", phRef?.ph ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
status = "Reference load failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ struct CueIOSApp: App {
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView(ble: ble)
|
ContentView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import Foundation
|
||||||
|
|
||||||
// MARK: - Measurement points
|
// MARK: - Measurement points
|
||||||
|
|
||||||
struct EisPoint: Codable {
|
struct EisPoint: Codable, Identifiable {
|
||||||
|
let id = UUID()
|
||||||
var freqHz: Float
|
var freqHz: Float
|
||||||
var magOhms: Float
|
var magOhms: Float
|
||||||
var phaseDeg: Float
|
var phaseDeg: Float
|
||||||
|
|
@ -16,22 +17,42 @@ struct EisPoint: Codable {
|
||||||
var revMag: Float
|
var revMag: Float
|
||||||
var revPhase: Float
|
var revPhase: Float
|
||||||
var pctErr: Float
|
var pctErr: Float
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case freqHz, magOhms, phaseDeg, zReal, zImag
|
||||||
|
case rtiaMagBefore, rtiaMagAfter, revMag, revPhase, pctErr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LsvPoint: Codable {
|
struct LsvPoint: Codable, Identifiable {
|
||||||
|
let id = UUID()
|
||||||
var vMv: Float
|
var vMv: Float
|
||||||
var iUa: Float
|
var iUa: Float
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case vMv, iUa
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AmpPoint: Codable {
|
struct AmpPoint: Codable, Identifiable {
|
||||||
|
let id = UUID()
|
||||||
var tMs: Float
|
var tMs: Float
|
||||||
var iUa: Float
|
var iUa: Float
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case tMs, iUa
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ClPoint: Codable {
|
struct ClPoint: Codable, Identifiable {
|
||||||
|
let id = UUID()
|
||||||
var tMs: Float
|
var tMs: Float
|
||||||
var iUa: Float
|
var iUa: Float
|
||||||
var phase: UInt8
|
var phase: UInt8
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case tMs, iUa, phase
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ClResult: Codable {
|
struct ClResult: Codable {
|
||||||
|
|
@ -58,7 +79,9 @@ struct EisConfig: Codable {
|
||||||
|
|
||||||
// MARK: - Hardware enums
|
// MARK: - Hardware enums
|
||||||
|
|
||||||
enum Rtia: UInt8, Codable, CaseIterable {
|
enum Rtia: UInt8, Codable, CaseIterable, Identifiable {
|
||||||
|
var id: UInt8 { rawValue }
|
||||||
|
|
||||||
case r200 = 0
|
case r200 = 0
|
||||||
case r1K = 1
|
case r1K = 1
|
||||||
case r5K = 2
|
case r5K = 2
|
||||||
|
|
@ -84,7 +107,9 @@ enum Rtia: UInt8, Codable, CaseIterable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Rcal: UInt8, Codable, CaseIterable {
|
enum Rcal: UInt8, Codable, CaseIterable, Identifiable {
|
||||||
|
var id: UInt8 { rawValue }
|
||||||
|
|
||||||
case r200 = 0
|
case r200 = 0
|
||||||
case r3K = 1
|
case r3K = 1
|
||||||
|
|
||||||
|
|
@ -96,7 +121,9 @@ enum Rcal: UInt8, Codable, CaseIterable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Electrode: UInt8, Codable, CaseIterable {
|
enum Electrode: UInt8, Codable, CaseIterable, Identifiable {
|
||||||
|
var id: UInt8 { rawValue }
|
||||||
|
|
||||||
case fourWire = 0
|
case fourWire = 0
|
||||||
case threeWire = 1
|
case threeWire = 1
|
||||||
|
|
||||||
|
|
@ -108,7 +135,9 @@ enum Electrode: UInt8, Codable, CaseIterable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LpRtia: UInt8, Codable, CaseIterable {
|
enum LpRtia: UInt8, Codable, CaseIterable, Identifiable {
|
||||||
|
var id: UInt8 { rawValue }
|
||||||
|
|
||||||
case r200 = 0
|
case r200 = 0
|
||||||
case r1K = 1
|
case r1K = 1
|
||||||
case r2K = 2
|
case r2K = 2
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,16 @@ final class Storage {
|
||||||
|
|
||||||
func deleteSession(_ id: Int64) throws {
|
func deleteSession(_ id: Int64) throws {
|
||||||
try dbQueue.write { db in
|
try dbQueue.write { db in
|
||||||
_ = try Session.deleteOne(db, id: id)
|
try db.execute(sql: "DELETE FROM session WHERE id = ?", arguments: [id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSession(_ id: Int64, label: String?, notes: String?) throws {
|
||||||
|
try dbQueue.write { db in
|
||||||
|
try db.execute(
|
||||||
|
sql: "UPDATE session SET label = ?, notes = ? WHERE id = ?",
|
||||||
|
arguments: [label, notes, id]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,6 +166,28 @@ final class Storage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteMeasurement(_ id: Int64) throws {
|
||||||
|
try dbQueue.write { db in
|
||||||
|
try db.execute(sql: "DELETE FROM measurement WHERE id = ?", arguments: [id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataPointCount(measurementId: Int64) throws -> Int {
|
||||||
|
try dbQueue.read { db in
|
||||||
|
try DataPoint
|
||||||
|
.filter(Column("measurementId") == measurementId)
|
||||||
|
.fetchCount(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func measurementCount(sessionId: Int64) throws -> Int {
|
||||||
|
try dbQueue.read { db in
|
||||||
|
try Measurement
|
||||||
|
.filter(Column("sessionId") == sessionId)
|
||||||
|
.fetchCount(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Data points
|
// MARK: - Data points
|
||||||
|
|
||||||
func addDataPoint<T: Encodable>(measurementId: Int64, index: Int, point: T) throws {
|
func addDataPoint<T: Encodable>(measurementId: Int64, index: Int, point: T) throws {
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,11 @@ struct MeasurementTable<Row: Identifiable>: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
#if os(iOS)
|
||||||
.background(Color(.systemBackground).opacity(0.05))
|
.background(Color(.systemBackground).opacity(0.05))
|
||||||
|
#else
|
||||||
|
.background(Color(white: 0.1).opacity(0.05))
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private var headerRow: some View {
|
private var headerRow: some View {
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ struct ContentView: View {
|
||||||
.tabItem { Label("pH", systemImage: "scalemass") }
|
.tabItem { Label("pH", systemImage: "scalemass") }
|
||||||
.tag(Tab.ph)
|
.tag(Tab.ph)
|
||||||
|
|
||||||
SessionView()
|
SessionView(state: state)
|
||||||
.tabItem { Label("Sessions", systemImage: "folder") }
|
.tabItem { Label("Sessions", systemImage: "folder") }
|
||||||
.tag(Tab.sessions)
|
.tag(Tab.sessions)
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +136,7 @@ struct ContentView: View {
|
||||||
case .amp: AmpView(state: state)
|
case .amp: AmpView(state: state)
|
||||||
case .chlorine: ChlorineView(state: state)
|
case .chlorine: ChlorineView(state: state)
|
||||||
case .ph: PhView(state: state)
|
case .ph: PhView(state: state)
|
||||||
case .sessions: SessionView()
|
case .sessions: SessionView(state: state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -554,6 +554,13 @@ struct LabeledPicker<Item: Hashable & Identifiable>: View {
|
||||||
let items: [Item]
|
let items: [Item]
|
||||||
let itemLabel: (Item) -> String
|
let itemLabel: (Item) -> String
|
||||||
|
|
||||||
|
init(_ label: String, selection: Binding<Item>, items: [Item], itemLabel: @escaping (Item) -> String) {
|
||||||
|
self.label = label
|
||||||
|
self._selection = selection
|
||||||
|
self.items = items
|
||||||
|
self.itemLabel = itemLabel
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(label)
|
Text(label)
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,14 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import GRDB
|
||||||
struct Session: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
var name: String
|
|
||||||
var notes: String
|
|
||||||
var created: Date
|
|
||||||
var measurements: [SessionMeasurement]
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SessionMeasurement: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
var type: Tab
|
|
||||||
var timestamp: Date
|
|
||||||
var pointCount: Int
|
|
||||||
var summary: String
|
|
||||||
}
|
|
||||||
|
|
||||||
@Observable
|
|
||||||
final class SessionStore {
|
|
||||||
var sessions: [Session] = []
|
|
||||||
var selectedSession: Session?
|
|
||||||
|
|
||||||
func createSession(name: String, notes: String) {
|
|
||||||
let session = Session(
|
|
||||||
name: name,
|
|
||||||
notes: notes,
|
|
||||||
created: Date(),
|
|
||||||
measurements: []
|
|
||||||
)
|
|
||||||
sessions.insert(session, at: 0)
|
|
||||||
selectedSession = session
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteSession(_ session: Session) {
|
|
||||||
sessions.removeAll { $0.id == session.id }
|
|
||||||
if selectedSession?.id == session.id {
|
|
||||||
selectedSession = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SessionView: View {
|
struct SessionView: View {
|
||||||
@State var store = SessionStore()
|
@Bindable var state: AppState
|
||||||
|
@State private var sessions: [Session] = []
|
||||||
|
@State private var selectedSessionId: Int64?
|
||||||
@State private var showingNewSession = false
|
@State private var showingNewSession = false
|
||||||
@State private var newName = ""
|
@State private var newLabel = ""
|
||||||
@State private var newNotes = ""
|
@State private var newNotes = ""
|
||||||
|
@State private var sessionCancellable: DatabaseCancellable?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
|
|
@ -57,6 +21,14 @@ struct SessionView: View {
|
||||||
.sheet(isPresented: $showingNewSession) {
|
.sheet(isPresented: $showingNewSession) {
|
||||||
newSessionSheet
|
newSessionSheet
|
||||||
}
|
}
|
||||||
|
.onAppear { startObserving() }
|
||||||
|
.onDisappear { sessionCancellable?.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startObserving() {
|
||||||
|
sessionCancellable = Storage.shared.observeSessions { sessions in
|
||||||
|
self.sessions = sessions
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Wide layout (iPad)
|
// MARK: - Wide layout (iPad)
|
||||||
|
|
@ -66,8 +38,8 @@ struct SessionView: View {
|
||||||
sessionList
|
sessionList
|
||||||
.frame(width: 300)
|
.frame(width: 300)
|
||||||
Divider()
|
Divider()
|
||||||
if let session = store.selectedSession {
|
if let sid = selectedSessionId, let session = sessions.first(where: { $0.id == sid }) {
|
||||||
sessionDetail(session)
|
SessionDetailView(session: session, state: state)
|
||||||
} else {
|
} else {
|
||||||
Text("Select or create a session")
|
Text("Select or create a session")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
@ -89,6 +61,12 @@ struct SessionView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationDestination(for: Int64.self) { sid in
|
||||||
|
if let session = sessions.first(where: { $0.id == sid }) {
|
||||||
|
SessionDetailView(session: session, state: state)
|
||||||
|
.navigationTitle(session.label ?? "Session")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,7 +85,7 @@ struct SessionView: View {
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
if store.sessions.isEmpty {
|
if sessions.isEmpty {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Text("No sessions")
|
Text("No sessions")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
@ -117,17 +95,17 @@ struct SessionView: View {
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
List(selection: Binding(
|
List(selection: $selectedSessionId) {
|
||||||
get: { store.selectedSession?.id },
|
ForEach(sessions, id: \.id) { session in
|
||||||
set: { id in store.selectedSession = store.sessions.first { $0.id == id } }
|
NavigationLink(value: session.id!) {
|
||||||
)) {
|
|
||||||
ForEach(store.sessions) { session in
|
|
||||||
sessionRow(session)
|
sessionRow(session)
|
||||||
.tag(session.id)
|
}
|
||||||
|
.tag(session.id!)
|
||||||
}
|
}
|
||||||
.onDelete { indices in
|
.onDelete { indices in
|
||||||
for idx in indices {
|
for idx in indices {
|
||||||
store.deleteSession(store.sessions[idx])
|
guard let sid = sessions[idx].id else { continue }
|
||||||
|
try? Storage.shared.deleteSession(sid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -138,22 +116,23 @@ struct SessionView: View {
|
||||||
|
|
||||||
private func sessionRow(_ session: Session) -> some View {
|
private func sessionRow(_ session: Session) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(session.name)
|
Text(session.label ?? "Untitled")
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
HStack {
|
HStack {
|
||||||
Text(session.created, style: .date)
|
Text(session.startedAt, style: .date)
|
||||||
Text(session.created, style: .time)
|
Text(session.startedAt, style: .time)
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if !session.notes.isEmpty {
|
if let notes = session.notes, !notes.isEmpty {
|
||||||
Text(session.notes)
|
Text(notes)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
if !session.measurements.isEmpty {
|
let count = (try? Storage.shared.measurementCount(sessionId: session.id!)) ?? 0
|
||||||
Text("\(session.measurements.count) measurement\(session.measurements.count == 1 ? "" : "s")")
|
if count > 0 {
|
||||||
|
Text("\(count) measurement\(count == 1 ? "" : "s")")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
@ -161,64 +140,13 @@ struct SessionView: View {
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session detail
|
|
||||||
|
|
||||||
private func sessionDetail(_ session: Session) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(session.name)
|
|
||||||
.font(.title2.bold())
|
|
||||||
HStack {
|
|
||||||
Text(session.created, style: .date)
|
|
||||||
Text(session.created, style: .time)
|
|
||||||
}
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
if !session.notes.isEmpty {
|
|
||||||
Text(session.notes)
|
|
||||||
.font(.body)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
if session.measurements.isEmpty {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
Text("No measurements in this session")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text("Run a measurement to add data")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color(white: 0.4))
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else {
|
|
||||||
List(session.measurements) { meas in
|
|
||||||
HStack {
|
|
||||||
Label(meas.type.rawValue, systemImage: measurementIcon(meas.type))
|
|
||||||
Spacer()
|
|
||||||
VStack(alignment: .trailing) {
|
|
||||||
Text("\(meas.pointCount) pts")
|
|
||||||
.font(.caption.monospacedDigit())
|
|
||||||
Text(meas.timestamp, style: .time)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - New session sheet
|
// MARK: - New session sheet
|
||||||
|
|
||||||
private var newSessionSheet: some View {
|
private var newSessionSheet: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
Section("Session Info") {
|
Section("Session Info") {
|
||||||
TextField("Name", text: $newName)
|
TextField("Name", text: $newLabel)
|
||||||
TextField("Notes (optional)", text: $newNotes, axis: .vertical)
|
TextField("Notes (optional)", text: $newNotes, axis: .vertical)
|
||||||
.lineLimit(3...6)
|
.lineLimit(3...6)
|
||||||
}
|
}
|
||||||
|
|
@ -228,32 +156,215 @@ struct SessionView: View {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
showingNewSession = false
|
showingNewSession = false
|
||||||
newName = ""
|
newLabel = ""
|
||||||
newNotes = ""
|
newNotes = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("Create") {
|
Button("Create") {
|
||||||
store.createSession(name: newName, notes: newNotes)
|
let label = newLabel.trimmingCharacters(in: .whitespaces)
|
||||||
|
let notes = newNotes.trimmingCharacters(in: .whitespaces)
|
||||||
|
if let session = try? Storage.shared.createSession(label: label.isEmpty ? nil : label) {
|
||||||
|
if !notes.isEmpty {
|
||||||
|
try? Storage.shared.updateSession(session.id!, label: session.label, notes: notes)
|
||||||
|
}
|
||||||
|
selectedSessionId = session.id
|
||||||
|
}
|
||||||
showingNewSession = false
|
showingNewSession = false
|
||||||
newName = ""
|
newLabel = ""
|
||||||
newNotes = ""
|
newNotes = ""
|
||||||
}
|
}
|
||||||
.disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty)
|
.disabled(newLabel.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.presentationDetents([.medium])
|
.presentationDetents([.medium])
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func measurementIcon(_ tab: Tab) -> String {
|
// MARK: - Session detail
|
||||||
switch tab {
|
|
||||||
case .eis: "waveform.path.ecg"
|
struct SessionDetailView: View {
|
||||||
case .lsv: "chart.xyaxis.line"
|
let session: Session
|
||||||
case .amp: "bolt.fill"
|
@Bindable var state: AppState
|
||||||
case .chlorine: "drop.fill"
|
@State private var measurements: [Measurement] = []
|
||||||
case .ph: "scalemass"
|
@State private var editing = false
|
||||||
case .sessions: "folder"
|
@State private var editLabel = ""
|
||||||
|
@State private var editNotes = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
measurementsList
|
||||||
|
}
|
||||||
|
.onAppear { loadMeasurements() }
|
||||||
|
.onChange(of: session.id) { loadMeasurements() }
|
||||||
|
.sheet(isPresented: $editing) { editSheet }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMeasurements() {
|
||||||
|
guard let sid = session.id else { return }
|
||||||
|
measurements = (try? Storage.shared.fetchMeasurements(sessionId: sid)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(session.label ?? "Untitled")
|
||||||
|
.font(.title2.bold())
|
||||||
|
Spacer()
|
||||||
|
Button(action: {
|
||||||
|
editLabel = session.label ?? ""
|
||||||
|
editNotes = session.notes ?? ""
|
||||||
|
editing = true
|
||||||
|
}) {
|
||||||
|
Image(systemName: "pencil.circle")
|
||||||
|
.imageScale(.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text(session.startedAt, style: .date)
|
||||||
|
Text(session.startedAt, style: .time)
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if let notes = session.notes, !notes.isEmpty {
|
||||||
|
Text(notes)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var measurementsList: some View {
|
||||||
|
if measurements.isEmpty {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("No measurements")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Run a measurement to add data here")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color(white: 0.4))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(measurements, id: \.id) { meas in
|
||||||
|
MeasurementRow(measurement: meas, state: state)
|
||||||
|
}
|
||||||
|
.onDelete { indices in
|
||||||
|
for idx in indices {
|
||||||
|
guard let mid = measurements[idx].id else { continue }
|
||||||
|
try? Storage.shared.deleteMeasurement(mid)
|
||||||
|
}
|
||||||
|
loadMeasurements()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var editSheet: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Session Info") {
|
||||||
|
TextField("Name", text: $editLabel)
|
||||||
|
TextField("Notes", text: $editNotes, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Edit Session")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { editing = false }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") {
|
||||||
|
guard let sid = session.id else { return }
|
||||||
|
let label = editLabel.trimmingCharacters(in: .whitespaces)
|
||||||
|
let notes = editNotes.trimmingCharacters(in: .whitespaces)
|
||||||
|
try? Storage.shared.updateSession(
|
||||||
|
sid,
|
||||||
|
label: label.isEmpty ? nil : label,
|
||||||
|
notes: notes.isEmpty ? nil : notes
|
||||||
|
)
|
||||||
|
editing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Measurement row
|
||||||
|
|
||||||
|
struct MeasurementRow: View {
|
||||||
|
let measurement: Measurement
|
||||||
|
@Bindable var state: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Label(typeLabel, systemImage: typeIcon)
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
Text("\(pointCount) pts")
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
Text(measurement.startedAt, style: .time)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { state.loadMeasurement(measurement) }
|
||||||
|
.contextMenu {
|
||||||
|
Button {
|
||||||
|
state.loadMeasurement(measurement)
|
||||||
|
} label: {
|
||||||
|
Label("Load", systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
state.loadAsReference(measurement)
|
||||||
|
} label: {
|
||||||
|
Label("Load as Reference", systemImage: "line.horizontal.2.decrease.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .leading) {
|
||||||
|
Button {
|
||||||
|
state.loadAsReference(measurement)
|
||||||
|
} label: {
|
||||||
|
Label("Reference", systemImage: "line.horizontal.2.decrease.circle")
|
||||||
|
}
|
||||||
|
.tint(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var typeLabel: String {
|
||||||
|
switch measurement.type {
|
||||||
|
case "eis": "EIS"
|
||||||
|
case "lsv": "LSV"
|
||||||
|
case "amp": "Amp"
|
||||||
|
case "chlorine": "Chlorine"
|
||||||
|
case "ph": "pH"
|
||||||
|
default: measurement.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var typeIcon: String {
|
||||||
|
switch measurement.type {
|
||||||
|
case "eis": "waveform.path.ecg"
|
||||||
|
case "lsv": "chart.xyaxis.line"
|
||||||
|
case "amp": "bolt.fill"
|
||||||
|
case "chlorine": "drop.fill"
|
||||||
|
case "ph": "scalemass"
|
||||||
|
default: "questionmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pointCount: Int {
|
||||||
|
guard let mid = measurement.id else { return 0 }
|
||||||
|
return (try? Storage.shared.dataPointCount(measurementId: mid)) ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "CueIOS",
|
name: "CueIOS",
|
||||||
platforms: [.iOS(.v17)],
|
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "CueIOS", targets: ["CueIOS"]),
|
.library(name: "CueIOS", targets: ["CueIOS"]),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue