merge integration
# Conflicts: # cue-ios/CueIOS/Views/ContentView.swift
This commit is contained in:
commit
aee5a2e033
|
|
@ -0,0 +1,348 @@
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
enum Tab: String, CaseIterable, Identifiable {
|
||||||
|
case eis = "EIS"
|
||||||
|
case lsv = "LSV"
|
||||||
|
case amp = "Amperometry"
|
||||||
|
case chlorine = "Chlorine"
|
||||||
|
case ph = "pH"
|
||||||
|
case sessions = "Sessions"
|
||||||
|
|
||||||
|
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
|
||||||
|
final class AppState {
|
||||||
|
var tab: Tab = .eis
|
||||||
|
var status: String = "Disconnected"
|
||||||
|
var bleConnected: Bool = false
|
||||||
|
var tempC: Float = 25.0
|
||||||
|
|
||||||
|
// EIS
|
||||||
|
var eisPoints: [EisPoint] = []
|
||||||
|
var sweepTotal: UInt16 = 0
|
||||||
|
var freqStart: String = "1000"
|
||||||
|
var freqStop: String = "200000"
|
||||||
|
var ppd: String = "10"
|
||||||
|
var rtia: Rtia = .r5k
|
||||||
|
var rcal: Rcal = .r3k
|
||||||
|
var electrode: Electrode = .fourWire
|
||||||
|
|
||||||
|
// LSV
|
||||||
|
var lsvPoints: [LsvPoint] = []
|
||||||
|
var lsvTotal: UInt16 = 0
|
||||||
|
var lsvStartV: String = "0"
|
||||||
|
var lsvStopV: String = "500"
|
||||||
|
var lsvScanRate: String = "50"
|
||||||
|
var lsvRtia: LpRtia = .r10k
|
||||||
|
|
||||||
|
// Amperometry
|
||||||
|
var ampPoints: [AmpPoint] = []
|
||||||
|
var ampTotal: UInt16 = 0
|
||||||
|
var ampRunning: Bool = false
|
||||||
|
var ampVHold: String = "200"
|
||||||
|
var ampInterval: String = "100"
|
||||||
|
var ampDuration: String = "60"
|
||||||
|
var ampRtia: LpRtia = .r10k
|
||||||
|
|
||||||
|
// Chlorine
|
||||||
|
var clPoints: [ClPoint] = []
|
||||||
|
var clResult: ClResult? = nil
|
||||||
|
var clTotal: UInt16 = 0
|
||||||
|
var clCondV: String = "800"
|
||||||
|
var clCondT: String = "2000"
|
||||||
|
var clFreeV: String = "100"
|
||||||
|
var clTotalV: String = "-200"
|
||||||
|
var clDepT: String = "5000"
|
||||||
|
var clMeasT: String = "5000"
|
||||||
|
var clRtia: LpRtia = .r10k
|
||||||
|
|
||||||
|
// pH
|
||||||
|
var phResult: PhResult? = nil
|
||||||
|
var phStabilize: String = "30"
|
||||||
|
|
||||||
|
// Reference baselines
|
||||||
|
var eisRef: [EisPoint]? = nil
|
||||||
|
var lsvRef: [LsvPoint]? = nil
|
||||||
|
var ampRef: [AmpPoint]? = nil
|
||||||
|
var clRef: (points: [ClPoint], result: ClResult)? = nil
|
||||||
|
var phRef: PhResult? = nil
|
||||||
|
|
||||||
|
// Device reference collection
|
||||||
|
var collectingRefs: Bool = false
|
||||||
|
var hasDeviceRefs: Bool = false
|
||||||
|
|
||||||
|
// Clean
|
||||||
|
var cleanV: String = "1200"
|
||||||
|
var cleanDur: String = "30"
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
func applyEISSettings() {
|
||||||
|
let fs = Float(freqStart) ?? 1000
|
||||||
|
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() {
|
||||||
|
lsvPoints.removeAll()
|
||||||
|
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() {
|
||||||
|
switch tab {
|
||||||
|
case .eis where !eisPoints.isEmpty:
|
||||||
|
eisRef = eisPoints
|
||||||
|
status = "EIS reference set (\(eisPoints.count) pts)"
|
||||||
|
case .lsv where !lsvPoints.isEmpty:
|
||||||
|
lsvRef = lsvPoints
|
||||||
|
status = "LSV reference set (\(lsvPoints.count) pts)"
|
||||||
|
case .amp where !ampPoints.isEmpty:
|
||||||
|
ampRef = ampPoints
|
||||||
|
status = "Amp reference set (\(ampPoints.count) pts)"
|
||||||
|
case .chlorine where !clPoints.isEmpty:
|
||||||
|
if let r = clResult {
|
||||||
|
clRef = (clPoints, r)
|
||||||
|
status = "Chlorine reference set"
|
||||||
|
}
|
||||||
|
case .ph:
|
||||||
|
if let r = phResult {
|
||||||
|
phRef = r
|
||||||
|
status = String(format: "pH reference set (%.2f)", r.ph)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearReference() {
|
||||||
|
switch tab {
|
||||||
|
case .eis: eisRef = nil; status = "EIS reference cleared"
|
||||||
|
case .lsv: lsvRef = nil; status = "LSV reference cleared"
|
||||||
|
case .amp: ampRef = nil; status = "Amp reference cleared"
|
||||||
|
case .chlorine: clRef = nil; status = "Chlorine reference cleared"
|
||||||
|
case .ph: phRef = nil; status = "pH reference cleared"
|
||||||
|
case .sessions: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectRefs() {
|
||||||
|
collectingRefs = true
|
||||||
|
status = "Starting reference collection..."
|
||||||
|
// BLEManager sends: start_refs
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearRefs() {
|
||||||
|
collectingRefs = false
|
||||||
|
hasDeviceRefs = false
|
||||||
|
eisRef = nil
|
||||||
|
lsvRef = nil
|
||||||
|
ampRef = nil
|
||||||
|
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 {
|
||||||
|
switch tab {
|
||||||
|
case .eis: eisRef != nil
|
||||||
|
case .lsv: lsvRef != nil
|
||||||
|
case .amp: ampRef != nil
|
||||||
|
case .chlorine: clRef != nil
|
||||||
|
case .ph: phRef != nil
|
||||||
|
case .sessions: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasCurrentData: Bool {
|
||||||
|
switch tab {
|
||||||
|
case .eis: !eisPoints.isEmpty
|
||||||
|
case .lsv: !lsvPoints.isEmpty
|
||||||
|
case .amp: !ampPoints.isEmpty
|
||||||
|
case .chlorine: clResult != nil
|
||||||
|
case .ph: phResult != nil
|
||||||
|
case .sessions: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct AmpView: View {
|
||||||
|
@Bindable var state: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
controlsRow
|
||||||
|
Divider()
|
||||||
|
GeometryReader { geo in
|
||||||
|
if geo.size.width > 700 {
|
||||||
|
HSplitLayout(ratio: 0.55) {
|
||||||
|
amperogramPlot
|
||||||
|
} trailing: {
|
||||||
|
ampTable
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
amperogramPlot.frame(height: 350)
|
||||||
|
ampTable.frame(height: 300)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls
|
||||||
|
|
||||||
|
private var controlsRow: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
LabeledField("V hold mV", text: $state.ampVHold, width: 80)
|
||||||
|
LabeledField("Interval ms", text: $state.ampInterval, width: 80)
|
||||||
|
LabeledField("Duration s", text: $state.ampDuration, width: 80)
|
||||||
|
|
||||||
|
LabeledPicker("RTIA", selection: $state.ampRtia, items: LpRtia.allCases) { $0.label }
|
||||||
|
.frame(width: 120)
|
||||||
|
|
||||||
|
if state.ampRunning {
|
||||||
|
Button("Stop") { state.stopAmp() }
|
||||||
|
.buttonStyle(ActionButtonStyle(color: .red))
|
||||||
|
} else {
|
||||||
|
Button("Start Amp") { state.startAmp() }
|
||||||
|
.buttonStyle(ActionButtonStyle(color: .green))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Plot
|
||||||
|
|
||||||
|
private var amperogramPlot: some View {
|
||||||
|
Group {
|
||||||
|
if state.ampPoints.isEmpty {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(Color(white: 0.4))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
} else {
|
||||||
|
let ampColor = Color(red: 0.6, green: 0.6, blue: 1.0)
|
||||||
|
PlotContainer(title: "") {
|
||||||
|
Chart {
|
||||||
|
if let ref = state.ampRef {
|
||||||
|
ForEach(Array(ref.enumerated()), id: \.offset) { _, pt in
|
||||||
|
LineMark(
|
||||||
|
x: .value("t", Double(pt.tMs)),
|
||||||
|
y: .value("I", Double(pt.iUa))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.5))
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 1.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(Array(state.ampPoints.enumerated()), id: \.offset) { _, pt in
|
||||||
|
LineMark(
|
||||||
|
x: .value("t", Double(pt.tMs)),
|
||||||
|
y: .value("I", Double(pt.iUa))
|
||||||
|
)
|
||||||
|
.foregroundStyle(ampColor)
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||||
|
}
|
||||||
|
ForEach(Array(state.ampPoints.enumerated()), id: \.offset) { _, pt in
|
||||||
|
PointMark(
|
||||||
|
x: .value("t", Double(pt.tMs)),
|
||||||
|
y: .value("I", Double(pt.iUa))
|
||||||
|
)
|
||||||
|
.foregroundStyle(ampColor)
|
||||||
|
.symbolSize(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartXAxisLabel("t (ms)")
|
||||||
|
.chartYAxisLabel("I (uA)", position: .leading)
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks { _ in
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.3))
|
||||||
|
AxisValueLabel()
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYAxis {
|
||||||
|
AxisMarks(position: .leading) { _ in
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.3))
|
||||||
|
AxisValueLabel()
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(ampColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Table
|
||||||
|
|
||||||
|
private var ampTable: some View {
|
||||||
|
MeasurementTable(
|
||||||
|
columns: [
|
||||||
|
MeasurementColumn(header: "t (ms)", width: 80, alignment: .trailing),
|
||||||
|
MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing),
|
||||||
|
],
|
||||||
|
rows: state.ampPoints,
|
||||||
|
cellText: { pt, col in
|
||||||
|
switch col {
|
||||||
|
case 0: String(format: "%.1f", pt.tMs)
|
||||||
|
case 1: String(format: "%.3f", pt.iUa)
|
||||||
|
default: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct ChlorineView: View {
|
||||||
|
@Bindable var state: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
controlsRow
|
||||||
|
Divider()
|
||||||
|
GeometryReader { geo in
|
||||||
|
if geo.size.width > 700 {
|
||||||
|
HSplitLayout(ratio: 0.55) {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
resultBanner
|
||||||
|
chlorinePlot
|
||||||
|
}
|
||||||
|
} trailing: {
|
||||||
|
clTable
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
resultBanner
|
||||||
|
chlorinePlot.frame(height: 350)
|
||||||
|
clTable.frame(height: 300)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls
|
||||||
|
|
||||||
|
private var controlsRow: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
LabeledField("Cond mV", text: $state.clCondV, width: 70)
|
||||||
|
LabeledField("Cond ms", text: $state.clCondT, width: 70)
|
||||||
|
LabeledField("Free mV", text: $state.clFreeV, width: 70)
|
||||||
|
LabeledField("Total mV", text: $state.clTotalV, width: 70)
|
||||||
|
LabeledField("Settle ms", text: $state.clDepT, width: 70)
|
||||||
|
LabeledField("Meas ms", text: $state.clMeasT, width: 70)
|
||||||
|
|
||||||
|
LabeledPicker("RTIA", selection: $state.clRtia, items: LpRtia.allCases) { $0.label }
|
||||||
|
.frame(width: 120)
|
||||||
|
|
||||||
|
Button("Measure") { state.startChlorine() }
|
||||||
|
.buttonStyle(ActionButtonStyle(color: .green))
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Result banner
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var resultBanner: some View {
|
||||||
|
if let r = state.clResult {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Text(String(format: "Free: %.3f uA", r.iFreeUa))
|
||||||
|
.foregroundStyle(Color(red: 0.2, green: 1, blue: 0.5))
|
||||||
|
Text(String(format: "Total: %.3f uA", r.iTotalUa))
|
||||||
|
.foregroundStyle(Color(red: 1, green: 0.6, blue: 0.2))
|
||||||
|
Text(String(format: "Combined: %.3f uA", r.iTotalUa - r.iFreeUa))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if let (_, refR) = state.clRef {
|
||||||
|
Divider().frame(height: 16)
|
||||||
|
Text(String(format: "vs Ref: dFree=%.3f dTotal=%.3f",
|
||||||
|
r.iFreeUa - refR.iFreeUa,
|
||||||
|
r.iTotalUa - refR.iTotalUa))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.subheadline.monospacedDigit())
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Plot
|
||||||
|
|
||||||
|
private var chlorinePlot: some View {
|
||||||
|
Group {
|
||||||
|
if state.clPoints.isEmpty {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(Color(white: 0.4))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
} else {
|
||||||
|
PlotContainer(title: "") {
|
||||||
|
chlorineCanvas
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chlorineCanvas: some View {
|
||||||
|
Canvas { context, size in
|
||||||
|
let ml: CGFloat = 50, mr: CGFloat = 12, mt: CGFloat = 12, mb: CGFloat = 22
|
||||||
|
let xl = ml, xr = size.width - mr
|
||||||
|
let yt = mt, yb = size.height - mb
|
||||||
|
|
||||||
|
let valid = state.clPoints.filter { $0.tMs.isFinite && $0.iUa.isFinite }
|
||||||
|
guard !valid.isEmpty else { return }
|
||||||
|
|
||||||
|
let ts = valid.map { CGFloat($0.tMs) }
|
||||||
|
let is_ = valid.map { CGFloat($0.iUa) }
|
||||||
|
let (tMin, tMax) = (ts.min()!, ts.max()!)
|
||||||
|
let (iMin, iMax) = (is_.min()!, is_.max()!)
|
||||||
|
let tPad = max(tMax - tMin, 100) * 0.05
|
||||||
|
let iPad = max(iMax - iMin, 0.001) * 0.12
|
||||||
|
let xvLo = tMin - tPad, xvHi = tMax + tPad
|
||||||
|
let yvLo = iMin - iPad, yvHi = iMax + iPad
|
||||||
|
|
||||||
|
func lx(_ v: CGFloat) -> CGFloat { xl + (v - xvLo) / (xvHi - xvLo) * (xr - xl) }
|
||||||
|
func ly(_ v: CGFloat) -> CGFloat { yt + (yvHi - v) / (yvHi - yvLo) * (yb - yt) }
|
||||||
|
|
||||||
|
// grid
|
||||||
|
let gridColor = Color(white: 0.25).opacity(0.6)
|
||||||
|
drawClGrid(context: context, xl: xl, xr: xr, yt: yt, yb: yb,
|
||||||
|
xvLo: xvLo, xvHi: xvHi, yvLo: yvLo, yvHi: yvHi, gridColor: gridColor)
|
||||||
|
|
||||||
|
// axis labels
|
||||||
|
context.draw(
|
||||||
|
Text("I (uA)").font(.caption2).foregroundStyle(.secondary),
|
||||||
|
at: CGPoint(x: 20, y: yt - 2))
|
||||||
|
context.draw(
|
||||||
|
Text("t (ms)").font(.caption2).foregroundStyle(.secondary),
|
||||||
|
at: CGPoint(x: (xl + xr) / 2, y: yb + 12))
|
||||||
|
|
||||||
|
// reference
|
||||||
|
if let refPts = state.clRef?.points {
|
||||||
|
let refFree = refPts.filter { $0.phase == 1 }
|
||||||
|
.map { CGPoint(x: lx(CGFloat($0.tMs)), y: ly(CGFloat($0.iUa))) }
|
||||||
|
let refTotal = refPts.filter { $0.phase == 2 }
|
||||||
|
.map { CGPoint(x: lx(CGFloat($0.tMs)), y: ly(CGFloat($0.iUa))) }
|
||||||
|
drawClPolyline(context: context, points: refFree, color: Color.gray.opacity(0.5), width: 1.5)
|
||||||
|
drawClPolyline(context: context, points: refTotal, color: Color.gray.opacity(0.5), width: 1.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
let clFreeColor = Color(red: 0.2, green: 1, blue: 0.5)
|
||||||
|
let clTotalColor = Color(red: 1, green: 0.6, blue: 0.2)
|
||||||
|
let condColor = Color.gray
|
||||||
|
|
||||||
|
// conditioning
|
||||||
|
let condPts = valid.filter { $0.phase == 0 }
|
||||||
|
.map { CGPoint(x: lx(CGFloat($0.tMs)), y: ly(CGFloat($0.iUa))) }
|
||||||
|
drawClPolyline(context: context, points: condPts, color: condColor, width: 1.5)
|
||||||
|
|
||||||
|
// free
|
||||||
|
let freePts = valid.filter { $0.phase == 1 }
|
||||||
|
.map { CGPoint(x: lx(CGFloat($0.tMs)), y: ly(CGFloat($0.iUa))) }
|
||||||
|
drawClPolyline(context: context, points: freePts, color: clFreeColor, width: 2)
|
||||||
|
for pt in freePts {
|
||||||
|
context.fill(Path(ellipseIn: CGRect(x: pt.x - 3, y: pt.y - 3, width: 6, height: 6)),
|
||||||
|
with: .color(clFreeColor))
|
||||||
|
}
|
||||||
|
|
||||||
|
// total
|
||||||
|
let totalPts = valid.filter { $0.phase == 2 }
|
||||||
|
.map { CGPoint(x: lx(CGFloat($0.tMs)), y: ly(CGFloat($0.iUa))) }
|
||||||
|
drawClPolyline(context: context, points: totalPts, color: clTotalColor, width: 2)
|
||||||
|
for pt in totalPts {
|
||||||
|
context.fill(Path(ellipseIn: CGRect(x: pt.x - 3, y: pt.y - 3, width: 6, height: 6)),
|
||||||
|
with: .color(clTotalColor))
|
||||||
|
}
|
||||||
|
|
||||||
|
// phase boundary
|
||||||
|
if let lastFree = valid.last(where: { $0.phase == 1 }),
|
||||||
|
let firstTotal = valid.first(where: { $0.phase == 2 }) {
|
||||||
|
let bx = lx(CGFloat((lastFree.tMs + firstTotal.tMs) / 2))
|
||||||
|
if bx > xl && bx < xr {
|
||||||
|
var bp = Path()
|
||||||
|
bp.move(to: CGPoint(x: bx, y: yt))
|
||||||
|
bp.addLine(to: CGPoint(x: bx, y: yb))
|
||||||
|
context.stroke(bp, with: .color(Color(white: 0.4)), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// legend
|
||||||
|
context.draw(
|
||||||
|
Text("Free").font(.caption2).foregroundStyle(clFreeColor),
|
||||||
|
at: CGPoint(x: xl + 25, y: yt + 10))
|
||||||
|
context.draw(
|
||||||
|
Text("Total").font(.caption2).foregroundStyle(clTotalColor),
|
||||||
|
at: CGPoint(x: xl + 70, y: yt + 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Table
|
||||||
|
|
||||||
|
private var clTable: some View {
|
||||||
|
MeasurementTable(
|
||||||
|
columns: [
|
||||||
|
MeasurementColumn(header: "t (ms)", width: 80, alignment: .trailing),
|
||||||
|
MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing),
|
||||||
|
MeasurementColumn(header: "Phase", width: 60, alignment: .trailing),
|
||||||
|
],
|
||||||
|
rows: state.clPoints,
|
||||||
|
cellText: { pt, col in
|
||||||
|
switch col {
|
||||||
|
case 0: String(format: "%.1f", pt.tMs)
|
||||||
|
case 1: String(format: "%.3f", pt.iUa)
|
||||||
|
case 2:
|
||||||
|
switch pt.phase {
|
||||||
|
case 1: "Free"
|
||||||
|
case 2: "Total"
|
||||||
|
default: "Cond"
|
||||||
|
}
|
||||||
|
default: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Drawing helpers (chlorine-local)
|
||||||
|
|
||||||
|
private func drawClPolyline(context: GraphicsContext, points: [CGPoint], color: Color, width: CGFloat) {
|
||||||
|
guard points.count >= 2 else { return }
|
||||||
|
var path = Path()
|
||||||
|
path.move(to: points[0])
|
||||||
|
for pt in points.dropFirst() {
|
||||||
|
guard pt.x.isFinite && pt.y.isFinite else { continue }
|
||||||
|
path.addLine(to: pt)
|
||||||
|
}
|
||||||
|
context.stroke(path, with: .color(color), lineWidth: width)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawClGrid(context: GraphicsContext, xl: CGFloat, xr: CGFloat, yt: CGFloat, yb: CGFloat,
|
||||||
|
xvLo: CGFloat, xvHi: CGFloat, yvLo: CGFloat, yvHi: CGFloat, gridColor: Color) {
|
||||||
|
func niceStep(_ range: CGFloat, _ ticks: Int) -> CGFloat {
|
||||||
|
guard abs(range) > 1e-10 else { return 1 }
|
||||||
|
let rough = range / CGFloat(ticks)
|
||||||
|
let mag = pow(10, floor(log10(abs(rough))))
|
||||||
|
let norm = abs(rough) / mag
|
||||||
|
let s: CGFloat
|
||||||
|
if norm < 1.5 { s = 1 } else if norm < 3.5 { s = 2 } else if norm < 7.5 { s = 5 } else { s = 10 }
|
||||||
|
return s * mag
|
||||||
|
}
|
||||||
|
|
||||||
|
let xs = niceStep(xvHi - xvLo, 5)
|
||||||
|
if xs > 0 {
|
||||||
|
var g = (xvLo / xs).rounded(.up) * xs
|
||||||
|
while g <= xvHi {
|
||||||
|
let x = xl + (g - xvLo) / (xvHi - xvLo) * (xr - xl)
|
||||||
|
var p = Path(); p.move(to: CGPoint(x: x, y: yt)); p.addLine(to: CGPoint(x: x, y: yb))
|
||||||
|
context.stroke(p, with: .color(gridColor), lineWidth: 0.5)
|
||||||
|
context.draw(Text(String(format: "%.0f", g)).font(.system(size: 9)).foregroundStyle(.secondary),
|
||||||
|
at: CGPoint(x: x, y: yb + 10))
|
||||||
|
g += xs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ys = niceStep(yvHi - yvLo, 4)
|
||||||
|
if ys > 0 {
|
||||||
|
var g = (yvLo / ys).rounded(.up) * ys
|
||||||
|
while g <= yvHi {
|
||||||
|
let y = yt + (yvHi - g) / (yvHi - yvLo) * (yb - yt)
|
||||||
|
var p = Path(); p.move(to: CGPoint(x: xl, y: y)); p.addLine(to: CGPoint(x: xr, y: y))
|
||||||
|
context.stroke(p, with: .color(gridColor), lineWidth: 0.5)
|
||||||
|
context.draw(Text(String(format: "%.1f", g)).font(.system(size: 9)).foregroundStyle(.secondary),
|
||||||
|
at: CGPoint(x: xl - 20, y: y))
|
||||||
|
g += ys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MeasurementColumn: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let header: String
|
||||||
|
let width: CGFloat
|
||||||
|
let alignment: Alignment
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MeasurementTable<Row: Identifiable>: View {
|
||||||
|
let columns: [MeasurementColumn]
|
||||||
|
let rows: [Row]
|
||||||
|
let cellText: (Row, Int) -> String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
headerRow
|
||||||
|
Divider().background(Color.gray.opacity(0.4))
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(Array(rows.enumerated()), id: \.element.id) { idx, row in
|
||||||
|
dataRow(row, even: idx % 2 == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.background(Color(.systemBackground).opacity(0.05))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var headerRow: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(Array(columns.enumerated()), id: \.element.id) { idx, col in
|
||||||
|
Text(col.header)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.frame(width: col.width, alignment: col.alignment)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if idx < columns.count - 1 {
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dataRow(_ row: Row, even: Bool) -> some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(Array(columns.enumerated()), id: \.element.id) { idx, col in
|
||||||
|
Text(cellText(row, idx))
|
||||||
|
.frame(width: col.width, alignment: col.alignment)
|
||||||
|
if idx < columns.count - 1 {
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.background(even ? Color.clear : Color.white.opacity(0.03))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PlotContainer<Content: View>: View {
|
||||||
|
let title: String
|
||||||
|
@ViewBuilder let content: () -> Content
|
||||||
|
|
||||||
|
@State private var scale: CGFloat = 1.0
|
||||||
|
@State private var offset: CGSize = .zero
|
||||||
|
@State private var lastOffset: CGSize = .zero
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
content()
|
||||||
|
.scaleEffect(scale)
|
||||||
|
.offset(offset)
|
||||||
|
.gesture(dragGesture)
|
||||||
|
.gesture(magnificationGesture)
|
||||||
|
.simultaneousGesture(doubleTapReset)
|
||||||
|
.clipped()
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
if !title.isEmpty {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dragGesture: some Gesture {
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
offset = CGSize(
|
||||||
|
width: lastOffset.width + value.translation.width,
|
||||||
|
height: lastOffset.height + value.translation.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
lastOffset = offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var magnificationGesture: some Gesture {
|
||||||
|
MagnifyGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
scale = max(0.5, min(10.0, value.magnification))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var doubleTapReset: some Gesture {
|
||||||
|
TapGesture(count: 2)
|
||||||
|
.onEnded {
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
scale = 1.0
|
||||||
|
offset = .zero
|
||||||
|
lastOffset = .zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct StatusBar: View {
|
||||||
|
let state: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
connectionIndicator
|
||||||
|
Text(state.status)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
refButtons
|
||||||
|
|
||||||
|
Text(String(format: "%.1f\u{00B0}C", state.tempC))
|
||||||
|
.font(.subheadline.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var connectionIndicator: some View {
|
||||||
|
Circle()
|
||||||
|
.fill(state.bleConnected ? Color.green : Color.red)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var refButtons: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if !state.collectingRefs {
|
||||||
|
Button("Collect Refs") { state.collectRefs() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.green)
|
||||||
|
.controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Button("Collecting...") {}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
.disabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.hasDeviceRefs {
|
||||||
|
Button("Clear Refs") { state.clearRefs() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.red)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.hasCurrentData {
|
||||||
|
Button("Set Ref") { state.setReference() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.hasCurrentRef {
|
||||||
|
Button("Clear Ref") { state.clearReference() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.red)
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
Text("REF")
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,63 +1,142 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
var ble: BLEManager
|
@State private var state = AppState()
|
||||||
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
Group {
|
||||||
Tab("EIS", systemImage: "waveform.path") {
|
if sizeClass == .regular {
|
||||||
PlaceholderTab(title: "Impedance Spectroscopy", ble: ble)
|
iPadLayout
|
||||||
}
|
|
||||||
Tab("LSV", systemImage: "arrow.right") {
|
|
||||||
PlaceholderTab(title: "Linear Sweep Voltammetry", ble: ble)
|
|
||||||
}
|
|
||||||
Tab("Amp", systemImage: "bolt") {
|
|
||||||
PlaceholderTab(title: "Amperometry", ble: ble)
|
|
||||||
}
|
|
||||||
Tab("Cl\u{2082}", systemImage: "drop") {
|
|
||||||
PlaceholderTab(title: "Chlorine", ble: ble)
|
|
||||||
}
|
|
||||||
Tab("pH", systemImage: "scalemass") {
|
|
||||||
PlaceholderTab(title: "pH", ble: ble)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct PlaceholderTab: View {
|
|
||||||
let title: String
|
|
||||||
var ble: BLEManager
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
VStack(spacing: 20) {
|
|
||||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
|
||||||
.font(.largeTitle)
|
|
||||||
.foregroundStyle(statusColor)
|
|
||||||
Text(ble.state.rawValue)
|
|
||||||
.font(.headline)
|
|
||||||
}
|
|
||||||
.navigationTitle(title)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
Button(ble.state == .connected ? "Disconnect" : "Connect") {
|
|
||||||
if ble.state == .connected {
|
|
||||||
ble.disconnect()
|
|
||||||
} else {
|
} else {
|
||||||
ble.startScanning()
|
iPhoneLayout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - iPad: NavigationSplitView
|
||||||
|
|
||||||
|
private var iPadLayout: some View {
|
||||||
|
NavigationSplitView {
|
||||||
|
sidebar
|
||||||
|
} detail: {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
StatusBar(state: state)
|
||||||
|
Divider()
|
||||||
|
tabContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusColor: Color {
|
private var sidebar: some View {
|
||||||
switch ble.state {
|
List(selection: $state.tab) {
|
||||||
case .connected: .green
|
Section("Measurements") {
|
||||||
case .scanning: .orange
|
Label("EIS", systemImage: "waveform.path.ecg")
|
||||||
case .connecting: .yellow
|
.tag(Tab.eis)
|
||||||
case .disconnected: .secondary
|
Label("LSV", systemImage: "chart.xyaxis.line")
|
||||||
|
.tag(Tab.lsv)
|
||||||
|
Label("Amperometry", systemImage: "bolt.fill")
|
||||||
|
.tag(Tab.amp)
|
||||||
|
Label("Chlorine", systemImage: "drop.fill")
|
||||||
|
.tag(Tab.chlorine)
|
||||||
|
Label("pH", systemImage: "scalemass")
|
||||||
|
.tag(Tab.ph)
|
||||||
|
}
|
||||||
|
Section("Data") {
|
||||||
|
Label("Sessions", systemImage: "folder")
|
||||||
|
.tag(Tab.sessions)
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
cleanControls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Cue")
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cleanControls: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Electrode Clean")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
TextField("mV", text: $state.cleanV)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 60)
|
||||||
|
#if os(iOS)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
#endif
|
||||||
|
TextField("s", text: $state.cleanDur)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 45)
|
||||||
|
#if os(iOS)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
#endif
|
||||||
|
Button("Clean") { state.startClean() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(Color(red: 0.65, green: 0.55, blue: 0.15))
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iPhone: TabView
|
||||||
|
|
||||||
|
private var iPhoneLayout: some View {
|
||||||
|
TabView(selection: $state.tab) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
StatusBar(state: state)
|
||||||
|
EISView(state: state)
|
||||||
|
}
|
||||||
|
.tabItem { Label("EIS", systemImage: "waveform.path.ecg") }
|
||||||
|
.tag(Tab.eis)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
StatusBar(state: state)
|
||||||
|
LSVView(state: state)
|
||||||
|
}
|
||||||
|
.tabItem { Label("LSV", systemImage: "chart.xyaxis.line") }
|
||||||
|
.tag(Tab.lsv)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
StatusBar(state: state)
|
||||||
|
AmpView(state: state)
|
||||||
|
}
|
||||||
|
.tabItem { Label("Amp", systemImage: "bolt.fill") }
|
||||||
|
.tag(Tab.amp)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
StatusBar(state: state)
|
||||||
|
ChlorineView(state: state)
|
||||||
|
}
|
||||||
|
.tabItem { Label("Chlorine", systemImage: "drop.fill") }
|
||||||
|
.tag(Tab.chlorine)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
StatusBar(state: state)
|
||||||
|
PhView(state: state)
|
||||||
|
}
|
||||||
|
.tabItem { Label("pH", systemImage: "scalemass") }
|
||||||
|
.tag(Tab.ph)
|
||||||
|
|
||||||
|
SessionView()
|
||||||
|
.tabItem { Label("Sessions", systemImage: "folder") }
|
||||||
|
.tag(Tab.sessions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tab content
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var tabContent: some View {
|
||||||
|
switch state.tab {
|
||||||
|
case .eis: EISView(state: state)
|
||||||
|
case .lsv: LSVView(state: state)
|
||||||
|
case .amp: AmpView(state: state)
|
||||||
|
case .chlorine: ChlorineView(state: state)
|
||||||
|
case .ph: PhView(state: state)
|
||||||
|
case .sessions: SessionView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,607 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct EISView: View {
|
||||||
|
@Bindable var state: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
controlsRow
|
||||||
|
Divider()
|
||||||
|
GeometryReader { geo in
|
||||||
|
if geo.size.width > 700 {
|
||||||
|
wideLayout(geo: geo)
|
||||||
|
} else {
|
||||||
|
compactLayout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls
|
||||||
|
|
||||||
|
private var controlsRow: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
LabeledField("Start Hz", text: $state.freqStart, width: 90)
|
||||||
|
LabeledField("Stop Hz", text: $state.freqStop, width: 90)
|
||||||
|
LabeledField("PPD", text: $state.ppd, width: 50)
|
||||||
|
|
||||||
|
LabeledPicker("RTIA", selection: $state.rtia, items: Rtia.allCases) { $0.label }
|
||||||
|
.frame(width: 120)
|
||||||
|
LabeledPicker("RCAL", selection: $state.rcal, items: Rcal.allCases) { $0.label }
|
||||||
|
.frame(width: 170)
|
||||||
|
LabeledPicker("Electrodes", selection: $state.electrode, items: Electrode.allCases) { $0.label }
|
||||||
|
.frame(width: 180)
|
||||||
|
|
||||||
|
Button("Apply") { state.applyEISSettings() }
|
||||||
|
.buttonStyle(ActionButtonStyle(color: .blue))
|
||||||
|
Button("Sweep") { state.startSweep() }
|
||||||
|
.buttonStyle(ActionButtonStyle(color: .green))
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wide layout (iPad)
|
||||||
|
|
||||||
|
private func wideLayout(geo: GeometryProxy) -> some View {
|
||||||
|
HSplitLayout(ratio: 0.55) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
bodePlot
|
||||||
|
nyquistPlot
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
} trailing: {
|
||||||
|
eisTable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compact layout (iPhone)
|
||||||
|
|
||||||
|
private var compactLayout: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
bodePlot.frame(height: 300)
|
||||||
|
nyquistPlot.frame(height: 300)
|
||||||
|
eisTable.frame(height: 300)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bode plot
|
||||||
|
|
||||||
|
private var bodePlot: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if state.eisPoints.isEmpty {
|
||||||
|
noDataPlaceholder
|
||||||
|
} else {
|
||||||
|
PlotContainer(title: "") {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
magnitudePlot
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
Divider()
|
||||||
|
phasePlot
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var magnitudePlot: some View {
|
||||||
|
Chart {
|
||||||
|
if let ref = state.eisRef {
|
||||||
|
ForEach(Array(ref.enumerated()), id: \.offset) { _, pt in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
|
||||||
|
y: .value("|Z|", Double(pt.magOhms))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.5))
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 1.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(Array(state.eisPoints.enumerated()), id: \.offset) { _, pt in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
|
||||||
|
y: .value("|Z|", Double(pt.magOhms))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.cyan)
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||||
|
}
|
||||||
|
ForEach(Array(state.eisPoints.enumerated()), id: \.offset) { _, pt in
|
||||||
|
PointMark(
|
||||||
|
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
|
||||||
|
y: .value("|Z|", Double(pt.magOhms))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.cyan)
|
||||||
|
.symbolSize(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartXAxisLabel("|Z| (Ohm)", alignment: .leading)
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks(values: .automatic) { value in
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.3))
|
||||||
|
AxisValueLabel {
|
||||||
|
if let v = value.as(Double.self) {
|
||||||
|
Text(freqLabel(v))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYAxisLabel("|Z|", position: .leading)
|
||||||
|
.chartYAxis {
|
||||||
|
AxisMarks(position: .leading) { _ in
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.3))
|
||||||
|
AxisValueLabel()
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.cyan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var phasePlot: some View {
|
||||||
|
Chart {
|
||||||
|
if let ref = state.eisRef {
|
||||||
|
ForEach(Array(ref.enumerated()), id: \.offset) { _, pt in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
|
||||||
|
y: .value("Phase", Double(pt.phaseDeg))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.5))
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 1.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(Array(state.eisPoints.enumerated()), id: \.offset) { _, pt in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
|
||||||
|
y: .value("Phase", Double(pt.phaseDeg))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.orange)
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||||
|
}
|
||||||
|
ForEach(Array(state.eisPoints.enumerated()), id: \.offset) { _, pt in
|
||||||
|
PointMark(
|
||||||
|
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
|
||||||
|
y: .value("Phase", Double(pt.phaseDeg))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.orange)
|
||||||
|
.symbolSize(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartXAxisLabel("Phase (deg)", alignment: .leading)
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks(values: .automatic) { value in
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.3))
|
||||||
|
AxisValueLabel {
|
||||||
|
if let v = value.as(Double.self) {
|
||||||
|
Text(freqLabel(v))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYAxisLabel("Phase", position: .leading)
|
||||||
|
.chartYAxis {
|
||||||
|
AxisMarks(position: .leading) { _ in
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.3))
|
||||||
|
AxisValueLabel()
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Nyquist plot
|
||||||
|
|
||||||
|
private var nyquistPlot: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if state.eisPoints.isEmpty {
|
||||||
|
noDataPlaceholder
|
||||||
|
} else {
|
||||||
|
PlotContainer(title: "") {
|
||||||
|
nyquistCanvas
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nyquistCanvas: some View {
|
||||||
|
Canvas { context, size in
|
||||||
|
let ml: CGFloat = 50, mr: CGFloat = 12, mt: CGFloat = 12, mb: CGFloat = 22
|
||||||
|
let xl = ml, xr = size.width - mr
|
||||||
|
let yt = mt, yb = size.height - mb
|
||||||
|
|
||||||
|
let points = state.eisPoints.filter { $0.zReal.isFinite && $0.zImag.isFinite }
|
||||||
|
guard !points.isEmpty else { return }
|
||||||
|
|
||||||
|
let reVals = points.map { CGFloat($0.zReal) }
|
||||||
|
let niVals = points.map { CGFloat(-$0.zImag) }
|
||||||
|
let (reMin, reMax) = (reVals.min()!, reVals.max()!)
|
||||||
|
let (niMin, niMax) = (niVals.min()!, niVals.max()!)
|
||||||
|
let reSpan = max(reMax - reMin, 1)
|
||||||
|
let niSpan = max(niMax - niMin, 1)
|
||||||
|
let span = max(reSpan, niSpan) * 1.3
|
||||||
|
let reC = (reMin + reMax) / 2
|
||||||
|
let niC = (niMin + niMax) / 2
|
||||||
|
let xvLo = reC - span / 2, xvHi = reC + span / 2
|
||||||
|
let yvLo = niC - span / 2, yvHi = niC + span / 2
|
||||||
|
|
||||||
|
func lx(_ v: CGFloat) -> CGFloat { xl + (v - xvLo) / (xvHi - xvLo) * (xr - xl) }
|
||||||
|
func ly(_ v: CGFloat) -> CGFloat { yt + (yvHi - v) / (yvHi - yvLo) * (yb - yt) }
|
||||||
|
|
||||||
|
// grid
|
||||||
|
let gridColor = Color(white: 0.25).opacity(0.6)
|
||||||
|
drawGrid(context: context, xl: xl, xr: xr, yt: yt, yb: yb,
|
||||||
|
xvLo: xvLo, xvHi: xvHi, yvLo: yvLo, yvHi: yvHi,
|
||||||
|
gridColor: gridColor, size: size)
|
||||||
|
|
||||||
|
// zero line
|
||||||
|
let zy = ly(0)
|
||||||
|
if zy > yt && zy < yb {
|
||||||
|
var zeroPath = Path()
|
||||||
|
zeroPath.move(to: CGPoint(x: xl, y: zy))
|
||||||
|
zeroPath.addLine(to: CGPoint(x: xr, y: zy))
|
||||||
|
context.stroke(zeroPath, with: .color(.gray.opacity(0.6)), lineWidth: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// axis labels
|
||||||
|
context.draw(
|
||||||
|
Text("-Z''").font(.caption2).foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4)),
|
||||||
|
at: CGPoint(x: 20, y: yt - 2))
|
||||||
|
context.draw(
|
||||||
|
Text("Z'").font(.caption2).foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4)),
|
||||||
|
at: CGPoint(x: (xl + xr) / 2, y: yb + 12))
|
||||||
|
|
||||||
|
// reference
|
||||||
|
if let ref = state.eisRef {
|
||||||
|
let refPts = ref.map { CGPoint(x: lx(CGFloat($0.zReal)), y: ly(CGFloat(-$0.zImag))) }
|
||||||
|
drawPolyline(context: context, points: refPts, color: Color.gray.opacity(0.5), width: 1.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// data
|
||||||
|
let dataPts = points.map { CGPoint(x: lx(CGFloat($0.zReal)), y: ly(CGFloat(-$0.zImag))) }
|
||||||
|
let nyqColor = Color(red: 0.4, green: 1, blue: 0.4)
|
||||||
|
drawPolyline(context: context, points: dataPts, color: nyqColor, width: 2)
|
||||||
|
for pt in dataPts {
|
||||||
|
context.fill(Path(ellipseIn: CGRect(x: pt.x - 3, y: pt.y - 3, width: 6, height: 6)),
|
||||||
|
with: .color(nyqColor))
|
||||||
|
}
|
||||||
|
|
||||||
|
// circle fit
|
||||||
|
if let fit = kasaCircleFit(points: points.map { (Double($0.zReal), Double(-$0.zImag)) }) {
|
||||||
|
let disc = fit.r * fit.r - fit.cy * fit.cy
|
||||||
|
if disc > 0 {
|
||||||
|
let sd = sqrt(disc)
|
||||||
|
let rs = fit.cx - sd
|
||||||
|
let rp = 2 * sd
|
||||||
|
|
||||||
|
if rp > 0 {
|
||||||
|
let thetaR = atan2(-fit.cy, sd)
|
||||||
|
var thetaL = atan2(-fit.cy, -sd)
|
||||||
|
if thetaL < thetaR { thetaL += 2 * .pi }
|
||||||
|
|
||||||
|
let nArc = 120
|
||||||
|
var arcPath = Path()
|
||||||
|
for i in 0...nArc {
|
||||||
|
let t = thetaR + (thetaL - thetaR) * Double(i) / Double(nArc)
|
||||||
|
let ax = fit.cx + fit.r * cos(t)
|
||||||
|
let ay = fit.cy + fit.r * sin(t)
|
||||||
|
let pt = CGPoint(x: lx(CGFloat(ax)), y: ly(CGFloat(ay)))
|
||||||
|
if i == 0 { arcPath.move(to: pt) } else { arcPath.addLine(to: pt) }
|
||||||
|
}
|
||||||
|
context.stroke(arcPath, with: .color(Color(red: 0.7, green: 0.3, blue: 0.9).opacity(0.5)),
|
||||||
|
lineWidth: 1.5)
|
||||||
|
|
||||||
|
// Rs and Rp markers
|
||||||
|
let fitPtColor = Color(red: 0.9, green: 0.4, blue: 0.9)
|
||||||
|
let rsScr = CGPoint(x: lx(CGFloat(rs)), y: ly(0))
|
||||||
|
let rpScr = CGPoint(x: lx(CGFloat(rs + rp)), y: ly(0))
|
||||||
|
context.fill(Path(ellipseIn: CGRect(x: rsScr.x - 5, y: rsScr.y - 5, width: 10, height: 10)),
|
||||||
|
with: .color(fitPtColor))
|
||||||
|
context.fill(Path(ellipseIn: CGRect(x: rpScr.x - 5, y: rpScr.y - 5, width: 10, height: 10)),
|
||||||
|
with: .color(fitPtColor))
|
||||||
|
context.draw(
|
||||||
|
Text(String(format: "Rs=%.0f", rs)).font(.caption2).foregroundStyle(fitPtColor),
|
||||||
|
at: CGPoint(x: rsScr.x, y: rsScr.y + 14))
|
||||||
|
context.draw(
|
||||||
|
Text(String(format: "Rp=%.0f", rp)).font(.caption2).foregroundStyle(fitPtColor),
|
||||||
|
at: CGPoint(x: rpScr.x, y: rpScr.y + 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data table
|
||||||
|
|
||||||
|
private var eisTable: some View {
|
||||||
|
MeasurementTable(
|
||||||
|
columns: [
|
||||||
|
MeasurementColumn(header: "Freq (Hz)", width: 80, alignment: .trailing),
|
||||||
|
MeasurementColumn(header: "|Z| (Ohm)", width: 90, alignment: .trailing),
|
||||||
|
MeasurementColumn(header: "Phase (\u{00B0})", width: 70, alignment: .trailing),
|
||||||
|
MeasurementColumn(header: "Re (Ohm)", width: 90, alignment: .trailing),
|
||||||
|
MeasurementColumn(header: "Im (Ohm)", width: 90, alignment: .trailing),
|
||||||
|
],
|
||||||
|
rows: state.eisPoints,
|
||||||
|
cellText: { pt, col in
|
||||||
|
switch col {
|
||||||
|
case 0: String(format: "%.1f", pt.freqHz)
|
||||||
|
case 1: String(format: "%.2f", pt.magOhms)
|
||||||
|
case 2: String(format: "%.2f", pt.phaseDeg)
|
||||||
|
case 3: String(format: "%.2f", pt.zReal)
|
||||||
|
case 4: String(format: "%.2f", pt.zImag)
|
||||||
|
default: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private var noDataPlaceholder: some View {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(Color(white: 0.4))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func freqLabel(_ logVal: Double) -> String {
|
||||||
|
let hz = pow(10, logVal)
|
||||||
|
if hz >= 1000 { return String(format: "%.0fk", hz / 1000) }
|
||||||
|
return String(format: "%.0f", hz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kasa circle fit (ported from plot.rs)
|
||||||
|
|
||||||
|
struct CircleFitResult {
|
||||||
|
let cx: Double
|
||||||
|
let cy: Double
|
||||||
|
let r: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
func kasaCircleFit(points: [(Double, Double)]) -> CircleFitResult? {
|
||||||
|
let all = points.filter { $0.0.isFinite && $0.1.isFinite }
|
||||||
|
guard all.count >= 4 else { return nil }
|
||||||
|
|
||||||
|
let minPts = max(4, all.count / 3)
|
||||||
|
var best: CircleFitResult?
|
||||||
|
var bestScore = Double.greatestFiniteMagnitude
|
||||||
|
|
||||||
|
for start in 0..<all.count {
|
||||||
|
var pts = Array(all[start...])
|
||||||
|
while pts.count >= minPts {
|
||||||
|
guard let raw = kasaFitRaw(pts) else { break }
|
||||||
|
let (cx, cy, r) = raw
|
||||||
|
let disc = r * r - cy * cy
|
||||||
|
if disc > 0 {
|
||||||
|
let sd = sqrt(disc)
|
||||||
|
let rp = 2 * sd
|
||||||
|
if rp > 0 {
|
||||||
|
let avgErr = pts.map { p in
|
||||||
|
abs(sqrt((p.0 - cx) * (p.0 - cx) + (p.1 - cy) * (p.1 - cy)) - r)
|
||||||
|
}.reduce(0, +) / (Double(pts.count) * r)
|
||||||
|
let coverage = Double(pts.count) / Double(all.count)
|
||||||
|
let score = avgErr / coverage
|
||||||
|
if score < bestScore {
|
||||||
|
bestScore = score
|
||||||
|
best = CircleFitResult(cx: cx, cy: cy, r: r)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// remove worst outlier
|
||||||
|
guard let worstIdx = pts.enumerated().max(by: { a, b in
|
||||||
|
let dA = abs(sqrt((a.element.0 - cx) * (a.element.0 - cx) +
|
||||||
|
(a.element.1 - cy) * (a.element.1 - cy)) - r)
|
||||||
|
let dB = abs(sqrt((b.element.0 - cx) * (b.element.0 - cx) +
|
||||||
|
(b.element.1 - cy) * (b.element.1 - cy)) - r)
|
||||||
|
return dA < dB
|
||||||
|
})?.offset else { break }
|
||||||
|
pts.remove(at: worstIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kasaFitRaw(_ pts: [(Double, Double)]) -> (Double, Double, Double)? {
|
||||||
|
guard pts.count >= 3 else { return nil }
|
||||||
|
let n = Double(pts.count)
|
||||||
|
var sx = 0.0, sy = 0.0
|
||||||
|
var sx2 = 0.0, sy2 = 0.0, sxy = 0.0
|
||||||
|
var sx3 = 0.0, sy3 = 0.0, sx2y = 0.0, sxy2 = 0.0
|
||||||
|
|
||||||
|
for (x, y) in pts {
|
||||||
|
sx += x; sy += y
|
||||||
|
let x2 = x * x, y2 = y * y, xy = x * y
|
||||||
|
sx2 += x2; sy2 += y2; sxy += xy
|
||||||
|
sx3 += x2 * x; sy3 += y2 * y; sx2y += x2 * y; sxy2 += x * y2
|
||||||
|
}
|
||||||
|
|
||||||
|
let (a00, a01, a02) = (sx2, sxy, sx)
|
||||||
|
let (a10, a11, a12) = (sxy, sy2, sy)
|
||||||
|
let (a20, a21, a22) = (sx, sy, n)
|
||||||
|
let (r0, r1, r2) = (sx3 + sxy2, sx2y + sy3, sx2 + sy2)
|
||||||
|
|
||||||
|
let det = a00 * (a11 * a22 - a12 * a21)
|
||||||
|
- a01 * (a10 * a22 - a12 * a20)
|
||||||
|
+ a02 * (a10 * a21 - a11 * a20)
|
||||||
|
guard abs(det) > 1e-20 else { return nil }
|
||||||
|
|
||||||
|
let a = (r0 * (a11 * a22 - a12 * a21)
|
||||||
|
- a01 * (r1 * a22 - a12 * r2)
|
||||||
|
+ a02 * (r1 * a21 - a11 * r2)) / det
|
||||||
|
let b = (a00 * (r1 * a22 - a12 * r2)
|
||||||
|
- r0 * (a10 * a22 - a12 * a20)
|
||||||
|
+ a02 * (a10 * r2 - r1 * a20)) / det
|
||||||
|
let c = (a00 * (a11 * r2 - r1 * a21)
|
||||||
|
- a01 * (a10 * r2 - r1 * a20)
|
||||||
|
+ r0 * (a10 * a21 - a11 * a20)) / det
|
||||||
|
|
||||||
|
let cx = a / 2
|
||||||
|
let cy = b / 2
|
||||||
|
let rSq = c + cx * cx + cy * cy
|
||||||
|
guard rSq > 0 else { return nil }
|
||||||
|
return (cx, cy, sqrt(rSq))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Canvas drawing helpers
|
||||||
|
|
||||||
|
private func drawPolyline(context: GraphicsContext, points: [CGPoint], color: Color, width: CGFloat) {
|
||||||
|
guard points.count >= 2 else { return }
|
||||||
|
var path = Path()
|
||||||
|
path.move(to: points[0])
|
||||||
|
for pt in points.dropFirst() {
|
||||||
|
guard pt.x.isFinite && pt.y.isFinite else { continue }
|
||||||
|
path.addLine(to: pt)
|
||||||
|
}
|
||||||
|
context.stroke(path, with: .color(color), lineWidth: width)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawGrid(context: GraphicsContext, xl: CGFloat, xr: CGFloat, yt: CGFloat, yb: CGFloat,
|
||||||
|
xvLo: CGFloat, xvHi: CGFloat, yvLo: CGFloat, yvHi: CGFloat,
|
||||||
|
gridColor: Color, size: CGSize) {
|
||||||
|
let xStep = niceStep(Double(xvHi - xvLo), targetTicks: 4)
|
||||||
|
if xStep > 0 {
|
||||||
|
var g = (Double(xvLo) / xStep).rounded(.up) * xStep
|
||||||
|
while g <= Double(xvHi) {
|
||||||
|
let x = xl + CGFloat((g - Double(xvLo)) / Double(xvHi - xvLo)) * (xr - xl)
|
||||||
|
var p = Path(); p.move(to: CGPoint(x: x, y: yt)); p.addLine(to: CGPoint(x: x, y: yb))
|
||||||
|
context.stroke(p, with: .color(gridColor), lineWidth: 0.5)
|
||||||
|
context.draw(
|
||||||
|
Text(String(format: "%.0f", g)).font(.system(size: 9)).foregroundStyle(.secondary),
|
||||||
|
at: CGPoint(x: x, y: yb + 10))
|
||||||
|
g += xStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let yStep = niceStep(Double(yvHi - yvLo), targetTicks: 4)
|
||||||
|
if yStep > 0 {
|
||||||
|
var g = (Double(yvLo) / yStep).rounded(.up) * yStep
|
||||||
|
while g <= Double(yvHi) {
|
||||||
|
let y = yt + CGFloat((Double(yvHi) - g) / Double(yvHi - yvLo)) * (yb - yt)
|
||||||
|
var p = Path(); p.move(to: CGPoint(x: xl, y: y)); p.addLine(to: CGPoint(x: xr, y: y))
|
||||||
|
context.stroke(p, with: .color(gridColor), lineWidth: 0.5)
|
||||||
|
context.draw(
|
||||||
|
Text(String(format: "%.0f", g)).font(.system(size: 9)).foregroundStyle(.secondary),
|
||||||
|
at: CGPoint(x: xl - 20, y: y))
|
||||||
|
g += yStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func niceStep(_ range: Double, targetTicks: Int) -> Double {
|
||||||
|
guard abs(range) > 1e-10 else { return 1 }
|
||||||
|
let rough = range / Double(targetTicks)
|
||||||
|
let mag = pow(10, floor(log10(abs(rough))))
|
||||||
|
let norm = abs(rough) / mag
|
||||||
|
let s: Double
|
||||||
|
if norm < 1.5 { s = 1 }
|
||||||
|
else if norm < 3.5 { s = 2 }
|
||||||
|
else if norm < 7.5 { s = 5 }
|
||||||
|
else { s = 10 }
|
||||||
|
return s * mag
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared UI components
|
||||||
|
|
||||||
|
struct LabeledField: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var text: String
|
||||||
|
let width: CGFloat
|
||||||
|
|
||||||
|
init(_ label: String, text: Binding<String>, width: CGFloat) {
|
||||||
|
self.label = label
|
||||||
|
self._text = text
|
||||||
|
self.width = width
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField(label, text: $text)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: width)
|
||||||
|
#if os(iOS)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LabeledPicker<Item: Hashable & Identifiable>: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var selection: Item
|
||||||
|
let items: [Item]
|
||||||
|
let itemLabel: (Item) -> String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Picker(label, selection: $selection) {
|
||||||
|
ForEach(items) { item in
|
||||||
|
Text(itemLabel(item)).tag(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
#if os(iOS)
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActionButtonStyle: ButtonStyle {
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(color.opacity(configuration.isPressed ? 0.7 : 1.0))
|
||||||
|
)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HSplitLayout<Leading: View, Trailing: View>: View {
|
||||||
|
let ratio: CGFloat
|
||||||
|
@ViewBuilder let leading: () -> Leading
|
||||||
|
@ViewBuilder let trailing: () -> Trailing
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
leading()
|
||||||
|
.frame(width: geo.size.width * ratio)
|
||||||
|
Divider()
|
||||||
|
trailing()
|
||||||
|
.frame(width: geo.size.width * (1 - ratio))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct LSVView: View {
|
||||||
|
@Bindable var state: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
controlsRow
|
||||||
|
Divider()
|
||||||
|
GeometryReader { geo in
|
||||||
|
if geo.size.width > 700 {
|
||||||
|
HSplitLayout(ratio: 0.55) {
|
||||||
|
voltammogramPlot
|
||||||
|
} trailing: {
|
||||||
|
lsvTable
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
voltammogramPlot.frame(height: 350)
|
||||||
|
lsvTable.frame(height: 300)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls
|
||||||
|
|
||||||
|
private var controlsRow: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
LabeledField("Start mV", text: $state.lsvStartV, width: 80)
|
||||||
|
LabeledField("Stop mV", text: $state.lsvStopV, width: 80)
|
||||||
|
LabeledField("Scan mV/s", text: $state.lsvScanRate, width: 80)
|
||||||
|
|
||||||
|
LabeledPicker("RTIA", selection: $state.lsvRtia, items: LpRtia.allCases) { $0.label }
|
||||||
|
.frame(width: 120)
|
||||||
|
|
||||||
|
Button("Start LSV") { state.startLSV() }
|
||||||
|
.buttonStyle(ActionButtonStyle(color: .green))
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Plot
|
||||||
|
|
||||||
|
private var voltammogramPlot: some View {
|
||||||
|
Group {
|
||||||
|
if state.lsvPoints.isEmpty {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(Color(white: 0.4))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
} else {
|
||||||
|
PlotContainer(title: "") {
|
||||||
|
Chart {
|
||||||
|
if let ref = state.lsvRef {
|
||||||
|
ForEach(Array(ref.enumerated()), id: \.offset) { _, pt in
|
||||||
|
LineMark(
|
||||||
|
x: .value("V", Double(pt.vMv)),
|
||||||
|
y: .value("I", Double(pt.iUa))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.5))
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 1.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(Array(state.lsvPoints.enumerated()), id: \.offset) { _, pt in
|
||||||
|
LineMark(
|
||||||
|
x: .value("V", Double(pt.vMv)),
|
||||||
|
y: .value("I", Double(pt.iUa))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.yellow)
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||||
|
}
|
||||||
|
ForEach(Array(state.lsvPoints.enumerated()), id: \.offset) { _, pt in
|
||||||
|
PointMark(
|
||||||
|
x: .value("V", Double(pt.vMv)),
|
||||||
|
y: .value("I", Double(pt.iUa))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.yellow)
|
||||||
|
.symbolSize(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartXAxisLabel("V (mV)")
|
||||||
|
.chartYAxisLabel("I (uA)", position: .leading)
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks { _ in
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.3))
|
||||||
|
AxisValueLabel()
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYAxis {
|
||||||
|
AxisMarks(position: .leading) { _ in
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.3))
|
||||||
|
AxisValueLabel()
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.yellow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Table
|
||||||
|
|
||||||
|
private var lsvTable: some View {
|
||||||
|
MeasurementTable(
|
||||||
|
columns: [
|
||||||
|
MeasurementColumn(header: "V (mV)", width: 80, alignment: .trailing),
|
||||||
|
MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing),
|
||||||
|
],
|
||||||
|
rows: state.lsvPoints,
|
||||||
|
cellText: { pt, col in
|
||||||
|
switch col {
|
||||||
|
case 0: String(format: "%.1f", pt.vMv)
|
||||||
|
case 1: String(format: "%.3f", pt.iUa)
|
||||||
|
default: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PhView: View {
|
||||||
|
@Bindable var state: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
controlsRow
|
||||||
|
Divider()
|
||||||
|
phBody
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls
|
||||||
|
|
||||||
|
private var controlsRow: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
LabeledField("Stabilize s", text: $state.phStabilize, width: 80)
|
||||||
|
|
||||||
|
Button("Measure pH") { state.startPh() }
|
||||||
|
.buttonStyle(ActionButtonStyle(color: .green))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Result body
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var phBody: some View {
|
||||||
|
if let r = state.phResult {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(String(format: "pH: %.2f", r.ph))
|
||||||
|
.font(.system(size: 48, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
let nernstSlope = 0.1984 * (Double(r.tempC) + 273.15)
|
||||||
|
Text(String(format: "OCP: %.1f mV | Nernst slope: %.2f mV/pH | Temp: %.1f\u{00B0}C",
|
||||||
|
r.vOcpMv, nernstSlope, r.tempC))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if let refR = state.phRef {
|
||||||
|
let dPh = r.ph - refR.ph
|
||||||
|
let dV = r.vOcpMv - refR.vOcpMv
|
||||||
|
Text(String(format: "vs Ref: dpH=%+.3f dOCP=%+.1f mV (ref pH=%.2f)",
|
||||||
|
dPh, dV, refR.ph))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("No measurement yet")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("OCP method: V(SE0) - V(RE0) with Nernst correction")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color(white: 0.4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,259 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionView: View {
|
||||||
|
@State var store = SessionStore()
|
||||||
|
@State private var showingNewSession = false
|
||||||
|
@State private var newName = ""
|
||||||
|
@State private var newNotes = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
if geo.size.width > 700 {
|
||||||
|
wideLayout
|
||||||
|
} else {
|
||||||
|
compactLayout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingNewSession) {
|
||||||
|
newSessionSheet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wide layout (iPad)
|
||||||
|
|
||||||
|
private var wideLayout: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
sessionList
|
||||||
|
.frame(width: 300)
|
||||||
|
Divider()
|
||||||
|
if let session = store.selectedSession {
|
||||||
|
sessionDetail(session)
|
||||||
|
} else {
|
||||||
|
Text("Select or create a session")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compact layout (iPhone)
|
||||||
|
|
||||||
|
private var compactLayout: some View {
|
||||||
|
NavigationStack {
|
||||||
|
sessionList
|
||||||
|
.navigationTitle("Sessions")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button(action: { showingNewSession = true }) {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session list
|
||||||
|
|
||||||
|
private var sessionList: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Sessions")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Button(action: { showingNewSession = true }) {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.imageScale(.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
if store.sessions.isEmpty {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("No sessions")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Create a session to organize measurements")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color(white: 0.4))
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
.onDelete { indices in
|
||||||
|
for idx in indices {
|
||||||
|
store.deleteSession(store.sessions[idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sessionRow(_ session: Session) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(session.name)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
HStack {
|
||||||
|
Text(session.created, style: .date)
|
||||||
|
Text(session.created, style: .time)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if !session.notes.isEmpty {
|
||||||
|
Text(session.notes)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
if !session.measurements.isEmpty {
|
||||||
|
Text("\(session.measurements.count) measurement\(session.measurements.count == 1 ? "" : "s")")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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("Notes (optional)", text: $newNotes, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("New Session")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
showingNewSession = false
|
||||||
|
newName = ""
|
||||||
|
newNotes = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Create") {
|
||||||
|
store.createSession(name: newName, notes: newNotes)
|
||||||
|
showingNewSession = false
|
||||||
|
newName = ""
|
||||||
|
newNotes = ""
|
||||||
|
}
|
||||||
|
.disabled(newName.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue