Add CSV export for days

This commit is contained in:
Christoph Hagen 2025-01-31 13:37:44 +01:00
parent 00e4da3f21
commit e9f6bafe33
6 changed files with 173 additions and 6 deletions

View File

@ -49,6 +49,8 @@
E2E69B682A529FA800C6035E /* TransferHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E69B672A529FA800C6035E /* TransferHandler.swift */; };
E2FD1D6B2D4CE64300B48627 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = E2FD1D6A2D4CE64300B48627 /* ZIPFoundation */; };
E2FD1D6F2D4CE83700B48627 /* ExportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D6E2D4CE83700B48627 /* ExportSheet.swift */; };
E2FD1D712D4CF5EB00B48627 /* CsvConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D702D4CF5E700B48627 /* CsvConverter.swift */; };
E2FD1D732D4CF82100B48627 /* ShareCsvSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D722D4CF81900B48627 /* ShareCsvSheet.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -93,6 +95,8 @@
E2E69B652A4DA48B00C6035E /* BluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothDevice.swift; sourceTree = "<group>"; };
E2E69B672A529FA800C6035E /* TransferHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferHandler.swift; sourceTree = "<group>"; };
E2FD1D6E2D4CE83700B48627 /* ExportSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportSheet.swift; sourceTree = "<group>"; };
E2FD1D702D4CF5E700B48627 /* CsvConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CsvConverter.swift; sourceTree = "<group>"; };
E2FD1D722D4CF81900B48627 /* ShareCsvSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareCsvSheet.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -112,6 +116,7 @@
88404DD92A2F4DB100D30244 /* Storage */ = {
isa = PBXGroup;
children = (
E2FD1D702D4CF5E700B48627 /* CsvConverter.swift */,
88404DDA2A2F4DCA00D30244 /* MeasurementDailyCount.swift */,
88CDE0672A2698B400114294 /* PersistentStorage.swift */,
E2A553F82A399F58005204C3 /* Log.swift */,
@ -196,6 +201,7 @@
E2A554042A4ADA93005204C3 /* TransferView.swift */,
E2E69B5F2A4CD48F00C6035E /* IconAndTextView.swift */,
E2FD1D6E2D4CE83700B48627 /* ExportSheet.swift */,
E2FD1D722D4CF81900B48627 /* ShareCsvSheet.swift */,
);
path = Views;
sourceTree = "<group>";
@ -312,6 +318,7 @@
E2A554142A4C9C96005204C3 /* DeviceDataRequest.swift in Sources */,
88CDE0512A2508E900114294 /* ContentView.swift in Sources */,
88CDE0702A28AEA300114294 /* TemperatureMeasurement.swift in Sources */,
E2FD1D732D4CF82100B48627 /* ShareCsvSheet.swift in Sources */,
E2A554162A4C9D2E005204C3 /* DeviceDataResetRequest.swift in Sources */,
88404DE12A31CA6B00D30244 /* BluetoothResponseType.swift in Sources */,
88404DDF2A2F68E100D30244 /* TemperatureDayOverview.swift in Sources */,
@ -337,6 +344,7 @@
88404DD22A2F0D8F00D30244 /* Double+Extensions.swift in Sources */,
E2A554102A4C9C68005204C3 /* DeviceRequest.swift in Sources */,
E253A9222A2B39B700EC6B28 /* Color+Extensions.swift in Sources */,
E2FD1D712D4CF5EB00B48627 /* CsvConverter.swift in Sources */,
E2E69B682A529FA800C6035E /* TransferHandler.swift in Sources */,
88404DEB2A37BE3000D30244 /* DeviceWakeCause.swift in Sources */,
88CDE06D2A28A92000114294 /* DeviceInfo.swift in Sources */,

View File

@ -0,0 +1,40 @@
import Foundation
struct CsvConverter {
private let dateFormatter: DateFormatter
init() {
dateFormatter = .init()
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .medium
}
private var header: String {
"Index;Date;Sensor 0;Sensor 1\n"
}
func convert(measurements: [TemperatureMeasurement]) -> String {
header + measurements.enumerated().map { (index, measurement) -> String in
let parts: [String] = [
"\(index)",
dateFormatter.string(from: measurement.date),
convert(measurement.sensor0),
convert(measurement.sensor1)
]
return parts.joined(separator: ";")
}.joined(separator: "\n")
}
private func convert(_ value: TemperatureValue) -> String {
switch value {
case .notFound:
return ""
case .invalidMeasurement:
return "?"
case .value(let value):
return String(format:"%.1f", value)
}
}
}

View File

@ -1,4 +1,5 @@
import SwiftUI
import SFSafeSymbols
private let df: DateFormatter = {
let df = DateFormatter()
@ -21,19 +22,45 @@ struct DayView: View {
@EnvironmentObject
var storage: PersistentStorage
@State
private var showShareSheet = false
private let title: String
init(dateIndex: Int) {
self.dateIndex = dateIndex
self.title = Date(dateIndex: dateIndex)
.formatted(date: .abbreviated, time: .omitted)
}
var entries: [TemperatureMeasurement] {
storage.loadMeasurements(for: dateIndex)
}
var body: some View {
TemperatureDayOverview(points: entries)
List(entries) { entry in
HStack {
Text(df.string(from: entry.date))
Spacer()
Text(entry.displayText)
VStack {
TemperatureDayOverview(points: entries)
List(entries) { entry in
HStack {
Text(entry.date.formatted(date: .omitted, time: .standard))
Spacer()
Text(entry.displayText)
}
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem {
Button(action: { showShareSheet = true }) {
Label("Export", systemSymbol: .arrowUpDoc)
}
}
}
.sheet(isPresented: $showShareSheet) {
ShareCsvSheet(isPresented: $showShareSheet,
measurements: entries)
}
}
}

View File

@ -39,6 +39,7 @@ struct ExportSheet: View {
Button("Delete archive", action: deleteArchive)
.padding()
}
Button("Dismiss", action: { isPresented = false })
}
.padding()
}

View File

@ -0,0 +1,91 @@
import SwiftUI
struct ShareCsvSheet: View {
@Binding var isPresented: Bool
@State
private var isCreatingFile = false
@State var url: URL?
@State var error: String?
@EnvironmentObject
private var storage: PersistentStorage
let measurements: [TemperatureMeasurement]
var body: some View {
VStack {
Text("Export")
.font(.title)
Text("Create a CSV file of all measurements for the day")
if isCreatingFile {
ProgressView()
Text("Creating file...")
}
if let error {
Text(error)
.foregroundStyle(.red)
}
Spacer()
Button("Create file", action: createFile)
.disabled(isCreatingFile)
.padding()
if let url {
ShareLink(item: url) {
Label("Share file", systemImage: "square.and.arrow.up")
}
.padding()
Button("Delete file", action: deleteFile)
.padding()
}
Button("Dismiss", action: { isPresented = false })
}
.padding()
}
private func createFile() {
guard !isCreatingFile else {
return
}
isCreatingFile = true
DispatchQueue.main.async {
do {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("export.csv")
let converter = CsvConverter()
let content = converter.convert(measurements: measurements)
try content.write(to: url, atomically: true, encoding: .utf8)
DispatchQueue.main.async {
self.url = url
self.error = nil
self.isCreatingFile = false
}
} catch {
DispatchQueue.main.async {
self.error = "Failed to save file: \(error.localizedDescription)"
self.isCreatingFile = false
}
}
}
}
private func deleteFile() {
guard let url else {
return
}
do {
try FileManager.default.removeItem(at: url)
self.error = nil
self.url = nil
} catch {
self.error = "Failed to delete archive: \(error.localizedDescription)"
}
}
}
#Preview {
ShareCsvSheet(isPresented: .constant(true),
measurements: [])
}