Read workouts, events, activities

This commit is contained in:
Christoph Hagen
2024-01-21 10:31:47 +01:00
parent b75e0afe4a
commit 8ace8e9319
22 changed files with 1177 additions and 13 deletions

View File

@@ -0,0 +1,39 @@
import SwiftUI
struct ActivityDetailView: View {
let activity: WorkoutActivity
var body: some View {
List {
DetailRow("UUID", value: activity.uuid)
DetailRow("Primary Activity", value: activity.isPrimaryActivity)
DetailRow("Activity", value: activity.activityType)
DetailRow("Location", value: activity.locationType)
DetailRow("Swimming Location", value: activity.swimmingLocationType)
DetailRow("Lap Length", value: activity.lapLength)
DetailRow("Start", date: activity.startDate)
DetailRow("End", date: activity.endDate)
DetailRow("Duration", duration: activity.duration)
DetailRow("Metadata", value: activity.metadata)
}
.navigationTitle("Activity")
}
}
#Preview {
NavigationStack {
ActivityDetailView(activity: .init(
uuid: .init(repeating: 42, count: 3),
isPrimaryActivity: true,
activityType: .running,
locationType: .outdoor,
swimmingLocationType: .unknown,
lapLength: .init(repeating: 42, count: 3),
startDate: .now.addingTimeInterval(-100),
endDate: .now,
duration: 100.0,
metadata: .init(repeating: 42, count: 3))
)
}
}

View File

@@ -1,21 +1,38 @@
//
// ContentView.swift
// HealthImport
//
// Created by iMac on 20.01.24.
//
import SwiftUI
import HealthKit
struct ContentView: View {
static let databaseFileUrl = Bundle.main.url(forResource: "healthdb_secure", withExtension: "sqlite")
@StateObject var database: HealthDatabase = {
try! .init(fileUrl: databaseFileUrl!)
}()
@State var navigationPath: NavigationPath = .init()
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
NavigationStack(path: $navigationPath) {
VStack {
List {
ForEach(database.workouts) { workout in
NavigationLink(value: workout) {
VStack(alignment: .leading) {
Text(workout.typeString)
Text(workout.dateString)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
.navigationTitle("Workouts")
.navigationDestination(for: Workout.self) {
WorkoutDetailView(workout: $0)
.environmentObject(database)
}
}
.padding()
}
}

View File

@@ -0,0 +1,45 @@
import Foundation
import SQLite
struct DBWorkout {
private static let table = Table("workouts")
private static let rowDataId = Expression<Int>("data_id")
private static let rowTotalDistance = Expression<Double?>("total_distance")
private static let rowGoalType = Expression<Int?>("goal_type")
private static let rowGoal = Expression<Double?>("goal")
private static let rowCondenserVersion = Expression<Int?>("condenser_version")
private static let rowCondenserDate = Expression<Double?>("condenser_date")
static func readAll(in database: Connection) throws -> [Self] {
try database.prepare(table).map(Self.init(row:))
}
let dataId: Int
let totalDistance: Double?
let goalType: Int?
let goal: Double?
let condenserVersion: Int?
let condenserDate: Double?
init(row: Row) {
self.dataId = row[DBWorkout.rowDataId]
self.totalDistance = row[DBWorkout.rowTotalDistance]
self.goalType = row[DBWorkout.rowGoalType]
self.goal = row[DBWorkout.rowGoal]
self.condenserVersion = row[DBWorkout.rowCondenserVersion]
self.condenserDate = row[DBWorkout.rowCondenserDate]
}
}

View File

@@ -0,0 +1,79 @@
import Foundation
import SQLite
struct DBWorkoutActivity {
private static let table = Table("workout_activities")
private static let rowId = Expression<Int>("ROWID")
private static let rowUUID = Expression<Data>("uuid")
private static let rowOwnerId = Expression<Int>("owner_id")
private static let rowIsPrimaryActivity = Expression<Bool>("is_primary_activity")
private static let rowActivityType = Expression<Int>("activity_type")
private static let rowLocationType = Expression<Int>("location_type")
private static let rowSwimmingLocationType = Expression<Int>("swimming_location_type")
private static let rowLapLength = Expression<Data?>("lap_length")
private static let rowStartDate = Expression<Double>("start_date")
private static let rowEndDate = Expression<Double>("end_date")
private static let rowDuration = Expression<Double>("duration")
private static let rowMetadata = Expression<Data?>("metadata")
static func readAll(in database: Connection) throws -> [Self] {
try database.prepare(table).map(Self.init)
}
static func activities(for workoutId: Int, in database: Connection) throws -> [Self] {
try database.prepare(table.filter(rowOwnerId == workoutId)).map(Self.init)
}
let id: Int
let uuid: Data
let ownerId: Int
let isPrimaryActivity: Bool
let activityType: Int
let locationType: Int
let swimmingLocationType: Int
let lapLength: Data?
#warning("Fix timezone for dates")
let startDate: Double
let endDate: Double
let duration: Double
let metadata: Data?
init(row: Row) {
self.id = row[DBWorkoutActivity.rowId]
self.uuid = row[DBWorkoutActivity.rowUUID]
self.ownerId = row[DBWorkoutActivity.rowOwnerId]
self.isPrimaryActivity = row[DBWorkoutActivity.rowIsPrimaryActivity]
self.activityType = row[DBWorkoutActivity.rowActivityType]
self.locationType = row[DBWorkoutActivity.rowLocationType]
self.swimmingLocationType = row[DBWorkoutActivity.rowSwimmingLocationType]
self.lapLength = row[DBWorkoutActivity.rowLapLength]
self.startDate = row[DBWorkoutActivity.rowStartDate]
self.endDate = row[DBWorkoutActivity.rowEndDate]
self.duration = row[DBWorkoutActivity.rowDuration]
self.metadata = row[DBWorkoutActivity.rowMetadata]
}
}

View File

@@ -0,0 +1,53 @@
import Foundation
import SQLite
struct DBWorkoutEvent {
private static let table = Table("workout_events")
private static let rowOwnerId = Expression<Int>("owner_id")
private static let rowDate = Expression<Double>("date")
private static let rowType = Expression<Int>("type")
private static let rowDuration = Expression<Double>("duration")
private static let rowMetadata = Expression<Data?>("metadata")
private static let rowSessionUUID = Expression<Data?>("session_uuid")
private static let rowError = Expression<Data?>("error")
static func readAll(in database: Connection) throws -> [Self] {
try database.prepare(table).map(Self.init)
}
static func events(for workoutId: Int, in database: Connection) throws -> [Self] {
try database.prepare(table.filter(rowOwnerId == workoutId)).map(Self.init)
}
let ownerId: Int
let date: Double
let type: Int
let duration: Double
let metadata: Data?
let sessionUUID: Data?
let error: Data?
init(row: Row) {
self.ownerId = row[DBWorkoutEvent.rowOwnerId]
self.date = row[DBWorkoutEvent.rowDate]
self.type = row[DBWorkoutEvent.rowType]
self.duration = row[DBWorkoutEvent.rowDuration]
self.metadata = row[DBWorkoutEvent.rowMetadata]
self.sessionUUID = row[DBWorkoutEvent.rowSessionUUID]
self.error = row[DBWorkoutEvent.rowError]
}
}

View File

@@ -0,0 +1,54 @@
import SwiftUI
struct DetailRow: View {
let title: String
let value: String
init(_ title: String, value: String) {
self.title = title
self.value = value
}
init(_ title: String, value: CustomStringConvertible?) {
self.title = title
self.value = value?.description ?? "-"
}
init(_ title: String, date value: Date?) {
self.title = title
self.value = value?.timeAndDateText ?? "-"
}
init(_ title: String, kilometer: Double?) {
self.title = title
self.value = kilometer?.distanceAsKilometer ?? "-"
}
init(_ title: String, duration: TimeInterval?) {
self.title = title
self.value = duration?.durationString ?? "-"
}
var body: some View {
HStack {
Text(title)
.foregroundStyle(.secondary)
Spacer()
Text(value)
.foregroundStyle(.primary)
}
}
}
#Preview {
List {
DetailRow("Title", value: "Some")
DetailRow("Convertible", value: 123)
DetailRow("Optional", value: nil)
DetailRow("Date", date: .now)
DetailRow("Distance", kilometer: 123.4)
DetailRow("Duration", duration: 678.9)
}
}

View File

@@ -0,0 +1,31 @@
import SwiftUI
struct EventDetailView: View {
let event: WorkoutEvent
var body: some View {
List {
DetailRow("Date", date: event.date)
DetailRow("Type", value: event.type)
DetailRow("Duration", duration: event.duration)
DetailRow("Metadata", value: event.metadata)
DetailRow("Session UUID", value: event.sessionUUID)
DetailRow("Error", value: event.error)
}
.navigationTitle("Event")
}
}
#Preview {
NavigationStack {
EventDetailView(event: .init(
date: .now,
type: .pause,
duration: 12.3,
metadata: .init(repeating: 42, count: 2),
sessionUUID: .init(repeating: 42, count: 3),
error: nil)
)
}
}

View File

@@ -0,0 +1,37 @@
import Foundation
import SQLite
final class HealthDatabase: ObservableObject {
let fileUrl: URL
let database: Connection
@Published
var workouts: [Workout] = []
init(fileUrl: URL) throws {
self.fileUrl = fileUrl
self.database = try Connection(fileUrl.path)
DispatchQueue.global().async {
self.readAllWorkouts()
}
}
func readAllWorkouts() {
do {
let dbWorkouts = try DBWorkout.readAll(in: database)
let workouts = try dbWorkouts.map { entry in
let events = try DBWorkoutEvent.events(for: entry.dataId, in: database)
let activities = try DBWorkoutActivity.activities(for: entry.dataId, in: database)
return Workout(entry: entry, events: events, activities: activities)
}
DispatchQueue.main.async {
self.workouts = workouts
}
} catch {
print("Failed to read workouts: \(error)")
}
}
}

View File

@@ -0,0 +1,93 @@
import Foundation
private let df: DateFormatter = {
let df = DateFormatter()
df.timeZone = .current
df.dateStyle = .short
df.timeStyle = .short
return df
}()
struct Workout {
let id: Int
/// The distance in km (?)
let totalDistance: Double?
let goalType: Int?
let goal: Double?
let condenserVersion: Int?
let condenserDate: Date?
let events: [WorkoutEvent]
let activities: [WorkoutActivity]
var firstActivityDate: Date? {
activities.map { $0.startDate }.min()
}
var firstEventDate: Date? {
events.map { $0.date }.min()
}
var firstAvailableDate: Date? {
[condenserDate, firstEventDate, firstActivityDate].compactMap { $0 }.min()
}
var dateString: String {
guard let firstAvailableDate else {
return "No date"
}
return df.string(from: firstAvailableDate)
}
var typeString: String {
activities.first?.activityType.description ?? "Unknown activity"
}
init(id: Int, totalDistance: Double? = nil, goalType: Int? = nil, goal: Double? = nil, condenserVersion: Int? = nil, condenserDate: Date? = nil, events: [WorkoutEvent] = [], activities: [WorkoutActivity] = []) {
self.id = id
self.totalDistance = totalDistance
self.goalType = goalType
self.goal = goal
self.condenserVersion = condenserVersion
self.condenserDate = condenserDate
self.events = events
self.activities = activities
}
}
extension Workout {
init(entry: DBWorkout, events: [DBWorkoutEvent], activities: [DBWorkoutActivity]) {
self.id = entry.dataId
self.totalDistance = entry.totalDistance
self.goalType = entry.goalType
self.goal = entry.goal
self.condenserVersion = entry.condenserVersion
self.condenserDate = entry.condenserDate.map { Date(timeIntervalSinceReferenceDate: $0) }
self.events = events.map(WorkoutEvent.init)
self.activities = activities.map(WorkoutActivity.init)
}
}
extension Workout: Identifiable { }
extension Workout: Equatable {
static func == (lhs: Workout, rhs: Workout) -> Bool {
lhs.id == rhs.id
}
}
extension Workout: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

View File

@@ -0,0 +1,62 @@
import Foundation
import HealthKit
struct WorkoutActivity {
let uuid: Data
let isPrimaryActivity: Bool
let activityType: HKWorkoutActivityType
let locationType: HKWorkoutSessionLocationType
let swimmingLocationType: HKWorkoutSwimmingLocationType
let lapLength: Data?
let startDate: Date
let endDate: Date
let duration: TimeInterval
let metadata: Data?
}
extension WorkoutActivity {
init(entry: DBWorkoutActivity) {
self.uuid = entry.uuid
self.isPrimaryActivity = entry.isPrimaryActivity
self.activityType = .init(rawValue: UInt(entry.activityType))!
self.locationType = .init(rawValue: entry.locationType)!
self.swimmingLocationType = .init(rawValue: entry.swimmingLocationType)!
self.lapLength = entry.lapLength
self.startDate = Date(timeIntervalSinceReferenceDate: entry.startDate)
self.endDate = Date(timeIntervalSinceReferenceDate: entry.endDate)
self.duration = entry.duration
self.metadata = entry.metadata
}
}
extension WorkoutActivity: Equatable {
static func == (lhs: WorkoutActivity, rhs: WorkoutActivity) -> Bool {
lhs.uuid == rhs.uuid
}
}
extension WorkoutActivity: Comparable {
static func < (lhs: WorkoutActivity, rhs: WorkoutActivity) -> Bool {
lhs.startDate < rhs.startDate
}
}
extension WorkoutActivity: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(uuid)
}
}

View File

@@ -0,0 +1,51 @@
import Foundation
import HealthKit
struct WorkoutEvent {
let date: Date
let type: HKWorkoutEventType
let duration: TimeInterval
let metadata: Data?
let sessionUUID: Data?
let error: Data?
}
extension WorkoutEvent {
init(entry: DBWorkoutEvent) {
self.date = Date(timeIntervalSinceReferenceDate: entry.date)
self.type = .init(rawValue: entry.type)!
self.duration = entry.duration
self.metadata = entry.metadata
self.sessionUUID = entry.sessionUUID
self.error = entry.error
}
}
extension WorkoutEvent: Equatable {
static func == (lhs: WorkoutEvent, rhs: WorkoutEvent) -> Bool {
lhs.date == rhs.date
}
}
extension WorkoutEvent: Comparable {
static func < (lhs: WorkoutEvent, rhs: WorkoutEvent) -> Bool {
lhs.date < rhs.date
}
}
extension WorkoutEvent: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(date)
}
}

View File

@@ -0,0 +1,63 @@
import Foundation
private let dateFormatter: DateFormatter = {
let df = DateFormatter()
df.dateStyle = .short
df.timeStyle = .short
return df
}()
private let justDateFormatter: DateFormatter = {
let df = DateFormatter()
df.dateStyle = .short
df.timeStyle = .none
return df
}()
private let timeFormatter: DateFormatter = {
let df = DateFormatter()
df.dateStyle = .none
df.timeStyle = .short
return df
}()
extension Date {
var durationSinceNowText: String {
let secondsAgo = -timeIntervalSinceNow
guard secondsAgo > 2.5 else {
return "Now"
}
guard secondsAgo > 60 else {
let multiples = Int((secondsAgo / 5).rounded()) * 5
return "\(multiples) seconds ago"
}
let minutesAgo = Int((secondsAgo / 60).rounded())
guard minutesAgo > 1 else {
return "\(minutesAgo) minute ago"
}
guard minutesAgo > 60 else {
return "\(minutesAgo) minutes ago"
}
let hoursAgo = Int(Double(minutesAgo / 60).rounded())
guard hoursAgo > 1 else {
return "\(hoursAgo) hour ago"
}
guard hoursAgo > 24 else {
return "\(hoursAgo) hours ago"
}
return dateFormatter.string(from: self)
}
var timeOrDateText: String {
if Calendar.current.isDateInToday(self) {
return timeFormatter.string(from: self)
}
return justDateFormatter.string(from: self)
}
var timeAndDateText: String {
dateFormatter.string(from: self)
}
}

View File

@@ -0,0 +1,187 @@
import Foundation
import HealthKit
extension HKWorkoutActivityType: CustomStringConvertible {
public var description: String {
switch self {
case .climbing:
return "Climbing"
case .cycling:
return "Cycling"
case .hiking:
return "Hiking"
case .hockey:
return "Hockey"
case .other:
return "Other"
case .rowing:
return "Rowing"
case .running:
return "Running"
case .swimming:
return "Swimming"
case .yoga:
return "Yoga"
case .walking:
return "Walking"
case .americanFootball:
return "American Football"
case .archery:
return "Archery"
case .australianFootball:
return "Australian Football"
case .badminton:
return "Badminton"
case .baseball:
return "Baseball"
case .basketball:
return "Basketball"
case .bowling:
return "Bowling"
case .boxing:
return "Boxing"
case .cricket:
return "Cricket"
case .crossTraining:
return "Cross Training"
case .curling:
return "Curling"
case .dance:
return "Dance"
case .danceInspiredTraining:
return "Dance Inspired Training"
case .elliptical:
return "Elliptical"
case .equestrianSports:
return "Equestrian Sports"
case .fencing:
return "Fencing"
case .fishing:
return "Fishing"
case .functionalStrengthTraining:
return "Functional Strength Training"
case .golf:
return "Golf"
case .gymnastics:
return "Gymnastics"
case .handball:
return "Handball"
case .hunting:
return "Hunting"
case .lacrosse:
return "Lacrosse"
case .martialArts:
return "Martial Arts"
case .mindAndBody:
return "Mind And Body"
case .mixedMetabolicCardioTraining:
return "Mixed Metabolic Cardio Training"
case .paddleSports:
return "Paddle Sports"
case .play:
return "Play"
case .preparationAndRecovery:
return "Preparation and Recovery"
case .racquetball:
return "Racquetball"
case .rugby:
return "Rugby"
case .sailing:
return "Sailing"
case .skatingSports:
return "Skating Sports"
case .snowSports:
return "Snow Sports"
case .soccer:
return "Soccer"
case .softball:
return "Softball"
case .squash:
return "Squash"
case .stairClimbing:
return "Stair Climbing"
case .surfingSports:
return "Surfing Sports"
case .tableTennis:
return "Table Tennis"
case .tennis:
return "Tennis"
case .trackAndField:
return "Track And Field"
case .traditionalStrengthTraining:
return "Traditional Strength Training"
case .volleyball:
return "Volleyball"
case .waterFitness:
return "Water Fitness"
case .waterPolo:
return "Water Polo"
case .waterSports:
return "Water Sports"
case .wrestling:
return "Wrestling"
case .barre:
return "Barre"
case .coreTraining:
return "Core Training"
case .crossCountrySkiing:
return "Cross Country Skiing"
case .downhillSkiing:
return "Downholl Skiing"
case .flexibility:
return "Flexibility"
case .highIntensityIntervalTraining:
return "High Intensity Interval Training"
case .jumpRope:
return "Jump Rope"
case .kickboxing:
return "Kickboxing"
case .pilates:
return "Pilates"
case .snowboarding:
return "Snowboarding"
case .stairs:
return "Stairs"
case .stepTraining:
return "Step Training"
case .wheelchairWalkPace:
return "Wheelchair Walk Pace"
case .wheelchairRunPace:
return "Wheelchair Run Pace"
case .taiChi:
return "Tai Chi"
case .mixedCardio:
return "Mixed Cardio"
case .handCycling:
return "Hand Cycling"
case .discSports:
return "Disc Sports"
case .fitnessGaming:
return "Fitness Gaming"
case .cardioDance:
return "Cardio Dance"
case .socialDance:
return "Social Dance"
case .pickleball:
return "Pickleball"
case .cooldown:
return "Cooldown"
case .swimBikeRun:
return "Triathlon"
case .transition:
return "Transition"
case .underwaterDiving:
return "Underwater Diving"
@unknown default:
return "\(rawValue)"
}
}
}
extension HKWorkoutActivityType: Comparable {
public static func < (lhs: HKWorkoutActivityType, rhs: HKWorkoutActivityType) -> Bool {
lhs.rawValue < rhs.rawValue
}
}

View File

@@ -0,0 +1,20 @@
import Foundation
import HealthKit
extension HKWorkoutEventType: CustomStringConvertible {
public var description: String {
switch self {
case .pause: return "Pause"
case .resume: return "Resume"
case .lap: return "Lap"
case .marker: return "Marker"
case .motionPaused: return "Motion Paused"
case .motionResumed: return "Motino Resumed"
case .segment: return "Segment"
case .pauseOrResumeRequest: return "Pause or Resume Request"
@unknown default:
return "Unknown"
}
}
}

View File

@@ -0,0 +1,18 @@
import Foundation
import HealthKit
extension HKWorkoutSessionLocationType: CustomStringConvertible {
public var description: String {
switch self {
case .unknown:
return "Unknown"
case .indoor:
return "Indoor"
case .outdoor:
return "Outdoor"
@unknown default:
return "Unknown default"
}
}
}

View File

@@ -0,0 +1,18 @@
import Foundation
import HealthKit
extension HKWorkoutSwimmingLocationType: CustomStringConvertible {
public var description: String {
switch self {
case .unknown:
return "Unknown"
case .pool:
return "Pool"
case .openWater:
return "Open Water"
@unknown default:
return "Unknown default"
}
}
}

View File

@@ -0,0 +1,11 @@
import Foundation
extension Optional {
func map<T>(_ transform: (Wrapped) -> T) -> T? {
guard let self else {
return nil
}
return transform(self)
}
}

View File

@@ -0,0 +1,33 @@
import Foundation
extension Double {
var roundedInt: Int {
Int(rounded())
}
var distanceAsKilometer: String {
guard self < 0.1 else {
return String(format: "%.2f km", self)
}
return String(format: "%.1f m", self / 1000)
}
}
extension TimeInterval {
var durationString: String {
let totalSeconds = roundedInt
guard totalSeconds >= 60 else {
return String(format: "%.3f s", self)
}
let seconds = totalSeconds % 60
let totalMinutes = totalSeconds / 60
guard totalMinutes >= 60 else {
return String(format: "%d:%02d", totalMinutes, seconds)
}
let minutes = totalMinutes % 60
let totalHours = totalMinutes / 60
return String(format: "%d:%02d:%02d", totalHours, minutes, seconds)
}
}

View File

@@ -0,0 +1,83 @@
import SwiftUI
struct WorkoutDetailView: View {
@EnvironmentObject
var database: HealthDatabase
let workout: Workout
var body: some View {
List {
Section("Info") {
DetailRow("ID", value: workout.id)
DetailRow("Total Distance", kilometer: workout.totalDistance)
DetailRow("Goal Type", value: workout.goalType)
DetailRow("Goal", value: workout.goal)
DetailRow("Condenser Version", value: workout.condenserVersion)
DetailRow("Condenser Date", date: workout.condenserDate)
}
if !workout.events.isEmpty {
Section("Events") {
ForEach(workout.events, id: \.date) { event in
NavigationLink(value: event) {
DetailRow(event.type.description, date: event.date)
}
}
}
}
if !workout.activities.isEmpty {
Section("Activities") {
ForEach(workout.activities, id: \.startDate) { activity in
NavigationLink(value: activity) {
DetailRow(activity.activityType.description,
date: activity.startDate)
}
}
}
}
}
.navigationTitle(workout.typeString)
.navigationDestination(for: WorkoutActivity.self) { activity in
ActivityDetailView(activity: activity)
}
.navigationDestination(for: WorkoutEvent.self) { event in
EventDetailView(event: event)
}
}
}
#Preview {
NavigationStack {
WorkoutDetailView(workout: .init(
id: 123,
totalDistance: 234.5,
goalType: 3,
goal: 345.6,
condenserVersion: 1,
condenserDate: .now,
events: [
.init(
date: .now,
type: .pause,
duration: 12.3,
metadata: .init(repeating: 42, count: 2),
sessionUUID: .init(repeating: 42, count: 3),
error: nil)
],
activities: [
.init(
uuid: .init(repeating: 42, count: 3),
isPrimaryActivity: true,
activityType: .running,
locationType: .outdoor,
swimmingLocationType: .unknown,
lapLength: .init(repeating: 42, count: 3),
startDate: .now.addingTimeInterval(-100),
endDate: .now,
duration: 100.0,
metadata: .init(repeating: 42, count: 3))
]))
}
}