HealthKit - Change HKWorkoutConfiguration during HKWorkoutSession? - ios

My question is the following:
Can I change the HKWorkoutConfiguration.activityType during a
HKWorkoutSession or does each HKWorkoutSession has to have its own
HKWorkoutConfiguration.activityType?
I want to create a workout app where you can create a workout consisting of different sets with different activity types. For example a Shadowing Boxing Workout, consisting of 3 sets of Boxing and 3 sets of Kickboxing (Boxing and Kickboxing are the different activities).
Ideally I would just start the HKWorkoutSession once at the beginning and end it after all sets for each activity are done, changing the HKWorkoutConfiguration.activityType in-between.
My current approach is based on the sample provided by Apple: https://developer.apple.com/documentation/healthkit/workouts_and_activity_rings/speedysloth_creating_a_workout
I adjusted the startWorkout() method to startWorkout(for type: String). It now looks like this:
// Start the workout.
func startWorkout(for type: String) {
// Start the timer.
setUpTimer()
self.running = true
// Create the session and obtain the workout builder.
/// - Tag: CreateWorkout
do {
session = try HKWorkoutSession(healthStore: healthStore, configuration: self.workoutConfiguration(for: type))
builder = session.associatedWorkoutBuilder()
} catch {
// Handle any exceptions.
return
}
// Setup session and builder.
session.delegate = self
builder.delegate = self
// Set the workout builder's data source.
/// - Tag: SetDataSource
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore,
workoutConfiguration: workoutConfiguration(for: type))
// Start the workout session and begin data collection.
/// - Tag: StartSession
session.startActivity(with: Date())
builder.beginCollection(withStart: Date()) { (success, error) in
// The workout has started.
}
print("New Workout has started")
}
In the method I get the respective activity by workoutConfiguration(for: type) which looks up the right activity from a string.
After a set is done (e.g. the boxing set), I end the session and start a new workout and session for the new set with the new activity.
My problem with the current approach is that I need to end the current HKWorkoutSession before I start the new one. But ending the session the way its done in the example does not execute immediately and therefore the new set of the workouts starts without saving the old set to the HKStore with the right activity.
Therefore I thought I would be nice to start the session just once and switch activityTypes in-between. However, I don't know if it is possible (maybe complications with HKStore) and how it is done.
Or is there any other smart way of doing things to achieve this?
I'm just starting out with iOS Programming.
Any help is greatly appreciated!

Disclaimer: This isn't a full answer, but an approach I'd take to address the problem.
Look at the workoutSession(_:didChangeTo:from:date:) method. Documentation Link
When one type of workout ends that method will receive a notification. As long as you haven't called session.end() the workout session should still be active. Use your healthStore instance to save the previous workout session before starting a new one.
It could look like this;
func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
if toState == .ended {
workoutBuilder.endCollection(withEnd: date) { (success, error) in
// collection has ended for one type of workout
self.workoutBuilder.finishWorkout { (workout, error) in
// unwrap the optional `workout`
guard let completedWorkout = workout else { return }
// save workout to health store
healthStore.save(completedWorkout) { (success, error) in
if success {
// begin a new workout by calling your method to start a new workout with a new configuration here
startWorkout(for type: String)
}
}
}
}
}
}
That is likely going to cause issues with your Timer since you're calling setUpTimer again but you can work with that. Also this doesn't address the point when the user is completely done with their workout and doesn't want to start a new workout type.

Related

How to run a Firestore query inside a map function in Swift

I am new to SwiftUI and Firebase and I am trying to build my first app. I am storing Game documents in Firestore and one of the fields is an array containing the user ids of the players as you can see in the image.
Game data structure
That being said, I am trying to list all games of a given user and have all the players listed in each one of the cells (the order is important).
In order to create the list of games in the UI I created a GameCellListView and a GameCellViewModel. The GameCellViewModel should load both the games and the array of users that correspond to the players of each game. However I am not being able to load the users to an array. I have to go through the players array and query the database for each Id and append to a User array; then I should be able to return this User array. Since I'm using a for loop, I can't assign the values to the array and then return it. I tried using map(), but I can't perform a query inside of it.
The goal is to load that "all" var with a struct that receives a game and its players GamePlayers(players: [User], game: Game)
It should look something like the code snippet below, but the users array always comes empty. This function runs on GameCellViewModel init.
I hope you can understand my problem and thank you in advance! Been stuck on this for 2 weeks now
func loadData() {
let userId = Auth.auth().currentUser?.uid
db.collection("games")
.order(by: "createdTime")
.whereField("userId", isEqualTo: userId)
.addSnapshotListener { (querySnapshot, error) in
if let querySnapshot = querySnapshot {
self.games = querySnapshot.documents.compactMap { document in
do {
let extractedGame = try document.data(as: Game.self)
var user = [User]()
let users = extractedGame!.players.map { playerId -> [User] in
self.db.collection("users")
.whereField("uid", isEqualTo: playerId)
.addSnapshotListener { (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("No documents")
return
}
user = documents.compactMap { queryDocumentSnapshot -> User? in
return try? queryDocumentSnapshot.data(as: User.self)
}
}
return user
}
self.all.append(GamePlayers(players: users.first ?? [User](), game: extractedGame!))
return extractedGame
}
catch {
print(error)
}
return nil
}
}
}
}
There are a lot of moving parts in your code and so to isolate points of failure would require seeing additional code so just be aware of that upfront. That said, if you are relatively new to Firestore or Swift then I would strongly suggest you first get a handle on this function using basic syntax. Once you're comfortable with the ins and outs of async looping then I would suggest refactoring the code using more advanced syntax, like you have here.
Your function requires performing async work within each loop iteration (of each document). You actually need to do this twice, async work within a loop within a loop. Be sure this is what you really want to do before proceeding because there may be cleaner ways, which may include a more efficient NoSQL data architecture. Regardless, for the purposes of this function, start with the most basic syntax there is for the job which is the Dispatch Group in concert with the for-loop. Go ahead and nest these until you have it working and then consider refactoring.
func loadData() {
// Always safely unwrap the user ID and never assume it is there.
guard let userId = Auth.auth().currentUser?.uid else {
return
}
// Query the database.
db.collection("games").whereField("userId", isEqualTo: userId).order(by: "createdTime").addSnapshotListener { (querySnapshot, error) in
if let querySnapshot = querySnapshot {
// We need to loop through a number of documents and perform
// async tasks within them so instantiate a Dispatch Group
// outside of the loop.
let dispatch = DispatchGroup()
for doc in querySnapshot.documents {
// Everytime you enter the loop, enter the dispatch.
dispatch.enter()
do {
// Do something with this document.
// You want to perform an additional async task in here,
// so fire up another dispatch and repeat these steps.
// Consider partitioning these tasks into separate functions
// for readability.
// At some point in this do block, we must leave the dispatch.
dispatch.leave()
} catch {
print(error)
// Everytime you leave this iteration, no matter the reason,
// even on error, you must leave the dispatch.
dispatch.leave()
// If there is an error in this iteration, do not return.
// Return will return out of the method itself (loadData).
// Instead, continue, which will continue the loop.
continue
}
}
dispatch.notify(queue: .main) {
// This is the completion handler of the dispatch.
// Your first round of data is ready, now proceed.
}
} else if let error = error {
// Always log errors to console!!!
// This should be automatic by now without even having to think about it.
print(error)
}
}
}
I also noticed that within the second set of async tasks within the second loop, you're adding snapshot listeners. Are you really sure you want to do this? Don't you just need a plain document get?

WatchOS: HKWorkoutSession ends whenever a second app like NRC or Runtastic is started

I am testing the SpeedySloth demo app from Apple: https://developer.apple.com/documentation/healthkit/workouts_and_activity_rings/speedysloth_creating_a_workout
Well, this ends here, whenever a second app starts:
// MARK: - HKWorkoutSessionDelegate
extension WorkoutManager: HKWorkoutSessionDelegate {
func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState, date: Date) {
// Wait for the session to transition states before ending the builder.
/// - Tag: SaveWorkout
if toState == .ended {
print("The workout has now ended.")
builder.endCollection(withEnd: Date()) { (success, error) in
self.builder.finishWorkout { (workout, error) in
// Optionally display a workout summary to the user.
self.resetWorkout()
}
}
}
}
The delegate is directly called with toState = .ended when I press the "Start" Button in Nike Running Club. I assume, that there is only one workout possible at one time, BUT I can use Adidas Running along with NRC, so, it must be somehow possible.
The documentation for HKWorkoutSession states that only one can be run at a time.
Apple Watch runs one workout session at a time. If a second workout starts while your workout is running, your HKWorkoutSessionDelegate object receives an HKError.Code.errorAnotherWorkoutSessionStarted error, and your session ends.
See https://developer.apple.com/documentation/healthkit/hkworkoutsession
Before the Apple Watch came out several Apps allowed users to measure a run or walk session by using the CoreMotion APIs. I suspect one of the Apps you mention may fall back to this if a Workout Session is already running.

Track the time it takes a user to navigate through an iOS app's UI

I want to measure how long (in seconds) it takes users to do certain things in my app. Some examples are logging in, pressing a button on a certain page, etc.
I am using an NSTimer for that. I am starting it in the viewDidLoad of a specific page, and stopping it at the point that I want to measure.
I also want to measure cumulative time for certain things. I would like to start the timer on the log-in screen, and then continue the timer until the user gets to the next view controller and clicks on a certain button.
I'm not sure how to do this. Should create a global variable in my app delegate? Or is there some other better way?
No need for an NSTimer, you just need to record the start times and compare them to the stop times. Try using a little helper class such as:
class MyTimer {
static let shared = MyTimer()
var startTimes = [String : Date]()
func start(withKey key: String) {
startTimes[key] = Date()
}
func measure(key: String) -> TimeInterval? {
if let start = startTimes[key] {
return Date().timeIntervalSince(start)
}
return nil
}
}
To use this, just call start(withKey:) right before you start a long-running task.
MyTimer.shared.start(withKey: "login")
Do something that takes a while and then call measure(key:) when you're done. Because MyTimer is a singleton, it can be called from anywhere in your code.
if let interval = MyTimer.shared.measure("login") {
print("Logging in time: \(interval)")
}
If you're using multiple threads, you may to to add some thread safety to this, but it should work as is in simple scenarios.

How do you detect Workout start / stop with HealthKit?

I'm looking for a way to detect workout detect / stop using HealthKit and it seems there is no way to detect it.
In Android you get "ACTION_SESSION_START" and "ACTION_SESSION_END" for sessions.
Has anyone tried detecting workout start / stop?
Thank you for your time!
There is no API for observing workouts being recorded by another app.
I was thinking you could detect events. Not save a workout.
In HealthKit you can create workout events. Therefore you could theoretically code something to detect pause/resume events.
Create a running workout that goes for ___ time.
let finish = NSDate() // Now
let start = finish.dateByAddingTimeInterval(0000) // workout time
let workout = HKWorkout(activityType: .Running, startDate: start, endDate: finish)
Create Pause (stop) and Resume (start) events.
let workoutEvents: [HKWorkoutEvent] = [
HKWorkoutEvent(type: .Pause, date: startDate.dateByAddingTimeInterval(000)),
HKWorkoutEvent(type: .Resume, date: startDate.dateByAddingTimeInterval(000))
]
Then you need to alter the creation of the HKWorkout object to use the more complex constructor, which allows you to include workoutEvents.
let workout = HKWorkout(
activityType: .Running
startDate: start,
endDate: end,
workoutEvents: workoutEvents,
device: nil,
metadata: nil
)
At this point you would normally pass the workout to HKHealthStore.saveObject to save it like this.
healthStore.saveObject(workout) { (success: Bool, error: NSError?) -> Void in
if success {
// Workout was saved
}
else {
// Workout was not saved
}
}
But
in your case you don't want to save. You want to detect events. could have a switch statement that could detect a Pause or Resume.
I'm not sure about the specific dateByAddingTimeInterval values you would want but it's definitely something you could experiment with using zero values maybe? Because technically 00 is still an event.
Workout events toggle a workout object between an active and an inactive state. You could create a method detectWorkoutEvent.

NSMetadataQuery’s update notification interferes with (run loop?)

I emailed an Apple engineer last week about a problem with my NSMetadataQuery.
Here’s the email:
Hi,
I'm writing a document-based app or iOS and my method for renaming (moving the document to a new location) seems to conflict with the running NSMetadataQuery.
The query updates a couple of time after the move method is called, the first time it has the old URL of the item that just moved, and the next it has the new URL. However, because of my updating method (below) if a URL has been removed since the update, my model removes the deleted URL and vice versa for if it finds a URL which doesn't exist yet.
I think my problem is one of two issue, either the NSMetadataQuery's update method is insufficient and doesn't check an item's URL for the 'correct' attributes before deleting it (although looking over documentation I can't see anything that would suggest I'm missing something) or my renaming method isn't doing something it should.
I have tried disabling updates at the start of the renaming method and reenabling once all completion blocks are finished but it doesn't make any difference.
My NSMetadataQuery's update method:
func metadataQueryDidUpdate(notification: NSNotification) {
ubiquitousItemsQuery?.disableUpdates()
var ubiquitousItemURLs = [NSURL]()
if ubiquitousItemsQuery != nil && UbiquityManager.sharedInstance.ubiquityIsAvailable {
for var i = 0; i < ubiquitousItemsQuery?.resultCount; i++ {
if let result = ubiquitousItemsQuery?.resultAtIndex(i) as? NSMetadataItem {
if let itemURLValue = result.valueForAttribute(NSMetadataItemURLKey) as? NSURL {
ubiquitousItemURLs.append(itemURLValue)
}
}
}
// Remove deleted items
//
for (index, fileRepresentation) in enumerate(fileRepresentations) {
if fileRepresentation.fileURL != nil && !contains(ubiquitousItemURLs, fileRepresentation.fileURL!) {
removeFileRepresentations([fileRepresentation], fromDisk: false)
}
}
// Load documents
//
for (index, fileURL) in enumerate(ubiquitousItemURLs) {
loadDocumentAtFileURL(fileURL, completionHandler: nil)
}
ubiquitousItemsQuery?.enableUpdates()
}
}
And my renaming method:
func renameFileRepresentation(fileRepresentation: FileRepresentation, toNewNameWithoutExtension newName: String) {
if fileRepresentation.name == newName || fileRepresentation.fileURL == nil || newName.isEmpty {
return
}
let newNameWithExtension = newName.stringByAppendingPathExtension(NotableDocumentExtension)!
// Update file representation
//
fileRepresentation.nameWithExtension = newNameWithExtension
if let indexPath = self.indexPathForFileRepresentation(fileRepresentation) {
self.reloadFileRepresentationsAtIndexPaths([indexPath])
}
UbiquityManager.automaticDocumentsDirectoryURLWithCompletionHandler { (documentsDirectoryURL) -> Void in
let sourceURL = fileRepresentation.fileURL!
let destinationURL = documentsDirectoryURL.URLByAppendingPathComponent(newNameWithExtension)
// Update file representation
//
fileRepresentation.fileURL = destinationURL
if let indexPath = self.indexPathForFileRepresentation(fileRepresentation) {
self.reloadFileRepresentationsAtIndexPaths([indexPath])
}
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), { () -> Void in
let coordinator = NSFileCoordinator(filePresenter: nil)
var coordinatorError: NSError?
coordinator.coordinateWritingItemAtURL(sourceURL, options: .ForMoving, writingItemAtURL: destinationURL, options: .ForReplacing, error: &coordinatorError, byAccessor: { (newSourceURL, newDestinationURL) -> Void in
var moveError: NSError?
let moveSuccess = NSFileManager().moveItemAtURL(newSourceURL, toURL: newDestinationURL, error: &moveError)
dispatch_async(dispatch_get_main_queue(), { () -> Void in
assert(moveError == nil || moveSuccess, "Error renaming (moving) document from \(newSourceURL) to \(newDestinationURL).\nSuccess? \(moveSuccess).\nError message: \(moveError).")
if let query = self.ubiquitousItemsQuery {
query.enableUpdates()
}
if moveError != nil || moveSuccess {
// TODO: Implement resetting file rep
}
})
})
})
}
}
I had a reply almost instantly but since then there’s been no reply.
Here’s the reply
One of the big things that jumps out at me is your usage of disableUpdates() and enableUpdates(). You’re executing them both on the same turn of the run loop, but NSMetadataQuery delivers results asynchronously. Since this code executes within your update notification, it is executing synchronously with respect to the query. So from the query’s point-of-view, it’s going to begin delivering updates by posting the notification. Posting a notification is a synchronous process, so while it’s posting the notification, updates will be disabled and the re-enabled. Thus, by the time the query is done posting the notification, it’s back in the exact same state it was in when it started delivering results. It sounds like that’s not the behavior you’re wanting.
Here’s where I need help
I took this to assume that NSMetadataQuery has some kind of cache which it adds results to while updates are disabled and when enabled, those (perhaps many) cache results are looped through and each are sent via the updates notification.
Anyway, I had a look at run loops on iOS and although I understand them as much as I can on my own, I don’t understand how the reply is helpful, i.e how to actually fix the problem - or what’s even causing the problem.
If anyone has any good idea I’d love your help!
Thanks.
Update
Here’s my log of when functions start and end:
start metadataQueryDidUpdate:
end metadataQueryDidUpdate:
start metadataQueryDidUpdate:
end metadataQueryDidUpdate:
start renameFileRepresentation:toNewNameWithoutExtension
start metadataQueryDidUpdate:
end metadataQueryDidUpdate:
end renameFileRepresentation:toNewNameWithoutExtension
start metadataQueryDidUpdate:
end metadataQueryDidUpdate:
start metadataQueryDidUpdate:
end metadataQueryDidUpdate:
start metadataQueryDidUpdate:
end metadataQueryDidUpdate:
I was having the same problem. NSMetaDataQuery updates tell you if there is a change, but does not tell you what that change was. If the change is a rename, there is no way to identify the previous name, so I can find the old entry in my tableView. Very frustrating.
But, you can get the information by using NSFileCoordinator and NSFilePresenter.
Use the NSFilePresenter method presentedSubitemAtURL(oldURL: NSURL, didMoveToURL newURL: NSURL)
As you noted, the query changed notification is called once with the old URL, and once with the new URL. The method above is called between those two notifications.

Resources