diff --git a/TempTrack.xcodeproj/project.pbxproj b/TempTrack.xcodeproj/project.pbxproj index 6e2087f..6e013b7 100644 --- a/TempTrack.xcodeproj/project.pbxproj +++ b/TempTrack.xcodeproj/project.pbxproj @@ -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 = ""; }; E2E69B672A529FA800C6035E /* TransferHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferHandler.swift; sourceTree = ""; }; E2FD1D6E2D4CE83700B48627 /* ExportSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportSheet.swift; sourceTree = ""; }; + E2FD1D702D4CF5E700B48627 /* CsvConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CsvConverter.swift; sourceTree = ""; }; + E2FD1D722D4CF81900B48627 /* ShareCsvSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareCsvSheet.swift; sourceTree = ""; }; /* 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 = ""; @@ -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 */, diff --git a/TempTrack.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate b/TempTrack.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate index fa979f4..e756b2f 100644 Binary files a/TempTrack.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate and b/TempTrack.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/TempTrack/Storage/CsvConverter.swift b/TempTrack/Storage/CsvConverter.swift new file mode 100644 index 0000000..b291d2e --- /dev/null +++ b/TempTrack/Storage/CsvConverter.swift @@ -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) + } + } +} diff --git a/TempTrack/Views/DayView.swift b/TempTrack/Views/DayView.swift index 3bf6c77..f972679 100644 --- a/TempTrack/Views/DayView.swift +++ b/TempTrack/Views/DayView.swift @@ -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) + } } } diff --git a/TempTrack/Views/ExportSheet.swift b/TempTrack/Views/ExportSheet.swift index 1517a67..c2fc694 100644 --- a/TempTrack/Views/ExportSheet.swift +++ b/TempTrack/Views/ExportSheet.swift @@ -39,6 +39,7 @@ struct ExportSheet: View { Button("Delete archive", action: deleteArchive) .padding() } + Button("Dismiss", action: { isPresented = false }) } .padding() } diff --git a/TempTrack/Views/ShareCsvSheet.swift b/TempTrack/Views/ShareCsvSheet.swift new file mode 100644 index 0000000..bd2d6cd --- /dev/null +++ b/TempTrack/Views/ShareCsvSheet.swift @@ -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: []) +}