From 019723d24534de80a1192941b926d9be7fa3737d Mon Sep 17 00:00:00 2001 From: jess Date: Tue, 31 Mar 2026 18:29:41 -0700 Subject: [PATCH] =?UTF-8?q?cue-ios:=20measurement=20browser=20=E2=80=94=20?= =?UTF-8?q?load,=20compare,=20reference=20overlay=20from=20saved=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cue-ios/CueIOS/AppState.swift | 223 ++++------- cue-ios/CueIOS/CueIOSApp.swift | 2 +- cue-ios/CueIOS/Models/DataTypes.swift | 45 ++- cue-ios/CueIOS/Models/Storage.swift | 33 +- .../Views/Components/MeasurementTable.swift | 4 + cue-ios/CueIOS/Views/ContentView.swift | 4 +- cue-ios/CueIOS/Views/EISView.swift | 7 + cue-ios/CueIOS/Views/SessionView.swift | 359 ++++++++++++------ cue-ios/Package.swift | 2 +- 9 files changed, 394 insertions(+), 285 deletions(-) diff --git a/cue-ios/CueIOS/AppState.swift b/cue-ios/CueIOS/AppState.swift index c69194c..be80fca 100644 --- a/cue-ios/CueIOS/AppState.swift +++ b/cue-ios/CueIOS/AppState.swift @@ -12,139 +12,6 @@ enum Tab: String, CaseIterable, Identifiable { 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 @Observable @@ -160,8 +27,8 @@ final class AppState { var freqStart: String = "1000" var freqStop: String = "200000" var ppd: String = "10" - var rtia: Rtia = .r5k - var rcal: Rcal = .r3k + var rtia: Rtia = .r5K + var rcal: Rcal = .r3K var electrode: Electrode = .fourWire // LSV @@ -170,7 +37,7 @@ final class AppState { var lsvStartV: String = "0" var lsvStopV: String = "500" var lsvScanRate: String = "50" - var lsvRtia: LpRtia = .r10k + var lsvRtia: LpRtia = .r10K // Amperometry var ampPoints: [AmpPoint] = [] @@ -179,7 +46,7 @@ final class AppState { var ampVHold: String = "200" var ampInterval: String = "100" var ampDuration: String = "60" - var ampRtia: LpRtia = .r10k + var ampRtia: LpRtia = .r10K // Chlorine var clPoints: [ClPoint] = [] @@ -191,7 +58,7 @@ final class AppState { var clTotalV: String = "-200" var clDepT: String = "5000" var clMeasT: String = "5000" - var clRtia: LpRtia = .r10k + var clRtia: LpRtia = .r10K // pH var phResult: PhResult? = nil @@ -219,13 +86,11 @@ final class AppState { let fe = Float(freqStop) ?? 200000 let p = UInt16(ppd) ?? 10 status = "Applying: \(fs)-\(fe) Hz, \(p) PPD" - // BLEManager sends: set_sweep, set_rtia, set_rcal, set_electrode, get_config } func startSweep() { eisPoints.removeAll() status = "Starting sweep..." - // BLEManager sends: get_temp, start_sweep } func startLSV() { @@ -233,33 +98,28 @@ final class AppState { let vs = Float(lsvStartV) ?? 0 let ve = Float(lsvStopV) ?? 500 status = "Starting LSV: \(vs)-\(ve) mV" - // BLEManager sends: get_temp, start_lsv } func startAmp() { ampPoints.removeAll() ampRunning = true status = "Starting amperometry..." - // BLEManager sends: get_temp, start_amp } func stopAmp() { ampRunning = false status = "Stopping amperometry..." - // BLEManager sends: stop_amp } func startChlorine() { clPoints.removeAll() clResult = nil status = "Starting chlorine measurement..." - // BLEManager sends: get_temp, start_cl } func startPh() { phResult = nil status = "Starting pH measurement..." - // BLEManager sends: get_temp, start_ph } func setReference() { @@ -302,7 +162,6 @@ final class AppState { func collectRefs() { collectingRefs = true status = "Starting reference collection..." - // BLEManager sends: start_refs } func clearRefs() { @@ -314,14 +173,12 @@ final class AppState { clRef = nil phRef = nil status = "Refs cleared" - // BLEManager sends: clear_refs } func startClean() { let v = Float(cleanV) ?? 1200 let d = Float(cleanDur) ?? 30 status = String(format: "Cleaning: %.0f mV for %.0fs", v, d) - // BLEManager sends: start_clean } var hasCurrentRef: Bool { @@ -345,4 +202,74 @@ final class AppState { 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)" + } + } } diff --git a/cue-ios/CueIOS/CueIOSApp.swift b/cue-ios/CueIOS/CueIOSApp.swift index 1fe8459..07a8c00 100644 --- a/cue-ios/CueIOS/CueIOSApp.swift +++ b/cue-ios/CueIOS/CueIOSApp.swift @@ -6,7 +6,7 @@ struct CueIOSApp: App { var body: some Scene { WindowGroup { - ContentView(ble: ble) + ContentView() } } } diff --git a/cue-ios/CueIOS/Models/DataTypes.swift b/cue-ios/CueIOS/Models/DataTypes.swift index c56c047..64a729a 100644 --- a/cue-ios/CueIOS/Models/DataTypes.swift +++ b/cue-ios/CueIOS/Models/DataTypes.swift @@ -5,7 +5,8 @@ import Foundation // MARK: - Measurement points -struct EisPoint: Codable { +struct EisPoint: Codable, Identifiable { + let id = UUID() var freqHz: Float var magOhms: Float var phaseDeg: Float @@ -16,22 +17,42 @@ struct EisPoint: Codable { var revMag: Float var revPhase: 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 iUa: Float + + enum CodingKeys: String, CodingKey { + case vMv, iUa + } } -struct AmpPoint: Codable { +struct AmpPoint: Codable, Identifiable { + let id = UUID() var tMs: 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 iUa: Float var phase: UInt8 + + enum CodingKeys: String, CodingKey { + case tMs, iUa, phase + } } struct ClResult: Codable { @@ -58,7 +79,9 @@ struct EisConfig: Codable { // MARK: - Hardware enums -enum Rtia: UInt8, Codable, CaseIterable { +enum Rtia: UInt8, Codable, CaseIterable, Identifiable { + var id: UInt8 { rawValue } + case r200 = 0 case r1K = 1 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 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 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 r1K = 1 case r2K = 2 diff --git a/cue-ios/CueIOS/Models/Storage.swift b/cue-ios/CueIOS/Models/Storage.swift index 655c9ae..fe1450f 100644 --- a/cue-ios/CueIOS/Models/Storage.swift +++ b/cue-ios/CueIOS/Models/Storage.swift @@ -110,7 +110,16 @@ final class Storage { func deleteSession(_ id: Int64) throws { 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 func addDataPoint(measurementId: Int64, index: Int, point: T) throws { diff --git a/cue-ios/CueIOS/Views/Components/MeasurementTable.swift b/cue-ios/CueIOS/Views/Components/MeasurementTable.swift index d60c04c..515ace8 100644 --- a/cue-ios/CueIOS/Views/Components/MeasurementTable.swift +++ b/cue-ios/CueIOS/Views/Components/MeasurementTable.swift @@ -25,7 +25,11 @@ struct MeasurementTable: View { } } .font(.system(.caption, design: .monospaced)) + #if os(iOS) .background(Color(.systemBackground).opacity(0.05)) + #else + .background(Color(white: 0.1).opacity(0.05)) + #endif } private var headerRow: some View { diff --git a/cue-ios/CueIOS/Views/ContentView.swift b/cue-ios/CueIOS/Views/ContentView.swift index 064cdc7..298cb9d 100644 --- a/cue-ios/CueIOS/Views/ContentView.swift +++ b/cue-ios/CueIOS/Views/ContentView.swift @@ -120,7 +120,7 @@ struct ContentView: View { .tabItem { Label("pH", systemImage: "scalemass") } .tag(Tab.ph) - SessionView() + SessionView(state: state) .tabItem { Label("Sessions", systemImage: "folder") } .tag(Tab.sessions) } @@ -136,7 +136,7 @@ struct ContentView: View { case .amp: AmpView(state: state) case .chlorine: ChlorineView(state: state) case .ph: PhView(state: state) - case .sessions: SessionView() + case .sessions: SessionView(state: state) } } } diff --git a/cue-ios/CueIOS/Views/EISView.swift b/cue-ios/CueIOS/Views/EISView.swift index afff577..7cb8b15 100644 --- a/cue-ios/CueIOS/Views/EISView.swift +++ b/cue-ios/CueIOS/Views/EISView.swift @@ -554,6 +554,13 @@ struct LabeledPicker: View { let items: [Item] let itemLabel: (Item) -> String + init(_ label: String, selection: Binding, items: [Item], itemLabel: @escaping (Item) -> String) { + self.label = label + self._selection = selection + self.items = items + self.itemLabel = itemLabel + } + var body: some View { VStack(alignment: .leading, spacing: 2) { Text(label) diff --git a/cue-ios/CueIOS/Views/SessionView.swift b/cue-ios/CueIOS/Views/SessionView.swift index 5bd1a4d..d858ed0 100644 --- a/cue-ios/CueIOS/Views/SessionView.swift +++ b/cue-ios/CueIOS/Views/SessionView.swift @@ -1,50 +1,14 @@ import SwiftUI - -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 - } - } -} +import GRDB 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 newName = "" + @State private var newLabel = "" @State private var newNotes = "" + @State private var sessionCancellable: DatabaseCancellable? var body: some View { GeometryReader { geo in @@ -57,6 +21,14 @@ struct SessionView: View { .sheet(isPresented: $showingNewSession) { newSessionSheet } + .onAppear { startObserving() } + .onDisappear { sessionCancellable?.cancel() } + } + + private func startObserving() { + sessionCancellable = Storage.shared.observeSessions { sessions in + self.sessions = sessions + } } // MARK: - Wide layout (iPad) @@ -66,8 +38,8 @@ struct SessionView: View { sessionList .frame(width: 300) Divider() - if let session = store.selectedSession { - sessionDetail(session) + if let sid = selectedSessionId, let session = sessions.first(where: { $0.id == sid }) { + SessionDetailView(session: session, state: state) } else { Text("Select or create a session") .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() - if store.sessions.isEmpty { + if sessions.isEmpty { VStack(spacing: 8) { Text("No sessions") .foregroundStyle(.secondary) @@ -117,17 +95,17 @@ struct SessionView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - List(selection: Binding( - get: { store.selectedSession?.id }, - set: { id in store.selectedSession = store.sessions.first { $0.id == id } } - )) { - ForEach(store.sessions) { session in - sessionRow(session) - .tag(session.id) + List(selection: $selectedSessionId) { + ForEach(sessions, id: \.id) { session in + NavigationLink(value: session.id!) { + sessionRow(session) + } + .tag(session.id!) } .onDelete { indices in 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 { VStack(alignment: .leading, spacing: 4) { - Text(session.name) + Text(session.label ?? "Untitled") .font(.subheadline.weight(.medium)) HStack { - Text(session.created, style: .date) - Text(session.created, style: .time) + Text(session.startedAt, style: .date) + Text(session.startedAt, style: .time) } .font(.caption) .foregroundStyle(.secondary) - if !session.notes.isEmpty { - Text(session.notes) + if let notes = session.notes, !notes.isEmpty { + Text(notes) .font(.caption) .foregroundStyle(.tertiary) .lineLimit(1) } - if !session.measurements.isEmpty { - Text("\(session.measurements.count) measurement\(session.measurements.count == 1 ? "" : "s")") + let count = (try? Storage.shared.measurementCount(sessionId: session.id!)) ?? 0 + if count > 0 { + Text("\(count) measurement\(count == 1 ? "" : "s")") .font(.caption2) .foregroundStyle(.secondary) } @@ -161,64 +140,13 @@ struct SessionView: View { .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 private var newSessionSheet: some View { NavigationStack { Form { Section("Session Info") { - TextField("Name", text: $newName) + TextField("Name", text: $newLabel) TextField("Notes (optional)", text: $newNotes, axis: .vertical) .lineLimit(3...6) } @@ -228,32 +156,215 @@ struct SessionView: View { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { showingNewSession = false - newName = "" + newLabel = "" newNotes = "" } } ToolbarItem(placement: .confirmationAction) { 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 - newName = "" + newLabel = "" newNotes = "" } - .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty) + .disabled(newLabel.trimmingCharacters(in: .whitespaces).isEmpty) } } } .presentationDetents([.medium]) } +} - private func measurementIcon(_ tab: Tab) -> String { - switch tab { - case .eis: "waveform.path.ecg" - case .lsv: "chart.xyaxis.line" - case .amp: "bolt.fill" - case .chlorine: "drop.fill" - case .ph: "scalemass" - case .sessions: "folder" +// MARK: - Session detail + +struct SessionDetailView: View { + let session: Session + @Bindable var state: AppState + @State private var measurements: [Measurement] = [] + @State private var editing = false + @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 + } } diff --git a/cue-ios/Package.swift b/cue-ios/Package.swift index b310cf9..5fc6ba0 100644 --- a/cue-ios/Package.swift +++ b/cue-ios/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "CueIOS", - platforms: [.iOS(.v17)], + platforms: [.iOS(.v17), .macOS(.v14)], products: [ .library(name: "CueIOS", targets: ["CueIOS"]), ],