import SwiftUI import GRDB 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 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 = "" 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 } }