cue-ios: measurement browser — load, compare, reference overlay from saved data

This commit is contained in:
jess 2026-03-31 18:29:41 -07:00
parent b2493ffb54
commit 019723d245
9 changed files with 394 additions and 285 deletions

View File

@ -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)"
}
}
}

View File

@ -6,7 +6,7 @@ struct CueIOSApp: App {
var body: some Scene {
WindowGroup {
ContentView(ble: ble)
ContentView()
}
}
}

View File

@ -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

View File

@ -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<T: Encodable>(measurementId: Int64, index: Int, point: T) throws {

View File

@ -25,7 +25,11 @@ struct MeasurementTable<Row: Identifiable>: 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 {

View File

@ -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)
}
}
}

View File

@ -554,6 +554,13 @@ struct LabeledPicker<Item: Hashable & Identifiable>: View {
let items: [Item]
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 {
VStack(alignment: .leading, spacing: 2) {
Text(label)

View File

@ -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
List(selection: $selectedSessionId) {
ForEach(sessions, id: \.id) { session in
NavigationLink(value: session.id!) {
sessionRow(session)
.tag(session.id)
}
.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
}
}

View File

@ -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"]),
],