Add basic storage, temperature history display
This commit is contained in:
parent
002eb11dc1
commit
147cd6a306
@ -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 = "<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; };
|
||||
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>"; };
|
||||
@ -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 = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
@ -146,6 +172,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E253A9212A2B39B700EC6B28 /* Color+Extensions.swift */,
|
||||
88404DD12A2F0D8F00D30244 /* Double+Extensions.swift */,
|
||||
88404DD32A2F0DB100D30244 /* Date+Extensions.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
@ -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" */;
|
||||
|
@ -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",
|
||||
|
@ -4,10 +4,31 @@
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<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>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
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
|
||||
|
||||
let storage = TemperatureStorage()
|
||||
let bluetoothClient = BluetoothClient()
|
||||
|
||||
@main
|
||||
struct TempTrackApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(storage)
|
||||
.environmentObject(bluetoothClient)
|
||||
.onAppear {
|
||||
bluetoothClient.delegate = storage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -3,4 +3,6 @@ import Foundation
|
||||
protocol TemperatureDataTransferDelegate: AnyObject {
|
||||
|
||||
func didReceiveRecording(_ measurement: TemperatureMeasurement)
|
||||
|
||||
func saveAfterTransfer()
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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)")
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
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 {
|
||||
|
||||
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)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user