├── .github └── workflows │ ├── deploy-firebase.yml │ ├── deploy-ios.yml │ └── test-ios.yml ├── .gitignore ├── .ruby-version ├── .sourcery.yml ├── .swiftlint.yml ├── .vscode └── settings.json ├── Brewfile ├── Brewfile.lock.json ├── CONTRIBUTING.md ├── FCKit ├── .gitignore ├── .sourcery.yml ├── .swiftpm │ └── xcode │ │ ├── package.xcworkspace │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcshareddata │ │ └── xcschemes │ │ └── FCKit.xcscheme ├── Package.swift ├── Sources │ ├── FCKit │ │ ├── Analytics │ │ │ ├── AnalyticsEvent.swift │ │ │ ├── AnalyticsManager+Factory.swift │ │ │ └── AnalyticsManager.swift │ │ ├── Database │ │ │ ├── Database+Factory.swift │ │ │ ├── Database+Firebase.swift │ │ │ ├── Database.swift │ │ │ └── Settings │ │ │ │ ├── DatabaseSettingsManager.swift │ │ │ │ └── DatabaseSettingsStore.swift │ │ ├── Environment │ │ │ ├── EnvironmentCache.swift │ │ │ ├── EnvironmentManager+Factory.swift │ │ │ ├── EnvironmentManager.swift │ │ │ └── FCEnvironment.swift │ │ ├── Extensions │ │ │ ├── Combine │ │ │ │ └── Publisher+Extensions.swift │ │ │ ├── Foundation │ │ │ │ └── UserDefaults+Extensions.swift │ │ │ └── StdLib │ │ │ │ └── Error+Extensions.swift │ │ ├── FeatureFlag │ │ │ ├── FeatureFlag.swift │ │ │ ├── FeatureFlagManager+Factory.swift │ │ │ ├── FeatureFlagManager.swift │ │ │ └── Flags │ │ │ │ ├── FeatureFlagBool.swift │ │ │ │ ├── FeatureFlagDouble.swift │ │ │ │ └── FeatureFlagString.swift │ │ ├── Firebase │ │ │ └── FirebaseConfigureHelper.swift │ │ └── Util │ │ │ ├── AppGroup.swift │ │ │ ├── PostDecoded │ │ │ ├── DateToEndOfDay.swift │ │ │ ├── DateToStartOfDay.swift │ │ │ └── PostDecoded.swift │ │ │ └── WidgetIdentifier.swift │ └── FCKitMocks │ │ ├── AutoMockable.generated.swift │ │ └── Database+Mock.swift ├── Tests │ └── FCKitTests │ │ └── Environment │ │ └── EnvironmentManagerTests.swift └── sourcery │ └── templates │ └── AutoMockable.stencil ├── FriendlyCompetitions.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ ├── WorkspaceSettings.xcsettings │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── FriendlyCompetitions.xcscheme │ └── FriendlyCompetitionsWidgets.xcscheme ├── FriendlyCompetitions ├── API │ ├── API+Factory.swift │ ├── API+Firebase.swift │ ├── API.swift │ └── Endpoint.swift ├── AppDelegate.swift ├── AppLauncher.swift ├── AppServices │ ├── AppService.swift │ ├── AppServices+Factory.swift │ ├── BackgroundJobs │ │ ├── BackgroundJob.swift │ │ ├── BackgroundJobsAppService.swift │ │ └── Jobs │ │ │ ├── FetchCompetitionResultsBackgroundJob.swift │ │ │ └── FetchDocumentBackgroundJob.swift │ ├── DataUploading │ │ └── DataUploadingAppService.swift │ ├── Developer │ │ └── DeveloperAppService.swift │ ├── Firebase │ │ ├── FCAppCheckProviderFactory.swift │ │ └── FirebaseAppService.swift │ ├── GoogleAds │ │ └── GoogleAdsAppService.swift │ ├── Notifications │ │ └── NotificationsAppService.swift │ └── Storage │ │ └── StorageAppService.swift ├── AppState │ ├── AppState+Factory.swift │ └── AppState.swift ├── FriendlyCompetitions.entitlements ├── FriendlyCompetitions.swift ├── FriendlyCompetitionsAppModel.swift ├── FriendlyCompetitionsTests.swift ├── Info.plist ├── IssueReporting │ ├── IssueReporting+Factory.swift │ └── IssueReporting.swift ├── Managers │ ├── ActivitySummary │ │ ├── ActivitySummaryCache.swift │ │ ├── ActivitySummaryManager+Factory.swift │ │ └── ActivitySummaryManager.swift │ ├── Authentication │ │ ├── AuthProviding │ │ │ ├── AuthProviding+Firebase.swift │ │ │ └── AuthProviding.swift │ │ ├── AuthenticationCache.swift │ │ ├── AuthenticationError.swift │ │ ├── AuthenticationManager+Factory.swift │ │ ├── AuthenticationManager.swift │ │ ├── AuthenticationMethod.swift │ │ └── SignInWithAppleProvider.swift │ ├── BackgroundRefresh │ │ ├── BackgroundRefreshManager+Factory.swift │ │ ├── BackgroundRefreshManager.swift │ │ └── BackgroundRefreshStatus.swift │ ├── Banner │ │ ├── BannerManager+Factory.swift │ │ └── BannerManager.swift │ ├── Competitions │ │ ├── CompetitionsManager+Factory.swift │ │ └── CompetitionsManager.swift │ ├── Friends │ │ ├── FriendsManager+Factory.swift │ │ └── FriendsManager.swift │ ├── HealthKit │ │ ├── HealthKitBackgroundDeliveryError.swift │ │ ├── HealthKitManager+Factory.swift │ │ ├── HealthKitManager.swift │ │ ├── HealthKitPermissionType.swift │ │ ├── HealthKitQuery.swift │ │ └── HealthStoring.swift │ ├── Notifications │ │ ├── NotificationsManager+Factory.swift │ │ └── NotificationsManager.swift │ ├── Search │ │ ├── SearchClient.swift │ │ ├── SearchManager+Factory.swift │ │ └── SearchManager.swift │ ├── StepCount │ │ ├── StepCount.swift │ │ ├── StepCountManager+Factory.swift │ │ └── StepCountManager.swift │ ├── Storage │ │ ├── StorageManager+Factory.swift │ │ └── StorageManager.swift │ ├── User │ │ ├── UserManager+Factory.swift │ │ └── UserManager.swift │ └── Workout │ │ ├── WorkoutManager+Factory.swift │ │ └── WorkoutManager.swift ├── Models │ ├── ActivitySummary │ │ └── ActivitySummary.swift │ ├── Competition │ │ ├── Competition+ScoringModel.swift │ │ ├── Competition+Standing.swift │ │ ├── Competition.swift │ │ └── CompetitionResult.swift │ ├── DeepLink │ │ └── DeepLink.swift │ ├── Permission │ │ ├── Permission.swift │ │ └── PermissionStatus.swift │ ├── Stored.swift │ ├── User │ │ ├── User+Statistics.swift │ │ ├── User+Tag.swift │ │ ├── User+Visibility.swift │ │ └── User.swift │ └── Workouts │ │ ├── Workout.swift │ │ ├── WorkoutMetric.swift │ │ └── WorkoutType.swift ├── PreviewContent │ ├── Helpers │ │ └── PreviewHelper.swift │ ├── Mocks │ │ └── Models │ │ │ ├── ActivitySummary+Mock.swift │ │ │ ├── Competition+Mock.swift │ │ │ ├── User+Mock.swift │ │ │ └── Workout+Mock.swift │ └── Preview Assets.xcassets │ │ └── Contents.json ├── PrivacyInfo.xcprivacy ├── Resources │ ├── Assets │ │ ├── Colors.xcassets │ │ │ ├── Branded │ │ │ │ ├── Blue.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── Contents.json │ │ │ │ ├── Green.colorset │ │ │ │ │ └── Contents.json │ │ │ │ └── Red.colorset │ │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ └── Images.xcassets │ │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── Icon-App-20x20@1x.png │ │ │ ├── Icon-App-20x20@2x.png │ │ │ ├── Icon-App-20x20@3x.png │ │ │ ├── Icon-App-29x29@1x.png │ │ │ ├── Icon-App-29x29@2x.png │ │ │ ├── Icon-App-29x29@3x.png │ │ │ ├── Icon-App-40x40@1x.png │ │ │ ├── Icon-App-40x40@2x.png │ │ │ ├── Icon-App-40x40@3x.png │ │ │ ├── Icon-App-60x60@2x.png │ │ │ ├── Icon-App-60x60@3x.png │ │ │ ├── Icon-App-76x76@1x.png │ │ │ ├── Icon-App-76x76@2x.png │ │ │ ├── Icon-App-83.5x83.5@2x.png │ │ │ └── ItunesArtwork@2x.png │ │ │ ├── Contents.json │ │ │ ├── Permissions │ │ │ ├── Contents.json │ │ │ ├── health.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── health.png │ │ │ └── notifications.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── notifications.png │ │ │ ├── Privacy │ │ │ ├── Contents.json │ │ │ ├── nameHidden.imageset │ │ │ │ ├── Contents.json │ │ │ │ ├── nameHiddenDark.png │ │ │ │ └── nameHiddenLight.png │ │ │ └── nameShown.imageset │ │ │ │ ├── Contents.json │ │ │ │ ├── nameShownDark.png │ │ │ │ └── nameShownLight.png │ │ │ └── logo.imageset │ │ │ ├── Contents.json │ │ │ └── raw.png │ └── Strings │ │ ├── en.lproj │ │ └── Localizable.strings │ │ └── fr-CA.lproj │ │ └── Localizable.strings ├── Sourcery │ └── AutoMockable.generated.swift ├── SwiftGen │ ├── Strings+Generated.swift │ └── XCAssets+Generated.swift ├── Utility │ ├── AppInfo.swift │ ├── ArrayBuilder.swift │ ├── Device.swift │ ├── Extensions │ │ ├── Foundation │ │ │ ├── Array+Extensions.swift │ │ │ ├── DateInterval+Extensions.swift │ │ │ ├── NotificationName+Extensions.swift │ │ │ └── URL+Extensions.swift │ │ ├── HealthKit │ │ │ ├── HKActivitySummary+Extensions.swift │ │ │ └── HKWorkoutActivityType+Extensions.swift │ │ ├── Std Lib │ │ │ └── Dictionary+Extensions.swift │ │ ├── StdLib │ │ │ └── String+AnonymousName.swift │ │ └── SwiftUI │ │ │ ├── Binding+Extensions.swift │ │ │ ├── Button+Extensions.swift │ │ │ ├── ColorScheme+Extensions.swift │ │ │ ├── Text+Extensions.swift │ │ │ ├── View+Analytics.swift │ │ │ ├── View+ConfirmationDialog.swift │ │ │ ├── View+HUD.swift │ │ │ └── View+RemovingMargin.swift │ ├── Nonce │ │ └── Nonce.swift │ └── Utility+Factory.swift └── Views │ ├── CompetitionContainer │ ├── Competition │ │ ├── Competition+Banners.swift │ │ ├── CompetitionParticipantRow.swift │ │ ├── CompetitionView.swift │ │ ├── CompetitionViewAction.swift │ │ └── CompetitionViewModel.swift │ ├── CompetitionContainerDateRange.swift │ ├── CompetitionContainerDateRangeSelector.swift │ ├── CompetitionContainerView.swift │ ├── CompetitionContainerViewModel.swift │ ├── Edit │ │ ├── CompetitionEditView.swift │ │ ├── CompetitionEditViewModel.swift │ │ └── ScoringModelLearnMoreView.swift │ └── Results │ │ ├── CompetitionResultsDataPoint+View.swift │ │ ├── CompetitionResultsDataPoint.swift │ │ ├── CompetitionResultsView.swift │ │ └── CompetitionResultsViewModel.swift │ ├── Components │ ├── ActivitySummaryInfo │ │ ├── ActivitySummaryInfoSource.swift │ │ ├── ActivitySummaryInfoView.swift │ │ ├── ActivitySummaryInfoViewModel.swift │ │ └── Health Kit Permissions Guide │ │ │ ├── HealthKitPermissionsGuideView.swift │ │ │ └── HealthKitPermissionsGuideViewModel.swift │ ├── AppIcon.swift │ ├── Banner.swift │ ├── CompetitionDetails │ │ ├── CompetitionDetails.swift │ │ └── CompetitionDetailsViewModel.swift │ ├── EmailSignIn │ │ ├── EmailSignInView.swift │ │ ├── EmailSignInViewInputType.swift │ │ └── EmailSignInViewModel.swift │ ├── FirebaseImage │ │ ├── FirebaseImage.swift │ │ └── FirebaseImageViewModel.swift │ ├── GoogleAd │ │ ├── GoogleAd.swift │ │ ├── GoogleAdUnit.swift │ │ └── GoogleAdViewModel.swift │ ├── ImmutableListItemView.swift │ ├── MedalsView.swift │ ├── ProfilePicture │ │ ├── ImageEditor.swift │ │ └── ProfilePicture.swift │ ├── SignInWithAppleButton.swift │ ├── TextFieldWithSecureToggle.swift │ ├── UIKitWrappers │ │ └── ActivityRingView.swift │ ├── UserHashIDPill.swift │ └── UserInfoSection.swift │ ├── Developer │ ├── DeveloperMenu.swift │ └── DeveloperMenuViewModel.swift │ ├── Explore │ ├── ExploreView.swift │ ├── ExploreViewModel.swift │ └── FeaturedCompetition.swift │ ├── FeatureFlagOverride │ ├── FeatureFlagOverrideView.swift │ └── FeatureFlagOverrideViewModel.swift │ ├── Home │ ├── HomeView.swift │ ├── HomeViewEmptyContent.swift │ ├── HomeViewModel.swift │ ├── Notifications │ │ ├── NotificationsView.swift │ │ └── NotificationsViewModel.swift │ └── User │ │ ├── UserView.swift │ │ ├── UserViewAction.swift │ │ └── UserViewModel.swift │ ├── InviteFriends │ ├── InviteFriendsAction.swift │ ├── InviteFriendsView.swift │ └── InviteFriendsViewModel.swift │ ├── NavigationDestination.swift │ ├── RootTab.swift │ ├── RootView.swift │ ├── RootViewModel.swift │ ├── Settings │ ├── Components │ │ └── HideNameLearnMoreView.swift │ ├── CreateAccount │ │ ├── CreateAccountView.swift │ │ └── CreateAccountViewModel.swift │ ├── ReportIssue │ │ ├── ReportIssueView.swift │ │ └── ReportIssueViewModel.swift │ ├── SettingsView.swift │ └── SettingsViewModel.swift │ ├── Styles │ └── FlatLinkStyle.swift │ └── Welcome │ ├── VerifyEmail │ ├── VerifyEmailView.swift │ └── VerifyEmailViewModel.swift │ ├── WelcomeNavigationDestination.swift │ ├── WelcomeView.swift │ └── WelcomeViewModel.swift ├── FriendlyCompetitionsTests ├── API │ └── EndpointTests.swift ├── AppServices │ ├── Background Jobs │ │ └── BackgroundJobsAppServiceTests.swift │ ├── Data Uploading │ │ └── DataUploadingAppServiceTests.swift │ └── Notifications │ │ └── NotificationsAppServiceTests.swift ├── AppState │ └── AppStateTests.swift ├── Extensions │ └── StdLib │ │ ├── ArrayTests.swift │ │ ├── DecodableTests.swift │ │ ├── EncodableTests.swift │ │ ├── IntTests.swift │ │ ├── SequenceTests.swift │ │ └── StringTests.swift ├── Managers │ ├── ActivitySummaryManagerTests.swift │ ├── AuthenticationManagerTests.swift │ ├── CompetitionsManagerTests.swift │ ├── FriendsManagerTests.swift │ ├── HealthKitManagerTests.swift │ ├── SearchManagerTests.swift │ ├── StepCountManagerTests.swift │ └── WorkoutManagerTests.swift ├── Models │ ├── ActivitySummary │ │ └── ActivitySummaryTests.swift │ ├── Competition │ │ ├── Competition+ScoringModelTests.swift │ │ ├── Competition+StandingTests.swift │ │ └── CompetitionTests.swift │ ├── Deep lInk │ │ └── DeepLinkTests.swift │ ├── Permissions │ │ └── PermissionStatusTests.swift │ ├── User │ │ └── UserTests.swift │ └── Workouts │ │ ├── WorkoutTests.swift │ │ └── WorkoutTypeTests.swift ├── Util │ ├── Extensions │ │ ├── ActivitySummary+Extensions.swift │ │ ├── Foundation │ │ │ ├── TimeIntervalTests.swift │ │ │ └── UserDefaultsTests.swift │ │ └── User+Extensions.swift │ ├── FCTestCase.swift │ ├── Mocks │ │ ├── MockError.swift │ │ └── SearchIndex+Mock.swift │ └── Publisher+Extensions.swift └── Views │ ├── Competitions │ ├── CompetitionParticipantRowConfigTests.swift │ └── CompetitionViewModelTests.swift │ ├── Components │ ├── ActivitySummaryInfoViewModelTests.swift │ ├── CompetitionDetailsViewModelTests.swift │ ├── EmailSignInViewModelTests.swift │ └── FirebaseImageViewModelTests.swift │ ├── Explore │ └── ExploreViewModelTests.swift │ ├── Home │ ├── HomeViewModelTests.swift │ └── Profile │ │ ├── Create Account │ │ └── CreateAccountViewModelTests.swift │ │ └── ProfileViewModelTests.swift │ ├── Invite Friends │ └── InviteFriendsViewModelTests.swift │ ├── NavigationDestinationTests.swift │ ├── RootViewModelTests.swift │ └── Welcome │ ├── Verify Email │ └── VerifyEmailViewModelTests.swift │ └── WelcomeViewModelTests.swift ├── FriendlyCompetitionsWidgets ├── CompetitionStandings │ ├── CompetitionStandingsIntent.swift │ ├── CompetitionStandingsWidget.swift │ └── Models │ │ ├── WidgetCompetition.swift │ │ └── WidgetStanding.swift ├── FriendlyCompetitionsWidgetsBundle.swift ├── FriendlyCompetitionsWidgetsExtension.entitlements ├── Info.plist ├── Network │ ├── AuthenticationAction.swift │ ├── CollectionRequest.swift │ ├── DocumentRequest.swift │ ├── FirestoreDocument.swift │ └── Network.swift └── Resources │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── Contents.json │ ├── WidgetBackground.colorset │ │ └── Contents.json │ └── icon.imageset │ │ ├── Contents.json │ │ └── ItunesArtwork@2x.png │ └── Localizable.xcstrings ├── Gemfile ├── Gemfile.lock ├── README.md ├── fastlane ├── Appfile ├── Deliverfile ├── Fastfile ├── Matchfile ├── Pluginfile ├── README.md ├── lanes │ ├── certificates.rb │ ├── deploy.rb │ └── test.rb ├── metadata │ ├── copyright.txt │ ├── en-CA │ │ ├── description.txt │ │ ├── keywords.txt │ │ ├── marketing_url.txt │ │ ├── name.txt │ │ ├── privacy_url.txt │ │ ├── release_notes.txt │ │ ├── subtitle.txt │ │ └── support_url.txt │ ├── primary_category.txt │ ├── review_information │ │ ├── demo_password.txt │ │ ├── demo_user.txt │ │ ├── email_address.txt │ │ ├── first_name.txt │ │ ├── last_name.txt │ │ ├── notes.txt │ │ └── phone_number.txt │ └── secondary_category.txt ├── screenshots │ ├── README.txt │ └── en-CA │ │ ├── iPhone 11 Pro Max 1.png │ │ ├── iPhone 11 Pro Max 2.png │ │ ├── iPhone 11 Pro Max 3.png │ │ ├── iPhone 11 Pro Max 4.png │ │ ├── iPhone 8 Plus 1.png │ │ ├── iPhone 8 Plus 2.png │ │ ├── iPhone 8 Plus 3.png │ │ └── iPhone 8 Plus 4.png └── scripts │ └── upload-symbols ├── firebase ├── .firebaserc ├── .gitignore ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── functions │ ├── .eslintrc.js │ ├── .gitignore │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── Handlers │ │ │ ├── account │ │ │ │ ├── accountSetup.ts │ │ │ │ ├── deleteAccount.ts │ │ │ │ └── signInWithAppleToken.ts │ │ │ ├── competitions │ │ │ │ ├── deleteCompetition.ts │ │ │ │ ├── inviteUserToCompetition.ts │ │ │ │ ├── joinCompetition.ts │ │ │ │ ├── leaveCompetition.ts │ │ │ │ ├── respondToCompetitionInvite.ts │ │ │ │ ├── sendNewCompetitionInvites.ts │ │ │ │ ├── updateCompetitionRanks.ts │ │ │ │ └── updateCompetitionStandingsLEGACY.ts │ │ │ ├── friends │ │ │ │ ├── deleteFriend.ts │ │ │ │ └── handleFriendRequest.ts │ │ │ ├── jobs │ │ │ │ ├── cleanupActivitySummaries.ts │ │ │ │ ├── cleanupWorkouts.ts │ │ │ │ ├── completeCompetitions.ts │ │ │ │ ├── handleCompetitionCreate.ts │ │ │ │ ├── handleCompetitionUpdate.ts │ │ │ │ ├── updateActivitySummaryScores.ts │ │ │ │ ├── updateScores.ts │ │ │ │ ├── updateStepCountScores.ts │ │ │ │ └── updateWorkoutScores.ts │ │ │ ├── notifications │ │ │ │ └── notifications.ts │ │ │ └── standings │ │ │ │ └── setStandingRanks.ts │ │ ├── Models │ │ │ ├── ActivitySummary.ts │ │ │ ├── Competition.ts │ │ │ ├── Helpers │ │ │ │ └── EnumDictionary.ts │ │ │ ├── ScoringModel.ts │ │ │ ├── Standing.ts │ │ │ ├── StepCount.ts │ │ │ ├── User.ts │ │ │ ├── Workout.ts │ │ │ ├── WorkoutMetric.ts │ │ │ └── WorkoutType.ts │ │ ├── Utilities │ │ │ ├── Constants.ts │ │ │ ├── firestore.ts │ │ │ ├── id.ts │ │ │ └── prepareForFirestore.ts │ │ └── index.ts │ ├── tsconfig.dev.json │ └── tsconfig.json ├── package-lock.json ├── package.json └── scripts │ ├── deploy.sh │ ├── emulate.sh │ ├── lint.sh │ └── ports.sh ├── icon ├── ios │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-App-20x20@1x.png │ │ ├── Icon-App-20x20@2x.png │ │ ├── Icon-App-20x20@3x.png │ │ ├── Icon-App-29x29@1x.png │ │ ├── Icon-App-29x29@2x.png │ │ ├── Icon-App-29x29@3x.png │ │ ├── Icon-App-40x40@1x.png │ │ ├── Icon-App-40x40@2x.png │ │ ├── Icon-App-40x40@3x.png │ │ ├── Icon-App-60x60@2x.png │ │ ├── Icon-App-60x60@3x.png │ │ ├── Icon-App-76x76@1x.png │ │ ├── Icon-App-76x76@2x.png │ │ ├── Icon-App-83.5x83.5@2x.png │ │ └── ItunesArtwork@2x.png │ ├── iTunesArtwork@1x.png │ ├── iTunesArtwork@2x.png │ └── iTunesArtwork@3x.png └── square.png ├── screenshots ├── 6.5 │ ├── competition.jpeg │ ├── explore.jpeg │ ├── home.jpeg │ └── new_competition.jpeg └── mockups │ ├── Friendly Competitions.mockup │ └── studio.app-mockup.com ├── sourcery └── templates │ └── AutoMockable.stencil └── swiftgen.yml /.github/workflows/deploy-firebase.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Firebase 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - 'firebase/**' 8 | 9 | jobs: 10 | build: 11 | name: Deploy 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@latest 16 | - name: Install firebase-tools 17 | run: npm install -g firebase-tools 18 | - name: Install dependencies 19 | run: cd firebase && npm install 20 | - name: Install functions dependencies 21 | run: cd firebase/functions && npm install 22 | - name: Build & deploy 23 | run: cd firebase && firebase deploy --token "$FIREBASE_TOKEN" --non-interactive 24 | env: 25 | FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/workflows/test-ios.yml: -------------------------------------------------------------------------------- 1 | name: Run unit tests 2 | 3 | on: 4 | pull_request: 5 | branches: main 6 | paths: 7 | - FriendlyCompetitions.xcodeproj/** 8 | - FriendlyCompetitions/** 9 | - FriendlyCompetitionsTests/** 10 | - FriendlyCompetitionsWidgets/** 11 | - FCKit/** 12 | 13 | concurrency: 14 | group: ${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | build: 19 | name: Test 20 | runs-on: macos-15 21 | env: 22 | BUILDCACHE_DEBUG: 2 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@latest 26 | with: 27 | fetch-depth: 0 28 | 29 | - name: Setup SSH 30 | uses: webfactory/ssh-agent@latest 31 | with: 32 | ssh-private-key: ${{ secrets.SSH_KEY }} 33 | 34 | - name: Cache Swift Packages 35 | uses: actions/cache@latest 36 | with: 37 | path: ~/Library/Developer/Xcode/DerivedData/**/SourcePackages/ 38 | key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} 39 | restore-keys: | 40 | ${{ runner.os }}-spm- 41 | 42 | - name: Setup Ruby 43 | uses: ruby/setup-ruby@latest 44 | with: 45 | bundler-cache: true 46 | 47 | - name: Setup GoogleService-Info.plist 48 | run: | 49 | mkdir -p "FriendlyCompetitions/Firebase/Debug" 50 | echo $GOOGLE_SERVICE_INFO_DEBUG > "FriendlyCompetitions/Firebase/Debug/GoogleService-Info.plist" 51 | env: 52 | GOOGLE_SERVICE_INFO_DEBUG: ${{ secrets.GOOGLE_SERVICE_INFO_DEBUG }} 53 | 54 | - name: Homebrew 55 | run: brew bundle 56 | 57 | - name: Fastlane 58 | run: bundle exec fastlane test 59 | env: 60 | FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120 61 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.3 -------------------------------------------------------------------------------- /.sourcery.yml: -------------------------------------------------------------------------------- 1 | sources: 2 | - FriendlyCompetitions 3 | templates: 4 | - sourcery/templates 5 | output: 6 | FriendlyCompetitions/Sourcery 7 | args: 8 | autoMockableImports: 9 | - Combine 10 | - HealthKit 11 | 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "requestee" 4 | ] 5 | } -------------------------------------------------------------------------------- /Brewfile: -------------------------------------------------------------------------------- 1 | # brew "swiftgen" # swiftgen is currently failing to install 2 | brew "sourcery" -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Setup for contributors 2 | ## Homebrew 3 | Instal [homebrew](https://brew.sh) and run the following command: 4 | ```shell 5 | brew bundle 6 | ``` 7 | This command will need to be re-run everytime [Brewfile](Brewfile) changes (almost never) 8 | 9 | ## Firebase 10 | Access to a Firebase project is required. You can create one for youself in the [firebase console](https://console.firebase.google.com). Follow the instructions to setup a project and download the `GoogleService-Info.plist`. You'll need to place it in one of the following directories, depending on the scheme that you plan to run: 11 | - `Friendly Competitions/Firebase/Debug/` 12 | - `Friendly Competitions/Firebase/Release/` 13 | 14 | The app uses the following firebase services: 15 | 16 | ### [Authentication](https://firebase.google.com/docs/auth) 17 | Manages user authentication. Users need to be signed in to access any & all features 18 | - Apple 19 | - Email 20 | 21 | ### [Firestore](https://firebase.google.com/docs/firestore) 22 | Stores all user & competition data 23 | 24 | ### [Functions](https://firebase.google.com/docs/functions) 25 | Sends notifications, computes competition scores, cleans up old data. 26 | 27 | To deploy this project's functions, run the following commands: 28 | ``` 29 | cd firebase/functions 30 | npm install 31 | npm run-script deploy 32 | ``` 33 | > Note: To deploy functions, this service requires the [Blaze plan](https://firebase.google.com/pricing). You can also explore [emulating functions](https://firebase.google.com/docs/functions/get-started#emulate-execution-of-your-functions) instead of upgrading to the Blaze plan, or disable it's usage in the iOS client. 34 | 35 | ### [Storage](https://firebase.google.com/docs/storage) 36 | Stores images, nothing else for now 37 | -------------------------------------------------------------------------------- /FCKit/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /FCKit/.sourcery.yml: -------------------------------------------------------------------------------- 1 | sources: 2 | - Sources/FCKit 3 | templates: 4 | - sourcery/templates 5 | output: 6 | Sources/FCKitMocks 7 | args: 8 | autoMockableImports: 9 | - Combine 10 | - FCKit 11 | -------------------------------------------------------------------------------- /FCKit/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /FCKit/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "FCKit", 7 | platforms: [.iOS(.v16)], 8 | products: [ 9 | .library(name: "FCKit", targets: ["FCKit"]), 10 | .library(name: "FCKitMocks", targets: ["FCKitMocks"]) 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/CombineCommunity/CombineExt", from: "1.0.0"), 14 | .package(url: "https://github.com/EvanCooper9/ECKit", branch: "main"), 15 | .package(url: "https://github.com/hmlongco/Factory", from: "2.0.0"), 16 | .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.0.0"), 17 | ], 18 | targets: [ 19 | .target(name: "FCKit", dependencies: [ 20 | "CombineExt", 21 | "ECKit", 22 | "Factory", 23 | .product(name: "FirebaseAnalytics", package: "firebase-ios-sdk"), 24 | .product(name: "FirebaseCrashlytics", package: "firebase-ios-sdk"), 25 | .product(name: "FirebaseFirestore", package: "firebase-ios-sdk"), 26 | .product(name: "FirebaseFirestoreCombine-Community", package: "firebase-ios-sdk"), 27 | .product(name: "FirebaseRemoteConfig", package: "firebase-ios-sdk"), 28 | ]), 29 | .target(name: "FCKitMocks", dependencies: [ 30 | "FCKit", 31 | .product(name: "FirebaseAuth", package: "firebase-ios-sdk"), 32 | .product(name: "FirebaseFirestore", package: "firebase-ios-sdk"), 33 | ]), 34 | .testTarget(name: "FCKitTests", dependencies: [ 35 | "FCKit", 36 | "FCKitMocks" 37 | ]), 38 | ] 39 | ) 40 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Analytics/AnalyticsManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | 3 | public extension Container { 4 | var analyticsManager: Factory { 5 | self { AnalyticsManager() }.scope(.shared) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Database/Settings/DatabaseSettingsManager.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | import Foundation 3 | 4 | // sourcery: AutoMockable 5 | public protocol DatabaseSettingManaging { 6 | var shouldResetCache: Bool { get } 7 | func didResetCache() 8 | } 9 | 10 | final class DatabaseSettingsManager: DatabaseSettingManaging { 11 | 12 | // MARK: - Private Properties 13 | 14 | @Injected(\.databaseSettingsStore) private var databaseSettingsStore 15 | @Injected(\.featureFlagManager) private var featureFlagManager 16 | 17 | // MARK: - Public Properties 18 | 19 | var shouldResetCache: Bool { 20 | let ttl = featureFlagManager.value(forDouble: .databaseCacheTtl) 21 | return databaseSettingsStore.lastCacheReset.addingTimeInterval(ttl) <= .now 22 | } 23 | 24 | // MARK: - Public Methods 25 | 26 | func didResetCache() { 27 | databaseSettingsStore.lastCacheReset = .now 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Database/Settings/DatabaseSettingsStore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol DatabaseSettingsStoring { 4 | var lastCacheReset: Date { get set } 5 | } 6 | 7 | extension UserDefaults: DatabaseSettingsStoring { 8 | 9 | private enum Constants { 10 | static var lastCacheResetKey: String { #function } 11 | } 12 | 13 | public var lastCacheReset: Date { 14 | get { decode(Date.self, forKey: Constants.lastCacheResetKey) ?? .now } 15 | set { encode(newValue, forKey: Constants.lastCacheResetKey) } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Environment/EnvironmentCache.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // sourcery: AutoMockable 4 | public protocol EnvironmentCache { 5 | var environment: FCEnvironment? { get set } 6 | } 7 | 8 | extension UserDefaults: EnvironmentCache { 9 | 10 | private enum Constants { 11 | static var environmentKey: String { #function } 12 | } 13 | 14 | public var environment: FCEnvironment? { 15 | get { decode(FCEnvironment.self, forKey: Constants.environmentKey) } 16 | set { encode(newValue, forKey: Constants.environmentKey) } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Environment/EnvironmentManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | import Foundation 3 | 4 | public extension Container { 5 | var environmentCache: Factory { 6 | self { UserDefaults.appGroup }.scope(.shared) 7 | } 8 | 9 | var environmentManager: Factory { 10 | self { EnvironmentManager() }.scope(.singleton) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Environment/EnvironmentManager.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineExt 3 | import ECKit 4 | import Factory 5 | import Foundation 6 | 7 | // sourcery: AutoMockable 8 | public protocol EnvironmentManaging { 9 | var environment: FCEnvironment { get } 10 | var environmentPublisher: AnyPublisher { get } 11 | func set(_ environment: FCEnvironment) 12 | } 13 | 14 | final class EnvironmentManager: EnvironmentManaging { 15 | 16 | private enum Constants { 17 | static var environmentKey: String { #function } 18 | } 19 | 20 | // MARK: - Public Properties 21 | 22 | var environment: FCEnvironment { 23 | environmentSubject.value 24 | } 25 | 26 | var environmentPublisher: AnyPublisher { 27 | environmentSubject.eraseToAnyPublisher() 28 | } 29 | 30 | // MARK: - Private Properties 31 | 32 | @Injected(\.environmentCache) private var environmentCache 33 | 34 | private let environmentSubject = CurrentValueSubject(.prod) 35 | private var cancellables = Cancellables() 36 | 37 | // MARK: - Lifecycle 38 | 39 | init() { 40 | if let environment = environmentCache.environment { 41 | environmentSubject.send(environment) 42 | } else { 43 | #if targetEnvironment(simulator) 44 | environmentSubject.send(.debugLocal) 45 | #endif 46 | } 47 | 48 | environmentSubject 49 | .sink(withUnretained: self) { $0.environmentCache.environment = $1 } 50 | .store(in: &cancellables) 51 | } 52 | 53 | // MARK: - Public Methods 54 | 55 | func set(_ environment: FCEnvironment) { 56 | environmentSubject.send(environment) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Environment/FCEnvironment.swift: -------------------------------------------------------------------------------- 1 | public enum FCEnvironment: Codable, Equatable { 2 | case prod 3 | case debugLocal 4 | case debugRemote(destination: String) 5 | 6 | public var isDebug: Bool { 7 | switch self { 8 | case .prod: 9 | return false 10 | case .debugLocal, .debugRemote: 11 | return true 12 | } 13 | } 14 | 15 | public var bundleIdentifier: String { 16 | switch self { 17 | case .prod: 18 | return "com.evancooper.FriendlyCompetitions" 19 | case .debugLocal: 20 | return "com.evancooper.FriendlyCompetitions.debug" 21 | case .debugRemote: 22 | return "com.evancooper.FriendlyCompetitions.debug" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Extensions/Combine/Publisher+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Evan Cooper on 2024-02-19. 6 | // 7 | 8 | import Foundation 9 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Extensions/Foundation/UserDefaults+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension UserDefaults { 4 | static var appGroup: UserDefaults { 5 | UserDefaults(suiteName: AppGroup.id()) ?? .standard 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Extensions/StdLib/Error+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import FirebaseCrashlytics 3 | 4 | extension Error { 5 | public func reportToCrashlytics(userInfo: [String: Any] = [:], file: String = #file, line: Int = #line) { 6 | var nsError = self as NSError 7 | 8 | var userInfo = nsError.userInfo.merging(userInfo) { _, newValue in newValue } 9 | userInfo["file"] = file 10 | userInfo["line"] = line 11 | 12 | nsError = NSError( 13 | domain: nsError.domain, 14 | code: nsError.code, 15 | userInfo: nsError.userInfo.merging(userInfo) { _, newValue in newValue } 16 | ) 17 | 18 | Crashlytics.crashlytics().record(error: nsError) 19 | } 20 | } 21 | 22 | extension Publisher where Failure == Error { 23 | public func reportErrorToCrashlytics(userInfo: [String: Any] = [:], caller: String = #function, file: String = #file, line: Int = #line) -> AnyPublisher { 24 | self.catch { error -> AnyPublisher in 25 | var userInfo = userInfo 26 | userInfo["caller"] = caller 27 | error.reportToCrashlytics(userInfo: userInfo, file: file, line: line) 28 | return .error(error) 29 | } 30 | .eraseToAnyPublisher() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/FeatureFlag/FeatureFlag.swift: -------------------------------------------------------------------------------- 1 | public protocol FeatureFlag: CaseIterable, RawRepresentable { 2 | associatedtype Data: Codable 3 | var stringValue: String { get } 4 | var defaultValue: Data { get } 5 | } 6 | 7 | extension FeatureFlag where RawValue == String { 8 | public var stringValue: String { rawValue } 9 | } 10 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/FeatureFlag/FeatureFlagManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | 3 | public extension Container { 4 | var featureFlagManager: Factory { 5 | self { FeatureFlagManager() }.scope(.shared) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/FeatureFlag/Flags/FeatureFlagBool.swift: -------------------------------------------------------------------------------- 1 | public enum FeatureFlagBool: String, CaseIterable, FeatureFlag { 2 | public typealias Data = Bool 3 | 4 | case adsEnabled = "ads_enabled" 5 | case newResultsBannerEnabled = "new_results_banner_enabled" 6 | case ignoreManuallyEnteredHealthKitData = "ignore_manually_entered_health_kit_data" 7 | case reportIssueFormEnabled = "report_issue_form_enabled" 8 | 9 | public var defaultValue: Data { false } 10 | } 11 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/FeatureFlag/Flags/FeatureFlagDouble.swift: -------------------------------------------------------------------------------- 1 | public enum FeatureFlagDouble: String, FeatureFlag { 2 | public typealias Data = Double 3 | 4 | case databaseCacheTtl = "database_cache_ttl" 5 | case healthKitBackgroundDeliveryTimeoutMS = "health_kit_background_delivery_timeout_ms" 6 | case widgetUpdateIntervalS = "widget_update_interval_s" 7 | case dataUploadGracePeriodHours = "data_upload_grace_period_hours" 8 | 9 | public var defaultValue: Data { 0 } 10 | } 11 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/FeatureFlag/Flags/FeatureFlagString.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum FeatureFlagString: String, FeatureFlag { 4 | public typealias Data = String 5 | 6 | case googleAdsHomeScreenAdUnit = "google_ads_home_screen_ad_unit" 7 | case googleAdsExploreScreenAdUnit = "google_ads_explore_screen_ad_unit" 8 | case minimumAppVersion = "ios_minimum_app_version" 9 | 10 | public var defaultValue: Data { 11 | switch self { 12 | case .googleAdsHomeScreenAdUnit: return "" 13 | case .googleAdsExploreScreenAdUnit: return "" 14 | case .minimumAppVersion: return Bundle.main.version 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Firebase/FirebaseConfigureHelper.swift: -------------------------------------------------------------------------------- 1 | import Firebase 2 | 3 | extension FirebaseApp { 4 | private static var hasConfigured = false 5 | public static func configureIfRequired() { 6 | guard !FirebaseApp.hasConfigured else { return } 7 | FirebaseApp.configure() 8 | FirebaseApp.hasConfigured = true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Util/AppGroup.swift: -------------------------------------------------------------------------------- 1 | import ECKit 2 | import Foundation 3 | 4 | public enum AppGroup { 5 | 6 | private enum Constants { 7 | static let widgetIdentifier = "FriendlyCompetitionsWidgets" 8 | } 9 | 10 | public static func id(bundleIdentifier: String = Bundle.main.id) -> String { 11 | let withoutWidgetIdentifier = bundleIdentifier 12 | .split(separator: ".") 13 | .filter { $0 != Constants.widgetIdentifier } 14 | .joined(separator: ".") 15 | return "group." + withoutWidgetIdentifier 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Util/PostDecoded/DateToEndOfDay.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum DateToEndOfDay: PostDecodingStrategy { 4 | public static func transform(_ value: Date) -> Date { 5 | Calendar.current.startOfDay(for: value).advanced(by: 23.hours + 59.minutes + 59.seconds) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Util/PostDecoded/DateToStartOfDay.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum DateToStartOfDay: PostDecodingStrategy { 4 | public static func transform(_ value: Date) -> Date { value } 5 | } 6 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Util/PostDecoded/PostDecoded.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @propertyWrapper 4 | public struct PostDecoded where Strategy.Value == Value { 5 | public var wrappedValue: Value 6 | 7 | public init(wrappedValue: Value) { 8 | self.wrappedValue = wrappedValue 9 | } 10 | } 11 | 12 | public protocol PostDecodingStrategy { 13 | associatedtype Value: Decodable 14 | static func transform(_ value: Value) -> Value 15 | } 16 | 17 | // MARK: - Decodable 18 | 19 | extension PostDecoded: Decodable where Value: Decodable { 20 | public init(from decoder: Decoder) throws { 21 | let container = try decoder.singleValueContainer() 22 | wrappedValue = Strategy.transform(try container.decode(Value.self)) 23 | } 24 | } 25 | 26 | // MARK: - Encodable 27 | 28 | extension PostDecoded: Encodable where Value: Encodable { 29 | public func encode(to encoder: Encoder) throws { 30 | var container = encoder.singleValueContainer() 31 | try container.encode(wrappedValue) 32 | } 33 | } 34 | 35 | // MARK: - Equatable 36 | 37 | extension PostDecoded: Equatable where Value: Equatable { 38 | public static func == (lhs: Self, rhs: Self) -> Bool { 39 | lhs.wrappedValue == rhs.wrappedValue 40 | } 41 | } 42 | 43 | // MARK: - Hashable 44 | 45 | extension PostDecoded: Hashable where Value: Hashable {} 46 | -------------------------------------------------------------------------------- /FCKit/Sources/FCKit/Util/WidgetIdentifier.swift: -------------------------------------------------------------------------------- 1 | import ECKit 2 | import Foundation 3 | 4 | public enum WidgetIdentifier { 5 | private enum Constants { 6 | static let widgetSuffix = ".FriendlyCompetitionsWidgets" 7 | static let competitionStandingsWidgetSuffix = ".CompetitionStandingsWidget" 8 | } 9 | 10 | case competitionStandings 11 | 12 | public func id(bundleIdentifier: String = Bundle.main.id) -> String { 13 | switch self { 14 | case .competitionStandings: 15 | if bundleIdentifier.hasSuffix(Constants.widgetSuffix) { 16 | return "group." + bundleIdentifier + Constants.competitionStandingsWidgetSuffix 17 | } else { 18 | return "group." + bundleIdentifier + Constants.widgetSuffix + Constants.competitionStandingsWidgetSuffix 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /FriendlyCompetitions.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /FriendlyCompetitions.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /FriendlyCompetitions.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /FriendlyCompetitions/API/API+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | import FCKit 3 | import FirebaseFunctions 4 | 5 | extension Container { 6 | var api: Factory { 7 | self { 8 | let environment = self.environmentManager().environment 9 | let functions = Functions.functions() 10 | 11 | switch environment { 12 | case .prod: 13 | break 14 | case .debugLocal: 15 | functions.useEmulator(withHost: "localhost", port: 5001) 16 | case .debugRemote(let destination): 17 | functions.useEmulator(withHost: destination, port: 5001) 18 | } 19 | 20 | return functions 21 | } 22 | .scope(.shared) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /FriendlyCompetitions/API/API+Firebase.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineExt 3 | import Factory 4 | import FirebaseFunctions 5 | import FirebaseFunctionsCombineSwift 6 | 7 | extension Functions: API { 8 | func call(_ endpoint: Endpoint) -> AnyPublisher { 9 | httpsCallable(endpoint.name) 10 | .call(endpoint.data) 11 | .mapToVoid() 12 | .reportErrorToCrashlytics(userInfo: [ 13 | "apiEndpoint": endpoint.name, 14 | "apiData": endpoint.data ?? "empty" 15 | ]) 16 | .eraseToAnyPublisher() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /FriendlyCompetitions/API/API.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | // sourcery: AutoMockable 4 | protocol API { 5 | func call(_ endpoint: Endpoint) -> AnyPublisher 6 | } 7 | -------------------------------------------------------------------------------- /FriendlyCompetitions/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import ECKit 3 | import Factory 4 | import Foundation 5 | import UIKit 6 | 7 | final class AppDelegate: NSObject, UIApplicationDelegate { 8 | 9 | @Injected(\.appServices) private var appServices: [AppService] 10 | 11 | private var cancellables = Cancellables() 12 | 13 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { 14 | appServices.forEach { $0.didFinishLaunching() } 15 | return true 16 | } 17 | 18 | func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { 19 | appServices.forEach { $0.didRegisterForRemoteNotifications(with: deviceToken) } 20 | } 21 | 22 | func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { 23 | error.reportToCrashlytics() 24 | } 25 | 26 | func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { 27 | appServices.map { $0.didReceiveRemoteNotification(with: userInfo) } 28 | .combineLatest() 29 | .first() 30 | .mapToVoid() 31 | .sink { completionHandler(.newData) } 32 | .store(in: &cancellables) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /FriendlyCompetitions/AppLauncher.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @main 4 | struct AppLauncher { 5 | static func main() throws { 6 | if NSClassFromString("XCTestCase") == nil { 7 | FriendlyCompetitions.main() 8 | } else { 9 | FriendlyCompetitionsTests.main() 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /FriendlyCompetitions/AppServices/AppService.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import ECKit 3 | import Foundation 4 | 5 | protocol AppService { 6 | func willFinishLaunching() 7 | func didFinishLaunching() 8 | func didRegisterForRemoteNotifications(with deviceToken: Data) 9 | func didReceiveRemoteNotification(with data: [AnyHashable: Any]) -> AnyPublisher 10 | } 11 | 12 | extension AppService { 13 | func willFinishLaunching() {} 14 | func didFinishLaunching() {} 15 | func didRegisterForRemoteNotifications(with deviceToken: Data) {} 16 | func didReceiveRemoteNotification(with data: [AnyHashable: Any]) -> AnyPublisher { .just(()) } 17 | } 18 | -------------------------------------------------------------------------------- /FriendlyCompetitions/AppServices/AppServices+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | 3 | extension Container { 4 | var appServices: Factory<[AppService]> { 5 | self { 6 | .build { 7 | FirebaseAppService() 8 | DeveloperAppService() 9 | DataUploadingAppService() 10 | NotificationsAppService() 11 | BackgroundJobsAppService() 12 | GoogleAdsAppService() 13 | StorageAppService() 14 | } 15 | } 16 | .scope(.singleton) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /FriendlyCompetitions/AppServices/BackgroundJobs/BackgroundJob.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | protocol BackgroundJob: Decodable { 4 | func execute() -> AnyPublisher 5 | } 6 | -------------------------------------------------------------------------------- /FriendlyCompetitions/AppServices/BackgroundJobs/Jobs/FetchCompetitionResultsBackgroundJob.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Factory 3 | 4 | final class FetchCompetitionResultsBackgroundJob: BackgroundJob { 5 | 6 | private enum CodingKeys: String, CodingKey { 7 | case competitionIDForResults 8 | } 9 | 10 | private let competitionIDForResults: Competition.ID 11 | 12 | @LazyInjected(\.authenticationManager) private var authenticationManager 13 | @LazyInjected(\.competitionsManager) private var competitionsManager 14 | 15 | func execute() -> AnyPublisher { 16 | authenticationManager.loggedIn 17 | .setFailureType(to: Error.self) 18 | .flatMapLatest(withUnretained: self) { strongSelf, loggedIn -> AnyPublisher in 19 | guard loggedIn else { return .just(()) } 20 | return strongSelf.competitionsManager 21 | .results(for: self.competitionIDForResults) 22 | .mapToVoid() 23 | .eraseToAnyPublisher() 24 | } 25 | .first() 26 | .catchErrorJustReturn(()) 27 | .eraseToAnyPublisher() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /FriendlyCompetitions/AppServices/BackgroundJobs/Jobs/FetchDocumentBackgroundJob.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineExt 3 | import Factory 4 | import Foundation 5 | 6 | final class FetchDocumentBackgroundJob: BackgroundJob { 7 | 8 | private enum CodingKeys: String, CodingKey { 9 | case documentPath 10 | } 11 | 12 | private let documentPath: String 13 | 14 | @LazyInjected(\.database) private var database 15 | 16 | func execute() -> AnyPublisher { 17 | database 18 | .document(documentPath) 19 | .cacheFromServer() 20 | .ignoreFailure() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /FriendlyCompetitions/AppServices/Developer/DeveloperAppService.swift: -------------------------------------------------------------------------------- 1 | import ECKit 2 | import Factory 3 | import UIKit 4 | 5 | final class DeveloperAppService: AppService { 6 | 7 | // Needs to be lazy so that `FirebaseApp.configure()` is called first 8 | @LazyInjected(\.api) private var api 9 | 10 | private var cancellables = Cancellables() 11 | 12 | func didFinishLaunching() { 13 | // do nothing 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /FriendlyCompetitions/AppServices/Firebase/FCAppCheckProviderFactory.swift: -------------------------------------------------------------------------------- 1 | import Firebase 2 | import FirebaseAppCheck 3 | 4 | class FCAppCheckProviderFactory: NSObject, AppCheckProviderFactory { 5 | func createProvider(with app: FirebaseApp) -> AppCheckProvider? { 6 | #if RELEASE 7 | DeviceCheckProvider(app: app) 8 | #else 9 | nil 10 | #endif 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /FriendlyCompetitions/AppServices/GoogleAds/GoogleAdsAppService.swift: -------------------------------------------------------------------------------- 1 | import GoogleMobileAds 2 | 3 | final class GoogleAdsAppService: AppService { 4 | func didFinishLaunching() { 5 | GADMobileAds.sharedInstance().start(completionHandler: nil) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /FriendlyCompetitions/AppServices/Notifications/NotificationsAppService.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | 3 | final class NotificationsAppService: AppService { 4 | 5 | @Injected(\.notificationsManager) private var notificationsManager: NotificationsManaging 6 | 7 | func didFinishLaunching() { 8 | notificationsManager.setUp() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /FriendlyCompetitions/AppServices/Storage/StorageAppService.swift: -------------------------------------------------------------------------------- 1 | import ECKit 2 | import Factory 3 | 4 | final class StorageAppService: AppService { 5 | 6 | // MARK: - AppService 7 | 8 | func didFinishLaunching() { 9 | Task { [storageManager] in 10 | storageManager.clear(ttl: 60.days) 11 | } 12 | } 13 | 14 | // MARK: - Private 15 | 16 | @LazyInjected(\.storageManager) private var storageManager: StorageManaging 17 | } 18 | -------------------------------------------------------------------------------- /FriendlyCompetitions/AppState/AppState+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | 3 | extension Container { 4 | var appState: Factory { 5 | self { AppState() }.scope(.shared) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /FriendlyCompetitions/FriendlyCompetitions.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.applesignin 8 | 9 | Default 10 | 11 | com.apple.developer.associated-domains 12 | 13 | applinks:friendly-competitions.app 14 | webcredentials:friendly-competitions.app 15 | 16 | com.apple.developer.authentication-services.autofill-credential-provider 17 | 18 | com.apple.developer.devicecheck.appattest-environment 19 | development 20 | com.apple.developer.healthkit 21 | 22 | com.apple.developer.healthkit.access 23 | 24 | com.apple.developer.healthkit.background-delivery 25 | 26 | com.apple.security.application-groups 27 | 28 | group.com.evancooper.FriendlyCompetitions 29 | group.com.evancooper.FriendlyCompetitions.debug 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /FriendlyCompetitions/FriendlyCompetitions.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct FriendlyCompetitions: App { 4 | 5 | @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate 6 | @StateObject private var appModel = FriendlyCompetitionsAppModel() 7 | 8 | var body: some Scene { 9 | WindowGroup { 10 | Group { 11 | if appModel.loggedIn { 12 | if appModel.emailVerified { 13 | RootView() 14 | } else { 15 | VerifyEmailView() 16 | } 17 | } else { 18 | WelcomeView() 19 | } 20 | } 21 | .hud(state: $appModel.hud) 22 | .onOpenURL(perform: appModel.handle) 23 | .environment(\.openURL, OpenURLAction { url in 24 | appModel.opened(url: url) 25 | return .systemAction 26 | }) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /FriendlyCompetitions/FriendlyCompetitionsTests.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct FriendlyCompetitionsTests: App { 4 | var body: some Scene { 5 | WindowGroup { 6 | Text("Running Unit Tests") 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /FriendlyCompetitions/IssueReporting/IssueReporting+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | 3 | extension Container { 4 | var issueReporter: Factory { 5 | self { IssueReporter() }.scope(.shared) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/ActivitySummary/ActivitySummaryCache.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // sourcery: AutoMockable 4 | protocol ActivitySummaryCache { 5 | var activitySummary: ActivitySummary? { get set } 6 | } 7 | 8 | extension UserDefaults: ActivitySummaryCache { 9 | 10 | private enum Constants { 11 | static var activitySummaryKey: String { #function } 12 | static var activitySummariesKey: String { #function } 13 | } 14 | 15 | var activitySummary: ActivitySummary? { 16 | get { decode(ActivitySummary.self, forKey: Constants.activitySummaryKey) } 17 | set { encode(newValue, forKey: Constants.activitySummaryKey) } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/ActivitySummary/ActivitySummaryManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | import Foundation 3 | 4 | extension Container { 5 | var activitySummaryCache: Factory { 6 | self { UserDefaults.standard }.scope(.shared) 7 | } 8 | 9 | var activitySummaryManager: Factory { 10 | self { ActivitySummaryManager() }.scope(.shared) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/Authentication/AuthProviding/AuthProviding.swift: -------------------------------------------------------------------------------- 1 | import AuthenticationServices 2 | import Combine 3 | 4 | // sourcery: AutoMockable 5 | protocol AuthProviding { 6 | var user: AuthUser? { get } 7 | func userPublisher() -> AnyPublisher 8 | func signIn(with credential: AuthCredential) -> AnyPublisher 9 | func signUp(with credential: AuthCredential) -> AnyPublisher 10 | func signOut() throws 11 | func sendPasswordReset(to email: String) -> AnyPublisher 12 | } 13 | 14 | // sourcery: AutoMockable 15 | protocol AuthUser { 16 | var id: String { get } 17 | var displayName: String? { get } 18 | var email: String? { get } 19 | var isEmailVerified: Bool { get } 20 | var isAnonymous: Bool { get } 21 | var hasSWA: Bool { get } 22 | 23 | func link(with credential: AuthCredential) -> AnyPublisher 24 | func sendEmailVerification() -> AnyPublisher 25 | func set(displayName: String) -> AnyPublisher 26 | func reload() async throws 27 | func delete() async throws 28 | } 29 | 30 | extension AuthUser { 31 | var databasePath: String { 32 | "users/\(id)" 33 | } 34 | } 35 | 36 | enum AuthCredential { 37 | case anonymous 38 | case apple(id: String, nonce: String?, fullName: PersonNameComponents?) 39 | case email(email: String, password: String) 40 | } 41 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/Authentication/AuthenticationCache.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // sourcery: AutoMockable 4 | protocol AuthenticationCache { 5 | var currentUser: User? { get set } 6 | } 7 | 8 | extension UserDefaults: AuthenticationCache { 9 | 10 | private enum Constants { 11 | static var currentUserKey = "current_user" 12 | } 13 | 14 | var currentUser: User? { 15 | get { decode(User.self, forKey: Constants.currentUserKey) } 16 | set { encode(newValue, forKey: Constants.currentUserKey) } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/Authentication/AuthenticationError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum AuthenticationError: LocalizedError { 4 | case missingEmail 5 | case passwordMatch 6 | 7 | var errorDescription: String? { localizedDescription } 8 | var localizedDescription: String { 9 | switch self { 10 | case .missingEmail: 11 | return L10n.AuthenticationError.missingEmail 12 | case .passwordMatch: 13 | return L10n.AuthenticationError.passwordMatch 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/Authentication/AuthenticationManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | import FCKit 3 | import FirebaseAuth 4 | 5 | extension Container { 6 | var authenticationCache: Factory { 7 | self { UserDefaults.standard }.scope(.shared) 8 | } 9 | 10 | var authenticationManager: Factory { 11 | self { AuthenticationManager() }.scope(.shared) 12 | } 13 | 14 | var auth: Factory { 15 | self { 16 | let environment = self.environmentManager().environment 17 | let auth = Auth.auth() 18 | 19 | switch environment { 20 | case .prod: 21 | break 22 | case .debugLocal: 23 | auth.useEmulator(withHost: "localhost", port: 9099) 24 | case .debugRemote(let destination): 25 | auth.useEmulator(withHost: destination, port: 9099) 26 | } 27 | 28 | let currentUser = auth.currentUser 29 | do { 30 | try auth.useUserAccessGroup(AppGroup.id()) 31 | } catch { 32 | print(error.localizedDescription) 33 | print((error as NSError).userInfo) 34 | } 35 | if let currentUser { 36 | auth.updateCurrentUser(currentUser) { error in 37 | guard let error else { return } 38 | print(error.localizedDescription) 39 | } 40 | } 41 | 42 | return auth 43 | }.scope(.shared) 44 | } 45 | 46 | var signInWithAppleProvider: Factory { 47 | self { SignInWithAppleProvider() } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/Authentication/AuthenticationMethod.swift: -------------------------------------------------------------------------------- 1 | enum AuthenticationMethod: Equatable { 2 | case anonymous 3 | case apple 4 | case email(_ email: String, password: String) 5 | } 6 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/BackgroundRefresh/BackgroundRefreshManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | 3 | extension Container { 4 | var backgroundRefreshManager: Factory { 5 | self { BackgroundRefreshManager() }.scope(.shared) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/BackgroundRefresh/BackgroundRefreshManager.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import ECKit 3 | import Factory 4 | import UIKit 5 | 6 | // sourcery: AutoMockable 7 | protocol BackgroundRefreshManaging { 8 | var status: AnyPublisher { get } 9 | } 10 | 11 | final class BackgroundRefreshManager: BackgroundRefreshManaging { 12 | 13 | // MARK: - Public Properties 14 | 15 | var status: AnyPublisher { statusSubject.eraseToAnyPublisher() } 16 | 17 | // MARK: - Private Properties 18 | 19 | private let statusSubject: CurrentValueSubject 20 | 21 | private var cancellables = Cancellables() 22 | 23 | // MARK: - Lifecycle 24 | 25 | init() { 26 | let status = BackgroundRefreshStatus(from: UIApplication.shared.backgroundRefreshStatus) 27 | statusSubject = .init(status) 28 | subscribeToStatusChange() 29 | } 30 | 31 | // MARK: - Private Methods 32 | 33 | private func subscribeToStatusChange() { 34 | UIApplication.backgroundRefreshStatusDidChangeNotification.publisher 35 | .prepend(()) 36 | .sink(withUnretained: self) { strongSelf in 37 | let status = BackgroundRefreshStatus(from: UIApplication.shared.backgroundRefreshStatus) 38 | strongSelf.statusSubject.send(status) 39 | } 40 | .store(in: &cancellables) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/BackgroundRefresh/BackgroundRefreshStatus.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | enum BackgroundRefreshStatus: String, Codable { 4 | case denied 5 | case restricted 6 | case available 7 | case unknown 8 | 9 | init(from status: UIBackgroundRefreshStatus) { 10 | switch status { 11 | case .restricted: 12 | self = .restricted 13 | case .denied: 14 | self = .denied 15 | case .available: 16 | self = .available 17 | @unknown default: 18 | self = .unknown 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/Banner/BannerManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | 3 | extension Container { 4 | var bannerManager: Factory { 5 | self { BannerManager() }.scope(.shared) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/Competitions/CompetitionsManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | import Foundation 3 | 4 | extension Container { 5 | var competitionsManager: Factory { 6 | self { CompetitionsManager() }.scope(.shared) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/Friends/FriendsManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | 3 | extension Container { 4 | var friendsManager: Factory { 5 | self { FriendsManager() }.scope(.shared) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/HealthKit/HealthKitBackgroundDeliveryError.swift: -------------------------------------------------------------------------------- 1 | enum HealthKitBackgroundDeliveryError: Error { 2 | case timeout 3 | } 4 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/HealthKit/HealthKitManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | import Foundation 3 | import HealthKit 4 | 5 | extension Container { 6 | var healthStore: Factory { 7 | self { HKHealthStore() }.scope(.shared) 8 | } 9 | 10 | var healthKitManager: Factory { 11 | self { HealthKitManager() }.scope(.shared) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/Notifications/NotificationsManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | 3 | extension Container { 4 | var notificationsManager: Factory { 5 | self { NotificationsManager() }.scope(.shared) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/Search/SearchManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import AlgoliaSearchClient 2 | import Factory 3 | 4 | extension Container { 5 | var searchClient: Factory { 6 | self { AlgoliaSearchClient.SearchClient() }.scope(.shared) 7 | } 8 | 9 | var searchManager: Factory { 10 | self { SearchManager() }.scope(.shared) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/StepCount/StepCount.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct StepCount: Codable, Hashable, Identifiable { 4 | var id: String { date.encodedToString(with: .dateDashed) } 5 | 6 | let count: Int 7 | let date: Date 8 | } 9 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/StepCount/StepCountManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | 3 | extension Container { 4 | var stepCountManager: Factory { 5 | self { StepCountManager() }.scope(.shared) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/Storage/StorageManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | 3 | extension Container { 4 | var storageManager: Factory { 5 | self { StorageManager() }.scope(.shared) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/User/UserManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | 3 | extension Container { 4 | var userManager: Factory { 5 | self { fatalError("User manager not initialized") }.scope(.shared) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Managers/Workout/WorkoutManager+Factory.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | import Foundation 3 | 4 | extension Container { 5 | var workoutManager: Factory { 6 | self { WorkoutManager() }.scope(.shared) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Models/Competition/Competition+Standing.swift: -------------------------------------------------------------------------------- 1 | extension Competition { 2 | struct Standing: Codable, Equatable, Identifiable { 3 | var id: String { userId } 4 | let rank: Int 5 | let userId: String 6 | let points: Int 7 | var isTie: Bool? = false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Models/Competition/Competition.swift: -------------------------------------------------------------------------------- 1 | import ECKit 2 | import Factory 3 | import FCKit 4 | import Foundation 5 | import HealthKit 6 | 7 | struct Competition: Codable, Equatable, Hashable, Identifiable { 8 | var id = UUID().uuidString 9 | var name: String 10 | var owner: String 11 | var participants: [String] 12 | var pendingParticipants: [String] 13 | var scoringModel: ScoringModel 14 | @PostDecoded var start: Date 15 | @PostDecoded var end: Date 16 | var repeats: Bool 17 | var isPublic: Bool 18 | let banner: String? 19 | } 20 | 21 | extension Competition: Stored { 22 | var databasePath: String { "competitions/\(id)" } 23 | } 24 | 25 | extension Competition { 26 | var started: Bool { start < .now } 27 | var ended: Bool { end < .now } 28 | var isActive: Bool { started && !ended } 29 | var appOwned: Bool { owner == Bundle.main.id } 30 | 31 | func canUploadData(gracePeriod: TimeInterval) -> Bool { 32 | isActive || end.addingTimeInterval(gracePeriod) >= .now 33 | } 34 | } 35 | 36 | extension Array where Element == Competition { 37 | var dateInterval: DateInterval? { 38 | reduce(nil) { existing, competition in 39 | let dateInterval = DateInterval(start: competition.start, end: competition.end) 40 | guard let existing else { 41 | return dateInterval 42 | } 43 | return existing.combined(with: dateInterval) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Models/Competition/CompetitionResult.swift: -------------------------------------------------------------------------------- 1 | import FCKit 2 | import Foundation 3 | 4 | struct CompetitionResult: Codable, Hashable, Identifiable { 5 | let id: String 6 | @PostDecoded var start: Date 7 | @PostDecoded var end: Date 8 | let participants: [User.ID] 9 | 10 | var dateInterval: DateInterval { .init(start: start, end: end) } 11 | } 12 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Models/Permission/Permission.swift: -------------------------------------------------------------------------------- 1 | enum Permission { 2 | case health(HealthKitPermissionType) 3 | case notifications 4 | } 5 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Models/Permission/PermissionStatus.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum PermissionStatus: String, Codable { 4 | case authorized 5 | case denied 6 | case notDetermined 7 | case done 8 | 9 | var buttonTitle: String { 10 | switch self { 11 | case .authorized: 12 | return L10n.Permission.Status.allowed 13 | case .denied: 14 | return L10n.Permission.Status.denied 15 | case .notDetermined: 16 | return L10n.Permission.Status.allow 17 | case .done: 18 | return L10n.Permission.Status.done 19 | } 20 | } 21 | 22 | var buttonColor: Color { 23 | switch self { 24 | case .authorized: 25 | return .green 26 | case .denied: 27 | return .red 28 | case .notDetermined: 29 | return .blue 30 | case .done: 31 | return .gray 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Models/Stored.swift: -------------------------------------------------------------------------------- 1 | protocol Stored { 2 | var databasePath: String { get } 3 | } 4 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Models/User/User+Statistics.swift: -------------------------------------------------------------------------------- 1 | extension User { 2 | struct Medals: Codable, Equatable, Hashable { 3 | let golds: Int 4 | let silvers: Int 5 | let bronzes: Int 6 | } 7 | } 8 | 9 | extension User.Medals { 10 | static var zero = Self(golds: 0, silvers: 0, bronzes: 0) 11 | } 12 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Models/User/User+Tag.swift: -------------------------------------------------------------------------------- 1 | extension User { 2 | enum Tag: String, Codable { 3 | case admin 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Models/User/User+Visibility.swift: -------------------------------------------------------------------------------- 1 | extension User { 2 | enum Visibility { 3 | case visible 4 | case hidden 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Models/Workouts/Workout.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Workout: Codable, Equatable, Hashable { 4 | let type: WorkoutType 5 | let date: Date 6 | let points: [WorkoutMetric: Int] 7 | } 8 | 9 | extension Workout: Identifiable { 10 | var id: String { "\(DateFormatter.dateDashed.string(from: date))_\(type)" } 11 | } 12 | -------------------------------------------------------------------------------- /FriendlyCompetitions/PreviewContent/Mocks/Models/ActivitySummary+Mock.swift: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | extension ActivitySummary { 3 | static var mock: ActivitySummary { 4 | .init( 5 | activeEnergyBurned: 100, 6 | appleExerciseTime: 10, 7 | appleStandHours: 8, 8 | activeEnergyBurnedGoal: 300, 9 | appleExerciseTimeGoal: 20, 10 | appleStandHoursGoal: 12, 11 | date: .now 12 | ) 13 | } 14 | } 15 | #endif 16 | -------------------------------------------------------------------------------- /FriendlyCompetitions/PreviewContent/Mocks/Models/User+Mock.swift: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | extension User { 3 | static var andrew: User { 4 | .init( 5 | id: "abc123", 6 | name: "Andrew Stapleton", 7 | email: "andrew@email.com" 8 | ) 9 | } 10 | static var evan: User { 11 | .init( 12 | id: "0IQfVBJIgGdfC9CHgYefpZUQ13l1", 13 | name: "Evan Cooper", 14 | email: "evan@test.com" 15 | ) 16 | } 17 | 18 | static var gabby: User { 19 | .init( 20 | id: "W8CwWA8GLqS5TnMNgbTZ9TO2qIG3", 21 | name: "Gabriella Carrier", 22 | email: "gabby@test.com" 23 | ) 24 | } 25 | } 26 | 27 | extension User.Medals { 28 | static var mock: Self { 29 | .init(golds: 3, silvers: 2, bronzes: 1) 30 | } 31 | } 32 | 33 | extension Array where Element == User { 34 | static var mock: [User] { 35 | [.evan] 36 | } 37 | } 38 | #endif 39 | -------------------------------------------------------------------------------- /FriendlyCompetitions/PreviewContent/Mocks/Models/Workout+Mock.swift: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | import Foundation 3 | 4 | extension Workout { 5 | static func mock(type: WorkoutType = .walking, date: Date = .now, points: [WorkoutMetric: Int] = [:]) -> Workout { 6 | .init( 7 | type: type, 8 | date: date, 9 | points: points 10 | ) 11 | } 12 | } 13 | #endif 14 | -------------------------------------------------------------------------------- /FriendlyCompetitions/PreviewContent/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /FriendlyCompetitions/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyTracking 6 | 7 | NSPrivacyTrackingDomains 8 | 9 | NSPrivacyCollectedDataTypes 10 | 11 | NSPrivacyAccessedAPITypes 12 | 13 | 14 | NSPrivacyAccessedAPIType 15 | NSPrivacyAccessedAPICategoryFileTimestamp 16 | NSPrivacyAccessedAPITypeReasons 17 | 18 | C617.1 19 | 20 | 21 | 22 | NSPrivacyAccessedAPIType 23 | NSPrivacyAccessedAPICategoryDiskSpace 24 | NSPrivacyAccessedAPITypeReasons 25 | 26 | E174.1 27 | 7D9E.1 28 | 29 | 30 | 31 | NSPrivacyAccessedAPIType 32 | NSPrivacyAccessedAPICategorySystemBootTime 33 | NSPrivacyAccessedAPITypeReasons 34 | 35 | 35F9.1 36 | 37 | 38 | 39 | NSPrivacyAccessedAPIType 40 | NSPrivacyAccessedAPICategoryUserDefaults 41 | NSPrivacyAccessedAPITypeReasons 42 | 43 | CA92.1 44 | 1C8F.1 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Colors.xcassets/Branded/Blue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.882", 9 | "green" : "0.980", 10 | "red" : "0.176" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Colors.xcassets/Branded/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "properties" : { 7 | "provides-namespace" : true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Colors.xcassets/Branded/Green.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.247", 9 | "green" : "0.973", 10 | "red" : "0.698" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Colors.xcassets/Branded/Red.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.365", 9 | "green" : "0.176", 10 | "red" : "0.961" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Colors.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/Permissions/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/Permissions/health.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "health.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/Permissions/health.imageset/health.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/Permissions/health.imageset/health.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/Permissions/notifications.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "notifications.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/Permissions/notifications.imageset/notifications.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/Permissions/notifications.imageset/notifications.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/Privacy/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/Privacy/nameHidden.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "nameHiddenLight.png", 5 | "idiom" : "universal" 6 | }, 7 | { 8 | "appearances" : [ 9 | { 10 | "appearance" : "luminosity", 11 | "value" : "dark" 12 | } 13 | ], 14 | "filename" : "nameHiddenDark.png", 15 | "idiom" : "universal" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "original" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/Privacy/nameHidden.imageset/nameHiddenDark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/Privacy/nameHidden.imageset/nameHiddenDark.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/Privacy/nameHidden.imageset/nameHiddenLight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/Privacy/nameHidden.imageset/nameHiddenLight.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/Privacy/nameShown.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "nameShownLight.png", 5 | "idiom" : "universal" 6 | }, 7 | { 8 | "appearances" : [ 9 | { 10 | "appearance" : "luminosity", 11 | "value" : "dark" 12 | } 13 | ], 14 | "filename" : "nameShownDark.png", 15 | "idiom" : "universal" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "original" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/Privacy/nameShown.imageset/nameShownDark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/Privacy/nameShown.imageset/nameShownDark.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/Privacy/nameShown.imageset/nameShownLight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/Privacy/nameShown.imageset/nameShownLight.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "raw.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Assets/Images.xcassets/logo.imageset/raw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitions/Resources/Assets/Images.xcassets/logo.imageset/raw.png -------------------------------------------------------------------------------- /FriendlyCompetitions/Resources/Strings/fr-CA.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | Friendly Competitions 4 | 5 | Created by Evan Cooper on 2023-02-02. 6 | 7 | */ 8 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/AppInfo.swift: -------------------------------------------------------------------------------- 1 | enum AppInfo { 2 | static var name = "Friendly Competitions" 3 | } 4 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/ArrayBuilder.swift: -------------------------------------------------------------------------------- 1 | @resultBuilder 2 | enum ArrayBuilder { 3 | static func buildEither(first component: [Element]) -> [Element] { component } 4 | static func buildEither(second component: [Element]) -> [Element] { component } 5 | static func buildOptional(_ component: [Element]?) -> [Element] { component ?? [] } 6 | static func buildExpression(_ expression: Element) -> [Element] { [expression] } 7 | static func buildExpression(_ expression: ()) -> [Element] { [] } 8 | static func buildBlock(_ components: [Element]...) -> [Element] { components.flatMap { $0 } } 9 | static func buildArray(_ components: [[Element]]) -> [Element] { Array(components.joined()) } 10 | } 11 | 12 | extension Array { 13 | static func build(@ArrayBuilder _ builder: () -> [Element]) -> [Element] { 14 | self.init(builder()) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/Device.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | struct Device { 4 | static let modelName: String = { UIDevice.current.name }() 5 | static let iOSVersion: String = { UIDevice.current.systemVersion }() 6 | } 7 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/Extensions/Foundation/Array+Extensions.swift: -------------------------------------------------------------------------------- 1 | extension Array where Element == Bool { 2 | func allTrue() -> Bool { 3 | allSatisfy { $0 } 4 | } 5 | 6 | func allFalse() -> Bool { 7 | allSatisfy { !$0 } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/Extensions/Foundation/DateInterval+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension DateInterval { 4 | static var dataFetchDefault: Self { 5 | let start = Calendar.current.startOfDay(for: .now).addingTimeInterval(-2.days) 6 | return DateInterval(start: start, end: .now) 7 | } 8 | } 9 | 10 | extension DateInterval { 11 | func combined(with other: DateInterval) -> DateInterval { 12 | let minStart = min(start, other.start) 13 | let maxEnd = max(end, other.end) 14 | return .init(start: minStart, end: maxEnd) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/Extensions/Foundation/NotificationName+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | extension Notification.Name { 5 | var publisher: AnyPublisher { 6 | NotificationCenter.default 7 | .publisher(for: self) 8 | .mapToValue(()) 9 | .eraseToAnyPublisher() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/Extensions/HealthKit/HKActivitySummary+Extensions.swift: -------------------------------------------------------------------------------- 1 | import HealthKit 2 | 3 | extension HKActivitySummary { 4 | static var mock: HKActivitySummary { 5 | let summary = HKActivitySummary() 6 | summary.activeEnergyBurned = .init(unit: .kilocalorie(), doubleValue: 33) 7 | summary.activeEnergyBurnedGoal = .init(unit: .kilocalorie(), doubleValue: 100) 8 | summary.appleExerciseTime = .init(unit: .minute(), doubleValue: 57) 9 | summary.appleExerciseTimeGoal = .init(unit: .minute(), doubleValue: 100) 10 | summary.appleStandHours = .init(unit: .count(), doubleValue: 8) 11 | summary.appleStandHoursGoal = .init(unit: .count(), doubleValue: 12) 12 | return summary 13 | } 14 | 15 | var activitySummary: ActivitySummary { 16 | .init( 17 | activeEnergyBurned: activeEnergyBurned.doubleValue(for: .kilocalorie()).rounded(.down), 18 | appleExerciseTime: appleExerciseTime.doubleValue(for: .minute()), 19 | appleStandHours: appleStandHours.doubleValue(for: .count()), 20 | activeEnergyBurnedGoal: activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()), 21 | appleExerciseTimeGoal: appleExerciseTimeGoal.doubleValue(for: .minute()), 22 | appleStandHoursGoal: appleStandHoursGoal.doubleValue(for: .count()), 23 | date: Calendar.current.date(from: dateComponents(for: .current)) ?? .now 24 | ) 25 | } 26 | 27 | var isToday: Bool { 28 | dateComponents(for: .current).day == Calendar.current.dateComponents([.day], from: Date()).day 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/Extensions/HealthKit/HKWorkoutActivityType+Extensions.swift: -------------------------------------------------------------------------------- 1 | import HealthKit 2 | 3 | extension HKWorkoutActivityType { 4 | var samples: [(HKQuantityType, HKUnit)] { 5 | switch self { 6 | case .cycling: 7 | return [ 8 | (HKObjectType.quantityType(forIdentifier: .distanceCycling)!, .meter()), 9 | (HKObjectType.quantityType(forIdentifier: .heartRate)!, .init(from: "count/min")) 10 | ] 11 | case .running: 12 | return [ 13 | (HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning)!, .meter()), 14 | (HKObjectType.quantityType(forIdentifier: .heartRate)!, .init(from: "count/min")), 15 | (HKObjectType.quantityType(forIdentifier: .stepCount)!, .count()) 16 | ] 17 | case .swimming: 18 | return [ 19 | (HKObjectType.quantityType(forIdentifier: .distanceSwimming)!, .meter()), 20 | (HKObjectType.quantityType(forIdentifier: .heartRate)!, .init(from: "count/min")) 21 | ] 22 | case .walking: 23 | return [ 24 | (HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning)!, .meter()), 25 | (HKObjectType.quantityType(forIdentifier: .heartRate)!, .init(from: "count/min")), 26 | (HKObjectType.quantityType(forIdentifier: .stepCount)!, .count()) 27 | ] 28 | default: 29 | return [] 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/Extensions/Std Lib/Dictionary+Extensions.swift: -------------------------------------------------------------------------------- 1 | extension Dictionary { 2 | func compactMapKeys(_ transform: (Key) -> T?) -> [T: Value] { 3 | let mapped = compactMap { key, value -> (T, Value)? in 4 | guard let newKey = transform(key) else { return nil } 5 | return (newKey, value) 6 | } 7 | return [T: Value](uniqueKeysWithValues: mapped) 8 | } 9 | 10 | func mapKeys(_ transform: (Key) -> T) -> [T: Value] { 11 | let mapped = map { (transform($0), $1) } 12 | return [T: Value](uniqueKeysWithValues: mapped) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/Extensions/SwiftUI/Binding+Extensions.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | func ?? (left: Binding, right: T) -> Binding { 4 | Binding { 5 | return left.wrappedValue ?? right 6 | } set: { 7 | left.wrappedValue = $0 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/Extensions/SwiftUI/ColorScheme+Extensions.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension ColorScheme { 4 | var textColor: Color { 5 | switch self { 6 | case .light: 7 | return .black 8 | case .dark: 9 | return .white 10 | @unknown default: 11 | return Color(uiColor: .lightText) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/Extensions/SwiftUI/Text+Extensions.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Text { 4 | init(_ int: Int) { 5 | self = Text("\(int)") 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/Extensions/SwiftUI/View+Analytics.swift: -------------------------------------------------------------------------------- 1 | import FirebaseAnalytics 2 | import SwiftUI 3 | 4 | extension View { 5 | func registerScreenView(name: String, parameters: [String: Any] = [:]) -> some View { 6 | analyticsScreen(name: name, extraParameters: parameters) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/Extensions/SwiftUI/View+ConfirmationDialog.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | func confirmationDialog(_ titleKey: LocalizedStringKey, presenting: Binding, titleVisibility: Visibility = .automatic, @ViewBuilder actions: (T) -> V) -> some View { 5 | confirmationDialog( 6 | titleKey, 7 | isPresented: .init { 8 | presenting.wrappedValue != nil 9 | } set: { isPresented in 10 | if !isPresented { 11 | presenting.wrappedValue = nil 12 | } 13 | }, 14 | titleVisibility: titleVisibility, 15 | actions: { 16 | if let value = presenting.wrappedValue { 17 | actions(value) 18 | } 19 | } 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/Extensions/SwiftUI/View+RemovingMargin.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | func removingMargin() -> some View { 5 | self 6 | .listRowBackground(Color.clear) 7 | .listRowInsets(.init(top: 0, leading: 0, bottom: 10, trailing: 0)) 8 | .listRowSeparator(.hidden) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/Nonce/Nonce.swift: -------------------------------------------------------------------------------- 1 | import CryptoKit 2 | import Foundation 3 | import Security 4 | 5 | enum Nonce { 6 | // Adapted from https://auth0.com/docs/api-auth/tutorials/nonce#generate-a-cryptographically-random-nonce 7 | static func randomNonceString(length: Int = 32) -> String { 8 | precondition(length > 0) 9 | let charset = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") 10 | var result = "" 11 | var remainingLength = length 12 | 13 | while remainingLength > 0 { 14 | let randoms: [UInt8] = (0 ..< 16).map { _ in 15 | var random: UInt8 = 0 16 | let errorCode = SecRandomCopyBytes(kSecRandomDefault, 1, &random) 17 | if errorCode != errSecSuccess { fatalError("Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)") } 18 | return random 19 | } 20 | 21 | randoms.forEach { random in 22 | if remainingLength == 0 { return } 23 | 24 | if random < charset.count { 25 | result.append(charset[Int(random)]) 26 | remainingLength -= 1 27 | } 28 | } 29 | } 30 | 31 | return result 32 | } 33 | 34 | static func sha256(_ input: String) -> String { 35 | SHA256.hash(data: Data(input.utf8)) 36 | .compactMap { String(format: "%02x", $0) } 37 | .joined() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Utility/Utility+Factory.swift: -------------------------------------------------------------------------------- 1 | import CombineSchedulers 2 | import Factory 3 | import Foundation 4 | 5 | extension Container { 6 | var scheduler: Factory> { 7 | self { AnySchedulerOf.main } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/CompetitionContainer/Competition/CompetitionParticipantRow.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CompetitionParticipantRow: View { 4 | 5 | struct Config: Identifiable { 6 | let id: String 7 | let rank: String 8 | let name: String 9 | let idPillText: String? 10 | let blurred: Bool 11 | let points: Int 12 | let highlighted: Bool 13 | } 14 | 15 | let config: Config 16 | 17 | var body: some View { 18 | HStack { 19 | Text(config.rank).bold() 20 | Text(config.name) 21 | .blur(radius: config.blurred ? 5 : 0) 22 | if let idPillText = config.idPillText { 23 | IDPill(id: idPillText) 24 | } 25 | Spacer() 26 | Text(config.points) 27 | .monospaced() 28 | } 29 | .foregroundColor(config.highlighted ? .blue : nil) 30 | } 31 | } 32 | 33 | extension CompetitionParticipantRow.Config { 34 | init(user: User?, currentUser: User, standing: Competition.Standing) { 35 | let visibility = user?.visibility(by: currentUser) ?? .hidden 36 | let rank = standing.isTie == true ? "T\(standing.rank)" : standing.rank.ordinalString ?? "?" 37 | self.init( 38 | id: standing.id, 39 | rank: rank, 40 | name: user?.name ?? standing.userId, 41 | idPillText: (user?.isAnonymous == true ? nil : user?.hashId)?.apply(visibility: visibility), 42 | blurred: visibility == .hidden, 43 | points: standing.points, 44 | highlighted: standing.userId == currentUser.id 45 | ) 46 | } 47 | } 48 | 49 | private extension String { 50 | func apply(visibility: User.Visibility) -> String? { 51 | visibility == .visible ? self : nil 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/CompetitionContainer/CompetitionContainerDateRange.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CompetitionContainerDateRange: Equatable, Identifiable { 4 | 5 | // MARK: - Public Properties 6 | 7 | let start: Date 8 | let end: Date 9 | var selected: Bool 10 | let active: Bool 11 | let title: String 12 | 13 | var id: String { title } 14 | var dateInterval: DateInterval { .init(start: start, end: end) } 15 | 16 | init(start: Date, end: Date, selected: Bool = false, active: Bool = false) { 17 | self.start = start 18 | self.end = end 19 | self.selected = selected 20 | self.active = active 21 | self.title = Self.dateFormatter.string(from: start, to: end) 22 | } 23 | 24 | // MARK: - Private Properties 25 | 26 | private static let dateFormatter: DateIntervalFormatter = { 27 | let formatter = DateIntervalFormatter() 28 | formatter.dateStyle = .medium 29 | formatter.timeStyle = .none 30 | return formatter 31 | }() 32 | } 33 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/ActivitySummaryInfo/ActivitySummaryInfoSource.swift: -------------------------------------------------------------------------------- 1 | enum ActivitySummaryInfoSource { 2 | case local 3 | case other(ActivitySummary?) 4 | } 5 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/ActivitySummaryInfo/Health Kit Permissions Guide/HealthKitPermissionsGuideView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct HealthKitPermissionsGuideView: View { 4 | 5 | @StateObject private var viewModel = HealthKitPermissionsGuideViewModel() 6 | 7 | // init() { 8 | // let viewModel = HealthKitPermissionsGuideViewModel() 9 | // _viewModel = .init(wrappedValue: viewModel) 10 | // } 11 | 12 | var body: some View { 13 | // TabView { 14 | // Summary() 15 | Summary() 16 | // } 17 | // .tabViewStyle(.page(indexDisplayMode: .always)) 18 | .padding(.extraExtraLarge) 19 | .background(.red) 20 | } 21 | } 22 | 23 | private struct Summary: View { 24 | var body: some View { 25 | NavigationView { 26 | VStack(alignment: .leading) { 27 | Text("Some title") 28 | .redacted(reason: .placeholder) 29 | Text("Some content") 30 | // .maxWidth(.infinity) 31 | // .padding(.extraLarge) 32 | .redacted(reason: .placeholder) 33 | Spacer() 34 | } 35 | .navigationTitle("Summary") 36 | } 37 | .frame(height: 500) 38 | } 39 | } 40 | 41 | #if DEBUG 42 | struct HealthKitPermissionsGuideView_Previews: PreviewProvider { 43 | static var previews: some View { 44 | HealthKitPermissionsGuideView() 45 | .setupMocks() 46 | } 47 | } 48 | #endif 49 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/ActivitySummaryInfo/Health Kit Permissions Guide/HealthKitPermissionsGuideViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineExt 3 | import ECKit 4 | 5 | final class HealthKitPermissionsGuideViewModel: ObservableObject { 6 | 7 | // MARK: - Public Properties 8 | 9 | // MARK: - Private Properties 10 | 11 | private var cancellables = Cancellables() 12 | 13 | // MARK: - Lifecycle 14 | 15 | init() {} 16 | 17 | // MARK: - Public Methods 18 | } 19 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/AppIcon.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | struct AppIcon: View { 5 | 6 | var size = 60.0 7 | private var cornerRadius: Double { size * 0.2237 } 8 | 9 | var body: some View { 10 | Image(uiImage: Bundle.main.icon) 11 | .resizable() 12 | .frame(width: size, height: size) 13 | .cornerRadius(cornerRadius) 14 | } 15 | } 16 | 17 | #if DEBUG 18 | struct AppIcon_Previews: PreviewProvider { 19 | static var previews: some View { 20 | AppIcon() 21 | } 22 | } 23 | #endif 24 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/CompetitionDetails/CompetitionDetailsViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineExt 3 | import Factory 4 | 5 | final class CompetitionDetailsViewModel: ObservableObject { 6 | 7 | // MARK: - Public Properties 8 | 9 | @Published var competition: Competition 10 | @Published var isInvitation = false 11 | 12 | // MARK: - Private Properties 13 | 14 | @Injected(\.competitionsManager) private var competitionsManager: CompetitionsManaging 15 | @Injected(\.userManager) private var userManager: UserManaging 16 | 17 | // MARK: - Lifecycle 18 | 19 | init(competition: Competition) { 20 | self.competition = competition 21 | 22 | competitionsManager.competitionPublisher(for: competition.id) 23 | .catchErrorJustReturn(competition) 24 | .map { [weak self] in 25 | guard let self else { return false } 26 | return $0.pendingParticipants.contains(self.userManager.user.id) 27 | } 28 | .assign(to: &$isInvitation) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/EmailSignIn/EmailSignInViewInputType.swift: -------------------------------------------------------------------------------- 1 | enum EmailSignInViewInputType: Equatable { 2 | case signIn 3 | case signUp 4 | 5 | var title: String { 6 | switch self { 7 | case .signIn: 8 | return L10n.SignIn.email 9 | case .signUp: 10 | return L10n.CreateAccount.email 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/FirebaseImage/FirebaseImage.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUIX 3 | 4 | struct FirebaseImage: View { 5 | 6 | @StateObject private var viewModel: FirebaseImageViewModel 7 | private let alternateContent: (() -> Content)? 8 | 9 | init(path: String, alternateContent: (() -> Content)?) { 10 | self.alternateContent = alternateContent 11 | _viewModel = .init(wrappedValue: .init(path: path)) 12 | } 13 | 14 | var body: some View { 15 | if let imageData = viewModel.imageData { 16 | if let image = UIImage(data: imageData) { 17 | Image(uiImage: image) 18 | .resizable() 19 | .aspectRatio(contentMode: .fill) 20 | .scaledToFill() 21 | } else { 22 | alternateContent?() ?? failedImage 23 | } 24 | } else if let alternateContent { 25 | alternateContent() 26 | } else if viewModel.failed { 27 | failedImage 28 | } else { 29 | ProgressView() 30 | .progressViewStyle(.circular) 31 | .frame(maxWidth: .infinity, maxHeight: .infinity) 32 | } 33 | } 34 | 35 | private var failedImage: some View { 36 | Image(systemName: .boltHorizontalCircle) 37 | .font(.largeTitle) 38 | } 39 | } 40 | 41 | extension FirebaseImage where Content == EmptyView { 42 | init(path: String) { 43 | self.init(path: path, alternateContent: nil) 44 | } 45 | } 46 | 47 | #if DEBUG 48 | struct FirebaseImage_Previews: PreviewProvider { 49 | static var previews: some View { 50 | FirebaseImage(path: "") 51 | .setupMocks() 52 | } 53 | } 54 | #endif 55 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/FirebaseImage/FirebaseImageViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineExt 3 | import CombineSchedulers 4 | import Factory 5 | import Foundation 6 | 7 | final class FirebaseImageViewModel: ObservableObject { 8 | 9 | // MARK: - Private Properties 10 | 11 | @Published private(set) var failed = false 12 | @Published private(set) var imageData: Data? 13 | 14 | // MARK: - Private Properties 15 | 16 | private let path: String 17 | 18 | @Injected(\.scheduler) private var scheduler: AnySchedulerOf 19 | @Injected(\.storageManager) private var storageManager: StorageManaging 20 | 21 | // MARK: - Lifecycle 22 | 23 | init(path: String) { 24 | self.path = path 25 | downloadImage() 26 | } 27 | 28 | // MARK: - Private Methods 29 | 30 | private func downloadImage() { 31 | storageManager.get(path) 32 | .asOptional() 33 | .retry(3) 34 | .catch { [weak self] _ -> AnyPublisher in 35 | self?.failed = true 36 | return .just(nil) 37 | } 38 | .receive(on: scheduler) 39 | .assign(to: &$imageData) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/GoogleAd/GoogleAdUnit.swift: -------------------------------------------------------------------------------- 1 | import ECKit 2 | import Factory 3 | import GoogleMobileAds 4 | 5 | enum GoogleAdUnit { 6 | case native(unit: String) 7 | 8 | var identifier: String { 9 | switch self { 10 | case .native(let unit): 11 | let environment = Container.shared.environmentManager.resolve().environment 12 | if environment.isDebug { 13 | // https://developers.google.com/admob/ios/test-ads#demo_ad_units 14 | // return "ca-app-pub-3940256099942544/3986624511" // native 15 | return "ca-app-pub-3940256099942544/2521693316" // native video 16 | } else { 17 | return unit 18 | } 19 | } 20 | } 21 | 22 | var adTypes: [GADAdLoaderAdType] { 23 | switch self { 24 | case .native: 25 | return [.native] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/ImmutableListItemView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUIX 3 | 4 | struct ImmutableListItemView: View { 5 | 6 | enum ValueType { 7 | case name 8 | case email 9 | case date(description: String) 10 | case other(systemImage: SFSymbolName, description: String) 11 | 12 | var systemImageName: SFSymbolName { 13 | switch self { 14 | case .name: 15 | return .personFill 16 | case .email: 17 | return .envelopeFill 18 | case .date: 19 | return .calendar 20 | case let .other(systemImage, _): 21 | return systemImage 22 | } 23 | } 24 | 25 | var description: String { 26 | switch self { 27 | case .name: 28 | return L10n.ListItem.Name.description 29 | case .email: 30 | return L10n.ListItem.Email.description 31 | case let .date(description): 32 | return description 33 | case let .other(_, description): 34 | return description 35 | } 36 | } 37 | } 38 | 39 | let value: String 40 | let valueType: ValueType 41 | 42 | var body: some View { 43 | HStack { 44 | Label(valueType.description, systemImage: valueType.systemImageName) 45 | Spacer() 46 | Text(value) 47 | .foregroundColor(.secondaryLabel) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/MedalsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct MedalsView: View { 4 | let statistics: User.Medals 5 | 6 | var body: some View { 7 | MedalView(title: "🥇 Gold medals", value: statistics.golds) 8 | MedalView(title: "🥈 Silver medals", value: statistics.silvers) 9 | MedalView(title: "🥉 Bronze medals", value: statistics.bronzes) 10 | } 11 | } 12 | 13 | struct MedalView: View { 14 | let title: String 15 | let value: Int 16 | 17 | var body: some View { 18 | HStack { 19 | Text(title) 20 | Spacer() 21 | Text("\(value)") 22 | .foregroundColor(.gray) 23 | .monospaced() 24 | } 25 | } 26 | } 27 | 28 | #if DEBUG 29 | struct MedalsView_Previews: PreviewProvider { 30 | static var previews: some View { 31 | List { 32 | MedalsView(statistics: .mock) 33 | } 34 | } 35 | } 36 | #endif 37 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/SignInWithAppleButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SignInWithAppleButton: View { 4 | 5 | let title: String 6 | let action: () -> Void 7 | 8 | @Environment(\.colorScheme) private var colorScheme 9 | 10 | init(_ title: String = L10n.SignIn.apple, action: @escaping () -> Void) { 11 | self.title = title 12 | self.action = action 13 | } 14 | 15 | var body: some View { 16 | Button(action: action) { 17 | Label(title, systemImage: "applelogo") 18 | .font(.title2.weight(.semibold)) 19 | .padding(8) 20 | .frame(maxWidth: .infinity) 21 | } 22 | .foregroundColor(colorScheme == .light ? .white : .black) 23 | .tint(colorScheme == .light ? .black : .white) 24 | .buttonStyle(.borderedProminent) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/TextFieldWithSecureToggle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUIX 3 | 4 | struct TextFieldWithSecureToggle: View { 5 | 6 | private enum FocusedField { 7 | case secure 8 | case unsecure 9 | 10 | var symbol: SFSymbolName { 11 | switch self { 12 | case .secure: 13 | return .eyeSlash 14 | case .unsecure: 15 | return .eye 16 | } 17 | } 18 | } 19 | 20 | private let title: String 21 | private let text: Binding 22 | 23 | @FocusState var focused: Bool 24 | @State private var showPassword: Bool 25 | 26 | private let textContentType: UITextContentType? 27 | 28 | init(_ title: String, text: Binding, showPassword: Bool = false, textContentType: UITextContentType? = nil) { 29 | self.title = title 30 | self.text = text 31 | self.showPassword = showPassword 32 | self.textContentType = textContentType 33 | } 34 | 35 | var body: some View { 36 | HStack { 37 | ZStack { 38 | TextField(title, text: text) 39 | .textContentType(textContentType) 40 | .focused($focused) 41 | .hidden(!showPassword) 42 | SecureField(title, text: text) 43 | .textContentType(textContentType) 44 | .focused($focused) 45 | .hidden(showPassword) 46 | } 47 | 48 | Button(systemImage: showPassword ? .eyeSlash : .eye) { 49 | showPassword.toggle() 50 | } 51 | .foregroundColor(.secondaryLabel) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/UIKitWrappers/ActivityRingView.swift: -------------------------------------------------------------------------------- 1 | import HealthKitUI 2 | import SwiftUI 3 | import SwiftUIX 4 | 5 | struct ActivityRingView: UIViewRepresentable { 6 | 7 | var activitySummary: HKActivitySummary? 8 | 9 | func makeUIView(context: Context) -> HKActivityRingView { 10 | HKActivityRingView() 11 | } 12 | 13 | func updateUIView(_ uiView: HKActivityRingView, context: Context) { 14 | uiView.setActivitySummary(activitySummary, animated: true) 15 | } 16 | } 17 | 18 | #if DEBUG 19 | struct ActivityRingView_Previews: PreviewProvider { 20 | static var previews: some View { 21 | ActivityRingView(activitySummary: .mock) 22 | .squareFrame() 23 | } 24 | } 25 | #endif 26 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/UserHashIDPill.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct IDPill: View { 4 | 5 | let id: String 6 | 7 | var body: some View { 8 | Text(id) 9 | .font(.footnote) 10 | .monospaced() 11 | .foregroundColor(.gray) 12 | .padding(.horizontal, 8) 13 | .padding(.vertical, 4) 14 | .background(.ultraThinMaterial) 15 | .clipShape(Capsule()) 16 | } 17 | } 18 | 19 | #if DEBUG 20 | struct IDPill_Previews: PreviewProvider { 21 | static var previews: some View { 22 | IDPill(id: User.evan.hashId) 23 | } 24 | } 25 | #endif 26 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Components/UserInfoSection.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct UserInfoSection: View { 4 | 5 | let user: User 6 | 7 | var body: some View { 8 | Section { 9 | HStack { 10 | ImmutableListItemView(value: user.name, valueType: .name) 11 | if user.isAnonymous != true { 12 | IDPill(id: user.hashId) 13 | } 14 | } 15 | if let email = user.email { 16 | ImmutableListItemView(value: email, valueType: .email) 17 | } 18 | } 19 | } 20 | } 21 | 22 | #if DEBUG 23 | struct UserInfoSection_Previews: PreviewProvider { 24 | static var previews: some View { 25 | List { 26 | UserInfoSection(user: .evan) 27 | } 28 | } 29 | } 30 | #endif 31 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Developer/DeveloperMenu.swift: -------------------------------------------------------------------------------- 1 | import ECKit 2 | import Factory 3 | import SwiftUI 4 | 5 | struct DeveloperMenu: View { 6 | 7 | @StateObject private var viewModel = DeveloperMenuViewModel() 8 | 9 | var body: some View { 10 | Menu { 11 | Picker("", selection: $viewModel.environment) { 12 | ForEach(DeveloperMenuViewModel.UnderlyingEnvironment.allCases) { environment in 13 | Text(viewModel.text(for: environment)) 14 | .tag(environment) 15 | } 16 | } 17 | Button(action: viewModel.featureFlagButtonTapped) { 18 | Label("Feature flags", systemImage: .flagFill) 19 | } 20 | } label: { 21 | Image(systemName: .hammerCircleFill) 22 | } 23 | .registerScreenView(name: "Developer") 24 | .alert("Environment Destination", isPresented: $viewModel.showDestinationAlert) { 25 | TextField("Destination", text: $viewModel.destination) 26 | .textInputAutocapitalization(.never) 27 | .font(.body) 28 | } 29 | .sheet(isPresented: $viewModel.showFeatureFlag) { 30 | FeatureFlagOverrideView() 31 | } 32 | } 33 | } 34 | 35 | #if DEBUG 36 | struct DeveloperMenu_Previews: PreviewProvider { 37 | static var previews: some View { 38 | DeveloperMenu() 39 | .setupMocks() 40 | } 41 | } 42 | #endif 43 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Explore/FeaturedCompetition.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct FeaturedCompetition: View { 4 | 5 | let competition: Competition 6 | 7 | @Environment(\.colorScheme) private var colorScheme 8 | 9 | var body: some View { 10 | color 11 | .aspectRatio(3/2, contentMode: .fit) 12 | .overlay { 13 | if let banner = competition.banner { 14 | FirebaseImage(path: banner) 15 | } 16 | } 17 | .overlay { 18 | CompetitionDetails(competition: competition, showParticipantCount: true, isFeatured: true) 19 | .padding(.vertical, 8) 20 | .padding(.horizontal) 21 | .background(.ultraThinMaterial) 22 | .frame(maxHeight: .infinity, alignment: .bottom) 23 | } 24 | .cornerRadius(10) 25 | } 26 | 27 | private var color: some View { 28 | colorScheme == .light ? Color(uiColor: .systemGray4) : Color.secondarySystemBackground 29 | } 30 | } 31 | 32 | #if DEBUG 33 | struct FeaturedCompetitionView_Previews: PreviewProvider { 34 | 35 | private static let competition = Competition.mockPublic 36 | 37 | private static func setupMocks() { 38 | competitionsManager.competitions = .just([competition]) 39 | storageManager.getReturnValue = .just(.init()) 40 | } 41 | 42 | static var previews: some View { 43 | List { 44 | Section { 45 | FeaturedCompetition(competition: competition) 46 | } 47 | .removingMargin() 48 | } 49 | .navigationTitle("Previews") 50 | .embeddedInNavigationView() 51 | .setupMocks(setupMocks) 52 | } 53 | } 54 | #endif 55 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Home/HomeViewEmptyContent.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct HomeViewEmptyContent: View { 4 | 5 | let symbol: String 6 | let message: String 7 | let buttons: [HomeViewEmptyContentButtonConfiguration] 8 | 9 | struct HomeViewEmptyContentButtonConfiguration: Identifiable { 10 | var id: String { title } 11 | let title: String 12 | let action: () -> Void 13 | } 14 | 15 | var body: some View { 16 | VStack(alignment: .center, spacing: 20) { 17 | Image(systemName: symbol) 18 | .symbolRenderingMode(.hierarchical) 19 | .resizable() 20 | .scaledToFit() 21 | .height(75) 22 | .foregroundStyle(.secondary) 23 | 24 | Text(message) 25 | .multilineTextAlignment(.center) 26 | .foregroundStyle(.secondary) 27 | .fixedSize(horizontal: false, vertical: true) 28 | 29 | HStack { 30 | ForEach(enumerating: buttons) { index, button in 31 | let button = Button(button.title, action: button.action) 32 | switch index { 33 | case 0: 34 | button.buttonStyle(.borderedProminent) 35 | case 1: 36 | button.buttonStyle(.bordered) 37 | default: 38 | button 39 | } 40 | } 41 | } 42 | } 43 | .padding(.vertical) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Home/Notifications/NotificationsViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineExt 3 | import CombineSchedulers 4 | import ECKit 5 | import Factory 6 | import Foundation 7 | 8 | final class NotificationsViewModel: ObservableObject { 9 | 10 | // MARK: - Public Properties 11 | 12 | @Published private(set) var banners = [Banner]() 13 | @Published private(set) var loading = false 14 | @Published private(set) var dismiss = false 15 | 16 | // MARK: - Private Properties 17 | 18 | @Injected(\.bannerManager) private var bannerManager: BannerManaging 19 | 20 | private var cancellables = Cancellables() 21 | 22 | // MARK: - Lifecycle 23 | 24 | init() { 25 | bannerManager.banners.assign(to: &$banners) 26 | } 27 | 28 | // MARK: - Public Methods 29 | 30 | func tapped(_ banner: Banner) { 31 | bannerManager.tapped(banner) 32 | .isLoading { [weak self] in self?.loading = $0 } 33 | .sink { [weak self] in self?.dismiss = true } 34 | .store(in: &cancellables) 35 | } 36 | 37 | func dismissed(_ banner: Banner) { 38 | bannerManager.dismissed(banner) 39 | .sink() 40 | .store(in: &cancellables) 41 | } 42 | 43 | func reset() { 44 | bannerManager.resetDismissed() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Home/User/UserViewAction.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUIX 3 | 4 | enum UserViewAction { 5 | case acceptFriendRequest 6 | case denyFriendRequest 7 | case request 8 | case deleteFriend 9 | } 10 | 11 | extension UserViewAction { 12 | 13 | var buttonTitle: String { 14 | switch self { 15 | case .acceptFriendRequest: 16 | return L10n.User.Action.AcceptFriendRequest.title 17 | case .denyFriendRequest: 18 | return L10n.User.Action.DeclineFriendRequest.title 19 | case .request: 20 | return L10n.User.Action.RequestFriend.title 21 | case .deleteFriend: 22 | return L10n.User.Action.DeleteFriend.title 23 | } 24 | } 25 | 26 | var destructive: Bool { 27 | switch self { 28 | case .acceptFriendRequest, .request: 29 | return false 30 | case .denyFriendRequest, .deleteFriend: 31 | return true 32 | } 33 | } 34 | 35 | var systemImage: SFSymbolName { 36 | switch self { 37 | case .acceptFriendRequest, .request: 38 | return .personCropCircleBadgePlus 39 | case .denyFriendRequest, .deleteFriend: 40 | return .personCropCircleBadgeMinus 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/InviteFriends/InviteFriendsAction.swift: -------------------------------------------------------------------------------- 1 | enum InviteFriendsAction { 2 | case addFriend 3 | case competitionInvite(Competition) 4 | } 5 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/NavigationDestination.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum NavigationDestination: Hashable { 4 | case competition(Competition, CompetitionResult?) 5 | case profile 6 | case user(User) 7 | } 8 | 9 | extension NavigationDestination: Identifiable { 10 | var id: String { 11 | switch self { 12 | case .competition(let competition, let result): 13 | return [competition.id, result?.id] 14 | .compacted() 15 | .joined(separator: "_") 16 | case .profile: 17 | return "profile" 18 | case .user(let user): 19 | return user.id 20 | } 21 | } 22 | } 23 | 24 | extension NavigationDestination { 25 | @ViewBuilder 26 | var view: some View { 27 | switch self { 28 | case .competition(let compeittion, let result): 29 | CompetitionContainerView(competition: compeittion, result: result) 30 | case .user(let user): 31 | UserView(user: user) 32 | case .profile: 33 | SettingsView() 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/RootTab.swift: -------------------------------------------------------------------------------- 1 | enum RootTab { 2 | case home 3 | case explore 4 | case settings 5 | } 6 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/RootView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct RootView: View { 4 | 5 | @StateObject private var viewModel = RootViewModel() 6 | 7 | var body: some View { 8 | TabView(selection: $viewModel.tab) { 9 | HomeView() 10 | .tabItem { Label(L10n.Root.home, systemImage: .houseFill) } 11 | .tag(RootTab.home) 12 | 13 | ExploreView() 14 | .tabItem { Label(L10n.Root.explore, systemImage: .sparkleMagnifyingglass) } 15 | .tag(RootTab.explore) 16 | 17 | SettingsView() 18 | .tabItem { Label(L10n.Root.settings, systemImage: .gear) } 19 | .tag(RootTab.settings) 20 | } 21 | } 22 | } 23 | 24 | #if DEBUG 25 | struct RootView_Previews: PreviewProvider { 26 | 27 | private static func setupMocks() { 28 | activitySummaryManager.activitySummary = .just(.mock) 29 | 30 | competitionsManager.appOwnedCompetitions = .just([.mockPublic, .mockPublic]) 31 | competitionsManager.competitions = .just([.mock, .mockInvited, .mockOld]) 32 | competitionsManager.standingsPublisherForLimitReturnValue = .just([.mock(for: .evan)]) 33 | 34 | let friend = User.gabby 35 | friendsManager.friends = .just([friend]) 36 | friendsManager.friendRequests = .just([friend]) 37 | friendsManager.friendActivitySummaries = .just([friend.id: .mock]) 38 | 39 | searchManager.searchForUsersWithIDsReturnValue = .just([.evan]) 40 | } 41 | 42 | static var previews: some View { 43 | RootView() 44 | .setupMocks(setupMocks) 45 | } 46 | } 47 | #endif 48 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/RootViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineExt 3 | import CombineSchedulers 4 | import ECKit 5 | import Factory 6 | import Foundation 7 | 8 | final class RootViewModel: ObservableObject { 9 | 10 | // MARK: - Public Properties 11 | 12 | @Published var tab = RootTab.home 13 | 14 | // MARK: - Private Properties 15 | 16 | @Injected(\.appState) private var appState: AppStateProviding 17 | @Injected(\.scheduler) private var scheduler: AnySchedulerOf 18 | 19 | private var cancellables = Cancellables() 20 | 21 | // MARK: - Lifecycle 22 | 23 | init() { 24 | appState.deepLink 25 | .unwrap() 26 | .removeDuplicates() 27 | .mapToValue(.home) 28 | .assign(to: &$tab) 29 | 30 | appState.rootTab 31 | .receive(on: scheduler) 32 | .sink(withUnretained: self) { $0.tab = $1 } 33 | .store(in: &cancellables) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Settings/ReportIssue/ReportIssueView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ReportIssueView: View { 4 | 5 | @Environment(\.dismiss) private var dismiss 6 | @StateObject private var viewModel = ReportIssueViewModel() 7 | 8 | var body: some View { 9 | Form { 10 | TextField("Title", text: $viewModel.title) 11 | TextField("Description", text: $viewModel.description) 12 | Button("Submit", action: viewModel.submit) 13 | } 14 | .withLoadingOverlay(isLoading: viewModel.loading) 15 | .errorAlert(error: $viewModel.error) 16 | .alert("Issue submitted", isPresented: $viewModel.showSuccess) { 17 | Button("Ok") { dismiss() } 18 | } message: { 19 | Text("Your issue has been submitted. We will review it as soon as possible.") 20 | } 21 | .navigationTitle("Report an issue") 22 | } 23 | } 24 | 25 | #if DEBUG 26 | struct ReportIssueView_Previews: PreviewProvider { 27 | static var previews: some View { 28 | setupMocks { 29 | storageManager.setDataReturnValue = .just(()) 30 | .delay(for: .seconds(2), scheduler: RunLoop.main) 31 | .eraseToAnyPublisher() 32 | } 33 | return ReportIssueView() 34 | .embeddedInNavigationView() 35 | } 36 | } 37 | #endif 38 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Settings/ReportIssue/ReportIssueViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineExt 3 | import ECKit 4 | import Factory 5 | import Foundation 6 | 7 | final class ReportIssueViewModel: ObservableObject { 8 | 9 | // MARK: - Public Properties 10 | 11 | @Published var title = "" 12 | @Published var description = "" 13 | var submitDisabled: Bool { title.isEmpty || description.isEmpty } 14 | 15 | @Published var showSuccess = false 16 | @Published var loading = false 17 | @Published var error: Error? 18 | 19 | // MARK: - Private Properties 20 | 21 | @Injected(\.issueReporter) private var issueReporter: IssueReporting 22 | 23 | private var cancellables = Cancellables() 24 | 25 | // MARK: - Lifecycle 26 | 27 | init() {} 28 | 29 | // MARK: - Public Methods 30 | 31 | func submit() { 32 | issueReporter 33 | .report(title: title, description: description) 34 | .eraseToAnyPublisher() 35 | .isLoading(set: \.loading, on: self) 36 | .materialize() 37 | .sink(receiveValue: { [weak self] event in 38 | guard let self else { return } 39 | switch event { 40 | case .failure(let error): 41 | self.error = error 42 | case .value, .finished: 43 | showSuccess = true 44 | } 45 | }) 46 | .store(in: &cancellables) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Styles/FlatLinkStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // https://stackoverflow.com/a/62311089/4027606 4 | struct FlatLinkStyle: ButtonStyle { 5 | func makeBody(configuration: Configuration) -> some View { 6 | configuration.label 7 | } 8 | } 9 | 10 | extension ButtonStyle where Self == FlatLinkStyle { 11 | static var flatLink: Self { FlatLinkStyle() } 12 | } 13 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Welcome/VerifyEmail/VerifyEmailView.swift: -------------------------------------------------------------------------------- 1 | import Factory 2 | import SwiftUI 3 | 4 | struct VerifyEmailView: View { 5 | 6 | @StateObject private var viewModel = VerifyEmailViewModel() 7 | 8 | var body: some View { 9 | VStack(spacing: 50) { 10 | 11 | Button(L10n.VerifyEmail.signIn, systemImage: .chevronLeft, action: viewModel.back) 12 | .frame(maxWidth: .infinity, alignment: .leading) 13 | 14 | Spacer() 15 | 16 | Image(systemName: .envelopeBadge) 17 | .font(.system(size: 100)) 18 | .padding(50) 19 | .background(Color(uiColor: .systemGray6)) 20 | .clipShape(Circle()) 21 | 22 | VStack(alignment: .leading, spacing: 10) { 23 | Text(L10n.VerifyEmail.title) 24 | .font(.title) 25 | .padding(.bottom, -5) 26 | if let email = viewModel.user.email { 27 | Text(L10n.VerifyEmail.instructions(email)) 28 | .fixedSize(horizontal: false, vertical: true) 29 | .foregroundColor(.gray) 30 | } 31 | Button(L10n.VerifyEmail.sendAgain, systemImage: .paperplaneFill, action: viewModel.resendVerification) 32 | } 33 | 34 | Spacer() 35 | Spacer() 36 | Spacer() 37 | } 38 | .padding(.horizontal) 39 | } 40 | } 41 | 42 | #if DEBUG 43 | struct VerifyEmailView_Previews: PreviewProvider { 44 | static var previews: some View { 45 | VerifyEmailView() 46 | .setupMocks() 47 | } 48 | } 49 | #endif 50 | -------------------------------------------------------------------------------- /FriendlyCompetitions/Views/Welcome/WelcomeNavigationDestination.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum WelcomeNavigationDestination: Hashable { 4 | case emailSignIn 5 | } 6 | 7 | extension WelcomeNavigationDestination { 8 | @ViewBuilder 9 | var view: some View { 10 | switch self { 11 | case .emailSignIn: 12 | EmailSignInView() 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/AppServices/Background Jobs/BackgroundJobsAppServiceTests.swift: -------------------------------------------------------------------------------- 1 | @testable import FriendlyCompetitions 2 | import XCTest 3 | 4 | final class BackgroundJobsAppServiceTests: FCTestCase { 5 | func testThatBackgroundNotificationReceivedAnalyticEventIsLogged() { 6 | let service = BackgroundJobsAppService() 7 | service.didReceiveRemoteNotification(with: [:]) 8 | .sink() 9 | .store(in: &cancellables) 10 | XCTAssertTrue(analyticsManager.logEventAnalyticsEventVoidReceivedInvocations.contains(.backgroundNotificationReceived)) 11 | } 12 | 13 | func testThatBackgroundNotificationfailedToParseJobAnalyticEventIsLogged() { 14 | let service = BackgroundJobsAppService() 15 | 16 | // fails 17 | service.didReceiveRemoteNotification(with: [:]) 18 | .sink() 19 | .store(in: &cancellables) 20 | 21 | // fails 22 | service.didReceiveRemoteNotification(with: ["customData": [String: Any]()]) 23 | .sink() 24 | .store(in: &cancellables) 25 | 26 | // does not fail 27 | service.didReceiveRemoteNotification(with: ["customData": ["backgroundJob": [String: Any]()]]) 28 | .sink() 29 | .store(in: &cancellables) 30 | 31 | let failedCount = analyticsManager.logEventAnalyticsEventVoidReceivedInvocations 32 | .filter { $0 == .backgroundNotificationFailedToParseJob } 33 | .count 34 | 35 | XCTAssertEqual(failedCount, 2) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/AppServices/Notifications/NotificationsAppServiceTests.swift: -------------------------------------------------------------------------------- 1 | @testable import FriendlyCompetitions 2 | import XCTest 3 | 4 | final class NotificationsAppServiceTests: FCTestCase { 5 | func testThatNotificationsManagerIsSetUp() { 6 | let service = NotificationsAppService() 7 | service.didFinishLaunching() 8 | XCTAssertTrue(notificationsManager.setUpCalled) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Extensions/StdLib/ArrayTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import FriendlyCompetitions 4 | 5 | final class ArrayTests: XCTestCase { 6 | func testThatAppendingIsCorrect() { 7 | let array = [0] 8 | XCTAssertEqual(array.appending(1), [0, 1]) 9 | } 10 | 11 | func testThatAppendingContentsOfIsCorrect() { 12 | let array = [0] 13 | XCTAssertEqual(array.appending(contentsOf: []), [0]) 14 | XCTAssertEqual(array.appending(contentsOf: [1]), [0, 1]) 15 | } 16 | 17 | func testThatRemoveIsCorrect() { 18 | var array = [0] 19 | array.remove(1) 20 | XCTAssertEqual(array, [0]) 21 | array.remove(0) 22 | XCTAssertEqual(array, []) 23 | } 24 | 25 | func testThatRemovingIsCorrect() { 26 | let array = [0] 27 | XCTAssertEqual(array.removing(0), []) 28 | XCTAssertEqual(array.removing(1), [0]) 29 | } 30 | 31 | func testAllTrue() { 32 | XCTAssertFalse([false, true, false, true].allTrue()) 33 | XCTAssertTrue([true, true].allTrue()) 34 | XCTAssertTrue([true].allTrue()) 35 | XCTAssertTrue([].allTrue()) 36 | } 37 | 38 | func testAllFalse() { 39 | XCTAssertFalse([false, true, false, true].allFalse()) 40 | XCTAssertTrue([false, false].allFalse()) 41 | XCTAssertTrue([false].allFalse()) 42 | XCTAssertTrue([].allFalse()) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Extensions/StdLib/DecodableTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import FriendlyCompetitions 4 | 5 | final class DecodableTests: XCTestCase { 6 | func testThatDecodedIsCorrect() throws { 7 | struct Model: Codable, Equatable { 8 | let foo: Int 9 | let bar: String 10 | } 11 | 12 | let model = Model(foo: 1, bar: "a") 13 | let data = try model.encoded() 14 | XCTAssertEqual(model, try Model.decoded(from: data)) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Extensions/StdLib/EncodableTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import FriendlyCompetitions 4 | 5 | final class EncodableTests: XCTestCase { 6 | 7 | func testThatJsonDictionaryIsCorrect() throws { 8 | struct Model: Encodable { 9 | let foo: Int 10 | let bar: String 11 | } 12 | 13 | let model = Model(foo: 1, bar: "a") 14 | let expected: [String: Any] = ["foo": 1, "bar": "a"] 15 | let actual = try model.jsonDictionary() 16 | XCTAssertEqual(expected["foo"] as! Int, actual["foo"] as! Int) 17 | XCTAssertEqual(expected["bar"] as! String, actual["bar"] as! String) 18 | } 19 | 20 | func testThatEncodedIsCorrect() throws { 21 | let string = "Something to be encoded" 22 | let expected = try JSONEncoder.shared.encode(string) 23 | XCTAssertEqual(expected, try string.encoded()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Extensions/StdLib/IntTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import FriendlyCompetitions 4 | 5 | final class IntTests: XCTestCase { 6 | func testThatSecondsIsCorrect() { 7 | XCTAssertEqual(1.seconds, 1) 8 | XCTAssertEqual(10.seconds, 10) 9 | XCTAssertEqual(100.seconds, 100) 10 | } 11 | 12 | func testThatMinutesIsCorrect() { 13 | XCTAssertEqual(1.minutes, 60) 14 | XCTAssertEqual(10.minutes, 600) 15 | XCTAssertEqual(100.minutes, 6000) 16 | } 17 | 18 | func testThatHoursIsCorrect() { 19 | XCTAssertEqual(1.hours, 3600) 20 | XCTAssertEqual(10.hours, 36000) 21 | XCTAssertEqual(100.hours, 360000) 22 | } 23 | 24 | func testThatDaysIsCorrect() { 25 | XCTAssertEqual(1.days, 86400) 26 | XCTAssertEqual(10.days, 864000) 27 | XCTAssertEqual(100.days, 8640000) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Extensions/StdLib/SequenceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import FriendlyCompetitions 4 | 5 | final class SequenceTests: XCTestCase { 6 | func testThatSortedIsCorrect() { 7 | struct Sortable { 8 | let foo: Int 9 | let bar: String 10 | } 11 | 12 | let sortables: [Sortable] = [ 13 | .init(foo: 1, bar: "c"), 14 | .init(foo: 2, bar: "b"), 15 | .init(foo: 3, bar: "a") 16 | ] 17 | 18 | let sortedOnFoo = sortables.sorted(by: \.foo) 19 | let sortedOnBar = sortables.sorted(by: \.bar) 20 | 21 | XCTAssertEqual(sortedOnFoo.map(\.foo), [1, 2, 3]) 22 | XCTAssertEqual(sortedOnBar.map(\.bar), ["a", "b", "c"]) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Extensions/StdLib/StringTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import FriendlyCompetitions 4 | 5 | final class StringTests: XCTestCase { 6 | func testThatAfterWorks() { 7 | let string = "abc123" 8 | XCTAssertEqual(string.after(prefix: "a"), "bc123") 9 | XCTAssertEqual(string.after(prefix: "ab"), "c123") 10 | XCTAssertEqual(string.after(prefix: "abc"), "123") 11 | XCTAssertNil(string.after(prefix: "abcd")) 12 | XCTAssertNil(string.after(prefix: "123")) 13 | } 14 | 15 | func testThatBeforeWorks() { 16 | let string = "abc123" 17 | XCTAssertEqual(string.before(suffix: "3"), "abc12") 18 | XCTAssertEqual(string.before(suffix: "23"), "abc1") 19 | XCTAssertEqual(string.before(suffix: "123"), "abc") 20 | XCTAssertNil(string.before(suffix: "1234")) 21 | XCTAssertNil(string.before(suffix: "abc")) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Managers/AuthenticationManagerTests.swift: -------------------------------------------------------------------------------- 1 | import CombineSchedulers 2 | import ECKit 3 | import XCTest 4 | 5 | @testable import FriendlyCompetitions 6 | 7 | final class AuthenticationManagerTests: FCTestCase { 8 | 9 | func testThatLoggedInIsFalseOnLaunch() { 10 | let expectation = self.expectation(description: #function) 11 | 12 | auth.userPublisherReturnValue = .never() 13 | 14 | let manager = AuthenticationManager() 15 | manager.loggedIn 16 | .expect(false, expectation: expectation) 17 | .store(in: &cancellables) 18 | 19 | waitForExpectations(timeout: 1) 20 | } 21 | 22 | func testThatIsLoggedInIsTrueOnLaunch() { 23 | let expectation = self.expectation(description: #function) 24 | 25 | auth.userPublisherReturnValue = .never() 26 | authenticationCache.currentUser = .evan 27 | 28 | let manager = AuthenticationManager() 29 | manager.loggedIn 30 | .expect(true, expectation: expectation) 31 | .store(in: &cancellables) 32 | 33 | waitForExpectations(timeout: 1) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Models/Competition/Competition+StandingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import FriendlyCompetitions 4 | 5 | class CompetitionStandingTests: FCTestCase { 6 | func testThatIdIsCorrect() { 7 | let standing = Competition.Standing(rank: 1, userId: #function, points: 10) 8 | XCTAssertEqual(standing.id, standing.userId) 9 | XCTAssertEqual(standing.id, #function) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Models/Competition/CompetitionTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import FriendlyCompetitions 4 | 5 | final class CompetitionTests: FCTestCase { 6 | func testThatStartedIsCorrect() { 7 | XCTAssertTrue(Competition(start: .distantPast).started) 8 | XCTAssertFalse(Competition(start: .distantFuture).started) 9 | } 10 | 11 | func testThatEndedIsCorrect() { 12 | XCTAssertTrue(Competition(end: .distantPast).ended) 13 | XCTAssertFalse(Competition(end: .distantFuture).ended) 14 | } 15 | 16 | func testThatIsActiveIsCorrect() { 17 | XCTAssertTrue(Competition(start: .distantPast, end: .distantFuture).isActive) 18 | XCTAssertFalse(Competition(start: .distantFuture, end: .distantFuture).isActive) 19 | XCTAssertFalse(Competition(start: .distantPast, end: .distantPast).isActive) 20 | } 21 | } 22 | 23 | private extension Competition { 24 | init(id: String = UUID().uuidString, start: Date = .now, end: Date = .now) { 25 | self.init( 26 | id: id, 27 | name: #function, 28 | owner: #function, 29 | participants: [], 30 | pendingParticipants: [], 31 | scoringModel: .percentOfGoals, 32 | start: start, 33 | end: end, 34 | repeats: false, 35 | isPublic: false, 36 | banner: nil 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Models/Permissions/PermissionStatusTests.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import XCTest 3 | 4 | @testable import FriendlyCompetitions 5 | 6 | class PermissionStatusTests: FCTestCase { 7 | func testThatButtonTitleIsCorrect() { 8 | XCTAssertEqual(PermissionStatus.authorized.buttonTitle, "Allowed") 9 | XCTAssertEqual(PermissionStatus.denied.buttonTitle, "Denied") 10 | XCTAssertEqual(PermissionStatus.notDetermined.buttonTitle, "Allow") 11 | XCTAssertEqual(PermissionStatus.done.buttonTitle, "Done") 12 | } 13 | 14 | func testThatButtonColorIsCorrect() { 15 | XCTAssertEqual(PermissionStatus.authorized.buttonColor, .green) 16 | XCTAssertEqual(PermissionStatus.denied.buttonColor, .red) 17 | XCTAssertEqual(PermissionStatus.notDetermined.buttonColor, .blue) 18 | XCTAssertEqual(PermissionStatus.done.buttonColor, .gray) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Models/User/UserTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import FriendlyCompetitions 4 | 5 | final class UserTests: FCTestCase { 6 | func testThatHashIdIsCorrect() { 7 | let user = User( 8 | id: "testing", 9 | name: "Test User", email: "test@example.com" 10 | ) 11 | 12 | XCTAssertEqual(user.hashId, "#TEST") 13 | } 14 | 15 | func testThatVisiblityIsCorrect() { 16 | let user = User(id: "testUser", name: "Test User", email: "test@example.com", friends: ["personA"], showRealName: false) 17 | let personA = User(id: "personA", name: "Test User", email: "test@example.com") 18 | let personB = User(id: "personB", name: "Person A", email: "test@example.com") 19 | 20 | XCTAssertEqual(user.visibility(by: user), .visible) 21 | XCTAssertEqual(user.visibility(by: personA), .visible) 22 | XCTAssertEqual(user.visibility(by: personB), .hidden) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Models/Workouts/WorkoutTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import FriendlyCompetitions 4 | 5 | final class WorkoutTests: FCTestCase { 6 | func testThatIdIsCorrect() { 7 | let date = Date.now 8 | let type = WorkoutType.running 9 | let workout = Workout(type: type, date: date, points: [.steps: 1000]) 10 | let expected = "\(DateFormatter.dateDashed.string(from: date))_\(type)" 11 | XCTAssertEqual(expected, workout.id) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Models/Workouts/WorkoutTypeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import FriendlyCompetitions 4 | 5 | final class WorkoutTypeTests: FCTestCase { 6 | func testThatIdIsCorrect() { 7 | WorkoutType.allCases.forEach { workoutType in 8 | XCTAssertEqual(workoutType.id, workoutType.rawValue) 9 | } 10 | } 11 | 12 | func testThatInitIsCorrect() { 13 | XCTAssertEqual(WorkoutType(hkWorkoutActivityType: .cycling), .cycling) 14 | XCTAssertEqual(WorkoutType(hkWorkoutActivityType: .running), .running) 15 | XCTAssertEqual(WorkoutType(hkWorkoutActivityType: .swimming), .swimming) 16 | XCTAssertEqual(WorkoutType(hkWorkoutActivityType: .walking), .walking) 17 | } 18 | 19 | func testThatHKWorkoutActivityTypeIsCorrect() { 20 | XCTAssertEqual(WorkoutType.cycling.hkWorkoutActivityType, .cycling) 21 | XCTAssertEqual(WorkoutType.running.hkWorkoutActivityType, .running) 22 | XCTAssertEqual(WorkoutType.swimming.hkWorkoutActivityType, .swimming) 23 | XCTAssertEqual(WorkoutType.walking.hkWorkoutActivityType, .walking) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Util/Extensions/ActivitySummary+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @testable import FriendlyCompetitions 4 | 5 | extension ActivitySummary { 6 | func with(date: Date) -> ActivitySummary { 7 | .init( 8 | activeEnergyBurned: activeEnergyBurned, 9 | appleExerciseTime: appleExerciseTime, 10 | appleStandHours: appleStandHours, 11 | activeEnergyBurnedGoal: activeEnergyBurnedGoal, 12 | appleExerciseTimeGoal: appleExerciseTimeGoal, 13 | appleStandHoursGoal: appleStandHoursGoal, 14 | date: date, 15 | userID: userID 16 | ) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Util/Extensions/Foundation/TimeIntervalTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import FriendlyCompetitions 4 | 5 | final class TimeIntervalTests: FCTestCase { 6 | func testThatSecondsIsCorrect() { 7 | XCTAssertEqual(1.0.seconds, 1) 8 | XCTAssertEqual(10.0.seconds, 10) 9 | XCTAssertEqual(100.0.seconds, 100) 10 | } 11 | 12 | func testThatMinutesIsCorrect() { 13 | XCTAssertEqual(1.0.minutes, 60) 14 | XCTAssertEqual(10.0.minutes, 600) 15 | XCTAssertEqual(100.0.minutes, 6000) 16 | } 17 | 18 | func testThatHoursIsCorrect() { 19 | XCTAssertEqual(1.0.hours, 3600) 20 | XCTAssertEqual(10.0.hours, 36000) 21 | XCTAssertEqual(100.0.hours, 360000) 22 | } 23 | 24 | func testThatDaysIsCorrect() { 25 | XCTAssertEqual(1.0.days, 86400) 26 | XCTAssertEqual(10.0.days, 864000) 27 | XCTAssertEqual(100.0.days, 8640000) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Util/Extensions/Foundation/UserDefaultsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import FriendlyCompetitions 4 | 5 | final class UserDefaultsTests: FCTestCase { 6 | 7 | private struct Model: Codable, Equatable { 8 | let foo: Int 9 | let bar: String 10 | } 11 | 12 | private var userDefaults: UserDefaults! 13 | 14 | override func setUp() { 15 | userDefaults = UserDefaults() 16 | } 17 | 18 | func testThatEncodeWorks() throws { 19 | let expected = Model(foo: 1, bar: "a") 20 | userDefaults.encode(expected, forKey: #function) 21 | let data = userDefaults.data(forKey: #function)! 22 | let actual = try Model.decoded(from: data) 23 | XCTAssertEqual(expected, actual) 24 | } 25 | 26 | func testThatDecodeWorks() throws { 27 | let expected = Model(foo: 1, bar: "a") 28 | let data = try JSONEncoder.shared.encode(expected) 29 | userDefaults.set(data, forKey: #function) 30 | let actual = userDefaults.decode(Model.self, forKey: #function) 31 | XCTAssertEqual(expected, actual) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Util/Extensions/User+Extensions.swift: -------------------------------------------------------------------------------- 1 | @testable import FriendlyCompetitions 2 | 3 | extension User { 4 | func with(friends: [User.ID]) -> User { 5 | User( 6 | id: id, 7 | name: name, 8 | email: email, 9 | friends: friends, 10 | incomingFriendRequests: incomingFriendRequests, 11 | outgoingFriendRequests: outgoingFriendRequests, 12 | notificationTokens: notificationTokens, 13 | statistics: statistics, 14 | searchable: searchable, 15 | showRealName: showRealName, 16 | isAnonymous: isAnonymous, 17 | tags: tags 18 | ) 19 | } 20 | 21 | func with(friendRequests: [User.ID]) -> User { 22 | User( 23 | id: id, 24 | name: name, 25 | email: email, 26 | friends: friends, 27 | incomingFriendRequests: friendRequests, 28 | outgoingFriendRequests: outgoingFriendRequests, 29 | notificationTokens: notificationTokens, 30 | statistics: statistics, 31 | searchable: searchable, 32 | showRealName: showRealName, 33 | isAnonymous: isAnonymous, 34 | tags: tags 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Util/Mocks/MockError.swift: -------------------------------------------------------------------------------- 1 | enum MockError: Error, Equatable { 2 | case mock(id: String) 3 | } 4 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Util/Mocks/SearchIndex+Mock.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | @testable import FriendlyCompetitions 4 | 5 | final class SearchIndexMock: SearchIndex { 6 | var searchCallCount = 0 7 | var searchClosure: ((String) -> AnyPublisher<[Model], Error>)? 8 | func search(query: String) -> AnyPublisher<[ResultType], Error> { 9 | searchCallCount += 1 10 | return searchClosure!(query) 11 | .map { $0 as! [ResultType] } 12 | .eraseToAnyPublisher() 13 | } 14 | 15 | #if DEBUG 16 | var uploadReturnValue: AnyPublisher! 17 | func upload(_ models: [T]) -> AnyPublisher { 18 | uploadReturnValue! 19 | } 20 | #endif 21 | } 22 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Util/Publisher+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import XCTest 3 | 4 | extension Publisher where Output: Equatable { 5 | func expect(_ expectedValues: Output..., expectation: XCTestExpectation) -> AnyCancellable { 6 | collect(expectedValues.count) 7 | .sink(receiveCompletion: { completion in 8 | switch completion { 9 | case .failure(let error): 10 | XCTFail(error.localizedDescription) 11 | case .finished: 12 | break 13 | } 14 | }, receiveValue: { values in 15 | XCTAssertEqual(values, expectedValues) 16 | expectation.fulfill() 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Views/Components/CompetitionDetailsViewModelTests.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | @testable import FriendlyCompetitions 3 | import XCTest 4 | 5 | final class CompetitionDetailsViewModelTests: FCTestCase { 6 | func testThatCompetitionIsSet() { 7 | let competition = Competition.mock 8 | competitionsManager.competitionPublisherForReturnValue = .never() 9 | let viewModel = CompetitionDetailsViewModel(competition: competition) 10 | XCTAssertEqual(viewModel.competition, competition) 11 | } 12 | 13 | func testThatIsInvitationIsCorrect() { 14 | let competitionSubject = PassthroughSubject() 15 | competitionsManager.competitionPublisherForReturnValue = competitionSubject.eraseToAnyPublisher() 16 | userManager.user = .evan 17 | 18 | let competition = Competition.mock 19 | let viewModel = CompetitionDetailsViewModel(competition: competition) 20 | 21 | XCTAssertFalse(viewModel.isInvitation) 22 | competitionSubject.send(.mockInvited) 23 | XCTAssertTrue(viewModel.isInvitation) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Views/Components/FirebaseImageViewModelTests.swift: -------------------------------------------------------------------------------- 1 | @testable import FriendlyCompetitions 2 | import XCTest 3 | 4 | final class FirebaseImageViewModelTests: FCTestCase { 5 | func testDownloadSucceeded() { 6 | let expectedData = Data([1, 2, 3]) 7 | storageManager.getReturnValue = .just(expectedData) 8 | let viewModel = FirebaseImageViewModel(path: #function) 9 | scheduler.advance() 10 | XCTAssertEqual(viewModel.imageData, expectedData) 11 | XCTAssertEqual(storageManager.getReceivedPath, #function) 12 | } 13 | 14 | func testDownloadFailed() { 15 | storageManager.getReturnValue = .error(MockError.mock(id: #function)) 16 | let viewModel = FirebaseImageViewModel(path: #function) 17 | scheduler.advance() 18 | XCTAssertTrue(viewModel.failed) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Views/Home/Profile/ProfileViewModelTests.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import ECKit 3 | import Factory 4 | import XCTest 5 | 6 | @testable import FriendlyCompetitions 7 | 8 | final class SettingsViewModelTests: FCTestCase { 9 | 10 | override func setUp() { 11 | super.setUp() 12 | 13 | featureFlagManager.valueForBoolFeatureFlagFeatureFlagBoolBoolReturnValue = false 14 | userManager.updateWithReturnValue = .never() 15 | } 16 | 17 | func testThatIsAnonymouseAccountIsCorrect() { 18 | let expectation = self.expectation(description: #function) 19 | let expected = [false, false, true] 20 | 21 | let userSubject = PassthroughSubject() 22 | userManager.userPublisher = userSubject.eraseToAnyPublisher() 23 | 24 | let viewModel = SettingsViewModel() 25 | viewModel.$isAnonymousAccount 26 | .collect(expected.count) 27 | .expect(expected, expectation: expectation) 28 | .store(in: &cancellables) 29 | 30 | userSubject.send(.init(id: #function, name: "name", email: "evan@mail.com")) 31 | userSubject.send(.init(id: #function, name: "name", email: "evan@mail.com", isAnonymous: false)) 32 | userSubject.send(.init(id: #function, name: "name", email: "evan@mail.com", isAnonymous: true)) 33 | 34 | waitForExpectations(timeout: 1) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Views/NavigationDestinationTests.swift: -------------------------------------------------------------------------------- 1 | @testable import FriendlyCompetitions 2 | import XCTest 3 | 4 | final class NavigationDestinationTests: FCTestCase { 5 | func testThatIDIsCorrect() { 6 | let competition = Competition.mock 7 | XCTAssertEqual(NavigationDestination.competition(competition, nil).id, competition.id) 8 | 9 | let user = User.evan 10 | XCTAssertEqual(NavigationDestination.user(user).id, user.id) 11 | 12 | XCTAssertEqual(NavigationDestination.profile.id, "profile") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /FriendlyCompetitionsTests/Views/RootViewModelTests.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import ECKit 3 | import Factory 4 | import XCTest 5 | 6 | @testable import FriendlyCompetitions 7 | 8 | final class RootViewModelTests: FCTestCase { 9 | 10 | override func setUp() { 11 | super.setUp() 12 | appState.deepLink = .never() 13 | appState.rootTab = .never() 14 | } 15 | 16 | func testThatTabChangesToHomeOnDeepLink() { 17 | let expectation = self.expectation(description: #function) 18 | 19 | let deepLinkPublisher = PassthroughSubject() 20 | appState.deepLink = deepLinkPublisher.eraseToAnyPublisher() 21 | 22 | let viewModel = RootViewModel() 23 | viewModel.tab = .explore 24 | viewModel.$tab 25 | .expect(.explore, .home, expectation: expectation) 26 | .store(in: &cancellables) 27 | 28 | deepLinkPublisher.send(.user(id: User.evan.id)) 29 | 30 | waitForExpectations(timeout: 1) 31 | } 32 | 33 | func testThatRooTabChanges() { 34 | let expectation = self.expectation(description: #function) 35 | 36 | let rootTabSubject = PassthroughSubject() 37 | appState.rootTab = rootTabSubject.eraseToAnyPublisher() 38 | 39 | let viewModel = RootViewModel() 40 | viewModel.$tab 41 | .expect(.home, .explore, .home, expectation: expectation) 42 | .store(in: &cancellables) 43 | 44 | rootTabSubject.send(.explore) 45 | scheduler.advance() 46 | rootTabSubject.send(.home) 47 | scheduler.advance() 48 | 49 | waitForExpectations(timeout: 1) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /FriendlyCompetitionsWidgets/CompetitionStandings/Models/WidgetCompetition.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct WidgetCompetition: Codable { 4 | public init(id: String, name: String, start: Date, end: Date, standings: [WidgetStanding]) { 5 | self.id = id 6 | self.name = name 7 | self.start = start 8 | self.end = end 9 | self.standings = standings 10 | self.createdOn = .now 11 | } 12 | 13 | public let id: String 14 | public let name: String 15 | public let start: Date 16 | public let end: Date 17 | public let standings: [WidgetStanding] 18 | public let createdOn: Date 19 | } 20 | 21 | public extension WidgetCompetition { 22 | var dateString: String { 23 | if Date.now.distance(to: start) >= 0 { 24 | let startString = start.formatted(date: .numeric, time: .omitted) 25 | return "Starts \(startString)" 26 | } else { 27 | let endString = end.formatted(date: .numeric, time: .omitted) 28 | if Date.now.distance(to: end) >= 0 { 29 | return "Ends \(endString)" 30 | } 31 | return "Ended \(endString)" 32 | } 33 | } 34 | } 35 | 36 | public extension WidgetCompetition { 37 | static let placeholder: WidgetCompetition = { 38 | .init( 39 | id: UUID().uuidString, 40 | name: "Monthly", 41 | start: .now.addingTimeInterval(-1.days), 42 | end: .now.addingTimeInterval(1.days), 43 | standings: .mock 44 | ) 45 | }() 46 | } 47 | -------------------------------------------------------------------------------- /FriendlyCompetitionsWidgets/CompetitionStandings/Models/WidgetStanding.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension NumberFormatter { 4 | static var ordinal: NumberFormatter = { 5 | let formatter = NumberFormatter() 6 | formatter.numberStyle = .ordinal 7 | return formatter 8 | }() 9 | } 10 | 11 | public struct WidgetStanding: Codable { 12 | public init(rank: Int, points: Int, highlight: Bool) { 13 | self.id = UUID() 14 | self.rank = NumberFormatter.ordinal.string(from: NSNumber(value: rank)) ?? "\(rank)" 15 | self.points = points 16 | self.highlight = highlight 17 | } 18 | 19 | public let id: UUID 20 | public let rank: String 21 | public let points: Int 22 | public let highlight: Bool 23 | } 24 | 25 | public extension Array where Element == WidgetStanding { 26 | static var mock: [WidgetStanding] { 27 | [ 28 | .init(rank: 1, points: 150_000, highlight: true), 29 | .init(rank: 2, points: 100_000, highlight: false), 30 | .init(rank: 3, points: 50_000, highlight: false) 31 | ] 32 | } 33 | 34 | var highlighted: WidgetStanding? { 35 | first(where: { $0.highlight }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /FriendlyCompetitionsWidgets/FriendlyCompetitionsWidgetsBundle.swift: -------------------------------------------------------------------------------- 1 | import WidgetKit 2 | import SwiftUI 3 | 4 | @main 5 | struct FriendlyCompetitionsWidgetsBundle: WidgetBundle { 6 | var body: some Widget { 7 | CompetitionStandingsWidget() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /FriendlyCompetitionsWidgets/FriendlyCompetitionsWidgetsExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.com.evancooper.FriendlyCompetitions 8 | group.com.evancooper.FriendlyCompetitions.debug 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /FriendlyCompetitionsWidgets/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionPointIdentifier 8 | com.apple.widgetkit-extension 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /FriendlyCompetitionsWidgets/Network/AuthenticationAction.swift: -------------------------------------------------------------------------------- 1 | import ECNetworking 2 | import Factory 3 | import FCKit 4 | import FirebaseAuth 5 | 6 | struct AuthenticationAction: RequestWillBeginAction { 7 | 8 | @Injected(\.environmentManager) private var environmentManager: EnvironmentManaging 9 | 10 | func requestWillBegin(_ request: NetworkRequest, completion: @escaping RequestCompletion) { 11 | guard request.requiresAuthentication && !environmentManager.environment.isDebug else { 12 | completion(.success(request)) 13 | return 14 | } 15 | Auth.auth().currentUser?.getIDTokenForcingRefresh(true) { idToken, error in 16 | if let error { 17 | completion(.failure(error)) 18 | } else if let idToken { 19 | var request = request 20 | request.headers["Authorization"] = "Bearer \(idToken)" 21 | completion(.success(request)) 22 | } 23 | } 24 | } 25 | } 26 | 27 | protocol AuthenticatedRequest: Request { 28 | var requiresAuthentication: Bool { get } 29 | } 30 | 31 | extension AuthenticatedRequest { 32 | var requiresAuthentication: Bool { true } 33 | var customProperties: [AnyHashable : Any] { 34 | ["requiresAuthentication": requiresAuthentication] 35 | } 36 | } 37 | 38 | extension NetworkRequest { 39 | var requiresAuthentication: Bool { 40 | customProperties["requiresAuthentication"] as? Bool ?? false 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /FriendlyCompetitionsWidgets/Network/DocumentRequest.swift: -------------------------------------------------------------------------------- 1 | import ECNetworking 2 | import Foundation 3 | 4 | struct DocumentRequest: AuthenticatedRequest { 5 | typealias Response = R 6 | 7 | let path: String 8 | 9 | func response(from data: Data, with decoder: JSONDecoder) throws -> R { 10 | let document = try decoder.decode(FirestoreDocument.self, from: data) 11 | let pairs = document.fields.compactMap { key, value -> (String, Any)? in 12 | (key, value.convertValueForJson) 13 | } 14 | let dictionary = Dictionary(uniqueKeysWithValues: pairs) 15 | let jsonData = try JSONSerialization.data(withJSONObject: dictionary) 16 | return try R.decoded(from: jsonData, using: decoder) 17 | } 18 | 19 | func buildRequest(with baseURL: URL) -> NetworkRequest { 20 | .init(method: .get, url: baseURL.appendingPathComponent(path)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /FriendlyCompetitionsWidgets/Network/Network.swift: -------------------------------------------------------------------------------- 1 | import ECNetworking 2 | import Factory 3 | import FCKit 4 | import FirebaseAuth 5 | 6 | extension Container { 7 | var network: Factory { 8 | self { 9 | let environment = self.environmentManager.resolve().environment 10 | let host: String = { 11 | switch environment { 12 | case .prod: return "firestore.googleapis.com" 13 | case .debugLocal: return "localhost:8080" 14 | case .debugRemote(let destination): return "\(destination):8080" 15 | } 16 | }() 17 | 18 | let ssl: Bool = { 19 | switch environment { 20 | case .prod: return true 21 | case .debugLocal, .debugRemote: return false 22 | } 23 | }() 24 | 25 | let configuration = NetworkConfiguration( 26 | baseURL: URL(string: "\(ssl ? "https" : "http")://\(host)/v1/projects/compyboi-79ae0/databases/(default)/documents")!, 27 | logging: true 28 | ) 29 | 30 | let customDecoder = JSONDecoder() 31 | customDecoder.dateDecodingStrategy = .formatted(DateFormatter.dateDashed) 32 | let customEncoder = JSONEncoder() 33 | customEncoder.dateEncodingStrategy = .formatted(DateFormatter.dateDashed) 34 | 35 | let authenticationAction = AuthenticationAction() 36 | 37 | return URLSessionNetwork( 38 | actions: [authenticationAction], 39 | configuration: configuration, 40 | decoder: customDecoder, 41 | encoder: customEncoder 42 | ) 43 | }.scope(.shared) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /FriendlyCompetitionsWidgets/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /FriendlyCompetitionsWidgets/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /FriendlyCompetitionsWidgets/Resources/Assets.xcassets/WidgetBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /FriendlyCompetitionsWidgets/Resources/Assets.xcassets/icon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "ItunesArtwork@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /FriendlyCompetitionsWidgets/Resources/Assets.xcassets/icon.imageset/ItunesArtwork@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/FriendlyCompetitionsWidgets/Resources/Assets.xcassets/icon.imageset/ItunesArtwork@2x.png -------------------------------------------------------------------------------- /FriendlyCompetitionsWidgets/Resources/Localizable.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "Competition" : { 5 | 6 | }, 7 | "Competition Standings" : { 8 | 9 | }, 10 | "Select Competition" : { 11 | 12 | }, 13 | "Selects the competition do display information for." : { 14 | 15 | }, 16 | "View your standings in a competition at a glace" : { 17 | 18 | } 19 | }, 20 | "version" : "1.0" 21 | } -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'fastlane' 4 | plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') 5 | eval_gemfile(plugins_path) if File.exist?(plugins_path) 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Friendly Competitions 2 | 3 | Compete against groups of friends in fitness. Just like Apple's fitness cometitions but support for multiple participants in a single competition. 4 | 5 |

6 | sc1 7 | sc2 8 | sc3 9 | sc3 10 |

11 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | app_identifier("com.evancooper.FriendlyCompetitions") # The bundle identifier of your app 2 | apple_id("evan.cooper@rogers.com") # Your Apple email address 3 | 4 | itc_team_id("121534644") # App Store Connect Team ID 5 | team_id("HP22MNWU5C") # Developer Portal Team ID 6 | 7 | # For more information about the Appfile, see: 8 | # https://docs.fastlane.tools/advanced/#appfile 9 | -------------------------------------------------------------------------------- /fastlane/Deliverfile: -------------------------------------------------------------------------------- 1 | # The Deliverfile allows you to store various App Store Connect metadata 2 | # For more information, check out the docs 3 | # https://docs.fastlane.tools/actions/deliver/ 4 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | default_platform :ios 2 | 3 | platform :ios do 4 | import "lanes/certificates.rb" 5 | import "lanes/deploy.rb" 6 | import "lanes/test.rb" 7 | end -------------------------------------------------------------------------------- /fastlane/Matchfile: -------------------------------------------------------------------------------- 1 | git_url("git@github.com:EvanCooper9/fastlane-match") 2 | storage_mode("git") 3 | username("evan.cooper@rogers.com") -------------------------------------------------------------------------------- /fastlane/Pluginfile: -------------------------------------------------------------------------------- 1 | # Autogenerated by fastlane 2 | # 3 | # Ensure this file is checked in to source control! 4 | 5 | gem 'fastlane-plugin-versioning' 6 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## iOS 17 | 18 | ### ios certificates 19 | 20 | ```sh 21 | [bundle exec] fastlane ios certificates 22 | ``` 23 | 24 | 25 | 26 | ### ios deploy 27 | 28 | ```sh 29 | [bundle exec] fastlane ios deploy 30 | ``` 31 | 32 | 33 | 34 | ### ios test 35 | 36 | ```sh 37 | [bundle exec] fastlane ios test 38 | ``` 39 | 40 | 41 | 42 | ---- 43 | 44 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 45 | 46 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 47 | 48 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 49 | -------------------------------------------------------------------------------- /fastlane/lanes/certificates.rb: -------------------------------------------------------------------------------- 1 | lane :certificates do 2 | # development release identifier 3 | match(type: "development", app_identifier: "com.evancooper.FriendlyCompetitions") 4 | match(type: "development", app_identifier: "com.evancooper.FriendlyCompetitions.FriendlyCompetitionsWidgets") 5 | 6 | # development debug identifier 7 | match(type: "development", app_identifier: "com.evancooper.FriendlyCompetitions.debug") 8 | match(type: "development", app_identifier: "com.evancooper.FriendlyCompetitions.debug.FriendlyCompetitionsWidgets") 9 | 10 | # app store release identifier 11 | match(type: "appstore", app_identifier: "com.evancooper.FriendlyCompetitions") 12 | match(type: "appstore", app_identifier: "com.evancooper.FriendlyCompetitions.FriendlyCompetitionsWidgets") 13 | end -------------------------------------------------------------------------------- /fastlane/lanes/test.rb: -------------------------------------------------------------------------------- 1 | lane :test do 2 | run_tests( 3 | clean: false, 4 | scheme: "FriendlyCompetitions", 5 | device: "iPhone 14 Pro Max", 6 | ) 7 | end -------------------------------------------------------------------------------- /fastlane/metadata/copyright.txt: -------------------------------------------------------------------------------- 1 | 2023 Evan Cooper -------------------------------------------------------------------------------- /fastlane/metadata/en-CA/description.txt: -------------------------------------------------------------------------------- 1 | Friendly Competitions allows you to compete against groups of friends in fitness. It automatically uses your fitness goals and results from the Health app, without the need for pesky configuration. Create competitions like never before, like start & end date, scoring models and more. 2 | 3 | Terms of use: https://www.apple.com/legal/internet-services/itunes/dev/stdeula/ -------------------------------------------------------------------------------- /fastlane/metadata/en-CA/keywords.txt: -------------------------------------------------------------------------------- 1 | fitness, competition, competitions, friends, group fitness, group competition, comp -------------------------------------------------------------------------------- /fastlane/metadata/en-CA/marketing_url.txt: -------------------------------------------------------------------------------- 1 | https://friendly-competitions.app -------------------------------------------------------------------------------- /fastlane/metadata/en-CA/name.txt: -------------------------------------------------------------------------------- 1 | Friendly Competitions -------------------------------------------------------------------------------- /fastlane/metadata/en-CA/privacy_url.txt: -------------------------------------------------------------------------------- 1 | https://www.termsfeed.com/live/83fffe02-9426-43f1-94ca-aedea5df3d24 -------------------------------------------------------------------------------- /fastlane/metadata/en-CA/release_notes.txt: -------------------------------------------------------------------------------- 1 | Improvements: 2 | • Performance 3 | • Bug fixes 4 | -------------------------------------------------------------------------------- /fastlane/metadata/en-CA/subtitle.txt: -------------------------------------------------------------------------------- 1 | Compete in fitness vs friends! -------------------------------------------------------------------------------- /fastlane/metadata/en-CA/support_url.txt: -------------------------------------------------------------------------------- 1 | https://evancooper.tech -------------------------------------------------------------------------------- /fastlane/metadata/primary_category.txt: -------------------------------------------------------------------------------- 1 | HEALTH_AND_FITNESS -------------------------------------------------------------------------------- /fastlane/metadata/review_information/demo_password.txt: -------------------------------------------------------------------------------- 1 | Password123! -------------------------------------------------------------------------------- /fastlane/metadata/review_information/demo_user.txt: -------------------------------------------------------------------------------- 1 | review@apple.com -------------------------------------------------------------------------------- /fastlane/metadata/review_information/email_address.txt: -------------------------------------------------------------------------------- 1 | evan.cooper@rogers.com -------------------------------------------------------------------------------- /fastlane/metadata/review_information/first_name.txt: -------------------------------------------------------------------------------- 1 | Evan -------------------------------------------------------------------------------- /fastlane/metadata/review_information/last_name.txt: -------------------------------------------------------------------------------- 1 | Cooper -------------------------------------------------------------------------------- /fastlane/metadata/review_information/notes.txt: -------------------------------------------------------------------------------- 1 | You can also use sign in with Apple. -------------------------------------------------------------------------------- /fastlane/metadata/review_information/phone_number.txt: -------------------------------------------------------------------------------- 1 | (416) 556-6552 -------------------------------------------------------------------------------- /fastlane/metadata/secondary_category.txt: -------------------------------------------------------------------------------- 1 | LIFESTYLE -------------------------------------------------------------------------------- /fastlane/screenshots/README.txt: -------------------------------------------------------------------------------- 1 | ## Screenshots Naming Rules 2 | 3 | Put all screenshots you want to use inside the folder of its language (e.g. `en-US`). 4 | The device type will automatically be recognized using the image resolution. 5 | 6 | The screenshots can be named whatever you want, but keep in mind they are sorted 7 | alphabetically, in a human-friendly way. See https://github.com/fastlane/fastlane/pull/18200 for more details. 8 | 9 | ### Exceptions 10 | 11 | #### iPad Pro (3rd Gen) 12.9" 12 | 13 | Since iPad Pro (3rd Gen) 12.9" and iPad Pro (2nd Gen) 12.9" have the same image 14 | resolution, screenshots of the iPad Pro (3rd gen) 12.9" must contain either the 15 | string `iPad Pro (12.9-inch) (3rd generation)`, `IPAD_PRO_3GEN_129`, or `ipadPro129` 16 | (App Store Connect's internal naming of the display family for the 3rd generation iPad Pro) 17 | in its filename to be assigned the correct display family and to be uploaded to 18 | the correct screenshot slot in your app's metadata. 19 | 20 | ### Other Platforms 21 | 22 | #### Apple TV 23 | 24 | Apple TV screenshots should be stored in a subdirectory named `appleTV` with language 25 | folders inside of it. 26 | 27 | #### iMessage 28 | 29 | iMessage screenshots, like the Apple TV ones, should also be stored in a subdirectory 30 | named `iMessage`, with language folders inside of it. 31 | -------------------------------------------------------------------------------- /fastlane/screenshots/en-CA/iPhone 11 Pro Max 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/fastlane/screenshots/en-CA/iPhone 11 Pro Max 1.png -------------------------------------------------------------------------------- /fastlane/screenshots/en-CA/iPhone 11 Pro Max 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/fastlane/screenshots/en-CA/iPhone 11 Pro Max 2.png -------------------------------------------------------------------------------- /fastlane/screenshots/en-CA/iPhone 11 Pro Max 3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/fastlane/screenshots/en-CA/iPhone 11 Pro Max 3.png -------------------------------------------------------------------------------- /fastlane/screenshots/en-CA/iPhone 11 Pro Max 4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/fastlane/screenshots/en-CA/iPhone 11 Pro Max 4.png -------------------------------------------------------------------------------- /fastlane/screenshots/en-CA/iPhone 8 Plus 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/fastlane/screenshots/en-CA/iPhone 8 Plus 1.png -------------------------------------------------------------------------------- /fastlane/screenshots/en-CA/iPhone 8 Plus 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/fastlane/screenshots/en-CA/iPhone 8 Plus 2.png -------------------------------------------------------------------------------- /fastlane/screenshots/en-CA/iPhone 8 Plus 3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/fastlane/screenshots/en-CA/iPhone 8 Plus 3.png -------------------------------------------------------------------------------- /fastlane/screenshots/en-CA/iPhone 8 Plus 4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/fastlane/screenshots/en-CA/iPhone 8 Plus 4.png -------------------------------------------------------------------------------- /fastlane/scripts/upload-symbols: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/fastlane/scripts/upload-symbols -------------------------------------------------------------------------------- /firebase/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "compyboi-79ae0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /firebase/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | firebase-debug.log* 8 | firebase-debug.*.log* 9 | 10 | # Firebase cache 11 | .firebase/ 12 | 13 | # Firebase config 14 | 15 | # Uncomment this if you'd like others to create their own Firebase project. 16 | # For a team working on the same Firebase project(s), it is recommended to leave 17 | # it commented so all members can deploy to the same project(s) in .firebaserc. 18 | # .firebaserc 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (http://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | 68 | # dev data 69 | exported-dev-data 70 | firebase-export* -------------------------------------------------------------------------------- /firebase/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "emulators": { 3 | "auth": { 4 | "port": 9099, 5 | "host":"0.0.0.0" 6 | }, 7 | "firestore": { 8 | "port": 8080, 9 | "host": "0.0.0.0" 10 | }, 11 | "functions": { 12 | "port": 5001, 13 | "host": "0.0.0.0" 14 | }, 15 | "pubsub": { 16 | "port": 8085, 17 | "host": "0.0.0.0" 18 | }, 19 | "ui": { 20 | "enabled": true 21 | }, 22 | "singleProjectMode": true 23 | }, 24 | "firestore": { 25 | "rules": "firestore.rules", 26 | "indexes": "firestore.indexes.json" 27 | }, 28 | "functions": { 29 | "predeploy": [ 30 | "npm --prefix \"$RESOURCE_DIR\" run lint", 31 | "npm --prefix \"$RESOURCE_DIR\" run build" 32 | ], 33 | "source": "functions", 34 | "runtime": "nodejs18" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /firebase/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [ 3 | { 4 | "collectionGroup": "competitions", 5 | "queryScope": "COLLECTION", 6 | "fields": [ 7 | { "fieldPath": "isPublic", "order": "ASCENDING" }, 8 | { "fieldPath": "owner", "order": "ASCENDING" } 9 | ] 10 | }, 11 | { 12 | "collectionGroup": "activitySummaries", 13 | "queryScope": "COLLECTION_GROUP", 14 | "fields": [ 15 | { "fieldPath": "date", "order": "ASCENDING" }, 16 | { "fieldPath": "userID", "order": "ASCENDING" } 17 | ] 18 | }, 19 | { 20 | "collectionGroup": "users", 21 | "queryScope": "COLLECTION", 22 | "fields": [ 23 | { "fieldPath": "searchable", "order": "ASCENDING" }, 24 | { "fieldPath": "id", "order": "ASCENDING" } 25 | ] 26 | }, 27 | { 28 | "collectionGroup": "workouts", 29 | "queryScope": "COLLECTION", 30 | "fields": [ 31 | { "fieldPath": "date", "order": "ASCENDING" }, 32 | { "fieldPath": "type", "order": "ASCENDING" } 33 | ] 34 | }, 35 | { 36 | "collectionGroup": "workouts", 37 | "queryScope": "COLLECTION", 38 | "fields": [ 39 | { "fieldPath": "type", "order": "ASCENDING" }, 40 | { "fieldPath": "date", "order": "ASCENDING" } 41 | ] 42 | }, 43 | { 44 | "collectionGroup": "results", 45 | "queryScope": "COLLECTION", 46 | "fields": [ 47 | { "fieldPath": "participants", "arrayConfig": "CONTAINS" }, 48 | { "fieldPath": "end", "order": "DESCENDING" } 49 | ] 50 | } 51 | ], 52 | "fieldOverrides": [] 53 | } 54 | -------------------------------------------------------------------------------- /firebase/firestore.rules: -------------------------------------------------------------------------------- 1 | // Allow read/write access on all documents to any user signed in to the application 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | match /{document=**} { 5 | allow read, write: if request.auth != null 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /firebase/functions/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es6: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | "eslint:recommended", 9 | "plugin:import/errors", 10 | "plugin:import/warnings", 11 | "plugin:import/typescript", 12 | "google", 13 | "plugin:@typescript-eslint/recommended", 14 | ], 15 | parser: "@typescript-eslint/parser", 16 | parserOptions: { 17 | project: ["tsconfig.json", "tsconfig.dev.json"], 18 | sourceType: "module", 19 | }, 20 | ignorePatterns: [ 21 | "/lib/**/*", // Ignore built files. 22 | ], 23 | plugins: [ 24 | "@typescript-eslint", 25 | "import", 26 | ], 27 | rules: { 28 | "quotes": ["error", "double"], 29 | "import/no-unresolved": 0, 30 | "indent": ["error", 4], 31 | "max-len": "off", 32 | "padded-blocks": "off", 33 | "no-trailing-spaces": "off", 34 | "comma-dangle": "off", 35 | "guard-for-in": "off", 36 | "arrow-parens": "off", 37 | "object-curly-spacing": "off", 38 | "@typescript-eslint/explicit-module-boundary-types": "off", 39 | "@typescript-eslint/no-explicit-any": "off" 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /firebase/functions/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled JavaScript files 2 | lib/**/*.js 3 | lib/**/*.js.map 4 | 5 | # TypeScript v1 declaration files 6 | typings/ 7 | 8 | # Node.js dependency directory 9 | node_modules/ 10 | -------------------------------------------------------------------------------- /firebase/functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "scripts": { 4 | "lint": "eslint --ext .js,.ts .", 5 | "build": "tsc", 6 | "serve": "npm run build && firebase emulators:start --only functions", 7 | "shell": "npm run build && firebase functions:shell", 8 | "start": "npm run shell", 9 | "deploy": "firebase deploy --only functions", 10 | "logs": "firebase functions:log" 11 | }, 12 | "main": "lib/index.js", 13 | "dependencies": { 14 | "firebase-admin": "^12.0.0", 15 | "firebase-functions": "^4.2.0", 16 | "moment": "^2.29.1" 17 | }, 18 | "devDependencies": { 19 | "@typescript-eslint/eslint-plugin": "^7.0.0", 20 | "@typescript-eslint/parser": "^7.0.0", 21 | "eslint": "^8.0.0", 22 | "eslint-config-google": "^0.14.0", 23 | "eslint-plugin-import": "^2.27.5", 24 | "firebase-tools": "^13.0.0", 25 | "typescript": "^5.0.0" 26 | }, 27 | "private": true 28 | } 29 | -------------------------------------------------------------------------------- /firebase/functions/src/Handlers/account/accountSetup.ts: -------------------------------------------------------------------------------- 1 | import { joinCompetition } from "../competitions/joinCompetition"; 2 | 3 | /** 4 | * Performs setup actions for a new account 5 | * @param {string} userID the ID of the user who's account to setup 6 | */ 7 | async function accountSetup(userID: string): Promise { 8 | const competitionIDs = [ 9 | "V7AJuKqhek6kVcrSWwRa", // weekly 10 | "xdsAs5bEIiOKh12nqxdy", // monthly 11 | "ZkFLFkAdMWWhgF2FIUqu" // steps 12 | ]; 13 | 14 | const joinCompetitions = competitionIDs.map(competitionID => { 15 | return joinCompetition(competitionID, userID); 16 | }); 17 | 18 | await Promise.all(joinCompetitions); 19 | } 20 | 21 | export { 22 | accountSetup 23 | }; 24 | -------------------------------------------------------------------------------- /firebase/functions/src/Handlers/competitions/deleteCompetition.ts: -------------------------------------------------------------------------------- 1 | import { getFirestore } from "../../Utilities/firestore"; 2 | 3 | /** 4 | * Deletes a competition and it's standings 5 | * @param {string} competitionID the ID of the competition to delete 6 | */ 7 | async function deleteCompetition(competitionID: string): Promise { 8 | const firestore = getFirestore(); 9 | const competitionDoc = firestore.doc(`competitions/${competitionID}`); 10 | await firestore.recursiveDelete(competitionDoc); 11 | } 12 | 13 | export { 14 | deleteCompetition 15 | }; 16 | -------------------------------------------------------------------------------- /firebase/functions/src/Handlers/competitions/inviteUserToCompetition.ts: -------------------------------------------------------------------------------- 1 | import { Competition } from "../../Models/Competition"; 2 | import { User } from "../../Models/User"; 3 | import { sendNotificationsToUser } from "../notifications/notifications"; 4 | import { getFirestore } from "../../Utilities/firestore"; 5 | import { Constants } from "../../Utilities/Constants"; 6 | 7 | /** 8 | * Invites a user to a competition and sends a notification 9 | * @param {string} competitionID The competition to invite the user to 10 | * @param {string} callerID The user who is creating the invite 11 | * @param {string} requesteeID The user who is receiving the invite 12 | * @return {Promise} A promise that resolves when the user has been invited 13 | */ 14 | async function inviteUserToCompetition(competitionID: string, callerID: string, requesteeID: string): Promise { 15 | const firestore = getFirestore(); 16 | 17 | const caller = await firestore.doc(`users/${callerID}`).get().then(doc => new User(doc)); 18 | const requestee = await firestore.doc(`users/${requesteeID}`).get().then(doc => new User(doc)); 19 | const competition = await firestore.doc(`competitions/${competitionID}`).get().then(doc => new Competition(doc)); 20 | 21 | competition.pendingParticipants.push(requesteeID); 22 | 23 | await firestore.doc(`competitions/${competitionID}`) 24 | .update({ pendingParticipants: competition.pendingParticipants }); 25 | await sendNotificationsToUser( 26 | requestee, 27 | "Friendly Competitions", 28 | `${caller.name} invited you to a competition`, 29 | `${Constants.NOTIFICATION_URL}/competition/${competition.id}` 30 | ); 31 | } 32 | 33 | export { 34 | inviteUserToCompetition 35 | }; 36 | -------------------------------------------------------------------------------- /firebase/functions/src/Handlers/competitions/joinCompetition.ts: -------------------------------------------------------------------------------- 1 | import { Competition } from "../../Models/Competition"; 2 | import { getFirestore } from "../../Utilities/firestore"; 3 | 4 | /** 5 | * Respond to a competition invite 6 | * @param {string} competitionID The ID of the competition 7 | * @param {string} userID The ID of the user joining the competition 8 | * @return {Promise} A promise that resolve when completed 9 | */ 10 | async function joinCompetition(competitionID: string, userID: string): Promise { 11 | const firestore = getFirestore(); 12 | const competition = await firestore.doc(`competitions/${competitionID}`).get().then(doc => new Competition(doc)); 13 | const index = competition.pendingParticipants.indexOf(userID, 0); 14 | if (index > -1) competition.pendingParticipants.splice(index, 1); 15 | competition.participants.push(userID); 16 | await firestore.doc(`competitions/${competitionID}`) 17 | .update({ 18 | participants: competition.participants, 19 | pendingParticipants: competition.pendingParticipants 20 | }); 21 | } 22 | 23 | export { 24 | joinCompetition 25 | }; 26 | -------------------------------------------------------------------------------- /firebase/functions/src/Handlers/competitions/leaveCompetition.ts: -------------------------------------------------------------------------------- 1 | import { Competition } from "../../Models/Competition"; 2 | import { getFirestore } from "../../Utilities/firestore"; 3 | 4 | /** 5 | * Respond to a competition invite 6 | * @param {string} competitionID The ID of the competition 7 | * @param {string} userID The ID of the user leaving the competition 8 | * @return {Promise} A promise that resolve when completed 9 | */ 10 | async function leaveCompetition(competitionID: string, userID: string): Promise { 11 | const firestore = getFirestore(); 12 | const competition = await firestore.doc(`competitions/${competitionID}`).get().then(doc => new Competition(doc)); 13 | const index = competition.participants.indexOf(userID, 0); 14 | if (index > -1) competition.participants.splice(index, 1); 15 | await firestore.doc(`competitions/${competitionID}`).update({ participants: competition.participants }); 16 | await firestore.doc(`competitions/${competitionID}/standings/${userID}`).delete(); 17 | await competition.updateStandingRanks(); 18 | } 19 | 20 | export { 21 | leaveCompetition 22 | }; 23 | -------------------------------------------------------------------------------- /firebase/functions/src/Handlers/competitions/respondToCompetitionInvite.ts: -------------------------------------------------------------------------------- 1 | import { Competition } from "../../Models/Competition"; 2 | import { getFirestore } from "../../Utilities/firestore"; 3 | 4 | /** 5 | * Respond to a competition invite 6 | * @param {string} competitionID The ID of the competition 7 | * @param {string} callerID The ID of the user responding to the competition 8 | * @param {boolean} accept Accept or decline the competition invite 9 | * @return {Promise} A promise that resolve when completed 10 | */ 11 | async function respondToCompetitionInvite(competitionID: string, callerID: string, accept: boolean): Promise { 12 | const firestore = getFirestore(); 13 | const competition = await firestore.doc(`competitions/${competitionID}`).get().then(doc => new Competition(doc)); 14 | const index = competition.pendingParticipants.indexOf(callerID, 0); 15 | if (index > -1) competition.pendingParticipants.splice(index, 1); 16 | if (accept) competition.participants.push(callerID); 17 | await firestore.doc(`competitions/${competitionID}`) 18 | .update({ 19 | pendingParticipants: competition.pendingParticipants, 20 | participants: competition.participants 21 | }); 22 | } 23 | 24 | export { 25 | respondToCompetitionInvite 26 | }; 27 | -------------------------------------------------------------------------------- /firebase/functions/src/Handlers/competitions/sendNewCompetitionInvites.ts: -------------------------------------------------------------------------------- 1 | import { Competition } from "../../Models/Competition"; 2 | import { User } from "../../Models/User"; 3 | import { Constants } from "../../Utilities/Constants"; 4 | import { getFirestore } from "../../Utilities/firestore"; 5 | import { sendNotificationsToUser } from "../notifications/notifications"; 6 | 7 | /** 8 | * Send invite notifications to all pending participants for a competition 9 | * @param {string} competitionID The ID of the competition to send notifications for 10 | * @return {Promise} A promise that resolves when complete 11 | */ 12 | async function sendNewCompetitionInvites(competitionID: string): Promise { 13 | const firestore = getFirestore(); 14 | const competition = await firestore.doc(`competitions/${competitionID}`).get().then(doc => new Competition(doc)); 15 | const owner = await firestore.doc(`users/${competition.owner}`).get().then(doc => new User(doc)); 16 | await Promise.allSettled(competition.pendingParticipants.map(async userID => { 17 | const user = await firestore.doc(`users/${userID}`).get().then(doc => new User(doc)); 18 | await sendNotificationsToUser( 19 | user, 20 | "Friendly Competitions", 21 | `${owner.name} invited you to a competition`, 22 | `${Constants.NOTIFICATION_URL}/competition/${competition.id}` 23 | ); 24 | })); 25 | } 26 | 27 | export { 28 | sendNewCompetitionInvites 29 | }; 30 | -------------------------------------------------------------------------------- /firebase/functions/src/Handlers/competitions/updateCompetitionRanks.ts: -------------------------------------------------------------------------------- 1 | import { Competition } from "../../Models/Competition"; 2 | import { getFirestore } from "../../Utilities/firestore"; 3 | 4 | /** 5 | * Updates the ranks of a competition's standings. Assumes points are up to date. 6 | * @param {string} competitionID 7 | * @return {Promise} A promise that resolves when completed 8 | */ 9 | async function updateCompetitionRanks(competitionID: string): Promise { 10 | const firestore = getFirestore(); 11 | const competitionRef = await firestore.doc(`competitions/${competitionID}`).get(); 12 | const competition = new Competition(competitionRef); 13 | await competition.updateStandingRanks(); 14 | } 15 | 16 | export { 17 | updateCompetitionRanks 18 | }; 19 | -------------------------------------------------------------------------------- /firebase/functions/src/Handlers/friends/deleteFriend.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../../Models/User"; 2 | import { getFirestore } from "../../Utilities/firestore"; 3 | 4 | /** 5 | * Deletes a friend 6 | * @param {string} userID the user deleting the friend 7 | * @param {string} friendID the friend to delete 8 | * @return {Promise} A promise that resolves when complete 9 | */ 10 | async function deleteFriend(userID: string, friendID: string): Promise { 11 | const firestore = getFirestore(); 12 | 13 | const user = await firestore.doc(`users/${userID}`).get().then(doc => new User(doc)); 14 | const friend = await firestore.doc(`users/${friendID}`).get().then(doc => new User(doc)); 15 | 16 | const userFriends = user.friends; 17 | const userIndex = userFriends.indexOf(friendID); 18 | if (userIndex > -1) userFriends.splice(userIndex, 1); 19 | const friendFriends = friend.friends; 20 | const friendIndex = friendFriends.indexOf(userID); 21 | if (friendIndex > -1) friendFriends.splice(friendIndex, 1); 22 | 23 | const batch = firestore.batch(); 24 | batch.update(firestore.doc(`users/${userID}`), {friends: userFriends}); 25 | batch.update(firestore.doc(`users/${friendID}`), {friends: friendFriends}); 26 | await batch.commit(); 27 | } 28 | 29 | export { 30 | deleteFriend 31 | }; 32 | -------------------------------------------------------------------------------- /firebase/functions/src/Handlers/jobs/cleanupActivitySummaries.ts: -------------------------------------------------------------------------------- 1 | import { ActivitySummary } from "../../Models/ActivitySummary"; 2 | import { Competition } from "../../Models/Competition"; 3 | import { getFirestore } from "../../Utilities/firestore"; 4 | 5 | /** 6 | * Delete all activity summaries that are not in use by active competitions 7 | * @return {Promise} A promise that resolves when complete 8 | */ 9 | async function cleanupActivitySummaries(): Promise { 10 | const firestore = getFirestore(); 11 | const competitions = await firestore.collection("competitions").get().then(query => query.docs.map(doc => new Competition(doc))); 12 | const activeCompetitions = competitions.filter(competition => competition.isActive()); 13 | const activitySummaries = await firestore.collectionGroup("activitySummaries").get().then(query => query.docs.map(doc => new ActivitySummary(doc))); 14 | 15 | const batch = firestore.batch(); 16 | 17 | activitySummaries 18 | .filter(activitySummary => { 19 | const matchingCompetition = activeCompetitions.find(competition => activitySummary.isIncludedInCompetition(competition)); 20 | return matchingCompetition == null || matchingCompetition == undefined; 21 | }) 22 | .forEach(activitySummary => { 23 | const ref = firestore.doc(`users/${activitySummary.userID}/activitySummaries/${activitySummary.id}`); 24 | batch.delete(ref); 25 | }); 26 | 27 | await batch.commit(); 28 | } 29 | 30 | export { 31 | cleanupActivitySummaries 32 | }; 33 | -------------------------------------------------------------------------------- /firebase/functions/src/Handlers/jobs/cleanupWorkouts.ts: -------------------------------------------------------------------------------- 1 | import { Competition } from "../../Models/Competition"; 2 | import { Workout } from "../../Models/Workout"; 3 | import { getFirestore } from "../../Utilities/firestore"; 4 | 5 | /** 6 | * Delete all activity summaries that are not in use by active competitions 7 | * @return {Promise} A promise that resolves when complete 8 | */ 9 | async function cleanupWorkouts(): Promise { 10 | const firestore = getFirestore(); 11 | const competitions = await firestore.collection("competitions").get().then(query => query.docs.map(doc => new Competition(doc))); 12 | const activeCompetitions = competitions.filter(competition => competition.isActive()); 13 | const workouts = await firestore.collectionGroup("workouts").get().then(query => query.docs.map(doc => new Workout(doc))); 14 | 15 | const batch = firestore.batch(); 16 | 17 | workouts 18 | .filter(workout => { 19 | const matchingCompetition = activeCompetitions.find(competition => workout.isIncludedInCompetition(competition)); 20 | return matchingCompetition == null || matchingCompetition == undefined; 21 | }) 22 | .forEach(workout => { 23 | const ref = firestore.doc(`users/${workout.userID}/workouts/${workout.id}`); 24 | batch.delete(ref); 25 | }); 26 | 27 | await batch.commit(); 28 | } 29 | 30 | export { 31 | cleanupWorkouts 32 | }; 33 | -------------------------------------------------------------------------------- /firebase/functions/src/Handlers/jobs/handleCompetitionCreate.ts: -------------------------------------------------------------------------------- 1 | import { DocumentSnapshot } from "firebase-admin/firestore"; 2 | import { sendNewCompetitionInvites } from "../competitions/sendNewCompetitionInvites"; 3 | import { Competition } from "../../Models/Competition"; 4 | import { recalculateStandings } from "./handleCompetitionUpdate"; 5 | 6 | /** 7 | * Performs actions necessary when creating a competition 8 | * @param {DocumentSnapshot} snapshot the snapshot of the created competition 9 | */ 10 | async function handleCompetitionCreate(snapshot: DocumentSnapshot) { 11 | const competitionID: string = snapshot.id; 12 | await sendNewCompetitionInvites(competitionID); 13 | 14 | const competition = new Competition(snapshot); 15 | await recalculateStandings(competition); 16 | } 17 | 18 | export { 19 | handleCompetitionCreate 20 | }; 21 | -------------------------------------------------------------------------------- /firebase/functions/src/Handlers/standings/setStandingRanks.ts: -------------------------------------------------------------------------------- 1 | import { Standing } from "../../Models/Standing"; 2 | import { prepareForFirestore } from "../../Utilities/prepareForFirestore"; 3 | import { getFirestore } from "../../Utilities/firestore"; 4 | import { Competition } from "../../Models/Competition"; 5 | 6 | /** 7 | * Update the ranks of standings for a given competition. Assumes points have already been set. 8 | * @param {Competition} competition The competition for the standings 9 | * @param {Standing[]} standings The standings to update 10 | */ 11 | export async function setStandingRanks(competition: Competition, standings: Standing[]): Promise { 12 | if (standings.length == 0) return; 13 | 14 | const firestore = getFirestore(); 15 | const batch = firestore.batch(); 16 | 17 | standings.sort((a, b) => b.points - a.points); 18 | const minRank = Math.min(...standings.map(x => x.rank)); 19 | let previousRank = minRank - 1; 20 | standings.forEach((standing, index, standings) => { 21 | const isSameAsPrevious = index - 1 >= 0 && standings[index - 1].points == standing.points; 22 | const isSameAsNext = index + 1 < standings.length && standings[index + 1].points == standing.points; 23 | const updatedStanding = standing; 24 | updatedStanding.isTie = isSameAsPrevious || isSameAsNext; 25 | updatedStanding.rank = isSameAsPrevious ? previousRank : minRank + index; 26 | previousRank = updatedStanding.rank; 27 | 28 | const ref = firestore.doc(competition.standingsPathForUser(standing.userId)); 29 | batch.set(ref, prepareForFirestore(updatedStanding)); 30 | }); 31 | 32 | await batch.commit(); 33 | } 34 | -------------------------------------------------------------------------------- /firebase/functions/src/Models/Helpers/EnumDictionary.ts: -------------------------------------------------------------------------------- 1 | type StringKeyDictionary = { 2 | [K in T]: U 3 | }; 4 | 5 | type EnumDictionary = { 6 | [K in T]: U; 7 | }; 8 | 9 | export { 10 | StringKeyDictionary, 11 | EnumDictionary 12 | }; 13 | -------------------------------------------------------------------------------- /firebase/functions/src/Models/ScoringModel.ts: -------------------------------------------------------------------------------- 1 | import { WorkoutMetric } from "./WorkoutMetric"; 2 | import { WorkoutType } from "./WorkoutType"; 3 | 4 | enum RawScoringModel { 5 | percentOfGoals = 0, 6 | rawNumbers = 1, 7 | workout = 2, 8 | activityRingCloseCount = 3, 9 | stepCount = 4 10 | } 11 | 12 | interface ScoringModel { 13 | type: RawScoringModel; 14 | workoutType?: WorkoutType; 15 | workoutMetrics?: [WorkoutMetric]; 16 | } 17 | 18 | export { 19 | RawScoringModel, 20 | ScoringModel 21 | }; 22 | -------------------------------------------------------------------------------- /firebase/functions/src/Models/Standing.ts: -------------------------------------------------------------------------------- 1 | import { StringKeyDictionary } from "./Helpers/EnumDictionary"; 2 | 3 | /** 4 | * Standing 5 | */ 6 | class Standing { 7 | points: number; 8 | rank: number; 9 | userId: string; 10 | date?: string; 11 | pointsBreakdown?: StringKeyDictionary; 12 | isTie?: boolean; 13 | 14 | /** 15 | * Builds a standing record from a firestore document 16 | * @param {FirebaseFirestore.DocumentSnapshot} document The firestore document to build the standing record from 17 | */ 18 | constructor(document: FirebaseFirestore.DocumentSnapshot) { 19 | this.points = document.get("points"); 20 | this.rank = document.get("rank"); 21 | this.userId = document.get("userId"); 22 | this.date = document.get("date"); 23 | this.pointsBreakdown = document.get("pointsBreakdown"); 24 | this.isTie = document.get("isTie") ?? false; 25 | } 26 | 27 | /** 28 | * Creates a new standing record 29 | * @param {number} points number of points 30 | * @param {string} userId The id if the user 31 | * @return {Standing} a new standing 32 | */ 33 | static new(points: number, userId: string): Standing { 34 | const date = new Date().toISOString().split("T")[0]; // YYYY-MM-DD 35 | return { points: points, rank: 1, userId: userId, date: date }; 36 | } 37 | } 38 | 39 | export { 40 | Standing 41 | }; 42 | -------------------------------------------------------------------------------- /firebase/functions/src/Models/WorkoutMetric.ts: -------------------------------------------------------------------------------- 1 | enum WorkoutMetric { 2 | distance = "distance", 3 | heartRate = "heartRate", 4 | steps = "steps" 5 | } 6 | 7 | export { 8 | WorkoutMetric 9 | }; 10 | -------------------------------------------------------------------------------- /firebase/functions/src/Models/WorkoutType.ts: -------------------------------------------------------------------------------- 1 | enum WorkoutType { 2 | cycling = "cycling", 3 | running = "running", 4 | swimming = "swimming", 5 | walking = "walking" 6 | } 7 | 8 | export { 9 | WorkoutType 10 | }; 11 | -------------------------------------------------------------------------------- /firebase/functions/src/Utilities/Constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Constants used through the app 3 | */ 4 | export class Constants { 5 | static NOTIFICATION_URL = "https://friendly-competitions.app"; 6 | } 7 | -------------------------------------------------------------------------------- /firebase/functions/src/Utilities/firestore.ts: -------------------------------------------------------------------------------- 1 | import * as admin from "firebase-admin"; 2 | 3 | let shared: admin.firestore.Firestore | null = null; 4 | 5 | /** 6 | * Get a shared firestore instance 7 | * @return {admin.firestore.Firestore} the shared instance 8 | */ 9 | function getFirestore(): admin.firestore.Firestore { 10 | if (shared != null) { 11 | return shared; 12 | } 13 | const firestore = admin.firestore(); 14 | firestore.settings({ 15 | ignoreUndefinedProperties: true 16 | }); 17 | shared = firestore; 18 | return firestore; 19 | } 20 | 21 | export { 22 | getFirestore 23 | }; 24 | -------------------------------------------------------------------------------- /firebase/functions/src/Utilities/id.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates an id 3 | * @return {string} an id 4 | */ 5 | function id(): string { 6 | // Math.random should be unique because of its seeding algorithm. 7 | // Convert it to base 36 (numbers + letters), and grab the first 9 characters 8 | // after the decimal. 9 | return Math.random().toString(36).substring(2).toUpperCase(); 10 | } 11 | 12 | export { 13 | id 14 | }; 15 | -------------------------------------------------------------------------------- /firebase/functions/src/Utilities/prepareForFirestore.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Prepare an object for Firestore 3 | * @param {any} object a JSON object ready for firestore upload 4 | * @return {any} the object ready for Firestore upload 5 | */ 6 | function prepareForFirestore(object: any): any { 7 | return Object.assign({}, object); 8 | } 9 | 10 | export { 11 | prepareForFirestore 12 | }; 13 | -------------------------------------------------------------------------------- /firebase/functions/tsconfig.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | ".eslintrc.js" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /firebase/functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "noImplicitReturns": true, 5 | "noUnusedLocals": true, 6 | "outDir": "lib", 7 | "sourceMap": true, 8 | "strict": true, 9 | "target": "es2017" 10 | }, 11 | "compileOnSave": true, 12 | "include": [ 13 | "src" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /firebase/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firebase", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /firebase/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "build-functions": "cd functions && npm run build", 4 | "serve": "npm run build-functions && firebase emulators:start --import=exported-dev-data --export-on-exit=exported-dev-data" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /firebase/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | DIR=$(pwd) 2 | if [[ $DIR == */scripts ]];then 3 | cd .. 4 | fi 5 | 6 | firebase deploy -------------------------------------------------------------------------------- /firebase/scripts/emulate.sh: -------------------------------------------------------------------------------- 1 | DIR=$(pwd) 2 | if [[ $DIR == */scripts ]];then 3 | cd .. 4 | fi 5 | 6 | cd functions && npm run build && cd .. && firebase emulators:start --import=exported-dev-data --export-on-exit=exported-dev-data 7 | -------------------------------------------------------------------------------- /firebase/scripts/lint.sh: -------------------------------------------------------------------------------- 1 | DIR=$(pwd) 2 | if [[ $DIR == */scripts ]];then 3 | cd .. 4 | fi 5 | 6 | cd functions && npm run lint && cd .. -------------------------------------------------------------------------------- /firebase/scripts/ports.sh: -------------------------------------------------------------------------------- 1 | for i in 4000, 4001, 4002, 4500, 5000, 8080, 8085, 9000; do 2 | echo "=== $i ===" 3 | lsof -i tcp:$i 4 | done -------------------------------------------------------------------------------- /icon/ios/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /icon/ios/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /icon/ios/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /icon/ios/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /icon/ios/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /icon/ios/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /icon/ios/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /icon/ios/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /icon/ios/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /icon/ios/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /icon/ios/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /icon/ios/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /icon/ios/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /icon/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /icon/ios/AppIcon.appiconset/ItunesArtwork@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/AppIcon.appiconset/ItunesArtwork@2x.png -------------------------------------------------------------------------------- /icon/ios/iTunesArtwork@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/iTunesArtwork@1x.png -------------------------------------------------------------------------------- /icon/ios/iTunesArtwork@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/iTunesArtwork@2x.png -------------------------------------------------------------------------------- /icon/ios/iTunesArtwork@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/ios/iTunesArtwork@3x.png -------------------------------------------------------------------------------- /icon/square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/icon/square.png -------------------------------------------------------------------------------- /screenshots/6.5/competition.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/screenshots/6.5/competition.jpeg -------------------------------------------------------------------------------- /screenshots/6.5/explore.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/screenshots/6.5/explore.jpeg -------------------------------------------------------------------------------- /screenshots/6.5/home.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/screenshots/6.5/home.jpeg -------------------------------------------------------------------------------- /screenshots/6.5/new_competition.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/screenshots/6.5/new_competition.jpeg -------------------------------------------------------------------------------- /screenshots/mockups/Friendly Competitions.mockup: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/screenshots/mockups/Friendly Competitions.mockup -------------------------------------------------------------------------------- /screenshots/mockups/studio.app-mockup.com: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanCooper9/Friendly-Competitions/36f2afb3f87442bbe96eaf758ac08398fe5c8ae1/screenshots/mockups/studio.app-mockup.com --------------------------------------------------------------------------------