443 lines
15 KiB
Swift
443 lines
15 KiB
Swift
import SwiftUI
|
|
import GRDB
|
|
import UniformTypeIdentifiers
|
|
|
|
struct SessionView: View {
|
|
@Bindable var state: AppState
|
|
@State private var sessions: [Session] = []
|
|
@State private var selectedSessionId: Int64?
|
|
@State private var showingNewSession = false
|
|
@State private var newLabel = ""
|
|
@State private var newNotes = ""
|
|
@State private var sessionCancellable: DatabaseCancellable?
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
if geo.size.width > 700 {
|
|
wideLayout
|
|
} else {
|
|
compactLayout
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingNewSession) {
|
|
newSessionSheet
|
|
}
|
|
.onAppear { startObserving() }
|
|
.onDisappear { sessionCancellable?.cancel() }
|
|
}
|
|
|
|
private func startObserving() {
|
|
sessionCancellable = Storage.shared.observeSessions { sessions in
|
|
Task { @MainActor in
|
|
self.sessions = sessions
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Wide layout (iPad)
|
|
|
|
private var wideLayout: some View {
|
|
HStack(spacing: 0) {
|
|
sessionList
|
|
.frame(width: 300)
|
|
Divider()
|
|
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)
|
|
.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")
|
|
}
|
|
}
|
|
}
|
|
.navigationDestination(for: Int64.self) { sid in
|
|
if let session = sessions.first(where: { $0.id == sid }) {
|
|
SessionDetailView(session: session, state: state)
|
|
.navigationTitle(session.label ?? "Session")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 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: $selectedSessionId) {
|
|
ForEach(sessions, id: \.id) { session in
|
|
NavigationLink(value: session.id!) {
|
|
sessionRow(session)
|
|
}
|
|
.tag(session.id!)
|
|
}
|
|
.onDelete { indices in
|
|
for idx in indices {
|
|
guard let sid = sessions[idx].id else { continue }
|
|
try? Storage.shared.deleteSession(sid)
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sessionRow(_ session: Session) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(session.label ?? "Untitled")
|
|
.font(.subheadline.weight(.medium))
|
|
HStack {
|
|
Text(session.startedAt, style: .date)
|
|
Text(session.startedAt, style: .time)
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
if let notes = session.notes, !notes.isEmpty {
|
|
Text(notes)
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
}
|
|
let count = (try? Storage.shared.measurementCount(sessionId: session.id!)) ?? 0
|
|
if count > 0 {
|
|
Text("\(count) measurement\(count == 1 ? "" : "s")")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
// MARK: - New session sheet
|
|
|
|
private var newSessionSheet: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section("Session Info") {
|
|
TextField("Name", text: $newLabel)
|
|
TextField("Notes (optional)", text: $newNotes, axis: .vertical)
|
|
.lineLimit(3...6)
|
|
}
|
|
}
|
|
.navigationTitle("New Session")
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
showingNewSession = false
|
|
newLabel = ""
|
|
newNotes = ""
|
|
}
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Create") {
|
|
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
|
|
newLabel = ""
|
|
newNotes = ""
|
|
}
|
|
.disabled(newLabel.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium])
|
|
}
|
|
}
|
|
|
|
// 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 = ""
|
|
@State private var showingFileImporter = false
|
|
@State private var showingShareSheet = false
|
|
@State private var exportFileURL: URL?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
header
|
|
Divider()
|
|
measurementsList
|
|
}
|
|
}
|
|
.onAppear { loadMeasurements() }
|
|
.onChange(of: session.id) { loadMeasurements() }
|
|
.sheet(isPresented: $editing) { editSheet }
|
|
.sheet(isPresented: $showingShareSheet) {
|
|
if let url = exportFileURL {
|
|
ShareSheet(items: [url])
|
|
}
|
|
}
|
|
.fileImporter(
|
|
isPresented: $showingFileImporter,
|
|
allowedContentTypes: [.plainText],
|
|
onCompletion: handleImportedFile
|
|
)
|
|
}
|
|
|
|
private func loadMeasurements() {
|
|
guard let sid = session.id else { return }
|
|
measurements = (try? Storage.shared.fetchMeasurements(sessionId: sid)) ?? []
|
|
}
|
|
|
|
private func exportSession() {
|
|
guard let sid = session.id else { return }
|
|
do {
|
|
let toml = try Storage.shared.exportSession(sid)
|
|
let name = (session.label ?? "session").replacingOccurrences(of: " ", with: "_")
|
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(name).toml")
|
|
try toml.write(to: url, atomically: true, encoding: .utf8)
|
|
exportFileURL = url
|
|
showingShareSheet = true
|
|
} catch {
|
|
state.status = "Export failed: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
private func handleImportedFile(_ result: Result<URL, Error>) {
|
|
switch result {
|
|
case .success(let url):
|
|
guard url.startAccessingSecurityScopedResource() else {
|
|
state.status = "Cannot access file"
|
|
return
|
|
}
|
|
defer { url.stopAccessingSecurityScopedResource() }
|
|
do {
|
|
let toml = try String(contentsOf: url, encoding: .utf8)
|
|
let _ = try Storage.shared.importSession(from: toml)
|
|
state.status = "Session imported"
|
|
} catch {
|
|
state.status = "Import failed: \(error.localizedDescription)"
|
|
}
|
|
case .failure(let error):
|
|
state.status = "File error: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
Button(action: { exportSession() }) {
|
|
Image(systemName: "square.and.arrow.up")
|
|
.imageScale(.large)
|
|
}
|
|
Button(action: { showingFileImporter = true }) {
|
|
Image(systemName: "square.and.arrow.down")
|
|
.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
|
|
NavigationLink {
|
|
MeasurementDataView(measurement: meas)
|
|
} label: {
|
|
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
|
|
}
|
|
}
|
|
|
|
struct ShareSheet: UIViewControllerRepresentable {
|
|
let items: [Any]
|
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
|
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
|
}
|
|
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
|
|
}
|