From 147cd6a30614582072eeba22e4107cd94b5e37c5 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Thu, 8 Jun 2023 09:52:20 +0200 Subject: [PATCH] Add basic storage, temperature history display --- TempTrack.xcodeproj/project.pbxproj | 51 +++- .../xcshareddata/swiftpm/Package.resolved | 9 + .../xcschemes/xcschememanagement.plist | 23 +- TempTrack/Bluetooth/BluetoothClient.swift | 33 ++- TempTrack/ContentView.swift | 75 ++--- TempTrack/DeviceInfo.swift | 42 ++- TempTrack/Extensions/Date+Extensions.swift | 65 +++++ TempTrack/Extensions/Double+Extensions.swift | 8 + TempTrack/Storage/MeasurementDailyCount.swift | 45 +++ TempTrack/Storage/TemperatureStorage.swift | 275 ++++++++++++++++++ TempTrack/TempTrackApp.swift | 9 + .../Temperature/TemperatureDataTransfer.swift | 3 +- .../TemperatureDataTransferDelegate.swift | 2 + .../Temperature/TemperatureMeasurement.swift | 103 +++++-- TempTrack/Temperature/TemperatureSensor.swift | 32 -- TempTrack/Temperature/TemperatureValue.swift | 27 ++ TempTrack/TemperatureStorage.swift | 76 ----- TempTrack/Views/DeviceInfoView.swift | 22 +- TempTrack/Views/HistoryList.swift | 28 ++ TempTrack/Views/HistoryListRow.swift | 29 ++ TempTrack/Views/TemperatureDayOverview.swift | 85 ++++++ TempTrack/Views/TemperatureHistoryChart.swift | 12 +- 22 files changed, 843 insertions(+), 211 deletions(-) create mode 100644 TempTrack/Extensions/Date+Extensions.swift create mode 100644 TempTrack/Extensions/Double+Extensions.swift create mode 100644 TempTrack/Storage/MeasurementDailyCount.swift create mode 100644 TempTrack/Storage/TemperatureStorage.swift delete mode 100644 TempTrack/TemperatureStorage.swift create mode 100644 TempTrack/Views/HistoryList.swift create mode 100644 TempTrack/Views/HistoryListRow.swift create mode 100644 TempTrack/Views/TemperatureDayOverview.swift diff --git a/TempTrack.xcodeproj/project.pbxproj b/TempTrack.xcodeproj/project.pbxproj index 0d84626..e1ca96b 100644 --- a/TempTrack.xcodeproj/project.pbxproj +++ b/TempTrack.xcodeproj/project.pbxproj @@ -7,6 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + 88404DD02A2E718B00D30244 /* BinaryCodable in Frameworks */ = {isa = PBXBuildFile; productRef = 88404DCF2A2E718B00D30244 /* BinaryCodable */; }; + 88404DD22A2F0D8F00D30244 /* Double+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88404DD12A2F0D8F00D30244 /* Double+Extensions.swift */; }; + 88404DD42A2F0DB100D30244 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88404DD32A2F0DB100D30244 /* Date+Extensions.swift */; }; + 88404DD82A2F381B00D30244 /* HistoryList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88404DD72A2F381B00D30244 /* HistoryList.swift */; }; + 88404DDB2A2F4DCA00D30244 /* MeasurementDailyCount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88404DDA2A2F4DCA00D30244 /* MeasurementDailyCount.swift */; }; + 88404DDD2A2F587400D30244 /* HistoryListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88404DDC2A2F587400D30244 /* HistoryListRow.swift */; }; + 88404DDF2A2F68E100D30244 /* TemperatureDayOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88404DDE2A2F68E100D30244 /* TemperatureDayOverview.swift */; }; 88CDE04F2A2508E900114294 /* TempTrackApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE04E2A2508E900114294 /* TempTrackApp.swift */; }; 88CDE0512A2508E900114294 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0502A2508E900114294 /* ContentView.swift */; }; 88CDE0532A2508EA00114294 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88CDE0522A2508EA00114294 /* Assets.xcassets */; }; @@ -32,6 +39,12 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 88404DD12A2F0D8F00D30244 /* Double+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extensions.swift"; sourceTree = ""; }; + 88404DD32A2F0DB100D30244 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; + 88404DD72A2F381B00D30244 /* HistoryList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryList.swift; sourceTree = ""; }; + 88404DDA2A2F4DCA00D30244 /* MeasurementDailyCount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementDailyCount.swift; sourceTree = ""; }; + 88404DDC2A2F587400D30244 /* HistoryListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryListRow.swift; sourceTree = ""; }; + 88404DDE2A2F68E100D30244 /* TemperatureDayOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureDayOverview.swift; sourceTree = ""; }; 88CDE04B2A2508E900114294 /* TempTrack.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TempTrack.app; sourceTree = BUILT_PRODUCTS_DIR; }; 88CDE04E2A2508E900114294 /* TempTrackApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTrackApp.swift; sourceTree = ""; }; 88CDE0502A2508E900114294 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -59,6 +72,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 88404DD02A2E718B00D30244 /* BinaryCodable in Frameworks */, E253A9272A2CA48A00EC6B28 /* SQLite in Frameworks */, 88CDE0662A25D08F00114294 /* SFSafeSymbols in Frameworks */, 88CDE06B2A2899C900114294 /* BottomSheet in Frameworks */, @@ -68,6 +82,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 88404DD92A2F4DB100D30244 /* Storage */ = { + isa = PBXGroup; + children = ( + 88404DDA2A2F4DCA00D30244 /* MeasurementDailyCount.swift */, + 88CDE0672A2698B400114294 /* TemperatureStorage.swift */, + ); + path = Storage; + sourceTree = ""; + }; 88CDE0422A2508E800114294 = { isa = PBXGroup; children = ( @@ -92,10 +115,10 @@ E253A9202A2B39A700EC6B28 /* Extensions */, 88CDE07C2A28AFE700114294 /* Views */, 88CDE0792A28AF3E00114294 /* Bluetooth */, + 88404DD92A2F4DB100D30244 /* Storage */, 88CDE06E2A28AE8D00114294 /* Temperature */, 88CDE0522A2508EA00114294 /* Assets.xcassets */, 88CDE0542A2508EA00114294 /* Preview Content */, - 88CDE0672A2698B400114294 /* TemperatureStorage.swift */, 88CDE06C2A28A92000114294 /* DeviceInfo.swift */, ); path = TempTrack; @@ -138,6 +161,9 @@ children = ( 88CDE07D2A28AFF400114294 /* DeviceInfoView.swift */, E253A9232A2B462500EC6B28 /* TemperatureHistoryChart.swift */, + 88404DD72A2F381B00D30244 /* HistoryList.swift */, + 88404DDC2A2F587400D30244 /* HistoryListRow.swift */, + 88404DDE2A2F68E100D30244 /* TemperatureDayOverview.swift */, ); path = Views; sourceTree = ""; @@ -146,6 +172,8 @@ isa = PBXGroup; children = ( E253A9212A2B39B700EC6B28 /* Color+Extensions.swift */, + 88404DD12A2F0D8F00D30244 /* Double+Extensions.swift */, + 88404DD32A2F0DB100D30244 /* Date+Extensions.swift */, ); path = Extensions; sourceTree = ""; @@ -170,6 +198,7 @@ 88CDE0652A25D08F00114294 /* SFSafeSymbols */, 88CDE06A2A2899C900114294 /* BottomSheet */, E253A9262A2CA48A00EC6B28 /* SQLite */, + 88404DCF2A2E718B00D30244 /* BinaryCodable */, ); productName = TempTrack; productReference = 88CDE04B2A2508E900114294 /* TempTrack.app */; @@ -203,6 +232,7 @@ 88CDE0642A25D08F00114294 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, 88CDE0692A2899C900114294 /* XCRemoteSwiftPackageReference "bottom-sheet" */, E253A9252A2CA48900EC6B28 /* XCRemoteSwiftPackageReference "SQLite" */, + 88404DCE2A2E718B00D30244 /* XCRemoteSwiftPackageReference "BinaryCodable" */, ); productRefGroup = 88CDE04C2A2508E900114294 /* Products */; projectDirPath = ""; @@ -230,23 +260,29 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 88404DDB2A2F4DCA00D30244 /* MeasurementDailyCount.swift in Sources */, 88CDE0512A2508E900114294 /* ContentView.swift in Sources */, 88CDE05F2A250F5200114294 /* DeviceState.swift in Sources */, 88CDE0632A253AD900114294 /* TemperatureDataTransfer.swift in Sources */, 88CDE0702A28AEA300114294 /* TemperatureMeasurement.swift in Sources */, 88CDE05D2A250F3C00114294 /* DeviceManager.swift in Sources */, + 88404DDF2A2F68E100D30244 /* TemperatureDayOverview.swift in Sources */, 88CDE04F2A2508E900114294 /* TempTrackApp.swift in Sources */, 88CDE0722A28AEB900114294 /* TemperatureDataTransferDelegate.swift in Sources */, 88CDE0782A28AF2C00114294 /* TemperatureSensor.swift in Sources */, 88CDE0682A2698B400114294 /* TemperatureStorage.swift in Sources */, + 88404DD42A2F0DB100D30244 /* Date+Extensions.swift in Sources */, 88CDE0762A28AF0900114294 /* TemperatureValue.swift in Sources */, 88CDE07B2A28AF5100114294 /* BluetoothRequest.swift in Sources */, E253A9242A2B462500EC6B28 /* TemperatureHistoryChart.swift in Sources */, + 88404DD22A2F0D8F00D30244 /* Double+Extensions.swift in Sources */, E253A9222A2B39B700EC6B28 /* Color+Extensions.swift in Sources */, 88CDE0612A25108100114294 /* BluetoothClient.swift in Sources */, 88CDE0742A28AEE500114294 /* DeviceManagerDelegate.swift in Sources */, 88CDE06D2A28A92000114294 /* DeviceInfo.swift in Sources */, + 88404DD82A2F381B00D30244 /* HistoryList.swift in Sources */, 88CDE07E2A28AFF400114294 /* DeviceInfoView.swift in Sources */, + 88404DDD2A2F587400D30244 /* HistoryListRow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -453,6 +489,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 88404DCE2A2E718B00D30244 /* XCRemoteSwiftPackageReference "BinaryCodable" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/christophhagen/BinaryCodable"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; 88CDE0642A25D08F00114294 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols"; @@ -480,6 +524,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 88404DCF2A2E718B00D30244 /* BinaryCodable */ = { + isa = XCSwiftPackageProductDependency; + package = 88404DCE2A2E718B00D30244 /* XCRemoteSwiftPackageReference "BinaryCodable" */; + productName = BinaryCodable; + }; 88CDE0652A25D08F00114294 /* SFSafeSymbols */ = { isa = XCSwiftPackageProductDependency; package = 88CDE0642A25D08F00114294 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; diff --git a/TempTrack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TempTrack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a493971..5dfd079 100644 --- a/TempTrack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TempTrack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "binarycodable", + "kind" : "remoteSourceControl", + "location" : "https://github.com/christophhagen/BinaryCodable", + "state" : { + "revision" : "295ca6399b2b01d1aa4fa84d666416f3bf99ffde", + "version" : "2.0.0" + } + }, { "identity" : "bottom-sheet", "kind" : "remoteSourceControl", diff --git a/TempTrack.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist b/TempTrack.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist index 5589bef..31ba060 100644 --- a/TempTrack.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/TempTrack.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,10 +4,31 @@ SchemeUserState + SQLite (Playground) 1.xcscheme + + isShown + + orderHint + 2 + + SQLite (Playground) 2.xcscheme + + isShown + + orderHint + 3 + + SQLite (Playground).xcscheme + + isShown + + orderHint + 0 + TempTrack.xcscheme_^#shared#^_ orderHint - 0 + 1 diff --git a/TempTrack/Bluetooth/BluetoothClient.swift b/TempTrack/Bluetooth/BluetoothClient.swift index d059250..c23867f 100644 --- a/TempTrack/Bluetooth/BluetoothClient.swift +++ b/TempTrack/Bluetooth/BluetoothClient.swift @@ -18,11 +18,13 @@ enum BluetoothResponseType: UInt8 { final class BluetoothClient: ObservableObject { + weak var delegate: TemperatureDataTransferDelegate? + private let updateInterval = 3.0 private let connection = DeviceManager() - private let recorder = TemperatureStorage() + private var didTransferData = false init(deviceInfo: DeviceInfo? = nil) { connection.delegate = self @@ -41,12 +43,25 @@ final class BluetoothClient: ObservableObject { startRegularUpdates() } else { endRegularUpdates() + didTransferData = false } } } @Published - private(set) var deviceInfo: DeviceInfo? + private(set) var deviceInfo: DeviceInfo? { + didSet { + guard !didTransferData, runningTransfer == nil else { + return + } + guard !openRequests.contains(where: { if case .getRecordingData = $0 { return true }; return false }) else { + return + } + guard collectRecordedData() else { + return + } + } + } private var openRequests: [BluetoothRequest] = [] @@ -55,9 +70,11 @@ final class BluetoothClient: ObservableObject { private var runningTransfer: TemperatureDataTransfer? func updateDeviceInfo() { - if case .configured = deviceState { - addRequest(.getInfo) + guard case .configured = deviceState else { + return } + addRequest(.getInfo) + } private var dataUpdateTimer: Timer? @@ -118,7 +135,7 @@ final class BluetoothClient: ObservableObject { } let transfer = TemperatureDataTransfer(info: info) runningTransfer = transfer - runningTransfer?.delegate = recorder + runningTransfer?.delegate = delegate let next = transfer.nextRequest() addRequest(next) return true @@ -137,6 +154,12 @@ final class BluetoothClient: ObservableObject { return // TODO: Start new transfer? } let next = runningTransfer.nextRequest() + if case .clearRecordingBuffer = next { + runningTransfer.completeTransfer() + self.runningTransfer = nil + didTransferData = true + return + } addRequest(next) } diff --git a/TempTrack/ContentView.swift b/TempTrack/ContentView.swift index 91e3dbb..6ff05b8 100644 --- a/TempTrack/ContentView.swift +++ b/TempTrack/ContentView.swift @@ -4,8 +4,6 @@ import BottomSheet struct ContentView: View { - private let updateInterval = 1.0 - private let minTempColor = Color(hue: 0.624, saturation: 0.5, brightness: 1.0) private let minTemperature = -20.0 @@ -14,49 +12,29 @@ struct ContentView: View { private let disconnectedColor = Color(white: 0.8) - @ObservedObject - var client = BluetoothClient() + @EnvironmentObject + var bluetoothClient: BluetoothClient - @ObservedObject - var storage = TemperatureStorage() + @EnvironmentObject + var storage: TemperatureStorage @State var showDeviceInfo = false - - @State - var updateTimer: Timer? - - @State - var updateInfoToggle = true + @State + var showHistory = false + init() { - startRegularUpdates() - } - - init(client: BluetoothClient, values: [TemperatureMeasurement]) { - self.client = client - self.storage = .init(lastMeasurements: values) - startRegularUpdates() - } - - private func startRegularUpdates() { - guard updateTimer == nil else { - return - } - updateTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true) { timer in - self.updateInfoToggle.toggle() - } - - updateTimer?.fire() + } var hasDeviceInfo: Bool { - client.deviceInfo != nil + bluetoothClient.deviceInfo != nil } var averageTemperature: Double? { - let t1 = client.deviceInfo?.sensor1?.optionalValue - guard let t0 = client.deviceInfo?.sensor0?.optionalValue else { + let t1 = bluetoothClient.deviceInfo?.sensor1?.optionalValue + guard let t0 = bluetoothClient.deviceInfo?.sensor0?.optionalValue else { return t1 } guard let t1 else { @@ -123,10 +101,15 @@ struct ContentView: View { } Spacer() - TemperatureHistoryChart(points: storage.lastMeasurements) - .frame(height: 150) - .background(Color.white.opacity(0.1)) - .cornerRadius(8) + + Button { + self.showHistory = true + } label: { + TemperatureHistoryChart(points: $storage.recentMeasurements) + .frame(height: 150) + .background(Color.white.opacity(0.1)) + .cornerRadius(8) + } HStack(alignment: .center) { Button { self.showDeviceInfo = true @@ -135,7 +118,7 @@ struct ContentView: View { Image(systemSymbol: .iphone) .font(.system(size: 30, weight: .regular)) } - Text(client.deviceState.text) + Text(bluetoothClient.deviceState.text) } .disabled(!hasDeviceInfo) .foregroundColor(.white) @@ -144,23 +127,25 @@ struct ContentView: View { } .padding() .bottomSheet(isPresented: $showDeviceInfo, height: 600) { - if let info = client.deviceInfo { - DeviceInfoView( - info: info, - isPresented: $showDeviceInfo, updateToggle: $updateInfoToggle) + if let info = bluetoothClient.deviceInfo { + DeviceInfoView(info: info, isPresented: $showDeviceInfo) } else { EmptyView() } } + .sheet(isPresented: $showHistory) { + HistoryList() + .environmentObject(storage) + } .background(backgroundGradient) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { - ContentView( - client: BluetoothClient(deviceInfo: .mock), - values: TemperatureMeasurement.mockData) + ContentView() + .environmentObject(TemperatureStorage(lastMeasurements: TemperatureMeasurement.mockData)) + .environmentObject(BluetoothClient(deviceInfo: .mock)) } } diff --git a/TempTrack/DeviceInfo.swift b/TempTrack/DeviceInfo.swift index 13d4109..b9ebeea 100644 --- a/TempTrack/DeviceInfo.swift +++ b/TempTrack/DeviceInfo.swift @@ -13,25 +13,29 @@ struct DeviceInfo { /// The interval between measurements (in seconds) let measurementInterval: Int - /// The maximum number of bytes which can be requested - let transferBlockSize: Int - - let deviceStartTime: Date - let nextMeasurement: Date let sensor0: TemperatureSensor? let sensor1: TemperatureSensor? - let storageSize: Int + // MARK: Device time /** The number of seconds the device has been powered on */ - var numberOfSecondsRunning: Int { - Int(-deviceStartTime.timeIntervalSinceNow) - } + let numberOfSecondsRunning: Int + + let deviceStartTime: Date + + let hasDeviceStartTimeSet: Bool + + // MARK: Storage + + let storageSize: Int + + /// The maximum number of bytes which can be requested + let transferBlockSize: Int var storageFillRatio: Double { Double(numberOfRecordedBytes) / Double(storageSize) @@ -44,7 +48,7 @@ struct DeviceInfo { extension DeviceInfo { - static var size = 38 + static var size = 42 init?(info: Data) { guard info.count == DeviceInfo.size else { @@ -60,10 +64,18 @@ extension DeviceInfo { self.numberOfMeasurements = .init(high: data[7], low: data[6]) self.transferBlockSize = .init(high: data[9], low: data[8]) let secondsSincePowerOn = Int(uint32: data[13], data[12], data[11], data[10]) - self.deviceStartTime = Date().addingTimeInterval(Double(-secondsSincePowerOn)) + self.numberOfSecondsRunning = secondsSincePowerOn self.sensor0 = .init(address: Array(data[16..<24]), valueByte: data[14], secondsAgo: UInt16(high: data[33], low: data[32])) self.sensor1 = .init(address: Array(data[24..<32]), valueByte: data[15], secondsAgo: UInt16(high: data[35], low: data[34])) self.storageSize = .init(high: data[37], low: data[36]) + let deviceStartTimeSeconds = Int(uint32: data[41], data[40], data[39], data[38]) + if deviceStartTimeSeconds != 0 { + self.hasDeviceStartTimeSet = true + self.deviceStartTime = Date(seconds: deviceStartTimeSeconds) + } else { + self.hasDeviceStartTimeSet = false + self.deviceStartTime = Date(seconds: Date().seconds - secondsSincePowerOn) // Round to nearest second + } } } @@ -93,11 +105,13 @@ extension DeviceInfo { numberOfRecordedBytes: 123, numberOfMeasurements: 234, measurementInterval: 60, - transferBlockSize: 180, - deviceStartTime: .now.addingTimeInterval(-1000), nextMeasurement: .now.addingTimeInterval(5), sensor0: .init(address: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08], value: .value(21.0), date: .now.addingTimeInterval(-2)), sensor1: .init(address: [0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09], value: .value(19.0), date: .now.addingTimeInterval(-4)), - storageSize: 10000) + numberOfSecondsRunning: 20, + deviceStartTime: .now.addingTimeInterval(-1000), + hasDeviceStartTimeSet: false, + storageSize: 10000, + transferBlockSize: 180) } } diff --git a/TempTrack/Extensions/Date+Extensions.swift b/TempTrack/Extensions/Date+Extensions.swift new file mode 100644 index 0000000..5273fab --- /dev/null +++ b/TempTrack/Extensions/Date+Extensions.swift @@ -0,0 +1,65 @@ +import Foundation + +extension Date { + + var seconds: Int { + timeIntervalSince1970.roundedInt + } + + var secondsToNow: Int { + -timeIntervalSinceNow.roundedInt + } + + init(seconds: Int) { + self.init(timeIntervalSince1970: TimeInterval(seconds)) + } + + var timePassedText: String { + let secs = secondsToNow + guard secs > 1 else { + return "Now" + } + guard secs >= 60 else { + return "\(secs) seconds ago" + } + let minutes = secs / 60 + guard minutes > 1 else { + return "1 minute ago" + } + guard minutes >= 60 else { + return "\(minutes) minutes ago" + } + let hours = minutes / 60 + guard hours > 1 else { + return "1 hour ago" + } + guard hours >= 60 else { + return "\(hours) hours ago" + } + let days = hours / 24 + guard days > 1 else { + return "1 day ago" + } + return "\(days) days ago" + } + + var dateIndex: Int { + let components = Calendar.current.dateComponents([.day, .month, .year], from: self) + return components.year! * 10000 + components.month! * 100 + components.day! + } + + init(dateIndex: Int) { + let year = dateIndex / 10000 + let month = (dateIndex % 10000) / 100 + let day = dateIndex % 100 + self = Calendar.current.date(from: .init(year: year, month: month, day: day))! + } + + var startOfDay: Date { + Calendar.current.startOfDay(for: self) + } + + var startOfNextDay: Date { + startOfDay.addingTimeInterval(86400) + } +} diff --git a/TempTrack/Extensions/Double+Extensions.swift b/TempTrack/Extensions/Double+Extensions.swift new file mode 100644 index 0000000..1978a19 --- /dev/null +++ b/TempTrack/Extensions/Double+Extensions.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Double { + + var roundedInt: Int { + Int(rounded()) + } +} diff --git a/TempTrack/Storage/MeasurementDailyCount.swift b/TempTrack/Storage/MeasurementDailyCount.swift new file mode 100644 index 0000000..27d44fd --- /dev/null +++ b/TempTrack/Storage/MeasurementDailyCount.swift @@ -0,0 +1,45 @@ +import Foundation + +struct MeasurementDailyCount { + + var dateIndex: Int + + var count: Int + + var date: Date { + .init(dateIndex: dateIndex) + } +} + +extension MeasurementDailyCount: Codable { + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + self.dateIndex = try container.decode(Int.self) + self.count = try container.decode(Int.self) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(dateIndex) + try container.encode(count) + } +} + +extension MeasurementDailyCount: Comparable { + + static func < (lhs: MeasurementDailyCount, rhs: MeasurementDailyCount) -> Bool { + lhs.dateIndex < rhs.dateIndex + } + + static func == (lhs: MeasurementDailyCount, rhs: MeasurementDailyCount) -> Bool { + lhs.dateIndex == rhs.dateIndex + } +} + +extension MeasurementDailyCount: Identifiable { + + var id: Int { + dateIndex + } +} diff --git a/TempTrack/Storage/TemperatureStorage.swift b/TempTrack/Storage/TemperatureStorage.swift new file mode 100644 index 0000000..fb01e1b --- /dev/null +++ b/TempTrack/Storage/TemperatureStorage.swift @@ -0,0 +1,275 @@ +import Foundation +import Combine +import BinaryCodable +import SwiftUI + +final class TemperatureStorage: ObservableObject { + + static var documentDirectory: URL { + try! FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, create: true) + } + + @Published + var recentMeasurements: [TemperatureMeasurement] + + @Published + var dailyMeasurementCounts: [MeasurementDailyCount] = [] + + private var unsavedMeasurements: [TemperatureMeasurement] = [] + + private let fileNameFormatter: DateFormatter + + private let storageFolder: URL + + private let overviewFileUrl: URL + + private let fm: FileManager + + private let lastValueInterval: TimeInterval + + init(lastMeasurements: [TemperatureMeasurement] = [], lastValueInterval: TimeInterval = 3600) { + self.recentMeasurements = lastMeasurements + let documentDirectory = TemperatureStorage.documentDirectory + self.storageFolder = documentDirectory.appendingPathComponent("measurements") + self.overviewFileUrl = documentDirectory.appendingPathComponent("overview.bin") + self.fm = .default + self.fileNameFormatter = DateFormatter() + self.fileNameFormatter.dateFormat = "yyyyMMdd.bin" + self.lastValueInterval = lastValueInterval + + if lastMeasurements.isEmpty { + loadLastMeasurements() + } else { + setDailyCounts(from: lastMeasurements) + } + + ensureExistenceOfFolder() + } + + private func ensureExistenceOfFolder() { + guard !fm.fileExists(atPath: storageFolder.path) else { + return + } + do { + try fm.createDirectory(at: storageFolder, withIntermediateDirectories: true) + } catch { + print("Failed to create folder: \(error)") + } + } + + private func fileName(for date: Date) -> String { + fileNameFormatter.string(from: date) + } + + private func fileName(for index: Int) -> String { + String(format: "%08d.bin", index) + } + + private func fileUrl(for fileName: String) -> URL { + storageFolder.appendingPathComponent(fileName) + } + + private func loadLastMeasurements() { + let startDate = Date().addingTimeInterval(-lastValueInterval) + let todayIndex = Date().dateIndex + let todayValues = loadMeasurements(for: todayIndex) + .filter { $0.date >= startDate } + let dateIndexOfStart = startDate.dateIndex + guard todayIndex != dateIndexOfStart else { + recentMeasurements = todayValues + return + } + let yesterdayValues = loadMeasurements(for: dateIndexOfStart) + .filter { $0.date >= startDate } + recentMeasurements = yesterdayValues + todayValues + } + + private func loadMeasurements(for date: Date) -> [TemperatureMeasurement] { + loadMeasurements(from: fileName(for: date)) + } + + func loadMeasurements(for dateIndex: Int) -> [TemperatureMeasurement] { + loadMeasurements(from: fileName(for: dateIndex)) + } + + private func loadMeasurements(from fileName: String) -> [TemperatureMeasurement] { + let fileUrl = fileUrl(for: fileName) + guard fm.fileExists(atPath: fileUrl.path) else { + print("No measurements for \(fileName)") + return [] + } + do { + let content = try Data(contentsOf: fileUrl) + let points: [TemperatureMeasurement] = try BinaryDecoder.decode(from: content) + print("Loaded \(points.count) points for \(fileName)") + return points + } catch { + print("Failed to read file \(fileName): \(error)") + return [] + } + } + + func save() { + for (dateIndex, values) in unsavedMeasurements.splitByDate() { + let count = saveNew(values, for: dateIndex) + print("Day \(dateIndex): \(count) of \(values.count) saved") + } + unsavedMeasurements = [] + saveDailyCounts() + } + + /** + - Returns: The number of new points + */ + private func saveNew(_ measurements: [TemperatureMeasurement], for dateIndex: Int) -> Int { + let fileName = fileName(for: dateIndex) + var existing = loadMeasurements(from: fileName) + guard !existing.isEmpty else { + save(measurements, for: fileName) + setDailyCount(measurements.count, for: dateIndex) + return measurements.count + } + var inserted = 0 + for value in measurements { + if existing.insert(value) { + inserted += 1 + } + } + save(existing, for: fileName) + setDailyCount(existing.count, for: dateIndex) + return inserted + } + + private func save(_ measurements: [TemperatureMeasurement], for fileName: String) { + let fileUrl = fileUrl(for: fileName) + do { + let data = try BinaryEncoder.encode(measurements.sorted()) + try data.write(to: fileUrl) + } catch { + print("Failed to save \(fileName): \(error)") + } + } + + // MARK: Daily counts + + private func setDailyCount(_ count: Int, for dateIndex: Int) { + guard let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex == dateIndex }) else { + add(dailyCount: count, for: dateIndex) + return + } + dailyMeasurementCounts[index].count = count + } + + private func add(dailyCount count: Int, for dateIndex: Int) { + let entry = MeasurementDailyCount(dateIndex: dateIndex, count: count) + guard let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex < dateIndex }) else { + dailyMeasurementCounts.append(entry) + return + } + dailyMeasurementCounts.insert(entry, at: index) + } + + private func incrementCount(for dateIndex: Int, by increment: Int = 1) { + guard let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex == dateIndex }) else { + add(dailyCount: increment, for: dateIndex) + return + } + dailyMeasurementCounts[index].count += increment + } + + private func loadDailyCounts() { + do { + let data = try Data(contentsOf: overviewFileUrl) + dailyMeasurementCounts = try BinaryDecoder.decode(from: data) + } catch { + print("Failed to load overview: \(error)") + } + } + + private func saveDailyCounts() { + do { + let data = try BinaryEncoder.encode(dailyMeasurementCounts) + try data.write(to: overviewFileUrl) + } catch { + print("Failed to write overview: \(error)") + } + } + + private func setDailyCounts(from measurements: [TemperatureMeasurement]) { + self.dailyMeasurementCounts = measurements.reduce(into: [Int: Int]()) { counts, value in + let index = value.date.dateIndex + counts[index] = (counts[index] ?? 0) + 1 + }.map { MeasurementDailyCount(dateIndex: $0.key, count: $0.value) } + .sorted() + } + + func recalculateDailyCounts() { + do { + let newValues: [Int: Int] = try fm.contentsOfDirectory(atPath: storageFolder.path) + .reduce(into: [:]) { counts, fileName in + guard let dateIndex = Int(fileName) else { + return + } + counts[dateIndex] = loadMeasurements(from: fileName).count + } + DispatchQueue.main.async { + self.dailyMeasurementCounts = newValues + .map { .init(dateIndex: $0.key, count: $0.value) } + .sorted() + } + } catch { + print("Failed to load daily counts: \(error)") + } + } + +} + +extension TemperatureStorage: TemperatureDataTransferDelegate { + + func didReceiveRecording(_ measurement: TemperatureMeasurement) { + // Add to unsaved measurements + if unsavedMeasurements.insert(measurement) { + incrementCount(for: measurement.date.dateIndex) + } + + // Add to last measurements + recentMeasurements.insert(measurement) + } + + func saveAfterTransfer() { + save() + } +} + +private extension Array where Element == TemperatureMeasurement { + + @discardableResult + mutating func insert(_ measurement: TemperatureMeasurement) -> Bool { + guard !contains(measurement) else { + return false + } + guard let index = self.firstIndex(where: { $0.date > measurement.date }) else { + append(measurement) + return true + } + insert(measurement, at: index) + return true + } + + func splitByDate() -> [Int : [TemperatureMeasurement]] { + reduce(into: [:]) { result, value in + let dateIndex = value.date.dateIndex + result[dateIndex] = (result[dateIndex] ?? []) + [value] + } + } +} + +extension TemperatureStorage { + + static var mock: TemperatureStorage { + .init(lastMeasurements: TemperatureMeasurement.mockData) + } +} diff --git a/TempTrack/TempTrackApp.swift b/TempTrack/TempTrackApp.swift index 7c6ef9c..c598434 100644 --- a/TempTrack/TempTrackApp.swift +++ b/TempTrack/TempTrackApp.swift @@ -1,10 +1,19 @@ import SwiftUI +let storage = TemperatureStorage() +let bluetoothClient = BluetoothClient() + @main struct TempTrackApp: App { + var body: some Scene { WindowGroup { ContentView() + .environmentObject(storage) + .environmentObject(bluetoothClient) + .onAppear { + bluetoothClient.delegate = storage + } } } } diff --git a/TempTrack/Temperature/TemperatureDataTransfer.swift b/TempTrack/Temperature/TemperatureDataTransfer.swift index bd999dc..491b7ad 100644 --- a/TempTrack/Temperature/TemperatureDataTransfer.swift +++ b/TempTrack/Temperature/TemperatureDataTransfer.swift @@ -78,6 +78,7 @@ final class TemperatureDataTransfer { func completeTransfer() { processBytes() + delegate?.saveAfterTransfer() } private func addRelative(byte: UInt8) { @@ -98,7 +99,7 @@ final class TemperatureDataTransfer { if measurement.sensor1.isValid { lastRecording.sensor1 = measurement.sensor1 } - lastRecording.date = measurement.date + lastRecording.id = measurement.id delegate?.didReceiveRecording(measurement) } diff --git a/TempTrack/Temperature/TemperatureDataTransferDelegate.swift b/TempTrack/Temperature/TemperatureDataTransferDelegate.swift index 648ede8..22165ad 100644 --- a/TempTrack/Temperature/TemperatureDataTransferDelegate.swift +++ b/TempTrack/Temperature/TemperatureDataTransferDelegate.swift @@ -3,4 +3,6 @@ import Foundation protocol TemperatureDataTransferDelegate: AnyObject { func didReceiveRecording(_ measurement: TemperatureMeasurement) + + func saveAfterTransfer() } diff --git a/TempTrack/Temperature/TemperatureMeasurement.swift b/TempTrack/Temperature/TemperatureMeasurement.swift index 5764b21..2ec490f 100644 --- a/TempTrack/Temperature/TemperatureMeasurement.swift +++ b/TempTrack/Temperature/TemperatureMeasurement.swift @@ -6,14 +6,87 @@ struct TemperatureMeasurement: Identifiable { var sensor1: TemperatureValue - var date: Date + var id: Int - var id: Int { - Int(date.timeIntervalSince1970.rounded()) + var date: Date { + get { + Date(seconds: id) + } + set { + id = newValue.seconds + } } - var secondsAgo: Int { - Int(date.timeIntervalSinceNow.rounded()) + var secondsToNow: Int { + Date().seconds - id + } + + var maximumValue: Double? { + guard let s0 = sensor0.optionalValue else { + return sensor1.optionalValue + } + guard let s1 = sensor1.optionalValue else { + return nil + } + return max(s0, s1) + } + + var minimumValue: Double? { + guard let s0 = sensor0.optionalValue else { + return sensor1.optionalValue + } + guard let s1 = sensor1.optionalValue else { + return nil + } + return min(s0, s1) + } +} + +extension TemperatureMeasurement { + + init(sensor0: TemperatureValue, sensor1: TemperatureValue, date: Date) { + self.sensor0 = sensor0 + self.sensor1 = sensor1 + self.id = date.seconds + } +} + +extension TemperatureMeasurement: Codable { + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + self.sensor0 = .init(byte: try container.decode(UInt8.self)) + self.sensor1 = .init(byte: try container.decode(UInt8.self)) + self.id = try container.decode(Int.self) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(sensor0.byte) + try container.encode(sensor1.byte) + try container.encode(id) + } +} + +extension TemperatureMeasurement: Comparable { + + static func < (lhs: TemperatureMeasurement, rhs: TemperatureMeasurement) -> Bool { + lhs.id < rhs.id + } + + static func == (lhs: TemperatureMeasurement, rhs: TemperatureMeasurement) -> Bool { + lhs.id == rhs.id + } +} + +extension Array where Element == TemperatureMeasurement { + + func maximumValue() -> Double? { + compactMap { $0.maximumValue }.max() + } + + func minimumValue() -> Double? { + compactMap { $0.minimumValue }.min() } } @@ -28,19 +101,6 @@ private extension TemperatureValue { } } -private extension TemperatureMeasurement { - - init(t0: Double?, t1: Double?, secs: Int) { - self.sensor0 = .init(value: t0) - self.sensor1 = .init(value: t1) - self.date = Date().addingTimeInterval(TimeInterval(secs-3600)) - } - - init(t0: Double?, t1: Double?, min: Int) { - self.init(t0: t0, t1: t1, secs: min * 60) - } -} - extension TemperatureMeasurement { static let mockData: [TemperatureMeasurement] = { @@ -106,8 +166,13 @@ extension TemperatureMeasurement { (15.5, 25.0), (15.0, 25.0), ] + + let seconds = Date().seconds return temps.enumerated().map { - TemperatureMeasurement(t0: $0.element.0, t1: $0.element.1, min: $0.offset) + TemperatureMeasurement( + sensor0: .init(value: $0.element.0), + sensor1: .init(value: $0.element.1), + id: seconds + $0.offset * 60) } }() } diff --git a/TempTrack/Temperature/TemperatureSensor.swift b/TempTrack/Temperature/TemperatureSensor.swift index a21bfa2..611f4eb 100644 --- a/TempTrack/Temperature/TemperatureSensor.swift +++ b/TempTrack/Temperature/TemperatureSensor.swift @@ -60,35 +60,3 @@ extension TemperatureSensor { self.date = Date().addingTimeInterval(-TimeInterval(secondsAgo)) } } - -extension Date { - - var timePassedText: String { - let secs = Int(-timeIntervalSinceNow.rounded()) - guard secs > 1 else { - return "Now" - } - guard secs >= 60 else { - return "\(secs) seconds ago" - } - let minutes = secs / 60 - guard minutes > 1 else { - return "1 minute ago" - } - guard minutes >= 60 else { - return "\(minutes) minutes ago" - } - let hours = minutes / 60 - guard hours > 1 else { - return "1 hour ago" - } - guard hours >= 60 else { - return "\(hours) hours ago" - } - let days = hours / 24 - guard days > 1 else { - return "1 day ago" - } - return "\(days) days ago" - } -} diff --git a/TempTrack/Temperature/TemperatureValue.swift b/TempTrack/Temperature/TemperatureValue.swift index 75352ab..6aade7f 100644 --- a/TempTrack/Temperature/TemperatureValue.swift +++ b/TempTrack/Temperature/TemperatureValue.swift @@ -16,6 +16,18 @@ enum TemperatureValue { } } + var byte: UInt8 { + switch self { + case .notFound: + return 0 + case .invalidMeasurement: + return 1 + case .value(let double): + let value = Int(double + 40) * 2 + return UInt8(clamping: value) + } + } + var optionalValue: Double? { if case .value(let val) = self { return val @@ -41,3 +53,18 @@ enum TemperatureValue { } } } + +extension TemperatureValue: Codable { + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let byte = try container.decode(UInt8.self) + self.init(byte: byte) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(byte) + } + +} diff --git a/TempTrack/TemperatureStorage.swift b/TempTrack/TemperatureStorage.swift deleted file mode 100644 index 130e503..0000000 --- a/TempTrack/TemperatureStorage.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation -import Combine -import SQLite - -final class TemperatureStorage: ObservableObject { - - static var documentDirectory: URL { - try! FileManager.default.url( - for: .documentDirectory, - in: .userDomainMask, - appropriateFor: nil, create: true) - } - - private let databaseUrl: URL - - @Published - var lastMeasurements: [TemperatureMeasurement] - - init(lastMeasurements: [TemperatureMeasurement] = []) { - self.lastMeasurements = lastMeasurements - self.databaseUrl = TemperatureStorage.documentDirectory.appendingPathComponent("db.sqlite3") - } - - private let table = Table("values") - private let i - - private func createDatabaseIfNeeded() throws { - let db = try Connection(databaseUrl.path) - - let users = Table("users") - let id = Expression("id") - let name = Expression("name") - let email = Expression("email") - - try db.run(users.create(ifNotExists: true) { t in - t.column(id, primaryKey: true) - t.column(name) - t.column(email, unique: true) - }) - // CREATE TABLE "users" ( - // "id" INTEGER PRIMARY KEY NOT NULL, - // "name" TEXT, - // "email" TEXT NOT NULL UNIQUE - // ) - - let insert = users.insert(name <- "Alice", email <- "alice@mac.com") - let rowid = try db.run(insert) - // INSERT INTO "users" ("name", "email") VALUES ('Alice', 'alice@mac.com') - - for user in try db.prepare(users) { - print("id: \(user[id]), name: \(user[name]), email: \(user[email])") - // id: 1, name: Optional("Alice"), email: alice@mac.com - } - // SELECT * FROM "users" - - let alice = users.filter(id == rowid) - - try db.run(alice.update(email <- email.replace("mac.com", with: "me.com"))) - // UPDATE "users" SET "email" = replace("email", 'mac.com', 'me.com') - // WHERE ("id" = 1) - - try db.run(alice.delete()) - // DELETE FROM "users" WHERE ("id" = 1) - - try db.scalar(users.count) // 0 - // SELECT count(*) FROM "users" - } - -} - -extension TemperatureStorage: TemperatureDataTransferDelegate { - - func didReceiveRecording(_ measurement: TemperatureMeasurement) { - //print("Temperature \(measurement.date): \(temp1), \(temp2)") - } -} diff --git a/TempTrack/Views/DeviceInfoView.swift b/TempTrack/Views/DeviceInfoView.swift index 26bff3d..4b852ec 100644 --- a/TempTrack/Views/DeviceInfoView.swift +++ b/TempTrack/Views/DeviceInfoView.swift @@ -17,9 +17,6 @@ struct DeviceInfoView: View { @Binding var isPresented: Bool - - @Binding - var updateToggle: Bool private var runTimeString: String { let number = info.numberOfSecondsRunning @@ -101,6 +98,10 @@ struct DeviceInfoView: View { } } + var updateText: String { + return "Updated \(info.receivedDate.timePassedText)" + } + var body: some View { VStack(alignment: .leading, spacing: 5) { HStack { @@ -164,9 +165,11 @@ struct DeviceInfoView: View { Spacer() HStack { Spacer() - Text("Updated \(info.receivedDate.timePassedText)") - .font(.footnote) - .textCase(.uppercase) + TimelineView(.periodic(from: Date(), by: 1)) { context in + Text(updateText) + .font(.footnote) + .textCase(.uppercase) + } Spacer() } }.padding() @@ -175,11 +178,8 @@ struct DeviceInfoView: View { struct DeviceInfoView_Previews: PreviewProvider { static var previews: some View { - DeviceInfoView( - info: .mock, - isPresented: .constant(true), - updateToggle: .constant(true)) - .previewLayout(.fixed(width: 375, height: 600)) + DeviceInfoView(info: .mock, isPresented: .constant(true)) + .previewLayout(.fixed(width: 375, height: 600)) } } diff --git a/TempTrack/Views/HistoryList.swift b/TempTrack/Views/HistoryList.swift new file mode 100644 index 0000000..44b3ff0 --- /dev/null +++ b/TempTrack/Views/HistoryList.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct HistoryList: View { + + @EnvironmentObject + var storage: TemperatureStorage + + var body: some View { + NavigationView { + List(storage.dailyMeasurementCounts) { day in + NavigationLink(destination: { + TemperatureDayOverview(storage: storage, dateIndex: day.dateIndex) + }) { + HistoryListRow(entry: day) + } + } + .navigationTitle("History") + .navigationBarTitleDisplayMode(.large) + } + } +} + +struct HistoryList_Previews: PreviewProvider { + static var previews: some View { + HistoryList() + .environmentObject(TemperatureStorage(lastMeasurements: TemperatureMeasurement.mockData)) + } +} diff --git a/TempTrack/Views/HistoryListRow.swift b/TempTrack/Views/HistoryListRow.swift new file mode 100644 index 0000000..e077a99 --- /dev/null +++ b/TempTrack/Views/HistoryListRow.swift @@ -0,0 +1,29 @@ +import SwiftUI +import SFSafeSymbols + +private let df: DateFormatter = { + let df = DateFormatter() + df.dateStyle = .short + df.timeStyle = .none + return df +}() + +struct HistoryListRow: View { + + let entry: MeasurementDailyCount + + var body: some View { + HStack { + Image(systemSymbol: .calendar) + Text(df.string(from: entry.date)) + Spacer() + Text("\(entry.count)") + } + } +} + +struct HistoryListRow_Previews: PreviewProvider { + static var previews: some View { + HistoryListRow(entry: .init(dateIndex: Date().dateIndex, count: 123)) + } +} diff --git a/TempTrack/Views/TemperatureDayOverview.swift b/TempTrack/Views/TemperatureDayOverview.swift new file mode 100644 index 0000000..d212a8b --- /dev/null +++ b/TempTrack/Views/TemperatureDayOverview.swift @@ -0,0 +1,85 @@ +import SwiftUI +import Charts + +struct TemperatureDayOverview: View { + + let storage: TemperatureStorage + + @State + var points: [TemperatureMeasurement] = [] + + init(storage: TemperatureStorage, dateIndex: Int) { + self.storage = storage + let points = storage.loadMeasurements(for: dateIndex) + print("Loaded \(points.count) points for date \(dateIndex)") + self.points = points + update() + } + + mutating func update() { + self.upperTempLimit = max(40, points.maximumValue() ?? 40) + self.lowerTempLimit = min(-20, points.minimumValue() ?? -20) + let startDay = points.first?.date.dateIndex ?? Date().dateIndex + self.pastDateLimit = Date(dateIndex: startDay) + let endDay = (points.last?.date.dateIndex ?? Date().dateIndex) + 1 + self.futureDateLimit = Date(dateIndex: endDay) + } + + var upperTempLimit: Double = 40.0 + var lowerTempLimit: Double = -20 + + var pastDateLimit: Date = Date().startOfDay + var futureDateLimit: Date = Date().startOfNextDay + + var body: some View { + Chart { + ForEach(points) { point in + if let s = point.sensor0.optionalValue { + LineMark( + x: .value("Date", point.date), + y: .value("Temperature", s)) + .foregroundStyle(by: .value("Type", "Sensor 0")) + } + if let s = point.sensor1.optionalValue { + LineMark( + x: .value("Date", point.date), + y: .value("Temperature", s)) + .foregroundStyle(by: .value("Type", "Sensor 1")) + } + } + } + .aspectRatio(2.6, contentMode: .fit) + //.chartXScale(domain: pastDateLimit...futureDateLimit) + .chartYScale(domain: lowerTempLimit...upperTempLimit) + .chartXAxis { + AxisMarks(preset: .automatic) + AxisMarks.init(values: AxisMarkValues.stride(by: .hour)) { + AxisGridLine() + } + } + .chartYAxis { + AxisMarks(position: .trailing, values: .automatic) { value in + AxisValueLabel(multiLabelAlignment: .trailing) { + if let intValue = value.as(Int.self) { + Text("\(intValue)°") + .font(.system(size: 10)) + //.foregroundColor(.white) + } + } + } + + AxisMarks.init(values: AxisMarkValues.stride(by: 5)) { + AxisGridLine() + } + } + .padding() + } +} + +struct TemperatureDayOverview_Previews: PreviewProvider { + static var previews: some View { + TemperatureDayOverview(storage: TemperatureStorage.mock, dateIndex: Date().dateIndex) + .previewLayout(.fixed(width: 350, height: 150)) + //.background(.gray) + } +} diff --git a/TempTrack/Views/TemperatureHistoryChart.swift b/TempTrack/Views/TemperatureHistoryChart.swift index 2149160..d9e0108 100644 --- a/TempTrack/Views/TemperatureHistoryChart.swift +++ b/TempTrack/Views/TemperatureHistoryChart.swift @@ -3,7 +3,8 @@ import Charts struct TemperatureHistoryChart: View { - let points: [TemperatureMeasurement] + @Binding + var points: [TemperatureMeasurement] let upperTempLimit = 40.0 let lowerTempLimit = -20.0 @@ -16,13 +17,13 @@ struct TemperatureHistoryChart: View { ForEach(points) { point in if let s = point.sensor0.optionalValue { LineMark( - x: .value("Date", point.secondsAgo), + x: .value("Date", point.secondsToNow), y: .value("Temperature", s)) .foregroundStyle(Color.red) } if let s = point.sensor1.optionalValue { LineMark( - x: .value("Date", point.secondsAgo), + x: .value("Date", point.secondsToNow), y: .value("Temperature", s)) .foregroundStyle(by: .value("Type", "Sensor 1")) } @@ -36,13 +37,12 @@ struct TemperatureHistoryChart: View { AxisMarks(position: .trailing, values: .automatic) { value in AxisValueLabel(multiLabelAlignment: .trailing) { if let intValue = value.as(Int.self) { - Text("\(intValue) km") + Text("\(intValue)°") .font(.system(size: 10)) .foregroundColor(.white) } } } - //AxisMarks(position: .trailing, stroke: StrokeStyle(lineWidth: 0)) } .padding() } @@ -51,7 +51,7 @@ struct TemperatureHistoryChart: View { struct TemperatureHistoryChart_Previews: PreviewProvider { static var previews: some View { TemperatureHistoryChart( - points: TemperatureMeasurement.mockData) + points: .constant(TemperatureMeasurement.mockData)) .previewLayout(.fixed(width: 350, height: 150)) .background(.gray) }