Improve sample view
This commit is contained in:
parent
218705a4d2
commit
ac96e6d4a5
@ -39,6 +39,8 @@
|
||||
E201EC772B626FC1005B83D3 /* MetadataKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC762B626FC1005B83D3 /* MetadataKey.swift */; };
|
||||
E201EC792B627572005B83D3 /* MetadataKey+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC782B627572005B83D3 /* MetadataKey+SQLite.swift */; };
|
||||
E201EC7B2B6275CA005B83D3 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7A2B6275CA005B83D3 /* Metadata.swift */; };
|
||||
E201EC7D2B62930E005B83D3 /* Sample+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7C2B62930E005B83D3 /* Sample+SQLite.swift */; };
|
||||
E201EC7F2B629B4C005B83D3 /* SampleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7E2B629B4C005B83D3 /* SampleListView.swift */; };
|
||||
E27BC67A2B5D99AC003A8873 /* LocationSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6792B5D99AC003A8873 /* LocationSample.swift */; };
|
||||
E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */; };
|
||||
E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */; };
|
||||
@ -47,6 +49,7 @@
|
||||
E27BC6862B5FBF0B003A8873 /* Sample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6852B5FBF0B003A8873 /* Sample.swift */; };
|
||||
E27BC6882B5FC220003A8873 /* Sample+Quantity.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */; };
|
||||
E27BC68A2B5FC255003A8873 /* Sample+Unit.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6892B5FC255003A8873 /* Sample+Unit.swift */; };
|
||||
E27BC68C2B5FC842003A8873 /* ActivitySamplesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */; };
|
||||
E27BC68E2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */; };
|
||||
E27BC6902B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */; };
|
||||
E27BC6922B5FD488003A8873 /* HealthDatabase+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */; };
|
||||
@ -85,6 +88,8 @@
|
||||
E201EC762B626FC1005B83D3 /* MetadataKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataKey.swift; sourceTree = "<group>"; };
|
||||
E201EC782B627572005B83D3 /* MetadataKey+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetadataKey+SQLite.swift"; sourceTree = "<group>"; };
|
||||
E201EC7A2B6275CA005B83D3 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
|
||||
E201EC7C2B62930E005B83D3 /* Sample+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+SQLite.swift"; sourceTree = "<group>"; };
|
||||
E201EC7E2B629B4C005B83D3 /* SampleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleListView.swift; sourceTree = "<group>"; };
|
||||
E27BC6792B5D99AC003A8873 /* LocationSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSample.swift; sourceTree = "<group>"; };
|
||||
E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Extensions.swift"; sourceTree = "<group>"; };
|
||||
E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSampleListView.swift; sourceTree = "<group>"; };
|
||||
@ -93,6 +98,7 @@
|
||||
E27BC6852B5FBF0B003A8873 /* Sample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sample.swift; sourceTree = "<group>"; };
|
||||
E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+Quantity.swift"; sourceTree = "<group>"; };
|
||||
E27BC6892B5FC255003A8873 /* Sample+Unit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+Unit.swift"; sourceTree = "<group>"; };
|
||||
E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivitySamplesView.swift; sourceTree = "<group>"; };
|
||||
E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutEvent+SQLite.swift"; sourceTree = "<group>"; };
|
||||
E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutActivity+SQLite.swift"; sourceTree = "<group>"; };
|
||||
E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HealthDatabase+Mock.swift"; sourceTree = "<group>"; };
|
||||
@ -140,6 +146,8 @@
|
||||
8850025C2B5C273C00E7D4DB /* ContentView.swift */,
|
||||
8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */,
|
||||
885002922B5D129300E7D4DB /* ActivityDetailView.swift */,
|
||||
E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */,
|
||||
E201EC7E2B629B4C005B83D3 /* SampleListView.swift */,
|
||||
E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */,
|
||||
E27BC6812B5E762D003A8873 /* LocationSampleDetailView.swift */,
|
||||
8850029C2B5D197300E7D4DB /* EventDetailView.swift */,
|
||||
@ -194,6 +202,7 @@
|
||||
8850029E2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift */,
|
||||
E27BC6852B5FBF0B003A8873 /* Sample.swift */,
|
||||
E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */,
|
||||
E201EC7C2B62930E005B83D3 /* Sample+SQLite.swift */,
|
||||
E27BC6892B5FC255003A8873 /* Sample+Unit.swift */,
|
||||
8850027E2B5C36A700E7D4DB /* Workout.swift */,
|
||||
8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */,
|
||||
@ -302,6 +311,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E201EC7F2B629B4C005B83D3 /* SampleListView.swift in Sources */,
|
||||
E27BC6982B5FD76F003A8873 /* Data+Extensions.swift in Sources */,
|
||||
885002872B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift in Sources */,
|
||||
8850025D2B5C273C00E7D4DB /* ContentView.swift in Sources */,
|
||||
@ -324,6 +334,7 @@
|
||||
885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */,
|
||||
8850029F2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift in Sources */,
|
||||
E27BC6882B5FC220003A8873 /* Sample+Quantity.swift in Sources */,
|
||||
E201EC7D2B62930E005B83D3 /* Sample+SQLite.swift in Sources */,
|
||||
8850027B2B5C35BF00E7D4DB /* Workout+SQLite.swift in Sources */,
|
||||
E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */,
|
||||
E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */,
|
||||
@ -338,6 +349,7 @@
|
||||
8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */,
|
||||
E27BC6862B5FBF0B003A8873 /* Sample.swift in Sources */,
|
||||
E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */,
|
||||
E27BC68C2B5FC842003A8873 /* ActivitySamplesView.swift in Sources */,
|
||||
E27BC6942B5FD587003A8873 /* Workout+Mock.swift in Sources */,
|
||||
E27BC68E2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift in Sources */,
|
||||
8850025B2B5C273C00E7D4DB /* HealthImportApp.swift in Sources */,
|
||||
|
@ -9,6 +9,8 @@ struct ActivityDetailView: View {
|
||||
|
||||
@State var locations: [LocationSample] = []
|
||||
|
||||
@State var sampleCount: Int = 0
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
DetailRow("UUID", value: activity.uuid)
|
||||
@ -28,6 +30,16 @@ struct ActivityDetailView: View {
|
||||
} else {
|
||||
DetailRow("Locations", value: "0")
|
||||
}
|
||||
if sampleCount != 0 {
|
||||
NavigationLink {
|
||||
ActivitySamplesView(activity: activity)
|
||||
.environmentObject(database)
|
||||
} label: {
|
||||
DetailRow("Samples", value: "\(sampleCount)")
|
||||
}
|
||||
} else {
|
||||
DetailRow("Samples", value: "0")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Activity")
|
||||
.navigationDestination(for: [LocationSample].self) { locations in
|
||||
@ -41,8 +53,10 @@ struct ActivityDetailView: View {
|
||||
do {
|
||||
let samples = try database.locationSamples(for: activity)
|
||||
.sorted { $0.timestamp }
|
||||
let sampleCount = try database.sampleCount(for: activity)
|
||||
DispatchQueue.main.async {
|
||||
self.locations = samples
|
||||
self.sampleCount = sampleCount
|
||||
}
|
||||
} catch {
|
||||
print("Failed to load location samples for activity: \(error)")
|
||||
|
52
HealthImport/ActivitySamplesView.swift
Normal file
52
HealthImport/ActivitySamplesView.swift
Normal file
@ -0,0 +1,52 @@
|
||||
import SwiftUI
|
||||
import OrderedCollections
|
||||
|
||||
struct ActivitySamplesView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
var database: HealthDatabase
|
||||
|
||||
let activity: WorkoutActivity
|
||||
|
||||
@State var samples: [(type: Sample.DataType, samples: [Sample])] = []
|
||||
|
||||
init(activity: WorkoutActivity) {
|
||||
self.activity = activity
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(samples, id: \.0) { entry in
|
||||
NavigationLink {
|
||||
SampleListView(type: entry.type, samples: entry.samples)
|
||||
} label: {
|
||||
DetailRow(entry.type.description, value: entry.samples.count)
|
||||
}
|
||||
}
|
||||
}.onAppear(perform: load)
|
||||
}
|
||||
|
||||
private func load() {
|
||||
Task {
|
||||
self.loadAsync()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAsync() {
|
||||
do {
|
||||
let samples = try database.samples(for: activity)
|
||||
let ordered = samples
|
||||
.sorted(using: { $0.key.rawValue })
|
||||
.map { (type: $0, samples: $1) }
|
||||
DispatchQueue.main.async {
|
||||
self.samples = ordered
|
||||
}
|
||||
} catch {
|
||||
print("Failed to load samples: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ActivitySamplesView(activity: .mock1)
|
||||
}
|
@ -43,6 +43,14 @@ final class HealthDatabase: ObservableObject {
|
||||
try activity.locationSamples(in: database)
|
||||
}
|
||||
|
||||
func samples(for activity: WorkoutActivity) throws -> [Sample.DataType : [Sample]] {
|
||||
try activity.samples(in: database)
|
||||
}
|
||||
|
||||
func sampleCount(for activity: WorkoutActivity) throws -> Int {
|
||||
try activity.sampleCount(in: database)
|
||||
}
|
||||
|
||||
var activities: [WorkoutActivity] {
|
||||
workouts.map { $0.activities }.joined().sorted()
|
||||
}
|
||||
|
@ -9,63 +9,84 @@ extension LocationSample {
|
||||
private static let table = Table("location_series_data")
|
||||
|
||||
/// `location_series_data[series_identifier]` <-> `workout_activities[ROW_ID]`
|
||||
private static let rowSeriesIdentifier = Expression<Int>("series_identifier")
|
||||
private static let columnSeriesIdentifier = Expression<Int>("series_identifier")
|
||||
|
||||
private static let rowTimestamp = Expression<Double>("timestamp")
|
||||
private static let columnTimestamp = Expression<Double>("timestamp")
|
||||
|
||||
private static let rowLongitude = Expression<Double>("longitude")
|
||||
private static let columnLongitude = Expression<Double>("longitude")
|
||||
|
||||
private static let rowLatitude = Expression<Double>("latitude")
|
||||
private static let columnLatitude = Expression<Double>("latitude")
|
||||
|
||||
private static let rowAltitude = Expression<Double>("altitude")
|
||||
private static let columnAltitude = Expression<Double>("altitude")
|
||||
|
||||
private static let rowSpeed = Expression<Double>("speed")
|
||||
private static let columnSpeed = Expression<Double>("speed")
|
||||
|
||||
private static let rowCourse = Expression<Double>("course")
|
||||
private static let columnCourse = Expression<Double>("course")
|
||||
|
||||
private static let rowHorizontalAccuracy = Expression<Double>("horizontal_accuracy")
|
||||
private static let columnHorizontalAccuracy = Expression<Double>("horizontal_accuracy")
|
||||
|
||||
private static let rowVerticalAccuracy = Expression<Double>("vertical_accuracy")
|
||||
private static let columnVerticalAccuracy = Expression<Double>("vertical_accuracy")
|
||||
|
||||
private static let rowSpeedAccuracy = Expression<Double>("speed_accuracy")
|
||||
private static let columnSpeedAccuracy = Expression<Double>("speed_accuracy")
|
||||
|
||||
private static let rowCourseAccuracy = Expression<Double>("course_accuracy")
|
||||
private static let columnCourseAccuracy = Expression<Double>("course_accuracy")
|
||||
|
||||
private static let rowSignalEnvironment = Expression<Double>("signal_environment")
|
||||
private static let columnSignalEnvironment = Expression<Double>("signal_environment")
|
||||
|
||||
static func locationSamples(for seriesId: Int, in database: Database) throws -> [LocationSample] {
|
||||
try database.prepare(table.filter(rowSeriesIdentifier == seriesId)).map(location)
|
||||
try database.prepare(table.filter(columnSeriesIdentifier == seriesId)).map(location)
|
||||
}
|
||||
|
||||
static func locationSampleCount(for seriesId: Int, in database: Database) throws -> Int {
|
||||
try database.scalar(table.filter(rowSeriesIdentifier == seriesId).count)
|
||||
try database.scalar(table.filter(columnSeriesIdentifier == seriesId).count)
|
||||
}
|
||||
|
||||
static func locationSamples(from start: Date, to end: Date, in database: Database) throws -> [LocationSample] {
|
||||
let startTime = start.timeIntervalSinceReferenceDate
|
||||
let endTime = end.timeIntervalSinceReferenceDate
|
||||
return try database.prepare(table.filter(rowTimestamp >= startTime && rowTimestamp <= endTime)).map(location)
|
||||
return try database.prepare(table.filter(columnTimestamp >= startTime && columnTimestamp <= endTime)).map(location)
|
||||
}
|
||||
|
||||
static func locationSampleCount(from start: Date, to end: Date, in database: Database) throws -> Int {
|
||||
let startTime = start.timeIntervalSinceReferenceDate
|
||||
let endTime = end.timeIntervalSinceReferenceDate
|
||||
return try database.scalar(table.filter(rowTimestamp >= startTime && rowTimestamp <= endTime).count)
|
||||
return try database.scalar(table.filter(columnTimestamp >= startTime && columnTimestamp <= endTime).count)
|
||||
}
|
||||
|
||||
private static func location(row: Row) -> LocationSample {
|
||||
.init(
|
||||
coordinate: .init(
|
||||
latitude: row[rowLatitude],
|
||||
longitude: row[rowLongitude]),
|
||||
altitude: row[rowAltitude],
|
||||
horizontalAccuracy: row[rowHorizontalAccuracy],
|
||||
verticalAccuracy: row[rowHorizontalAccuracy],
|
||||
course: row[rowCourse],
|
||||
courseAccuracy: row[rowCourseAccuracy],
|
||||
speed: row[rowSpeed],
|
||||
speedAccuracy: row[rowSpeedAccuracy],
|
||||
timestamp: .init(timeIntervalSinceReferenceDate: row[rowTimestamp]),
|
||||
latitude: row[columnLatitude],
|
||||
longitude: row[columnLongitude]),
|
||||
altitude: row[columnAltitude],
|
||||
horizontalAccuracy: row[columnHorizontalAccuracy],
|
||||
verticalAccuracy: row[columnHorizontalAccuracy],
|
||||
course: row[columnCourse],
|
||||
courseAccuracy: row[columnCourseAccuracy],
|
||||
speed: row[columnSpeed],
|
||||
speedAccuracy: row[columnSpeedAccuracy],
|
||||
timestamp: .init(timeIntervalSinceReferenceDate: row[columnTimestamp]),
|
||||
sourceInfo: .init())
|
||||
}
|
||||
|
||||
static func createTable(in database: Connection) throws {
|
||||
try database.execute("CREATE TABLE location_series_data (series_identifier INTEGER NOT NULL REFERENCES data_series(hfd_key) DEFERRABLE INITIALLY DEFERRED, timestamp REAL NOT NULL, longitude REAL NOT NULL, latitude REAL NOT NULL, altitude REAL NOT NULL, speed REAL NOT NULL, course REAL NOT NULL, horizontal_accuracy REAL NOT NULL, vertical_accuracy REAL NOT NULL, speed_accuracy REAL NOT NULL, course_accuracy REAL NOT NULL, signal_environment INTEGER NOT NULL, PRIMARY KEY (series_identifier, timestamp)) WITHOUT ROWID")
|
||||
}
|
||||
|
||||
static func insert(_ sample: LocationSample, in database: Connection, seriesId: Int) throws {
|
||||
try database.run(table.insert(
|
||||
columnSeriesIdentifier <- seriesId,
|
||||
columnTimestamp <- sample.timestamp.timeIntervalSinceReferenceDate,
|
||||
columnLongitude <- sample.coordinate.longitude,
|
||||
columnLatitude <- sample.coordinate.latitude,
|
||||
columnAltitude <- sample.altitude,
|
||||
columnSpeed <- sample.speed,
|
||||
columnCourse <- sample.course,
|
||||
columnHorizontalAccuracy <- sample.horizontalAccuracy,
|
||||
columnHorizontalAccuracy <- sample.verticalAccuracy,
|
||||
columnSpeedAccuracy <- sample.speedAccuracy,
|
||||
columnCourseAccuracy <- sample.courseAccuracy,
|
||||
columnSignalEnvironment <- 1
|
||||
))
|
||||
}
|
||||
}
|
||||
|
44
HealthImport/Model/Sample+SQLite.swift
Normal file
44
HealthImport/Model/Sample+SQLite.swift
Normal file
@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
extension Sample {
|
||||
|
||||
private static let table = Table("samples")
|
||||
|
||||
private static let columnDataId = Expression<Int>("data_id")
|
||||
|
||||
// NOTE: Technically optional
|
||||
private static let columnStartDate = Expression<Double>("start_date")
|
||||
|
||||
// NOTE: Technically optional
|
||||
private static let columnEndDate = Expression<Double>("end_date")
|
||||
|
||||
private static let columnDataType = Expression<Int>("data_type")
|
||||
|
||||
static func samples(from start: Date, to end: Date, in database: Database) throws -> [Sample] {
|
||||
let start = start.timeIntervalSinceReferenceDate
|
||||
let end = end.timeIntervalSinceReferenceDate
|
||||
return try database.prepare(table.filter(columnStartDate >= start && columnEndDate <= end)).compactMap { (row: Row) -> Sample? in
|
||||
let dataId = row[columnDataId]
|
||||
guard let quantity = try Sample.quantity(for: dataId, in: database) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let unit = try quantity.unit.map { try Sample.unit(for: $0, in: database) }
|
||||
|
||||
return Sample(
|
||||
startDate: Date(timeIntervalSinceReferenceDate: row[columnStartDate]),
|
||||
endDate: Date(timeIntervalSinceReferenceDate: row[columnEndDate]),
|
||||
dataType: .init(rawValue: row[columnDataType]),
|
||||
quantity: quantity.quantity,
|
||||
originalQuantity: quantity.original,
|
||||
originalUnit: unit)
|
||||
}
|
||||
}
|
||||
|
||||
static func sampleCount(from start: Date, to end: Date, in database: Database) throws -> Int {
|
||||
let start = start.timeIntervalSinceReferenceDate
|
||||
let end = end.timeIntervalSinceReferenceDate
|
||||
return try database.scalar(table.filter(columnStartDate >= start && columnEndDate <= end).count)
|
||||
}
|
||||
}
|
@ -1,33 +1,4 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
enum SampleDataType: RawRepresentable {
|
||||
|
||||
case unknown(Int)
|
||||
|
||||
init(rawValue: Int) {
|
||||
switch rawValue {
|
||||
|
||||
default:
|
||||
self = .unknown(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
var rawValue: Int {
|
||||
switch self {
|
||||
case .unknown(let value):
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SampleDataType: Equatable {
|
||||
|
||||
}
|
||||
|
||||
extension SampleDataType: Hashable {
|
||||
|
||||
}
|
||||
|
||||
struct Sample {
|
||||
|
||||
@ -35,53 +6,117 @@ struct Sample {
|
||||
|
||||
let endDate: Date
|
||||
|
||||
let dataType: SampleDataType
|
||||
let dataType: DataType
|
||||
|
||||
let quantity: Double
|
||||
|
||||
let originalQuantity: Double?
|
||||
|
||||
let originalUnit: String?
|
||||
|
||||
var duration: TimeInterval {
|
||||
endDate.timeIntervalSince(startDate)
|
||||
}
|
||||
|
||||
var originalQuantityText: String {
|
||||
guard let originalQuantity, let originalUnit else {
|
||||
return ""
|
||||
}
|
||||
return " (\(originalQuantity) \(originalUnit))"
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension Sample: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
"\(startDate.timeAndDateText) (\(Int(duration)) s) \(quantity)\(originalQuantityText)"
|
||||
}
|
||||
}
|
||||
|
||||
extension Sample {
|
||||
|
||||
private static let table = Table("samples")
|
||||
enum DataType: RawRepresentable {
|
||||
|
||||
private static let columnDataId = Expression<Int>("data_id")
|
||||
case weight // 3
|
||||
case heartRate // 5
|
||||
case stepCount // 7
|
||||
case distance // 8
|
||||
case restingEnergy // 9
|
||||
case activeEnergy // 10
|
||||
case flightsClimed // 12
|
||||
case weeklyCalorieGoal // 67
|
||||
case watchOn // 70
|
||||
case standMinutes // 75
|
||||
case activity // 76
|
||||
case workout // 79
|
||||
|
||||
// NOTE: Technically optional
|
||||
private static let columnStartDate = Expression<Double>("start_date")
|
||||
case unknown(Int)
|
||||
|
||||
// NOTE: Technically optional
|
||||
private static let columnEndDate = Expression<Double>("end_date")
|
||||
|
||||
private static let columnDataType = Expression<Int>("data_type")
|
||||
|
||||
static func samples(from start: Date, to end: Date, in database: Database) throws -> [Sample] {
|
||||
let start = start.timeIntervalSinceReferenceDate
|
||||
let end = end.timeIntervalSinceReferenceDate
|
||||
return try database.prepare(table.filter(columnStartDate >= start && columnEndDate <= end)).compactMap { (row: Row) -> Sample? in
|
||||
let dataId = row[columnDataId]
|
||||
guard let quantity = try Sample.quantity(for: dataId, in: database) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let unit = try quantity.unit.map { try Sample.unit(for: $0, in: database) }
|
||||
|
||||
return Sample(
|
||||
startDate: Date(timeIntervalSinceReferenceDate: row[columnStartDate]),
|
||||
endDate: Date(timeIntervalSinceReferenceDate: row[columnEndDate]),
|
||||
dataType: .init(rawValue: row[columnDataType]),
|
||||
quantity: quantity.quantity,
|
||||
originalQuantity: quantity.original,
|
||||
originalUnit: unit)
|
||||
init(rawValue: Int) {
|
||||
switch rawValue {
|
||||
case 3: self = .weight
|
||||
case 5: self = .heartRate
|
||||
case 7: self = .stepCount
|
||||
case 8: self = .distance
|
||||
case 9: self = .restingEnergy
|
||||
case 10: self = .activeEnergy
|
||||
case 12: self = .flightsClimed
|
||||
case 67: self = .weeklyCalorieGoal
|
||||
case 70: self = .watchOn
|
||||
case 75: self = .standMinutes
|
||||
case 76: self = .activity
|
||||
case 79: self = .workout
|
||||
default:
|
||||
self = .unknown(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
static func sampleCount(from start: Date, to end: Date, in database: Database) throws -> Int {
|
||||
let start = start.timeIntervalSinceReferenceDate
|
||||
let end = end.timeIntervalSinceReferenceDate
|
||||
return try database.scalar(table.filter(columnStartDate >= start && columnEndDate <= end).count)
|
||||
var rawValue: Int {
|
||||
switch self {
|
||||
case .stepCount: return 7
|
||||
case .weight: return 3
|
||||
case .heartRate: return 5
|
||||
case .distance: return 8
|
||||
case .restingEnergy: return 9
|
||||
case .activeEnergy: return 10
|
||||
case .flightsClimed: return 12
|
||||
case .weeklyCalorieGoal: return 67
|
||||
case .watchOn: return 70
|
||||
case .standMinutes: return 75
|
||||
case .activity: return 76
|
||||
case .workout: return 79
|
||||
case .unknown(let value): return value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Sample.DataType: Equatable {
|
||||
|
||||
}
|
||||
|
||||
extension Sample.DataType: Hashable {
|
||||
|
||||
}
|
||||
|
||||
extension Sample.DataType: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .stepCount: return "Step Count"
|
||||
case .weight: return "Weight"
|
||||
case .heartRate: return "Heart Rate"
|
||||
case .distance: return "Distance"
|
||||
case .restingEnergy: return "Resting Energy"
|
||||
case .activeEnergy: return "Active Energy"
|
||||
case .flightsClimed: return "Flights Climbed"
|
||||
case .weeklyCalorieGoal: return "Weekly Calorie Goal"
|
||||
case .watchOn: return "Watch On"
|
||||
case .standMinutes: return "Stand Minutes"
|
||||
case .activity: return "Activity"
|
||||
case .workout: return "Workout"
|
||||
case .unknown(let int): return "Unknown(\(int))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -52,9 +52,9 @@ struct WorkoutActivity {
|
||||
try Sample.sampleCount(from: startDate, to: endDate, in: database)
|
||||
}
|
||||
|
||||
func samples(in database: Database) throws -> [SampleDataType : Sample] {
|
||||
func samples(in database: Database) throws -> [Sample.DataType : [Sample]] {
|
||||
try Sample.samples(from: startDate, to: endDate, in: database).reduce(into: [:]) {
|
||||
$0[$1.dataType] = $1
|
||||
$0[$1.dataType] = ($0[$1.dataType] ?? []) + [$1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
29
HealthImport/SampleListView.swift
Normal file
29
HealthImport/SampleListView.swift
Normal file
@ -0,0 +1,29 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SampleListView: View {
|
||||
|
||||
let type: Sample.DataType
|
||||
|
||||
let samples: [Sample]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(samples) { sample in
|
||||
DetailRow("", value: sample)
|
||||
}
|
||||
}
|
||||
.navigationTitle(type.description)
|
||||
}
|
||||
}
|
||||
/*
|
||||
#Preview {
|
||||
SampleListView()
|
||||
}
|
||||
*/
|
||||
|
||||
extension Sample: Identifiable {
|
||||
|
||||
var id: Date {
|
||||
startDate
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user