diff --git a/HealthImport.xcodeproj/project.pbxproj b/HealthImport.xcodeproj/project.pbxproj index 3636071..d0df140 100644 --- a/HealthImport.xcodeproj/project.pbxproj +++ b/HealthImport.xcodeproj/project.pbxproj @@ -38,7 +38,7 @@ 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 */; }; + E201EC7D2B62930E005B83D3 /* SamplesTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7C2B62930E005B83D3 /* SamplesTable.swift */; }; E201EC7F2B629B4C005B83D3 /* SampleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7E2B629B4C005B83D3 /* SampleListView.swift */; }; E201EC812B631708005B83D3 /* Goal.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC802B631708005B83D3 /* Goal.swift */; }; E27BC67A2B5D99AC003A8873 /* LocationSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6792B5D99AC003A8873 /* LocationSample.swift */; }; @@ -47,8 +47,8 @@ E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6812B5E762D003A8873 /* LocationSampleDetailView.swift */; }; E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6832B5E76A4003A8873 /* Location+Mock.swift */; }; 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 */; }; + E27BC6882B5FC220003A8873 /* QuantitySamplesTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6872B5FC220003A8873 /* QuantitySamplesTable.swift */; }; + E27BC68A2B5FC255003A8873 /* UnitStringsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6892B5FC255003A8873 /* UnitStringsTable.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 */; }; @@ -62,6 +62,9 @@ E2FDFF1C2B6BD0D20080A7B3 /* HKDatabaseFile+Interface.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF1B2B6BD0D20080A7B3 /* HKDatabaseFile+Interface.swift */; }; E2FDFF202B6BE34C0080A7B3 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */; }; E2FDFF222B6BE35B0080A7B3 /* EventMetadata.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF212B6BE35B0080A7B3 /* EventMetadata.pb.swift */; }; + E2FDFF252B6C50A80080A7B3 /* ObjectsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF242B6C50A80080A7B3 /* ObjectsTable.swift */; }; + E2FDFF272B6C56C70080A7B3 /* DataProvenancesTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF262B6C56C70080A7B3 /* DataProvenancesTable.swift */; }; + E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -93,7 +96,7 @@ 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 = ""; }; + E201EC7C2B62930E005B83D3 /* SamplesTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SamplesTable.swift; sourceTree = ""; }; E201EC7E2B629B4C005B83D3 /* SampleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleListView.swift; sourceTree = ""; }; E201EC802B631708005B83D3 /* Goal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Goal.swift; sourceTree = ""; }; E27BC6792B5D99AC003A8873 /* LocationSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSample.swift; sourceTree = ""; }; @@ -102,8 +105,8 @@ E27BC6812B5E762D003A8873 /* LocationSampleDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSampleDetailView.swift; sourceTree = ""; }; E27BC6832B5E76A4003A8873 /* Location+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Location+Mock.swift"; sourceTree = ""; }; 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 = ""; }; + E27BC6872B5FC220003A8873 /* QuantitySamplesTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuantitySamplesTable.swift; sourceTree = ""; }; + E27BC6892B5FC255003A8873 /* UnitStringsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitStringsTable.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 = ""; }; @@ -115,6 +118,9 @@ E2FDFF192B6BB6A40080A7B3 /* HKHealthStore+Interface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKHealthStore+Interface.swift"; sourceTree = ""; }; E2FDFF1B2B6BD0D20080A7B3 /* HKDatabaseFile+Interface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKDatabaseFile+Interface.swift"; sourceTree = ""; }; E2FDFF212B6BE35B0080A7B3 /* EventMetadata.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventMetadata.pb.swift; sourceTree = ""; }; + E2FDFF242B6C50A80080A7B3 /* ObjectsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectsTable.swift; sourceTree = ""; }; + E2FDFF262B6C56C70080A7B3 /* DataProvenancesTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProvenancesTable.swift; sourceTree = ""; }; + E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -199,6 +205,7 @@ 885002812B5C37B700E7D4DB /* Model */ = { isa = PBXGroup; children = ( + E2FDFF232B6C509D0080A7B3 /* Tables */, E2FDFF212B6BE35B0080A7B3 /* EventMetadata.pb.swift */, E201EC802B631708005B83D3 /* Goal.swift */, E27BC6792B5D99AC003A8873 /* LocationSample.swift */, @@ -208,9 +215,6 @@ 885002A22B5D217600E7D4DB /* MetadataValue.swift */, 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 */, E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */, @@ -232,6 +236,7 @@ 885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */, 8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */, E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */, + E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */, ); path = Support; sourceTree = ""; @@ -246,6 +251,18 @@ path = API; sourceTree = ""; }; + E2FDFF232B6C509D0080A7B3 /* Tables */ = { + isa = PBXGroup; + children = ( + E2FDFF262B6C56C70080A7B3 /* DataProvenancesTable.swift */, + E2FDFF242B6C50A80080A7B3 /* ObjectsTable.swift */, + E27BC6872B5FC220003A8873 /* QuantitySamplesTable.swift */, + E201EC7C2B62930E005B83D3 /* SamplesTable.swift */, + E27BC6892B5FC255003A8873 /* UnitStringsTable.swift */, + ); + path = Tables; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -339,6 +356,7 @@ 885002972B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift in Sources */, 885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */, E201EC792B627572005B83D3 /* MetadataKey+SQLite.swift in Sources */, + E2FDFF252B6C50A80080A7B3 /* ObjectsTable.swift in Sources */, 885002992B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift in Sources */, E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */, E201EC752B626B19005B83D3 /* Metadata+Mock.swift in Sources */, @@ -355,8 +373,8 @@ E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */, 885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */, 8850029F2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift in Sources */, - E27BC6882B5FC220003A8873 /* Sample+Quantity.swift in Sources */, - E201EC7D2B62930E005B83D3 /* Sample+SQLite.swift in Sources */, + E27BC6882B5FC220003A8873 /* QuantitySamplesTable.swift in Sources */, + E201EC7D2B62930E005B83D3 /* SamplesTable.swift in Sources */, 8850027B2B5C35BF00E7D4DB /* Workout+SQLite.swift in Sources */, E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */, E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */, @@ -366,13 +384,15 @@ E201EC7B2B6275CA005B83D3 /* Metadata.swift in Sources */, E27BC67A2B5D99AC003A8873 /* LocationSample.swift in Sources */, 885002952B5D147100E7D4DB /* DetailRow.swift in Sources */, + E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */, E201EC732B626A30005B83D3 /* WorkoutActivity+Mock.swift in Sources */, E2FDFF1A2B6BB6A40080A7B3 /* HKHealthStore+Interface.swift in Sources */, - E27BC68A2B5FC255003A8873 /* Sample+Unit.swift in Sources */, + E27BC68A2B5FC255003A8873 /* UnitStringsTable.swift in Sources */, 8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */, E27BC6862B5FBF0B003A8873 /* Sample.swift in Sources */, E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */, E27BC68C2B5FC842003A8873 /* ActivitySamplesView.swift in Sources */, + E2FDFF272B6C56C70080A7B3 /* DataProvenancesTable.swift in Sources */, E201EC812B631708005B83D3 /* Goal.swift in Sources */, E27BC6942B5FD587003A8873 /* Workout+Mock.swift in Sources */, E27BC68E2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift in Sources */, diff --git a/HealthImport/ActivitySamplesView.swift b/HealthImport/ActivitySamplesView.swift index 002e979..3d857a9 100644 --- a/HealthImport/ActivitySamplesView.swift +++ b/HealthImport/ActivitySamplesView.swift @@ -11,17 +11,28 @@ struct ActivitySamplesView: View { @State var samples: [(type: Sample.DataType, samples: [Sample])] = [] + @State var timeZones: [TimeZone] = [] + init(activity: HKWorkoutActivity) { 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) + if !timeZones.isEmpty { + Section("Time Zones") { + ForEach(timeZones, id: \.identifier) { timeZone in + Text(timeZone.debugDescription) + } + } + } + Section("Samples") { + 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) @@ -39,9 +50,15 @@ struct ActivitySamplesView: View { let ordered = samples .sorted(using: { $0.key.rawValue }) .map { (type: $0, samples: $1) } + let timeZones: Set = samples.reduce(into: Set()) { timeZones, sample in + timeZones.formUnion(sample.value.compactMap { $0.timeZone }) + } + DispatchQueue.main.async { self.samples = ordered + self.timeZones = timeZones.sorted { $0.identifier } } + } catch { print("Failed to load samples: \(error)") } diff --git a/HealthImport/HealthDatabase.swift b/HealthImport/HealthDatabase.swift index 05f25c2..31ff64a 100644 --- a/HealthImport/HealthDatabase.swift +++ b/HealthImport/HealthDatabase.swift @@ -11,6 +11,8 @@ final class HealthDatabase: ObservableObject { private let database: Connection + private let samples: SamplesTable + @Published var workouts: [Workout] = [] @@ -22,6 +24,7 @@ final class HealthDatabase: ObservableObject { init(fileUrl: URL, database: Connection) { self.fileUrl = fileUrl self.database = database + self.samples = .init(database: database) DispatchQueue.global().async { self.readAllWorkouts() } @@ -49,13 +52,13 @@ final class HealthDatabase: ObservableObject { } func samples(for activity: HKWorkoutActivity) throws -> [Sample.DataType : [Sample]] { - try Sample.samples(from: activity.startDate, to: activity.currentEndDate, in: database).reduce(into: [:]) { + try samples.samples(from: activity.startDate, to: activity.currentEndDate).reduce(into: [:]) { $0[$1.dataType] = ($0[$1.dataType] ?? []) + [$1] } } func sampleCount(for activity: HKWorkoutActivity) throws -> Int { - try Sample.sampleCount(from: activity.startDate, to: activity.currentEndDate, in: database) + try samples.sampleCount(from: activity.startDate, to: activity.currentEndDate) } var activities: [HKWorkoutActivity] { @@ -79,6 +82,15 @@ final class HealthDatabase: ObservableObject { convenience init(database: Database) { self.init(fileUrl: .init(filePath: "/"), database: database) } + + func insert(workout: Workout, into store: HKHealthStore) async throws -> HKWorkout? { + guard let configuration = workout.activities.first?.workoutConfiguration else { + return nil + } + + let builder = HKWorkoutBuilder(healthStore: store, configuration: configuration, device: nil) + return try await builder.finishWorkout() + } } private extension HKWorkoutActivity { diff --git a/HealthImport/Model/Sample+Quantity.swift b/HealthImport/Model/Sample+Quantity.swift deleted file mode 100644 index c3f10ad..0000000 --- a/HealthImport/Model/Sample+Quantity.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -import SQLite - -extension Sample { - - private static let table = Table("quantity_samples") - - private static let rowDataId = Expression("data_id") - - // NOTE: Technically optional - private static let rowQuantity = Expression("quantity") - - private static let rowOriginalQuantity = Expression("original_quantity") - - /// References `ROW_ID` on table `unit_strings` - private static let rowOriginalUnit = Expression("original_unit") - - static func quantity(for id: Int, in database: Database) throws -> (quantity: Double, original: Double?, unit: Int?)? { - try database.prepare(table.filter(rowDataId == id).limit(1)).map { - (quantity: $0[rowQuantity], original: $0[rowOriginalQuantity], unit: $0[rowOriginalUnit]) - }.first - } -} diff --git a/HealthImport/Model/Sample+SQLite.swift b/HealthImport/Model/Sample+SQLite.swift deleted file mode 100644 index d01ad60..0000000 --- a/HealthImport/Model/Sample+SQLite.swift +++ /dev/null @@ -1,44 +0,0 @@ -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+Unit.swift b/HealthImport/Model/Sample+Unit.swift deleted file mode 100644 index 7ce331f..0000000 --- a/HealthImport/Model/Sample+Unit.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation -import SQLite - -extension Sample { - - private static let table = Table("unit_strings") - - private static let rowId = Expression("ROW_ID") - - // - NOTE: Technically optional - private static let rowUnitString = Expression("quantity") - - static func unit(for id: Int, in database: Database) throws -> String? { - try database.prepare(table.filter(rowId == id).limit(1)).map { row in - row[rowUnitString] - }.first - } -} diff --git a/HealthImport/Model/Sample.swift b/HealthImport/Model/Sample.swift index 528d3db..2ad685c 100644 --- a/HealthImport/Model/Sample.swift +++ b/HealthImport/Model/Sample.swift @@ -8,12 +8,25 @@ struct Sample { let dataType: DataType - let quantity: Double + let quantity: Double? let originalQuantity: Double? let originalUnit: String? + let timeZoneName: String? + + var timeZone: TimeZone? { + guard let timeZoneName else { + return nil + } + guard let zone = TimeZone(identifier: timeZoneName) else { + print("No time zone for '\(timeZoneName)'") + return nil + } + return zone + } + var duration: TimeInterval { endDate.timeIntervalSince(startDate) } @@ -25,12 +38,23 @@ struct Sample { return " (\(originalQuantity) \(originalUnit))" } + + var quantityText: String { + guard let quantity else { + return "-" + } + return "\(quantity)" + } + + var dateText: String { + startDate.timeAndDateText(in: timeZone ?? .current) + } } extension Sample: CustomStringConvertible { var description: String { - "\(startDate.timeAndDateText) (\(Int(duration)) s) \(quantity)\(originalQuantityText)" + "\(dateText) (\(Int(duration)) s) \(quantityText)\(originalQuantityText)" } } diff --git a/HealthImport/Model/Tables/DataProvenancesTable.swift b/HealthImport/Model/Tables/DataProvenancesTable.swift new file mode 100644 index 0000000..6473c6c --- /dev/null +++ b/HealthImport/Model/Tables/DataProvenancesTable.swift @@ -0,0 +1,266 @@ +import Foundation +import SQLite +import HealthKit + +struct DataProvenancesTable { + + private let database: Connection + + init(database: Connection) { + self.database = database + } + + func create() throws { + try database.execute("CREATE TABLE data_provenances (ROWID INTEGER PRIMARY KEY AUTOINCREMENT, sync_provenance INTEGER NOT NULL, origin_product_type TEXT NOT NULL, origin_build TEXT NOT NULL, local_product_type TEXT NOT NULL, local_build TEXT NOT NULL, source_id INTEGER NOT NULL, device_id INTEGER NOT NULL, contributor_id INTEGER NOT NULL, source_version TEXT NOT NULL, tz_name TEXT NOT NULL, origin_major_version INTEGER NOT NULL, origin_minor_version INTEGER NOT NULL, origin_patch_version INTEGER NOT NULL, sync_identity INTEGER NOT NULL, derived_flags INTEGER NOT NULL, UNIQUE(sync_provenance, origin_product_type, origin_build, local_product_type, local_build, source_id, device_id, contributor_id, source_version, tz_name, origin_major_version, origin_minor_version, origin_patch_version, sync_identity))") + } + + let table = Table("data_provenances") + + let rowId = Expression("ROWID") + + let syncProvenance = Expression("sync_provenance") + + /// Device that created the data (e.g. Watch) + let originProductType = Expression("origin_product_type") + + let originBuild = Expression("origin_build") + + /// Device saving the data (e.g. iPhone) + let localProductType = Expression("local_product_type") + + let localBuild = Expression("local_build") + + let sourceId = Expression("source_id") + + let deviceId = Expression("device_id") + + let contributorId = Expression("contributor_id") + + let sourceVersion = Expression("source_version") + + let tzName = Expression("tz_name") + + let originMajorVersion = Expression("origin_major_version") + + let originMinorVersion = Expression("origin_minor_version") + + let originPatchVersion = Expression("origin_patch_version") + + let syncIdentity = Expression("sync_identity") + + let derivedFlags = Expression("derived_flags") + + func device(for rowId: Int) throws -> HKDevice? { + try database.pluck(table.filter(self.rowId == rowId)).map { row in + let productType = row[originProductType] + return HKDevice( + name: nil, + manufacturer: "Apple Inc.", + model: productTypeToHumanName[productType], + hardwareVersion: productType, + firmwareVersion: nil, + softwareVersion: "\(row[originMajorVersion]).\(row[originMinorVersion]).\(row[originPatchVersion])", + localIdentifier: nil, + udiDeviceIdentifier: nil) + } + } + + func timeZoneName(for rowId: Int) throws -> String? { + try database.pluck(table.filter(self.rowId == rowId)).map { $0[tzName] } + } +} + +private let productTypeToHumanName: [String : String] = [ + "i386" : "iPhone Simulator", + "x86_64" : "iPhone Simulator", + "arm64" : "iPhone Simulator", + "iPhone1,1" : "iPhone", + "iPhone1,2" : "iPhone 3G", + "iPhone2,1" : "iPhone 3GS", + "iPhone3,1" : "iPhone 4", + "iPhone3,2" : "iPhone 4 GSM Rev A", + "iPhone3,3" : "iPhone 4 CDMA", + "iPhone4,1" : "iPhone 4S", + "iPhone5,1" : "iPhone 5 (GSM)", + "iPhone5,2" : "iPhone 5 (GSM+CDMA)", + "iPhone5,3" : "iPhone 5C (GSM)", + "iPhone5,4" : "iPhone 5C (Global)", + "iPhone6,1" : "iPhone 5S (GSM)", + "iPhone6,2" : "iPhone 5S (Global)", + "iPhone7,1" : "iPhone 6 Plus", + "iPhone7,2" : "iPhone 6", + "iPhone8,1" : "iPhone 6s", + "iPhone8,2" : "iPhone 6s Plus", + "iPhone8,4" : "iPhone SE (GSM)", + "iPhone9,1" : "iPhone 7", + "iPhone9,2" : "iPhone 7 Plus", + "iPhone9,3" : "iPhone 7", + "iPhone9,4" : "iPhone 7 Plus", + "iPhone10,1" : "iPhone 8", + "iPhone10,2" : "iPhone 8 Plus", + "iPhone10,3" : "iPhone X Global", + "iPhone10,4" : "iPhone 8", + "iPhone10,5" : "iPhone 8 Plus", + "iPhone10,6" : "iPhone X GSM", + "iPhone11,2" : "iPhone XS", + "iPhone11,4" : "iPhone XS Max", + "iPhone11,6" : "iPhone XS Max Global", + "iPhone11,8" : "iPhone XR", + "iPhone12,1" : "iPhone 11", + "iPhone12,3" : "iPhone 11 Pro", + "iPhone12,5" : "iPhone 11 Pro Max", + "iPhone12,8" : "iPhone SE 2nd Gen", + "iPhone13,1" : "iPhone 12 Mini", + "iPhone13,2" : "iPhone 12", + "iPhone13,3" : "iPhone 12 Pro", + "iPhone13,4" : "iPhone 12 Pro Max", + "iPhone14,2" : "iPhone 13 Pro", + "iPhone14,3" : "iPhone 13 Pro Max", + "iPhone14,4" : "iPhone 13 Mini", + "iPhone14,5" : "iPhone 13", + "iPhone14,6" : "iPhone SE 3rd Gen", + "iPhone14,7" : "iPhone 14", + "iPhone14,8" : "iPhone 14 Plus", + "iPhone15,2" : "iPhone 14 Pro", + "iPhone15,3" : "iPhone 14 Pro Max", + "iPhone15,4" : "iPhone 15", + "iPhone15,5" : "iPhone 15 Plus", + "iPhone16,1" : "iPhone 15 Pro", + "iPhone16,2" : "iPhone 15 Pro Max", + + "iPod1,1" : "1st Gen iPod", + "iPod2,1" : "2nd Gen iPod", + "iPod3,1" : "3rd Gen iPod", + "iPod4,1" : "4th Gen iPod", + "iPod5,1" : "5th Gen iPod", + "iPod7,1" : "6th Gen iPod", + "iPod9,1" : "7th Gen iPod", + + "iPad1,1" : "iPad", + "iPad1,2" : "iPad 3G", + "iPad2,1" : "2nd Gen iPad", + "iPad2,2" : "2nd Gen iPad GSM", + "iPad2,3" : "2nd Gen iPad CDMA", + "iPad2,4" : "2nd Gen iPad New Revision", + "iPad3,1" : "3rd Gen iPad", + "iPad3,2" : "3rd Gen iPad CDMA", + "iPad3,3" : "3rd Gen iPad GSM", + "iPad2,5" : "iPad mini", + "iPad2,6" : "iPad mini GSM+LTE", + "iPad2,7" : "iPad mini CDMA+LTE", + "iPad3,4" : "4th Gen iPad", + "iPad3,5" : "4th Gen iPad GSM+LTE", + "iPad3,6" : "4th Gen iPad CDMA+LTE", + "iPad4,1" : "iPad Air (WiFi)", + "iPad4,2" : "iPad Air (GSM+CDMA)", + "iPad4,3" : "1st Gen iPad Air (China)", + "iPad4,4" : "iPad mini Retina (WiFi)", + "iPad4,5" : "iPad mini Retina (GSM+CDMA)", + "iPad4,6" : "iPad mini Retina (China)", + "iPad4,7" : "iPad mini 3 (WiFi)", + "iPad4,8" : "iPad mini 3 (GSM+CDMA)", + "iPad4,9" : "iPad Mini 3 (China)", + "iPad5,1" : "iPad mini 4 (WiFi)", + "iPad5,2" : "4th Gen iPad mini (WiFi+Cellular)", + "iPad5,3" : "iPad Air 2 (WiFi)", + "iPad5,4" : "iPad Air 2 (Cellular)", + "iPad6,3" : "iPad Pro (9.7 inch, WiFi)", + "iPad6,4" : "iPad Pro (9.7 inch, WiFi+LTE)", + "iPad6,7" : "iPad Pro (12.9 inch, WiFi)", + "iPad6,8" : "iPad Pro (12.9 inch, WiFi+LTE)", + "iPad6,11" : "iPad (2017)", + "iPad6,12" : "iPad (2017)", + "iPad7,1" : "iPad Pro 2nd Gen (WiFi)", + "iPad7,2" : "iPad Pro 2nd Gen (WiFi+Cellular)", + "iPad7,3" : "iPad Pro 10.5-inch 2nd Gen", + "iPad7,4" : "iPad Pro 10.5-inch 2nd Gen", + "iPad7,5" : "iPad 6th Gen (WiFi)", + "iPad7,6" : "iPad 6th Gen (WiFi+Cellular)", + "iPad7,11" : "iPad 7th Gen 10.2-inch (WiFi)", + "iPad7,12" : "iPad 7th Gen 10.2-inch (WiFi+Cellular)", + "iPad8,1" : "iPad Pro 11 inch 3rd Gen (WiFi)", + "iPad8,2" : "iPad Pro 11 inch 3rd Gen (1TB, WiFi)", + "iPad8,3" : "iPad Pro 11 inch 3rd Gen (WiFi+Cellular)", + "iPad8,4" : "iPad Pro 11 inch 3rd Gen (1TB, WiFi+Cellular)", + "iPad8,5" : "iPad Pro 12.9 inch 3rd Gen (WiFi)", + "iPad8,6" : "iPad Pro 12.9 inch 3rd Gen (1TB, WiFi)", + "iPad8,7" : "iPad Pro 12.9 inch 3rd Gen (WiFi+Cellular)", + "iPad8,8" : "iPad Pro 12.9 inch 3rd Gen (1TB, WiFi+Cellular)", + "iPad8,9" : "iPad Pro 11 inch 4th Gen (WiFi)", + "iPad8,10" : "iPad Pro 11 inch 4th Gen (WiFi+Cellular)", + "iPad8,11" : "iPad Pro 12.9 inch 4th Gen (WiFi)", + "iPad8,12" : "iPad Pro 12.9 inch 4th Gen (WiFi+Cellular)", + "iPad11,1" : "iPad mini 5th Gen (WiFi)", + "iPad11,2" : "iPad mini 5th Gen", + "iPad11,3" : "iPad Air 3rd Gen (WiFi)", + "iPad11,4" : "iPad Air 3rd Gen", + "iPad11,6" : "iPad 8th Gen (WiFi)", + "iPad11,7" : "iPad 8th Gen (WiFi+Cellular)", + "iPad12,1" : "iPad 9th Gen (WiFi)", + "iPad12,2" : "iPad 9th Gen (WiFi+Cellular)", + "iPad14,1" : "iPad mini 6th Gen (WiFi)", + "iPad14,2" : "iPad mini 6th Gen (WiFi+Cellular)", + "iPad13,1" : "iPad Air 4th Gen (WiFi)", + "iPad13,2" : "iPad Air 4th Gen (WiFi+Cellular)", + "iPad13,4" : "iPad Pro 11 inch 5th Gen", + "iPad13,5" : "iPad Pro 11 inch 5th Gen", + "iPad13,6" : "iPad Pro 11 inch 5th Gen", + "iPad13,7" : "iPad Pro 11 inch 5th Gen", + "iPad13,8" : "iPad Pro 12.9 inch 5th Gen", + "iPad13,9" : "iPad Pro 12.9 inch 5th Gen", + "iPad13,10" : "iPad Pro 12.9 inch 5th Gen", + "iPad13,11" : "iPad Pro 12.9 inch 5th Gen", + "iPad13,16" : "iPad Air 5th Gen (WiFi)", + "iPad13,17" : "iPad Air 5th Gen (WiFi+Cellular)", + "iPad13,18" : "iPad 10th Gen", + "iPad13,19" : "iPad 10th Gen", + "iPad14,3" : "iPad Pro 11 inch 4th Gen", + "iPad14,4" : "iPad Pro 11 inch 4th Gen", + "iPad14,5" : "iPad Pro 12.9 inch 6th Gen", + "iPad14,6" : "iPad Pro 12.9 inch 6th Gen", + + "Watch1,1" : "Apple Watch 38mm case", + "Watch1,2" : "Apple Watch 42mm case", + "Watch2,6" : "Apple Watch Series 1 38mm case", + "Watch2,7" : "Apple Watch Series 1 42mm case", + "Watch2,3" : "Apple Watch Series 2 38mm case", + "Watch2,4" : "Apple Watch Series 2 42mm case", + "Watch3,1" : "Apple Watch Series 3 38mm case (GPS+Cellular)", + "Watch3,2" : "Apple Watch Series 3 42mm case (GPS+Cellular)", + "Watch3,3" : "Apple Watch Series 3 38mm case (GPS)", + "Watch3,4" : "Apple Watch Series 3 42mm case (GPS)", + "Watch4,1" : "Apple Watch Series 4 40mm case (GPS)", + "Watch4,2" : "Apple Watch Series 4 44mm case (GPS)", + "Watch4,3" : "Apple Watch Series 4 40mm case (GPS+Cellular)", + "Watch4,4" : "Apple Watch Series 4 44mm case (GPS+Cellular)", + "Watch5,1" : "Apple Watch Series 5 40mm case (GPS)", + "Watch5,2" : "Apple Watch Series 5 44mm case (GPS)", + "Watch5,3" : "Apple Watch Series 5 40mm case (GPS+Cellular)", + "Watch5,4" : "Apple Watch Series 5 44mm case (GPS+Cellular)", + "Watch5,9" : "Apple Watch SE 40mm case (GPS)", + "Watch5,10" : "Apple Watch SE 44mm case (GPS)", + "Watch5,11" : "Apple Watch SE 40mm case (GPS+Cellular)", + "Watch5,12" : "Apple Watch SE 44mm case (GPS+Cellular)", + "Watch6,1" : "Apple Watch Series 6 40mm case (GPS)", + "Watch6,2" : "Apple Watch Series 6 44mm case (GPS)", + "Watch6,3" : "Apple Watch Series 6 40mm case (GPS+Cellular)", + "Watch6,4" : "Apple Watch Series 6 44mm case (GPS+Cellular)", + "Watch6,6" : "Apple Watch Series 7 41mm case (GPS)", + "Watch6,7" : "Apple Watch Series 7 45mm case (GPS)", + "Watch6,8" : "Apple Watch Series 7 41mm case (GPS+Cellular)", + "Watch6,9" : "Apple Watch Series 7 45mm case (GPS+Cellular)", + "Watch6,10" : "Apple Watch SE 40mm case (GPS)", + "Watch6,11" : "Apple Watch SE 44mm case (GPS)", + "Watch6,12" : "Apple Watch SE 40mm case (GPS+Cellular)", + "Watch6,13" : "Apple Watch SE 44mm case (GPS+Cellular)", + "Watch6,14" : "Apple Watch Series 8 41mm case (GPS)", + "Watch6,15" : "Apple Watch Series 8 45mm case (GPS)", + "Watch6,16" : "Apple Watch Series 8 41mm case (GPS+Cellular)", + "Watch6,17" : "Apple Watch Series 8 45mm case (GPS+Cellular)", + "Watch6,18" : "Apple Watch Ultra", + "Watch7,1" : "Apple Watch Series 9 41mm case (GPS)", + "Watch7,2" : "Apple Watch Series 9 45mm case (GPS)", + "Watch7,3" : "Apple Watch Series 9 41mm case (GPS+Cellular)", + "Watch7,4" : "Apple Watch Series 9 45mm case (GPS+Cellular)", + "Watch7,5" : "Apple Watch Ultra 2", +] diff --git a/HealthImport/Model/Tables/ObjectsTable.swift b/HealthImport/Model/Tables/ObjectsTable.swift new file mode 100644 index 0000000..9430221 --- /dev/null +++ b/HealthImport/Model/Tables/ObjectsTable.swift @@ -0,0 +1,37 @@ +import Foundation +import SQLite + +struct ObjectsTable { + + private let database: Connection + + init(database: Connection) { + self.database = database + } + + func create() throws { + try database.execute("CREATE TABLE objects (data_id INTEGER PRIMARY KEY AUTOINCREMENT, uuid BLOB UNIQUE, provenance INTEGER NOT NULL REFERENCES data_provenances (ROWID) ON DELETE CASCADE, type INTEGER, creation_date REAL)") + } + + let table = Table("objects") + + let dataId = Expression("data_id") + + let uuid = Expression("uuid") + + let provenance = Expression("provenance") + + let type = Expression("type") + + let creationDate = Expression("creation_date") + + func object(for dataId: Int) throws -> (uuid: UUID, provenance: Int, type: Int, creationDate: Date)? { + try database.pluck(table.filter(self.dataId == dataId)).map { row in + let uuid = row[uuid]!.asUUID()! + let provenance = row[provenance] + let type = row[type]! + let creationDate = Date(timeIntervalSinceReferenceDate: row[creationDate]!) + return (uuid, provenance, type, creationDate) + } + } +} diff --git a/HealthImport/Model/Tables/QuantitySamplesTable.swift b/HealthImport/Model/Tables/QuantitySamplesTable.swift new file mode 100644 index 0000000..d9def06 --- /dev/null +++ b/HealthImport/Model/Tables/QuantitySamplesTable.swift @@ -0,0 +1,32 @@ +import Foundation +import SQLite + +struct QuantitySamplesTable { + + private let database: Connection + + init(database: Connection) { + self.database = database + } + + func create() throws { + try database.execute("CREATE TABLE quantity_samples (data_id INTEGER PRIMARY KEY, quantity REAL, original_quantity REAL, original_unit INTEGER REFERENCES unit_strings (ROWID) ON DELETE NO ACTION)") + } + + let table = Table("quantity_samples") + + let dataId = Expression("data_id") + + let quantity = Expression("quantity") + + let originalQuantity = Expression("original_quantity") + + /// References `ROWID` on table `unit_strings` + let originalUnit = Expression("original_unit") + + func quantity(for id: Int, in database: Database) throws -> (quantity: Double?, original: Double?, unit: Int?) { + try database.prepare(table.filter(dataId == id).limit(1)).map { + (quantity: $0[quantity], original: $0[originalQuantity], unit: $0[originalUnit]) + }.first ?? (nil, nil, nil) + } +} diff --git a/HealthImport/Model/Tables/SamplesTable.swift b/HealthImport/Model/Tables/SamplesTable.swift new file mode 100644 index 0000000..1cc6311 --- /dev/null +++ b/HealthImport/Model/Tables/SamplesTable.swift @@ -0,0 +1,88 @@ +import Foundation +import SQLite + +struct SamplesTable { + + private let database: Connection + + private let quantitySamples: QuantitySamplesTable + + private let objects: ObjectsTable + + private let dataProvenances: DataProvenancesTable + + private let unitStrings: UnitStringsTable + + init(database: Connection) { + self.database = database + self.quantitySamples = .init(database: database) + self.objects = .init(database: database) + self.dataProvenances = .init(database: database) + self.unitStrings = .init(database: database) + } + + func create() throws { + try database.execute("CREATE TABLE samples (data_id INTEGER PRIMARY KEY, start_date REAL, end_date REAL, data_type INTEGER)") + } + + private let table = Table("samples") + + private let dataId = Expression("data_id") + + // NOTE: Technically optional + private let startDate = Expression("start_date") + + // NOTE: Technically optional + private let endDate = Expression("end_date") + + private let dataType = Expression("data_type") + + func samples(from start: Date, to end: Date) throws -> [Sample] { + let start = start.timeIntervalSinceReferenceDate + let end = end.timeIntervalSinceReferenceDate + + // Samples: data_id, start_date, end_date, data_type + // JOIN quantity_samples on samples.data_id == quantity_samples.data_id + // quantity_samples: quantity, original_quantity, original_unit + // JOIN objects on samples.data_id == objects.data_id + // objects: data_id, uuid, provenance, type, creation_date + + // JOIN data_provenances on objects.provenance == data_provenances.ROWID + // SELECT tz_name FROM data_provenances + + let selection = table + .select(table[*], + quantitySamples.table[*], + dataProvenances.table[dataProvenances.tzName], + unitStrings.table[unitStrings.unitString]) + .filter(startDate >= start && endDate <= end) + .join(.leftOuter, quantitySamples.table, on: table[dataId] == quantitySamples.table[quantitySamples.dataId]) + .join(.leftOuter, objects.table, on: table[dataId] == objects.table[objects.dataId]) + .join(.leftOuter, dataProvenances.table, on: objects.table[objects.provenance] == dataProvenances.table[dataProvenances.rowId]) + .join(.leftOuter, unitStrings.table, on: quantitySamples.table[quantitySamples.originalUnit] == unitStrings.table[unitStrings.rowId]) + + return try database.prepare(selection).map { row in + let startDate = Date(timeIntervalSinceReferenceDate: row[startDate]) + let endDate = Date(timeIntervalSinceReferenceDate: row[endDate]) + let dataType = Sample.DataType(rawValue: row[dataType]) + let quantity = row[quantitySamples.quantity] + let original = row[quantitySamples.originalQuantity] + let unit = row[unitStrings.unitString] + let timeZone = row[dataProvenances.tzName].nonEmpty + return .init( + startDate: startDate, + endDate: endDate, + dataType: dataType, + quantity: quantity, + originalQuantity: original, + originalUnit: unit, + timeZoneName: timeZone) + } + } + + func sampleCount(from start: Date, to end: Date) throws -> Int { + let start = start.timeIntervalSinceReferenceDate + let end = end.timeIntervalSinceReferenceDate + return try database.scalar(table.filter(startDate >= start && endDate <= end).count) + } +} diff --git a/HealthImport/Model/Tables/UnitStringsTable.swift b/HealthImport/Model/Tables/UnitStringsTable.swift new file mode 100644 index 0000000..c2ada22 --- /dev/null +++ b/HealthImport/Model/Tables/UnitStringsTable.swift @@ -0,0 +1,27 @@ +import Foundation +import SQLite + +struct UnitStringsTable { + + private let database: Connection + + init(database: Connection) { + self.database = database + } + + func create() throws { + try database.execute("CREATE TABLE unit_strings (ROWID INTEGER PRIMARY KEY AUTOINCREMENT, unit_string TEXT UNIQUE)") + } + + let table = Table("unit_strings") + + let rowId = Expression("ROWID") + + let unitString = Expression("unit_string") + + func unit(for id: Int) throws -> String? { + try database.pluck(table.filter(rowId == id).limit(1)).map { row in + row[unitString] + } + } +} diff --git a/HealthImport/Model/WorkoutActivity+SQLite.swift b/HealthImport/Model/WorkoutActivity+SQLite.swift index 7547ec3..ef913c0 100644 --- a/HealthImport/Model/WorkoutActivity+SQLite.swift +++ b/HealthImport/Model/WorkoutActivity+SQLite.swift @@ -51,7 +51,10 @@ enum WorkoutActivityTable { let start = Date(timeIntervalSinceReferenceDate: row[columnStartDate]) let end = Date(timeIntervalSinceReferenceDate: row[columnEndDate]) let uuid = row[columnUUID].uuidString - let metadata: [String : Any] = [HKMetadataKeyExternalUUID : uuid] + + var metadata: [String : Any] = [ : ] + metadata[HKMetadataKeyExternalUUID] = uuid + // duration: row[columnDuration] // isPrimaryActivity: row[columnIsPrimaryActivity] @@ -123,19 +126,3 @@ private extension WorkoutActivityTable { try NSKeyedUnarchiver.unarchivedObject(ofClass: HKQuantity.self, from: data) } } - -private extension Data { - - var uuidString: String { - let h = Array(self) - let parts = [h[0..<4], h[4..<6], h[6..<8], h[8..<10], h[10..<16]] - return parts.map { Data($0).hex }.joined(separator: "-") - } -} - -private extension UUID { - - init?(data: Data) { - self.init(uuidString: data.uuidString) - } -} diff --git a/HealthImport/Support/Data+Extensions.swift b/HealthImport/Support/Data+Extensions.swift index 2bb060b..696243a 100644 --- a/HealthImport/Support/Data+Extensions.swift +++ b/HealthImport/Support/Data+Extensions.swift @@ -41,6 +41,37 @@ extension Data { } } +extension Data { + + func asUUID() -> UUID? { + .init(data: self) + } + + var uuidString: String? { + guard self.count == 16 else { + return nil + } + let h = Array(self) + let parts = [h[0..<4], h[4..<6], h[6..<8], h[8..<10], h[10..<16]] + return parts.map { Data($0).hex }.joined(separator: "-") + } +} + +extension UUID { + + init?(data: Data) { + guard let uuidString = data.uuidString else { + return nil + } + self.init(uuidString: uuidString) + } + + var data: Data? { + .init(hex: uuidString.replacingOccurrences(of: "-", with: "")) + } +} + + extension Data { diff --git a/HealthImport/Support/Date+Extensions.swift b/HealthImport/Support/Date+Extensions.swift index fcd6aad..6674114 100644 --- a/HealthImport/Support/Date+Extensions.swift +++ b/HealthImport/Support/Date+Extensions.swift @@ -65,7 +65,13 @@ extension Date { } var timeAndDateText: String { - dateFormatter.string(from: self) + dateFormatter.timeZone = .current + return dateFormatter.string(from: self) + } + + func timeAndDateText(in timeZone: TimeZone) -> String { + dateFormatter.timeZone = timeZone + return dateFormatter.string(from: self) } var timeAndDateWithSecondsText: String { diff --git a/HealthImport/Support/String+Extensions.swift b/HealthImport/Support/String+Extensions.swift new file mode 100644 index 0000000..876f862 --- /dev/null +++ b/HealthImport/Support/String+Extensions.swift @@ -0,0 +1,8 @@ +import Foundation + +extension String { + + var nonEmpty: String? { + self != "" ? self : nil + } +}