Skip to content

Instantly share code, notes, and snippets.

@shaundon
Created February 1, 2024 21:16
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save shaundon/005f1903c286601254e680fe4d366e6c to your computer and use it in GitHub Desktop.
Save shaundon/005f1903c286601254e680fe4d366e6c to your computer and use it in GitHub Desktop.

Here's most of the code I use for the achievements system in Personal Best. It's a lot more complex than I remember, so hopefully it makes sense.

For the notification that appears, I'm using Drops. I usually avoid external dependencies as much as possible, but this one saved me a lot of time. Initially I built the notification UI myself, but I kept running into issues and edge cases so I decided to just outsource it in the end. If you're interested, I wrote something recently about the dependencies I use.

In terms of achievements, I have five different types (see the AchievementType enum). The personalBest type is directly related to things you do in the app, for example 'share a workout', and when a user does this I simply call a function which sets it as achieved and shows the notification:

func shareWorkout() {
  ...
  AchievementRecord.sharedWorkout.setAchieved()
}

The other types of achievement are created on demand – that is to say, they're not based on storing any data in UserDefaults, I perform some logic when you go to the achievements screen where I look at your workouts to see which ones should be marked as achieved. The upside of this is that the data should always be correct, as it's linked directly to what's in your HealthKit database, but the downside is I'm doing a lot of expensive logic each time. I might refactor this in future, but it hasn't really been a priority. I do think the code is a little messy in general and could be improved though.

import Foundation
import SwiftUI
protocol Achievement {
var id: UUID { get }
var name: String { get }
var description: String { get }
var isAchieved: Bool { get set }
var primaryTint: Color { get }
var secondaryTint: Color { get }
var image: Image? { get }
var text: Text? { get }
var colorContent: Bool { get set }
}
import SwiftUI
import Foundation
import HealthKit
enum AchievementType: String, CaseIterable {
case personalBest = "Personal Best"
case streak = "Streaks"
case dateAndTime = "Date and time based"
case totalTypesOfWorkout = "Total types of workout"
case workoutType = "Workout types"
var description: String {
switch self {
case .dateAndTime: return "Mix up your schedule by working out at different times."
case .workoutType: return "Discover a new hobby"
case .totalTypesOfWorkout: return "Gotta try 'em all"
case .personalBest: return "Do stuff in Personal Best, earn achievements"
case .streak: return "Streak your way to success"
}
}
}
struct AchievementManager {
static func getEarnedAchievements(forWorkouts workouts: [HKWorkout], leaderboardsCount: Int) -> [Achievement] {
let allAchievements = getAchievements(forWorkouts: workouts, leaderboardsCount: leaderboardsCount)
var earnedAchievements = [Achievement]()
allAchievements.values.forEach { achievementsForType in
achievementsForType.forEach { achievement in
if achievement.isAchieved {
earnedAchievements.append(achievement)
}
}
}
return earnedAchievements
}
static func getAchievements(forWorkouts workouts: [HKWorkout], leaderboardsCount: Int) -> [AchievementType: [Achievement]] {
var allAchievementsForTotalTypesOfWorkout = AchievementForTotalTypesOfWorkout.allAchievements.toDictionary(with: { $0.id })
var allAchievementsForWorkoutType = AchievementForWorkoutType.allAchievements.toDictionary(with: { $0.id })
var allAchievementsForTimeOfDay = AchievementForTimeOfDay.allAchievements.toDictionary(with: { $0.id })
var allAchievementsForDate = AchievementForDate.allAchievements.toDictionary(with: { $0.id })
var allAchievementsForDayStreak = AchievementForStreak.allDayAchievements.toDictionary(with: { $0.id })
var allAchievementsForMonthStreak = AchievementForStreak.allMonthAchievements.toDictionary(with: { $0.id })
var allAchievementsForYearStreak = AchievementForStreak.allYearAchievements.toDictionary(with: { $0.id })
var workoutsGroupedByType = [HKWorkoutActivityType: [HKWorkout]]()
var workoutsGroupedByHour = [Int: Set<HKWorkout>]()
var workoutsGroupedByYear = [Date: [HKWorkout]]()
var workoutsGroupedByMonthAndYear = [Date: [HKWorkout]]()
var workoutsGroupedByDayAndMonth = [Date: [HKWorkout]]()
var workoutsGroupedByDayMonthAndYear = [Date: [HKWorkout]]()
for workout in workouts {
workoutsGroupedByType.add(workout.workoutActivityType, value: workout)
let startHour = workout.startDateWithTimeZone.hour
let endHour = workout.startDateWithTimeZone.hour
workoutsGroupedByHour.add(startHour, value: workout)
if endHour != startHour {
workoutsGroupedByHour.add(endHour, value: workout)
}
if let dayMonthYear = Calendar.current.date(from: workout.endDateWithTimeZone.get(.day, .month, .year)) {
workoutsGroupedByDayMonthAndYear.add(dayMonthYear, value: workout)
}
if let dayMonth = Calendar.current.date(from: workout.endDateWithTimeZone.get(.day, .month)) {
workoutsGroupedByDayAndMonth.add(dayMonth, value: workout)
}
if let monthYear = Calendar.current.date(from: workout.endDateWithTimeZone.get(.month, .year)) {
workoutsGroupedByMonthAndYear.add(monthYear, value: workout)
}
if let year = Calendar.current.date(from: workout.endDateWithTimeZone.get(.year)) {
workoutsGroupedByYear.add(year, value: workout)
}
} // for
// Streaks (day)
if let highestNumberOfWorkoutsInOneDay = workoutsGroupedByDayMonthAndYear.values.map({ $0.count }).max() {
for id in allAchievementsForDayStreak.keys {
allAchievementsForDayStreak[id]?.checkAchievement(totalWorkouts: highestNumberOfWorkoutsInOneDay)
}
}
// Streaks (month)
if let highestNumberOfWorkoutsInOneMonth = workoutsGroupedByMonthAndYear.values.map({ $0.count }).max() {
for id in allAchievementsForMonthStreak.keys {
allAchievementsForMonthStreak[id]?.checkAchievement(totalWorkouts: highestNumberOfWorkoutsInOneMonth)
}
}
// Streaks (year)
if let highestNumberOfWorkoutsInOneYear = workoutsGroupedByYear.values.map({ $0.count }).max() {
for id in allAchievementsForYearStreak.keys {
allAchievementsForYearStreak[id]?.checkAchievement(totalWorkouts: highestNumberOfWorkoutsInOneYear)
}
}
// Total types completed.
let totalTypesOfWorkoutCompleted = workoutsGroupedByType.keys.count
for id in allAchievementsForTotalTypesOfWorkout.keys {
allAchievementsForTotalTypesOfWorkout[id]?.checkAchievement(totalTypesCompleted: totalTypesOfWorkoutCompleted)
}
// Workout types.
for id in allAchievementsForWorkoutType.keys {
if let desiredWorkoutType = allAchievementsForWorkoutType[id]?.workoutType,
let workoutsForDesiredType = workoutsGroupedByType[desiredWorkoutType] {
allAchievementsForWorkoutType[id]?.checkAchievement(totalTypesCompleted: workoutsForDesiredType.count)
}
}
// Time of day.
for id in allAchievementsForTimeOfDay.keys {
let hoursWithWorkouts = Array(workoutsGroupedByHour.keys)
allAchievementsForTimeOfDay[id]?.checkAchievement(forHours: hoursWithWorkouts)
}
// Date.
for id in allAchievementsForDate.keys {
let datesWithWorkouts = Array(workoutsGroupedByDayAndMonth.keys)
allAchievementsForDate[id]?.checkAchievement(forDates: datesWithWorkouts)
}
// Personal Best.
var allAchievementsForPersonalBest = [AchievementForPersonalBest]()
var hasPersonalBest = AchievementForPersonalBest.hasPersonalBest
hasPersonalBest.isAchieved = true
allAchievementsForPersonalBest.append(hasPersonalBest)
var hasPersonalBestPro = AchievementForPersonalBest.hasPersonalBestPro
if UserPreferences.hasAccessToPro {
hasPersonalBestPro.isAchieved = true
}
allAchievementsForPersonalBest.append(hasPersonalBestPro)
var changedAppIcon = AchievementForPersonalBest.changedAppIcon
if AchievementRecord.changedAppIcon.isAchieved || UIApplication.shared.alternateIconName != nil {
changedAppIcon.isAchieved = true
AchievementRecord.changedAppIcon.setAchieved()
}
allAchievementsForPersonalBest.append(changedAppIcon)
var leftATip = AchievementForPersonalBest.leftATip
if AchievementRecord.leftATip.isAchieved {
leftATip.isAchieved = true
}
allAchievementsForPersonalBest.append(leftATip)
var customLeaderboard = AchievementForPersonalBest.customLeaderboard
if AchievementRecord.madeCustomLeaderboard.isAchieved || leaderboardsCount > 0 {
customLeaderboard.isAchieved = true
}
allAchievementsForPersonalBest.append(customLeaderboard)
var confettiCannon = AchievementForPersonalBest.confettiCannon
if AchievementRecord.firedConfettiCannon.isAchieved {
confettiCannon.isAchieved = true
}
allAchievementsForPersonalBest.append(confettiCannon)
var sharedWorkout = AchievementForPersonalBest.sharedWorkout
if AchievementRecord.sharedWorkout.isAchieved {
sharedWorkout.isAchieved = true
}
allAchievementsForPersonalBest.append(sharedWorkout)
var openedMap = AchievementForPersonalBest.openedMap
if AchievementRecord.openedMap.isAchieved {
openedMap.isAchieved = true
}
allAchievementsForPersonalBest.append(openedMap)
var openedSplits = AchievementForPersonalBest.openedSplits
if AchievementRecord.openedSplits.isAchieved {
openedSplits.isAchieved = true
}
allAchievementsForPersonalBest.append(openedSplits)
var changedStatsPeriod = AchievementForPersonalBest.changedStatisticTimePeriod
if AchievementRecord.changedStatisticsTimePeriod.isAchieved {
changedStatsPeriod.isAchieved = true
}
allAchievementsForPersonalBest.append(changedStatsPeriod)
var completedYearInReview2023 = AchievementForPersonalBest.completedYearInReview2023
if AchievementRecord.completedYearInReview2023.isAchieved {
completedYearInReview2023.isAchieved = true
}
allAchievementsForPersonalBest.append(completedYearInReview2023)
let workoutTypeAchievements = Array(allAchievementsForWorkoutType.values).sorted(by: AchievementForWorkoutType.sortFunction)
let dateAndTimeAchievements: [Achievement] = Array(allAchievementsForTimeOfDay.values).sorted(by: AchievementForTimeOfDay.sortFunction) + Array(allAchievementsForDate.values).sorted(by: AchievementForDate.sortFunction)
let totalTypesOfWorkoutAchievements = Array(allAchievementsForTotalTypesOfWorkout.values).sorted(by: AchievementForTotalTypesOfWorkout.sortFunction)
let streakAchievements = Array(allAchievementsForDayStreak.values).sorted(by: AchievementForStreak.sortFunction) + Array(allAchievementsForMonthStreak.values).sorted(by: AchievementForStreak.sortFunction) + Array(allAchievementsForYearStreak.values).sorted(by: AchievementForStreak.sortFunction)
return [
.workoutType: workoutTypeAchievements,
.dateAndTime: dateAndTimeAchievements,
.totalTypesOfWorkout: totalTypesOfWorkoutAchievements,
.personalBest: allAchievementsForPersonalBest,
.streak: streakAchievements,
]
}
}
import Foundation
import SwiftUI
import Drops
struct AchievementNotification {
static func show(forAchievement achievement: Achievement) {
let drop = Drop(
title: achievement.name,
titleNumberOfLines: 0,
subtitle: achievement.description,
subtitleNumberOfLines: 0,
icon: UIImage(systemName: "trophy.fill"),
position: .top,
duration: Drop.Duration(floatLiteral: 5),
accessibility: Drop.Accessibility(message: "Achievement unlocked. \(achievement.name). \(achievement.description)")
)
Drops.show(drop)
}
}
import Foundation
import SwiftUI
import Drops
enum AchievementRecord: String, CaseIterable {
case changedAppIcon
case leftATip
case madeCustomLeaderboard
case firedConfettiCannon
case sharedWorkout
case openedMap
case openedSplits
case changedStatisticsTimePeriod
case completedYearInReview2023
var associatedAchievement: Achievement {
switch self {
case .changedAppIcon:
return AchievementForPersonalBest.changedAppIcon
case .leftATip:
return AchievementForPersonalBest.leftATip
case .madeCustomLeaderboard:
return AchievementForPersonalBest.customLeaderboard
case .firedConfettiCannon:
return AchievementForPersonalBest.confettiCannon
case .sharedWorkout:
return AchievementForPersonalBest.sharedWorkout
case .openedMap:
return AchievementForPersonalBest.openedMap
case .openedSplits:
return AchievementForPersonalBest.openedSplits
case .changedStatisticsTimePeriod:
return AchievementForPersonalBest.changedStatisticTimePeriod
case .completedYearInReview2023:
return AchievementForPersonalBest.completedYearInReview2023
}
}
var isAchieved: Bool {
return timestamp != nil
}
var timestamp: Date? {
if let group = UserDefaults.group {
let timestamp = group.double(forKey: self.rawValue)
if timestamp.isZero {
return nil
}
return Date(timeIntervalSince1970: timestamp)
}
return nil
}
func saveAchievementState() {
if let group = UserDefaults.group {
let timestamp = Date().timeIntervalSince1970
group.set(timestamp, forKey: self.rawValue)
AnalyticsManager.log("Achievement earned: \(self.rawValue)")
}
}
func setAchieved() {
if timestamp == nil {
saveAchievementState()
#if !APPCLIP
AchievementNotification.show(forAchievement: self.associatedAchievement)
#endif
}
}
func resetAchieved() {
if let group = UserDefaults.group {
group.removeObject(forKey: self.rawValue)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment