Add basic storage, temperature history display
This commit is contained in:
parent
002eb11dc1
commit
147cd6a306
@ -7,6 +7,13 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
88CDE04F2A2508E900114294 /* TempTrackApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE04E2A2508E900114294 /* TempTrackApp.swift */; };
|
||||||
88CDE0512A2508E900114294 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0502A2508E900114294 /* ContentView.swift */; };
|
88CDE0512A2508E900114294 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0502A2508E900114294 /* ContentView.swift */; };
|
||||||
88CDE0532A2508EA00114294 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88CDE0522A2508EA00114294 /* Assets.xcassets */; };
|
88CDE0532A2508EA00114294 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88CDE0522A2508EA00114294 /* Assets.xcassets */; };
|
||||||
@ -32,6 +39,12 @@
|
|||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
88404DD12A2F0D8F00D30244 /* Double+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
|
88404DD32A2F0DB100D30244 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
|
88404DD72A2F381B00D30244 /* HistoryList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryList.swift; sourceTree = "<group>"; };
|
||||||
|
88404DDA2A2F4DCA00D30244 /* MeasurementDailyCount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementDailyCount.swift; sourceTree = "<group>"; };
|
||||||
|
88404DDC2A2F587400D30244 /* HistoryListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryListRow.swift; sourceTree = "<group>"; };
|
||||||
|
88404DDE2A2F68E100D30244 /* TemperatureDayOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureDayOverview.swift; sourceTree = "<group>"; };
|
||||||
88CDE04B2A2508E900114294 /* TempTrack.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TempTrack.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
88CDE04E2A2508E900114294 /* TempTrackApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTrackApp.swift; sourceTree = "<group>"; };
|
||||||
88CDE0502A2508E900114294 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
88CDE0502A2508E900114294 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
@ -59,6 +72,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
88404DD02A2E718B00D30244 /* BinaryCodable in Frameworks */,
|
||||||
E253A9272A2CA48A00EC6B28 /* SQLite in Frameworks */,
|
E253A9272A2CA48A00EC6B28 /* SQLite in Frameworks */,
|
||||||
88CDE0662A25D08F00114294 /* SFSafeSymbols in Frameworks */,
|
88CDE0662A25D08F00114294 /* SFSafeSymbols in Frameworks */,
|
||||||
88CDE06B2A2899C900114294 /* BottomSheet in Frameworks */,
|
88CDE06B2A2899C900114294 /* BottomSheet in Frameworks */,
|
||||||
@ -68,6 +82,15 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
88404DD92A2F4DB100D30244 /* Storage */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
88404DDA2A2F4DCA00D30244 /* MeasurementDailyCount.swift */,
|
||||||
|
88CDE0672A2698B400114294 /* TemperatureStorage.swift */,
|
||||||
|
);
|
||||||
|
path = Storage;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
88CDE0422A2508E800114294 = {
|
88CDE0422A2508E800114294 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@ -92,10 +115,10 @@
|
|||||||
E253A9202A2B39A700EC6B28 /* Extensions */,
|
E253A9202A2B39A700EC6B28 /* Extensions */,
|
||||||
88CDE07C2A28AFE700114294 /* Views */,
|
88CDE07C2A28AFE700114294 /* Views */,
|
||||||
88CDE0792A28AF3E00114294 /* Bluetooth */,
|
88CDE0792A28AF3E00114294 /* Bluetooth */,
|
||||||
|
88404DD92A2F4DB100D30244 /* Storage */,
|
||||||
88CDE06E2A28AE8D00114294 /* Temperature */,
|
88CDE06E2A28AE8D00114294 /* Temperature */,
|
||||||
88CDE0522A2508EA00114294 /* Assets.xcassets */,
|
88CDE0522A2508EA00114294 /* Assets.xcassets */,
|
||||||
88CDE0542A2508EA00114294 /* Preview Content */,
|
88CDE0542A2508EA00114294 /* Preview Content */,
|
||||||
88CDE0672A2698B400114294 /* TemperatureStorage.swift */,
|
|
||||||
88CDE06C2A28A92000114294 /* DeviceInfo.swift */,
|
88CDE06C2A28A92000114294 /* DeviceInfo.swift */,
|
||||||
);
|
);
|
||||||
path = TempTrack;
|
path = TempTrack;
|
||||||
@ -138,6 +161,9 @@
|
|||||||
children = (
|
children = (
|
||||||
88CDE07D2A28AFF400114294 /* DeviceInfoView.swift */,
|
88CDE07D2A28AFF400114294 /* DeviceInfoView.swift */,
|
||||||
E253A9232A2B462500EC6B28 /* TemperatureHistoryChart.swift */,
|
E253A9232A2B462500EC6B28 /* TemperatureHistoryChart.swift */,
|
||||||
|
88404DD72A2F381B00D30244 /* HistoryList.swift */,
|
||||||
|
88404DDC2A2F587400D30244 /* HistoryListRow.swift */,
|
||||||
|
88404DDE2A2F68E100D30244 /* TemperatureDayOverview.swift */,
|
||||||
);
|
);
|
||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -146,6 +172,8 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
E253A9212A2B39B700EC6B28 /* Color+Extensions.swift */,
|
E253A9212A2B39B700EC6B28 /* Color+Extensions.swift */,
|
||||||
|
88404DD12A2F0D8F00D30244 /* Double+Extensions.swift */,
|
||||||
|
88404DD32A2F0DB100D30244 /* Date+Extensions.swift */,
|
||||||
);
|
);
|
||||||
path = Extensions;
|
path = Extensions;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -170,6 +198,7 @@
|
|||||||
88CDE0652A25D08F00114294 /* SFSafeSymbols */,
|
88CDE0652A25D08F00114294 /* SFSafeSymbols */,
|
||||||
88CDE06A2A2899C900114294 /* BottomSheet */,
|
88CDE06A2A2899C900114294 /* BottomSheet */,
|
||||||
E253A9262A2CA48A00EC6B28 /* SQLite */,
|
E253A9262A2CA48A00EC6B28 /* SQLite */,
|
||||||
|
88404DCF2A2E718B00D30244 /* BinaryCodable */,
|
||||||
);
|
);
|
||||||
productName = TempTrack;
|
productName = TempTrack;
|
||||||
productReference = 88CDE04B2A2508E900114294 /* TempTrack.app */;
|
productReference = 88CDE04B2A2508E900114294 /* TempTrack.app */;
|
||||||
@ -203,6 +232,7 @@
|
|||||||
88CDE0642A25D08F00114294 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */,
|
88CDE0642A25D08F00114294 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */,
|
||||||
88CDE0692A2899C900114294 /* XCRemoteSwiftPackageReference "bottom-sheet" */,
|
88CDE0692A2899C900114294 /* XCRemoteSwiftPackageReference "bottom-sheet" */,
|
||||||
E253A9252A2CA48900EC6B28 /* XCRemoteSwiftPackageReference "SQLite" */,
|
E253A9252A2CA48900EC6B28 /* XCRemoteSwiftPackageReference "SQLite" */,
|
||||||
|
88404DCE2A2E718B00D30244 /* XCRemoteSwiftPackageReference "BinaryCodable" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 88CDE04C2A2508E900114294 /* Products */;
|
productRefGroup = 88CDE04C2A2508E900114294 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@ -230,23 +260,29 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
88404DDB2A2F4DCA00D30244 /* MeasurementDailyCount.swift in Sources */,
|
||||||
88CDE0512A2508E900114294 /* ContentView.swift in Sources */,
|
88CDE0512A2508E900114294 /* ContentView.swift in Sources */,
|
||||||
88CDE05F2A250F5200114294 /* DeviceState.swift in Sources */,
|
88CDE05F2A250F5200114294 /* DeviceState.swift in Sources */,
|
||||||
88CDE0632A253AD900114294 /* TemperatureDataTransfer.swift in Sources */,
|
88CDE0632A253AD900114294 /* TemperatureDataTransfer.swift in Sources */,
|
||||||
88CDE0702A28AEA300114294 /* TemperatureMeasurement.swift in Sources */,
|
88CDE0702A28AEA300114294 /* TemperatureMeasurement.swift in Sources */,
|
||||||
88CDE05D2A250F3C00114294 /* DeviceManager.swift in Sources */,
|
88CDE05D2A250F3C00114294 /* DeviceManager.swift in Sources */,
|
||||||
|
88404DDF2A2F68E100D30244 /* TemperatureDayOverview.swift in Sources */,
|
||||||
88CDE04F2A2508E900114294 /* TempTrackApp.swift in Sources */,
|
88CDE04F2A2508E900114294 /* TempTrackApp.swift in Sources */,
|
||||||
88CDE0722A28AEB900114294 /* TemperatureDataTransferDelegate.swift in Sources */,
|
88CDE0722A28AEB900114294 /* TemperatureDataTransferDelegate.swift in Sources */,
|
||||||
88CDE0782A28AF2C00114294 /* TemperatureSensor.swift in Sources */,
|
88CDE0782A28AF2C00114294 /* TemperatureSensor.swift in Sources */,
|
||||||
88CDE0682A2698B400114294 /* TemperatureStorage.swift in Sources */,
|
88CDE0682A2698B400114294 /* TemperatureStorage.swift in Sources */,
|
||||||
|
88404DD42A2F0DB100D30244 /* Date+Extensions.swift in Sources */,
|
||||||
88CDE0762A28AF0900114294 /* TemperatureValue.swift in Sources */,
|
88CDE0762A28AF0900114294 /* TemperatureValue.swift in Sources */,
|
||||||
88CDE07B2A28AF5100114294 /* BluetoothRequest.swift in Sources */,
|
88CDE07B2A28AF5100114294 /* BluetoothRequest.swift in Sources */,
|
||||||
E253A9242A2B462500EC6B28 /* TemperatureHistoryChart.swift in Sources */,
|
E253A9242A2B462500EC6B28 /* TemperatureHistoryChart.swift in Sources */,
|
||||||
|
88404DD22A2F0D8F00D30244 /* Double+Extensions.swift in Sources */,
|
||||||
E253A9222A2B39B700EC6B28 /* Color+Extensions.swift in Sources */,
|
E253A9222A2B39B700EC6B28 /* Color+Extensions.swift in Sources */,
|
||||||
88CDE0612A25108100114294 /* BluetoothClient.swift in Sources */,
|
88CDE0612A25108100114294 /* BluetoothClient.swift in Sources */,
|
||||||
88CDE0742A28AEE500114294 /* DeviceManagerDelegate.swift in Sources */,
|
88CDE0742A28AEE500114294 /* DeviceManagerDelegate.swift in Sources */,
|
||||||
88CDE06D2A28A92000114294 /* DeviceInfo.swift in Sources */,
|
88CDE06D2A28A92000114294 /* DeviceInfo.swift in Sources */,
|
||||||
|
88404DD82A2F381B00D30244 /* HistoryList.swift in Sources */,
|
||||||
88CDE07E2A28AFF400114294 /* DeviceInfoView.swift in Sources */,
|
88CDE07E2A28AFF400114294 /* DeviceInfoView.swift in Sources */,
|
||||||
|
88404DDD2A2F587400D30244 /* HistoryListRow.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -453,6 +489,14 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference 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" */ = {
|
88CDE0642A25D08F00114294 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols";
|
repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols";
|
||||||
@ -480,6 +524,11 @@
|
|||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
88404DCF2A2E718B00D30244 /* BinaryCodable */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 88404DCE2A2E718B00D30244 /* XCRemoteSwiftPackageReference "BinaryCodable" */;
|
||||||
|
productName = BinaryCodable;
|
||||||
|
};
|
||||||
88CDE0652A25D08F00114294 /* SFSafeSymbols */ = {
|
88CDE0652A25D08F00114294 /* SFSafeSymbols */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 88CDE0642A25D08F00114294 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */;
|
package = 88CDE0642A25D08F00114294 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */;
|
||||||
|
@ -1,5 +1,14 @@
|
|||||||
{
|
{
|
||||||
"pins" : [
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "binarycodable",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/christophhagen/BinaryCodable",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "295ca6399b2b01d1aa4fa84d666416f3bf99ffde",
|
||||||
|
"version" : "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "bottom-sheet",
|
"identity" : "bottom-sheet",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
@ -4,10 +4,31 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>SchemeUserState</key>
|
<key>SchemeUserState</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>SQLite (Playground) 1.xcscheme</key>
|
||||||
|
<dict>
|
||||||
|
<key>isShown</key>
|
||||||
|
<false/>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>2</integer>
|
||||||
|
</dict>
|
||||||
|
<key>SQLite (Playground) 2.xcscheme</key>
|
||||||
|
<dict>
|
||||||
|
<key>isShown</key>
|
||||||
|
<false/>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>3</integer>
|
||||||
|
</dict>
|
||||||
|
<key>SQLite (Playground).xcscheme</key>
|
||||||
|
<dict>
|
||||||
|
<key>isShown</key>
|
||||||
|
<false/>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
<key>TempTrack.xcscheme_^#shared#^_</key>
|
<key>TempTrack.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
@ -18,11 +18,13 @@ enum BluetoothResponseType: UInt8 {
|
|||||||
|
|
||||||
final class BluetoothClient: ObservableObject {
|
final class BluetoothClient: ObservableObject {
|
||||||
|
|
||||||
|
weak var delegate: TemperatureDataTransferDelegate?
|
||||||
|
|
||||||
private let updateInterval = 3.0
|
private let updateInterval = 3.0
|
||||||
|
|
||||||
private let connection = DeviceManager()
|
private let connection = DeviceManager()
|
||||||
|
|
||||||
private let recorder = TemperatureStorage()
|
private var didTransferData = false
|
||||||
|
|
||||||
init(deviceInfo: DeviceInfo? = nil) {
|
init(deviceInfo: DeviceInfo? = nil) {
|
||||||
connection.delegate = self
|
connection.delegate = self
|
||||||
@ -41,12 +43,25 @@ final class BluetoothClient: ObservableObject {
|
|||||||
startRegularUpdates()
|
startRegularUpdates()
|
||||||
} else {
|
} else {
|
||||||
endRegularUpdates()
|
endRegularUpdates()
|
||||||
|
didTransferData = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published
|
@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] = []
|
private var openRequests: [BluetoothRequest] = []
|
||||||
|
|
||||||
@ -55,9 +70,11 @@ final class BluetoothClient: ObservableObject {
|
|||||||
private var runningTransfer: TemperatureDataTransfer?
|
private var runningTransfer: TemperatureDataTransfer?
|
||||||
|
|
||||||
func updateDeviceInfo() {
|
func updateDeviceInfo() {
|
||||||
if case .configured = deviceState {
|
guard case .configured = deviceState else {
|
||||||
addRequest(.getInfo)
|
return
|
||||||
}
|
}
|
||||||
|
addRequest(.getInfo)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dataUpdateTimer: Timer?
|
private var dataUpdateTimer: Timer?
|
||||||
@ -118,7 +135,7 @@ final class BluetoothClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
let transfer = TemperatureDataTransfer(info: info)
|
let transfer = TemperatureDataTransfer(info: info)
|
||||||
runningTransfer = transfer
|
runningTransfer = transfer
|
||||||
runningTransfer?.delegate = recorder
|
runningTransfer?.delegate = delegate
|
||||||
let next = transfer.nextRequest()
|
let next = transfer.nextRequest()
|
||||||
addRequest(next)
|
addRequest(next)
|
||||||
return true
|
return true
|
||||||
@ -137,6 +154,12 @@ final class BluetoothClient: ObservableObject {
|
|||||||
return // TODO: Start new transfer?
|
return // TODO: Start new transfer?
|
||||||
}
|
}
|
||||||
let next = runningTransfer.nextRequest()
|
let next = runningTransfer.nextRequest()
|
||||||
|
if case .clearRecordingBuffer = next {
|
||||||
|
runningTransfer.completeTransfer()
|
||||||
|
self.runningTransfer = nil
|
||||||
|
didTransferData = true
|
||||||
|
return
|
||||||
|
}
|
||||||
addRequest(next)
|
addRequest(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,8 +4,6 @@ import BottomSheet
|
|||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
|
|
||||||
private let updateInterval = 1.0
|
|
||||||
|
|
||||||
private let minTempColor = Color(hue: 0.624, saturation: 0.5, brightness: 1.0)
|
private let minTempColor = Color(hue: 0.624, saturation: 0.5, brightness: 1.0)
|
||||||
private let minTemperature = -20.0
|
private let minTemperature = -20.0
|
||||||
|
|
||||||
@ -14,49 +12,29 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private let disconnectedColor = Color(white: 0.8)
|
private let disconnectedColor = Color(white: 0.8)
|
||||||
|
|
||||||
@ObservedObject
|
@EnvironmentObject
|
||||||
var client = BluetoothClient()
|
var bluetoothClient: BluetoothClient
|
||||||
|
|
||||||
@ObservedObject
|
@EnvironmentObject
|
||||||
var storage = TemperatureStorage()
|
var storage: TemperatureStorage
|
||||||
|
|
||||||
@State
|
@State
|
||||||
var showDeviceInfo = false
|
var showDeviceInfo = false
|
||||||
|
|
||||||
@State
|
@State
|
||||||
var updateTimer: Timer?
|
var showHistory = false
|
||||||
|
|
||||||
@State
|
|
||||||
var updateInfoToggle = true
|
|
||||||
|
|
||||||
init() {
|
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 {
|
var hasDeviceInfo: Bool {
|
||||||
client.deviceInfo != nil
|
bluetoothClient.deviceInfo != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var averageTemperature: Double? {
|
var averageTemperature: Double? {
|
||||||
let t1 = client.deviceInfo?.sensor1?.optionalValue
|
let t1 = bluetoothClient.deviceInfo?.sensor1?.optionalValue
|
||||||
guard let t0 = client.deviceInfo?.sensor0?.optionalValue else {
|
guard let t0 = bluetoothClient.deviceInfo?.sensor0?.optionalValue else {
|
||||||
return t1
|
return t1
|
||||||
}
|
}
|
||||||
guard let t1 else {
|
guard let t1 else {
|
||||||
@ -123,10 +101,15 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
TemperatureHistoryChart(points: storage.lastMeasurements)
|
|
||||||
.frame(height: 150)
|
Button {
|
||||||
.background(Color.white.opacity(0.1))
|
self.showHistory = true
|
||||||
.cornerRadius(8)
|
} label: {
|
||||||
|
TemperatureHistoryChart(points: $storage.recentMeasurements)
|
||||||
|
.frame(height: 150)
|
||||||
|
.background(Color.white.opacity(0.1))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
Button {
|
Button {
|
||||||
self.showDeviceInfo = true
|
self.showDeviceInfo = true
|
||||||
@ -135,7 +118,7 @@ struct ContentView: View {
|
|||||||
Image(systemSymbol: .iphone)
|
Image(systemSymbol: .iphone)
|
||||||
.font(.system(size: 30, weight: .regular))
|
.font(.system(size: 30, weight: .regular))
|
||||||
}
|
}
|
||||||
Text(client.deviceState.text)
|
Text(bluetoothClient.deviceState.text)
|
||||||
}
|
}
|
||||||
.disabled(!hasDeviceInfo)
|
.disabled(!hasDeviceInfo)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
@ -144,23 +127,25 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.bottomSheet(isPresented: $showDeviceInfo, height: 600) {
|
.bottomSheet(isPresented: $showDeviceInfo, height: 600) {
|
||||||
if let info = client.deviceInfo {
|
if let info = bluetoothClient.deviceInfo {
|
||||||
DeviceInfoView(
|
DeviceInfoView(info: info, isPresented: $showDeviceInfo)
|
||||||
info: info,
|
|
||||||
isPresented: $showDeviceInfo, updateToggle: $updateInfoToggle)
|
|
||||||
} else {
|
} else {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showHistory) {
|
||||||
|
HistoryList()
|
||||||
|
.environmentObject(storage)
|
||||||
|
}
|
||||||
.background(backgroundGradient)
|
.background(backgroundGradient)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
struct ContentView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ContentView(
|
ContentView()
|
||||||
client: BluetoothClient(deviceInfo: .mock),
|
.environmentObject(TemperatureStorage(lastMeasurements: TemperatureMeasurement.mockData))
|
||||||
values: TemperatureMeasurement.mockData)
|
.environmentObject(BluetoothClient(deviceInfo: .mock))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,25 +13,29 @@ struct DeviceInfo {
|
|||||||
/// The interval between measurements (in seconds)
|
/// The interval between measurements (in seconds)
|
||||||
let measurementInterval: Int
|
let measurementInterval: Int
|
||||||
|
|
||||||
/// The maximum number of bytes which can be requested
|
|
||||||
let transferBlockSize: Int
|
|
||||||
|
|
||||||
let deviceStartTime: Date
|
|
||||||
|
|
||||||
let nextMeasurement: Date
|
let nextMeasurement: Date
|
||||||
|
|
||||||
let sensor0: TemperatureSensor?
|
let sensor0: TemperatureSensor?
|
||||||
|
|
||||||
let sensor1: TemperatureSensor?
|
let sensor1: TemperatureSensor?
|
||||||
|
|
||||||
let storageSize: Int
|
// MARK: Device time
|
||||||
|
|
||||||
/**
|
/**
|
||||||
The number of seconds the device has been powered on
|
The number of seconds the device has been powered on
|
||||||
*/
|
*/
|
||||||
var numberOfSecondsRunning: Int {
|
let numberOfSecondsRunning: Int
|
||||||
Int(-deviceStartTime.timeIntervalSinceNow)
|
|
||||||
}
|
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 {
|
var storageFillRatio: Double {
|
||||||
Double(numberOfRecordedBytes) / Double(storageSize)
|
Double(numberOfRecordedBytes) / Double(storageSize)
|
||||||
@ -44,7 +48,7 @@ struct DeviceInfo {
|
|||||||
|
|
||||||
extension DeviceInfo {
|
extension DeviceInfo {
|
||||||
|
|
||||||
static var size = 38
|
static var size = 42
|
||||||
|
|
||||||
init?(info: Data) {
|
init?(info: Data) {
|
||||||
guard info.count == DeviceInfo.size else {
|
guard info.count == DeviceInfo.size else {
|
||||||
@ -60,10 +64,18 @@ extension DeviceInfo {
|
|||||||
self.numberOfMeasurements = .init(high: data[7], low: data[6])
|
self.numberOfMeasurements = .init(high: data[7], low: data[6])
|
||||||
self.transferBlockSize = .init(high: data[9], low: data[8])
|
self.transferBlockSize = .init(high: data[9], low: data[8])
|
||||||
let secondsSincePowerOn = Int(uint32: data[13], data[12], data[11], data[10])
|
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.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.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])
|
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,
|
numberOfRecordedBytes: 123,
|
||||||
numberOfMeasurements: 234,
|
numberOfMeasurements: 234,
|
||||||
measurementInterval: 60,
|
measurementInterval: 60,
|
||||||
transferBlockSize: 180,
|
|
||||||
deviceStartTime: .now.addingTimeInterval(-1000),
|
|
||||||
nextMeasurement: .now.addingTimeInterval(5),
|
nextMeasurement: .now.addingTimeInterval(5),
|
||||||
sensor0: .init(address: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08], value: .value(21.0), date: .now.addingTimeInterval(-2)),
|
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)),
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
65
TempTrack/Extensions/Date+Extensions.swift
Normal file
65
TempTrack/Extensions/Date+Extensions.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
8
TempTrack/Extensions/Double+Extensions.swift
Normal file
8
TempTrack/Extensions/Double+Extensions.swift
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Double {
|
||||||
|
|
||||||
|
var roundedInt: Int {
|
||||||
|
Int(rounded())
|
||||||
|
}
|
||||||
|
}
|
45
TempTrack/Storage/MeasurementDailyCount.swift
Normal file
45
TempTrack/Storage/MeasurementDailyCount.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
275
TempTrack/Storage/TemperatureStorage.swift
Normal file
275
TempTrack/Storage/TemperatureStorage.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -1,10 +1,19 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
let storage = TemperatureStorage()
|
||||||
|
let bluetoothClient = BluetoothClient()
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct TempTrackApp: App {
|
struct TempTrackApp: App {
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
.environmentObject(storage)
|
||||||
|
.environmentObject(bluetoothClient)
|
||||||
|
.onAppear {
|
||||||
|
bluetoothClient.delegate = storage
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -78,6 +78,7 @@ final class TemperatureDataTransfer {
|
|||||||
|
|
||||||
func completeTransfer() {
|
func completeTransfer() {
|
||||||
processBytes()
|
processBytes()
|
||||||
|
delegate?.saveAfterTransfer()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addRelative(byte: UInt8) {
|
private func addRelative(byte: UInt8) {
|
||||||
@ -98,7 +99,7 @@ final class TemperatureDataTransfer {
|
|||||||
if measurement.sensor1.isValid {
|
if measurement.sensor1.isValid {
|
||||||
lastRecording.sensor1 = measurement.sensor1
|
lastRecording.sensor1 = measurement.sensor1
|
||||||
}
|
}
|
||||||
lastRecording.date = measurement.date
|
lastRecording.id = measurement.id
|
||||||
delegate?.didReceiveRecording(measurement)
|
delegate?.didReceiveRecording(measurement)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,4 +3,6 @@ import Foundation
|
|||||||
protocol TemperatureDataTransferDelegate: AnyObject {
|
protocol TemperatureDataTransferDelegate: AnyObject {
|
||||||
|
|
||||||
func didReceiveRecording(_ measurement: TemperatureMeasurement)
|
func didReceiveRecording(_ measurement: TemperatureMeasurement)
|
||||||
|
|
||||||
|
func saveAfterTransfer()
|
||||||
}
|
}
|
||||||
|
@ -6,14 +6,87 @@ struct TemperatureMeasurement: Identifiable {
|
|||||||
|
|
||||||
var sensor1: TemperatureValue
|
var sensor1: TemperatureValue
|
||||||
|
|
||||||
var date: Date
|
var id: Int
|
||||||
|
|
||||||
var id: Int {
|
var date: Date {
|
||||||
Int(date.timeIntervalSince1970.rounded())
|
get {
|
||||||
|
Date(seconds: id)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
id = newValue.seconds
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var secondsAgo: Int {
|
var secondsToNow: Int {
|
||||||
Int(date.timeIntervalSinceNow.rounded())
|
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 {
|
extension TemperatureMeasurement {
|
||||||
|
|
||||||
static let mockData: [TemperatureMeasurement] = {
|
static let mockData: [TemperatureMeasurement] = {
|
||||||
@ -106,8 +166,13 @@ extension TemperatureMeasurement {
|
|||||||
(15.5, 25.0),
|
(15.5, 25.0),
|
||||||
(15.0, 25.0),
|
(15.0, 25.0),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
let seconds = Date().seconds
|
||||||
return temps.enumerated().map {
|
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)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
@ -60,35 +60,3 @@ extension TemperatureSensor {
|
|||||||
self.date = Date().addingTimeInterval(-TimeInterval(secondsAgo))
|
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -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? {
|
var optionalValue: Double? {
|
||||||
if case .value(let val) = self {
|
if case .value(let val) = self {
|
||||||
return val
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -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<Int64>("id")
|
|
||||||
let name = Expression<String?>("name")
|
|
||||||
let email = Expression<String>("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)")
|
|
||||||
}
|
|
||||||
}
|
|
@ -18,9 +18,6 @@ struct DeviceInfoView: View {
|
|||||||
@Binding
|
@Binding
|
||||||
var isPresented: Bool
|
var isPresented: Bool
|
||||||
|
|
||||||
@Binding
|
|
||||||
var updateToggle: Bool
|
|
||||||
|
|
||||||
private var runTimeString: String {
|
private var runTimeString: String {
|
||||||
let number = info.numberOfSecondsRunning
|
let number = info.numberOfSecondsRunning
|
||||||
guard number >= 60 else {
|
guard number >= 60 else {
|
||||||
@ -101,6 +98,10 @@ struct DeviceInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var updateText: String {
|
||||||
|
return "Updated \(info.receivedDate.timePassedText)"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
HStack {
|
HStack {
|
||||||
@ -164,9 +165,11 @@ struct DeviceInfoView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("Updated \(info.receivedDate.timePassedText)")
|
TimelineView(.periodic(from: Date(), by: 1)) { context in
|
||||||
.font(.footnote)
|
Text(updateText)
|
||||||
.textCase(.uppercase)
|
.font(.footnote)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}.padding()
|
}.padding()
|
||||||
@ -175,11 +178,8 @@ struct DeviceInfoView: View {
|
|||||||
|
|
||||||
struct DeviceInfoView_Previews: PreviewProvider {
|
struct DeviceInfoView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
DeviceInfoView(
|
DeviceInfoView(info: .mock, isPresented: .constant(true))
|
||||||
info: .mock,
|
.previewLayout(.fixed(width: 375, height: 600))
|
||||||
isPresented: .constant(true),
|
|
||||||
updateToggle: .constant(true))
|
|
||||||
.previewLayout(.fixed(width: 375, height: 600))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
28
TempTrack/Views/HistoryList.swift
Normal file
28
TempTrack/Views/HistoryList.swift
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
29
TempTrack/Views/HistoryListRow.swift
Normal file
29
TempTrack/Views/HistoryListRow.swift
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
85
TempTrack/Views/TemperatureDayOverview.swift
Normal file
85
TempTrack/Views/TemperatureDayOverview.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -3,7 +3,8 @@ import Charts
|
|||||||
|
|
||||||
struct TemperatureHistoryChart: View {
|
struct TemperatureHistoryChart: View {
|
||||||
|
|
||||||
let points: [TemperatureMeasurement]
|
@Binding
|
||||||
|
var points: [TemperatureMeasurement]
|
||||||
|
|
||||||
let upperTempLimit = 40.0
|
let upperTempLimit = 40.0
|
||||||
let lowerTempLimit = -20.0
|
let lowerTempLimit = -20.0
|
||||||
@ -16,13 +17,13 @@ struct TemperatureHistoryChart: View {
|
|||||||
ForEach(points) { point in
|
ForEach(points) { point in
|
||||||
if let s = point.sensor0.optionalValue {
|
if let s = point.sensor0.optionalValue {
|
||||||
LineMark(
|
LineMark(
|
||||||
x: .value("Date", point.secondsAgo),
|
x: .value("Date", point.secondsToNow),
|
||||||
y: .value("Temperature", s))
|
y: .value("Temperature", s))
|
||||||
.foregroundStyle(Color.red)
|
.foregroundStyle(Color.red)
|
||||||
}
|
}
|
||||||
if let s = point.sensor1.optionalValue {
|
if let s = point.sensor1.optionalValue {
|
||||||
LineMark(
|
LineMark(
|
||||||
x: .value("Date", point.secondsAgo),
|
x: .value("Date", point.secondsToNow),
|
||||||
y: .value("Temperature", s))
|
y: .value("Temperature", s))
|
||||||
.foregroundStyle(by: .value("Type", "Sensor 1"))
|
.foregroundStyle(by: .value("Type", "Sensor 1"))
|
||||||
}
|
}
|
||||||
@ -36,13 +37,12 @@ struct TemperatureHistoryChart: View {
|
|||||||
AxisMarks(position: .trailing, values: .automatic) { value in
|
AxisMarks(position: .trailing, values: .automatic) { value in
|
||||||
AxisValueLabel(multiLabelAlignment: .trailing) {
|
AxisValueLabel(multiLabelAlignment: .trailing) {
|
||||||
if let intValue = value.as(Int.self) {
|
if let intValue = value.as(Int.self) {
|
||||||
Text("\(intValue) km")
|
Text("\(intValue)°")
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//AxisMarks(position: .trailing, stroke: StrokeStyle(lineWidth: 0))
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
@ -51,7 +51,7 @@ struct TemperatureHistoryChart: View {
|
|||||||
struct TemperatureHistoryChart_Previews: PreviewProvider {
|
struct TemperatureHistoryChart_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
TemperatureHistoryChart(
|
TemperatureHistoryChart(
|
||||||
points: TemperatureMeasurement.mockData)
|
points: .constant(TemperatureMeasurement.mockData))
|
||||||
.previewLayout(.fixed(width: 350, height: 150))
|
.previewLayout(.fixed(width: 350, height: 150))
|
||||||
.background(.gray)
|
.background(.gray)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user