From ac96e6d4a5f0b285999ffec7452dffbb016d9a05 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Wed, 31 Jan 2024 11:02:26 +0100 Subject: [PATCH] Improve sample view --- HealthImport.xcodeproj/project.pbxproj | 12 ++ HealthImport/ActivityDetailView.swift | 14 +++ HealthImport/ActivitySamplesView.swift | 52 ++++++++ HealthImport/HealthDatabase.swift | 8 ++ HealthImport/Model/LocationSample.swift | 73 +++++++---- HealthImport/Model/Sample+SQLite.swift | 44 +++++++ HealthImport/Model/Sample.swift | 153 ++++++++++++++--------- HealthImport/Model/WorkoutActivity.swift | 4 +- HealthImport/SampleListView.swift | 29 +++++ 9 files changed, 302 insertions(+), 87 deletions(-) create mode 100644 HealthImport/ActivitySamplesView.swift create mode 100644 HealthImport/Model/Sample+SQLite.swift create mode 100644 HealthImport/SampleListView.swift diff --git a/HealthImport.xcodeproj/project.pbxproj b/HealthImport.xcodeproj/project.pbxproj index 9c66cba..0905d7d 100644 --- a/HealthImport.xcodeproj/project.pbxproj +++ b/HealthImport.xcodeproj/project.pbxproj @@ -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 = ""; }; E201EC782B627572005B83D3 /* MetadataKey+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetadataKey+SQLite.swift"; sourceTree = ""; }; E201EC7A2B6275CA005B83D3 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = ""; }; + E201EC7C2B62930E005B83D3 /* Sample+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+SQLite.swift"; sourceTree = ""; }; + E201EC7E2B629B4C005B83D3 /* SampleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleListView.swift; sourceTree = ""; }; E27BC6792B5D99AC003A8873 /* LocationSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSample.swift; sourceTree = ""; }; E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Extensions.swift"; sourceTree = ""; }; E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSampleListView.swift; sourceTree = ""; }; @@ -93,6 +98,7 @@ E27BC6852B5FBF0B003A8873 /* Sample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sample.swift; sourceTree = ""; }; E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+Quantity.swift"; sourceTree = ""; }; E27BC6892B5FC255003A8873 /* Sample+Unit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+Unit.swift"; sourceTree = ""; }; + E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivitySamplesView.swift; sourceTree = ""; }; E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutEvent+SQLite.swift"; sourceTree = ""; }; E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutActivity+SQLite.swift"; sourceTree = ""; }; E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HealthDatabase+Mock.swift"; sourceTree = ""; }; @@ -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 */, diff --git a/HealthImport/ActivityDetailView.swift b/HealthImport/ActivityDetailView.swift index a34d4af..f630a95 100644 --- a/HealthImport/ActivityDetailView.swift +++ b/HealthImport/ActivityDetailView.swift @@ -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)") diff --git a/HealthImport/ActivitySamplesView.swift b/HealthImport/ActivitySamplesView.swift new file mode 100644 index 0000000..dbfe281 --- /dev/null +++ b/HealthImport/ActivitySamplesView.swift @@ -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) +} diff --git a/HealthImport/HealthDatabase.swift b/HealthImport/HealthDatabase.swift index f6b5a90..ed5160a 100644 --- a/HealthImport/HealthDatabase.swift +++ b/HealthImport/HealthDatabase.swift @@ -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() } diff --git a/HealthImport/Model/LocationSample.swift b/HealthImport/Model/LocationSample.swift index caaa169..b53e1ad 100644 --- a/HealthImport/Model/LocationSample.swift +++ b/HealthImport/Model/LocationSample.swift @@ -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("series_identifier") + private static let columnSeriesIdentifier = Expression("series_identifier") - private static let rowTimestamp = Expression("timestamp") + private static let columnTimestamp = Expression("timestamp") - private static let rowLongitude = Expression("longitude") + private static let columnLongitude = Expression("longitude") - private static let rowLatitude = Expression("latitude") + private static let columnLatitude = Expression("latitude") - private static let rowAltitude = Expression("altitude") + private static let columnAltitude = Expression("altitude") - private static let rowSpeed = Expression("speed") + private static let columnSpeed = Expression("speed") - private static let rowCourse = Expression("course") + private static let columnCourse = Expression("course") - private static let rowHorizontalAccuracy = Expression("horizontal_accuracy") + private static let columnHorizontalAccuracy = Expression("horizontal_accuracy") - private static let rowVerticalAccuracy = Expression("vertical_accuracy") + private static let columnVerticalAccuracy = Expression("vertical_accuracy") - private static let rowSpeedAccuracy = Expression("speed_accuracy") + private static let columnSpeedAccuracy = Expression("speed_accuracy") - private static let rowCourseAccuracy = Expression("course_accuracy") + private static let columnCourseAccuracy = Expression("course_accuracy") - private static let rowSignalEnvironment = Expression("signal_environment") + private static let columnSignalEnvironment = Expression("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 + )) + } } diff --git a/HealthImport/Model/Sample+SQLite.swift b/HealthImport/Model/Sample+SQLite.swift new file mode 100644 index 0000000..d01ad60 --- /dev/null +++ b/HealthImport/Model/Sample+SQLite.swift @@ -0,0 +1,44 @@ +import Foundation +import SQLite + +extension Sample { + + private static let table = Table("samples") + + private static let columnDataId = Expression("data_id") + + // NOTE: Technically optional + private static let columnStartDate = Expression("start_date") + + // NOTE: Technically optional + private static let columnEndDate = Expression("end_date") + + private static let columnDataType = Expression("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) + } +} diff --git a/HealthImport/Model/Sample.swift b/HealthImport/Model/Sample.swift index e959bd8..528d3db 100644 --- a/HealthImport/Model/Sample.swift +++ b/HealthImport/Model/Sample.swift @@ -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("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("start_date") + case unknown(Int) - // NOTE: Technically optional - private static let columnEndDate = Expression("end_date") - - private static let columnDataType = Expression("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 + 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) } + } - 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) + 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 + } } } +} - 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) +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))" + } } } diff --git a/HealthImport/Model/WorkoutActivity.swift b/HealthImport/Model/WorkoutActivity.swift index 5fcb731..6623431 100644 --- a/HealthImport/Model/WorkoutActivity.swift +++ b/HealthImport/Model/WorkoutActivity.swift @@ -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] } } } diff --git a/HealthImport/SampleListView.swift b/HealthImport/SampleListView.swift new file mode 100644 index 0000000..8ff2332 --- /dev/null +++ b/HealthImport/SampleListView.swift @@ -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 + } +}