Add CSV export for days
This commit is contained in:
parent
00e4da3f21
commit
e9f6bafe33
@ -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 */,
|
||||
|
Binary file not shown.
40
TempTrack/Storage/CsvConverter.swift
Normal file
40
TempTrack/Storage/CsvConverter.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -39,6 +39,7 @@ struct ExportSheet: View {
|
||||
Button("Delete archive", action: deleteArchive)
|
||||
.padding()
|
||||
}
|
||||
Button("Dismiss", action: { isPresented = false })
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
91
TempTrack/Views/ShareCsvSheet.swift
Normal file
91
TempTrack/Views/ShareCsvSheet.swift
Normal 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: [])
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user