Prettify main view, add temperature history

This commit is contained in:
Christoph Hagen 2023-06-05 13:05:57 +02:00
parent 6e0910e47f
commit 002eb11dc1
16 changed files with 454 additions and 55 deletions

View File

@ -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 = "<group>"; };
88CDE07A2A28AF5100114294 /* BluetoothRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothRequest.swift; sourceTree = "<group>"; };
88CDE07D2A28AFF400114294 /* DeviceInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoView.swift; sourceTree = "<group>"; };
E253A9212A2B39B700EC6B28 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
E253A9232A2B462500EC6B28 /* TemperatureHistoryChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureHistoryChart.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -130,10 +137,19 @@
isa = PBXGroup;
children = (
88CDE07D2A28AFF400114294 /* DeviceInfoView.swift */,
E253A9232A2B462500EC6B28 /* TemperatureHistoryChart.swift */,
);
path = Views;
sourceTree = "<group>";
};
E253A9202A2B39A700EC6B28 /* Extensions */ = {
isa = PBXGroup;
children = (
E253A9212A2B39B700EC6B28 /* Color+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
/* 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 */;

View File

@ -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"
}
}
],

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<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>1</integer>
</dict>
<key>TempTrack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@ -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?

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -1,10 +1,3 @@
//
// TempTrackApp.swift
// TempTrack
//
// Created by iMac on 29.05.23.
//
import SwiftUI
@main

View File

@ -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)
}
}()
}

View File

@ -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)
}
}
}

View File

@ -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<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"
}
}

View File

@ -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))
}
}

View File

@ -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)
}
}