From 002eb11dc134e2618a749c58e3a0d7e9510a40bd Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Mon, 5 Jun 2023 13:05:57 +0200 Subject: [PATCH] Prettify main view, add temperature history --- TempTrack.xcodeproj/project.pbxproj | 43 +++++- .../xcshareddata/swiftpm/Package.resolved | 13 +- .../UserInterfaceState.xcuserstate | Bin 0 -> 41667 bytes .../xcschemes/xcschememanagement.plist | 35 +++++ TempTrack/Bluetooth/BluetoothClient.swift | 4 +- TempTrack/{ => Bluetooth}/DeviceManager.swift | 0 .../DeviceManagerDelegate.swift | 0 TempTrack/{ => Bluetooth}/DeviceState.swift | 0 TempTrack/ContentView.swift | 127 +++++++++++++----- TempTrack/Extensions/Color+Extensions.swift | 24 ++++ TempTrack/TempTrackApp.swift | 7 - .../Temperature/TemperatureMeasurement.swift | 105 ++++++++++++++- TempTrack/Temperature/TemperatureValue.swift | 4 +- TempTrack/TemperatureStorage.swift | 66 ++++++++- TempTrack/Views/DeviceInfoView.swift | 23 +++- TempTrack/Views/TemperatureHistoryChart.swift | 58 ++++++++ 16 files changed, 454 insertions(+), 55 deletions(-) create mode 100644 TempTrack.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 TempTrack.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist rename TempTrack/{ => Bluetooth}/DeviceManager.swift (100%) rename TempTrack/{ => Bluetooth}/DeviceManagerDelegate.swift (100%) rename TempTrack/{ => Bluetooth}/DeviceState.swift (100%) create mode 100644 TempTrack/Extensions/Color+Extensions.swift create mode 100644 TempTrack/Views/TemperatureHistoryChart.swift diff --git a/TempTrack.xcodeproj/project.pbxproj b/TempTrack.xcodeproj/project.pbxproj index cde577d..0d84626 100644 --- a/TempTrack.xcodeproj/project.pbxproj +++ b/TempTrack.xcodeproj/project.pbxproj @@ -26,6 +26,9 @@ 88CDE0782A28AF2C00114294 /* TemperatureSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0772A28AF2C00114294 /* TemperatureSensor.swift */; }; 88CDE07B2A28AF5100114294 /* BluetoothRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE07A2A28AF5100114294 /* BluetoothRequest.swift */; }; 88CDE07E2A28AFF400114294 /* DeviceInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE07D2A28AFF400114294 /* DeviceInfoView.swift */; }; + E253A9222A2B39B700EC6B28 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253A9212A2B39B700EC6B28 /* Color+Extensions.swift */; }; + E253A9242A2B462500EC6B28 /* TemperatureHistoryChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253A9232A2B462500EC6B28 /* TemperatureHistoryChart.swift */; }; + E253A9272A2CA48A00EC6B28 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = E253A9262A2CA48A00EC6B28 /* SQLite */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -47,6 +50,8 @@ 88CDE0772A28AF2C00114294 /* TemperatureSensor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSensor.swift; sourceTree = ""; }; 88CDE07A2A28AF5100114294 /* BluetoothRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothRequest.swift; sourceTree = ""; }; 88CDE07D2A28AFF400114294 /* DeviceInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoView.swift; sourceTree = ""; }; + E253A9212A2B39B700EC6B28 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = ""; }; + E253A9232A2B462500EC6B28 /* TemperatureHistoryChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureHistoryChart.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -54,6 +59,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E253A9272A2CA48A00EC6B28 /* SQLite in Frameworks */, 88CDE0662A25D08F00114294 /* SFSafeSymbols in Frameworks */, 88CDE06B2A2899C900114294 /* BottomSheet in Frameworks */, ); @@ -81,14 +87,12 @@ 88CDE04D2A2508E900114294 /* TempTrack */ = { isa = PBXGroup; children = ( + 88CDE04E2A2508E900114294 /* TempTrackApp.swift */, + 88CDE0502A2508E900114294 /* ContentView.swift */, + E253A9202A2B39A700EC6B28 /* Extensions */, 88CDE07C2A28AFE700114294 /* Views */, 88CDE0792A28AF3E00114294 /* Bluetooth */, 88CDE06E2A28AE8D00114294 /* Temperature */, - 88CDE04E2A2508E900114294 /* TempTrackApp.swift */, - 88CDE05C2A250F3C00114294 /* DeviceManager.swift */, - 88CDE0732A28AEE500114294 /* DeviceManagerDelegate.swift */, - 88CDE05E2A250F5200114294 /* DeviceState.swift */, - 88CDE0502A2508E900114294 /* ContentView.swift */, 88CDE0522A2508EA00114294 /* Assets.xcassets */, 88CDE0542A2508EA00114294 /* Preview Content */, 88CDE0672A2698B400114294 /* TemperatureStorage.swift */, @@ -122,6 +126,9 @@ children = ( 88CDE0602A25108100114294 /* BluetoothClient.swift */, 88CDE07A2A28AF5100114294 /* BluetoothRequest.swift */, + 88CDE05C2A250F3C00114294 /* DeviceManager.swift */, + 88CDE0732A28AEE500114294 /* DeviceManagerDelegate.swift */, + 88CDE05E2A250F5200114294 /* DeviceState.swift */, ); path = Bluetooth; sourceTree = ""; @@ -130,10 +137,19 @@ isa = PBXGroup; children = ( 88CDE07D2A28AFF400114294 /* DeviceInfoView.swift */, + E253A9232A2B462500EC6B28 /* TemperatureHistoryChart.swift */, ); path = Views; sourceTree = ""; }; + E253A9202A2B39A700EC6B28 /* Extensions */ = { + isa = PBXGroup; + children = ( + E253A9212A2B39B700EC6B28 /* Color+Extensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -153,6 +169,7 @@ packageProductDependencies = ( 88CDE0652A25D08F00114294 /* SFSafeSymbols */, 88CDE06A2A2899C900114294 /* BottomSheet */, + E253A9262A2CA48A00EC6B28 /* SQLite */, ); productName = TempTrack; productReference = 88CDE04B2A2508E900114294 /* TempTrack.app */; @@ -185,6 +202,7 @@ packageReferences = ( 88CDE0642A25D08F00114294 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, 88CDE0692A2899C900114294 /* XCRemoteSwiftPackageReference "bottom-sheet" */, + E253A9252A2CA48900EC6B28 /* XCRemoteSwiftPackageReference "SQLite" */, ); productRefGroup = 88CDE04C2A2508E900114294 /* Products */; projectDirPath = ""; @@ -223,6 +241,8 @@ 88CDE0682A2698B400114294 /* TemperatureStorage.swift in Sources */, 88CDE0762A28AF0900114294 /* TemperatureValue.swift in Sources */, 88CDE07B2A28AF5100114294 /* BluetoothRequest.swift in Sources */, + E253A9242A2B462500EC6B28 /* TemperatureHistoryChart.swift in Sources */, + E253A9222A2B39B700EC6B28 /* Color+Extensions.swift in Sources */, 88CDE0612A25108100114294 /* BluetoothClient.swift in Sources */, 88CDE0742A28AEE500114294 /* DeviceManagerDelegate.swift in Sources */, 88CDE06D2A28A92000114294 /* DeviceInfo.swift in Sources */, @@ -449,6 +469,14 @@ minimumVersion = 1.0.0; }; }; + E253A9252A2CA48900EC6B28 /* XCRemoteSwiftPackageReference "SQLite" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/stephencelis/SQLite.swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.14.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -462,6 +490,11 @@ package = 88CDE0692A2899C900114294 /* XCRemoteSwiftPackageReference "bottom-sheet" */; productName = BottomSheet; }; + E253A9262A2CA48A00EC6B28 /* SQLite */ = { + isa = XCSwiftPackageProductDependency; + package = E253A9252A2CA48900EC6B28 /* XCRemoteSwiftPackageReference "SQLite" */; + productName = SQLite; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 88CDE0432A2508E800114294 /* Project object */; diff --git a/TempTrack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TempTrack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 64ac4f3..a493971 100644 --- a/TempTrack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TempTrack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", "state" : { - "revision" : "7cca2d60925876b5953a2cf7341cd80fbeac983c", - "version" : "4.1.1" + "revision" : "2bcd249b49178247e6b52bac7d67d6e338a40cee", + "version" : "4.1.0" + } + }, + { + "identity" : "sqlite.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/stephencelis/SQLite.swift", + "state" : { + "revision" : "7a2e3cd27de56f6d396e84f63beefd0267b55ccb", + "version" : "0.14.1" } } ], diff --git a/TempTrack.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate b/TempTrack.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..4945cb5e6de3975f1ff2c62798a3cf3a00da8842 GIT binary patch literal 41667 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 XcNe;ge@kg%Vt%`CH2?qb#$Eb9Cz&_U literal 0 HcmV?d00001 diff --git a/TempTrack.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist b/TempTrack.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..3d5a1f6 --- /dev/null +++ b/TempTrack.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,35 @@ + + + + + SchemeUserState + + SQLite (Playground) 1.xcscheme + + isShown + + orderHint + 2 + + SQLite (Playground) 2.xcscheme + + isShown + + orderHint + 3 + + SQLite (Playground).xcscheme + + isShown + + orderHint + 1 + + TempTrack.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/TempTrack/Bluetooth/BluetoothClient.swift b/TempTrack/Bluetooth/BluetoothClient.swift index 301a2bd..d059250 100644 --- a/TempTrack/Bluetooth/BluetoothClient.swift +++ b/TempTrack/Bluetooth/BluetoothClient.swift @@ -55,7 +55,9 @@ final class BluetoothClient: ObservableObject { private var runningTransfer: TemperatureDataTransfer? func updateDeviceInfo() { - addRequest(.getInfo) + if case .configured = deviceState { + addRequest(.getInfo) + } } private var dataUpdateTimer: Timer? diff --git a/TempTrack/DeviceManager.swift b/TempTrack/Bluetooth/DeviceManager.swift similarity index 100% rename from TempTrack/DeviceManager.swift rename to TempTrack/Bluetooth/DeviceManager.swift diff --git a/TempTrack/DeviceManagerDelegate.swift b/TempTrack/Bluetooth/DeviceManagerDelegate.swift similarity index 100% rename from TempTrack/DeviceManagerDelegate.swift rename to TempTrack/Bluetooth/DeviceManagerDelegate.swift diff --git a/TempTrack/DeviceState.swift b/TempTrack/Bluetooth/DeviceState.swift similarity index 100% rename from TempTrack/DeviceState.swift rename to TempTrack/Bluetooth/DeviceState.swift diff --git a/TempTrack/ContentView.swift b/TempTrack/ContentView.swift index 025feae..91e3dbb 100644 --- a/TempTrack/ContentView.swift +++ b/TempTrack/ContentView.swift @@ -3,20 +3,56 @@ import SFSafeSymbols 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 + + private let maxTempColor = Color(hue: 1.0, saturation: 0.5, brightness: 1.0) + private let maxTemperature = 40.0 + + private let disconnectedColor = Color(white: 0.8) + @ObservedObject var client = BluetoothClient() - - init() { - - } - - init(client: BluetoothClient) { - self.client = client - } - + + @ObservedObject + var storage = TemperatureStorage() + @State var showDeviceInfo = false + + @State + var updateTimer: Timer? + + @State + var updateInfoToggle = true + + 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 + } var averageTemperature: Double? { let t1 = client.deviceInfo?.sensor1?.optionalValue @@ -54,54 +90,77 @@ struct ContentView: View { } return .thermometerHigh } - + + var backgroundColor: Color { + guard let temp = averageTemperature else { + return disconnectedColor + } + guard temp > minTemperature else { + return minTempColor + } + guard temp < maxTemperature else { + return maxTempColor + } + let ratio = (temp - minTemperature) / (maxTemperature - minTemperature) + return minTempColor.blend(to: maxTempColor, intensity: ratio) + } + + var backgroundGradient: Gradient { + let color = backgroundColor + let lighter = color.opacity(0.5) + return .init(colors: [lighter, color]) + } + var body: some View { VStack { - HStack { - Image(systemSymbol: .iphone) - .frame(width: 30) - Text(client.deviceState.text) - Spacer() - } Spacer() - Image(systemSymbol: temperatureIcon) - .font(.system(size: 200, weight: .light)) +// Image(systemSymbol: temperatureIcon) +// .font(.system(size: 100, weight: .light)) if hasTemperature { Text(temperatureString) - .font(.system(size: 100, weight: .light)) + .font(.system(size: 150, weight: .light)) + .foregroundColor(.white) } - + Spacer() + TemperatureHistoryChart(points: storage.lastMeasurements) + .frame(height: 150) + .background(Color.white.opacity(0.1)) + .cornerRadius(8) HStack(alignment: .center) { - Button(action: { - _ = client.collectRecordedData() - }) { - Text("Transfer") - }.padding() - Spacer() Button { self.showDeviceInfo = true } label: { - Image(systemSymbol: .infoCircle) - .font(.system(size: 40, weight: .regular)) - }.disabled(client.deviceInfo == nil) + if hasDeviceInfo { + Image(systemSymbol: .iphone) + .font(.system(size: 30, weight: .regular)) + } + Text(client.deviceState.text) + } + .disabled(!hasDeviceInfo) + .foregroundColor(.white) + }.padding() } .padding() - .bottomSheet(isPresented: $showDeviceInfo, height: 520) { + .bottomSheet(isPresented: $showDeviceInfo, height: 600) { if let info = client.deviceInfo { - DeviceInfoView(info: info) + DeviceInfoView( + info: info, + isPresented: $showDeviceInfo, updateToggle: $updateInfoToggle) } else { EmptyView() } } - + .background(backgroundGradient) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { - ContentView(client: BluetoothClient(deviceInfo: .mock)) + ContentView( + client: BluetoothClient(deviceInfo: .mock), + values: TemperatureMeasurement.mockData) } } diff --git a/TempTrack/Extensions/Color+Extensions.swift b/TempTrack/Extensions/Color+Extensions.swift new file mode 100644 index 0000000..963de9f --- /dev/null +++ b/TempTrack/Extensions/Color+Extensions.swift @@ -0,0 +1,24 @@ +import Foundation +import SwiftUI + +extension Color { + + func blend(to other: Color, intensity: CGFloat = 0.5) -> Color { + Color(UIColor(self).blend(to: UIColor(other), intensity: intensity)) + } +} + +extension UIColor { + + func blend(to other: UIColor, intensity: CGFloat = 0.5) -> UIColor { + let l2 = max(0.0, min(1.0, intensity)) + let l1 = 1 - l2 + var (r1, g1, b1, a1): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0) + var (r2, g2, b2, a2): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0) + + getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + other.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) + + return UIColor(red: l1*r1 + l2*r2, green: l1*g1 + l2*g2, blue: l1*b1 + l2*b2, alpha: l1*a1 + l2*a2) + } +} diff --git a/TempTrack/TempTrackApp.swift b/TempTrack/TempTrackApp.swift index 26ca9f5..7c6ef9c 100644 --- a/TempTrack/TempTrackApp.swift +++ b/TempTrack/TempTrackApp.swift @@ -1,10 +1,3 @@ -// -// TempTrackApp.swift -// TempTrack -// -// Created by iMac on 29.05.23. -// - import SwiftUI @main diff --git a/TempTrack/Temperature/TemperatureMeasurement.swift b/TempTrack/Temperature/TemperatureMeasurement.swift index 06d2a46..5764b21 100644 --- a/TempTrack/Temperature/TemperatureMeasurement.swift +++ b/TempTrack/Temperature/TemperatureMeasurement.swift @@ -1,10 +1,113 @@ import Foundation -struct TemperatureMeasurement { +struct TemperatureMeasurement: Identifiable { var sensor0: TemperatureValue var sensor1: TemperatureValue var date: Date + + var id: Int { + Int(date.timeIntervalSince1970.rounded()) + } + + var secondsAgo: Int { + Int(date.timeIntervalSinceNow.rounded()) + } +} + +private extension TemperatureValue { + + init(value: Double?) { + if let value { + self = .value(value) + } else { + self = .notFound + } + } +} + +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] = { + let temps: [(Double?, Double?)] = [ + (20, 14), + (20, 13.5), + (20.5, 13.5), + (20.5, 13.5), + (21, 14), + (21, 14), + (nil, 14.5), + (nil, 14), + (nil, 14.5), + (nil, 14), + (nil, 14), + (nil, 14.5), + (nil, 15), + (5.0, 15), + (4.5, 15.5), + (4.5, 16), + (4.0, 16.5), + (3.0, 17), + (3.0, 19), + (2.5, 20), + (2.5, 20.5), + (2.0, 20.5), + (1.0, 20.5), + (0.5, 20.5), + (0.0, 20), + (0.0, 20), + (-1.0, 21.0), + (-0.5, 21.0), + (-3.0, 21.0), + (-3.5, 20.5), + (-4.0, 20.5), + (-5.0, 20.0), + (-5.0, nil), + (-5.5, nil), + (-5.0, nil), + (-5.5, nil), + (-6.0, nil), + (-5.0, nil), + (nil, nil), + (nil, nil), + (nil, nil), + (-5.0, nil), + (-4.5, nil), + (-4.0, 23.0), + (5.0, 24.0), + (7.0, 25.0), + (8.0, 25.5), + (8.5, 25.5), + (10.0, 25.5), + (10.5, 24.0), + (10.5, 24.0), + (10.5, 24.5), + (12.0, 23.5), + (12.5, 24.0), + (12.0, 23.5), + (14.0, 24.0), + (15.0, 25.0), + (15.0, 25.0), + (15.5, 25.0), + (15.0, 25.0), + ] + return temps.enumerated().map { + TemperatureMeasurement(t0: $0.element.0, t1: $0.element.1, min: $0.offset) + } + }() } diff --git a/TempTrack/Temperature/TemperatureValue.swift b/TempTrack/Temperature/TemperatureValue.swift index 6ba3b2f..75352ab 100644 --- a/TempTrack/Temperature/TemperatureValue.swift +++ b/TempTrack/Temperature/TemperatureValue.swift @@ -36,8 +36,8 @@ enum TemperatureValue { return "No sensor" case .invalidMeasurement: return "Invalid" - case .value(let double): - return "\(Int(double.rounded()))°C" + case .value(let value): + return String(format:" %.1f°C", value) } } } diff --git a/TempTrack/TemperatureStorage.swift b/TempTrack/TemperatureStorage.swift index 69ceabd..130e503 100644 --- a/TempTrack/TemperatureStorage.swift +++ b/TempTrack/TemperatureStorage.swift @@ -1,6 +1,70 @@ import Foundation +import Combine +import SQLite -final class TemperatureStorage { +final class TemperatureStorage: ObservableObject { + + static var documentDirectory: URL { + try! FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, create: true) + } + + private let databaseUrl: URL + + @Published + var lastMeasurements: [TemperatureMeasurement] + + init(lastMeasurements: [TemperatureMeasurement] = []) { + self.lastMeasurements = lastMeasurements + self.databaseUrl = TemperatureStorage.documentDirectory.appendingPathComponent("db.sqlite3") + } + + private let table = Table("values") + private let i + + private func createDatabaseIfNeeded() throws { + let db = try Connection(databaseUrl.path) + + let users = Table("users") + let id = Expression("id") + let name = Expression("name") + let email = Expression("email") + + try db.run(users.create(ifNotExists: true) { t in + t.column(id, primaryKey: true) + t.column(name) + t.column(email, unique: true) + }) + // CREATE TABLE "users" ( + // "id" INTEGER PRIMARY KEY NOT NULL, + // "name" TEXT, + // "email" TEXT NOT NULL UNIQUE + // ) + + let insert = users.insert(name <- "Alice", email <- "alice@mac.com") + let rowid = try db.run(insert) + // INSERT INTO "users" ("name", "email") VALUES ('Alice', 'alice@mac.com') + + for user in try db.prepare(users) { + print("id: \(user[id]), name: \(user[name]), email: \(user[email])") + // id: 1, name: Optional("Alice"), email: alice@mac.com + } + // SELECT * FROM "users" + + let alice = users.filter(id == rowid) + + try db.run(alice.update(email <- email.replace("mac.com", with: "me.com"))) + // UPDATE "users" SET "email" = replace("email", 'mac.com', 'me.com') + // WHERE ("id" = 1) + + try db.run(alice.delete()) + // DELETE FROM "users" WHERE ("id" = 1) + + try db.scalar(users.count) // 0 + // SELECT count(*) FROM "users" + } } diff --git a/TempTrack/Views/DeviceInfoView.swift b/TempTrack/Views/DeviceInfoView.swift index 21c1300..26bff3d 100644 --- a/TempTrack/Views/DeviceInfoView.swift +++ b/TempTrack/Views/DeviceInfoView.swift @@ -14,6 +14,12 @@ struct DeviceInfoView: View { private let storageWarnBytes = 500 let info: DeviceInfo + + @Binding + var isPresented: Bool + + @Binding + var updateToggle: Bool private var runTimeString: String { let number = info.numberOfSecondsRunning @@ -97,6 +103,16 @@ struct DeviceInfoView: View { var body: some View { VStack(alignment: .leading, spacing: 5) { + HStack { + Text("Device Info").font(.title2).bold() + Spacer() + Button(action: { isPresented = false }) { + Image(systemSymbol: .xmarkCircleFill) + .foregroundColor(.gray) + .font(.system(size: 26)) + } + } + .padding(.bottom) VStack(alignment: .leading, spacing: 5) { Text("Recording") .font(.headline) @@ -159,8 +175,11 @@ struct DeviceInfoView: View { struct DeviceInfoView_Previews: PreviewProvider { static var previews: some View { - DeviceInfoView(info: .mock) - .previewLayout(.fixed(width: 375, height: 500)) + DeviceInfoView( + info: .mock, + isPresented: .constant(true), + updateToggle: .constant(true)) + .previewLayout(.fixed(width: 375, height: 600)) } } diff --git a/TempTrack/Views/TemperatureHistoryChart.swift b/TempTrack/Views/TemperatureHistoryChart.swift new file mode 100644 index 0000000..2149160 --- /dev/null +++ b/TempTrack/Views/TemperatureHistoryChart.swift @@ -0,0 +1,58 @@ +import SwiftUI +import Charts + +struct TemperatureHistoryChart: View { + + let points: [TemperatureMeasurement] + + let upperTempLimit = 40.0 + let lowerTempLimit = -20.0 + + let pastDateLimit = -3600 + let futureDateLimit = 0 + + var body: some View { + Chart { + ForEach(points) { point in + if let s = point.sensor0.optionalValue { + LineMark( + x: .value("Date", point.secondsAgo), + y: .value("Temperature", s)) + .foregroundStyle(Color.red) + } + if let s = point.sensor1.optionalValue { + LineMark( + x: .value("Date", point.secondsAgo), + y: .value("Temperature", s)) + .foregroundStyle(by: .value("Type", "Sensor 1")) + } + } + } + .chartXScale(domain: pastDateLimit...futureDateLimit) + .chartYScale(domain: lowerTempLimit...upperTempLimit) + .chartXAxis(.hidden) + .chartLegend(.hidden) + .chartYAxis { + AxisMarks(position: .trailing, values: .automatic) { value in + AxisValueLabel(multiLabelAlignment: .trailing) { + if let intValue = value.as(Int.self) { + Text("\(intValue) km") + .font(.system(size: 10)) + .foregroundColor(.white) + } + } + } + //AxisMarks(position: .trailing, stroke: StrokeStyle(lineWidth: 0)) + } + .padding() + } +} + +struct TemperatureHistoryChart_Previews: PreviewProvider { + static var previews: some View { + TemperatureHistoryChart( + points: TemperatureMeasurement.mockData) + .previewLayout(.fixed(width: 350, height: 150)) + .background(.gray) + } +}