diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt index 70690126a..31abb444f 100644 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt +++ b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt @@ -792,6 +792,38 @@ class HealthPlugin(private var channel: MethodChannel? = null) : totalSteps += stepRec.count } + + val routeLocations = mutableListOf>() + + + Log.d("FLUTTER_HEALTH", "record.exerciseRouteResult: ${record.exerciseRouteResult}") + when (val exerciseRouteResult = record.exerciseRouteResult) { + + is ExerciseRouteResult.Data -> + { + val locations = exerciseRouteResult.exerciseRoute.route.orEmpty() + for (location in locations) { + val locationMap = mapOf( + "latitude" to location.latitude as Any, + "longitude" to location.longitude as Any, + "altitude" to location.altitude as Any, + "timestamp" to location.time.toEpochMilli() as Any + ) + routeLocations.add(locationMap) + } + } + is ExerciseRouteResult.ConsentRequired -> { + Log.d("FLUTTER_HEALTH", "Consent required") + } + is ExerciseRouteResult.NoData -> { + Log.d("FLUTTER_HEALTH", "No data") + } + else -> Unit + } + + + + // val metadata = (rec as Record).metadata // Add final datapoint healthConnectData.add( @@ -845,6 +877,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : record.metadata .dataOrigin .packageName, + "routeLocations" to routeLocations, ), ) } diff --git a/packages/health/example/ios/Runner.xcodeproj/project.pbxproj b/packages/health/example/ios/Runner.xcodeproj/project.pbxproj index 4ce55d34f..1ded13e8e 100644 --- a/packages/health/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/health/example/ios/Runner.xcodeproj/project.pbxproj @@ -176,7 +176,6 @@ TargetAttributes = { 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; - DevelopmentTeam = 59TCTNUBMQ; LastSwiftMigration = 0910; ProvisioningStyle = Automatic; SystemCapabilities = { @@ -386,7 +385,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 59TCTNUBMQ; + DEVELOPMENT_TEAM = V9MZ5W578T; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -394,12 +393,15 @@ ); INFOPLIST_FILE = Runner/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = dk.cachet.example; + PRODUCT_BUNDLE_IDENTIFIER = "dk.cachet.example-benlrichards"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -531,7 +533,10 @@ ); INFOPLIST_FILE = Runner/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -564,7 +569,10 @@ ); INFOPLIST_FILE = Runner/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", diff --git a/packages/health/example/ios/Runner/Info.plist b/packages/health/example/ios/Runner/Info.plist index b4af43739..3ed804518 100644 --- a/packages/health/example/ios/Runner/Info.plist +++ b/packages/health/example/ios/Runner/Info.plist @@ -28,6 +28,8 @@ We will sync your data with the Apple Health app to give you better insights NSHealthUpdateUsageDescription We will sync your data with the Apple Health app to give you better insights + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -47,7 +49,5 @@ UIViewControllerBasedStatusBarAppearance - UIApplicationSupportsIndirectInputEvents - diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index d7af83e05..f6a1f5506 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -1,6 +1,7 @@ import Flutter import HealthKit import UIKit +import CoreLocation enum RecordingMethod: Int { case unknown = 0 // RECORDING_METHOD_UNKNOWN (not supported on iOS) @@ -922,8 +923,13 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { case let (samplesWorkout as [HKWorkout]) as Any: - let dictionaries = samplesWorkout.map { sample -> NSDictionary in - return [ + var dictionaries = [[String: Any]]() + let dispatchGroup = DispatchGroup() + + for sample in samplesWorkout { + dispatchGroup.enter() + print("sample.workoutActivityType, \(sample.workoutActivityType)") + var workoutDict: [String: Any] = [ "uuid": "\(sample.uuid)", "workoutActivityType": workoutActivityTypeMap.first(where: { $0.value == sample.workoutActivityType @@ -941,9 +947,65 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { "total_distance": sample.totalDistance != nil ? Int(sample.totalDistance!.doubleValue(for: HKUnit.meter())) : 0, "total_energy_burned": sample.totalEnergyBurned != nil ? Int(sample.totalEnergyBurned!.doubleValue(for: HKUnit.kilocalorie())) : 0 ] + + // Fetch associated route data + let workoutPredicate = HKQuery.predicateForObjects(from: sample) + let routeQuery = HKSampleQuery(sampleType: HKSeriesType.workoutRoute(), predicate: workoutPredicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { (query, routeSamplesOrNil, error) in + if let error = error { + print("Error fetching route data: \(error.localizedDescription)") + // Continue without route data + dictionaries.append(workoutDict) + dispatchGroup.leave() + return + } + guard let routeSamples = routeSamplesOrNil as? [HKWorkoutRoute], !routeSamples.isEmpty else { + // No route data + dictionaries.append(workoutDict) + dispatchGroup.leave() + return + } + + var routeLocations = [[String: Any]]() + let routeDispatchGroup = DispatchGroup() + + for routeSample in routeSamples { + routeDispatchGroup.enter() + var locations = [CLLocation]() + let locationQuery = HKWorkoutRouteQuery(route: routeSample) { (query, locationData, done, error) in + if let error = error { + print("Error fetching locations: \(error.localizedDescription)") + routeDispatchGroup.leave() + return + } + if let locationData = locationData { + locations.append(contentsOf: locationData) + } + if done { + let locationDicts = locations.map { loc in + return [ + "latitude": loc.coordinate.latitude, + "longitude": loc.coordinate.longitude, + "altitude": loc.altitude, + "timestamp": Int(loc.timestamp.timeIntervalSince1970 * 1000) + ] + } + routeLocations.append(contentsOf: locationDicts) + routeDispatchGroup.leave() + } + } + self.healthStore.execute(locationQuery) + } + + routeDispatchGroup.notify(queue: .main) { + workoutDict["route"] = routeLocations + dictionaries.append(workoutDict) + dispatchGroup.leave() + } + } + self.healthStore.execute(routeQuery) } - DispatchQueue.main.async { + dispatchGroup.notify(queue: DispatchQueue.main) { result(dictionaries) } @@ -1376,6 +1438,9 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { workoutActivityTypeMap["KICKBOXING"] = .kickboxing workoutActivityTypeMap["MARTIAL_ARTS"] = .martialArts workoutActivityTypeMap["TAI_CHI"] = .taiChi + if #available(iOS 17.0, *) { + workoutActivityTypeMap["UNDERWATER_DIVING"] = .underwaterDiving + } workoutActivityTypeMap["WRESTLING"] = .wrestling workoutActivityTypeMap["OTHER"] = .other nutritionList = [ @@ -1619,6 +1684,13 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { } func getWorkoutType(type: HKWorkoutActivityType) -> String { + + if #available(iOS 17.0, *) { + if type == .underwaterDiving { + return "underwaterDiving" + } + } + switch type { case .americanFootball: return "americanFootball" @@ -1732,6 +1804,8 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { return "waterSports" case .wrestling: return "wrestling" + + case .yoga: return "yoga" case .barre: diff --git a/packages/health/lib/health.dart b/packages/health/lib/health.dart index 1c960d54f..6441a0cb1 100644 --- a/packages/health/lib/health.dart +++ b/packages/health/lib/health.dart @@ -1,21 +1,21 @@ -library health; +library; import 'dart:async'; import 'dart:collection'; import 'dart:io' show Platform; import 'package:carp_serializable/carp_serializable.dart'; -import 'package:json_annotation/json_annotation.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:json_annotation/json_annotation.dart'; -part 'src/heath_data_types.dart'; +part 'health.g.dart'; +part 'health.json.dart'; part 'src/functions.dart'; part 'src/health_data_point.dart'; -part 'src/health_value_types.dart'; part 'src/health_plugin.dart'; +part 'src/health_value_types.dart'; +part 'src/heath_data_types.dart'; +part 'src/route_point.dart'; part 'src/workout_summary.dart'; - -part 'health.g.dart'; -part 'health.json.dart'; diff --git a/packages/health/lib/health.g.dart b/packages/health/lib/health.g.dart index fa4423446..4f741a0e2 100644 --- a/packages/health/lib/health.g.dart +++ b/packages/health/lib/health.g.dart @@ -406,6 +406,7 @@ const _$HealthWorkoutActivityTypeEnumMap = { HealthWorkoutActivityType.WHEELCHAIR_RUN_PACE: 'WHEELCHAIR_RUN_PACE', HealthWorkoutActivityType.WHEELCHAIR_WALK_PACE: 'WHEELCHAIR_WALK_PACE', HealthWorkoutActivityType.WRESTLING: 'WRESTLING', + HealthWorkoutActivityType.UNDERWATER_DIVING: 'UNDERWATER_DIVING', HealthWorkoutActivityType.BIKING_STATIONARY: 'BIKING_STATIONARY', HealthWorkoutActivityType.CALISTHENICS: 'CALISTHENICS', HealthWorkoutActivityType.DANCING: 'DANCING', @@ -670,18 +671,59 @@ const _$MenstrualFlowEnumMap = { MenstrualFlow.spotting: 'spotting', }; +RoutePoint _$RoutePointFromJson(Map json) => RoutePoint( + longitude: (json['longitude'] as num).toDouble(), + latitude: (json['latitude'] as num).toDouble(), + altitude: (json['altitude'] as num).toDouble(), + timestamp: (json['timestamp'] as num).toInt(), + horizontalAccuracy: (json['horizontalAccuracy'] as num?)?.toDouble(), + verticalAccuracy: (json['verticalAccuracy'] as num?)?.toDouble(), + ); + +Map _$RoutePointToJson(RoutePoint instance) { + final val = { + 'longitude': instance.longitude, + 'latitude': instance.latitude, + 'altitude': instance.altitude, + 'timestamp': instance.timestamp, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('horizontalAccuracy', instance.horizontalAccuracy); + writeNotNull('verticalAccuracy', instance.verticalAccuracy); + return val; +} + WorkoutSummary _$WorkoutSummaryFromJson(Map json) => WorkoutSummary( workoutType: json['workoutType'] as String, totalDistance: json['totalDistance'] as num, totalEnergyBurned: json['totalEnergyBurned'] as num, totalSteps: json['totalSteps'] as num, + route: (json['route'] as List?) + ?.map((e) => RoutePoint.fromJson(e as Map)) + .toList(), ); -Map _$WorkoutSummaryToJson(WorkoutSummary instance) => - { - 'workoutType': instance.workoutType, - 'totalDistance': instance.totalDistance, - 'totalEnergyBurned': instance.totalEnergyBurned, - 'totalSteps': instance.totalSteps, - }; +Map _$WorkoutSummaryToJson(WorkoutSummary instance) { + final val = { + 'workoutType': instance.workoutType, + 'totalDistance': instance.totalDistance, + 'totalEnergyBurned': instance.totalEnergyBurned, + 'totalSteps': instance.totalSteps, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('route', instance.route?.map((e) => e.toJson()).toList()); + return val; +} diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index 8868a519b..2adf5013b 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -1254,6 +1254,7 @@ class Health { HealthWorkoutActivityType.WHEELCHAIR_RUN_PACE, HealthWorkoutActivityType.WHEELCHAIR_WALK_PACE, HealthWorkoutActivityType.WRESTLING, + HealthWorkoutActivityType.UNDERWATER_DIVING, HealthWorkoutActivityType.YOGA, HealthWorkoutActivityType.SWIMMING_OPEN_WATER, HealthWorkoutActivityType.SWIMMING_POOL, diff --git a/packages/health/lib/src/heath_data_types.dart b/packages/health/lib/src/heath_data_types.dart index f50762721..e924564b2 100644 --- a/packages/health/lib/src/heath_data_types.dart +++ b/packages/health/lib/src/heath_data_types.dart @@ -527,7 +527,7 @@ enum HealthWorkoutActivityType { WHEELCHAIR_RUN_PACE, WHEELCHAIR_WALK_PACE, WRESTLING, - + UNDERWATER_DIVING, // Android only BIKING_STATIONARY, CALISTHENICS, diff --git a/packages/health/lib/src/route_point.dart b/packages/health/lib/src/route_point.dart new file mode 100644 index 000000000..fae102a4f --- /dev/null +++ b/packages/health/lib/src/route_point.dart @@ -0,0 +1,55 @@ +part of '../health.dart'; + +/// A [RoutePoint] object stores various metrics of a route location. +/// +/// * [longitude] - The longitude of the location. +/// * [latitude] - The latitude of the location. +/// * [altitude] - The altitude of the location. +/// * [timestamp] - The timestamp of the location. +/// * [horizontalAccuracy] - The horizontal accuracy of the location. +/// * [verticalAccuracy] - The vertical accuracy of the location. +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class RoutePoint { + /// The longitude of the location. + double longitude; + + /// The latitude of the location. + double latitude; + + /// The altitude of the location. + double altitude; + + /// The timestamp of the location. + int timestamp; + + /// The horizontal accuracy of the location. + double? horizontalAccuracy; + + /// The vertical accuracy of the location. + double? verticalAccuracy; + + RoutePoint({ + required this.longitude, + required this.latitude, + required this.altitude, + required this.timestamp, + this.horizontalAccuracy, + this.verticalAccuracy, + }); + + /// Create a [RoutePoint] from json. + factory RoutePoint.fromJson(Map json) => + _$RoutePointFromJson(json); + + /// Convert this [RoutePoint] to json. + Map toJson() => _$RoutePointToJson(this); + + @override + String toString() => '$runtimeType - ' + 'longitude: $longitude' + 'latitude: $latitude, ' + 'altitude: $altitude, ' + 'timestamp: $timestamp, ' + 'horizontalAccuracy: $horizontalAccuracy, ' + 'verticalAccuracy: $verticalAccuracy'; +} diff --git a/packages/health/lib/src/workout_summary.dart b/packages/health/lib/src/workout_summary.dart index 14682a402..ffe93fa14 100644 --- a/packages/health/lib/src/workout_summary.dart +++ b/packages/health/lib/src/workout_summary.dart @@ -6,6 +6,7 @@ part of '../health.dart'; /// * [totalDistance] - The total distance that was traveled during a workout. /// * [totalEnergyBurned] - The amount of energy that was burned during a workout. /// * [totalSteps] - The number of steps during a workout. +/// * [route] - The route of the workout. @JsonSerializable(includeIfNull: false, explicitToJson: true) class WorkoutSummary { /// Workout type. @@ -20,11 +21,15 @@ class WorkoutSummary { /// The total steps value of the workout. num totalSteps; + /// The route of the workout. + List? route; + WorkoutSummary({ required this.workoutType, required this.totalDistance, required this.totalEnergyBurned, required this.totalSteps, + this.route, }); /// Create a [WorkoutSummary] based on a health data point from native data format. @@ -34,6 +39,17 @@ class WorkoutSummary { totalDistance: dataPoint['total_distance'] as num? ?? 0, totalEnergyBurned: dataPoint['total_energy_burned'] as num? ?? 0, totalSteps: dataPoint['total_steps'] as num? ?? 0, + route: (dataPoint['route'] as List?)?.isNotEmpty ?? false + ? (dataPoint['route'] as List?)! + .map((l) => RoutePoint( + longitude: l['longitude'] as double, + latitude: l['latitude'] as double, + altitude: l['altitude'] as double, + timestamp: l['timestamp'] as int, + horizontalAccuracy: l['horizontal_accuracy'] as double?, + verticalAccuracy: l['vertical_accuracy'] as double?)) + .toList() + : null, ); /// Create a [HealthDataPoint] from json. @@ -48,5 +64,6 @@ class WorkoutSummary { 'workoutType: $workoutType' 'totalDistance: $totalDistance, ' 'totalEnergyBurned: $totalEnergyBurned, ' - 'totalSteps: $totalSteps'; + 'totalSteps: $totalSteps, ' + 'route: ${route?.map((l) => l.toString()).join('\n')}'; }