diff --git a/Friendly Competitions.xcodeproj/project.pbxproj b/Friendly Competitions.xcodeproj/project.pbxproj index f29f80fd..32ae5708 100644 --- a/Friendly Competitions.xcodeproj/project.pbxproj +++ b/Friendly Competitions.xcodeproj/project.pbxproj @@ -139,7 +139,6 @@ ED7881AD2850F6DF00C7F6CB /* SignUpError.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED7881AC2850F6DF00C7F6CB /* SignUpError.swift */; }; ED7881B02850F71E00C7F6CB /* AnalyticsEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED7881AF2850F71E00C7F6CB /* AnalyticsEvent.swift */; }; ED8C324729118DC400339988 /* VerifyEmailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8C324629118DC400339988 /* VerifyEmailView.swift */; }; - ED8C324929118E5500339988 /* FriendlyCompetitionsAppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8C324829118E5500339988 /* FriendlyCompetitionsAppModel.swift */; }; ED8C324C2911BFD300339988 /* ECKit in Frameworks */ = {isa = PBXBuildFile; productRef = ED8C324B2911BFD300339988 /* ECKit */; }; ED8C324E2911BFD300339988 /* ECKit+Firebase in Frameworks */ = {isa = PBXBuildFile; productRef = ED8C324D2911BFD300339988 /* ECKit+Firebase */; }; ED8D05F7295F5DA1002B3B3A /* CompetitionResultsDateRangeSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8D05F6295F5DA1002B3B3A /* CompetitionResultsDateRangeSelector.swift */; }; @@ -156,6 +155,7 @@ ED901B712802618700FE619E /* NewCompetitionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED901B702802618700FE619E /* NewCompetitionViewModel.swift */; }; ED901B742802649200FE619E /* VerifyEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED901B732802649200FE619E /* VerifyEmailViewModel.swift */; }; ED901B76280265DB00FE619E /* SignInViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED901B75280265DB00FE619E /* SignInViewModel.swift */; }; + ED97FCA5297B238B00597A16 /* RootTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED97FCA4297B238B00597A16 /* RootTab.swift */; }; EDA035B829173F140091E7A6 /* ArrayBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA035B729173F140091E7A6 /* ArrayBuilder.swift */; }; EDA035BB2917FE190091E7A6 /* NotificationName+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA035BA2917FE190091E7A6 /* NotificationName+Extensions.swift */; }; EDA035BE2917FFF90091E7A6 /* Dictionary+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA035BD2917FFF90091E7A6 /* Dictionary+Extensions.swift */; }; @@ -164,6 +164,9 @@ EDA352902836EFB600390585 /* UserViewAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA3528F2836EFB600390585 /* UserViewAction.swift */; }; EDA352932836F87600390585 /* CompetitionViewAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA352922836F87600390585 /* CompetitionViewAction.swift */; }; EDA35298283713C200390585 /* Sharable.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA35297283713C200390585 /* Sharable.swift */; }; + EDA61C0B2977243A00370C5C /* HomeContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA61C0A2977243A00370C5C /* HomeContainerView.swift */; }; + EDA61C0D2977276F00370C5C /* RootViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA61C0C2977276F00370C5C /* RootViewModel.swift */; }; + EDA61C0F2977317E00370C5C /* FriendlyCompetitionsAppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA61C0E2977317E00370C5C /* FriendlyCompetitionsAppModel.swift */; }; EDAF6C47285277FF002BC0DD /* Nonce.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDAF6C46285277FF002BC0DD /* Nonce.swift */; }; EDB749B72875BC3F00021014 /* StringKeyEncoded.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDB749B62875BC3F00021014 /* StringKeyEncoded.swift */; }; EDBEA170281459D200058A47 /* EditCompetitionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDBEA16F281459D200058A47 /* EditCompetitionSection.swift */; }; @@ -333,7 +336,6 @@ ED7881AC2850F6DF00C7F6CB /* SignUpError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpError.swift; sourceTree = ""; }; ED7881AF2850F71E00C7F6CB /* AnalyticsEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEvent.swift; sourceTree = ""; }; ED8C324629118DC400339988 /* VerifyEmailView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VerifyEmailView.swift; sourceTree = ""; }; - ED8C324829118E5500339988 /* FriendlyCompetitionsAppModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FriendlyCompetitionsAppModel.swift; sourceTree = ""; }; ED8D05F6295F5DA1002B3B3A /* CompetitionResultsDateRangeSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompetitionResultsDateRangeSelector.swift; sourceTree = ""; }; ED8D05F8295F5DB8002B3B3A /* CompetitionResultsDateRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompetitionResultsDateRange.swift; sourceTree = ""; }; ED901B442802426800FE619E /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; @@ -348,6 +350,7 @@ ED901B702802618700FE619E /* NewCompetitionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewCompetitionViewModel.swift; sourceTree = ""; }; ED901B732802649200FE619E /* VerifyEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyEmailViewModel.swift; sourceTree = ""; }; ED901B75280265DB00FE619E /* SignInViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInViewModel.swift; sourceTree = ""; }; + ED97FCA4297B238B00597A16 /* RootTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTab.swift; sourceTree = ""; }; EDA035B729173F140091E7A6 /* ArrayBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayBuilder.swift; sourceTree = ""; }; EDA035BA2917FE190091E7A6 /* NotificationName+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationName+Extensions.swift"; sourceTree = ""; }; EDA035BD2917FFF90091E7A6 /* Dictionary+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Extensions.swift"; sourceTree = ""; }; @@ -356,6 +359,9 @@ EDA3528F2836EFB600390585 /* UserViewAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserViewAction.swift; sourceTree = ""; }; EDA352922836F87600390585 /* CompetitionViewAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompetitionViewAction.swift; sourceTree = ""; }; EDA35297283713C200390585 /* Sharable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sharable.swift; sourceTree = ""; }; + EDA61C0A2977243A00370C5C /* HomeContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeContainerView.swift; sourceTree = ""; }; + EDA61C0C2977276F00370C5C /* RootViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModel.swift; sourceTree = ""; }; + EDA61C0E2977317E00370C5C /* FriendlyCompetitionsAppModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FriendlyCompetitionsAppModel.swift; sourceTree = ""; }; EDAF6C46285277FF002BC0DD /* Nonce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nonce.swift; sourceTree = ""; }; EDB749B62875BC3F00021014 /* StringKeyEncoded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringKeyEncoded.swift; sourceTree = ""; }; EDBEA16F281459D200058A47 /* EditCompetitionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCompetitionSection.swift; sourceTree = ""; }; @@ -651,7 +657,7 @@ C163E30026F2344D00C9EC30 /* Info.plist */, C1E6406226F4395200E3E70A /* FriendlyCompetitions.entitlements */, C1691DC626D55FCF006FFA30 /* FriendlyCompetitions.swift */, - ED8C324829118E5500339988 /* FriendlyCompetitionsAppModel.swift */, + EDA61C0E2977317E00370C5C /* FriendlyCompetitionsAppModel.swift */, C1B2F4E3272B505C0028449F /* AppDelegate.swift */, C1930E4427C55EF500DB45C1 /* AppState.swift */, C1A4696926DA823C00254105 /* Factory.swift */, @@ -774,7 +780,10 @@ C1ABB20C26D6839900716984 /* Views */ = { isa = PBXGroup; children = ( + ED97FCA4297B238B00597A16 /* RootTab.swift */, C19B7F2727BD8A5E00BCA4B9 /* RootView.swift */, + EDA61C0C2977276F00370C5C /* RootViewModel.swift */, + EDA61C0A2977243A00370C5C /* HomeContainerView.swift */, ED3C09DF29626A1D00ED4ACC /* NavigationDestination.swift */, C19B7F2E27BEA69200BCA4B9 /* Home */, C19B7F2927BD8C5C00BCA4B9 /* Explore */, @@ -1369,12 +1378,15 @@ ED3868DE29677F8100522722 /* PaywallPurchaseView.swift in Sources */, C12D640727A5F2FC00259237 /* CompetitionsManager.swift in Sources */, C1691DC726D55FCF006FFA30 /* FriendlyCompetitions.swift in Sources */, + EDA61C0F2977317E00370C5C /* FriendlyCompetitionsAppModel.swift in Sources */, C123C9C927A74F3500375F73 /* PermissionsManager.swift in Sources */, EDA035B829173F140091E7A6 /* ArrayBuilder.swift in Sources */, 7CBF67E827F4BA910047DC81 /* User+Visibility.swift in Sources */, ED901B4D280247E600FE619E /* CompetitionDetailsViewModel.swift in Sources */, ED7881AD2850F6DF00C7F6CB /* SignUpError.swift in Sources */, ED901B6B2802525700FE619E /* FirebaseImageViewModel.swift in Sources */, + EDA61C0D2977276F00370C5C /* RootViewModel.swift in Sources */, + ED97FCA5297B238B00597A16 /* RootTab.swift in Sources */, ED8C324729118DC400339988 /* VerifyEmailView.swift in Sources */, C1ABB21526D6987C00716984 /* NewCompetitionView.swift in Sources */, ED3ED8F7284F951800B5100F /* AutoMockable.generated.swift in Sources */, @@ -1451,6 +1463,7 @@ C1C8B24226F3FB47008D6B1B /* HealthKitManager.swift in Sources */, ED1CFAA22864FF0000192E49 /* WorkoutMetric.swift in Sources */, C1C1542326D5DFFD00C6FA9F /* HKActivitySummary+Extensions.swift in Sources */, + EDA61C0B2977243A00370C5C /* HomeContainerView.swift in Sources */, C114314227B9B6ED00915757 /* DeepLink.swift in Sources */, ED48782E282747BD00AC1E2C /* InviteFriendsView.swift in Sources */, EDA035BE2917FFF90091E7A6 /* Dictionary+Extensions.swift in Sources */, @@ -1477,7 +1490,6 @@ C1C8B24326F3FB47008D6B1B /* ActivitySummaryManager.swift in Sources */, ED1B5CFE296DBD2400122D7E /* PaywallView.swift in Sources */, C10E5548272B47080053BBA6 /* XCAssets+Generated.swift in Sources */, - ED8C324929118E5500339988 /* FriendlyCompetitionsAppModel.swift in Sources */, C1E4AB9826FCC8BD00B1AF12 /* ActivitySummaryInfoView.swift in Sources */, C16CC40627A7332800C26208 /* UserView.swift in Sources */, ED438FF927F7C2B900D72F99 /* View+Analytics.swift in Sources */, @@ -1692,6 +1704,7 @@ ); MARKETING_VERSION = 1.8.1; OTHER_LDFLAGS = "-Objc"; + OTHER_SWIFT_FLAGS = "-Xfrontend -warn-long-expression-type-checking=200"; PRODUCT_BUNDLE_IDENTIFIER = com.evancooper.FriendlyCompetitions.debug; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match Development com.evancooper.FriendlyCompetitions.debug"; @@ -1740,6 +1753,7 @@ ); MARKETING_VERSION = 1.8.1; OTHER_LDFLAGS = "-Objc"; + OTHER_SWIFT_FLAGS = "-Xfrontend -warn-long-expression-type-checking=200"; PRODUCT_BUNDLE_IDENTIFIER = com.evancooper.FriendlyCompetitions; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.evancooper.FriendlyCompetitions"; diff --git a/Friendly Competitions/AppDelegate.swift b/Friendly Competitions/AppDelegate.swift index 62329ebc..6fc0b3f0 100644 --- a/Friendly Competitions/AppDelegate.swift +++ b/Friendly Competitions/AppDelegate.swift @@ -3,6 +3,7 @@ import Factory import Firebase import FirebaseFirestore import FirebaseMessaging +import RevenueCat import UIKit final class AppDelegate: NSObject, UIApplicationDelegate { @@ -13,6 +14,16 @@ final class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { FirebaseApp.configure() Messaging.messaging().delegate = self + + let apiKey: String + #if DEBUG + apiKey = "appl_REFBiyXbqcpKtUtawSUJezooOfQ" + #else + apiKey = "appl_PfCzNKLwrBPhZHDqVcrFOfigEHq" + #endif + Purchases.logLevel = .warn + Purchases.configure(with: .init(withAPIKey: apiKey).with(usesStoreKit2IfAvailable: true)) + return true } @@ -26,13 +37,16 @@ extension AppDelegate: MessagingDelegate { guard let fcmToken = fcmToken, let userId = Auth.auth().currentUser?.uid else { return } Task { - var user = try await database.document("users/\(userId)") - .getDocument() + let tokens = try await database.document("users/\(userId)") + .getDocument(source: .cache) // can fetch from cache because tokens shouldn't be out of date .data(as: User.self) + .notificationTokens ?? [] - guard user.notificationTokens?.contains(fcmToken) == false else { return } - user.notificationTokens = user.notificationTokens?.appending(fcmToken) ?? [fcmToken] - try await database.document("users/\(userId)").updateDataEncodable(user) + guard !tokens.contains(fcmToken) else { return } + try await database.document("users/\(userId)") + .updateData([ + "notificationTokens": tokens.appending(fcmToken) + ]) } } } diff --git a/Friendly Competitions/Extensions/Firebase/DocumentSnapshot+Extensions.swift b/Friendly Competitions/Extensions/Firebase/DocumentSnapshot+Extensions.swift index 6c4d4246..68bda36f 100644 --- a/Friendly Competitions/Extensions/Firebase/DocumentSnapshot+Extensions.swift +++ b/Friendly Competitions/Extensions/Firebase/DocumentSnapshot+Extensions.swift @@ -1,6 +1,7 @@ import ECKit import Firebase import FirebaseFirestore +import FirebaseCrashlytics enum DocumentSnapshotDecodingError: Error { case missingData @@ -15,7 +16,7 @@ public extension DocumentSnapshot { let data = try JSONSerialization.data(withJSONObject: documentData, options: []) return try JSONDecoder.shared.decode(T.self, from: data) } catch { - print(error) + Crashlytics.crashlytics().record(error: error) throw error } } diff --git a/Friendly Competitions/Factory.swift b/Friendly Competitions/Factory.swift index 5c44fe8a..578961e6 100644 --- a/Friendly Competitions/Factory.swift +++ b/Friendly Competitions/Factory.swift @@ -22,14 +22,12 @@ extension Container { static let workoutManager = Factory(scope: .shared) { WorkoutManager() as WorkoutManaging } // Global state - static let appState = Factory(scope: .singleton) { AppState() as AppStateProviding } + static let appState = Factory(scope: .shared) { AppState() as AppStateProviding } static let database = Factory(scope: .shared) { let environment = Container.environmentManager.callAsFunction().firestoreEnvironment let firestore = Firestore.firestore() let settings = firestore.settings - settings.isPersistenceEnabled = false - settings.cacheSizeBytes = 1_048_576 // 1 MB switch environment.type { case .prod: diff --git a/Friendly Competitions/FriendlyCompetitions.swift b/Friendly Competitions/FriendlyCompetitions.swift index 26f18521..6cf8a492 100644 --- a/Friendly Competitions/FriendlyCompetitions.swift +++ b/Friendly Competitions/FriendlyCompetitions.swift @@ -6,28 +6,21 @@ struct FriendlyCompetitions: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var appModel = FriendlyCompetitionsAppModel() - @State private var reload = false - var body: some Scene { WindowGroup { - if reload { - ProgressView().onAppear { reload.toggle() } - } else { - Group { - if appModel.loggedIn { - if appModel.emailVerified { - RootView() - } else { - VerifyEmailView() - } + Group { + if appModel.loggedIn { + if appModel.emailVerified { + RootView() } else { - SignIn() + VerifyEmailView() } + } else { + SignIn() } - .hud(state: $appModel.hud) - .onOpenURL(perform: appModel.handle) - .onReceive(appModel.$environmentUUID.dropFirst()) { _ in reload.toggle() } } + .hud(state: $appModel.hud) + .onOpenURL(perform: appModel.handle) } } } diff --git a/Friendly Competitions/FriendlyCompetitionsAppModel.swift b/Friendly Competitions/FriendlyCompetitionsAppModel.swift index 92142327..6468741a 100644 --- a/Friendly Competitions/FriendlyCompetitionsAppModel.swift +++ b/Friendly Competitions/FriendlyCompetitionsAppModel.swift @@ -9,15 +9,11 @@ final class FriendlyCompetitionsAppModel: ObservableObject { @Published private(set)var loggedIn = false @Published private(set)var emailVerified = false @Published var hud: HUD? - @Published private(set) var environmentUUID = UUID() // MARK: - Private Properties @Injected(Container.appState) private var appState @Injected(Container.authenticationManager) private var authenticationManager - @Injected(Container.environmentManager) private var environmentManager - - private var cancellables = Set() // MARK: - Lifecycle @@ -25,10 +21,6 @@ final class FriendlyCompetitionsAppModel: ObservableObject { authenticationManager.loggedIn.assign(to: &$loggedIn) authenticationManager.emailVerified.assign(to: &$emailVerified) appState.hud.assign(to: &$hud) - - environmentManager.firestoreEnvironmentDidChange - .mapToValue(UUID()) - .assign(to: &$environmentUUID) } // MARK: - Public Methods diff --git a/Friendly Competitions/Managers/ActivitySummaryManager.swift b/Friendly Competitions/Managers/ActivitySummaryManager.swift index 04c25e18..b799df92 100644 --- a/Friendly Competitions/Managers/ActivitySummaryManager.swift +++ b/Friendly Competitions/Managers/ActivitySummaryManager.swift @@ -11,12 +11,14 @@ import HealthKit // sourcery: AutoMockable protocol ActivitySummaryManaging { var activitySummary: AnyPublisher { get } - func activitySummaries(in dateInterval: DateInterval) -> AnyPublisher<[ActivitySummary], Error> - func update() -> AnyPublisher } final class ActivitySummaryManager: ActivitySummaryManaging { + + private enum Constants { + static var activitySummaryKey: String { #function } + } // MARK: - Public Properties @@ -31,53 +33,54 @@ final class ActivitySummaryManager: ActivitySummaryManaging { @Injected(Container.workoutManager) private var workoutManager private var activitySummarySubject: CurrentValueSubject - private let upload = PassthroughSubject<[ActivitySummary], Never>() - private let uploadFinished = PassthroughSubject() - private let query = PassthroughSubject() - private var cancellables = Cancellables() // MARK: - Lifecycle init() { - let storedActivitySummary = UserDefaults.standard.decode(ActivitySummary.self, forKey: "activity_summary") + let storedActivitySummary = UserDefaults.standard.decode(ActivitySummary.self, forKey: Constants.activitySummaryKey) activitySummarySubject = .init(storedActivitySummary?.date.isToday == true ? storedActivitySummary : nil) activitySummary = activitySummarySubject .removeDuplicates() - .handleEvents(receiveOutput: { UserDefaults.standard.encode($0, forKey: "activity_summary") }) + .handleEvents(receiveOutput: { UserDefaults.standard.encode($0, forKey: Constants.activitySummaryKey) }) .receive(on: RunLoop.main) .share(replay: 1) .eraseToAnyPublisher() - - Publishers - .Merge3( - healthKitManager.backgroundDeliveryReceived, - query, - UIApplication.willEnterForegroundNotification.publisher - ) - .debounce(for: .seconds(0.5), scheduler: RunLoop.main) - .flatMapLatest(withUnretained: self) { $0.requestActivitySummaries() } - .combineLatest(userManager.userPublisher) - .sinkAsync { [weak self] activitySummaries, user in - guard let strongSelf = self else { return } - defer { strongSelf.uploadFinished.send() } - - guard activitySummaries.isNotEmpty else { return } + + let fetchAndUpload = PassthroughSubject() + let fetchAndUploadFinished = PassthroughSubject() + + fetchAndUpload + .debounce(for: .seconds(1), scheduler: RunLoop.main) + .flatMapLatest(withUnretained: self) { $0.activitySummaries(in: $0.competitionsManager.competitionsDateInterval) } + .handleEvents(withUnretained: self, receiveOutput: { strongSelf, activitySummaries in if let activitySummary = activitySummaries.last, activitySummary.date.isToday { strongSelf.activitySummarySubject.send(activitySummary) + } else { + strongSelf.activitySummarySubject.send(nil) } - - let batch = strongSelf.database.batch() - try activitySummaries.forEach { activitySummary in - var activitySummary = activitySummary - activitySummary.userID = user.id - let documentId = DateFormatter.dateDashed.string(from: activitySummary.date) - let document = strongSelf.database.document("users/\(user.id)/activitySummaries/\(documentId)") - let _ = try batch.setDataEncodable(activitySummary, forDocument: document) - } - try await batch.commit() - } + }) + .flatMapLatest(withUnretained: self) { $0.upload(activitySummaries: $1) } + .sink(receiveCompletion: { _ in }, receiveValue: { fetchAndUploadFinished.send() }) + .store(in: &cancellables) + + let backgroundDeliveryTrigger = Just(()) + .handleEvents(receiveSubscription: { _ in + fetchAndUpload.send() + }) + .flatMapLatest { fetchAndUploadFinished } + .eraseToAnyPublisher() + + healthKitManager.registerBackgroundDeliveryTask(backgroundDeliveryTrigger) + + Publishers + .CombineLatest( + UIApplication.willEnterForegroundNotification.publisher, + competitionsManager.competitions + ) + .debounce(for: .seconds(1), scheduler: RunLoop.main) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in fetchAndUpload.send() }) .store(in: &cancellables) } @@ -97,27 +100,26 @@ final class ActivitySummaryManager: ActivitySummaryManaging { .handleEvents(withUnretained: self, receiveSubscription: { strongSelf, _ in strongSelf.healthKitManager.execute(query) }) - .eraseToAnyPublisher() - } - - func update() -> AnyPublisher { - uploadFinished - .handleEvents(receiveSubscription: { [weak self] _ in self?.query.send() }) + .mapToValue([.mock]) .eraseToAnyPublisher() } // MARK: - Private Methods - - /// Don't call this directly, call `query` instead - private func requestActivitySummaries() -> AnyPublisher<[ActivitySummary], Never> { - competitionsManager.competitions - .filterMany(\.isActive) - .map(\.dateInterval) - .flatMapLatest(withUnretained: self) { strongSelf, dateInterval in - strongSelf.activitySummaries(in: dateInterval) - .catchErrorJustReturn([]) + + private func upload(activitySummaries: [ActivitySummary]) -> AnyPublisher { + .fromAsync { [weak self] in + guard let strongSelf = self else { return } + let userID = strongSelf.userManager.user.id + let batch = strongSelf.database.batch() + try activitySummaries.forEach { activitySummary in + var activitySummary = activitySummary + activitySummary.userID = userID + let documentID = DateFormatter.dateDashed.string(from: activitySummary.date) + let document = strongSelf.database.document("users/\(userID)/activitySummaries/\(documentID)") + let _ = try batch.setDataEncodable(activitySummary, forDocument: document) } - .eraseToAnyPublisher() + try await batch.commit() + } } } diff --git a/Friendly Competitions/Managers/Analytics/AnalyticsEvent.swift b/Friendly Competitions/Managers/Analytics/AnalyticsEvent.swift index 61afd59c..c0b297f7 100644 --- a/Friendly Competitions/Managers/Analytics/AnalyticsEvent.swift +++ b/Friendly Competitions/Managers/Analytics/AnalyticsEvent.swift @@ -21,7 +21,4 @@ enum AnalyticsEvent: Codable { case premiumPurchaseCancelled(id: String) case premiumPurchased(id: String) case premiumBannerDismissed - - // errors - case decodingError(error: String) } diff --git a/Friendly Competitions/Managers/CompetitionsManager.swift b/Friendly Competitions/Managers/CompetitionsManager.swift index 466da580..0049c14c 100644 --- a/Friendly Competitions/Managers/CompetitionsManager.swift +++ b/Friendly Competitions/Managers/CompetitionsManager.swift @@ -13,6 +13,7 @@ protocol CompetitionsManaging { var competitions: AnyPublisher<[Competition], Never>! { get } var invitedCompetitions: AnyPublisher<[Competition], Never>! { get } var appOwnedCompetitions: AnyPublisher<[Competition], Never>! { get } + var competitionsDateInterval: DateInterval { get } func accept(_ competition: Competition) -> AnyPublisher func create(_ competition: Competition) -> AnyPublisher @@ -40,6 +41,7 @@ final class CompetitionsManager: CompetitionsManaging { private enum Constants { static let maxParticipantsToFetch = 10 + static var competitionsDateIntervalKey: String { #function } } // MARK: - Public Properties @@ -47,6 +49,8 @@ final class CompetitionsManager: CompetitionsManaging { private(set) var competitions: AnyPublisher<[Competition], Never>! private(set) var invitedCompetitions: AnyPublisher<[Competition], Never>! private(set) var appOwnedCompetitions: AnyPublisher<[Competition], Never>! + + private(set) var competitionsDateInterval: DateInterval // MARK: - Private Properties @@ -55,12 +59,10 @@ final class CompetitionsManager: CompetitionsManaging { private let appOwnedCompetitionsSubject = CurrentValueSubject<[Competition], Never>([]) @Injected(Container.appState) private var appState - @LazyInjected(Container.activitySummaryManager) private var activitySummaryManager @Injected(Container.analyticsManager) private var analyticsManager @Injected(Container.database) private var database @Injected(Container.functions) private var functions @Injected(Container.userManager) private var userManager - @LazyInjected(Container.workoutManager) private var workoutManager private var updateTask: Task? { willSet { updateTask?.cancel() } @@ -68,6 +70,18 @@ final class CompetitionsManager: CompetitionsManaging { private var cancellables = Cancellables() private var listenerBag = ListenerBag() + + private lazy var firestoreEncoder: Firestore.Encoder = { + let encoder = Firestore.Encoder() + encoder.dateEncodingStrategy = .formatted(.dateDashed) + return encoder + }() + + private lazy var firestoreDecoder: Firestore.Decoder = { + let decoder = Firestore.Decoder() + decoder.dateDecodingStrategy = .formatted(.dateDashed) + return decoder + }() // MARK: - Lifecycle @@ -75,24 +89,24 @@ final class CompetitionsManager: CompetitionsManaging { competitions = competitionsSubject.share(replay: 1).eraseToAnyPublisher() invitedCompetitions = invitedCompetitionsSubject.share(replay: 1).eraseToAnyPublisher() appOwnedCompetitions = appOwnedCompetitionsSubject.share(replay: 1).eraseToAnyPublisher() + + let dateInterval = UserDefaults.standard.decode(DateInterval.self, forKey: Constants.competitionsDateIntervalKey) ?? .init() + competitionsDateInterval = dateInterval + competitions + .dropFirst() + .filterMany(\.isActive) + .map(\.dateInterval) + .sink(withUnretained: self) { strongSelf, dateInterval in + strongSelf.competitionsDateInterval = dateInterval + UserDefaults.standard.encode(dateInterval, forKey: Constants.competitionsDateIntervalKey) + } + .store(in: &cancellables) appState.didBecomeActive .filter { $0 } .mapToVoid() .sink(withUnretained: self) { $0.listenForCompetitions() } .store(in: &cancellables) - - Publishers - .CombineLatest3(competitions, appOwnedCompetitions, invitedCompetitions) - .map { $0 + $1 + $2 } - .removeDuplicates() - .mapToVoid() - .debounce(for: .seconds(0.5), scheduler: RunLoop.main) - .eraseToAnyPublisher() - .setFailureType(to: Error.self) - .flatMapLatest(withUnretained: self) { $0.updateStandings() } - .sink() - .store(in: &cancellables) } // MARK: - Public Methods @@ -201,16 +215,19 @@ final class CompetitionsManager: CompetitionsManaging { func standings(for competitionID: Competition.ID, resultID: CompetitionResult.ID) -> AnyPublisher<[Competition.Standing], Error> { database.collection("competitions/\(competitionID)/results/\(resultID)/standings") - .getDocuments() + .getDocumentsPreferCache() .map { $0.documents.compactMap { try? $0.data(as: Competition.Standing.self) } } .eraseToAnyPublisher() } func standings(for competitionID: Competition.ID) -> AnyPublisher<[Competition.Standing], Error> { - database.collection("competitions/\(competitionID)/standings") - .getDocuments() - .map { $0.documents.compactMap { try? $0.data(as: Competition.Standing.self) } } - .eraseToAnyPublisher() + updateCompetitionStandingsIfRequired(for: competitionID) + .flatMapLatest(withUnretained: self) { strongSelf in + strongSelf.database.collection("competitions/\(competitionID)/standings") + .getDocumentsPreferCache() + .map { $0.documents.compactMap { try? $0.data(as: Competition.Standing.self) } } + .eraseToAnyPublisher() + } } func participants(for competitionsID: Competition.ID) -> AnyPublisher<[User], Error> { @@ -233,10 +250,15 @@ final class CompetitionsManager: CompetitionsManaging { // MARK: - Private Methods - private func updateStandings() -> AnyPublisher { - functions.httpsCallable("updateCompetitionStandings") - .call() + private var lastCompetitionStandingsUpdate = [Competition.ID: Date]() + private func updateCompetitionStandingsIfRequired(for competitionID: Competition.ID) -> AnyPublisher { + if abs(lastCompetitionStandingsUpdate[competitionID]?.timeIntervalSinceNow ?? .infinity) < 5.minutes { + return .just(()) + } + return functions.httpsCallable("updateCompetitionStandings") + .call(["competitionID": competitionID]) .mapToVoid() + .handleEvents(withUnretained: self, receiveOutput: { $0.lastCompetitionStandingsUpdate[competitionID] = .now }) .eraseToAnyPublisher() } @@ -258,7 +280,7 @@ final class CompetitionsManager: CompetitionsManaging { self.invitedCompetitionsSubject.send(competitions) } .store(in: listenerBag) - + database.collection("competitions") .whereField("isPublic", isEqualTo: true) .whereField("owner", isEqualTo: Bundle.main.id) @@ -276,3 +298,13 @@ private extension Dictionary where Key == Competition.ID { filter { competitionId, _ in competitions.contains(where: { $0.id == competitionId }) } } } + +extension Query { + func getDocumentsPreferCache() -> AnyPublisher { + getDocuments(source: .cache) + .catch { [weak self] _ -> AnyPublisher in + self?.getDocuments(source: .server).eraseToAnyPublisher() ?? .never() + } + .eraseToAnyPublisher() + } +} diff --git a/Friendly Competitions/Managers/Environment/EnvironmentManager.swift b/Friendly Competitions/Managers/Environment/EnvironmentManager.swift index 8f2b9cdf..5b50c3a6 100644 --- a/Friendly Competitions/Managers/Environment/EnvironmentManager.swift +++ b/Friendly Competitions/Managers/Environment/EnvironmentManager.swift @@ -13,10 +13,13 @@ protocol EnvironmentManaging { final class EnvironmentManager: EnvironmentManaging { + private enum Constants { + static var environmentKey: String { #file + #function } + } + // MARK: - Public Properties var firestoreEnvironment: FirestoreEnvironment { firestoreEnvironmentSubject.value } - let firestoreEnvironmentDidChange: AnyPublisher // MARK: - Private Properties @@ -28,10 +31,10 @@ final class EnvironmentManager: EnvironmentManaging { // MARK: - Lifecycle init() { - if let environment = UserDefaults.standard.decode(FirestoreEnvironment.self, forKey: "environment") { + if let environment = UserDefaults.standard.decode(FirestoreEnvironment.self, forKey: Constants.environmentKey) { firestoreEnvironmentSubject = .init(environment) } else { - firestoreEnvironmentSubject = .init(.defaultEnvionment) + firestoreEnvironmentSubject = .init(.default) } firestoreEnvironmentDidChange = firestoreEnvironmentSubject @@ -39,7 +42,7 @@ final class EnvironmentManager: EnvironmentManaging { .eraseToAnyPublisher() firestoreEnvironmentSubject - .sink { UserDefaults.standard.encode($0, forKey: "environment") } + .sink { UserDefaults.standard.encode($0, forKey: Constants.environmentKey) } .store(in: &cancellables) } diff --git a/Friendly Competitions/Managers/Environment/FirestoreEnvironment.swift b/Friendly Competitions/Managers/Environment/FirestoreEnvironment.swift index 1fe2cd29..e87961d8 100644 --- a/Friendly Competitions/Managers/Environment/FirestoreEnvironment.swift +++ b/Friendly Competitions/Managers/Environment/FirestoreEnvironment.swift @@ -6,9 +6,7 @@ struct FirestoreEnvironment: Codable { let emulationType: EmulationType let emulationDestination: String? - static var defaultEnvionment: Self { - .init(type: .prod, emulationType: .localhost, emulationDestination: "localhost") - } + static let `default` = Self.init(type: .prod, emulationType: .localhost, emulationDestination: "localhost") enum EnvironmentType: String, CaseIterable, Codable, Hashable, Identifiable { case prod diff --git a/Friendly Competitions/Managers/FriendsManager.swift b/Friendly Competitions/Managers/FriendsManager.swift index a78cb60b..7ad2520d 100644 --- a/Friendly Competitions/Managers/FriendsManager.swift +++ b/Friendly Competitions/Managers/FriendsManager.swift @@ -44,6 +44,7 @@ final class FriendsManager: FriendsManaging { // MARK: - Private Properties + @Injected(Container.appState) private var appState @Injected(Container.database) private var database @Injected(Container.functions) private var functions @Injected(Container.userManager) private var userManager @@ -61,49 +62,10 @@ final class FriendsManager: FriendsManaging { friendActivitySummaries = friendActivitySummariesSubject.eraseToAnyPublisher() friendRequests = friendRequestsSubject.eraseToAnyPublisher() - let allFriends = userManager.userPublisher - .flatMapAsync { [weak self] user -> [User] in - guard let strongSelf = self else { return [] } - return try await strongSelf.database.collection("users") - .whereFieldWithChunking("id", in: user.friends + user.incomingFriendRequests) - .decoded(asArrayOf: User.self) - .sorted(by: \.name) - } - .share(replay: 1) - - Publishers - .CombineLatest(userManager.userPublisher, allFriends) - .map { user, allFriends in - allFriends.filter { user.friends.contains($0.id) } - } - .sink(withUnretained: self) { $0.friendsSubject.send($1) } - .store(in: &cancellables) - - Publishers - .CombineLatest(userManager.userPublisher, allFriends) - .map { user, allFriends in - allFriends.filter { user.incomingFriendRequests.contains($0.id) } - } - .sink(withUnretained: self) { $0.friendRequestsSubject.send($1) } - .store(in: &cancellables) - - allFriends - .flatMapAsync { [weak self] (friends: [User]) in - guard let strongSelf = self else { return [:] } - - let activitySummaries = try await strongSelf.database.collectionGroup("activitySummaries") - .whereField("date", isEqualTo: DateFormatter.dateDashed.string(from: .now)) - .whereFieldWithChunking("userID", in: friends.map(\.id)) - .decoded(asArrayOf: ActivitySummary.self) - - let pairs = activitySummaries.compactMap { activitySummary -> (User.ID, ActivitySummary)? in - guard let userID = activitySummary.userID else { return nil } - return (userID, activitySummary) - } - - return Dictionary(uniqueKeysWithValues: pairs) - } - .sink(withUnretained: self) { $0.friendActivitySummariesSubject.send($1) } + appState.didBecomeActive + .filter { $0 } + .mapToVoid() + .sink(withUnretained: self) { $0.listenForFriends() } .store(in: &cancellables) } @@ -168,4 +130,53 @@ final class FriendsManager: FriendsManaging { .map { $0.sorted(by: \.name) } .eraseToAnyPublisher() } + + // MARK: - Private Methods + + private func listenForFriends() { + let allFriends = userManager.userPublisher + .flatMapAsync { [weak self] user -> [User] in + guard let strongSelf = self else { return [] } + return try await strongSelf.database.collection("users") + .whereFieldWithChunking("id", in: user.friends + user.incomingFriendRequests) + .decoded(asArrayOf: User.self) + .sorted(by: \.name) + } + .share(replay: 1) + + Publishers + .CombineLatest(userManager.userPublisher, allFriends) + .map { user, allFriends in + allFriends.filter { user.friends.contains($0.id) } + } + .sink(withUnretained: self) { $0.friendsSubject.send($1) } + .store(in: &cancellables) + + Publishers + .CombineLatest(userManager.userPublisher, allFriends) + .map { user, allFriends in + allFriends.filter { user.incomingFriendRequests.contains($0.id) } + } + .sink(withUnretained: self) { $0.friendRequestsSubject.send($1) } + .store(in: &cancellables) + + allFriends + .flatMapAsync { [weak self] (friends: [User]) in + guard let strongSelf = self else { return [:] } + + let activitySummaries = try await strongSelf.database.collectionGroup("activitySummaries") + .whereField("date", isEqualTo: DateFormatter.dateDashed.string(from: .now)) + .whereFieldWithChunking("userID", in: friends.map(\.id)) + .decoded(asArrayOf: ActivitySummary.self) + + let pairs = activitySummaries.compactMap { activitySummary -> (User.ID, ActivitySummary)? in + guard let userID = activitySummary.userID else { return nil } + return (userID, activitySummary) + } + + return Dictionary(uniqueKeysWithValues: pairs) + } + .sink(withUnretained: self) { $0.friendActivitySummariesSubject.send($1) } + .store(in: &cancellables) + } } diff --git a/Friendly Competitions/Managers/HealthKit/HealthKitManager.swift b/Friendly Competitions/Managers/HealthKit/HealthKitManager.swift index 97d7e855..0cb3347e 100644 --- a/Friendly Competitions/Managers/HealthKit/HealthKitManager.swift +++ b/Friendly Competitions/Managers/HealthKit/HealthKitManager.swift @@ -1,5 +1,6 @@ import Combine import CombineExt +import ECKit import Factory import HealthKit @@ -8,6 +9,7 @@ protocol HealthKitManaging { var backgroundDeliveryReceived: AnyPublisher { get } var permissionStatus: AnyPublisher { get } func execute(_ query: HKQuery) + func registerBackgroundDeliveryTask(_ publisher: AnyPublisher) func requestPermissions() } @@ -15,22 +17,24 @@ final class HealthKitManager: HealthKitManaging { // MARK: - Public Properties - var backgroundDeliveryReceived: AnyPublisher { _backgroundDeliveryReceived.eraseToAnyPublisher() } + var backgroundDeliveryReceived: AnyPublisher { backgroundDeliveryReceivedSubject.eraseToAnyPublisher() } let permissionStatus: AnyPublisher // MARK: - Private Properties @Injected(Container.analyticsManager) private var analyticsManager + + private var cancellables = Cancellables() private let healthStore = HKHealthStore() - private let _backgroundDeliveryReceived = PassthroughSubject() - private let _permissionStatus: CurrentValueSubject<[HealthKitPermissionType: PermissionStatus], Never> + private let backgroundDeliveryReceivedSubject = PassthroughSubject() + private let permissionStatusSubject: CurrentValueSubject<[HealthKitPermissionType: PermissionStatus], Never> // MARK: - Initializers init() { - _permissionStatus = .init(UserDefaults.standard.decode([HealthKitPermissionType: PermissionStatus].self, forKey: "health_kit_permissions") ?? [:]) - permissionStatus = _permissionStatus + permissionStatusSubject = .init(UserDefaults.standard.decode([HealthKitPermissionType: PermissionStatus].self, forKey: "health_kit_permissions") ?? [:]) + permissionStatus = permissionStatusSubject .handleEvents(receiveOutput: { UserDefaults.standard.encode($0, forKey: "health_kit_permissions") }) .map { permissionStatuses in let hasUndetermined = HealthKitPermissionType.allCases @@ -60,7 +64,7 @@ final class HealthKitManager: HealthKitManaging { func requestPermissions() { let permissionsToRequest = HealthKitPermissionType.allCases - .filter { _permissionStatus.value[$0] != .authorized } + .filter { permissionStatusSubject.value[$0] != .authorized } healthStore.requestAuthorization( toShare: nil, @@ -69,29 +73,68 @@ final class HealthKitManager: HealthKitManaging { guard let strongSelf = self else { return } let permissionStatus: PermissionStatus = authorized ? .authorized : .denied strongSelf.analyticsManager.log(event: .healthKitPermissions(authorized: authorized)) - var currentPermissions = strongSelf._permissionStatus.value + var currentPermissions = strongSelf.permissionStatusSubject.value permissionsToRequest.forEach { currentPermissions[$0] = permissionStatus } - strongSelf._permissionStatus.send(currentPermissions) + strongSelf.permissionStatusSubject.send(currentPermissions) strongSelf.registerForBackgroundDelivery() } ) } + + private var backgroundDeliveryPublishers = [AnyPublisher]() + func registerBackgroundDeliveryTask(_ publisher: AnyPublisher) { + backgroundDeliveryPublishers.append(publisher) + } // MARK: - Private Methods private func registerForBackgroundDelivery() { let backgroundDeliveryTypes = HealthKitPermissionType.allCases - .filter { _permissionStatus.value[$0] == .authorized } + .filter { permissionStatusSubject.value[$0] == .authorized } .compactMap { $0.objectType as? HKSampleType } for sampleType in backgroundDeliveryTypes { - let query = HKObserverQuery(sampleType: sampleType, predicate: nil) { [weak self] query, completion, _ in - guard let self = self else { return } - self._backgroundDeliveryReceived.send() - completion() + let query = HKObserverQuery(sampleType: sampleType, predicate: nil) { [weak self] _, completion, _ in + guard let strongSelf = self else { return } + Publishers + .ZipMany(strongSelf.backgroundDeliveryPublishers) + .mapToVoid() + .sink(receiveValue: { + print("trigger bg delivery finished") + completion() + }) + .store(in: &strongSelf.cancellables) } healthStore.execute(query) healthStore.enableBackgroundDelivery(for: sampleType, frequency: .hourly) { _, _ in } } } } + +extension Publishers { + struct ZipMany: Publisher { + typealias Output = [Element] + typealias Failure = F + + private let upstreams: [AnyPublisher] + + init(_ upstreams: [AnyPublisher]) { + self.upstreams = upstreams + } + + func receive(subscriber: S) where Self.Failure == S.Failure, Self.Output == S.Input { + let initial = Just<[Element]>([]) + .setFailureType(to: F.self) + .eraseToAnyPublisher() + + let zipped = upstreams.reduce(into: initial) { result, upstream in + result = result.zip(upstream) { elements, element in + elements + [element] + } + .eraseToAnyPublisher() + } + + zipped.subscribe(subscriber) + } + } +} diff --git a/Friendly Competitions/Managers/Premium/PremiumManager.swift b/Friendly Competitions/Managers/Premium/PremiumManager.swift index 5aa47ac7..ee62733c 100644 --- a/Friendly Competitions/Managers/Premium/PremiumManager.swift +++ b/Friendly Competitions/Managers/Premium/PremiumManager.swift @@ -19,7 +19,7 @@ protocol PremiumManaging { func manageSubscription() } -final class PremiumManager: NSObject, PremiumManaging { +final class PremiumManager: PremiumManaging { enum PurchaseError: Error { case cancelled @@ -46,21 +46,10 @@ final class PremiumManager: NSObject, PremiumManaging { // MARK: - Lifecycle - override init() { + init() { premium = premiumSubject.eraseToAnyPublisher() products = productsSubject.eraseToAnyPublisher() - super.init() - - let apiKey: String - #if DEBUG - apiKey = "appl_REFBiyXbqcpKtUtawSUJezooOfQ" - #else - apiKey = "appl_PfCzNKLwrBPhZHDqVcrFOfigEHq" - #endif - - Purchases.configure(with: .init(withAPIKey: apiKey).with(usesStoreKit2IfAvailable: true)) - login() .subscribe(on: DispatchQueue.global(qos: .background)) .flatMapLatest(withUnretained: self) { $0.fetchStore() } @@ -89,14 +78,14 @@ final class PremiumManager: NSObject, PremiumManaging { .sink() .store(in: &cancellables) - customerInfoTask = .init { [weak self] in - guard let strongSelf = self else { return } - for try await _ in Purchases.shared.customerInfoStream { - strongSelf.restorePurchases() - .sink() - .store(in: &strongSelf.cancellables) - } - } +// customerInfoTask = .init { [weak self] in +// for try await _ in Purchases.shared.customerInfoStream { [weak self] in +// guard let strongSelf = self else { return } +// strongSelf.restorePurchases() +// .sink() +// .store(in: &strongSelf.cancellables) +// } +// } } // MARK: - Public Methods diff --git a/Friendly Competitions/Managers/UserManager.swift b/Friendly Competitions/Managers/UserManager.swift index 03dc1e2c..577d2665 100644 --- a/Friendly Competitions/Managers/UserManager.swift +++ b/Friendly Competitions/Managers/UserManager.swift @@ -40,6 +40,7 @@ final class UserManager: UserManaging { init(user: User) { userSubject = .init(user) userPublisher = userSubject + .removeDuplicates() .share(replay: 1) .eraseToAnyPublisher() diff --git a/Friendly Competitions/Managers/WorkoutManager.swift b/Friendly Competitions/Managers/WorkoutManager.swift index ed649405..d61c718f 100644 --- a/Friendly Competitions/Managers/WorkoutManager.swift +++ b/Friendly Competitions/Managers/WorkoutManager.swift @@ -10,7 +10,6 @@ import UIKit // sourcery: AutoMockable protocol WorkoutManaging { - func update() -> AnyPublisher func workouts(of type: WorkoutType, with metrics: [WorkoutMetric], in dateInterval: DateInterval) -> AnyPublisher<[Workout], Error> } @@ -18,48 +17,66 @@ final class WorkoutManager: WorkoutManaging { // MARK: - Private Properties + @Injected(Container.appState) private var appState @Injected(Container.competitionsManager) private var competitionsManager @Injected(Container.healthKitManager) private var healthKitManager @Injected(Container.userManager) private var userManager @Injected(Container.database) private var database - private let query = PassthroughSubject() - private let uploadFinished = PassthroughSubject() + private var cachedMetrics = [WorkoutType: [WorkoutMetric]]() + private var cancellables = Cancellables() // MARK: - Lifecycle init() { - Publishers - .Merge3( - healthKitManager.backgroundDeliveryReceived, - query, - UIApplication.willEnterForegroundNotification.publisher - ) - .debounce(for: .seconds(0.5), scheduler: RunLoop.main) - .flatMapLatest(withUnretained: self) { $0.requestWorkouts() } - .combineLatest(userManager.userPublisher) - .sinkAsync { [weak self] workouts, user in - guard let strongSelf = self else { return } - defer { strongSelf.uploadFinished.send() } - let batch = strongSelf.database.batch() - try workouts.forEach { workout in - let document = strongSelf.database.document("users/\(user.id)/workouts/\(workout.id)") - _ = try batch.setDataEncodable(workout, forDocument: document) + let fetchAndUpload = PassthroughSubject() + let fetchAndUploadFinished = PassthroughSubject() + + fetchAndUpload + .debounce(for: .seconds(1), scheduler: RunLoop.main) + .flatMapLatest(withUnretained: self) { strongSelf in + let dateInterval = strongSelf.competitionsManager.competitionsDateInterval + let publishers = strongSelf.cachedMetrics.map { workoutType, metrics in + strongSelf.workouts(of: workoutType, with: metrics, in: dateInterval) } - try await batch.commit() + return Publishers + .ZipMany(publishers) + .map { $0.reduce([], +) } + .eraseToAnyPublisher() } + .flatMapLatest(withUnretained: self) { $0.upload(workouts: $1) } + .sink(receiveCompletion: { _ in }, receiveValue: { fetchAndUploadFinished.send() }) .store(in: &cancellables) - } - - // MARK: - Public Methods - - func update() -> AnyPublisher { - uploadFinished - .handleEvents(receiveSubscription: { [weak self] _ in self?.query.send() }) + + appState.didBecomeActive + .filter { $0 } + .mapToVoid() + .flatMapLatest(withUnretained: self) { $0.fetchWorkoutMetrics() } + .sink() + .store(in: &cancellables) + + let backgroundDeliveryTrigger = Just(()) + .handleEvents(receiveSubscription: { _ in + fetchAndUpload.send() + }) + .flatMapLatest { fetchAndUploadFinished } .eraseToAnyPublisher() + + healthKitManager.registerBackgroundDeliveryTask(backgroundDeliveryTrigger) + + Publishers + .CombineLatest( + UIApplication.willEnterForegroundNotification.publisher, + competitionsManager.competitions + ) + .debounce(for: .seconds(1), scheduler: RunLoop.main) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in fetchAndUpload.send() }) + .store(in: &cancellables) } + // MARK: - Public Methods + func workouts(of type: WorkoutType, with metrics: [WorkoutMetric], in dateInterval: DateInterval) -> AnyPublisher<[Workout], Error> { .fromAsync { [weak self] in guard let strongSelf = self else { return [] } @@ -85,6 +102,34 @@ final class WorkoutManager: WorkoutManaging { } // MARK: - Private Methods + + private func upload(workouts: [Workout]) -> AnyPublisher { + .fromAsync { [weak self] in + guard let strongSelf = self else { return } + let userID = strongSelf.userManager.user.id + let batch = strongSelf.database.batch() + try workouts.forEach { workout in + let document = strongSelf.database.document("users/\(userID)/workouts/\(workout.id)") + _ = try batch.setDataEncodable(workout, forDocument: document) + } + try await batch.commit() + } + } + + private func fetchWorkoutMetrics() -> AnyPublisher<[WorkoutType: [WorkoutMetric]], Never> { + competitionsManager.competitions + .map { competitions -> [WorkoutType: [WorkoutMetric]] in + competitions.reduce(into: [WorkoutType: [WorkoutMetric]]()) { partialResult, competition in + guard case let .workout(workoutType, metrics) = competition.scoringModel else { return } + let metricsForWorkoutType = (partialResult[workoutType] ?? []) + .appending(contentsOf: metrics) + .uniqued() + partialResult[workoutType] = Array(metricsForWorkoutType) + } + } + .handleEvents(withUnretained: self, receiveOutput: { $0.cachedMetrics = $1 }) + .eraseToAnyPublisher() + } private func requestWorkouts() -> AnyPublisher<[Workout], Never> { competitionsManager.competitions diff --git a/Friendly Competitions/Preview Content/Helpers/PreviewHelper.swift b/Friendly Competitions/Preview Content/Helpers/PreviewHelper.swift index db473721..39582607 100644 --- a/Friendly Competitions/Preview Content/Helpers/PreviewHelper.swift +++ b/Friendly Competitions/Preview Content/Helpers/PreviewHelper.swift @@ -32,7 +32,6 @@ fileprivate enum Dependencies { static func baseSetupMocks() { activitySummaryManager.activitySummary = .just(nil) - activitySummaryManager.updateReturnValue = .just(()) activitySummaryManager.activitySummariesInReturnValue = .just([]) authenticationManager.emailVerified = .just(true) @@ -67,7 +66,6 @@ fileprivate enum Dependencies { userManager.userPublisher = .just(.evan) userManager.updateWithReturnValue = .just(()) - workoutManager.updateReturnValue = .just(()) workoutManager.workoutsOfWithInReturnValue = .just([]) } } diff --git a/Friendly Competitions/Sourcery/AutoMockable.generated.swift b/Friendly Competitions/Sourcery/AutoMockable.generated.swift index ff04fcb7..61f04299 100644 --- a/Friendly Competitions/Sourcery/AutoMockable.generated.swift +++ b/Friendly Competitions/Sourcery/AutoMockable.generated.swift @@ -56,24 +56,6 @@ class ActivitySummaryManagingMock: ActivitySummaryManaging { } } - //MARK: - update - - var updateCallsCount = 0 - var updateCalled: Bool { - return updateCallsCount > 0 - } - var updateReturnValue: AnyPublisher! - var updateClosure: (() -> AnyPublisher)? - - func update() -> AnyPublisher { - updateCallsCount += 1 - if let updateClosure = updateClosure { - return updateClosure() - } else { - return updateReturnValue - } - } - } class AnalyticsManagingMock: AnalyticsManaging { @@ -318,6 +300,11 @@ class CompetitionsManagingMock: CompetitionsManaging { var competitions: AnyPublisher<[Competition], Never>! var invitedCompetitions: AnyPublisher<[Competition], Never>! var appOwnedCompetitions: AnyPublisher<[Competition], Never>! + var competitionsDateInterval: DateInterval { + get { return underlyingCompetitionsDateInterval } + set(value) { underlyingCompetitionsDateInterval = value } + } + var underlyingCompetitionsDateInterval: DateInterval! //MARK: - accept @@ -859,6 +846,23 @@ class HealthKitManagingMock: HealthKitManaging { executeClosure?(query) } + //MARK: - registerBackgroundDeliveryTask + + var registerBackgroundDeliveryTaskCallsCount = 0 + var registerBackgroundDeliveryTaskCalled: Bool { + return registerBackgroundDeliveryTaskCallsCount > 0 + } + var registerBackgroundDeliveryTaskReceivedPublisher: AnyPublisher? + var registerBackgroundDeliveryTaskReceivedInvocations: [AnyPublisher] = [] + var registerBackgroundDeliveryTaskClosure: ((AnyPublisher) -> Void)? + + func registerBackgroundDeliveryTask(_ publisher: AnyPublisher) { + registerBackgroundDeliveryTaskCallsCount += 1 + registerBackgroundDeliveryTaskReceivedPublisher = publisher + registerBackgroundDeliveryTaskReceivedInvocations.append(publisher) + registerBackgroundDeliveryTaskClosure?(publisher) + } + //MARK: - requestPermissions var requestPermissionsCallsCount = 0 @@ -1070,24 +1074,6 @@ class UserManagingMock: UserManaging { } class WorkoutManagingMock: WorkoutManaging { - //MARK: - update - - var updateCallsCount = 0 - var updateCalled: Bool { - return updateCallsCount > 0 - } - var updateReturnValue: AnyPublisher! - var updateClosure: (() -> AnyPublisher)? - - func update() -> AnyPublisher { - updateCallsCount += 1 - if let updateClosure = updateClosure { - return updateClosure() - } else { - return updateReturnValue - } - } - //MARK: - workouts var workoutsOfWithInCallsCount = 0 diff --git a/Friendly Competitions/Views/Competitions/CompetitionView.swift b/Friendly Competitions/Views/Competitions/CompetitionView.swift index ec0c1153..085d8d10 100644 --- a/Friendly Competitions/Views/Competitions/CompetitionView.swift +++ b/Friendly Competitions/Views/Competitions/CompetitionView.swift @@ -90,7 +90,7 @@ struct CompetitionView: View { } header: { Text("Standings") } footer: { - if viewModel.standings.isEmpty { + if viewModel.standings.isEmpty && !viewModel.loadingStandings { Text("Nothing here, yet.") } } diff --git a/Friendly Competitions/Views/Competitions/Results/CompetitionResultsViewModel.swift b/Friendly Competitions/Views/Competitions/Results/CompetitionResultsViewModel.swift index 8cf0d710..f9afe0bc 100644 --- a/Friendly Competitions/Views/Competitions/Results/CompetitionResultsViewModel.swift +++ b/Friendly Competitions/Views/Competitions/Results/CompetitionResultsViewModel.swift @@ -44,10 +44,10 @@ final class CompetitionResultsViewModel: ObservableObject { .sorted(by: \.end) .reversed() .enumerated() - .map { offset, event in + .map { offset, result in CompetitionResultsDateRange( - start: event.start, - end: event.end, + start: result.start, + end: result.end, selected: offset == selectedIndex, locked: offset == 0 ? false : !hasPremium ) @@ -60,7 +60,7 @@ final class CompetitionResultsViewModel: ObservableObject { .map { $0.first(where: \.selected)?.locked ?? true } .assign(to: &$locked) - let currentResults = Publishers + let currentSelection = Publishers .CombineLatest(results, selectedIndex) .compactMap { results, selectedIndex -> (CompetitionResult, CompetitionResult?)? in if let previousIndex = selectedIndex <= results.count - 2 ? selectedIndex + 1 : nil { @@ -71,14 +71,14 @@ final class CompetitionResultsViewModel: ObservableObject { .handleEvents(withUnretained: self, receiveSubscription: { strongSelf, _ in strongSelf.loading = true }) Publishers - .CombineLatest(currentResults, $locked) + .CombineLatest(currentSelection, $locked) .flatMapLatest(withUnretained: self, { strongSelf, input in - let (results, locked) = input + let (currentSelection, locked) = input guard !locked else { return .just([]) } return Publishers .CombineLatest( - strongSelf.standingsDataPoints(currentResult: results.0, previousResult: results.1), - strongSelf.scoringDataPoints(currentResult: results.0, previousResult: results.1) + strongSelf.standingsDataPoints(currentResult: currentSelection.0, previousResult: currentSelection.1), + strongSelf.scoringDataPoints(currentResult: currentSelection.0, previousResult: currentSelection.1) ) .first() .map(+) diff --git a/Friendly Competitions/Views/Home/Home/HomeViewModel.swift b/Friendly Competitions/Views/Home/Home/HomeViewModel.swift index eaf93b6a..76e96973 100644 --- a/Friendly Competitions/Views/Home/Home/HomeViewModel.swift +++ b/Friendly Competitions/Views/Home/Home/HomeViewModel.swift @@ -40,6 +40,8 @@ final class HomeViewModel: ObservableObject { @UserDefault("competitionsFiltered", defaultValue: false) var competitionsFiltered @UserDefault("dismissedPremiumBanner", defaultValue: false) private var dismissedPremiumBanner + private var cancellables = Cancellables() + // MARK: - Lifecycle init() { @@ -83,6 +85,30 @@ final class HomeViewModel: ObservableObject { competitionsManager.competitions.assign(to: &$competitions) competitionsManager.invitedCompetitions.assign(to: &$invitedCompetitions) + Publishers + .CombineLatest4($competitions, $invitedCompetitions, $friendRows, appState.deepLink) + .sink(withUnretained: self) { strongSelf, result in + let (competitions, invitedCompetitions, friendRows, deepLink) = result + let homeScreenCompetitionIDs = Set(competitions.map(\.id) + invitedCompetitions.map(\.id)) + let homeScreenFriendIDs = Set(friendRows.map(\.user.id)) + + // remove stale navigation destinations (ex: leaving a competition, declining a friend invite) + strongSelf.navigationDestinations = strongSelf.navigationDestinations.filter { navigationDestination in + // accont for deep link to ensure that if comp/friend lists updates, then the deep link isn't dismissed + switch (navigationDestination, deepLink) { + case (.competition(let competition), .competition(id: let deepLinkedCompeititonID)): + return homeScreenCompetitionIDs.contains(competition.id) || deepLinkedCompeititonID == competition.id + case (.competitionResults(let competition), .competitionResults(id: let deepLinkedCompeititonID)): + return homeScreenCompetitionIDs.contains(competition.id) || deepLinkedCompeititonID == competition.id + case (.user(let user), .user(id: let deepLinkedUserID)): + return homeScreenFriendIDs.contains(user.id) || deepLinkedUserID == user.id + default: + return true + } + } + } + .store(in: &cancellables) + Publishers .CombineLatest(friendsManager.friends, friendsManager.friendRequests) .map { $0.with(false) + $1.with(true) } @@ -107,7 +133,6 @@ final class HomeViewModel: ObservableObject { $dismissedPremiumBanner, premiumManager.premium.map { $0 != nil } ) - .print("inputs") .handleEvents(withUnretained: self, receiveOutput: { strongSelf, result in let (_, premium) = result guard premium else { return } @@ -123,10 +148,6 @@ final class HomeViewModel: ObservableObject { .assign(to: &$title) } - func purchaseTapped() { - showPaywall.toggle() - } - func dismissPremiumBannerTapped() { analyticsManager.log(event: .premiumBannerDismissed) dismissedPremiumBanner.toggle() diff --git a/Friendly Competitions/Views/Home/RootView.swift b/Friendly Competitions/Views/Home/RootView.swift index ba3d0251..854dae1d 100644 --- a/Friendly Competitions/Views/Home/RootView.swift +++ b/Friendly Competitions/Views/Home/RootView.swift @@ -1,13 +1,18 @@ import SwiftUI struct RootView: View { + + @StateObject private var viewModel = RootViewModel() + var body: some View { - TabView { + TabView(selection: $viewModel.tab) { HomeView() .tabItem { Label("Home", systemImage: .houseFill) } + .tag(RootTab.home) ExploreView() .tabItem { Label("Explore", systemImage: .sparkleMagnifyingglass) } + .tag(RootTab.explore) } } } diff --git a/Friendly Competitions/Views/HomeContainerView.swift b/Friendly Competitions/Views/HomeContainerView.swift new file mode 100644 index 00000000..706a59ec --- /dev/null +++ b/Friendly Competitions/Views/HomeContainerView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct HomeContainerView: View { + var body: some View { + TabView { + HomeView() + .tabItem { Label("Home", systemImage: .houseFill) } + + ExploreView() + .tabItem { Label("Explore", systemImage: .sparkleMagnifyingglass) } + } + } +} + +#if DEBUG +struct HomeContainerView_Previews: PreviewProvider { + private static func setupMocks() { + activitySummaryManager.activitySummary = .just(.mock) + + competitionsManager.appOwnedCompetitions = .just([.mockPublic, .mockPublic]) + competitionsManager.competitions = .just([.mock, .mockInvited, .mockOld]) + competitionsManager.participantsForReturnValue = .just([.evan]) + competitionsManager.standingsForReturnValue = .just([.mock(for: .evan)]) + + let friend = User.gabby + friendsManager.friends = .just([friend]) + friendsManager.friendRequests = .just([friend]) + friendsManager.friendActivitySummaries = .just([friend.id: .mock]) + + permissionsManager.requiresPermission = .just(false) + permissionsManager.permissionStatus = .just([ + .health: .authorized, + .notifications: .authorized + ]) + } + + static var previews: some View { + HomeContainerView() + .setupMocks(setupMocks) + } +} +#endif diff --git a/Friendly Competitions/Views/RootTab.swift b/Friendly Competitions/Views/RootTab.swift new file mode 100644 index 00000000..d1ffd9d3 --- /dev/null +++ b/Friendly Competitions/Views/RootTab.swift @@ -0,0 +1,4 @@ +enum RootTab { + case home + case explore +} diff --git a/Friendly Competitions/Views/RootViewModel.swift b/Friendly Competitions/Views/RootViewModel.swift new file mode 100644 index 00000000..0acfb9f4 --- /dev/null +++ b/Friendly Competitions/Views/RootViewModel.swift @@ -0,0 +1,25 @@ +import Combine +import CombineExt +import Factory +import Foundation + +final class RootViewModel: ObservableObject { + + // MARK: - Public Properties + + @Published var tab = RootTab.home + + // MARK: - Private Properties + + @Injected(Container.appState) private var appState + + // MARK: - Lifecycle + + init() { + appState.deepLink + .unwrap() + .removeDuplicates() + .mapToValue(.home) + .assign(to: &$tab) + } +} diff --git a/Friendly Competitions/Views/Sign In/SignIn.swift b/Friendly Competitions/Views/Sign In/SignIn.swift index 41d37a91..9f6e928a 100644 --- a/Friendly Competitions/Views/Sign In/SignIn.swift +++ b/Friendly Competitions/Views/Sign In/SignIn.swift @@ -34,7 +34,7 @@ struct SignIn: View { ) Spacer() } else { - Image("logo") + Asset.Images.logo.swiftUIImage .resizable() .aspectRatio(contentMode: .fit) .padding() @@ -44,6 +44,13 @@ struct SignIn: View { } Spacer() + + #if DEBUG + Button(systemImage: .hammer) { + + } + #endif + VStack { Button(action: viewModel.submit) { Label("Sign in with Apple", systemImage: "applelogo") diff --git a/firebase/functions/src/Handlers/competitions/inviteUserToCompetition.ts b/firebase/functions/src/Handlers/competitions/inviteUserToCompetition.ts index f4816a96..628b949d 100644 --- a/firebase/functions/src/Handlers/competitions/inviteUserToCompetition.ts +++ b/firebase/functions/src/Handlers/competitions/inviteUserToCompetition.ts @@ -1,6 +1,6 @@ import { Competition } from "../../Models/Competition"; import { User } from "../../Models/User"; -import { sendNotificationsToUser } from "../../notifications"; +import { sendNotificationsToUser } from "../notifications/notifications"; import { getFirestore } from "../../Utilities/firstore"; /** diff --git a/firebase/functions/src/Handlers/competitions/sendNewCompetitionInvites.ts b/firebase/functions/src/Handlers/competitions/sendNewCompetitionInvites.ts index 4b6b624f..568d010e 100644 --- a/firebase/functions/src/Handlers/competitions/sendNewCompetitionInvites.ts +++ b/firebase/functions/src/Handlers/competitions/sendNewCompetitionInvites.ts @@ -1,7 +1,7 @@ import { getFirestore } from "firebase-admin/firestore"; import { Competition } from "../../Models/Competition"; import { User } from "../../Models/User"; -import { sendNotificationsToUser } from "../../notifications"; +import { sendNotificationsToUser } from "../notifications/notifications"; /** * Send invite notifications to all pending participants for a competition diff --git a/firebase/functions/src/Handlers/competitions/updateCompetitionStandings.ts b/firebase/functions/src/Handlers/competitions/updateCompetitionStandings.ts index a586548f..82e3f732 100644 --- a/firebase/functions/src/Handlers/competitions/updateCompetitionStandings.ts +++ b/firebase/functions/src/Handlers/competitions/updateCompetitionStandings.ts @@ -6,10 +6,8 @@ import { getFirestore } from "../../Utilities/firstore"; * @param {string} userID the ID of the user to update competition standings for * @return {Promise} A promise that resolves when completed */ -function updateCompetitionStandings(userID: string): Promise { - const firestore = getFirestore(); - - return firestore.collection("competitions") +function updateUserCompetitionStandings(userID: string): Promise { + return getFirestore().collection("competitions") .where("participants", "array-contains", userID) .get() .then(query => query.docs.map(doc => new Competition(doc))) @@ -17,6 +15,19 @@ function updateCompetitionStandings(userID: string): Promise { .then(); } +/** + * Update a competitions's standings + * @param {string} competitionID the ID of the competition to update standings for + * @return {Promise} A promise that resolves when completed + */ +function updateCompetitionStandings(competitionID: string): Promise { + return getFirestore().doc(`competitions/${competitionID}`) + .get() + .then(doc => new Competition(doc)) + .then(competition => competition.updateStandings()); +} + export { + updateUserCompetitionStandings, updateCompetitionStandings }; diff --git a/firebase/functions/src/Handlers/friends/handleFriendRequest.ts b/firebase/functions/src/Handlers/friends/handleFriendRequest.ts index cb89c3e2..81a7ad7d 100644 --- a/firebase/functions/src/Handlers/friends/handleFriendRequest.ts +++ b/firebase/functions/src/Handlers/friends/handleFriendRequest.ts @@ -1,5 +1,5 @@ import { User } from "../../Models/User"; -import { sendNotificationsToUser } from "../../notifications"; +import { sendNotificationsToUser } from "../notifications/notifications"; import { getFirestore } from "../../Utilities/firstore"; /** diff --git a/firebase/functions/src/Handlers/jobs/sendCompetitionCompleteNotifications.ts b/firebase/functions/src/Handlers/jobs/sendCompetitionCompleteNotifications.ts index a0fa2fe5..509ce0f0 100644 --- a/firebase/functions/src/Handlers/jobs/sendCompetitionCompleteNotifications.ts +++ b/firebase/functions/src/Handlers/jobs/sendCompetitionCompleteNotifications.ts @@ -3,7 +3,7 @@ import { Competition } from "../../Models/Competition"; import { Standing } from "../../Models/Standing"; import { User } from "../../Models/User"; import { getFirestore } from "../../Utilities/firstore"; -import * as notifications from "../../notifications"; +import * as notifications from "../notifications/notifications"; /** * Sends notifications to competition participants, updates history, and resets repeating competitions. @@ -44,7 +44,7 @@ async function sendCompetitionCompleteNotifications(): Promise { .then(async () => { await competition.recordResults(); await competition.updateRepeatingCompetition(); - await competition.updateStandings(); + await competition.resetStandings(); }); }); diff --git a/firebase/functions/src/Handlers/jobs/updateActivitySummaryScores.ts b/firebase/functions/src/Handlers/jobs/updateActivitySummaryScores.ts new file mode 100644 index 00000000..2b8f6dc5 --- /dev/null +++ b/firebase/functions/src/Handlers/jobs/updateActivitySummaryScores.ts @@ -0,0 +1,48 @@ +import { DocumentSnapshot } from "firebase-admin/firestore"; +import { ActivitySummary } from "../../Models/ActivitySummary"; +import { Competition } from "../../Models/Competition"; +import { Standing } from "../../Models/Standing"; +import { getFirestore } from "../../Utilities/firstore"; +import { prepareForFirestore } from "../../Utilities/prepareForFirestore"; + +async function updateActivitySummaryScores(userID: string, before?: DocumentSnapshot, after?: DocumentSnapshot) { + const firestore = getFirestore(); + const competitionsRef = await firestore.collection(`competitions`) + .where("participants", "array-contains", userID) + .get(); + const competitions = competitionsRef.docs.map(doc => new Competition(doc)); + + await competitions.forEach(async competition => { + if (!competition.isActive()) return; + + const standingDoc = firestore.doc(`competitions/${competition.id}/standings/${userID}`); + const standingRef = await standingDoc.get(); + let standing = Standing.new(0, userID); + if (standingRef.exists) standing = new Standing(standingRef); + + let pointsDiff = 0; + if (after == null && before != undefined) { // deleted + const beforeActivitySummary = new ActivitySummary(before); + if (!beforeActivitySummary.isIncludedInCompetition(competition)) return; + pointsDiff = -beforeActivitySummary.pointsFor(competition.scoringModel); + } else if (before == null && after != undefined) { // created + const afterActivitySummary = new ActivitySummary(after); + if (!afterActivitySummary.isIncludedInCompetition(competition)) return; + pointsDiff = afterActivitySummary.pointsFor(competition.scoringModel); + } else if (before != null && after != null) { // updated + const beforeActivitySummary = new ActivitySummary(before); + const afterActivitySummary = new ActivitySummary(after); + if (!beforeActivitySummary.isIncludedInCompetition(competition)) return; + if (!afterActivitySummary.isIncludedInCompetition(competition)) return; + pointsDiff = afterActivitySummary.pointsFor(competition.scoringModel) - beforeActivitySummary.pointsFor(competition.scoringModel); + } + standing.points += pointsDiff; + + await standingDoc.set(prepareForFirestore(standing)); + await competition.updateStandingRanks(); + }); +} + +export { + updateActivitySummaryScores +}; diff --git a/firebase/functions/src/Handlers/jobs/updateWorkoutScores.ts b/firebase/functions/src/Handlers/jobs/updateWorkoutScores.ts new file mode 100644 index 00000000..704567c4 --- /dev/null +++ b/firebase/functions/src/Handlers/jobs/updateWorkoutScores.ts @@ -0,0 +1,48 @@ +import { DocumentSnapshot } from "firebase-admin/firestore"; +import { Competition } from "../../Models/Competition"; +import { Standing } from "../../Models/Standing"; +import { Workout } from "../../Models/Workout"; +import { getFirestore } from "../../Utilities/firstore"; +import { prepareForFirestore } from "../../Utilities/prepareForFirestore"; + +async function updateWorkoutScores(userID: string, before?: DocumentSnapshot, after?: DocumentSnapshot) { + const firestore = getFirestore(); + const competitionsRef = await firestore.collection(`competitions`) + .where("participants", "array-contains", userID) + .get(); + const competitions = competitionsRef.docs.map(doc => new Competition(doc)); + + await competitions.forEach(async competition => { + if (!competition.isActive()) return; + + const standingDoc = firestore.doc(`competitions/${competition.id}/standings/${userID}`); + const standingRef = await standingDoc.get(); + let standing = Standing.new(0, userID); + if (standingRef.exists) standing = new Standing(standingRef); + + let pointsDiff = 0; + if (after == null && before != undefined) { // deleted + const beforeWorkout = new Workout(before); + if (!beforeWorkout.isIncludedInCompetition(competition)) return; + // pointsDiff = -beforeWorkout.points; + } else if (before == null && after != undefined) { // created + const afterWorkout = new Workout(after); + if (!afterWorkout.isIncludedInCompetition(competition)) return; + // pointsDiff = afterWorkout.points; + } else if (before != null && after != null) { // updated + const beforeWorkout = new Workout(before); + const afterWorkout = new Workout(after); + if (!beforeWorkout.isIncludedInCompetition(competition)) return; + if (!afterWorkout.isIncludedInCompetition(competition)) return; + // pointsDiff = afterWorkout.points - beforeWorkout.points; + } + standing.points += pointsDiff; + + await standingDoc.set(prepareForFirestore(standing)); + await competition.updateStandingRanks(); + }); +} + +export { + updateWorkoutScores +}; diff --git a/firebase/functions/src/notifications.ts b/firebase/functions/src/Handlers/notifications/notifications.ts similarity index 97% rename from firebase/functions/src/notifications.ts rename to firebase/functions/src/Handlers/notifications/notifications.ts index 8808d7c8..a9dea72e 100644 --- a/firebase/functions/src/notifications.ts +++ b/firebase/functions/src/Handlers/notifications/notifications.ts @@ -1,5 +1,5 @@ import * as admin from "firebase-admin"; -import { User } from "./Models/User"; +import { User } from "../../Models/User"; /** * Sends a notification to all of a user's notification tokens diff --git a/firebase/functions/src/Models/ActivitySummary.ts b/firebase/functions/src/Models/ActivitySummary.ts index e2d0f5c4..ea23852d 100644 --- a/firebase/functions/src/Models/ActivitySummary.ts +++ b/firebase/functions/src/Models/ActivitySummary.ts @@ -1,4 +1,5 @@ import { Competition } from "./Competition"; +import { RawScoringModel, ScoringModel } from "./ScoringModel"; /** * Activity summary @@ -36,6 +37,29 @@ class ActivitySummary { isIncludedInCompetition(competition: Competition): boolean { return this.date >= competition.start && this.date <= competition.end; } + + /** + * Calculate how many points are earned based on a scoring model + * @param {ScoringModel} scoringModel the scoring model for a given competition + * @return {number} the amount of points + */ + pointsFor(scoringModel: ScoringModel): number { + switch (scoringModel.type) { + case RawScoringModel.percentOfGoals: { + const energy = (this.activeEnergyBurned / this.activeEnergyBurnedGoal) * 100; + const exercise = (this.appleExerciseTime / this.appleExerciseTimeGoal) * 100; + const stand = (this.appleStandHours / this.appleStandHoursGoal) * 100; + return energy + exercise + stand; + } + case RawScoringModel.rawNumbers: { + return this.activeEnergyBurned + this.appleExerciseTime + this.appleStandHours; + } + case RawScoringModel.workout: { + return 0 + } + } + + } } export { diff --git a/firebase/functions/src/Models/Competition.ts b/firebase/functions/src/Models/Competition.ts index 11903797..80d84339 100644 --- a/firebase/functions/src/Models/Competition.ts +++ b/firebase/functions/src/Models/Competition.ts @@ -1,6 +1,7 @@ import * as admin from "firebase-admin"; import { getFirestore } from "firebase-admin/firestore"; import * as moment from "moment"; +import { prepareForFirestore } from "../Utilities/prepareForFirestore"; import { ActivitySummary } from "./ActivitySummary"; import { RawScoringModel, ScoringModel } from "./ScoringModel"; import { Standing } from "./Standing"; @@ -47,91 +48,124 @@ class Competition { this.end = new Date(endDateString); } - /** + /** * Updates the points and standings */ - async updateStandings(): Promise { - const standingPromises = this.participants.map(async userId => { - let totalPoints = 0; - - switch (this.scoringModel.type) { - case RawScoringModel.percentOfGoals: { - const activitySummaries = await this.activitySummaries(userId); - activitySummaries.forEach(activitySummary => { - const energy = (activitySummary.activeEnergyBurned / activitySummary.activeEnergyBurnedGoal) * 100; - const exercise = (activitySummary.appleExerciseTime / activitySummary.appleExerciseTimeGoal) * 100; - const stand = (activitySummary.appleStandHours / activitySummary.appleStandHoursGoal) * 100; - const points = energy + exercise + stand; - totalPoints += parseInt(`${points}`); - }); - break; - } - case RawScoringModel.rawNumbers: { - const activitySummaries = await this.activitySummaries(userId); - activitySummaries.forEach(activitySummary => { - const energy = activitySummary.activeEnergyBurned; - const exercise = activitySummary.appleExerciseTime; - const stand = activitySummary.appleStandHours; - const points = energy + exercise + stand; - totalPoints += parseInt(`${points}`); - }); - break; - } - case RawScoringModel.workout: { - const workoutType = this.scoringModel.workoutType; - const workoutMetrics = this.scoringModel.workoutMetrics; - if (workoutType != undefined && workoutMetrics != undefined) { - const workoutsPromise = await admin.firestore().collection(`users/${userId}/workouts`).get(); - workoutsPromise.docs - .map(doc => new Workout(doc)) - .filter(workout => workout.type == workoutType && workout.isIncludedInCompetition(this)) - .forEach(workout => { - workoutMetrics.forEach(metric => { - totalPoints += workout.points[metric]; + async updateStandings(): Promise { + const standingPromises = this.participants.map(async userId => { + let totalPoints = 0; + + switch (this.scoringModel.type) { + case RawScoringModel.percentOfGoals: { + const activitySummaries = await this.activitySummaries(userId); + activitySummaries.forEach(activitySummary => { + const energy = (activitySummary.activeEnergyBurned / activitySummary.activeEnergyBurnedGoal) * 100; + const exercise = (activitySummary.appleExerciseTime / activitySummary.appleExerciseTimeGoal) * 100; + const stand = (activitySummary.appleStandHours / activitySummary.appleStandHoursGoal) * 100; + const points = energy + exercise + stand; + totalPoints += parseInt(`${points}`); + }); + break; + } + case RawScoringModel.rawNumbers: { + const activitySummaries = await this.activitySummaries(userId); + activitySummaries.forEach(activitySummary => { + const energy = activitySummary.activeEnergyBurned; + const exercise = activitySummary.appleExerciseTime; + const stand = activitySummary.appleStandHours; + const points = energy + exercise + stand; + totalPoints += parseInt(`${points}`); + }); + break; + } + case RawScoringModel.workout: { + const workoutType = this.scoringModel.workoutType; + const workoutMetrics = this.scoringModel.workoutMetrics; + if (workoutType != undefined && workoutMetrics != undefined) { + const workoutsPromise = await admin.firestore().collection(`users/${userId}/workouts`).get(); + workoutsPromise.docs + .map(doc => new Workout(doc)) + .filter(workout => workout.type == workoutType && workout.isIncludedInCompetition(this)) + .forEach(workout => { + workoutMetrics.forEach(metric => { + totalPoints += workout.points[metric]; + }); }); - }); + } + break; } - break; - } - } - - if (isNaN(totalPoints)) { - totalPoints = 0; - console.error(`Encountered NaN when setting total points - competition: ${this.id} - user: ${userId} - `); - } - return Promise.resolve(Standing.new(totalPoints, userId)); - }); + } + + if (isNaN(totalPoints)) { + totalPoints = 0; + console.error(`Encountered NaN when setting total points + competition: ${this.id} + user: ${userId} + `); + } + return Promise.resolve(Standing.new(totalPoints, userId)); + }); + + return Promise.all(standingPromises) + .then(standings => { + const batch = admin.firestore().batch(); + standings + .sort((a, b) => a.points > b.points ? 1 : -1) + .reverse() + .forEach((standing, index) => { + standing.rank = index + 1; + const obj = Object.assign({}, standing); + const ref = admin.firestore().doc(`competitions/${this.id}/standings/${standing.userId}`); + batch.set(ref, obj); + }); + return batch.commit(); + }) + .then(); + } + + /** + * Fetch activity summaries for a user that fall within the bounds of this competition's start & end + * @param {string} userId The ID of the user to fetch activity summaries for + * @return {Promise} A promise of activity summaries + */ + async activitySummaries(userId: string): Promise { + const activitySummariesPromise = await admin.firestore().collection(`users/${userId}/activitySummaries`).get(); + return activitySummariesPromise.docs + .map(doc => new ActivitySummary(doc)) + .filter(activitySummary => activitySummary.isIncludedInCompetition(this)); + } - return Promise.all(standingPromises) - .then(standings => { - const batch = admin.firestore().batch(); - standings - .sort((a, b) => a.points > b.points ? 1 : -1) - .reverse() - .forEach((standing, index) => { - standing.rank = index + 1; - const obj = Object.assign({}, standing); - const ref = admin.firestore().doc(`competitions/${this.id}/standings/${standing.userId}`); - batch.set(ref, obj); - }); - return batch.commit(); - }) - .then(); + /** + * Reset the scores of standings to 0 + */ + async resetStandings(): Promise { + const standingsRef = await admin.firestore().collection(`competitions/${this.id}/standings`).get(); + const standings = standingsRef.docs.map(doc => new Standing(doc)); + const batch = admin.firestore().batch(); + standings.forEach(standing => { + standing.points = 0; + const ref = admin.firestore().doc(`competitions/${this.id}/standings/${standing.userId}`); + batch.set(ref, prepareForFirestore(standing)); + }); + await batch.commit(); } /** - * Fetch activity summaries for a user that fall within the bounds of this competition's start & end - * @param {string} userId The ID of the user to fetch activity summaries for - * @return {Promise} A promise of activity summaries + * Updates the points and standings */ - async activitySummaries(userId: string): Promise { - const activitySummariesPromise = await admin.firestore().collection(`users/${userId}/activitySummaries`).get(); - return activitySummariesPromise.docs - .map(doc => new ActivitySummary(doc)) - .filter(activitySummary => activitySummary.isIncludedInCompetition(this)); + async updateStandingRanks(): Promise { + const standingsRef = await admin.firestore().collection(`competitions/${this.id}/standings`).get(); + const standings = standingsRef.docs.map(doc => new Standing(doc)); + const batch = admin.firestore().batch(); + standings + .sort((a, b) => a.points > b.points ? 1 : -1) + .reverse() + .forEach((standing, index) => { + standing.rank = index + 1; + const ref = admin.firestore().doc(`competitions/${this.id}/standings/${standing.userId}`); + batch.set(ref, prepareForFirestore(standing)); + }); + await batch.commit(); } /** @@ -179,11 +213,17 @@ class Competition { standings.forEach(standing => { const ref = firestore.doc(`competitions/${this.id}/results/${end}/standings/${standing.userId}`); - const obj = Object.assign({}, standing); - batch.set(ref, obj); + batch.set(ref, prepareForFirestore(standing)); }); await batch.commit(); } + + isActive(): boolean { + const competitionStart = moment(this.start); + const competitionEnd = moment(this.end); + const now = moment(); + return now >= competitionStart && now <= competitionEnd; + } } export { diff --git a/firebase/functions/src/Models/Workout.ts b/firebase/functions/src/Models/Workout.ts index 3ac55f7a..1f358bdd 100644 --- a/firebase/functions/src/Models/Workout.ts +++ b/firebase/functions/src/Models/Workout.ts @@ -33,6 +33,12 @@ class Workout { isIncludedInCompetition(competition: Competition): boolean { return this.date >= competition.start && this.date <= competition.end; } + + pointsForMetrics(workoutMetrics: WorkoutMetric[]): number { + let total = 0; + workoutMetrics.forEach(workoutMetric => this.points[workoutMetric]); + return total; + } } export { diff --git a/firebase/functions/src/Utilities/prepareForFirestore.ts b/firebase/functions/src/Utilities/prepareForFirestore.ts new file mode 100644 index 00000000..c42acb58 --- /dev/null +++ b/firebase/functions/src/Utilities/prepareForFirestore.ts @@ -0,0 +1,11 @@ +/** + * Prepare an object for Firestore + * @param {any} object a JSON object ready for firestore upload + */ +function prepareForFirestore(object: any): any { + return Object.assign({}, object); +} + +export { + prepareForFirestore +}; diff --git a/firebase/functions/src/index.ts b/firebase/functions/src/index.ts index c327e007..1e73d06d 100644 --- a/firebase/functions/src/index.ts +++ b/firebase/functions/src/index.ts @@ -4,7 +4,6 @@ import { deleteAccount } from "./Handlers/account/deleteAccount"; import { deleteCompetition } from "./Handlers/competitions/deleteCompetition"; import { respondToCompetitionInvite } from "./Handlers/competitions/respondToCompetitionInvite"; import { inviteUserToCompetition } from "./Handlers/competitions/inviteUserToCompetition"; -import { updateCompetitionStandings } from "./Handlers/competitions/updateCompetitionStandings"; import { deleteFriend } from "./Handlers/friends/deleteFriend"; import { FriendRequestAction, handleFriendRequest } from "./Handlers/friends/handleFriendRequest"; import { joinCompetition } from "./Handlers/competitions/joinCompetition"; @@ -12,6 +11,7 @@ import { leaveCompetition } from "./Handlers/competitions/leaveCompetition"; import { cleanActivitySummaries } from "./Handlers/jobs/cleanActivitySummaries"; import { sendCompetitionCompleteNotifications } from "./Handlers/jobs/sendCompetitionCompleteNotifications"; import { sendNewCompetitionInvites } from "./Handlers/competitions/sendNewCompetitionInvites"; +import { updateCompetitionStandings, updateUserCompetitionStandings } from "./Handlers/competitions/updateCompetitionStandings"; admin.initializeApp(); @@ -40,21 +40,26 @@ exports.inviteUserToCompetition = functions.https.onCall((data, context) => { exports.respondToCompetitionInvite = functions.https.onCall((data, context) => { const caller = context.auth?.uid; - const competitionID = data.competitionID; + const competitionID: string = data.competitionID; const accept = data.accept; if (caller == null) return Promise.resolve(); return respondToCompetitionInvite(competitionID, caller, accept); }); -exports.updateCompetitionStandings = functions.https.onCall((_data, context) => { - const userID = context.auth?.uid; - if (userID == null) return Promise.resolve(); - return updateCompetitionStandings(userID); +exports.updateCompetitionStandings = functions.https.onCall((data, context) => { + const competitionID = data.competitionID; + if (competitionID == undefined) { + const userID = context.auth?.uid; + if (userID == null) return Promise.resolve(); + return updateUserCompetitionStandings(userID); + } else { + return updateCompetitionStandings(competitionID) + } }); exports.joinCompetition = functions.https.onCall((data, context) => { const competitionID = data.competitionID; - const userID = context.auth?.uid; + const userID: string | undefined = context.auth?.uid; if (userID == null) return Promise.resolve(); return joinCompetition(competitionID, userID); }); @@ -99,10 +104,10 @@ exports.deleteFriend = functions.https.onCall((data, context) => { // Jobs -exports.cleanStaleActivitySummaries = functions.pubsub.schedule("every day 02:00") +exports.cleanActivitySummaries = functions.pubsub.schedule("every sunday 02:00") .timeZone("America/Toronto") .onRun(async () => cleanActivitySummaries()); exports.sendCompetitionCompleteNotifications = functions.pubsub.schedule("every day 12:00") .timeZone("America/Toronto") - .onRun(async () => sendCompetitionCompleteNotifications()); + .onRun(async () => sendCompetitionCompleteNotifications()); \ No newline at end of file