EIS-BLE-S3/cue-ios/CueIOS/Views/SessionView.swift

441 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
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) {}
}