diff --git a/Documentation/UI/series_ui_screens.psd b/Documentation/UI/series_ui_screens.psd new file mode 100644 index 00000000..423e94d2 Binary files /dev/null and b/Documentation/UI/series_ui_screens.psd differ diff --git a/Pods/Pods.xcodeproj/project.pbxproj b/Pods/Pods.xcodeproj/project.pbxproj index 939345cf..8c6c65aa 100644 --- a/Pods/Pods.xcodeproj/project.pbxproj +++ b/Pods/Pods.xcodeproj/project.pbxproj @@ -112,7 +112,7 @@ 4DB05A93AE8618CA17FE9F41E5D584AB /* String+StringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945257FCDBF2F385DB98CCE3DDFE1888 /* String+StringConvertible.swift */; }; 4DEA11E2368CE6B011C45DB3D110F7A7 /* Pods-RaceSyncAPITests-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = A2BDB7F2851DBDD3DCDE3B7939FE999F /* Pods-RaceSyncAPITests-dummy.m */; }; 4E5BA8645D3EAB87789EF5DADE974356 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 952925E838A0DE4D7BDC4EE54022633D /* Foundation.framework */; }; - 4F26DD4813AA4800A0C83E1F25AAA89A /* TOCropViewController-TOCropViewControllerBundle in Resources */ = {isa = PBXBuildFile; fileRef = FE1E812071397E31AE21DFF368B781B1 /* TOCropViewController-TOCropViewControllerBundle */; }; + 4F26DD4813AA4800A0C83E1F25AAA89A /* TOCropViewControllerBundle.bundle in Resources */ = {isa = PBXBuildFile; fileRef = FE1E812071397E31AE21DFF368B781B1 /* TOCropViewControllerBundle.bundle */; }; 4FB55DD81C0428B1BCC037BEDFC8A0AC /* URLTransform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F56D4F9AFF45DABB76A7D328665B98 /* URLTransform.swift */; }; 4FC40B366BE9BB420501D5DA59AE12B3 /* TransformOf.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20831B46840ADEF373A821F40C88FDB1 /* TransformOf.swift */; }; 5151A004B9E4F31745607C4FD05F192A /* SwiftValidators-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = CD7E9A05D0089F225A157365A3BC45E5 /* SwiftValidators-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -578,7 +578,7 @@ 121B0C53B627EDDC9BFEB26AEFD2A11C /* Pods-RaceSyncAPITests-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "Pods-RaceSyncAPITests-umbrella.h"; sourceTree = ""; }; 13A22184EE0716278F677309CF9BAEF2 /* FlipHorizontalAnimation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FlipHorizontalAnimation.swift; path = Presentr/FlipHorizontalAnimation.swift; sourceTree = ""; }; 172A4A9B902731C8FD2ECDE7CD90A5F8 /* TOActivityCroppedImageProvider.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = TOActivityCroppedImageProvider.h; path = "Objective-C/TOCropViewController/Models/TOActivityCroppedImageProvider.h"; sourceTree = ""; }; - 182CB281FBC245D9709B360E9FA4E00C /* Presentr */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = Presentr; path = Presentr.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 182CB281FBC245D9709B360E9FA4E00C /* Presentr.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Presentr.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 191AE1DC5165B97D8DE107F70CC4B1B9 /* Double+StringConvertible.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Double+StringConvertible.swift"; path = "SwiftValidators/Classes/Extensions/Double+StringConvertible.swift"; sourceTree = ""; }; 1A1D49CD07A849CAD851A63047AB6FB3 /* EmptyDataSet-Swift.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "EmptyDataSet-Swift.debug.xcconfig"; sourceTree = ""; }; 1AD23B6C7C6B38E62CE2FC0DC12DC479 /* ImageCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ImageCache.swift; path = Source/ImageCache.swift; sourceTree = ""; }; @@ -658,7 +658,7 @@ 4F26B21712AE5C4AB2F25AD682A8AC18 /* AlamofireNetworkActivityIndicator.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = AlamofireNetworkActivityIndicator.modulemap; sourceTree = ""; }; 4FCEDECCCD9B1D60C6B01516E70CF5B8 /* PresentrAnimation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PresentrAnimation.swift; path = Presentr/PresentrAnimation.swift; sourceTree = ""; }; 503DF382A871807B05DF4F2AACA1C2E0 /* ShimmeringMaskLayer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ShimmeringMaskLayer.swift; path = Shimmer/ShimmeringMaskLayer.swift; sourceTree = ""; }; - 512ADD55131CC16CBB04EF165C0C703E /* Pods-RaceSyncAPITests */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = "Pods-RaceSyncAPITests"; path = Pods_RaceSyncAPITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 512ADD55131CC16CBB04EF165C0C703E /* Pods_RaceSyncAPITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RaceSyncAPITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 535F83D2AB4F7015466A7EC979231533 /* TOCropToolbar.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = TOCropToolbar.h; path = "Objective-C/TOCropViewController/Views/TOCropToolbar.h"; sourceTree = ""; }; 5386D557A33CB6D569F3A8D9F95E445E /* TOCropViewControllerTransitioning.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = TOCropViewControllerTransitioning.m; path = "Objective-C/TOCropViewController/Models/TOCropViewControllerTransitioning.m"; sourceTree = ""; }; 53D5A3FEAAB23A8BE7E22B510DBF700C /* Pods-RaceSync.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-RaceSync.release.xcconfig"; sourceTree = ""; }; @@ -675,15 +675,15 @@ 58D5925330F73F28D972A2AF4A7D4929 /* AlamofireObjectMapper-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "AlamofireObjectMapper-prefix.pch"; sourceTree = ""; }; 590EB860A3B3D2931D5969D74FB06A35 /* ConstraintDirectionalInsetTarget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintDirectionalInsetTarget.swift; path = Sources/ConstraintDirectionalInsetTarget.swift; sourceTree = ""; }; 59566F671C2FF4ED28A3564CD792A783 /* Pods-RaceSyncAPITests-acknowledgements.markdown */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = "Pods-RaceSyncAPITests-acknowledgements.markdown"; sourceTree = ""; }; - 5C871E3AA3292278FEE126AAFAEC60A5 /* QRCode */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = QRCode; path = QRCode.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5C871E3AA3292278FEE126AAFAEC60A5 /* QRCode.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = QRCode.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5D0C58D9DB7B4F510C11D58EA4D4C87E /* Presentr-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Presentr-Info.plist"; sourceTree = ""; }; - 5D797E9A5C5782CE845840781FA1CC81 /* Alamofire */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = Alamofire; path = Alamofire.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5D797E9A5C5782CE845840781FA1CC81 /* Alamofire.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Alamofire.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5DDB69273F2266F7C73562DD88EFB60D /* Pods-RaceSyncAPITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-RaceSyncAPITests.debug.xcconfig"; sourceTree = ""; }; 5E196649FF1DFD9FB37069631E7B7FCC /* Alamofire-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "Alamofire-dummy.m"; sourceTree = ""; }; 5E87FC7AE43C20637CC520F4A366CFB1 /* AlamofireNetworkActivityIndicator.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = AlamofireNetworkActivityIndicator.release.xcconfig; sourceTree = ""; }; 5E9A723B18F898B2448887041C58CC56 /* Presentr.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Presentr.swift; path = Presentr/Presentr.swift; sourceTree = ""; }; 5EBF5AFE8AD8D116A176C4A494800056 /* Presentr-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "Presentr-prefix.pch"; sourceTree = ""; }; - 5F855BED381F8369EEB86AA458842D3D /* Pods-RaceSync */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = "Pods-RaceSync"; path = Pods_RaceSync.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5F855BED381F8369EEB86AA458842D3D /* Pods_RaceSync.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RaceSync.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 60C1ABDDA76251A4D81D4CF16C925929 /* ImageFilter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ImageFilter.swift; path = Source/ImageFilter.swift; sourceTree = ""; }; 60ECB40C80F4F5113F8FD0E23BAD67EC /* ToJSON.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ToJSON.swift; path = Sources/ToJSON.swift; sourceTree = ""; }; 615E73324A15D6A0D1C59DA2BD7E7D11 /* MapError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MapError.swift; path = Sources/MapError.swift; sourceTree = ""; }; @@ -722,7 +722,7 @@ 764750FFDE0F3D1ABABA176974F34458 /* id.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = id.lproj; path = "Objective-C/TOCropViewController/Resources/id.lproj"; sourceTree = ""; }; 76AA8B4BFD3C779337860B1427C89EE9 /* SourceSansPro-Regular.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; name = "SourceSansPro-Regular.ttf"; path = "Presentr/SourceSansPro-Regular.ttf"; sourceTree = ""; }; 7759A85FCA1ADF8BE320A302352A5594 /* EmptyDataSet-Swift-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "EmptyDataSet-Swift-dummy.m"; sourceTree = ""; }; - 7862C607B7BE0510C2D65193F9B4B4F9 /* TOCropViewController */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = TOCropViewController; path = TOCropViewController.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7862C607B7BE0510C2D65193F9B4B4F9 /* TOCropViewController.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TOCropViewController.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7A31434CACDC307391DE089F2691F545 /* SnapKit-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "SnapKit-Info.plist"; sourceTree = ""; }; 7A7FA71894CC25376E4BD8F5EB77C9A9 /* TOCroppedImageAttributes.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = TOCroppedImageAttributes.h; path = "Objective-C/TOCropViewController/Models/TOCroppedImageAttributes.h"; sourceTree = ""; }; 7AD421264ACE741029F6BBE19E88281F /* ConstraintDirectionalInsets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintDirectionalInsets.swift; path = Sources/ConstraintDirectionalInsets.swift; sourceTree = ""; }; @@ -739,7 +739,7 @@ 82E23474EB024D41AE1A20D0E749C39D /* Pods-RaceSyncAPI.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-RaceSyncAPI.release.xcconfig"; sourceTree = ""; }; 833F63377A7AE8E2360FC7C73390A1A6 /* PickerView-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "PickerView-dummy.m"; sourceTree = ""; }; 8493BE88888E87EF43656E62910D5786 /* AFIError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AFIError.swift; path = Source/AFIError.swift; sourceTree = ""; }; - 86822384CD379FCE373C2F755839B8DE /* AlamofireObjectMapper */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = AlamofireObjectMapper; path = AlamofireObjectMapper.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 86822384CD379FCE373C2F755839B8DE /* AlamofireObjectMapper.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AlamofireObjectMapper.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 87EBDF7C4ACF1740DB26E4F9A420D1C3 /* MigrationResult.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MigrationResult.swift; path = Sources/Valet/MigrationResult.swift; sourceTree = ""; }; 88980CAF9AE661CD1E6608D6C72610B6 /* SwiftValidators.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = SwiftValidators.release.xcconfig; sourceTree = ""; }; 8AD167450167065178DF5C5DA0912D7E /* DateFormatterTransform.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DateFormatterTransform.swift; path = Sources/DateFormatterTransform.swift; sourceTree = ""; }; @@ -754,7 +754,7 @@ 8FD454EF5271F8B21D415E918129094D /* ru.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = ru.lproj; path = "Objective-C/TOCropViewController/Resources/ru.lproj"; sourceTree = ""; }; 906405D2047623F343474FFC8C5B3914 /* Result.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Result.swift; path = Source/Result.swift; sourceTree = ""; }; 92FEC15EAF7A88BD8A74565F772C7BE5 /* Pods-RaceSync-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-RaceSync-Info.plist"; sourceTree = ""; }; - 93027197DCE144EF9A5DDF15E6C717AA /* ShimmerSwift */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = ShimmerSwift; path = ShimmerSwift.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 93027197DCE144EF9A5DDF15E6C717AA /* ShimmerSwift.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ShimmerSwift.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 932E0CBD74E508CDE7A425F3FEFD3497 /* SessionManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SessionManager.swift; path = Source/SessionManager.swift; sourceTree = ""; }; 93F2B4909C3789B4685A3EC042963500 /* ConstraintPriority.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintPriority.swift; path = Sources/ConstraintPriority.swift; sourceTree = ""; }; 945257FCDBF2F385DB98CCE3DDFE1888 /* String+StringConvertible.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "String+StringConvertible.swift"; path = "SwiftValidators/Classes/Extensions/String+StringConvertible.swift"; sourceTree = ""; }; @@ -762,12 +762,12 @@ 952925E838A0DE4D7BDC4EE54022633D /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 956D07178F5DB4C0895F357F4BAA358F /* FQDNOptions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FQDNOptions.swift; path = SwiftValidators/Classes/Helpers/FQDNOptions.swift; sourceTree = ""; }; 95F56D4F9AFF45DABB76A7D328665B98 /* URLTransform.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = URLTransform.swift; path = Sources/URLTransform.swift; sourceTree = ""; }; - 979486118B3E90C08386079D57962701 /* SnapKit */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = SnapKit; path = SnapKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 979486118B3E90C08386079D57962701 /* SnapKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SnapKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 97DE81706A61BFCFD98EE3DF1C033A1C /* Pods-RaceSyncAPI-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "Pods-RaceSyncAPI-dummy.m"; sourceTree = ""; }; 9B6924B2549D5DD17AE3109395B8FBF7 /* Presentr+Equatable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Presentr+Equatable.swift"; path = "Presentr/Presentr+Equatable.swift"; sourceTree = ""; }; 9D940727FF8FB9C785EB98E56350EF41 /* Podfile */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; indentWidth = 2; name = Podfile; path = ../Podfile; sourceTree = SOURCE_ROOT; tabWidth = 2; }; 9EF7C727DA9F66F824468359E25C8931 /* sk.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = sk.lproj; path = "Objective-C/TOCropViewController/Resources/sk.lproj"; sourceTree = ""; }; - A03A87E195ADD30F134A645333DE6D9C /* PickerView */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = PickerView; path = PickerView.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A03A87E195ADD30F134A645333DE6D9C /* PickerView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = PickerView.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A0ACCDFD007A19FEC4E135C0F14765C0 /* TOCropViewController.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = TOCropViewController.m; path = "Objective-C/TOCropViewController/TOCropViewController.m"; sourceTree = ""; }; A1BB9E97B546D1B50FB0A89A3D87DAF5 /* LayoutConstraint.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LayoutConstraint.swift; path = Sources/LayoutConstraint.swift; sourceTree = ""; }; A2AFF4D3B8686896DB128581146ECBCA /* EmptyDataSetView+Extension.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "EmptyDataSetView+Extension.swift"; path = "EmptyDataSet-Swift/Sources/EmptyDataSetView+Extension.swift"; sourceTree = ""; }; @@ -799,14 +799,14 @@ B1DBF5A920702A5C4B8144BA5414932A /* ConstraintConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintConfig.swift; path = Sources/ConstraintConfig.swift; sourceTree = ""; }; B2ACC5E98733C48E56D390FF215BCDE8 /* AlamofireImage.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = AlamofireImage.release.xcconfig; sourceTree = ""; }; B2D52D1DC7947E50CC3D3DDD6C0CA230 /* TOCropScrollView.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = TOCropScrollView.h; path = "Objective-C/TOCropViewController/Views/TOCropScrollView.h"; sourceTree = ""; }; - B3081E0CFC54E0C89C7B6D481B03DC95 /* Valet */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = Valet; path = Valet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B3081E0CFC54E0C89C7B6D481B03DC95 /* Valet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Valet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B39AA227428FA269171268A080203DC7 /* ar.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = ar.lproj; path = "Objective-C/TOCropViewController/Resources/ar.lproj"; sourceTree = ""; }; B5ED07EC299A291738E12B9805FE1FC9 /* AlamofireNetworkActivityIndicator.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = AlamofireNetworkActivityIndicator.debug.xcconfig; sourceTree = ""; }; B6A54F085CDB609E03C0C941D3B1AC35 /* PickerView.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = PickerView.debug.xcconfig; sourceTree = ""; }; B6B788779E6D86BDF5BDC2D41A07A0B1 /* SwiftValidators-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "SwiftValidators-prefix.pch"; sourceTree = ""; }; B7963C277562F9E9CA2367F93F1262E9 /* Map.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Map.swift; path = Sources/Map.swift; sourceTree = ""; }; B890924F839332E121E18E09CD555634 /* Alamofire.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Alamofire.swift; path = Source/Alamofire.swift; sourceTree = ""; }; - B9084FE779702931E8DF1D00A2D725FB /* ObjectMapper */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = ObjectMapper; path = ObjectMapper.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B9084FE779702931E8DF1D00A2D725FB /* ObjectMapper.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ObjectMapper.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B92169387FF2FF0B5B5DCB188EA1E201 /* ImmutableMappable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ImmutableMappable.swift; path = Sources/ImmutableMappable.swift; sourceTree = ""; }; B968F59D43BB89EF659967F7C904944B /* ServerTrustPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ServerTrustPolicy.swift; path = Source/ServerTrustPolicy.swift; sourceTree = ""; }; BA9971C8ECD3BA10DEBB5C0741550105 /* Keychain.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Keychain.swift; path = Sources/Valet/Internal/Keychain.swift; sourceTree = ""; }; @@ -829,23 +829,23 @@ C654BCAA6E68CACFEEB518A587DC7B75 /* ConstraintOffsetTarget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintOffsetTarget.swift; path = Sources/ConstraintOffsetTarget.swift; sourceTree = ""; }; C7625358969D2827CE967DD3A42372B0 /* ResourceBundle-TOCropViewControllerBundle-TOCropViewController-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "ResourceBundle-TOCropViewControllerBundle-TOCropViewController-Info.plist"; sourceTree = ""; }; C82C7E3CF23DAEABDBD3F28F183095EA /* Valet-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Valet-Info.plist"; sourceTree = ""; }; - C8498160B5D29EF2745E4D41F2D971B5 /* AlamofireImage */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = AlamofireImage; path = AlamofireImage.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C8498160B5D29EF2745E4D41F2D971B5 /* AlamofireImage.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AlamofireImage.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CB058A7D7F61584830ACA2F2919C7DD7 /* ConstraintLayoutSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintLayoutSupport.swift; path = Sources/ConstraintLayoutSupport.swift; sourceTree = ""; }; CBCE47EA2F6536AE5A07756E25B2E284 /* Pods-RaceSync-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "Pods-RaceSync-umbrella.h"; sourceTree = ""; }; CCA043FDF276A3DF2C4AB914BD447DFC /* ConstraintLayoutGuideDSL.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintLayoutGuideDSL.swift; path = Sources/ConstraintLayoutGuideDSL.swift; sourceTree = ""; }; CD4897F8E5BB1BA0DC5052A375C2B168 /* Pods-RaceSyncAPI-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-RaceSyncAPI-Info.plist"; sourceTree = ""; }; CD7E9A05D0089F225A157365A3BC45E5 /* SwiftValidators-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "SwiftValidators-umbrella.h"; sourceTree = ""; }; - CF8D19DFCF0FADE0AFA804F44FA81E75 /* EmptyDataSet-Swift */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = "EmptyDataSet-Swift"; path = EmptyDataSet_Swift.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CF8D19DFCF0FADE0AFA804F44FA81E75 /* EmptyDataSet_Swift.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = EmptyDataSet_Swift.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CFB1D1A8D9ED10E00D5D6D4CDC0FC48C /* UIImage+CropRotate.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "UIImage+CropRotate.m"; path = "Objective-C/TOCropViewController/Categories/UIImage+CropRotate.m"; sourceTree = ""; }; D03BA49789E3F485ECCCA8CD4FF77F2E /* ConstraintAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintAttributes.swift; path = Sources/ConstraintAttributes.swift; sourceTree = ""; }; D0CD1F7CFE6BC0CD66C3C9276F17E0B6 /* HexColorTransform.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HexColorTransform.swift; path = Sources/HexColorTransform.swift; sourceTree = ""; }; D16BB505ECF8908972809702A9285EF1 /* Pods-RaceSync.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-RaceSync.debug.xcconfig"; sourceTree = ""; }; D1B094606009F55C7155A9DD21AD6977 /* TOCropViewController-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "TOCropViewController-dummy.m"; sourceTree = ""; }; - D2883ADA77FDCE8B81F2C5DDAD66578F /* AlamofireNetworkActivityIndicator */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = AlamofireNetworkActivityIndicator; path = AlamofireNetworkActivityIndicator.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D2883ADA77FDCE8B81F2C5DDAD66578F /* AlamofireNetworkActivityIndicator.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AlamofireNetworkActivityIndicator.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D2CAC407DACC5B0D6AC1982A0AC35204 /* ConstraintView+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "ConstraintView+Extensions.swift"; path = "Sources/ConstraintView+Extensions.swift"; sourceTree = ""; }; D6FF6B460DFDB15DC266FA2CE2FA4A48 /* ImageDownloader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ImageDownloader.swift; path = Source/ImageDownloader.swift; sourceTree = ""; }; D8018275AC4A95481A167E526DA3DC17 /* CoverVerticalAnimation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CoverVerticalAnimation.swift; path = Presentr/CoverVerticalAnimation.swift; sourceTree = ""; }; - D92DA9F456FB93C0EF410807CB76213A /* SwiftValidators */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = SwiftValidators; path = SwiftValidators.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D92DA9F456FB93C0EF410807CB76213A /* SwiftValidators.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftValidators.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DA8747D00744CF74766A241F72161693 /* Valet-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "Valet-dummy.m"; sourceTree = ""; }; DAE2570F9884DBF858792A8F97492DEF /* ConstraintLayoutGuide.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintLayoutGuide.swift; path = Sources/ConstraintLayoutGuide.swift; sourceTree = ""; }; DB15B6847942950ACB4F4A353FA4D49E /* vi.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = vi.lproj; path = "Objective-C/TOCropViewController/Resources/vi.lproj"; sourceTree = ""; }; @@ -857,7 +857,7 @@ DF7694A74855C8453F124EC0EA7ED1F8 /* String.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = String.swift; path = SwiftValidators/Classes/Extensions/String.swift; sourceTree = ""; }; DFAB126F8541525560C031D662C5D77B /* Pods-RaceSyncAPITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-RaceSyncAPITests.release.xcconfig"; sourceTree = ""; }; E1D88EFF544F527DCE698FE545B356C5 /* Pods-RaceSync-acknowledgements.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-RaceSync-acknowledgements.plist"; sourceTree = ""; }; - E23C076BA70925415F490FEDB215DA92 /* SwiftyJSON */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = SwiftyJSON; path = SwiftyJSON.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E23C076BA70925415F490FEDB215DA92 /* SwiftyJSON.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftyJSON.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E266C135010D8DF8BAA17742A51EB30B /* PickerView.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = PickerView.modulemap; sourceTree = ""; }; E3FDD0561854BE0C0D2F59DC075C0240 /* fa-IR.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = "fa-IR.lproj"; path = "Objective-C/TOCropViewController/Resources/fa-IR.lproj"; sourceTree = ""; }; E47908C844F8FD9E9F784FD657DD30E8 /* SnapKit.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = SnapKit.modulemap; sourceTree = ""; }; @@ -896,14 +896,14 @@ F540C2AA98B5F78D2638EEFEE7C3ACA7 /* Response.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Response.swift; path = Source/Response.swift; sourceTree = ""; }; F55ED5A09B55EC424F182E588317ABBB /* UIImage+CropRotate.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "UIImage+CropRotate.h"; path = "Objective-C/TOCropViewController/Categories/UIImage+CropRotate.h"; sourceTree = ""; }; F634837B520C4C4D5BA6E5696C44764E /* it.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = it.lproj; path = "Objective-C/TOCropViewController/Resources/it.lproj"; sourceTree = ""; }; - F8A1131B3FEAA8CA159A060F557DCBA9 /* Pods-RaceSyncAPI */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = "Pods-RaceSyncAPI"; path = Pods_RaceSyncAPI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + F8A1131B3FEAA8CA159A060F557DCBA9 /* Pods_RaceSyncAPI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RaceSyncAPI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; F9676022E80C0AFC8555A9F4B8F0A895 /* ObjectMapper.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = ObjectMapper.modulemap; sourceTree = ""; }; FADB6181EA4D36D96303713B9C752448 /* SwiftyJSON-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "SwiftyJSON-prefix.pch"; sourceTree = ""; }; FB17CC1E94DA110B34195F643745F5A2 /* ConstraintMakerEditable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintMakerEditable.swift; path = Sources/ConstraintMakerEditable.swift; sourceTree = ""; }; FB1D1FFEFA020EB6ED065EE2160FB5D1 /* Debugging.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Debugging.swift; path = Sources/Debugging.swift; sourceTree = ""; }; FBB4B52334B2D822A7B3CCADDEC669FC /* Presentr-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "Presentr-dummy.m"; sourceTree = ""; }; FC3BB8E8B18F28B0521B7F32E235FD4D /* AlamofireImage-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "AlamofireImage-dummy.m"; sourceTree = ""; }; - FE1E812071397E31AE21DFF368B781B1 /* TOCropViewController-TOCropViewControllerBundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = "TOCropViewController-TOCropViewControllerBundle"; path = TOCropViewControllerBundle.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; + FE1E812071397E31AE21DFF368B781B1 /* TOCropViewControllerBundle.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TOCropViewControllerBundle.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; FE7A55A94E9B8C763F8BAA6BF053D359 /* QRCode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = QRCode.swift; path = QRCode/QRCode.swift; sourceTree = ""; }; FF05A602FF4A263A74DC8422711B53AA /* NetworkActivityIndicatorManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NetworkActivityIndicatorManager.swift; path = Source/NetworkActivityIndicatorManager.swift; sourceTree = ""; }; FFC4FDB58DD3D440280BAA88F503F30C /* TOCropView.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = TOCropView.m; path = "Objective-C/TOCropViewController/Views/TOCropView.m"; sourceTree = ""; }; @@ -1764,25 +1764,25 @@ FEF4D70D8140BEF756570AF07B6B324B /* Products */ = { isa = PBXGroup; children = ( - 5D797E9A5C5782CE845840781FA1CC81 /* Alamofire */, - C8498160B5D29EF2745E4D41F2D971B5 /* AlamofireImage */, - D2883ADA77FDCE8B81F2C5DDAD66578F /* AlamofireNetworkActivityIndicator */, - 86822384CD379FCE373C2F755839B8DE /* AlamofireObjectMapper */, - CF8D19DFCF0FADE0AFA804F44FA81E75 /* EmptyDataSet-Swift */, - B9084FE779702931E8DF1D00A2D725FB /* ObjectMapper */, - A03A87E195ADD30F134A645333DE6D9C /* PickerView */, - 5F855BED381F8369EEB86AA458842D3D /* Pods-RaceSync */, - F8A1131B3FEAA8CA159A060F557DCBA9 /* Pods-RaceSyncAPI */, - 512ADD55131CC16CBB04EF165C0C703E /* Pods-RaceSyncAPITests */, - 182CB281FBC245D9709B360E9FA4E00C /* Presentr */, - 5C871E3AA3292278FEE126AAFAEC60A5 /* QRCode */, - 93027197DCE144EF9A5DDF15E6C717AA /* ShimmerSwift */, - 979486118B3E90C08386079D57962701 /* SnapKit */, - D92DA9F456FB93C0EF410807CB76213A /* SwiftValidators */, - E23C076BA70925415F490FEDB215DA92 /* SwiftyJSON */, - 7862C607B7BE0510C2D65193F9B4B4F9 /* TOCropViewController */, - FE1E812071397E31AE21DFF368B781B1 /* TOCropViewController-TOCropViewControllerBundle */, - B3081E0CFC54E0C89C7B6D481B03DC95 /* Valet */, + 5D797E9A5C5782CE845840781FA1CC81 /* Alamofire.framework */, + C8498160B5D29EF2745E4D41F2D971B5 /* AlamofireImage.framework */, + D2883ADA77FDCE8B81F2C5DDAD66578F /* AlamofireNetworkActivityIndicator.framework */, + 86822384CD379FCE373C2F755839B8DE /* AlamofireObjectMapper.framework */, + CF8D19DFCF0FADE0AFA804F44FA81E75 /* EmptyDataSet_Swift.framework */, + B9084FE779702931E8DF1D00A2D725FB /* ObjectMapper.framework */, + A03A87E195ADD30F134A645333DE6D9C /* PickerView.framework */, + 5F855BED381F8369EEB86AA458842D3D /* Pods_RaceSync.framework */, + F8A1131B3FEAA8CA159A060F557DCBA9 /* Pods_RaceSyncAPI.framework */, + 512ADD55131CC16CBB04EF165C0C703E /* Pods_RaceSyncAPITests.framework */, + 182CB281FBC245D9709B360E9FA4E00C /* Presentr.framework */, + 5C871E3AA3292278FEE126AAFAEC60A5 /* QRCode.framework */, + 93027197DCE144EF9A5DDF15E6C717AA /* ShimmerSwift.framework */, + 979486118B3E90C08386079D57962701 /* SnapKit.framework */, + D92DA9F456FB93C0EF410807CB76213A /* SwiftValidators.framework */, + E23C076BA70925415F490FEDB215DA92 /* SwiftyJSON.framework */, + 7862C607B7BE0510C2D65193F9B4B4F9 /* TOCropViewController.framework */, + FE1E812071397E31AE21DFF368B781B1 /* TOCropViewControllerBundle.bundle */, + B3081E0CFC54E0C89C7B6D481B03DC95 /* Valet.framework */, ); name = Products; sourceTree = ""; @@ -1963,7 +1963,7 @@ ); name = ObjectMapper; productName = ObjectMapper; - productReference = B9084FE779702931E8DF1D00A2D725FB /* ObjectMapper */; + productReference = B9084FE779702931E8DF1D00A2D725FB /* ObjectMapper.framework */; productType = "com.apple.product-type.framework"; }; 19622742EBA51E823D6DAE3F8CDBFAD4 /* SnapKit */ = { @@ -1981,7 +1981,7 @@ ); name = SnapKit; productName = SnapKit; - productReference = 979486118B3E90C08386079D57962701 /* SnapKit */; + productReference = 979486118B3E90C08386079D57962701 /* SnapKit.framework */; productType = "com.apple.product-type.framework"; }; 2AE6498163839D0268759D257B77AF2A /* SwiftValidators */ = { @@ -1999,7 +1999,7 @@ ); name = SwiftValidators; productName = SwiftValidators; - productReference = D92DA9F456FB93C0EF410807CB76213A /* SwiftValidators */; + productReference = D92DA9F456FB93C0EF410807CB76213A /* SwiftValidators.framework */; productType = "com.apple.product-type.framework"; }; 2D4D3D5AD93ADCCF3DD45A88009E48D6 /* TOCropViewController-TOCropViewControllerBundle */ = { @@ -2016,7 +2016,7 @@ ); name = "TOCropViewController-TOCropViewControllerBundle"; productName = TOCropViewControllerBundle; - productReference = FE1E812071397E31AE21DFF368B781B1 /* TOCropViewController-TOCropViewControllerBundle */; + productReference = FE1E812071397E31AE21DFF368B781B1 /* TOCropViewControllerBundle.bundle */; productType = "com.apple.product-type.bundle"; }; 3F64080413BB03C0B16BFEB621E6E0F2 /* AlamofireObjectMapper */ = { @@ -2036,7 +2036,7 @@ ); name = AlamofireObjectMapper; productName = AlamofireObjectMapper; - productReference = 86822384CD379FCE373C2F755839B8DE /* AlamofireObjectMapper */; + productReference = 86822384CD379FCE373C2F755839B8DE /* AlamofireObjectMapper.framework */; productType = "com.apple.product-type.framework"; }; 433F79D5F3953F8EA8E7B0C7A2A27D32 /* Presentr */ = { @@ -2054,7 +2054,7 @@ ); name = Presentr; productName = Presentr; - productReference = 182CB281FBC245D9709B360E9FA4E00C /* Presentr */; + productReference = 182CB281FBC245D9709B360E9FA4E00C /* Presentr.framework */; productType = "com.apple.product-type.framework"; }; 5C8A7314E8B4FC7AE44BE79CDFC81B4E /* Pods-RaceSync */ = { @@ -2088,7 +2088,7 @@ ); name = "Pods-RaceSync"; productName = Pods_RaceSync; - productReference = 5F855BED381F8369EEB86AA458842D3D /* Pods-RaceSync */; + productReference = 5F855BED381F8369EEB86AA458842D3D /* Pods_RaceSync.framework */; productType = "com.apple.product-type.framework"; }; 8BC749AEE792351779CC89A60976560E /* Pods-RaceSyncAPI */ = { @@ -2114,7 +2114,7 @@ ); name = "Pods-RaceSyncAPI"; productName = Pods_RaceSyncAPI; - productReference = F8A1131B3FEAA8CA159A060F557DCBA9 /* Pods-RaceSyncAPI */; + productReference = F8A1131B3FEAA8CA159A060F557DCBA9 /* Pods_RaceSyncAPI.framework */; productType = "com.apple.product-type.framework"; }; 8C796CDB90777AB5D92691D3F8F2DDA5 /* AlamofireNetworkActivityIndicator */ = { @@ -2133,7 +2133,7 @@ ); name = AlamofireNetworkActivityIndicator; productName = AlamofireNetworkActivityIndicator; - productReference = D2883ADA77FDCE8B81F2C5DDAD66578F /* AlamofireNetworkActivityIndicator */; + productReference = D2883ADA77FDCE8B81F2C5DDAD66578F /* AlamofireNetworkActivityIndicator.framework */; productType = "com.apple.product-type.framework"; }; 99CF93EA2FED5B171F261E21C20B3206 /* Pods-RaceSyncAPITests */ = { @@ -2159,7 +2159,7 @@ ); name = "Pods-RaceSyncAPITests"; productName = Pods_RaceSyncAPITests; - productReference = 512ADD55131CC16CBB04EF165C0C703E /* Pods-RaceSyncAPITests */; + productReference = 512ADD55131CC16CBB04EF165C0C703E /* Pods_RaceSyncAPITests.framework */; productType = "com.apple.product-type.framework"; }; B5AB76607FAE66C0A637C8241113BBDE /* EmptyDataSet-Swift */ = { @@ -2177,7 +2177,7 @@ ); name = "EmptyDataSet-Swift"; productName = EmptyDataSet_Swift; - productReference = CF8D19DFCF0FADE0AFA804F44FA81E75 /* EmptyDataSet-Swift */; + productReference = CF8D19DFCF0FADE0AFA804F44FA81E75 /* EmptyDataSet_Swift.framework */; productType = "com.apple.product-type.framework"; }; BF2974CEBD4F57BEFE88471BC9F6361A /* ShimmerSwift */ = { @@ -2195,7 +2195,7 @@ ); name = ShimmerSwift; productName = ShimmerSwift; - productReference = 93027197DCE144EF9A5DDF15E6C717AA /* ShimmerSwift */; + productReference = 93027197DCE144EF9A5DDF15E6C717AA /* ShimmerSwift.framework */; productType = "com.apple.product-type.framework"; }; C9778AE8394BE451567650E0B239BA21 /* AlamofireImage */ = { @@ -2214,7 +2214,7 @@ ); name = AlamofireImage; productName = AlamofireImage; - productReference = C8498160B5D29EF2745E4D41F2D971B5 /* AlamofireImage */; + productReference = C8498160B5D29EF2745E4D41F2D971B5 /* AlamofireImage.framework */; productType = "com.apple.product-type.framework"; }; CE0B191E20D9960101AD8D1CAB4AAFDA /* PickerView */ = { @@ -2232,7 +2232,7 @@ ); name = PickerView; productName = PickerView; - productReference = A03A87E195ADD30F134A645333DE6D9C /* PickerView */; + productReference = A03A87E195ADD30F134A645333DE6D9C /* PickerView.framework */; productType = "com.apple.product-type.framework"; }; CEF561CEEBFA5EBBDA6E240FF1B20CEC /* QRCode */ = { @@ -2250,7 +2250,7 @@ ); name = QRCode; productName = QRCode; - productReference = 5C871E3AA3292278FEE126AAFAEC60A5 /* QRCode */; + productReference = 5C871E3AA3292278FEE126AAFAEC60A5 /* QRCode.framework */; productType = "com.apple.product-type.framework"; }; D118A6A04828FD3CDA8640CD2B6796D2 /* SwiftyJSON */ = { @@ -2268,7 +2268,7 @@ ); name = SwiftyJSON; productName = SwiftyJSON; - productReference = E23C076BA70925415F490FEDB215DA92 /* SwiftyJSON */; + productReference = E23C076BA70925415F490FEDB215DA92 /* SwiftyJSON.framework */; productType = "com.apple.product-type.framework"; }; E3D29E214E770F28213292B63B7A05DA /* Valet */ = { @@ -2286,7 +2286,7 @@ ); name = Valet; productName = Valet; - productReference = B3081E0CFC54E0C89C7B6D481B03DC95 /* Valet */; + productReference = B3081E0CFC54E0C89C7B6D481B03DC95 /* Valet.framework */; productType = "com.apple.product-type.framework"; }; EAAA1AD3A8A1B59AB91319EE40752C6D /* Alamofire */ = { @@ -2304,7 +2304,7 @@ ); name = Alamofire; productName = Alamofire; - productReference = 5D797E9A5C5782CE845840781FA1CC81 /* Alamofire */; + productReference = 5D797E9A5C5782CE845840781FA1CC81 /* Alamofire.framework */; productType = "com.apple.product-type.framework"; }; F3966F664F3CFAEFAB57C40FB54D3788 /* TOCropViewController */ = { @@ -2323,7 +2323,7 @@ ); name = TOCropViewController; productName = TOCropViewController; - productReference = 7862C607B7BE0510C2D65193F9B4B4F9 /* TOCropViewController */; + productReference = 7862C607B7BE0510C2D65193F9B4B4F9 /* TOCropViewController.framework */; productType = "com.apple.product-type.framework"; }; /* End PBXNativeTarget section */ @@ -2482,7 +2482,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4F26DD4813AA4800A0C83E1F25AAA89A /* TOCropViewController-TOCropViewControllerBundle in Resources */, + 4F26DD4813AA4800A0C83E1F25AAA89A /* TOCropViewControllerBundle.bundle in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 0c706702..f31c5d5b 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,6 +1,43 @@ # App Store Release Notes +--- + +## 1.8.2 + +### New Features: + * Added a universal search tool for querying races, users, and chapters by name or ID. + * Race web links now open the race detail natively in the app instead of launching the browser. + * Added pull-to-refresh support for reloading pilot results. + * Introduced groundwork for Series support (currently available to beta testers only). + +### For Chapter Organizers: + * Enabled the contextual editing menu via long-press on managed race rows. + * Fixed a crash when opening or closing race enrolment. + * Restored the ability to duplicate or delete races (regression introduced in v1.8). + * Temporarily disabled Finalizing a Race due to intermittent API issues. + +### Fixes and Enhancements: + * Fixed an issue where the payments list displayed $0 amounts in rare cases. + * Improved several UI elements related to RaceSync Pay. + * Displaying the current round and heat for a race in progress. + * Refined the race results display to better match the website and corrected related UI issues. + * Fixed tab bar selection inconsistencies. + * Updated chapter profiles to hide join buttons for unapproved or demoted chapters and to display the disabled-chapter label when applicable. + +--- + +## 1.8.1 + +### Fixes and Enhancements: + * Resolved an issue where the payments list for organizers displayed $0 amounts under rare conditions. + * Fixed not being able to duplicate or delete a race anymore. This was a regression introduced in v1.8. + * Fixed text truncation issues in join buttons, when the race fee is over $99. + * Web links to races now open directly in the app, showing the race detail view. + * You can now email feedback directly from Settings with your preferred email app. + +--- + ## 1.8 ### Introducing RaceSync Pay: @@ -10,7 +47,7 @@ You can now pay for races in-app with PayPal or credit card in USD (only for rac * View paid and unpaid pilot registrations in the race detail screen * Overhauled race creation and editing forms, now including payment options -### Fixes and enhancements: +### Fixes and Enhancements: * See your personal result in a pinned format, similar to the GQ Standings view * Joining a race no longer requires selecting an aircraft beforehand * The Standings tab is now selected by default. The app will also remember your last selected tab and open it on launch @@ -23,8 +60,7 @@ You can now pay for races in-app with PayPal or credit card in USD (only for rac ## 1.7.2 -### Fixes and enhancements: - +### Fixes and Enhancements: * Fixed a sorting inconsistency in the Global Qualifier standings so they now match the website. * Improved the display of multi-day events. * Resolved minor UI and layout issues in the race detail view. @@ -34,7 +70,6 @@ You can now pay for races in-app with PayPal or credit card in USD (only for rac ## 1.7.1 ### Fixes and enhancements: - * Fixed several UI issues related to GQ standings * Removed 2025 IO schedule quick access @@ -50,7 +85,7 @@ Pay for races directly in-app using PayPal or credit card in USD (available for * New Registrations Tab: View paid and unpaid pilot registrations in the race detail screen. * Adding a new tab to the race detail view, to see paid and unpaid registered pilots. -### Fixes and enhancements: +### Fixes and Enhancements: * Pinned View for Race Results: See your personal result in a pinned format, similar to the GQ Standings view. * Removed Aircraft support. Joining a race no longer requires selecting an aircraft beforehand! * The Standings tab is now selected by default. The app will also remember your last selected tab and open it on launch. @@ -62,18 +97,18 @@ Pay for races directly in-app using PayPal or credit card in USD (available for ## 1.6 -New Features: +### New Features: * Race Results: View race result standings directly on the race detail screen (when available). * Personalized Race Filters: Customize the race feed on the main screen to your preferences by filtering specific race classes, or yearly GQ races. * Updated Race Classes: Reflecting the latest MultiGP changes (Pro Spec, E-Sport, Micro, Whoop, etc.) * Races Quick Access: Display a race's ZippyQ web schedule as well as the LiveFPV or FPVScore pages for detailed live results. * Enhanced Visuals: Updated app icon and launch screen for a refreshed look. -For Chapter Organizers: +### For Chapter Organizers: * Simplified Race Creation: Streamlined the experience with a single description field and improved default values. * Finalize Races: You can now finalize a race directly from the race detail screen. -Hot Fixes: +### Fixes and Enhancements: * Show only upcoming races when filtering by class/spec for better relevance. * Removed duplicate managed chapters and sorted them alphabetically. * Prevented duplication of GQ races. diff --git a/RaceSync.xcodeproj/project.pbxproj b/RaceSync.xcodeproj/project.pbxproj index 3a4dbe0f..c685e0c3 100644 --- a/RaceSync.xcodeproj/project.pbxproj +++ b/RaceSync.xcodeproj/project.pbxproj @@ -27,12 +27,19 @@ 4F0B61432385EEFF00930D91 /* ViewModelHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0B61422385EEFF00930D91 /* ViewModelHelper.swift */; }; 4F0B61452385F66300930D91 /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0B61442385F66300930D91 /* ProfileHeaderView.swift */; }; 4F0B61472385F69E00930D91 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0B61462385F69E00930D91 /* ProfileViewModel.swift */; }; + 4F0F2F412E871E23006788A7 /* HTMLLinkTransform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0F2F402E871E23006788A7 /* HTMLLinkTransform.swift */; }; + 4F0F2F432E872FBA006788A7 /* MGPWebTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0F2F422E872FBA006788A7 /* MGPWebTests.swift */; }; + 4F0F2F442E87525D006788A7 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC05C142E64FFB700EB97A4 /* DeepLink.swift */; }; + 4F0F2F452E875264006788A7 /* DeepLinkURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC05C162E65004400EB97A4 /* DeepLinkURLHandler.swift */; }; + 4F0F2F472E87532F006788A7 /* DeepLinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0F2F462E87532F006788A7 /* DeepLinkTests.swift */; }; + 4F0F2F492E888100006788A7 /* SliderTableViewHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0F2F482E888100006788A7 /* SliderTableViewHeaderView.swift */; }; + 4F0F8F802EFB42FC004EB3D3 /* StandingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0F8F7F2EFB42FC004EB3D3 /* StandingsController.swift */; }; + 4F0F8F822F027A0F004EB3D3 /* StandingsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0F8F812F027A0F004EB3D3 /* StandingsListViewController.swift */; }; 4F1342482360B67D00A9DBDE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F1342472360B67D00A9DBDE /* AppDelegate.swift */; }; 4F13424D2360B67D00A9DBDE /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4F13424B2360B67D00A9DBDE /* Main.storyboard */; }; 4F1342522360B67D00A9DBDE /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4F1342502360B67D00A9DBDE /* LaunchScreen.storyboard */; }; 4F1342762360DA4C00A9DBDE /* RaceSyncAPI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F13426D2360DA4B00A9DBDE /* RaceSyncAPI.framework */; }; 4F13427D2360DA4C00A9DBDE /* RaceSyncAPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F13427C2360DA4C00A9DBDE /* RaceSyncAPITests.swift */; }; - 4F13427F2360DA4C00A9DBDE /* RaceSyncAPI.h in Headers */ = {isa = PBXBuildFile; fileRef = 4F13426F2360DA4B00A9DBDE /* RaceSyncAPI.h */; settings = {ATTRIBUTES = (Public, ); }; }; 4F1342822360DA4C00A9DBDE /* RaceSyncAPI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F13426D2360DA4B00A9DBDE /* RaceSyncAPI.framework */; }; 4F1342832360DA4C00A9DBDE /* RaceSyncAPI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 4F13426D2360DA4B00A9DBDE /* RaceSyncAPI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 4F13428C2360DAD100A9DBDE /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F13428B2360DAD100A9DBDE /* LoginViewController.swift */; }; @@ -43,6 +50,11 @@ 4F2724362515D88A000C7408 /* Chapter+UIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2724352515D88A000C7408 /* Chapter+UIExtensions.swift */; }; 4F27243B2517DB1C000C7408 /* CopyLinkActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F27243A2517DB1C000C7408 /* CopyLinkActivity.swift */; }; 4F2724402517DC5B000C7408 /* UIActivityViewController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F27243F2517DC5B000C7408 /* UIActivityViewController+Extensions.swift */; }; + 4F2726E12EE8C59E00001C86 /* UniversalSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2726E02EE8C59E00001C86 /* UniversalSearchViewController.swift */; }; + 4F2B7E872E86309300643697 /* SeriesApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2B7E862E86309300643697 /* SeriesApi.swift */; }; + 4F2B7E892E8635D200643697 /* SeriesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2B7E882E8635D200643697 /* SeriesViewModel.swift */; }; + 4F3750A92EF4C9510068FE16 /* RaceEditable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F3750A82EF4C9510068FE16 /* RaceEditable.swift */; }; + 4F3750AB2EF8B0BD0068FE16 /* TimeUtilTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F3750AA2EF8B0BD0068FE16 /* TimeUtilTests.swift */; }; 4F3D640D23FB253900DE6DF2 /* UICollectionView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F3D640C23FB253900DE6DF2 /* UICollectionView+Extensions.swift */; }; 4F3D640F23FC65F700DE6DF2 /* TextPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F3D640E23FC65F700DE6DF2 /* TextPickerViewController.swift */; }; 4F3D641123FE664A00DE6DF2 /* TextFieldViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F3D641023FE664A00DE6DF2 /* TextFieldViewController.swift */; }; @@ -130,6 +142,7 @@ 4F86690423877041005E310A /* ChapterTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F86690323877041005E310A /* ChapterTableViewCell.swift */; }; 4F8669062388D3BC005E310A /* AuthApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8669052388D3BC005E310A /* AuthApi.swift */; }; 4F87E4352727976E0061425B /* NetworkProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F87E4342727976E0061425B /* NetworkProxy.swift */; }; + 4F8AFAFA2E8F37D000088304 /* SeriesResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8AFAF92E8F37D000088304 /* SeriesResult.swift */; }; 4F8B4A0A2DDC7AEF00B735DA /* PushMessagesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8B4A092DDC7AEF00B735DA /* PushMessagesViewController.swift */; }; 4F8B4A0E2DDCECE900B735DA /* PushMessagesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8B4A0D2DDCECE900B735DA /* PushMessagesController.swift */; }; 4F8B4A102DDCEECD00B735DA /* BadgeHub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8B4A0F2DDCEECD00B735DA /* BadgeHub.swift */; }; @@ -142,6 +155,9 @@ 4F8F2C0E23846AEB00DC4907 /* DateUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8F2C0D23846AEB00DC4907 /* DateUtil.swift */; }; 4F8F2C112384903F00DC4907 /* RaceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8F2C102384903F00DC4907 /* RaceViewModel.swift */; }; 4F8F2C132385136300DC4907 /* NumberUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8F2C122385136300DC4907 /* NumberUtil.swift */; }; + 4F90F0CE2E8DD04100D9F5AF /* SeriesDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F90F0CD2E8DD04100D9F5AF /* SeriesDetailViewController.swift */; }; + 4F90F0D22E8DD56D00D9F5AF /* SeriesStandingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F90F0D12E8DD56D00D9F5AF /* SeriesStandingsViewController.swift */; }; + 4F90F0D42E8DD60700D9F5AF /* SeriesTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F90F0D32E8DD60700D9F5AF /* SeriesTabBarController.swift */; }; 4F91BC8A2398FBCE00539E95 /* ViewJoinable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F91BC892398FBCE00539E95 /* ViewJoinable.swift */; }; 4F9979BE26D57CFF00496451 /* AppIcon-KRU@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 4F9979BA26D57CFF00496451 /* AppIcon-KRU@2x.png */; }; 4F9979BF26D57CFF00496451 /* AppIcon-KRU@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 4F9979BB26D57CFF00496451 /* AppIcon-KRU@3x.png */; }; @@ -180,7 +196,9 @@ 4FA26831237A58E1008970AC /* ApiError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA26830237A58E1008970AC /* ApiError.swift */; }; 4FA26833237A5983008970AC /* ObjectMapper+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA26832237A5983008970AC /* ObjectMapper+Extensions.swift */; }; 4FA26835237A80B9008970AC /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA26834237A80B9008970AC /* ActionButton.swift */; }; + 4FA706072EC33873006EAE70 /* RaceSyncAPI.h in Headers */ = {isa = PBXBuildFile; fileRef = 4F13426F2360DA4B00A9DBDE /* RaceSyncAPI.h */; settings = {ATTRIBUTES = (Public, ); }; }; 4FAAA88523AA15EA00A004DC /* MapKit+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAAA88423AA15EA00A004DC /* MapKit+Extensions.swift */; }; + 4FAAF8242E80FFF5002CF62E /* Series.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAAF8232E80FFF5002CF62E /* Series.swift */; }; 4FAC24A823DC3E06009AD585 /* UILabel+LinesCount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAC24A723DC3E06009AD585 /* UILabel+LinesCount.swift */; }; 4FB06AB3296B521000F14E59 /* AppIcon-MGP1@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 4FB06AAF296B521000F14E59 /* AppIcon-MGP1@3x.png */; }; 4FB06AB4296B521000F14E59 /* AppIcon-MGP2@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 4FB06AB0296B521000F14E59 /* AppIcon-MGP2@2x.png */; }; @@ -190,8 +208,6 @@ 4FBADDF624D4E2DB00A7D291 /* Array+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FBADDF524D4E2DB00A7D291 /* Array+Extensions.swift */; }; 4FBE7608259B348200312B66 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4F13424E2360B67D00A9DBDE /* Assets.xcassets */; }; 4FBE760F259B387E00312B66 /* User+UIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FBE760E259B387E00312B66 /* User+UIExtensions.swift */; }; - 4FC05C152E64FFB800EB97A4 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC05C142E64FFB700EB97A4 /* DeepLink.swift */; }; - 4FC05C172E65004500EB97A4 /* DeepLinkURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC05C162E65004400EB97A4 /* DeepLinkURLHandler.swift */; }; 4FC1797D24B0801900D2EA2D /* ImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC1797C24B0801900D2EA2D /* ImagePickerController.swift */; }; 4FC3F17725A7AB3B0039B94C /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC3F17625A7AB3B0039B94C /* String+Extensions.swift */; }; 4FC4C59C24D8F3F2007D3B71 /* DimmableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC4C59B24D8F3F2007D3B71 /* DimmableView.swift */; }; @@ -211,6 +227,7 @@ 4FCAF9FE23AC8B8400ACE7D3 /* Shimmable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCAF9FD23AC8B8400ACE7D3 /* Shimmable.swift */; }; 4FCD99B62976242A0094C562 /* RaceFeedMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCD99B52976242A0094C562 /* RaceFeedMenuViewController.swift */; }; 4FCD99B829788F720094C562 /* AppWebConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCD99B729788F720094C562 /* AppWebConstants.swift */; }; + 4FCEAB442F0CD81F00CAE0BD /* UIApplication+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCEAB432F0CD81F00CAE0BD /* UIApplication+Extensions.swift */; }; 4FD4E1BC237DCAA3008816B3 /* UserApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD4E1BB237DCAA3008816B3 /* UserApi.swift */; }; 4FD4E1BE237DCB24008816B3 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD4E1BD237DCB24008816B3 /* User.swift */; }; 4FD4E1C1237DDFB4008816B3 /* RaceFeedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD4E1C0237DDFB4008816B3 /* RaceFeedViewController.swift */; }; @@ -220,7 +237,7 @@ 4FD4E1C9237F987C008816B3 /* RacePilotsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD4E1C8237F987C008816B3 /* RacePilotsViewController.swift */; }; 4FD4E1CB237F98A0008816B3 /* RaceDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD4E1CA237F98A0008816B3 /* RaceDetailViewController.swift */; }; 4FD4E1CD237F98E7008816B3 /* RaceTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD4E1CC237F98E7008816B3 /* RaceTabBarController.swift */; }; - 4FD4E1D1237F9DC8008816B3 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD4E1D0237F9DC8008816B3 /* SearchViewController.swift */; }; + 4FD4E1D1237F9DC8008816B3 /* DummyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD4E1D0237F9DC8008816B3 /* DummyViewController.swift */; }; 4FD4E1D9237FB1EA008816B3 /* RaceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD4E1D8237FB1EA008816B3 /* RaceTableViewCell.swift */; }; 4FD4E1DB237FBE36008816B3 /* JoinButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD4E1DA237FBE36008816B3 /* JoinButton.swift */; }; 4FD4E1DD237FBE8B008816B3 /* MemberBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD4E1DC237FBE8B008816B3 /* MemberBadgeView.swift */; }; @@ -292,16 +309,6 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; - 4F95B7682596968200631A27 /* Embed Watch Content */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; - dstSubfolderSpec = 16; - files = ( - ); - name = "Embed Watch Content"; - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -330,6 +337,12 @@ 4F0B61422385EEFF00930D91 /* ViewModelHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelHelper.swift; sourceTree = ""; }; 4F0B61442385F66300930D91 /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = ""; }; 4F0B61462385F69E00930D91 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; + 4F0F2F402E871E23006788A7 /* HTMLLinkTransform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLLinkTransform.swift; sourceTree = ""; }; + 4F0F2F422E872FBA006788A7 /* MGPWebTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MGPWebTests.swift; sourceTree = ""; }; + 4F0F2F462E87532F006788A7 /* DeepLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkTests.swift; sourceTree = ""; }; + 4F0F2F482E888100006788A7 /* SliderTableViewHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderTableViewHeaderView.swift; sourceTree = ""; }; + 4F0F8F7F2EFB42FC004EB3D3 /* StandingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandingsController.swift; sourceTree = ""; }; + 4F0F8F812F027A0F004EB3D3 /* StandingsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandingsListViewController.swift; sourceTree = ""; }; 4F1342442360B67D00A9DBDE /* RaceSync.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RaceSync.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4F1342472360B67D00A9DBDE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 4F13424C2360B67D00A9DBDE /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; @@ -350,6 +363,11 @@ 4F2724352515D88A000C7408 /* Chapter+UIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Chapter+UIExtensions.swift"; sourceTree = ""; }; 4F27243A2517DB1C000C7408 /* CopyLinkActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyLinkActivity.swift; sourceTree = ""; }; 4F27243F2517DC5B000C7408 /* UIActivityViewController+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivityViewController+Extensions.swift"; sourceTree = ""; }; + 4F2726E02EE8C59E00001C86 /* UniversalSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalSearchViewController.swift; sourceTree = ""; }; + 4F2B7E862E86309300643697 /* SeriesApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesApi.swift; sourceTree = ""; }; + 4F2B7E882E8635D200643697 /* SeriesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesViewModel.swift; sourceTree = ""; }; + 4F3750A82EF4C9510068FE16 /* RaceEditable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaceEditable.swift; sourceTree = ""; }; + 4F3750AA2EF8B0BD0068FE16 /* TimeUtilTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeUtilTests.swift; sourceTree = ""; }; 4F3D640C23FB253900DE6DF2 /* UICollectionView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICollectionView+Extensions.swift"; sourceTree = ""; }; 4F3D640E23FC65F700DE6DF2 /* TextPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextPickerViewController.swift; sourceTree = ""; }; 4F3D641023FE664A00DE6DF2 /* TextFieldViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldViewController.swift; sourceTree = ""; }; @@ -438,6 +456,7 @@ 4F86690323877041005E310A /* ChapterTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterTableViewCell.swift; sourceTree = ""; }; 4F8669052388D3BC005E310A /* AuthApi.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthApi.swift; sourceTree = ""; }; 4F87E4342727976E0061425B /* NetworkProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProxy.swift; sourceTree = ""; }; + 4F8AFAF92E8F37D000088304 /* SeriesResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesResult.swift; sourceTree = ""; }; 4F8B4A092DDC7AEF00B735DA /* PushMessagesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushMessagesViewController.swift; sourceTree = ""; }; 4F8B4A0D2DDCECE900B735DA /* PushMessagesController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushMessagesController.swift; sourceTree = ""; }; 4F8B4A0F2DDCEECD00B735DA /* BadgeHub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeHub.swift; sourceTree = ""; }; @@ -450,6 +469,9 @@ 4F8F2C0D23846AEB00DC4907 /* DateUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateUtil.swift; sourceTree = ""; }; 4F8F2C102384903F00DC4907 /* RaceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaceViewModel.swift; sourceTree = ""; }; 4F8F2C122385136300DC4907 /* NumberUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberUtil.swift; sourceTree = ""; }; + 4F90F0CD2E8DD04100D9F5AF /* SeriesDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesDetailViewController.swift; sourceTree = ""; }; + 4F90F0D12E8DD56D00D9F5AF /* SeriesStandingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesStandingsViewController.swift; sourceTree = ""; }; + 4F90F0D32E8DD60700D9F5AF /* SeriesTabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesTabBarController.swift; sourceTree = ""; }; 4F91BC892398FBCE00539E95 /* ViewJoinable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewJoinable.swift; sourceTree = ""; }; 4F9979BA26D57CFF00496451 /* AppIcon-KRU@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon-KRU@2x.png"; sourceTree = ""; }; 4F9979BB26D57CFF00496451 /* AppIcon-KRU@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon-KRU@3x.png"; sourceTree = ""; }; @@ -492,6 +514,7 @@ 4FA26832237A5983008970AC /* ObjectMapper+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ObjectMapper+Extensions.swift"; sourceTree = ""; }; 4FA26834237A80B9008970AC /* ActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; 4FAAA88423AA15EA00A004DC /* MapKit+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MapKit+Extensions.swift"; sourceTree = ""; }; + 4FAAF8232E80FFF5002CF62E /* Series.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Series.swift; sourceTree = ""; }; 4FAC24A723DC3E06009AD585 /* UILabel+LinesCount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+LinesCount.swift"; sourceTree = ""; }; 4FB06AAF296B521000F14E59 /* AppIcon-MGP1@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon-MGP1@3x.png"; sourceTree = ""; }; 4FB06AB0296B521000F14E59 /* AppIcon-MGP2@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon-MGP2@2x.png"; sourceTree = ""; }; @@ -521,6 +544,7 @@ 4FCAF9FD23AC8B8400ACE7D3 /* Shimmable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shimmable.swift; sourceTree = ""; }; 4FCD99B52976242A0094C562 /* RaceFeedMenuViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaceFeedMenuViewController.swift; sourceTree = ""; }; 4FCD99B729788F720094C562 /* AppWebConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppWebConstants.swift; sourceTree = ""; }; + 4FCEAB432F0CD81F00CAE0BD /* UIApplication+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Extensions.swift"; sourceTree = ""; }; 4FD4E1BB237DCAA3008816B3 /* UserApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserApi.swift; sourceTree = ""; }; 4FD4E1BD237DCB24008816B3 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; 4FD4E1C0237DDFB4008816B3 /* RaceFeedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaceFeedViewController.swift; sourceTree = ""; }; @@ -530,7 +554,7 @@ 4FD4E1C8237F987C008816B3 /* RacePilotsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RacePilotsViewController.swift; sourceTree = ""; }; 4FD4E1CA237F98A0008816B3 /* RaceDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaceDetailViewController.swift; sourceTree = ""; }; 4FD4E1CC237F98E7008816B3 /* RaceTabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaceTabBarController.swift; sourceTree = ""; }; - 4FD4E1D0237F9DC8008816B3 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; + 4FD4E1D0237F9DC8008816B3 /* DummyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DummyViewController.swift; sourceTree = ""; }; 4FD4E1D8237FB1EA008816B3 /* RaceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaceTableViewCell.swift; sourceTree = ""; }; 4FD4E1DA237FBE36008816B3 /* JoinButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinButton.swift; sourceTree = ""; }; 4FD4E1DC237FBE8B008816B3 /* MemberBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberBadgeView.swift; sourceTree = ""; }; @@ -639,6 +663,7 @@ 4F5488972D2236950056EA59 /* String+HTML.swift */, 4F73DAE92DEE7300007B15F2 /* String+UIExtensions.swift */, 4FF16CBB2E6F838E00B4FC51 /* NotificationCenter+Extensions.swift */, + 4FCEAB432F0CD81F00CAE0BD /* UIApplication+Extensions.swift */, ); path = "UI Extensions"; sourceTree = ""; @@ -729,6 +754,9 @@ 4F13427C2360DA4C00A9DBDE /* RaceSyncAPITests.swift */, 4FEBB27923A2DAFF007514B4 /* DescriptableTests.swift */, 4F9A888C2974AAD400B52092 /* ParametersTests.swift */, + 4F0F2F462E87532F006788A7 /* DeepLinkTests.swift */, + 4F3750AA2EF8B0BD0068FE16 /* TimeUtilTests.swift */, + 4F0F2F422E872FBA006788A7 /* MGPWebTests.swift */, 4F13427E2360DA4C00A9DBDE /* Info.plist */, ); path = RaceSyncAPITests; @@ -794,6 +822,9 @@ isa = PBXGroup; children = ( 4F521F812E6E18D800AE7C03 /* SeriesViewController.swift */, + 4F90F0D32E8DD60700D9F5AF /* SeriesTabBarController.swift */, + 4F90F0CD2E8DD04100D9F5AF /* SeriesDetailViewController.swift */, + 4F90F0D12E8DD56D00D9F5AF /* SeriesStandingsViewController.swift */, ); path = Series; sourceTree = ""; @@ -925,6 +956,7 @@ isa = PBXGroup; children = ( 4F8F2C102384903F00DC4907 /* RaceViewModel.swift */, + 4F2B7E882E8635D200643697 /* SeriesViewModel.swift */, 4F86690123876E71005E310A /* ChapterViewModel.swift */, 4F0B61462385F69E00930D91 /* ProfileViewModel.swift */, 4FD53FAC23A0B01E00158206 /* UserViewModel.swift */, @@ -940,8 +972,6 @@ children = ( 4FA1FC8A23D6D7C0006D4704 /* ApplicationControl.swift */, 4F6FA16D2E4C66940056E115 /* AppplicationPreferences.swift */, - 4FC05C162E65004400EB97A4 /* DeepLinkURLHandler.swift */, - 4FC05C142E64FFB700EB97A4 /* DeepLink.swift */, 4FEADB732416B3D400F82F0D /* EventTracker.swift */, 4F00E065242930B3001DCFC4 /* RateMe */, ); @@ -963,6 +993,7 @@ 4F8669052388D3BC005E310A /* AuthApi.swift */, 4FD4E1BB237DCAA3008816B3 /* UserApi.swift */, 4F8F2C0B2383DF1E00DC4907 /* RaceApi.swift */, + 4F2B7E862E86309300643697 /* SeriesApi.swift */, 4F8668F923868359005E310A /* ChapterApi.swift */, 4F004E9B295A3027009C46AA /* SeasonApi.swift */, 4F004E9D295A3066009C46AA /* CourseApi.swift */, @@ -989,6 +1020,8 @@ 4FA26828237A4CCE008970AC /* Utils */ = { isa = PBXGroup; children = ( + 4FC05C142E64FFB700EB97A4 /* DeepLink.swift */, + 4FC05C162E65004400EB97A4 /* DeepLinkURLHandler.swift */, 4F8B8664295C112B00EAF695 /* EnumTitle.swift */, 4F00E077242963F1001DCFC4 /* Joinable.swift */, 4F8668F723864389005E310A /* Descriptable.swift */, @@ -1001,6 +1034,7 @@ 4F8F2C122385136300DC4907 /* NumberUtil.swift */, 4FCAF9F923AB226500ACE7D3 /* MapperUtil.swift */, 4FA26832237A5983008970AC /* ObjectMapper+Extensions.swift */, + 4F0F2F402E871E23006788A7 /* HTMLLinkTransform.swift */, 4FA213FA29582CCB00C8E45A /* IntegerTransform.swift */, 4F6503372E45CE4900FA8AED /* FloatTransform.swift */, 4F1DC111297E42BA005EDAE0 /* BooleanTransform.swift */, @@ -1019,6 +1053,8 @@ 4FEBB27723A2023A007514B4 /* RaceEntry.swift */, 4F65033B2E45CEBA00FA8AED /* RacePayment.swift */, 4F6A28E02D13F38000FF692D /* ResultEntry.swift */, + 4FAAF8232E80FFF5002CF62E /* Series.swift */, + 4F8AFAF92E8F37D000088304 /* SeriesResult.swift */, 4F8668F523861736005E310A /* Chapter.swift */, 4FC51B1E2409EA9B00D654D0 /* ManagedChapter.swift */, 4FDD499523B0461D009DD2DB /* Season.swift */, @@ -1033,7 +1069,8 @@ 4FD4E1D2237F9DEF008816B3 /* Search */ = { isa = PBXGroup; children = ( - 4FD4E1D0237F9DC8008816B3 /* SearchViewController.swift */, + 4F2726E02EE8C59E00001C86 /* UniversalSearchViewController.swift */, + 4FD4E1D0237F9DC8008816B3 /* DummyViewController.swift */, ); path = Search; sourceTree = ""; @@ -1047,7 +1084,6 @@ 4FE4C0A12945E0EF00F47F0A /* RaceListViewController.swift */, 4F6CAD8C2983040100E80B5F /* ChapterPickerViewController.swift */, 4FD4E1CC237F98E7008816B3 /* RaceTabBarController.swift */, - 4FF16CC12E79FF0300B4FC51 /* RaceController.swift */, 4FD4E1CA237F98A0008816B3 /* RaceDetailViewController.swift */, 4FD4E1C8237F987C008816B3 /* RacePilotsViewController.swift */, 4F5488952D2146C80056EA59 /* RaceScheduleViewController.swift */, @@ -1056,6 +1092,8 @@ 4F004E9F295AA641009C46AA /* RaceFormViewController.swift */, 4F004EA6295B83F6009C46AA /* RaceForm.swift */, 4FC502592428710D0088320B /* RaceTabbable.swift */, + 4FF16CC12E79FF0300B4FC51 /* RaceController.swift */, + 4F3750A82EF4C9510068FE16 /* RaceEditable.swift */, ); path = Races; sourceTree = ""; @@ -1106,6 +1144,7 @@ 4FD53FB223A0E9B700158206 /* AvatarImageView.swift */, 4F8668FB23870E1E005E310A /* SegmentedTableViewHeaderView.swift */, 4F65033D2E467EFB00FA8AED /* ColumnTableViewHeaderView.swift */, + 4F0F2F482E888100006788A7 /* SliderTableViewHeaderView.swift */, 4FD4E1DA237FBE36008816B3 /* JoinButton.swift */, 4FA26834237A80B9008970AC /* ActionButton.swift */, 4FD4E1C2237F960A008816B3 /* CustomButton.swift */, @@ -1144,7 +1183,9 @@ 4FE1DAD92DEB6AE4009143C4 /* Standings */ = { isa = PBXGroup; children = ( + 4F0F8F812F027A0F004EB3D3 /* StandingsListViewController.swift */, 4F8175A02411C35A00685F83 /* StandingsViewController.swift */, + 4F0F8F7F2EFB42FC004EB3D3 /* StandingsController.swift */, 4FE1DADE2DEB9EEF009143C4 /* StandingViewModel.swift */, 4F3E2D052DEE52AE00A6646C /* StandingBadgeView.swift */, 4F3E2D032DEE4E8500A6646C /* StandingBadgeView.xib */, @@ -1198,7 +1239,7 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( - 4F13427F2360DA4C00A9DBDE /* RaceSyncAPI.h in Headers */, + 4FA706072EC33873006EAE70 /* RaceSyncAPI.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1211,7 +1252,6 @@ buildPhases = ( 9096BCDFA897EAE64E1192AF /* [CP] Check Pods Manifest.lock */, 4F1342872360DA4C00A9DBDE /* Embed Frameworks */, - 4F95B7682596968200631A27 /* Embed Watch Content */, 4F1342402360B67D00A9DBDE /* Sources */, 4F1342412360B67D00A9DBDE /* Frameworks */, 4F1342422360B67D00A9DBDE /* Resources */, @@ -1480,8 +1520,7 @@ 4FC5025A2428710D0088320B /* RaceTabbable.swift in Sources */, 4F00E072242932D7001DCFC4 /* RateMe.swift in Sources */, 4F71B32B2E4910100029D3CB /* Collection+Extensions.swift in Sources */, - 4FD4E1D1237F9DC8008816B3 /* SearchViewController.swift in Sources */, - 4FC05C152E64FFB800EB97A4 /* DeepLink.swift in Sources */, + 4FD4E1D1237F9DC8008816B3 /* DummyViewController.swift in Sources */, 4F5488962D2146C80056EA59 /* RaceScheduleViewController.swift in Sources */, 4F8668F423860900005E310A /* UniversalConstants.swift in Sources */, 4FF16CC02E70EC9300B4FC51 /* ViewJoinableRegistry.swift in Sources */, @@ -1491,6 +1530,7 @@ 4F0B61472385F69E00930D91 /* ProfileViewModel.swift in Sources */, 4F65033A2E45CE5900FA8AED /* RacePaymentsViewController.swift in Sources */, 4FE1DADF2DEB9EEF009143C4 /* StandingViewModel.swift in Sources */, + 4F0F2F492E888100006788A7 /* SliderTableViewHeaderView.swift in Sources */, 4F86690223876E71005E310A /* ChapterViewModel.swift in Sources */, 4F8F2C112384903F00DC4907 /* RaceViewModel.swift in Sources */, 4FF16CBE2E70EC7D00B4FC51 /* WeakRef.swift in Sources */, @@ -1522,8 +1562,10 @@ 4FEBB27623A1F9DA007514B4 /* UITabBarController+Extensions.swift in Sources */, 4FD4E1C3237F960A008816B3 /* CustomButton.swift in Sources */, 4F5C1E3E238A770B00D756EB /* UITableViewCell+Reuse.swift in Sources */, + 4F2B7E892E8635D200643697 /* SeriesViewModel.swift in Sources */, 4FA1FC8B23D6D7C0006D4704 /* ApplicationControl.swift in Sources */, 4F714DE123CD4906000C4036 /* CalendarActivity.swift in Sources */, + 4FCEAB442F0CD81F00CAE0BD /* UIApplication+Extensions.swift in Sources */, 4F714DE323CD5EAA000C4036 /* SafariActivity.swift in Sources */, 4F004EA0295AA641009C46AA /* RaceFormViewController.swift in Sources */, 4F6CAD8D2983040100E80B5F /* ChapterPickerViewController.swift in Sources */, @@ -1540,6 +1582,7 @@ 4FD4E1DD237FBE8B008816B3 /* MemberBadgeView.swift in Sources */, 4FC50258242700DC0088320B /* RacePilotsPickerController.swift in Sources */, 4FD4E1DB237FBE36008816B3 /* JoinButton.swift in Sources */, + 4F90F0D42E8DD60700D9F5AF /* SeriesTabBarController.swift in Sources */, 4F7C8E3724D8AE16008FE125 /* ProfileAvatarView.swift in Sources */, 4F9DB79923D2D94E00570483 /* ExternalAppConstants.swift in Sources */, 4F482EFB251158FF00DBA0DF /* SocialActivity.swift in Sources */, @@ -1548,6 +1591,7 @@ 4FD4E1D9237FB1EA008816B3 /* RaceTableViewCell.swift in Sources */, 4F5C1E4F2390B01700D756EB /* ImageNetworking.swift in Sources */, 4FD4E1CB237F98A0008816B3 /* RaceDetailViewController.swift in Sources */, + 4F0F8F802EFB42FC004EB3D3 /* StandingsController.swift in Sources */, 4FD4E1C1237DDFB4008816B3 /* RaceFeedViewController.swift in Sources */, 4FE272362580A36D00EC121B /* SimpleTableViewCell.swift in Sources */, 4FCD99B62976242A0094C562 /* RaceFeedMenuViewController.swift in Sources */, @@ -1555,6 +1599,7 @@ 4F6C8A96236A9D580017FD78 /* UIColor+Extensions.swift in Sources */, 4F2724402517DC5B000C7408 /* UIActivityViewController+Extensions.swift in Sources */, 4FA1AB3D23C94F5F007CF389 /* UIEdgeInsets+Extensions.swift in Sources */, + 4F3750A92EF4C9510068FE16 /* RaceEditable.swift in Sources */, 4F6429122402DB00005E8036 /* ImageConstants.swift in Sources */, 4FD4E1C5237F9746008816B3 /* UserViewController.swift in Sources */, 4F6FA16E2E4C66940056E115 /* AppplicationPreferences.swift in Sources */, @@ -1581,12 +1626,13 @@ 4F9A888F2975E6D700B52092 /* Race+UIExtensions.swift in Sources */, 4F8668FC23870E1E005E310A /* SegmentedTableViewHeaderView.swift in Sources */, 4FBE760F259B387E00312B66 /* User+UIExtensions.swift in Sources */, - 4FC05C172E65004500EB97A4 /* DeepLinkURLHandler.swift in Sources */, 4F8175A12411C35A00685F83 /* StandingsViewController.swift in Sources */, + 4F90F0D22E8DD56D00D9F5AF /* SeriesStandingsViewController.swift in Sources */, 4F8B866C296583EB00EAF695 /* TextEditorViewController.swift in Sources */, 4FB0EB6A23BB3B1F009ABAF0 /* FormTableViewCell.swift in Sources */, 4FD4E1CD237F98E7008816B3 /* RaceTabBarController.swift in Sources */, 4FCAF9FE23AC8B8400ACE7D3 /* Shimmable.swift in Sources */, + 4F0F8F822F027A0F004EB3D3 /* StandingsListViewController.swift in Sources */, 4F4129DB2DC0AE32006A2763 /* NotificationsController.swift in Sources */, 4F9A88912975F30C00B52092 /* Season+UIExtensions.swift in Sources */, 4FCD99B829788F720094C562 /* AppWebConstants.swift in Sources */, @@ -1597,8 +1643,10 @@ 4FAC24A823DC3E06009AD585 /* UILabel+LinesCount.swift in Sources */, 4FE1DADD2DEB6F6D009143C4 /* HomeTabBarController.swift in Sources */, 4F5488982D2236950056EA59 /* String+HTML.swift in Sources */, + 4F2726E12EE8C59E00001C86 /* UniversalSearchViewController.swift in Sources */, 4F5001E523823C940025A593 /* FlagEmojiGenerator.swift in Sources */, 4F5C1E4B238EEC5200D756EB /* MapViewController.swift in Sources */, + 4F90F0CE2E8DD04100D9F5AF /* SeriesDetailViewController.swift in Sources */, 4FAAA88523AA15EA00A004DC /* MapKit+Extensions.swift in Sources */, 4F5C1E43238CF6BD00D756EB /* ProfileViewController.swift in Sources */, 4F8175A32411C39C00685F83 /* RaceFeedController.swift in Sources */, @@ -1630,6 +1678,7 @@ buildActionMask = 2147483647; files = ( 4F004E9A295A2CA1009C46AA /* Course.swift in Sources */, + 4F8AFAFA2E8F37D000088304 /* SeriesResult.swift in Sources */, 4F1DC112297E42BA005EDAE0 /* BooleanTransform.swift in Sources */, 4FCAF9FA23AB226500ACE7D3 /* MapperUtil.swift in Sources */, 4F8669062388D3BC005E310A /* AuthApi.swift in Sources */, @@ -1641,8 +1690,10 @@ 4FEBB27F23A310BB007514B4 /* User+Extensions.swift in Sources */, 4FDD499A23B065C9009DD2DB /* RaceEnums.swift in Sources */, 4F9DB79723D2B01C00570483 /* MGPWebConstants.swift in Sources */, + 4F0F2F452E875264006788A7 /* DeepLinkURLHandler.swift in Sources */, 4F5001E723827DE70025A593 /* ImageUtil.swift in Sources */, 4FA268222378F8A0008970AC /* APIServices.swift in Sources */, + 4F2B7E872E86309300643697 /* SeriesApi.swift in Sources */, 4F8668F623861736005E310A /* Chapter.swift in Sources */, 4F65033C2E45CEBA00FA8AED /* RacePayment.swift in Sources */, 4FC86BA32D274B8C004297E7 /* APIRaceFilters.swift in Sources */, @@ -1656,9 +1707,11 @@ 4F81759C241192AF00685F83 /* Clog.swift in Sources */, 4FEBB27823A2023A007514B4 /* RaceEntry.swift in Sources */, 4F87E4352727976E0061425B /* NetworkProxy.swift in Sources */, + 4F0F2F412E871E23006788A7 /* HTMLLinkTransform.swift in Sources */, 4FD4E1E32381E175008816B3 /* Random.swift in Sources */, 4FE1DAD82DEAD443009143C4 /* StandingApi.swift in Sources */, 4FEADB7224147D2D00F82F0D /* LocationManager.swift in Sources */, + 4FAAF8242E80FFF5002CF62E /* Series.swift in Sources */, 4F8B8665295C112B00EAF695 /* EnumTitle.swift in Sources */, 4F8F2C0E23846AEB00DC4907 /* DateUtil.swift in Sources */, 4FA2682F237A5897008970AC /* ErrorUtil.swift in Sources */, @@ -1671,6 +1724,7 @@ 4FC51B212409EC8E00D654D0 /* Chapter+Extensions.swift in Sources */, 4FC51B232409EEB400D654D0 /* Race+Extensions.swift in Sources */, 4F9DB7AE23D614CF00570483 /* APISettings.swift in Sources */, + 4F0F2F442E87525D006788A7 /* DeepLink.swift in Sources */, 4FA213FC29582D1B00C8E45A /* IntegerTransform.swift in Sources */, 4F7974B524A4A4D4003D623A /* ImageEnums.swift in Sources */, 4F9A886B297473AF00B52092 /* Parameters+Extensions.swift in Sources */, @@ -1701,7 +1755,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4F3750AB2EF8B0BD0068FE16 /* TimeUtilTests.swift in Sources */, 4F13427D2360DA4C00A9DBDE /* RaceSyncAPITests.swift in Sources */, + 4F0F2F472E87532F006788A7 /* DeepLinkTests.swift in Sources */, + 4F0F2F432E872FBA006788A7 /* MGPWebTests.swift in Sources */, 4FEBB27A23A2DAFF007514B4 /* DescriptableTests.swift in Sources */, 4F9A888D2974AAD400B52092 /* ParametersTests.swift in Sources */, ); @@ -1876,8 +1933,8 @@ CODE_SIGN_ENTITLEMENTS = RaceSync/RaceSync.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 82; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + CURRENT_PROJECT_VERSION = 89; + DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = TJ4PB66YQS; GCC_WARN_INHIBIT_ALL_WARNINGS = NO; INFOPLIST_FILE = RaceSync/Info.plist; @@ -1887,7 +1944,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.8; + MARKETING_VERSION = 1.8.2; PRODUCT_BUNDLE_IDENTIFIER = com.multigp.RaceSyncApp; PRODUCT_NAME = RaceSync; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1913,7 +1970,7 @@ CODE_SIGN_ENTITLEMENTS = RaceSync/RaceSync.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 89; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = TJ4PB66YQS; GCC_WARN_INHIBIT_ALL_WARNINGS = NO; @@ -1924,7 +1981,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.8; + MARKETING_VERSION = 1.8.2; PRODUCT_BUNDLE_IDENTIFIER = com.multigp.RaceSyncApp; PRODUCT_NAME = RaceSync; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1946,13 +2003,15 @@ CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEAD_CODE_STRIPPING = NO; + DEBUG_INFORMATION_FORMAT = dwarf; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = TJ4PB66YQS; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = YES; + ENABLE_MODULE_VERIFIER = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; INFOPLIST_FILE = RaceSyncAPI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -1964,9 +2023,11 @@ MARKETING_VERSION = 1.5; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; + OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS"; PRODUCT_BUNDLE_IDENTIFIER = com.multigp.RaceSyncAPI; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -1987,6 +2048,7 @@ CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = TJ4PB66YQS; @@ -1994,6 +2056,7 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; INFOPLIST_FILE = RaceSyncAPI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -2005,9 +2068,11 @@ MARKETING_VERSION = 1.5; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; + OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS"; PRODUCT_BUNDLE_IDENTIFIER = com.multigp.RaceSyncAPI; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; diff --git a/RaceSync.xcodeproj/xcshareddata/xcschemes/RaceSync [Prod].xcscheme b/RaceSync.xcodeproj/xcshareddata/xcschemes/RaceSync [Prod].xcscheme index 9c7b95b5..a7ee2edb 100644 --- a/RaceSync.xcodeproj/xcshareddata/xcschemes/RaceSync [Prod].xcscheme +++ b/RaceSync.xcodeproj/xcshareddata/xcschemes/RaceSync [Prod].xcscheme @@ -28,16 +28,6 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> - - - - @@ -81,6 +73,11 @@ value = "YES" isEnabled = "YES"> + + + enableGPUFrameCaptureMode = "3" + enableGPUValidationMode = "1" + allowLocationSimulation = "NO"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/RaceSync/Assets.xcassets/icn_activity_paypal.imageset/icn_activity_paypal.pdf b/RaceSync/Assets.xcassets/icn_activity_paypal.imageset/icn_activity_paypal.pdf index 9a3ddd10..0c90864b 100644 Binary files a/RaceSync/Assets.xcassets/icn_activity_paypal.imageset/icn_activity_paypal.pdf and b/RaceSync/Assets.xcassets/icn_activity_paypal.imageset/icn_activity_paypal.pdf differ diff --git a/RaceSync/Assets.xcassets/icn_activity_safari.imageset/icn_safari_activity.pdf b/RaceSync/Assets.xcassets/icn_activity_safari.imageset/icn_safari_activity.pdf index 89a96fd6..63fb6530 100644 Binary files a/RaceSync/Assets.xcassets/icn_activity_safari.imageset/icn_safari_activity.pdf and b/RaceSync/Assets.xcassets/icn_activity_safari.imageset/icn_safari_activity.pdf differ diff --git a/RaceSync/Assets.xcassets/icn_badge.imageset/Contents.json b/RaceSync/Assets.xcassets/icn_badge.imageset/Contents.json deleted file mode 100644 index a00696a9..00000000 --- a/RaceSync/Assets.xcassets/icn_badge.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "icn_badge.pdf" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/RaceSync/Assets.xcassets/icn_button_join.imageset/Contents.json b/RaceSync/Assets.xcassets/icn_badge_small.imageset/Contents.json similarity index 74% rename from RaceSync/Assets.xcassets/icn_button_join.imageset/Contents.json rename to RaceSync/Assets.xcassets/icn_badge_small.imageset/Contents.json index 80072e34..6310a568 100644 --- a/RaceSync/Assets.xcassets/icn_button_join.imageset/Contents.json +++ b/RaceSync/Assets.xcassets/icn_badge_small.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "icn_button_join.pdf", + "filename" : "icn_badge_small.pdf", "idiom" : "universal" } ], diff --git a/RaceSync/Assets.xcassets/icn_badge.imageset/icn_badge.pdf b/RaceSync/Assets.xcassets/icn_badge_small.imageset/icn_badge_small.pdf similarity index 100% rename from RaceSync/Assets.xcassets/icn_badge.imageset/icn_badge.pdf rename to RaceSync/Assets.xcassets/icn_badge_small.imageset/icn_badge_small.pdf diff --git a/RaceSync/Assets.xcassets/icn_button_closed.imageset/Contents.json b/RaceSync/Assets.xcassets/icn_button_closed.imageset/Contents.json deleted file mode 100644 index 3a908d60..00000000 --- a/RaceSync/Assets.xcassets/icn_button_closed.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "icn_button_closed.pdf" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/RaceSync/Assets.xcassets/icn_calendar_end_small.imageset/icn_calendar_end_small.pdf b/RaceSync/Assets.xcassets/icn_calendar_end_small.imageset/icn_calendar_end_small.pdf deleted file mode 100644 index 928756a2..00000000 Binary files a/RaceSync/Assets.xcassets/icn_calendar_end_small.imageset/icn_calendar_end_small.pdf and /dev/null differ diff --git a/RaceSync/Assets.xcassets/icn_calendar_end_small.imageset/Contents.json b/RaceSync/Assets.xcassets/icn_calendar_small.imageset/Contents.json similarity index 71% rename from RaceSync/Assets.xcassets/icn_calendar_end_small.imageset/Contents.json rename to RaceSync/Assets.xcassets/icn_calendar_small.imageset/Contents.json index 7109f865..e8e2f6bd 100644 --- a/RaceSync/Assets.xcassets/icn_calendar_end_small.imageset/Contents.json +++ b/RaceSync/Assets.xcassets/icn_calendar_small.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "icn_calendar_end_small.pdf", + "filename" : "icn_calendar_small.pdf", "idiom" : "universal" } ], diff --git a/RaceSync/Assets.xcassets/icn_calendar_small.imageset/icn_calendar_small.pdf b/RaceSync/Assets.xcassets/icn_calendar_small.imageset/icn_calendar_small.pdf new file mode 100644 index 00000000..c1d0d974 Binary files /dev/null and b/RaceSync/Assets.xcassets/icn_calendar_small.imageset/icn_calendar_small.pdf differ diff --git a/RaceSync/Assets.xcassets/icn_calendar_start_small.imageset/icn_calendar_start_small.pdf b/RaceSync/Assets.xcassets/icn_calendar_start_small.imageset/icn_calendar_start_small.pdf deleted file mode 100644 index 2bc406bb..00000000 Binary files a/RaceSync/Assets.xcassets/icn_calendar_start_small.imageset/icn_calendar_start_small.pdf and /dev/null differ diff --git a/RaceSync/Assets.xcassets/icn_calendar_start_small.imageset/Contents.json b/RaceSync/Assets.xcassets/icn_date_path_continuous.imageset/Contents.json similarity index 70% rename from RaceSync/Assets.xcassets/icn_calendar_start_small.imageset/Contents.json rename to RaceSync/Assets.xcassets/icn_date_path_continuous.imageset/Contents.json index 671e43fe..fd813f94 100644 --- a/RaceSync/Assets.xcassets/icn_calendar_start_small.imageset/Contents.json +++ b/RaceSync/Assets.xcassets/icn_date_path_continuous.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "icn_calendar_start_small.pdf", + "filename" : "icn_date_path_continuous.pdf", "idiom" : "universal" } ], diff --git a/RaceSync/Assets.xcassets/icn_date_path_continuous.imageset/icn_date_path_continuous.pdf b/RaceSync/Assets.xcassets/icn_date_path_continuous.imageset/icn_date_path_continuous.pdf new file mode 100644 index 00000000..e39da679 Binary files /dev/null and b/RaceSync/Assets.xcassets/icn_date_path_continuous.imageset/icn_date_path_continuous.pdf differ diff --git a/RaceSync/Assets.xcassets/icn_date_path_progress.imageset/Contents.json b/RaceSync/Assets.xcassets/icn_date_path_progress.imageset/Contents.json new file mode 100644 index 00000000..a1dc50be --- /dev/null +++ b/RaceSync/Assets.xcassets/icn_date_path_progress.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icn_date_path_progress.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RaceSync/Assets.xcassets/icn_date_path_progress.imageset/icn_date_path_progress.pdf b/RaceSync/Assets.xcassets/icn_date_path_progress.imageset/icn_date_path_progress.pdf new file mode 100644 index 00000000..c3eb4ab6 Binary files /dev/null and b/RaceSync/Assets.xcassets/icn_date_path_progress.imageset/icn_date_path_progress.pdf differ diff --git a/RaceSync/Assets.xcassets/icn_join_check.imageset/Contents.json b/RaceSync/Assets.xcassets/icn_join_check.imageset/Contents.json new file mode 100644 index 00000000..db3ddb3d --- /dev/null +++ b/RaceSync/Assets.xcassets/icn_join_check.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icn_join_check.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RaceSync/Assets.xcassets/icn_button_join.imageset/icn_button_join.pdf b/RaceSync/Assets.xcassets/icn_join_check.imageset/icn_join_check.pdf similarity index 100% rename from RaceSync/Assets.xcassets/icn_button_join.imageset/icn_button_join.pdf rename to RaceSync/Assets.xcassets/icn_join_check.imageset/icn_join_check.pdf diff --git a/RaceSync/Assets.xcassets/icn_join_cross.imageset/Contents.json b/RaceSync/Assets.xcassets/icn_join_cross.imageset/Contents.json new file mode 100644 index 00000000..daec7402 --- /dev/null +++ b/RaceSync/Assets.xcassets/icn_join_cross.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icn_join_cross.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RaceSync/Assets.xcassets/icn_button_closed.imageset/icn_button_closed.pdf b/RaceSync/Assets.xcassets/icn_join_cross.imageset/icn_join_cross.pdf similarity index 100% rename from RaceSync/Assets.xcassets/icn_button_closed.imageset/icn_button_closed.pdf rename to RaceSync/Assets.xcassets/icn_join_cross.imageset/icn_join_cross.pdf diff --git a/RaceSync/Assets.xcassets/icn_mgp_watermark.imageset/Contents.json b/RaceSync/Assets.xcassets/icn_mgp_watermark.imageset/Contents.json new file mode 100644 index 00000000..036b9668 --- /dev/null +++ b/RaceSync/Assets.xcassets/icn_mgp_watermark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icn_mgp_watermark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RaceSync/Assets.xcassets/icn_settings_header.imageset/icn_settings_header.pdf b/RaceSync/Assets.xcassets/icn_mgp_watermark.imageset/icn_mgp_watermark.pdf similarity index 100% rename from RaceSync/Assets.xcassets/icn_settings_header.imageset/icn_settings_header.pdf rename to RaceSync/Assets.xcassets/icn_mgp_watermark.imageset/icn_mgp_watermark.pdf diff --git a/RaceSync/Assets.xcassets/icn_navbar_calendar.imageset/Contents.json b/RaceSync/Assets.xcassets/icn_navbar_calendar.imageset/Contents.json index 13ae0035..67d813c6 100644 --- a/RaceSync/Assets.xcassets/icn_navbar_calendar.imageset/Contents.json +++ b/RaceSync/Assets.xcassets/icn_navbar_calendar.imageset/Contents.json @@ -1,12 +1,12 @@ { "images" : [ { - "idiom" : "universal", - "filename" : "icn_calendar.pdf" + "filename" : "icn_navbar_calendar.pdf", + "idiom" : "universal" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/RaceSync/Assets.xcassets/icn_navbar_calendar.imageset/icn_calendar.pdf b/RaceSync/Assets.xcassets/icn_navbar_calendar.imageset/icn_navbar_calendar.pdf similarity index 100% rename from RaceSync/Assets.xcassets/icn_navbar_calendar.imageset/icn_calendar.pdf rename to RaceSync/Assets.xcassets/icn_navbar_calendar.imageset/icn_navbar_calendar.pdf diff --git a/RaceSync/Assets.xcassets/icn_button_camera.imageset/Contents.json b/RaceSync/Assets.xcassets/icn_navbar_camera.imageset/Contents.json similarity index 100% rename from RaceSync/Assets.xcassets/icn_button_camera.imageset/Contents.json rename to RaceSync/Assets.xcassets/icn_navbar_camera.imageset/Contents.json diff --git a/RaceSync/Assets.xcassets/icn_button_camera.imageset/icn_button_camera.pdf b/RaceSync/Assets.xcassets/icn_navbar_camera.imageset/icn_button_camera.pdf similarity index 100% rename from RaceSync/Assets.xcassets/icn_button_camera.imageset/icn_button_camera.pdf rename to RaceSync/Assets.xcassets/icn_navbar_camera.imageset/icn_button_camera.pdf diff --git a/RaceSync/Assets.xcassets/icn_navbar_qrcode.imageset/Contents.json b/RaceSync/Assets.xcassets/icn_navbar_qrcode.imageset/Contents.json new file mode 100644 index 00000000..0f921f2d --- /dev/null +++ b/RaceSync/Assets.xcassets/icn_navbar_qrcode.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icn_navbar_qrcode.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RaceSync/Assets.xcassets/icn_qrcode.imageset/icn_qrcode.pdf b/RaceSync/Assets.xcassets/icn_navbar_qrcode.imageset/icn_navbar_qrcode.pdf similarity index 100% rename from RaceSync/Assets.xcassets/icn_qrcode.imageset/icn_qrcode.pdf rename to RaceSync/Assets.xcassets/icn_navbar_qrcode.imageset/icn_navbar_qrcode.pdf diff --git a/RaceSync/Assets.xcassets/icn_pin_small.imageset/icn_pin_small.pdf b/RaceSync/Assets.xcassets/icn_pin_small.imageset/icn_pin_small.pdf index bf90057b..dc437c5d 100644 Binary files a/RaceSync/Assets.xcassets/icn_pin_small.imageset/icn_pin_small.pdf and b/RaceSync/Assets.xcassets/icn_pin_small.imageset/icn_pin_small.pdf differ diff --git a/RaceSync/Assets.xcassets/icn_qrcode.imageset/Contents.json b/RaceSync/Assets.xcassets/icn_qrcode.imageset/Contents.json deleted file mode 100644 index 3328748b..00000000 --- a/RaceSync/Assets.xcassets/icn_qrcode.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "icn_qrcode.pdf" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/RaceSync/Assets.xcassets/icn_settings_header.imageset/Contents.json b/RaceSync/Assets.xcassets/icn_settings_header.imageset/Contents.json deleted file mode 100644 index 88fd1e04..00000000 --- a/RaceSync/Assets.xcassets/icn_settings_header.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "icn_settings_header.pdf" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/RaceSync/Constants/AppWebConstants.swift b/RaceSync/Constants/AppWebConstants.swift index be2e7efa..1ed5c492 100644 --- a/RaceSync/Constants/AppWebConstants.swift +++ b/RaceSync/Constants/AppWebConstants.swift @@ -51,9 +51,9 @@ enum AppWeb: Int { var image: UIImage? { if self == .livefpv { - return UIImage(named: "logo_livefpv") + return LogoImg.livefpv } else if self == .fpvscores { - return UIImage(named: "logo_fpvscores") + return LogoImg.fpvscores } else { return nil } diff --git a/RaceSync/Constants/ImageConstants.swift b/RaceSync/Constants/ImageConstants.swift index 6c0fc28f..6c9c8e18 100644 --- a/RaceSync/Constants/ImageConstants.swift +++ b/RaceSync/Constants/ImageConstants.swift @@ -8,6 +8,31 @@ import UIKit +enum LogoImg { + static let header = UIImage(named: "racesync_logo_header") + static let app_icon = UIImage(named: "AppIcon60x60") + static let watermark = UIImage(named: "icn_mgp_watermark") + + static let photos = UIImage(named: "icn_apple_photos") + static let share = UIImage(named: "icn_apple_share") + static let insta = UIImage(named: "icn_meta_instagram") + static let livefpv = UIImage(named: "logo_livefpv") + static let fpvscores = UIImage(named: "logo_fpvscores") + + static let activity_calendar = UIImage(named: "icn_activity_calendar") + static let activity_safari = UIImage(named: "icn_activity_safari") + static let activity_mgp = UIImage(named: "icn_activity_mgp") + static let activity_copylink = UIImage(named: "icn_activity_copylink") + + static let activity_livefpv = UIImage(named: "icn_activity_livefpv") + static let activity_facebook = UIImage(named: "icn_activity_facebook") + static let activity_twitter = UIImage(named: "icn_activity_twitter") + static let activity_youtube = UIImage(named: "icn_activity_youtube") + static let activity_instagram = UIImage(named: "icn_activity_instagram") + static let activity_meetup = UIImage(named: "icn_activity_meetup") + static let activity_paypal = UIImage(named: "icn_activity_paypal") +} + enum PlaceholderImg { static let small = UIImage(named: "placeholder_small") static let medium = UIImage(named: "placeholder_medium") @@ -26,12 +51,32 @@ enum ButtonImg { static let calendar = UIImage(named: "icn_navbar_calendar") static let settings = UIImage(named: "icn_navbar_settings") static let notifications = UIImage(named: "icn_navbar_notifications") - static let search = UIImage(named: "icn_navbar_search") static let filter = UIImage(named: "icn_navbar_filter") static let map = UIImage(named: "icn_navbar_map") static let safari = UIImage(named: "icn_navbar_safari") - static let radius = UIImage(named: "icn_settings_radius") static let empty = UIImage(named: "icn_navbar_empty") + static let qrcode = UIImage(named: "icn_navbar_qrcode") + static let directions = UIImage(named: "icn_navbar_directions") + static let camera = UIImage(named: "icn_navbar_camera") + static let member = UIImage(named: "icn_member") + + static let join_check = UIImage(named: "icn_join_check") + static let join_cross = UIImage(named: "icn_join_cross") + + static let radius = UIImage(named: "icn_settings_radius") + + static let checkmark = UIImage(named: "icn_cell_checkmark") + +// static let pin_small = UIImage(named: "icn_pin_small") + static let cal_small = UIImage(named: "icn_calendar_small") + static let race_small = UIImage(named: "icn_race_small") + static let chapter_small = UIImage(named: "icn_chapter_small") + static let member_small = UIImage(named: "icn_member_small") + static let badge_small = UIImage(named: "icn_badge_small") + static let date_path2 = UIImage(named: "icn_date_path_progress") + static let date_path1 = UIImage(named: "icn_date_path_continuous") + static let map_annotation = UIImage(named: "icn_map_annotation") + static let trophy = UIImage(named: "icn_trophy_qualifier") } enum SystemImg { @@ -46,6 +91,11 @@ enum SystemImg { static let stack = UIImage(systemName:"rectangle.stack") // iOS 13.0+ static let stackFill = UIImage(systemName:"rectangle.stack.fill") // iOS 13.0+ + static let pin_small = UIImage(systemName:"mappin.and.ellipse") // iOS 13.0+ + static let globe = UIImage(systemName:"globe") // iOS 13.0+ + static let search = UIImage(systemName: "magnifyingglass") // iOS 13.0+ + static let badge_cross_small = UIImage(systemName: "xmark.square.fill") // iOS 13.0+ + static var flagCheckeredCrossed: UIImage? { if #available(iOS 18.0, *) { return UIImage(systemName:"flag.pattern.checkered.2.crossed") // iOS 18.0+ diff --git a/RaceSync/Constants/StringConstants.swift b/RaceSync/Constants/StringConstants.swift index 2f5a49a8..840b8fb8 100644 --- a/RaceSync/Constants/StringConstants.swift +++ b/RaceSync/Constants/StringConstants.swift @@ -18,13 +18,3 @@ public class StringConstants { public static let developedBy = "Developed by Ignacio 'Zenith' Romero" public static let supportEmail = "mobile@multigp.com" } - -extension StandingSeason { - - var sectionTitle: String { - switch self { - case .y2025: return "\(self.rawValue) MultiGP Global Qualifier - Sponsored by FINZ\nFastest 3 Consecutive Laps" - default: return "\(self.rawValue) MultiGP Global Qualifier\nFastest 3 Consecutive Laps" - } - } -} diff --git a/RaceSync/RaceSyncAPI Extensions/Chapter+UIExtensions.swift b/RaceSync/RaceSyncAPI Extensions/Chapter+UIExtensions.swift index 7c8e6bc5..9b442ed8 100644 --- a/RaceSync/RaceSyncAPI Extensions/Chapter+UIExtensions.swift +++ b/RaceSync/RaceSyncAPI Extensions/Chapter+UIExtensions.swift @@ -18,8 +18,8 @@ extension Chapter { func socialActivities() -> [SocialActivity] { - var activities = [SocialActivity]() let items = [websiteUrl, facebookUrl, twitterUrl, youtubeUrl, instagramUrl, meetupUrl] + var activities = [SocialActivity]() for item in items { if let url = item, let _URL = URL(string: url) { diff --git a/RaceSync/RaceSyncAPI Extensions/Race+UIExtensions.swift b/RaceSync/RaceSyncAPI Extensions/Race+UIExtensions.swift index 9247f0d3..3888c903 100644 --- a/RaceSync/RaceSyncAPI Extensions/Race+UIExtensions.swift +++ b/RaceSync/RaceSyncAPI Extensions/Race+UIExtensions.swift @@ -11,26 +11,39 @@ import RaceSyncAPI extension Race { - var canShowResults: Bool { - guard let results = results, results.count > 0 else { return false } + var hasStarted: Bool { guard let startDate = startDate else { return false } return startDate.isPassed } + var hasEnded: Bool { + guard let startDate = startDate else { return false } + + if let endDate = endDate, startDate.isPassed { + return endDate.isPassed(hours: 2) + } + return startDate.isPassed(hours: 8) + } + + var inProgress: Bool { + guard !isFinalized else { return false } + return hasStarted && !hasEnded + } + + var canShowResults: Bool { + if isFinalized { return true } // Assume results should be displayed since the race is finalized already + guard let results = results, results.count > 0 else { return false } + return hasStarted + } + var canShowSchedule: Bool { - return isZippyQEnabled && !isFinalized + guard hasStarted && !hasEnded else { return false } + return isZippyQEnabled } func canCreateCalendarEvent() -> Bool { - if let endDate = endDate, endDate.isPassed { - return false - } - else if let startDate = startDate, startDate.isPassed(by: 1) { - return false - } - else { - return true - } + guard !hasEnded else { return false } + return true } func createCalendarEvent(with raceId: ObjectId) -> CalendarEvent? { diff --git a/RaceSync/Tools/ApplicationControl.swift b/RaceSync/Tools/ApplicationControl.swift index 2073aab4..18299e1c 100644 --- a/RaceSync/Tools/ApplicationControl.swift +++ b/RaceSync/Tools/ApplicationControl.swift @@ -75,7 +75,7 @@ class ApplicationControl: NSObject { guard let deeplink = note.object as? DeepLink else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: { [weak self] in - self?.handleDeepLink(deeplink) + self?.handle(deeplink) }) } } @@ -113,7 +113,9 @@ extension ApplicationControl { if race.isPayable { AlertUtil.presentAlertMessage("This race has a fee of \(race.fee) USD. Would you like to pay it now?", title: "Joined race!", - buttonTitle: "Pay Now", delay: 0.5) { action in + okTitle: "Pay Now", + cancelTitle: "Later", + delay: 0.5) { action in DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: { self.presentPayment(for: race, completion) }) @@ -158,27 +160,50 @@ extension ApplicationControl { func presentPayment(for race: Race, _ completion: @escaping JoinStateCompletionBlock) { guard let url = race.getMyPaymentUrl() else { return } - WebViewController.openURL(url, style: .formSheet) { + WebViewController.open(url, style: .formSheet) { // return the original state for now let state = RaceViewModel.joinState(for: race) completion(state) } } - fileprivate func handleDeepLink(_ deeplink: DeepLink) { + // Use this method to know if a specific deep link is supported or not + func canHandleDeepLink(_ link: DeepLink) -> Bool { - if deeplink.action == .join { - // Makes sure to dismiss the payment webview, if still present - if let webvc = UIViewController.topMostViewController(), webvc.isKind(of: WebViewController.self) { + if link.isRace { + if link.action == .view, let _ = link.parameters[ParamKey.id] { + return true + } else if link.action == .join { + return true + } + } + return false + } + + func handle(_ link: DeepLink) { - webvc.dismiss(animated: true) + if link.isRace { + if link.action == .view, let raceId = link.parameters[ParamKey.id] { + if let nc = UIViewController.topMostViewController() as? NavigationController { - // force reloading the visible ViewJoinable - // to reflect the updated state change. - // TODO: Consider reactive join button states, to avoid heavylift reloads - let joinables = ViewJoinableRegistry.shared.all() - for vc in joinables { - vc.loadContent(forced: true) + let vc = RaceTabBarController(with: raceId) + vc.hidesBottomBarWhenPushed = true + nc.pushViewController(vc, animated: true) + } + } + else if link.action == .join { + // Makes sure to dismiss the payment webview, if still present + if let webvc = UIViewController.topMostViewController(), webvc.isKind(of: WebViewController.self) { + + webvc.dismiss(animated: true) + + // force reloading the visible ViewJoinable + // to reflect the updated state change. + // TODO: Consider reactive join button states, to avoid heavylift reloads + let joinables = ViewJoinableRegistry.shared.all() + for vc in joinables { + vc.loadContent(forced: true) + } } } } diff --git a/RaceSync/Tools/DeepLink.swift b/RaceSync/Tools/DeepLink.swift deleted file mode 100644 index 29258128..00000000 --- a/RaceSync/Tools/DeepLink.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// DeepLink.swift -// RaceSync -// -// Created by Ignacio Romero Zurbuchen on 2025-08-31. -// Copyright © 2025 MultiGP Inc. All rights reserved. -// - -import Foundation - -struct DeepLink { - let domain: Domain - let action: Action - let parameters: [String: String] - - enum Domain: String { - case race - case user // unsupported - case chapter // unsupported - case settings // unsupported - case unknown - } - - enum Action: String { - case join - case view // unsupported - case unknown // unsupported - } -} diff --git a/RaceSync/Tools/DeepLinkURLHandler.swift b/RaceSync/Tools/DeepLinkURLHandler.swift deleted file mode 100644 index 37c4d841..00000000 --- a/RaceSync/Tools/DeepLinkURLHandler.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// DeepLinkURLHandler.swift -// RaceSync -// -// Created by Ignacio Romero Zurbuchen on 2025-08-31. -// Copyright © 2025 MultiGP Inc. All rights reserved. -// - -import Foundation -import RaceSyncAPI - -class DeepLinkURLHandler: Descriptable { - - static let shared = DeepLinkURLHandler() - - // MARK: - Private Variables - - fileprivate let raceApi = RaceApi() - - fileprivate static var scheme: String? { - if let urlTypes = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]], - let urlSchemes = urlTypes.first?["CFBundleURLSchemes"] as? [String] { - return urlSchemes.first - } - return nil - } - - fileprivate init() {} - - // MARK: - Actions - - func handle(url: URL) -> Bool { - guard url.scheme == Self.scheme else { return false } - - guard let host = url.host, let domain = DeepLink.Domain(rawValue: host) else { - return false - } - - let action = url.pathComponents.dropFirst().first.flatMap { - DeepLink.Action(rawValue: $0) - } ?? .unknown - - // Extract query items into dictionary - var params: [String: String] = [:] - if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems { - queryItems.forEach { item in - params[item.name] = item.value ?? "" - } - } - - let deepLink = DeepLink(domain: domain, action: action, parameters: params) - - return handleDeepLink(deepLink) - } -} - -fileprivate extension DeepLinkURLHandler { - - // racesync://race/join?id=29941&pilotId=20676 - - func handleDeepLink(_ deepLink: DeepLink) -> Bool { - if deepLink.domain == .race, deepLink.action == .join { - return handleJoiningRace(with: deepLink) - } - return false - } - - func handleJoiningRace(with deepLink: DeepLink) -> Bool { - guard let myUser = APIServices.shared.myUser else { return false } - guard let raceId = deepLink.parameters[ParamKey.id], let pilotId = deepLink.parameters[ParamKey.pilotId] else { return false } - guard pilotId == myUser.id else { return false } - - raceApi.join(race: raceId) { (status, error) in - // Broadcast regardless if joined successful or not - // since this may be called, even if the race has already been joined - // in cases like paying fees after joining a race. - NotificationCenter.default.post( - name: .joinedRaceViaDeeplink, - object: deepLink - ) - } - return true - } -} - -extension Notification.Name { - static let joinedRaceViaDeeplink = Notification.Name("com.racecync.joinedRaceViaDeeplink") -} diff --git a/RaceSync/UI Components/BadgeHub.swift b/RaceSync/UI Components/BadgeHub.swift index 5c9d36a2..ca286941 100644 --- a/RaceSync/UI Components/BadgeHub.swift +++ b/RaceSync/UI Components/BadgeHub.swift @@ -100,14 +100,14 @@ public class BadgeHub: NSObject { redCircle = BadgeView() redCircle?.isUserInteractionEnabled = false - redCircle.backgroundColor = UIColor.red + redCircle.backgroundColor = Color.red countLabel = UILabel(frame: redCircle.frame) countLabel?.isUserInteractionEnabled = false count = startCount countLabel?.textAlignment = .center - countLabel?.textColor = UIColor.white - countLabel?.backgroundColor = UIColor.clear + countLabel?.textColor = Color.white + countLabel?.backgroundColor = Color.clear setCircleAtFrame(CGRect(x: (frame?.size.width ?? 0.0) - ((Constants.notificHubDefaultDiameter) * 2 / 3), y: (-Constants.notificHubDefaultDiameter) / 3, diff --git a/RaceSync/UI Components/ColumnTableViewHeaderView.swift b/RaceSync/UI Components/ColumnTableViewHeaderView.swift index b879d644..0baef5f1 100644 --- a/RaceSync/UI Components/ColumnTableViewHeaderView.swift +++ b/RaceSync/UI Components/ColumnTableViewHeaderView.swift @@ -49,7 +49,7 @@ class ColumnTableViewHeaderView: UITableViewHeaderFooterView { static let height: CGFloat = padding * 2 } - // MARK: - Initializers + // MARK: - Initialization override init(reuseIdentifier: String?) { super.init(reuseIdentifier: reuseIdentifier) diff --git a/RaceSync/UI Components/ImageExportViewController.swift b/RaceSync/UI Components/ImageExportViewController.swift index d5149970..1e13c719 100644 --- a/RaceSync/UI Components/ImageExportViewController.swift +++ b/RaceSync/UI Components/ImageExportViewController.swift @@ -80,7 +80,7 @@ class ImageExportViewController: UIViewController { }() fileprivate lazy var photosButton: UIButton = { - let image = UIImage(named: "icn_apple_photos")?.withRenderingMode(.alwaysOriginal) + let image = LogoImg.photos?.withRenderingMode(.alwaysOriginal) let button = UIButton(type: .system) button.setImage(image, for: .normal) button.setTitle("Save to Photos", for: .normal) @@ -96,7 +96,7 @@ class ImageExportViewController: UIViewController { }() fileprivate lazy var shareButton: UIButton = { - let image = UIImage(named: "icn_apple_share")?.withRenderingMode(.alwaysOriginal) + let image = LogoImg.share?.withRenderingMode(.alwaysOriginal) let button = UIButton(type: .system) button.setImage(image, for: .normal) button.setTitle("Share to...", for: .normal) @@ -112,7 +112,7 @@ class ImageExportViewController: UIViewController { }() fileprivate lazy var instagramButton: UIButton = { - let image = UIImage(named: "icn_meta_instagram")?.withRenderingMode(.alwaysOriginal) + let image = LogoImg.insta?.withRenderingMode(.alwaysOriginal) let button = UIButton(type: .system) button.setImage(image, for: .normal) button.setTitle("Share to Instagram", for: .normal) diff --git a/RaceSync/UI Components/JoinButton.swift b/RaceSync/UI Components/JoinButton.swift index 85fa88e1..81d30874 100644 --- a/RaceSync/UI Components/JoinButton.swift +++ b/RaceSync/UI Components/JoinButton.swift @@ -36,6 +36,7 @@ class JoinButton: CustomButton { static let minHeight: CGFloat = 32 static let minWidth: CGFloat = 76 + static let cornerRadius: CGFloat = 6 // MARK: - Private Variables @@ -69,11 +70,22 @@ class JoinButton: CustomButton { // MARK: - Layout fileprivate func setupLayout() { + titleLabel?.lineBreakMode = .byClipping + titleLabel?.numberOfLines = 1 + titleLabel?.adjustsFontSizeToFitWidth = false + adjustsImageWhenHighlighted = false adjustsImageWhenDisabled = true + imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 0) contentEdgeInsets = UIEdgeInsets(top: 5, left: 8, bottom: 5, right: 8) - layer.cornerRadius = 6 + + // Critical: prevent shrinking + setContentHuggingPriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .horizontal) + + layer.cornerRadius = Self.cornerRadius + layer.borderWidth = 0 } fileprivate func updateLayout() { @@ -170,12 +182,6 @@ class JoinButton: CustomButton { } } - override var isEnabled: Bool { - didSet { - // nothing - } - } - override func sendAction(_ action: Selector, to target: Any?, for event: UIEvent?) { guard joinState.interactionEnabled else { return } super.sendAction(action, to: target, for: event) @@ -191,8 +197,8 @@ extension JoinState { var icon: UIImage? { switch self { - case .joined: return UIImage(named: "icn_button_join")?.withRenderingMode(.alwaysOriginal) - case .closed: return UIImage(named: "icn_button_closed")?.withRenderingMode(.alwaysOriginal) + case .joined: return ButtonImg.join_check?.withRenderingMode(.alwaysOriginal) + case .closed: return ButtonImg.join_cross?.withRenderingMode(.alwaysOriginal) default: return nil } } diff --git a/RaceSync/UI Components/MemberBadgeView.swift b/RaceSync/UI Components/MemberBadgeView.swift index 01f9a16f..495ee14d 100644 --- a/RaceSync/UI Components/MemberBadgeView.swift +++ b/RaceSync/UI Components/MemberBadgeView.swift @@ -34,7 +34,7 @@ class MemberBadgeView: CustomButton { setTitleColor(Color.black, for: .normal) tintColor = Color.black - setImage(UIImage(named: "icn_member"), for: .normal) + setImage(ButtonImg.member, for: .normal) imageEdgeInsets = UIEdgeInsets(left: -7) contentEdgeInsets = UIEdgeInsets(top: 5, left: 15, bottom: 5, right: 12) diff --git a/RaceSync/UI Components/MultiTextPickerViewController.swift b/RaceSync/UI Components/MultiTextPickerViewController.swift index 95a993db..16000531 100644 --- a/RaceSync/UI Components/MultiTextPickerViewController.swift +++ b/RaceSync/UI Components/MultiTextPickerViewController.swift @@ -179,7 +179,7 @@ extension MultiTextPickerViewController: UITableViewDataSource { cell.detailTextLabel?.text = matchingItems.joined(separator: ", ") } else if selectedItems.contains(item) { - let imageView = UIImageView(image: UIImage(named: "icn_cell_checkmark")) + let imageView = UIImageView(image: ButtonImg.checkmark) imageView.tintColor = Color.blue cell.accessoryView = imageView } diff --git a/RaceSync/UI Components/NavigationController.swift b/RaceSync/UI Components/NavigationController.swift index 47f52e32..ec150e91 100644 --- a/RaceSync/UI Components/NavigationController.swift +++ b/RaceSync/UI Components/NavigationController.swift @@ -30,14 +30,17 @@ class NavigationController: UINavigationController { super.pushViewController(viewController, animated: animated) } + @discardableResult override func popViewController(animated: Bool) -> UIViewController? { return super.popViewController(animated: animated) } + @discardableResult override func popToViewController(_ viewController: UIViewController, animated: Bool) -> [UIViewController]? { return super.popToViewController(viewController, animated: animated) } + @discardableResult override func popToRootViewController(animated: Bool) -> [UIViewController]? { return super.popToRootViewController(animated: animated) } diff --git a/RaceSync/UI Components/Profile Header/ProfileAvatarView.swift b/RaceSync/UI Components/Profile Header/ProfileAvatarView.swift index f7c53ec4..3ce6b943 100644 --- a/RaceSync/UI Components/Profile Header/ProfileAvatarView.swift +++ b/RaceSync/UI Components/Profile Header/ProfileAvatarView.swift @@ -14,11 +14,11 @@ class ProfileAvatarView: DimmableView { // MARK: - Public Variables lazy var imageView: UIImageView = { - let imageView = UIImageView() - imageView.backgroundColor = Color.white - imageView.layer.cornerRadius = height/2 - imageView.layer.masksToBounds = true - return imageView + let view = UIImageView() + view.backgroundColor = Color.white + view.layer.cornerRadius = height/2 + view.layer.masksToBounds = true + return view }() let height: CGFloat = 170 diff --git a/RaceSync/UI Components/Profile Header/ProfileHeaderView.swift b/RaceSync/UI Components/Profile Header/ProfileHeaderView.swift index 30f2a713..b077e021 100644 --- a/RaceSync/UI Components/Profile Header/ProfileHeaderView.swift +++ b/RaceSync/UI Components/Profile Header/ProfileHeaderView.swift @@ -12,7 +12,7 @@ import RaceSyncAPI import TOCropViewController protocol ProfileHeaderViewDelegate { - func shouldUploadImage(_ image: UIImage, imageType: ImageType, for objectId: ObjectId) + func shouldUploadImage(_ image: UIImage, imageType: ImageType, for id: ObjectId) } class ProfileHeaderView: UIView { @@ -40,17 +40,25 @@ class ProfileHeaderView: UIView { lazy var locationButton: PasteboardButton = { let button = PasteboardButton(type: .system) - button.tintColor = Color.red - button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .regular) - button.setImage(UIImage(named: "icn_pin_small"), for: .normal) - button.titleEdgeInsets = UIEdgeInsets(top: -1, left: 0, bottom: 0, right: -Constants.padding) button.shouldHighlight = true + button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .regular) + button.titleLabel?.numberOfLines = 1 + button.tintColor = Color.link return button }() + fileprivate lazy var locationIconView: UIImageView = { + let view = UIImageView() + view.image = SystemImg.pin_small?.withRenderingMode(.alwaysTemplate) + view.contentMode = .scaleAspectFit + view.backgroundColor = Color.clear + view.tintColor = Color.link + return view + }() + lazy var cameraButton: CustomButton = { let button = CustomButton(type: .system) - button.setImage(UIImage(named: "icn_button_camera"), for: .normal) + button.setImage(ButtonImg.camera, for: .normal) button.tintColor = Color.white button.hitTestEdgeInsets = UIEdgeInsets(proportionally: -20) button.addTarget(self, action: #selector(didTapCameraButton), for: .touchUpInside) @@ -80,8 +88,8 @@ class ProfileHeaderView: UIView { static var backgroundViewHeight: CGFloat { // TODO: Use dynamic values instead of hardcoding them. - if Constants.backgroundImageHeight - 44 < Constants.avatarImageHeight { - return Constants.avatarImageHeight + 44 + if Constants.backgroundImageHeight - 44 < Constants.avatarImageSize { + return Constants.avatarImageSize + 44 } return Constants.backgroundImageHeight } @@ -98,7 +106,7 @@ class ProfileHeaderView: UIView { fileprivate lazy var mainTextLabel: PasteboardLabel = { let label = PasteboardLabel() - label.font = UIFont.systemFont(ofSize: 15, weight: .regular) + label.font = UIFont.systemFont(ofSize: 16, weight: .regular) label.textColor = Color.black label.numberOfLines = 2 return label @@ -120,7 +128,7 @@ class ProfileHeaderView: UIView { fileprivate lazy var leftBadgeButton: UIButton = { let button = UIButton(type: .system) button.tintColor = Color.gray400 - button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .regular) + button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .regular) button.titleEdgeInsets = UIEdgeInsets(right: -Constants.padding/2) button.isUserInteractionEnabled = false return button @@ -129,20 +137,25 @@ class ProfileHeaderView: UIView { fileprivate lazy var rightBadgeButton: UIButton = { let button = UIButton(type: .system) button.tintColor = Color.gray400 - button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .regular) + button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .regular) button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -Constants.padding/2, bottom: 0, right: 0) button.isUserInteractionEnabled = false return button }() fileprivate lazy var headerLabelStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [mainTextLabel, locationButton]) - stackView.axis = .vertical - stackView.distribution = .fill - - stackView.alignment = .leading - stackView.spacing = Constants.padding*1.5 - return stackView + let stackView1 = UIStackView(arrangedSubviews: [locationIconView, locationButton]) + stackView1.axis = .horizontal + stackView1.alignment = .center + stackView1.distribution = .fill + stackView1.spacing = Constants.padding / 2 + + let stackView2 = UIStackView(arrangedSubviews: [mainTextLabel, stackView1]) + stackView2.axis = .vertical + stackView2.alignment = .leading + stackView2.distribution = .fill + stackView2.spacing = Constants.padding + return stackView2 }() fileprivate var hasLaidOut: Bool = false @@ -152,7 +165,7 @@ class ProfileHeaderView: UIView { fileprivate enum Constants { static let padding: CGFloat = UniversalConstants.padding static let backgroundImageHeight: CGFloat = CGFloat(Int(UIScreen.main.bounds.size.height/3.5)) - static let avatarImageHeight: CGFloat = 170 + static let avatarImageSize: CGFloat = 170 } // MARK: - Initialization @@ -186,9 +199,9 @@ class ProfileHeaderView: UIView { addSubview(avatarView) avatarView.snp.makeConstraints { - $0.top.equalTo(backgroundView.snp.bottom).offset(-Constants.avatarImageHeight*6/7) // 85% + $0.top.equalTo(backgroundView.snp.bottom).offset(-Constants.avatarImageSize*6/7) // 85% $0.centerX.equalToSuperview() - $0.height.equalTo(Constants.avatarImageHeight) + $0.height.equalTo(Constants.avatarImageSize) } addSubview(cameraButton) @@ -235,11 +248,13 @@ class ProfileHeaderView: UIView { guard image == nil else { return } let placeholder = PlaceholderImg.profileBkgd backgroundView.imageView.image = placeholder + backgroundView.isHidden = false } func handleAvatarImage(_ image: UIImage?) { guard image == nil else { return } avatarView.imageView.image = viewModel.type.placeholder + avatarView.isHidden = false } let headerImageSize = CGSize(width: UIScreen.main.bounds.width*3, height: Self.backgroundViewHeight) @@ -254,7 +269,7 @@ class ProfileHeaderView: UIView { handleBackgroundImage(nil) } - let avatarImageSize = CGSize(width: Constants.avatarImageHeight, height: Constants.avatarImageHeight) + let avatarImageSize = CGSize(width: Constants.avatarImageSize, height: Constants.avatarImageSize) let avatarPlaceholder = UIImage.image(withColor: Color.gray100, imageSize: avatarImageSize) if let avatarImageUrl = ImageUtil.getImageUrl(for: viewModel.pictureUrl) { @@ -262,15 +277,18 @@ class ProfileHeaderView: UIView { handleAvatarImage(image) } } else { - handleAvatarImage(nil) + avatarView.isHidden = true } mainTextLabel.text = viewModel.displayName if !viewModel.locationName.isEmpty { locationButton.setTitle(viewModel.locationName, for: .normal) + locationButton.isHidden = false } else { - locationButton.setTitle("Earth", for: .normal) + locationButton.setTitle(nil, for: .normal) + locationButton.isHidden = true + locationIconView.isHidden = true } if viewModel.topBadgeLabel != nil { @@ -310,9 +328,9 @@ class ProfileHeaderView: UIView { func presentUploadSheet(_ imageType: ImageType) { guard let topMostVC = UIViewController.topMostViewController() else { return } - guard let viewModel = viewModel else { return } + guard let _ = viewModel else { return } - let alert = UIAlertController(title: "Upload \(imageType.title) image for your \(viewModel.type.rawValue)", message: nil, preferredStyle: .actionSheet) + let alert = UIAlertController(title: "Upload \(imageType.title) image", message: nil, preferredStyle: .actionSheet) alert.view.tintColor = Color.blue alert.addAction(UIAlertAction(title: "Camera", style: .default) { [weak self] (action) in diff --git a/RaceSync/UI Components/RankView.swift b/RaceSync/UI Components/RankView.swift index 0f267f98..b69df62a 100644 --- a/RaceSync/UI Components/RankView.swift +++ b/RaceSync/UI Components/RankView.swift @@ -38,6 +38,7 @@ class RankView: UIView { guard let rank = rank, rank >= 0 else { return nil } switch rank { + case 0: return " " // space to align with the other ranks case 1: return "🥇" case 2: return "🥈" case 3: return "🥉" diff --git a/RaceSync/UI Components/SegmentedTableViewHeaderView.swift b/RaceSync/UI Components/SegmentedTableViewHeaderView.swift index 9dc3c90f..755304f8 100644 --- a/RaceSync/UI Components/SegmentedTableViewHeaderView.swift +++ b/RaceSync/UI Components/SegmentedTableViewHeaderView.swift @@ -31,7 +31,7 @@ class SegmentedTableViewHeaderView: UITableViewHeaderFooterView { static let height: CGFloat = 32 + padding * 2 // slightly higher than native height } - // MARK: - Initializers + // MARK: - Initialization override init(reuseIdentifier: String?) { super.init(reuseIdentifier: reuseIdentifier) diff --git a/RaceSync/UI Components/SliderTableViewHeaderView.swift b/RaceSync/UI Components/SliderTableViewHeaderView.swift new file mode 100644 index 00000000..f440f2cc --- /dev/null +++ b/RaceSync/UI Components/SliderTableViewHeaderView.swift @@ -0,0 +1,372 @@ +// +// SliderTableViewHeaderView.swift +// RaceSync +// +// Created by Ignacio Romero Zurbuchen on 2025-09-27. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import UIKit +import SnapKit + +protocol SliderTableViewHeaderViewDelegate: AnyObject { + func sliderNumberOfItems(_ slider: SliderTableViewHeaderView) -> Int + func slider(_ slider: SliderTableViewHeaderView, imageFor view: UIImageView, at index: Int) + func slider(_ slider: SliderTableViewHeaderView, didSelectImageAt index: Int) +} + +class SliderTableViewHeaderView: UIView { + + // MARK: - Public + + var isCarouselEnabled: Bool = true + + weak var delegate: SliderTableViewHeaderViewDelegate? + + static var height: CGFloat { + UIScreen.main.bounds.height / 4 + } + + // MARK: - Private + + fileprivate var totalElements: Int = 0 + fileprivate var numberOfItems: Int = 0 + fileprivate var currentIndex: Int = 0 + + fileprivate lazy var collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = Constants.spacing + layout.sectionInset = .zero + + let view = UICollectionView(frame: .zero, collectionViewLayout: layout) + view.showsHorizontalScrollIndicator = false + view.decelerationRate = .fast + view.backgroundColor = .clear + view.delegate = self + view.dataSource = self + view.register(SliderImageCell.self, forCellWithReuseIdentifier: SliderImageCell.identifier) + return view + }() + + fileprivate lazy var pageControl: UIPageControl = { + let control = UIPageControl() + control.addTarget(self, action: #selector(didTapPageControl), for: .valueChanged) + control.currentPageIndicatorTintColor = Color.gray400 + control.pageIndicatorTintColor = Color.gray200 + control.hidesForSinglePage = true + return control + }() + + fileprivate var autoScrollTimer: Timer? + + // MARK: - Constants + + fileprivate enum Constants { + static let spacing: CGFloat = 20 + static let cellRatio: CGFloat = 0.7 // how much of width each cell takes + } + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + setupLayout() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public Reload + + func reloadData() { + guard let delegate = delegate else { return } + let count = delegate.sliderNumberOfItems(self) + guard count > 0 else { return } + + let minimumCount = 3 + + numberOfItems = count + totalElements = count * minimumCount // repeat 3x for infinite scroll illusion + pageControl.numberOfPages = count + currentIndex = count // start in the middle block + + if count < minimumCount { + isCarouselEnabled = false // disable it if no enough elements + } + + collectionView.reloadData() + + DispatchQueue.main.async { + self.scrollToIndex(self.currentIndex, animated: false) + + if self.isCarouselEnabled { + self.startAutoScroll() + } + } + } + + // MARK: - Setup + + fileprivate func setupLayout() { + + backgroundColor = Color.gray50 + + addSubview(collectionView) + collectionView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(Self.height) + } + + addSubview(pageControl) + pageControl.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalToSuperview().offset(-Constants.spacing/5) + } + + let separatorLine = UIView() + separatorLine.backgroundColor = Color.gray100 + addSubview(separatorLine) + separatorLine.snp.makeConstraints { + $0.height.equalTo(0.5) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(self.snp.bottom) + } + } + + override var intrinsicContentSize: CGSize { + return CGSize(width: UIView.noIntrinsicMetric, height: Self.height) + } + + override func layoutSubviews() { + frame.size.height = Self.height + super.layoutSubviews() + } + + fileprivate func startAutoScroll(interval: TimeInterval = 3.0) { + stopAutoScroll() // in case it's already running + + autoScrollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + self?.scrollToNextIndex() + } + } + + fileprivate func stopAutoScroll() { + autoScrollTimer?.invalidate() + autoScrollTimer = nil + } + + fileprivate func scrollToIndex(_ index: Int, animated: Bool = true) { + let indexPath = IndexPath(item: index, section: 0) + collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: animated) + + currentIndex = index + pageControl.currentPage = index % numberOfItems + } + + fileprivate func scrollToNextIndex() { + guard numberOfItems > 0 else { return } + + let nextIndex = (currentIndex + 1) % totalElements + scrollToIndex(nextIndex, animated: true) + } + + // MARK: - Actions + + @objc fileprivate func didTapPageControl(_ sender: UIPageControl) { + stopAutoScroll() + + let index = sender.currentPage + numberOfItems // map to middle block + scrollToIndex(index, animated: true) + } +} + +// MARK: - UICollectionViewDataSource & Delegate + +extension SliderTableViewHeaderView: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return totalElements + } + + func collectionView(_ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: SliderImageCell.identifier, for: indexPath) as? SliderImageCell else { + return UICollectionViewCell() + } + + if let delegate = delegate { + let index = indexPath.item % numberOfItems + delegate.slider(self, imageFor: cell.imageView, at: index) + } + return cell + } + + func collectionView(_ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath) -> CGSize { + let width = collectionView.bounds.width * Constants.cellRatio + let height = collectionView.bounds.height * Constants.cellRatio + return CGSize(width: width, height: height) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + stopAutoScroll() + + if let cell = collectionView.cellForItem(at: indexPath) { + UIView.animate(withDuration: 0.1, + animations: { + cell.transform = CGAffineTransform(scaleX: 0.96, y: 0.96) + }, + completion: { _ in + UIView.animate(withDuration: 0.1) { + cell.transform = .identity + } + }) + } + + let selectedIdx = indexPath.item % numberOfItems + let currentIdx = currentIndex % numberOfItems + + if selectedIdx == currentIdx { + delegate?.slider(self, didSelectImageAt: selectedIdx) + } else { + scrollToIndex(indexPath.item, animated: true) + } + } +} + +extension SliderTableViewHeaderView: UIScrollViewDelegate { + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + stopAutoScroll() + } + + func scrollViewWillEndDragging(_ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer) { + + guard let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else { return } + + let cellWidth = collectionView.bounds.width * Constants.cellRatio + let pageWidth = cellWidth + layout.minimumLineSpacing + let tolerance = 0.5 + + // Where the scroll would naturally stop + let estimatedIndex = scrollView.contentOffset.x / pageWidth + var index = round(estimatedIndex) + + if velocity.x > 0 { // force forward + if velocity.x < tolerance { + index = ceil(estimatedIndex + 1) + } else { + index = floor(estimatedIndex + 1) + } + } else if velocity.x < 0 { // force backward + if velocity.x > -tolerance { + index = floor(estimatedIndex - 1) + } else { + index = ceil(estimatedIndex - 1) + } + } + + // Clamp index + let maxIndex = totalElements - 1 + let newIndex = max(0, min(Int(index), maxIndex)) + + // Offset to center the cell + let newOffset = CGFloat(newIndex) * pageWidth - (collectionView.bounds.width - cellWidth)/2 + targetContentOffset.pointee = CGPoint(x: newOffset, y: 0) + + // Update state + currentIndex = newIndex + pageControl.currentPage = newIndex % numberOfItems + } + + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + adjustInfiniteScroll() + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + adjustInfiniteScroll() + } + + fileprivate func adjustInfiniteScroll() { + let center = collectionView.center + let point = convert(center, to: collectionView) + + if let indexPath = collectionView.indexPathForItem(at: point) { + currentIndex = indexPath.item + pageControl.currentPage = currentIndex % numberOfItems + + // Reset to middle block if scrolled too far + if currentIndex < numberOfItems || currentIndex >= 2*numberOfItems { + let newIndex = numberOfItems + (currentIndex % numberOfItems) + let indexPath = IndexPath(item: newIndex, section: 0) + collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false) + currentIndex = newIndex + } + } + } +} + +// MARK: - Custom Cell + +private final class SliderImageCell: UICollectionViewCell { + + // MARK: - Public Variables + + static let identifier = "SliderImageCell" + + let imageView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFill + view.layer.cornerRadius = 12 + view.layer.masksToBounds = true + return view + }() + + // MARK: - Private Variables + + fileprivate let shadowView: UIView = { + let view = UIView() + view.layer.shadowRadius = 2 + view.layer.shadowOpacity = 0.35 + view.layer.shadowColor = Color.black.cgColor + view.layer.shadowOffset = CGSize(width: 0, height: 2.0) + view.layer.masksToBounds = false + return view + }() + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Layout + + fileprivate func setupLayout() { + contentView.addSubview(shadowView) + shadowView.snp.makeConstraints { $0.edges.equalToSuperview() } + + shadowView.addSubview(imageView) + imageView.snp.makeConstraints { $0.edges.equalToSuperview() } + } + + override func layoutSubviews() { + super.layoutSubviews() + + let cornerRadius: CGFloat = 12 + shadowView.layer.cornerRadius = cornerRadius + shadowView.layer.shadowPath = UIBezierPath(roundedRect: shadowView.bounds, cornerRadius: cornerRadius).cgPath + } +} + diff --git a/RaceSync/UI Components/TextEditorViewController.swift b/RaceSync/UI Components/TextEditorViewController.swift index 0ca4857d..66749a8c 100644 --- a/RaceSync/UI Components/TextEditorViewController.swift +++ b/RaceSync/UI Components/TextEditorViewController.swift @@ -160,14 +160,14 @@ extension TextEditorViewController: RichEditorToolbarDelegate { func richEditorToolbarChangeTextColor(_ toolbar: RichEditorToolbar) { // TODO: Present a color picker - toolbar.editor?.setTextColor(Color.red) + toolbar.editor?.setTextColor(Color.link) updateSaveButton() } func richEditorToolbarChangeBackgroundColor(_ toolbar: RichEditorToolbar) { // TODO: Present a color picker - toolbar.editor?.setTextBackgroundColor(Color.red) + toolbar.editor?.setTextBackgroundColor(Color.link) updateSaveButton() } diff --git a/RaceSync/UI Components/TextPill.swift b/RaceSync/UI Components/TextPill.swift index 9dca7392..133a0429 100644 --- a/RaceSync/UI Components/TextPill.swift +++ b/RaceSync/UI Components/TextPill.swift @@ -22,11 +22,11 @@ class TextPill: UIView { case .badge: titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .bold) titleLabel.textColor = Color.white - backgroundColor = Color.gray200.withAlphaComponent(0.5) + backgroundColor = Color.gray200.withAlphaComponent(0.75) case .text: titleLabel.font = UIFont.systemFont(ofSize: 13, weight: .semibold) titleLabel.textColor = Color.gray400 - backgroundColor = Color.gray100 + backgroundColor = Color.gray100.withAlphaComponent(0.75) } } } diff --git a/RaceSync/UI Components/WebViewController.swift b/RaceSync/UI Components/WebViewController.swift index 5f8f4d73..d63e3340 100644 --- a/RaceSync/UI Components/WebViewController.swift +++ b/RaceSync/UI Components/WebViewController.swift @@ -12,14 +12,14 @@ import RaceSyncAPI class WebViewController: SFSafariViewController { - // MARK: - Public Static Convenience Methods + // MARK: - Public - static func openUrl(_ url: String, style: UIModalPresentationStyle = .automatic) { + static func open(_ url: String, style: UIModalPresentationStyle = .automatic, completion: (() -> Void)? = nil) { guard let URL = URL(string: url) else { return } - openURL(URL, style: style) - } + open(URL, style: style, completion: completion) + } - static func openURL(_ URL: URL, style: UIModalPresentationStyle = .automatic, completion: (() -> Void)? = nil) { + static func open(_ URL: URL, style: UIModalPresentationStyle = .automatic, completion: (() -> Void)? = nil) { let webvc = WebViewController(url: URL) webvc.modalPresentationStyle = style UIViewController.topMostViewController()?.present(webvc, animated: true, completion: completion) @@ -41,7 +41,7 @@ class WebViewController: SFSafariViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - AppUtil.lockOrientation(.allButUpsideDown) + AppUtil.lock(.allButUpsideDown) } override func viewDidAppear(_ animated: Bool) { diff --git a/RaceSync/UI Extensions/NSAttributedString+Extensions.swift b/RaceSync/UI Extensions/NSAttributedString+Extensions.swift index c838b136..c131b5d4 100644 --- a/RaceSync/UI Extensions/NSAttributedString+Extensions.swift +++ b/RaceSync/UI Extensions/NSAttributedString+Extensions.swift @@ -123,7 +123,7 @@ public extension String { \(self) diff --git a/RaceSync/UI Extensions/UIApplication+Extensions.swift b/RaceSync/UI Extensions/UIApplication+Extensions.swift new file mode 100644 index 00000000..fe9a09be --- /dev/null +++ b/RaceSync/UI Extensions/UIApplication+Extensions.swift @@ -0,0 +1,30 @@ +// +// UIApplication+Extensions.swift +// RaceSync +// +// Created by Ignacio Romero Zurbuchen on 2026-01-05. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import UIKit + +extension UIApplication { + + var statusBarFrame: CGRect { + guard let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first, + let statusBarFrame = scene.statusBarManager?.statusBarFrame + else { + return .zero + } + return statusBarFrame + } + + var keyWindow: UIWindow? { + return UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + } +} diff --git a/RaceSync/UI Extensions/UITabBarController+Extensions.swift b/RaceSync/UI Extensions/UITabBarController+Extensions.swift index b748b4f0..4f26fb02 100644 --- a/RaceSync/UI Extensions/UITabBarController+Extensions.swift +++ b/RaceSync/UI Extensions/UITabBarController+Extensions.swift @@ -20,23 +20,25 @@ extension UITabBarController { // Trick to pre-load each view controller self.preloadTabs() - var index = selectedIndex - let defaultIndex = 0 + var idx = selectedIndex - // makes sure disabled tabs aren't selected - // and defaults to the first tab - if index != defaultIndex { - let vc = vcs[index] + if idx < vcs.count { + let vc = vcs[idx] + // makes sure disabled tabs aren't selected if let item = vc.tabBarItem, !item.isEnabled { - index = defaultIndex + idx = HomeTabs.default.rawValue } } - else if vcs.count > 1 { - self.selectedIndex = index+1 + + // force refresh to work around UITabBarController bug + if idx < vcs.count-1 { + self.selectedIndex = idx+1 + } else if idx > 0 { + self.selectedIndex = idx-1 } - self.selectedIndex = index + self.selectedIndex = idx } func preloadTabs() { diff --git a/RaceSync/UI Extensions/UITableViewCell+Reuse.swift b/RaceSync/UI Extensions/UITableViewCell+Reuse.swift index 398ccb9a..cd9f886a 100644 --- a/RaceSync/UI Extensions/UITableViewCell+Reuse.swift +++ b/RaceSync/UI Extensions/UITableViewCell+Reuse.swift @@ -27,8 +27,7 @@ extension UITableView { register(cellType.self, forCellReuseIdentifier: reuseIdentifier) } - func dequeueReusableCell(forIndexPath indexPath: IndexPath, - identifier: String? = nil) -> T { + func dequeueReusableCell(forIndexPath indexPath: IndexPath, identifier: String? = nil) -> T { let reuseIdentifier = identifier ?? T.reuseIdentifier guard let cell = dequeueReusableCell(withIdentifier: reuseIdentifier, for: indexPath) as? T else { fatalError("Could not dequeue cell with identifier: \(reuseIdentifier)") diff --git a/RaceSync/UI Extensions/UIViewController+Lookup.swift b/RaceSync/UI Extensions/UIViewController+Lookup.swift index 8d4c2f40..dbe2603b 100644 --- a/RaceSync/UI Extensions/UIViewController+Lookup.swift +++ b/RaceSync/UI Extensions/UIViewController+Lookup.swift @@ -10,7 +10,6 @@ import UIKit extension UIViewController { - // TODO: 'keyWindow' was deprecated in iOS 13.0: Should not be used for applications that support multiple scenes static func topMostViewController() -> UIViewController? { guard let window = UIApplication.shared.keyWindow, let rootViewController = window.rootViewController else { return nil diff --git a/RaceSync/UI Extensions/UIViewController+Navigation.swift b/RaceSync/UI Extensions/UIViewController+Navigation.swift index 6137aa04..e56cb591 100644 --- a/RaceSync/UI Extensions/UIViewController+Navigation.swift +++ b/RaceSync/UI Extensions/UIViewController+Navigation.swift @@ -12,11 +12,20 @@ extension UIViewController { var topOffset: CGFloat { get { - let status_height = UIApplication.shared.statusBarFrame.height - let navi_height = navigationController?.navigationBar.frame.size.height ?? 44 - return status_height + navi_height + var height = UIApplication.shared.statusBarFrame.height + height += navigationController?.navigationBar.frame.size.height ?? 44.0 + return height } } + + func hideNavigationShadow(_ hide: Bool = true) { + guard let nc = navigationController else { return } + + // By masking to bounds, the shadow of a navigation bar is no longer visible + // This trick only works when the backgroud of view behind the navigation bar is the same color + // It cannot be used for transitioning to more complicated views. + nc.navigationBar.layer.masksToBounds = hide + } } protocol ScrollToTop where Self: UIViewController { diff --git a/RaceSync/UI Utils/AlertUtil.swift b/RaceSync/UI Utils/AlertUtil.swift index c22c7e7e..ce854ed2 100644 --- a/RaceSync/UI Utils/AlertUtil.swift +++ b/RaceSync/UI Utils/AlertUtil.swift @@ -14,15 +14,15 @@ public typealias AlertTextfieldCompletionBlock = ([String: String]) -> Void // Thread-safe Alert convenience methods class AlertUtil { - static func presentAlertMessage(_ message: String?, title: String? = nil, buttonTitle: String? = nil, delay: TimeInterval = 0, completion: AlertCompletionBlock? = nil) { + static func presentAlertMessage(_ message: String?, title: String? = nil, okTitle: String? = nil, cancelTitle: String? = nil, delay: TimeInterval = 0, completion: AlertCompletionBlock? = nil) { DispatchQueue.main.async { let alert = UIAlertController(title: title ?? "Something Went Wrong", message: message ?? "Please try again.", preferredStyle: .alert) alert.view.tintColor = Color.blue - alert.addAction(UIAlertAction(title: buttonTitle ?? "Ok", style: .default, handler: completion)) - if buttonTitle != nil { - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) + alert.addAction(UIAlertAction(title: okTitle ?? "Ok", style: .default, handler: completion)) + if okTitle != nil { + alert.addAction(UIAlertAction(title: cancelTitle ?? "Cancel", style: .cancel, handler: nil)) } DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: { diff --git a/RaceSync/UI Utils/AppUtil.swift b/RaceSync/UI Utils/AppUtil.swift index b1974468..91f85333 100644 --- a/RaceSync/UI Utils/AppUtil.swift +++ b/RaceSync/UI Utils/AppUtil.swift @@ -10,7 +10,7 @@ import UIKit struct AppUtil { - static func lockOrientation(_ orientation: UIInterfaceOrientationMask) { + static func lock(_ orientation: UIInterfaceOrientationMask) { if let delegate = UIApplication.shared.delegate as? AppDelegate { delegate.orientationLock = orientation } @@ -20,7 +20,7 @@ struct AppUtil { static func lockOrientation(_ orientation: UIInterfaceOrientationMask, andRotateTo rotateOrientation: UIInterfaceOrientation) { UIView.performWithoutAnimation { - self.lockOrientation(orientation) + self.lock(orientation) UIDevice.current.setValue(rotateOrientation.rawValue, forKey: "orientation") UINavigationController.attemptRotationToDeviceOrientation() } diff --git a/RaceSync/UI Utils/CalendarActivity.swift b/RaceSync/UI Utils/CalendarActivity.swift index 4116db2a..da37b9e0 100644 --- a/RaceSync/UI Utils/CalendarActivity.swift +++ b/RaceSync/UI Utils/CalendarActivity.swift @@ -26,7 +26,7 @@ class CalendarActivity: UIActivity { } override var activityImage: UIImage? { - return UIImage(named: "icn_activity_calendar") + return LogoImg.activity_calendar } override func canPerform(withActivityItems activityItems: [Any]) -> Bool { diff --git a/RaceSync/UI Utils/CalendarUtil.swift b/RaceSync/UI Utils/CalendarUtil.swift index fbb3247f..5163c2ec 100644 --- a/RaceSync/UI Utils/CalendarUtil.swift +++ b/RaceSync/UI Utils/CalendarUtil.swift @@ -32,25 +32,25 @@ class CalendarUtil { ekevent.location = event.location ekevent.notes = event.description ekevent.startDate = event.startDate - ekevent.endDate = (event.endDate != nil) ? event.endDate : event.startDate.advanced(by: 3600) // add 1 hour diff - ekevent.url = event.url + ekevent.endDate = (event.endDate != nil) ? event.endDate : event.startDate.advanced(by: 3600 * 5) // add 5 hours diff ekevent.calendar = eventStore.defaultCalendarForNewEvents ekevent.isAllDay = false + ekevent.url = event.url do { try eventStore.save(ekevent, span: .thisEvent) // saves the event to the calendar - var buttonTitle: String? = nil + var okTitle: String? = nil var completion: AlertCompletionBlock? if let calendarURL = URL(string: ExternalAppUri.Calendar), UIApplication.shared.canOpenURL(calendarURL) { - buttonTitle = "View Calendar" + okTitle = "View Calendar" completion = { action in UIApplication.shared.open(calendarURL, options: [:], completionHandler: nil) } } - AlertUtil.presentAlertMessage("\(event.title) saved in your calendar!", title: "Event Saved", buttonTitle: buttonTitle, delay: 0.5, completion: completion) + AlertUtil.presentAlertMessage("\(event.title) saved in your calendar!", title: "Event Saved", okTitle: okTitle, delay: 0.5, completion: completion) } catch { Clog.log("error saving to calendar: \(error.localizedDescription)") diff --git a/RaceSync/UI Utils/Color.swift b/RaceSync/UI Utils/Color.swift index abaafbd4..e6910e68 100644 --- a/RaceSync/UI Utils/Color.swift +++ b/RaceSync/UI Utils/Color.swift @@ -33,5 +33,6 @@ public struct Color { public static let clear: UIColor = #colorLiteral(red: 0, green: 0, blue: 0, alpha: 0) // #000000 // UI specific - public static let navigationBarColor = Color.white.withAlphaComponent(0.97) + public static let navigationBarColor = Color.white.withAlphaComponent(0.98) + public static let link = Color.red } diff --git a/RaceSync/UI Utils/CopyLinkActivity.swift b/RaceSync/UI Utils/CopyLinkActivity.swift index d38391b3..b1986f89 100644 --- a/RaceSync/UI Utils/CopyLinkActivity.swift +++ b/RaceSync/UI Utils/CopyLinkActivity.swift @@ -15,7 +15,7 @@ class CopyLinkActivity: UIActivity { } override var activityImage: UIImage? { - return UIImage(named: "icn_activity_copylink") + return LogoImg.activity_copylink } override func canPerform(withActivityItems activityItems: [Any]) -> Bool { diff --git a/RaceSync/UI Utils/PaypalActivity.swift b/RaceSync/UI Utils/PaypalActivity.swift index 72420b9b..e155478d 100644 --- a/RaceSync/UI Utils/PaypalActivity.swift +++ b/RaceSync/UI Utils/PaypalActivity.swift @@ -10,9 +10,13 @@ import UIKit class PaypalActivity: UIActivity { - override var activityTitle: String? { "Open PayPal"} + override var activityTitle: String? { + "Open PayPal" + } - override var activityImage: UIImage? { UIImage(named: "icn_activity_paypal") } + override var activityImage: UIImage? { + LogoImg.activity_paypal + } private var paypalURL: URL? { [ExternalAppUri.Paypal, ExternalAppUrl.Paypal] diff --git a/RaceSync/UI Utils/SafariActivity.swift b/RaceSync/UI Utils/SafariActivity.swift index 0fb38833..8673bc4f 100644 --- a/RaceSync/UI Utils/SafariActivity.swift +++ b/RaceSync/UI Utils/SafariActivity.swift @@ -16,7 +16,7 @@ class SafariActivity: UIActivity { } override var activityImage: UIImage? { - return UIImage(named: "icn_activity_safari") + return LogoImg.activity_safari } override func canPerform(withActivityItems activityItems: [Any]) -> Bool { @@ -48,13 +48,30 @@ class SafariActivity: UIActivity { fileprivate var _url : URL? = nil } -class MultiGPActivity: SafariActivity { +class MGPActivity: SafariActivity { override var activityTitle: String? { - return "View on MultiGP" + return "View on multigp.com" } override var activityImage: UIImage? { - return UIImage(named: "icn_activity_mgp") + return LogoImg.activity_mgp + } +} + +class MGPLeaderboardActivity: MGPActivity { + + var chapterId: ObjectId? + + override var activityTitle: String? { + return "View chapter leaderboard" + } + + override var _url : URL? { + get { + guard let id = chapterId else { return nil } + return MGPWeb.getURL(for: .chapterLeaderboard, value: id) + } + set { } } } diff --git a/RaceSync/UI Utils/SocialActivity.swift b/RaceSync/UI Utils/SocialActivity.swift index b82120f1..e5cb8523 100644 --- a/RaceSync/UI Utils/SocialActivity.swift +++ b/RaceSync/UI Utils/SocialActivity.swift @@ -66,13 +66,13 @@ class SocialActivity: UIActivity { override var activityImage: UIImage? { switch platform { - case .livefpv: return UIImage(named: "icn_activity_livefpv") - case .facebook: return UIImage(named: "icn_activity_facebook") - case .twitter: return UIImage(named: "icn_activity_twitter") - case .youtube: return UIImage(named: "icn_activity_youtube") - case .instagram: return UIImage(named: "icn_activity_instagram") - case .meetup: return UIImage(named: "icn_activity_meetup") - case .website: return UIImage(named: "icn_activity_safari") + case .livefpv: return LogoImg.activity_livefpv + case .facebook: return LogoImg.activity_facebook + case .twitter: return LogoImg.activity_twitter + case .youtube: return LogoImg.activity_youtube + case .instagram: return LogoImg.activity_instagram + case .meetup: return LogoImg.activity_meetup + case .website: return LogoImg.activity_safari } } diff --git a/RaceSync/UI Utils/ViewJoinable.swift b/RaceSync/UI Utils/ViewJoinable.swift index a826eb1a..baa896e5 100644 --- a/RaceSync/UI Utils/ViewJoinable.swift +++ b/RaceSync/UI Utils/ViewJoinable.swift @@ -61,7 +61,7 @@ extension ViewJoinable { switch state { case .notJoined, .notPaid(_): - if let endDate = race.endDate, endDate.isPassed { + if race.hasEnded { AlertUtil.presentAlertMessage("Cannot join a passed race.", title: "Uh Oh", delay: 0.5, @@ -88,11 +88,12 @@ extension ViewJoinable { switch state { case .notJoined: - AppControl.shared.resign(chapter: chapter, chapterApi: chapterApi) { (newState) in + AppControl.shared.join(chapter: chapter, chapterApi: chapterApi) { (newState) in self.handleStateChange(state, newState: newState, in: button, with: chapter, completion) } + case .joined: - AppControl.shared.join(chapter: chapter, chapterApi: chapterApi) { (newState) in + AppControl.shared.resign(chapter: chapter, chapterApi: chapterApi) { (newState) in self.handleStateChange(state, newState: newState, in: button, with: chapter, completion) } default: diff --git a/RaceSync/View Cells/AvatarTableViewCell.swift b/RaceSync/View Cells/AvatarTableViewCell.swift index 6a5ad912..bce852b6 100644 --- a/RaceSync/View Cells/AvatarTableViewCell.swift +++ b/RaceSync/View Cells/AvatarTableViewCell.swift @@ -71,9 +71,10 @@ class AvatarTableViewCell: UITableViewCell { return view }() + fileprivate var imageViewLeadingConstraint: Constraint? + fileprivate var imageViewWidthConstraint: Constraint? fileprivate var rankLabelWidthConstraint: Constraint? - fileprivate var leftSpacingConstraint: Constraint? - fileprivate var avatarImageViewWidthConstraint: Constraint? + fileprivate var rankLabelLeadingConstraint: Constraint? fileprivate enum Constants { static let padding: CGFloat = UniversalConstants.padding @@ -111,11 +112,13 @@ class AvatarTableViewCell: UITableViewCell { contentView.addSubview(rankView) rankView.snp.makeConstraints { - $0.leading.equalToSuperview().offset(Constants.padding) $0.centerY.equalToSuperview() rankLabelWidthConstraint = $0.width.greaterThanOrEqualTo(Constants.imageHeight / 2).constraint rankLabelWidthConstraint?.activate() + + rankLabelLeadingConstraint = $0.leading.equalToSuperview().offset(Constants.padding).constraint + rankLabelLeadingConstraint?.activate() } contentView.addSubview(avatarImageView) @@ -123,11 +126,11 @@ class AvatarTableViewCell: UITableViewCell { $0.height.equalTo(Constants.imageHeight) $0.centerY.equalToSuperview() - leftSpacingConstraint = $0.leading.equalTo(rankView.snp.trailing).offset(Constants.padding/2).constraint - leftSpacingConstraint?.activate() + imageViewLeadingConstraint = $0.leading.equalTo(rankView.snp.trailing).offset(Constants.padding/2).constraint + imageViewLeadingConstraint?.activate() - avatarImageViewWidthConstraint = $0.width.equalTo(Constants.imageHeight).constraint - avatarImageViewWidthConstraint?.activate() + imageViewWidthConstraint = $0.width.equalTo(Constants.imageHeight).constraint + imageViewWidthConstraint?.activate() } contentView.addSubview(textStackView) @@ -140,15 +143,17 @@ class AvatarTableViewCell: UITableViewCell { override func layoutSubviews() { super.layoutSubviews() - avatarImageViewWidthConstraint?.update(offset: avatarImageView.isHidden ? 0 : Constants.imageHeight) + imageViewWidthConstraint?.update(offset: avatarImageView.isHidden ? 0 : Constants.imageHeight) if rankView.isHidden { + imageViewLeadingConstraint?.update(offset: 0) rankLabelWidthConstraint?.update(offset: 0) - leftSpacingConstraint?.update(offset: 0) - - } else { + rankLabelLeadingConstraint?.update(offset: 0) rankLabelWidthConstraint?.deactivate() - leftSpacingConstraint?.update(offset: Constants.padding/2) + } else { + imageViewLeadingConstraint?.update(offset: Constants.padding/2) + rankLabelWidthConstraint?.activate() + rankLabelLeadingConstraint?.update(offset: Constants.padding) } } } diff --git a/RaceSync/View Cells/ChapterTableViewCell.swift b/RaceSync/View Cells/ChapterTableViewCell.swift index f8a1add6..76dccf29 100644 --- a/RaceSync/View Cells/ChapterTableViewCell.swift +++ b/RaceSync/View Cells/ChapterTableViewCell.swift @@ -65,7 +65,7 @@ class ChapterTableViewCell: UITableViewCell { self.accessoryType = .disclosureIndicator let selectedBackgroundView = UIView() - selectedBackgroundView.backgroundColor = Color.gray50 + selectedBackgroundView.backgroundColor = Color.gray20 self.selectedBackgroundView = selectedBackgroundView contentView.addSubview(avatarImageView) diff --git a/RaceSync/View Cells/ChapterUserTableViewCell.swift b/RaceSync/View Cells/ChapterUserTableViewCell.swift index ce5d6887..4f52ae02 100644 --- a/RaceSync/View Cells/ChapterUserTableViewCell.swift +++ b/RaceSync/View Cells/ChapterUserTableViewCell.swift @@ -7,6 +7,7 @@ // import UIKit +import SnapKit class ChapterUserTableViewCell: AvatarTableViewCell { diff --git a/RaceSync/View Cells/FormTableViewCell.swift b/RaceSync/View Cells/FormTableViewCell.swift index 3c07043b..5f88ea01 100644 --- a/RaceSync/View Cells/FormTableViewCell.swift +++ b/RaceSync/View Cells/FormTableViewCell.swift @@ -68,7 +68,7 @@ class FormTableViewCell: UITableViewCell { fileprivate func setupLayout() { let selectedBackgroundView = UIView() - selectedBackgroundView.backgroundColor = Color.gray50 + selectedBackgroundView.backgroundColor = Color.gray20 self.selectedBackgroundView = selectedBackgroundView accessoryType = .disclosureIndicator diff --git a/RaceSync/View Cells/MessageViewCell.swift b/RaceSync/View Cells/MessageViewCell.swift index 70415aa8..f007827f 100644 --- a/RaceSync/View Cells/MessageViewCell.swift +++ b/RaceSync/View Cells/MessageViewCell.swift @@ -87,7 +87,7 @@ class MessageViewCell: UITableViewCell { fileprivate func setupLayout() { let selectedBackgroundView = UIView() - selectedBackgroundView.backgroundColor = Color.gray50 + selectedBackgroundView.backgroundColor = Color.gray20 self.selectedBackgroundView = selectedBackgroundView accessoryType = .disclosureIndicator diff --git a/RaceSync/View Cells/RaceTableViewCell.swift b/RaceSync/View Cells/RaceTableViewCell.swift index 33825aaa..74b7ad58 100644 --- a/RaceSync/View Cells/RaceTableViewCell.swift +++ b/RaceSync/View Cells/RaceTableViewCell.swift @@ -97,7 +97,7 @@ class RaceTableViewCell: UITableViewCell { fileprivate func setupLayout() { let selectedBackgroundView = UIView() - selectedBackgroundView.backgroundColor = Color.gray50 + selectedBackgroundView.backgroundColor = Color.gray20 self.selectedBackgroundView = selectedBackgroundView contentView.addSubview(avatarImageView) @@ -109,7 +109,6 @@ class RaceTableViewCell: UITableViewCell { contentView.addSubview(buttonStackView) buttonStackView.snp.makeConstraints { - $0.width.greaterThanOrEqualTo(Constants.minButtonSize) $0.trailing.equalToSuperview().offset(-Constants.padding) $0.centerY.equalToSuperview() } diff --git a/RaceSync/View Cells/SimpleTableViewCell.swift b/RaceSync/View Cells/SimpleTableViewCell.swift index 355eb98a..41c3feb8 100644 --- a/RaceSync/View Cells/SimpleTableViewCell.swift +++ b/RaceSync/View Cells/SimpleTableViewCell.swift @@ -13,11 +13,22 @@ class SimpleTableViewCell: UITableViewCell { // MARK: - Public Variables + var imageRatio: CGFloat = 1 { + didSet { + iconImageView.snp.updateConstraints { make in + make.width.equalTo(Constants.imageHeight * imageRatio) + } + + imageViewWidthConstraint?.update(offset: Constants.imageHeight * imageRatio) + } + } + fileprivate var imageViewWidthConstraint: Constraint? + lazy var iconImageView: UIImageView = { - let imageView = UIImageView() - imageView.backgroundColor = Color.clear - imageView.clipsToBounds = true - return imageView + let view = UIImageView() + view.backgroundColor = Color.clear + view.clipsToBounds = true + return view }() lazy var titleLabel: UILabel = { @@ -39,7 +50,7 @@ class SimpleTableViewCell: UITableViewCell { fileprivate lazy var labelStackView: UIStackView = { let stackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) stackView.axis = .vertical - stackView.distribution = .fillEqually + stackView.distribution = .fillProportionally stackView.alignment = .leading stackView.spacing = 2 return stackView @@ -66,14 +77,18 @@ class SimpleTableViewCell: UITableViewCell { open func setupLayout() { let selectedBackgroundView = UIView() - selectedBackgroundView.backgroundColor = Color.gray50 + selectedBackgroundView.backgroundColor = Color.gray20 self.selectedBackgroundView = selectedBackgroundView contentView.addSubview(iconImageView) iconImageView.snp.makeConstraints { - $0.height.width.equalTo(Constants.imageHeight) + $0.height.equalTo(Constants.imageHeight) + $0.width.equalTo(Constants.imageHeight * imageRatio) $0.leading.equalToSuperview().offset(Constants.padding) $0.centerY.equalToSuperview() + +// imageViewWidthConstraint = $0.width.equalTo(Constants.imageHeight * imageRatio).constraint +// imageViewWidthConstraint?.activate() } contentView.addSubview(labelStackView) diff --git a/RaceSync/View Cells/TableViewCellShimmerView.swift b/RaceSync/View Cells/TableViewCellShimmerView.swift index 39f02761..07ca7723 100644 --- a/RaceSync/View Cells/TableViewCellShimmerView.swift +++ b/RaceSync/View Cells/TableViewCellShimmerView.swift @@ -21,6 +21,8 @@ class TableViewCellShimmerView: UIView { fatalError("init(coder:) has not been implemented") } + // MARK: - Layout + fileprivate func setupLayout() { guard let image = PlaceholderImg.shimmerList else { return } diff --git a/RaceSync/View Cells/UserRaceTableViewCell.swift b/RaceSync/View Cells/UserRaceTableViewCell.swift index 78c049bc..351a37eb 100644 --- a/RaceSync/View Cells/UserRaceTableViewCell.swift +++ b/RaceSync/View Cells/UserRaceTableViewCell.swift @@ -13,6 +13,10 @@ class UserRaceTableViewCell: UITableViewCell { // MARK: - Public Variables + static var height: CGFloat { + return UniversalConstants.cellHeight + } + lazy var avatarImageView: AvatarImageView = { return AvatarImageView(withHeight: Constants.imageHeight) }() @@ -79,7 +83,7 @@ class UserRaceTableViewCell: UITableViewCell { fileprivate func setupLayout() { let selectedBackgroundView = UIView() - selectedBackgroundView.backgroundColor = Color.gray50 + selectedBackgroundView.backgroundColor = Color.gray20 self.selectedBackgroundView = selectedBackgroundView accessoryType = .disclosureIndicator diff --git a/RaceSync/View Controllers/Gallery/GalleryViewController.swift b/RaceSync/View Controllers/Gallery/GalleryViewController.swift index 3e68e2a2..02f82baf 100644 --- a/RaceSync/View Controllers/Gallery/GalleryViewController.swift +++ b/RaceSync/View Controllers/Gallery/GalleryViewController.swift @@ -165,7 +165,7 @@ class GalleryViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - AppUtil.lockOrientation(.allButUpsideDown) + AppUtil.lock(.allButUpsideDown) if currentPage < 0 { currentPage = initialPage diff --git a/RaceSync/View Controllers/HomeTabBarController.swift b/RaceSync/View Controllers/HomeTabBarController.swift index 0edaf129..ef913cd6 100644 --- a/RaceSync/View Controllers/HomeTabBarController.swift +++ b/RaceSync/View Controllers/HomeTabBarController.swift @@ -26,17 +26,17 @@ class HomeTabBarController: UITabBarController { return RaceFeedViewController(filters, selectedFilter: filters.first!) }() - fileprivate lazy var standingsVC: StandingsViewController = { - return StandingsViewController() - }() - fileprivate lazy var seriesVC: SeriesViewController = { return SeriesViewController() }() + fileprivate lazy var standingsVC: StandingsViewController = { + return StandingsViewController(with: .y2025) + }() + fileprivate lazy var titleView: UIView = { let view = UIView() - let imageView = UIImageView(image: UIImage(named: "racesync_logo_header")) + let imageView = UIImageView(image: LogoImg.header) view.addSubview(imageView) imageView.snp.makeConstraints { $0.centerX.centerY.equalToSuperview() @@ -170,15 +170,6 @@ class HomeTabBarController: UITabBarController { navigationItem.rightBarButtonItem = UIBarButtonItem(customView: rightStackView) } - fileprivate func hideNavigationShadow(_ hide: Bool = true) { - guard let nc = navigationController else { return } - - // By masking to bounds, the shadow of a navigation bar is no longer visible - // This trick only works when the backgroud of view behind the navigation bar is the same color - // It cannot be used for transitioning to more complicated views. - nc.navigationBar.layer.masksToBounds = hide - } - // MARK: - Actions @objc fileprivate func didPressUserProfileButton() { @@ -224,7 +215,7 @@ class HomeTabBarController: UITabBarController { // MARK: - Data Update fileprivate func loadContent() { - let vcs: [UIViewController] = [raceFeedVC, standingsVC, seriesVC] + let vcs: [UIViewController] = [raceFeedVC, seriesVC, standingsVC] let idx = AppPrefs.lastSelectedTab configureTabBarController(with: vcs, selectedIndex: idx) diff --git a/RaceSync/View Controllers/Login/LoginViewController.swift b/RaceSync/View Controllers/Login/LoginViewController.swift index cbe047e3..9054a8f3 100644 --- a/RaceSync/View Controllers/Login/LoginViewController.swift +++ b/RaceSync/View Controllers/Login/LoginViewController.swift @@ -70,7 +70,7 @@ class LoginViewController: UIViewController { fileprivate lazy var passwordRecoveryButton: UIButton = { let button = UIButton(type: .system) button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium) - button.setTitleColor(Color.red, for: .normal) + button.setTitleColor(Color.link, for: .normal) button.setTitle("Forgot your password?", for: .normal) button.addTarget(self, action:#selector(didPressPasswordRecoveryButton), for: .touchUpInside) return button @@ -79,7 +79,7 @@ class LoginViewController: UIViewController { fileprivate lazy var createAccountButton: UIButton = { let button = UIButton(type: .system) button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium) - button.setTitleColor(Color.red, for: .normal) + button.setTitleColor(Color.link, for: .normal) button.setTitle("Create an account", for: .normal) button.addTarget(self, action:#selector(didPressCreateAccountButton), for: .touchUpInside) button.isHidden = true @@ -110,7 +110,7 @@ class LoginViewController: UIViewController { NSAttributedString.Key.foregroundColor: Color.gray200] let linkAttributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 14, weight: .medium), - NSAttributedString.Key.foregroundColor: Color.red] + NSAttributedString.Key.foregroundColor: Color.link] let attributedString = NSMutableAttributedString(string: label, attributes: attributes) attributedString.setAttributes(linkAttributes, range: NSString(string: label).range(of: link)) @@ -290,11 +290,11 @@ class LoginViewController: UIViewController { } @objc func didPressPasswordRecoveryButton() { - WebViewController.openUrl(AppWebConstants.passwordReset) + WebViewController.open(AppWebConstants.passwordReset) } @objc func didPressCreateAccountButton() { - WebViewController.openUrl(AppWebConstants.accountRegistration) + WebViewController.open(AppWebConstants.accountRegistration) } @objc func didPressLoginButton() { @@ -302,7 +302,7 @@ class LoginViewController: UIViewController { } @objc func didPressLegalButton() { - WebViewController.openUrl(AppWebConstants.termsOfUse) + WebViewController.open(AppWebConstants.termsOfUse) } @objc func keyboardWillShow(_ notification: Notification) { diff --git a/RaceSync/View Controllers/Map/MapViewController.swift b/RaceSync/View Controllers/Map/MapViewController.swift index 1fa1f381..0ddec97a 100644 --- a/RaceSync/View Controllers/Map/MapViewController.swift +++ b/RaceSync/View Controllers/Map/MapViewController.swift @@ -47,7 +47,7 @@ class MapViewController: UIViewController { }() fileprivate lazy var navigationBarButtonItem: UIBarButtonItem = { - return UIBarButtonItem(image: UIImage(named: "icn_navbar_directions"), style: .done, target: self, action: #selector(didPressDirectionsButton)) + return UIBarButtonItem(image: ButtonImg.directions, style: .done, target: self, action: #selector(didPressDirectionsButton)) }() fileprivate enum Constants { @@ -202,7 +202,7 @@ extension MapViewController: MKMapViewDelegate { view.annotation = annotation } else { annotationView = MKAnnotationView(annotation: annotation, reuseIdentifier: Constants.annotationIdentifier) - annotationView?.image = UIImage(named: "icn_map_annotation") + annotationView?.image = ButtonImg.map_annotation annotationView?.canShowCallout = true } diff --git a/RaceSync/View Controllers/Profiles/ChapterViewController.swift b/RaceSync/View Controllers/Profiles/ChapterViewController.swift index 58a7c257..2985f7d2 100644 --- a/RaceSync/View Controllers/Profiles/ChapterViewController.swift +++ b/RaceSync/View Controllers/Profiles/ChapterViewController.swift @@ -11,7 +11,11 @@ import RaceSyncAPI import EmptyDataSet_Swift import CoreLocation -class ChapterViewController: ProfileViewController, ViewJoinable { +class ChapterViewController: ProfileViewController, ViewJoinable, RaceEditable { + + // MARK: - Public Variables + + var raceController: RaceController? // MARK: - Private Variables @@ -22,23 +26,10 @@ class ChapterViewController: ProfileViewController, ViewJoinable { button.type = .chapter button.objectId = chapter.id button.joinState = chapterViewModel.joinState + button.isHidden = !chapter.isApproved return button }() - fileprivate func raceViewModel(for index: Int) -> RaceViewModel? { - if index >= 0, index < raceViewModels.count { - return raceViewModels[index] - } - return nil - } - - fileprivate func userViewModel(for index: Int) -> UserViewModel? { - if index >= 0, index < userViewModels.count { - return userViewModels[index] - } - return nil - } - fileprivate let chapter: Chapter fileprivate let raceApi = RaceApi() fileprivate let chapterApi = ChapterApi() @@ -86,15 +77,6 @@ class ChapterViewController: ProfileViewController, ViewJoinable { override func viewDidLoad() { super.viewDidLoad() - registerJoinable() - configureBarButtonItems() - - tableView.register(cellType: RaceTableViewCell.self) - tableView.register(cellType: AvatarTableViewCell.self) - tableView.dataSource = self - tableView.emptyDataSetSource = self - tableView.emptyDataSetDelegate = self - loadContent() } @@ -119,6 +101,20 @@ class ChapterViewController: ProfileViewController, ViewJoinable { override func setupLayout() { super.setupLayout() + registerJoinable() + configureBarButtonItems() + + tableView.register(cellType: RaceTableViewCell.self) + tableView.register(cellType: AvatarTableViewCell.self) + tableView.dataSource = self + tableView.emptyDataSetSource = self + tableView.emptyDataSetDelegate = self + + let longPress = UILongPressGestureRecognizer(target: self,action: #selector(didLongPress(_:))) + longPress.minimumPressDuration = 0.3 + longPress.delaysTouchesBegan = true + tableView.addGestureRecognizer(longPress) + headerView.addSubview(joinButton) joinButton.snp.makeConstraints { $0.bottom.equalToSuperview() @@ -216,10 +212,17 @@ class ChapterViewController: ProfileViewController, ViewJoinable { } } + @objc func didLongPress(_ gesture: UIGestureRecognizer) { + handleLongPress(gesture) + } + @objc func didPressShareButton() { guard let chapterURL = URL(string: chapter.url) else { return } - var activities: [UIActivity] = [CopyLinkActivity(), MultiGPActivity()] + let leaderboardActivity = MGPLeaderboardActivity() + leaderboardActivity.chapterId = chapter.id + + var activities: [UIActivity] = [MGPActivity(), leaderboardActivity, CopyLinkActivity()] activities += chapter.socialActivities() let vc = UIActivityViewController(activityItems: [chapterURL], applicationActivities: activities) @@ -237,34 +240,20 @@ class ChapterViewController: ProfileViewController, ViewJoinable { // ViewJoinable func loadContent(forced: Bool = false) { if selectedSegment == .left { - loadRaces(forced: forced) + loadRaces(forced) } else { - loadUsers(forced: forced) + loadUsers(forced) } } - func loadRaces(forced: Bool = false) { - if raceViewModels.isEmpty || forced { - isLoadingList(true) - - fetchRaces { [weak self] in - self?.isLoadingList(false) - } - } else { - tableView.reloadData() - } + fileprivate func loadRaces(_ forced: Bool = false) { + loadList(forced: forced, isEmpty: raceViewModels.isEmpty, + segment: .left, fetch: fetchRaces) } - func loadUsers(forced: Bool = false) { - if userViewModels.isEmpty || forced { - isLoadingList(true) - - fetchUsers { [weak self] in - self?.isLoadingList(false) - } - } else { - tableView.reloadData() - } + fileprivate func loadUsers(_ forced: Bool = false) { + loadList(forced: forced, isEmpty: userViewModels.isEmpty, + segment: .right, fetch: fetchUsers) } func fetchRaces(_ completion: VoidCompletionBlock? = nil) { @@ -299,6 +288,55 @@ class ChapterViewController: ProfileViewController, ViewJoinable { completion?() } } + + fileprivate func loadList(forced: Bool, + isEmpty: Bool, + segment: ProfileSegment, + fetch: (@escaping () -> Void) -> Void ){ + + guard isEmpty || forced else { + tableView.reloadData() + return + } + + let showShimmer = shouldShowShimmer(for: segment) + + if showShimmer { + isLoadingList(true) + } + + fetch { [weak self] in + guard let self else { return } + + if showShimmer { + self.isLoadingList(false) // triggers its own reload + } else { + self.tableView.reloadData() + } + } + } + + func shouldShowShimmer(for segment: ProfileSegment) -> Bool { + if selectedSegment == .left { + return raceViewModels.count == 0 + } else { + return userViewModels.count == 0 + } + } + + func raceViewModel(for index: Int) -> RaceViewModel? { + if index >= 0, index < raceViewModels.count { + return raceViewModels[index] + } + return nil + } + + func userViewModel(for index: Int) -> UserViewModel? { + if index >= 0, index < userViewModels.count { + return userViewModels[index] + } + return nil + } } // MARK: - UITableView DataSource @@ -322,7 +360,7 @@ extension ChapterViewController: UITableViewDataSource { } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return UniversalConstants.cellHeight + return RaceTableViewCell.height } func raceTableViewCell(for indexPath: IndexPath) -> RaceTableViewCell { @@ -331,13 +369,22 @@ extension ChapterViewController: UITableViewDataSource { cell.dateLabel.text = viewModel.startDateLabel //"Saturday Sept 14 @ 9:00 AM" cell.titleLabel.text = viewModel.titleLabel - cell.joinButton.type = .race - cell.joinButton.objectId = viewModel.race.id - cell.joinButton.joinState = viewModel.joinState - cell.joinButton.addTarget(self, action: #selector(didPressJoinButton), for: .touchUpInside) - cell.memberBadgeView.count = viewModel.participantCount cell.avatarImageView.imageView.setImage(with: viewModel.imageUrl, placeholderImage: PlaceholderImg.medium, size: Constants.avatarImageSize) cell.subtitleLabel.text = viewModel.locationLabel + cell.joinButton.isHidden = false + cell.memberBadgeView.isHidden = false + + if chapter.isApproved { + cell.joinButton.type = .race + cell.joinButton.objectId = viewModel.race.id + cell.joinButton.joinState = viewModel.joinState + cell.joinButton.addTarget(self, action: #selector(didPressJoinButton), for: .touchUpInside) + cell.memberBadgeView.count = viewModel.participantCount + } else { + cell.joinButton.isHidden = true + cell.memberBadgeView.isHidden = true + } + return cell } @@ -357,7 +404,6 @@ extension ChapterViewController: RaceFormViewControllerDelegate { func raceFormViewController(_ viewController: RaceFormViewController, didUpdateRace race: Race) { let vc = RaceTabBarController(with: race) vc.isDismissable = true - viewController.navigationController?.pushViewController(vc, animated: true) } diff --git a/RaceSync/View Controllers/Profiles/UserViewController.swift b/RaceSync/View Controllers/Profiles/UserViewController.swift index 3e6a54fa..5c72208a 100644 --- a/RaceSync/View Controllers/Profiles/UserViewController.swift +++ b/RaceSync/View Controllers/Profiles/UserViewController.swift @@ -14,40 +14,32 @@ import EmptyDataSet_Swift import CoreLocation import QRCode -class UserViewController: ProfileViewController, ViewJoinable { +class UserViewController: ProfileViewController, ViewJoinable, RaceEditable { + + // MARK: - Public Variables + + var raceController: RaceController? // MARK: - Private Variables fileprivate lazy var qrButton: UIButton = { let button = UIButton(type: .system) button.addTarget(self, action: #selector(didPressQRButton), for: .touchUpInside) - button.setImage(UIImage(named: "icn_qrcode"), for: .normal) + button.setImage(ButtonImg.qrcode, for: .normal) button.setBackgroundImage(nil, for: .normal) return button }() - fileprivate func raceViewModel(for index: Int) -> RaceViewModel? { - if index >= 0, index < raceViewModels.count { - return raceViewModels[index] - } - return nil - } - - fileprivate func chapterViewModel(for index: Int) -> ChapterViewModel? { - if index >= 0, index < chapterViewModels.count { - return chapterViewModels[index] - } - return nil - } - - fileprivate let user: User + fileprivate var user: User fileprivate let raceApi = RaceApi() fileprivate let chapterApi = ChapterApi() + fileprivate let userApi = UserApi() fileprivate var raceViewModels = [RaceViewModel]() fileprivate var chapterViewModels = [ChapterViewModel]() fileprivate var presenter: Presentr? fileprivate var userCoordinates: CLLocationCoordinate2D? + fileprivate var isPhotoEditale = false fileprivate let emptyStateRaces = EmptyStateViewModel(.noProfileRaces) fileprivate let emptyStateChapters = EmptyStateViewModel(.noProfileChapters) @@ -56,7 +48,6 @@ class UserViewController: ProfileViewController, ViewJoinable { fileprivate enum Constants { static let padding: CGFloat = UniversalConstants.padding - static let cellHeight: CGFloat = UniversalConstants.cellHeight static let buttonHeight: CGFloat = 32 static let buttonSpacing: CGFloat = 12 static let avatarImageSize = CGSize(width: 50, height: 50) @@ -84,15 +75,6 @@ class UserViewController: ProfileViewController, ViewJoinable { override func viewDidLoad() { super.viewDidLoad() - registerJoinable() - configureBarButtonItems() - - tableView.register(cellType: UserRaceTableViewCell.self) - tableView.register(cellType: ChapterTableViewCell.self) - tableView.dataSource = self - tableView.emptyDataSetSource = self - tableView.emptyDataSetDelegate = self - loadContent() } @@ -112,6 +94,24 @@ class UserViewController: ProfileViewController, ViewJoinable { override func setupLayout() { super.setupLayout() + + registerJoinable() + configureBarButtonItems() + + tableView.register(cellType: UserRaceTableViewCell.self) + tableView.register(cellType: ChapterTableViewCell.self) + tableView.dataSource = self + tableView.emptyDataSetSource = self + tableView.emptyDataSetDelegate = self + + let longPress = UILongPressGestureRecognizer(target: self,action: #selector(didLongPress(_:))) + longPress.minimumPressDuration = 0.3 + longPress.delaysTouchesBegan = true + tableView.addGestureRecognizer(longPress) + + headerView.isEditable = user.isMe && isPhotoEditale + headerView.avatarView.isUserInteractionEnabled = isPhotoEditale + headerView.delegate = self } fileprivate func configureBarButtonItems() { @@ -202,10 +202,14 @@ class UserViewController: ProfileViewController, ViewJoinable { } } + @objc func didLongPress(_ gesture: UIGestureRecognizer) { + handleLongPress(gesture) + } + @objc func didPressShareButton() { guard let userURL = URL(string: user.url) else { return } - let activities: [UIActivity] = [CopyLinkActivity(), MultiGPActivity()] + let activities: [UIActivity] = [MGPActivity(), CopyLinkActivity()] let vc = UIActivityViewController(activityItems: [userURL], applicationActivities: activities) vc.excludeAllActivityTypes(except: [.airDrop]) @@ -223,31 +227,17 @@ class UserViewController: ProfileViewController, ViewJoinable { } } - func loadRaces(_ forced: Bool = false) { - if raceViewModels.isEmpty || forced { - isLoadingList(true) - - fetchRaces { [weak self] in - self?.isLoadingList(false) - } - } else { - tableView.reloadData() - } + fileprivate func loadRaces(_ forced: Bool = false) { + loadList(forced: forced, isEmpty: raceViewModels.isEmpty, + segment: .left, fetch: fetchRaces) } - func loadChapters(_ forced: Bool = false) { - if chapterViewModels.isEmpty || forced { - isLoadingList(true) - - fetchChapters { [weak self] in - self?.isLoadingList(false) - } - } else { - tableView.reloadData() - } + fileprivate func loadChapters(_ forced: Bool = false) { + loadList(forced: forced, isEmpty: raceViewModels.isEmpty, + segment: .right, fetch: fetchChapters) } - func fetchRaces(_ completion: VoidCompletionBlock? = nil) { + fileprivate func fetchRaces(_ completion: VoidCompletionBlock? = nil) { raceApi.getRaces(with: [.joined], userId: user.id) { (races, error) in if let races = races { let sortedRaces = races.sorted(by: { $0.startDate?.compare($1.startDate ?? Date()) == .orderedDescending }) @@ -260,7 +250,7 @@ class UserViewController: ProfileViewController, ViewJoinable { } } - func fetchChapters(_ completion: VoidCompletionBlock? = nil) { + fileprivate func fetchChapters(_ completion: VoidCompletionBlock? = nil) { chapterApi.getChapters(forUser: user.id) { [weak self] (chapters, error) in guard let strongSelf = self else { return } @@ -285,6 +275,55 @@ class UserViewController: ProfileViewController, ViewJoinable { completion?() } } + + fileprivate func loadList(forced: Bool, + isEmpty: Bool, + segment: ProfileSegment, + fetch: (@escaping () -> Void) -> Void ){ + + guard isEmpty || forced else { + tableView.reloadData() + return + } + + let showShimmer = shouldShowShimmer(for: segment) + + if showShimmer { + isLoadingList(true) + } + + fetch { [weak self] in + guard let self else { return } + + if showShimmer { + self.isLoadingList(false) // triggers its own reload + } else { + self.tableView.reloadData() + } + } + } + + func shouldShowShimmer(for segment: ProfileSegment) -> Bool { + if selectedSegment == .left { + return raceViewModels.count == 0 + } else { + return chapterViewModels.count == 0 + } + } + + func raceViewModel(for index: Int) -> RaceViewModel? { + if index >= 0, index < raceViewModels.count { + return raceViewModels[index] + } + return nil + } + + func chapterViewModel(for index: Int) -> ChapterViewModel? { + if index >= 0, index < chapterViewModels.count { + return chapterViewModels[index] + } + return nil + } } // MARK: - UITableView DataSource @@ -308,7 +347,7 @@ extension UserViewController: UITableViewDataSource { } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return Constants.cellHeight + return UserRaceTableViewCell.height } func userRaceTableViewCell(for indexPath: IndexPath) -> UserRaceTableViewCell { @@ -337,6 +376,32 @@ extension UserViewController: UITableViewDataSource { } } +extension UserViewController: ProfileHeaderViewDelegate { + + func shouldUploadImage(_ image: UIImage, imageType: ImageType, for id: ObjectId) { + + userApi.uploadProfileImage(image, imageType: imageType) { [weak self] (url, error) in + if let url = url { + self?.updateUserProfileUrl(url, for: imageType) + } else { + AlertUtil.presentAlertMessage(error?.localizedDescription) + } + } + } + + func updateUserProfileUrl(_ url: String, for imageType: ImageType) { + + if imageType == .main { + user.profilePictureUrl = url + } else { + user.profileBackgroundUrl = url + } + + let viewModel = ProfileViewModel(with: user) + headerView.viewModel = viewModel + } +} + extension UserViewController: EmptyDataSetSource { func getEmptyStateViewModel() -> EmptyStateViewModel { diff --git a/RaceSync/View Controllers/Races/ChapterPickerViewController.swift b/RaceSync/View Controllers/Races/ChapterPickerViewController.swift index 4ee5593f..cde104fd 100644 --- a/RaceSync/View Controllers/Races/ChapterPickerViewController.swift +++ b/RaceSync/View Controllers/Races/ChapterPickerViewController.swift @@ -219,7 +219,7 @@ extension ChapterPickerViewController: UITableViewDataSource { if let selectedId = selectedChapterId { if viewModel.chapter.id == selectedId { - let imageView = UIImageView(image: UIImage(named: "icn_cell_checkmark")) + let imageView = UIImageView(image: ButtonImg.checkmark) imageView.tintColor = Color.blue cell.accessoryView = imageView } else { diff --git a/RaceSync/View Controllers/Races/RaceController.swift b/RaceSync/View Controllers/Races/RaceController.swift index cca31482..bf54ee1c 100644 --- a/RaceSync/View Controllers/Races/RaceController.swift +++ b/RaceSync/View Controllers/Races/RaceController.swift @@ -20,16 +20,17 @@ class RaceController { var parentViewController: RaceTabBarController? = nil var isLoading: Bool = false - // MARK: - Private + var menuCompletion: BoolCompletionBlock? = nil - fileprivate let ignoreFinalizingError: Bool = true // The API finalize(id) still returns 500 error. Reported https://github.com/MultiGP/multigp-com/issues/93 + // MARK: - Private fileprivate var visibleViewController: UIViewController? { - get { return UIViewController.topMostViewController() } + UIViewController.topMostViewController() } - fileprivate var visibleNavigationController: UINavigationController? { - get { return UIViewController.topMostViewController()?.navigationController } + fileprivate var visibleNavigationController: NavigationController? { + (visibleViewController as? NavigationController) + ?? (visibleViewController?.navigationController as? NavigationController) } // MARK: - Initialization @@ -68,16 +69,21 @@ class RaceController { public func reloadRace() { - raceApi.view(race: raceId) { [weak self] race, error in - guard let self = self else { return } + if let completion = menuCompletion { + completion(true) + menuCompletion = nil // invalidate completion right after + } else { + raceApi.view(race: raceId) { [weak self] race, error in + guard let self = self else { return } - if let race = race { - // TODO: Temporary hack since race/view API doesn't include the raceOwnerName attribute - // See issue https://github.com/MultiGP/multigp-com/issues/88 - race.ownerUserName = self.race?.ownerUserName ?? "" - self.race = race + if let race = race { + // TODO: Temporary hack since race/view API doesn't include the raceOwnerName attribute + // See issue https://github.com/MultiGP/multigp-com/issues/88 + race.ownerUserName = self.race?.ownerUserName ?? "" + self.race = race - reloadContentViews() + reloadContentViews() + } } } } @@ -88,8 +94,7 @@ class RaceController { public func raceUserViewModels() -> [UserViewModel] { var viewModels = [UserViewModel]() - - guard let race = race else { return viewModels } + guard let race = race, let entries = race.entries else { return viewModels } func populateScore(in userViewModels: [UserViewModel]) { guard race.isGQ == false else { return } // Don't display points for GQ race results @@ -101,29 +106,47 @@ class RaceController { } } - if race.canShowResults, let results = ResultEntryViewModel.combinedResults(from: race.results, for: race.trueScoringFormat) { - viewModels += UserViewModel.viewModelsFromResults(results) - populateScore(in: viewModels) - } + if race.canShowResults { + if let results = ResultEntryViewModel.combinedResults(from: race.results, for: race.trueScoringFormat) { + viewModels += UserViewModel.viewModelsFromResults(results) + populateScore(in: viewModels) - if let entries = race.entries, entries.count > 0 { - // We need to include the pilots that didn't complete laps still - if viewModels.count > 0, viewModels.count < entries.count { + // Sort only when at least one score > 0, to match the web + // GQ races aren't scored + if !race.isGQ { viewModels.sort { ($0.score ?? 0) > ($1.score ?? 0) } } + } + + if viewModels.count < entries.count { viewModels += UserViewModel.viewModels(viewModels, withoutResults: entries) populateScore(in: viewModels) - // No race results, so let's just populate with race entries instead - } else if viewModels.count == 0 { - viewModels += UserViewModel.viewModelsFromEntries(entries) + // Sort only when at least one score > 0, to match the web + // GQ races aren't scored + if !race.isGQ { viewModels.sort { ($0.score ?? 0) > ($1.score ?? 0) } } } + } else { + // No race results, so let's just populate the race entries + viewModels += UserViewModel.viewModelsFromEntries(entries) } return viewModels } + public func currentRaceTitle() -> String { + guard let race, let schedule = race.schedule, let lastRound = schedule.rounds.last + else { return "" } + + let title = lastRound.name ?? "" + + guard let lastHeat = lastRound.heats.last?.name + else { return title } + + return "\(title) - \(lastHeat)" + } + // MARK: - Actions - @objc func didPressEditButton() { + func showEditMenu() { guard let race = race else { return } let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) @@ -154,26 +177,19 @@ class RaceController { visibleViewController?.present(alert, animated: true) } - @objc func didPressCalendarButton() { - guard let race = race, let event = race.createCalendarEvent(with: race.id) else { return } - - ActionSheetUtil.presentActionSheet(withTitle: "Save the race details to your calendar?", buttonTitle: "Save to Calendar", completion: { (action) in - CalendarUtil.add(event) - }) - } - - @objc public func didPressShareButton() { + func showShareMenu() { guard let race = race else { return } - guard let raceURL = MGPWeb.getURL(for: .raceView, value: race.id) else { return } - var items: [Any] = [raceURL] + let url = MGPWeb.getURL(for: .raceView, value: race.id) + + var items: [Any] = [url] var activities = [UIActivity]() if race.canManagePayments { activities += [PaypalActivity()] } - activities += [MultiGPActivity(), CopyLinkActivity()] + activities += [MGPActivity(), CopyLinkActivity()] // Calendar integration if let event = race.createCalendarEvent(with: raceId) { @@ -186,18 +202,29 @@ class RaceController { visibleViewController?.present(vc, animated: true) } - @objc fileprivate func didPressZippyQButton() { + func showZippyQWeb() { guard let race = race else { return } - guard let url = MGPWeb.getURL(for: .zippyqView, value: race.id) else { return } + + let url = MGPWeb.getURL(for: .zippyqView, value: race.id) if UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } } + func saveInCalendar() { + guard let race = race, let event = race.createCalendarEvent(with: race.id) else { return } + + ActionSheetUtil.presentActionSheet( + withTitle: "Save the race details to your calendar?", + buttonTitle: "Save to Calendar", completion: { (action) in + CalendarUtil.add(event) + }) + } + // MARK: - Navigation Action Builders - enum RaceAction: CaseIterable { + enum RaceAction: Int, CaseIterable { case edit, calendar, share, zippyQ func makeButton(target: Any?, action: Selector) -> UIButton { @@ -233,7 +260,7 @@ class RaceController { if (option == .zippyQ && !race.isZippyQEnabled) { continue } let button = option.makeButton(target: self, action: #selector(raceActionTapped(_:))) - button.tag = options.firstIndex(of: option) ?? 0 + button.tag = option.rawValue stackView.addArrangedSubview(button) } @@ -241,13 +268,23 @@ class RaceController { } @objc private func raceActionTapped(_ sender: UIButton) { - guard let option = RaceAction.allCases[safe: sender.tag] else { return } + guard let action = RaceAction(rawValue: sender.tag) else { return } + showContextualMenu(action) + } + + func showContextualMenu(_ action: RaceAction, completion: BoolCompletionBlock? = nil) { + + menuCompletion = completion - switch option { - case .edit: didPressEditButton() - case .calendar: didPressCalendarButton() - case .share: didPressShareButton() - case .zippyQ: didPressZippyQButton() + switch action { + case .edit: + showEditMenu() + case .calendar: + saveInCalendar() + case .share: + showShareMenu() + case .zippyQ: + showZippyQWeb() } } @@ -271,9 +308,10 @@ class RaceController { let message = isClosed ? "Are you sure you want to open race enrollment?" : "Are you sure you want to close race enrollment?" return UIAlertAction(title: title, style: .default) { [weak self] _ in - ActionSheetUtil.presentActionSheet(withTitle: message) { [weak self] _ in + ActionSheetUtil.presentActionSheet( + withTitle: message, completion: { [weak self] _ in self?.toggleRaceEnrollment() - } + }) } } @@ -288,7 +326,7 @@ class RaceController { ActionSheetUtil.presentDestructiveActionSheet( withTitle: "Are you sure you want to finalize \"\(race.name)\"?", message: "Finalizing this race will close enrollment, email the results to the pilots, and initialize the next race if configured.", - destructiveTitle: "Yes, Finalize", cancel: { [weak self] _ in + destructiveTitle: "Yes, Finalize", completion: { [weak self] _ in self?.finalizeRace() }) } @@ -298,7 +336,7 @@ class RaceController { UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in ActionSheetUtil.presentDestructiveActionSheet( withTitle: "Are you sure you want to delete \"\(race.name)\"?", - destructiveTitle: "Yes, Delete", cancel: { [weak self] _ in + destructiveTitle: "Yes, Delete", completion: { [weak self] _ in self?.deleteRace() }) } @@ -374,7 +412,7 @@ class RaceController { func finalizeRace() { guard let race = race else { return } raceApi.finalizeRace(with: race.id) { status, error in - if status == true || self.ignoreFinalizingError == true { + if status { self.reloadRace() } else if let error = error { AlertUtil.presentAlertMessage("Couldn't finalize this race. Please try again later. \(error.localizedDescription)", title: "Error", delay: 0.5) @@ -386,7 +424,12 @@ class RaceController { guard let race = race else { return } raceApi.deleteRace(with: race.id) { status, error in if status == true { - self.visibleNavigationController?.popViewController(animated: true) + if let completion = self.menuCompletion { + completion(true) + self.menuCompletion = nil // invalidate completion right after + } else { + self.visibleNavigationController?.popViewController(animated: true) + } } else if let error = error { AlertUtil.presentAlertMessage("Couldn't delete this race. Please try again later. \(error.localizedDescription)", title: "Error", delay: 0.5) } @@ -404,8 +447,13 @@ extension RaceController: RaceFormViewControllerDelegate { self.reloadRace() viewController.dismiss(animated: true, completion: nil) case .new: - visibleNavigationController?.popViewController(animated: true) - viewController.dismiss(animated: true, completion: nil) + let vc = RaceTabBarController(with: race) + vc.isDismissable = true + viewController.navigationController?.pushViewController(vc, animated: true) + + if let nc = viewController.presentingViewController as? NavigationController { + nc.popViewController(animated: false) // let's pop, so when the current view is dismissed, we see the list of races + } } } diff --git a/RaceSync/View Controllers/Races/RaceDetailViewController.swift b/RaceSync/View Controllers/Races/RaceDetailViewController.swift index 4234c6ca..d88894cc 100644 --- a/RaceSync/View Controllers/Races/RaceDetailViewController.swift +++ b/RaceSync/View Controllers/Races/RaceDetailViewController.swift @@ -71,7 +71,7 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { fileprivate lazy var rotatingIconView: RotatingIconView = { let view = RotatingIconView() view.tintColor = Color.yellow - view.imageView.image = UIImage(named: "icn_trophy_qualifier")?.withRenderingMode(.alwaysTemplate) + view.imageView.image = ButtonImg.trophy?.withRenderingMode(.alwaysTemplate) view.imageView.tintColor = Color.yellow return view }() @@ -96,49 +96,56 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { fileprivate lazy var memberBadgeView: MemberBadgeView = { let view = MemberBadgeView(type: .system) - view.isUserInteractionEnabled = false + view.addTarget(self, action: #selector(didPressMembersBadge), for: .touchUpInside) + view.isUserInteractionEnabled = true return view }() - fileprivate lazy var locationButton: PasteboardButton = { + func contextualButton() -> PasteboardButton { let button = PasteboardButton(type: .system) - button.tintColor = Color.red button.shouldHighlight = true button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .regular) button.titleLabel?.numberOfLines = 2 - button.setImage(UIImage(named: "icn_pin_small"), for: .normal) - button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -Constants.padding, bottom: 0, right: 0) - button.imageView?.tintColor = button.tintColor - button.addTarget(self, action: #selector(didPressLocationButton), for: .touchUpInside) + button.tintColor = Color.black return button - }() + } - fileprivate lazy var startDateButton: PasteboardButton = { - let button = PasteboardButton(type: .system) - button.tintColor = Color.black - button.shouldHighlight = true - button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .regular) - button.titleLabel?.numberOfLines = 2 - button.setImage(UIImage(named: "icn_calendar_start_small"), for: .normal) // 15 x 15 - button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -Constants.padding, bottom: 0, right: 0) - button.imageView?.tintColor = button.tintColor + fileprivate lazy var date1Button: PasteboardButton = { + let button = contextualButton() button.addTarget(self, action: #selector(didPressDateButton), for: .touchUpInside) return button }() - fileprivate lazy var endDateButton: PasteboardButton = { - let button = PasteboardButton(type: .system) - button.tintColor = Color.black - button.shouldHighlight = true - button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .regular) - button.titleLabel?.numberOfLines = 2 - button.setImage(UIImage(named: "icn_calendar_end_small"), for: .normal) // 15 x 15 - button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -Constants.padding, bottom: 0, right: 0) - button.imageView?.tintColor = button.tintColor + fileprivate lazy var date2Button: PasteboardButton = { + let button = contextualButton() button.addTarget(self, action: #selector(didPressDateButton), for: .touchUpInside) + button.isHidden = true + return button + }() + + fileprivate lazy var dateIconView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFit + view.backgroundColor = Color.clear + return view + }() + + fileprivate lazy var locationButton: PasteboardButton = { + let button = contextualButton() + button.addTarget(self, action: #selector(didPressLocationButton), for: .touchUpInside) + button.tintColor = Color.link return button }() + fileprivate lazy var locationIconView: UIImageView = { + let view = UIImageView() + view.image = SystemImg.pin_small?.withRenderingMode(.alwaysTemplate) + view.contentMode = .scaleAspectFit + view.backgroundColor = Color.clear + view.tintColor = Color.link + return view + }() + fileprivate lazy var htmlView: RichEditorView = { let view = RichEditorView() view.isEditable = false @@ -194,13 +201,37 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { }() fileprivate lazy var leftStackView: UIStackView = { - var subviews = [startDateButton, endDateButton, locationButton] - let stackView = UIStackView(arrangedSubviews: subviews) - stackView.axis = .vertical - stackView.alignment = .leading - stackView.distribution = .equalSpacing - stackView.spacing = Constants.padding*3/4 - return stackView + // vertical stack for the date buttons + let stackView1 = UIStackView(arrangedSubviews: [date1Button, date2Button]) + stackView1.axis = .vertical + stackView1.alignment = .leading + stackView1.distribution = .fill + + // horizontal stack for the icon + the date stack + let stackView2 = UIStackView(arrangedSubviews: [dateIconView, stackView1]) + stackView2.axis = .horizontal + stackView2.alignment = .center + stackView2.distribution = .fill + stackView2.spacing = Constants.padding * 3/4 + + if canDisplayAddress { + let stackView3 = UIStackView(arrangedSubviews: [locationIconView, locationButton]) + stackView3.axis = .horizontal + stackView3.alignment = .center + stackView3.distribution = .fill + stackView3.spacing = Constants.padding * 3/4 + + // vertical stack containing the icon+dates row and the location button + let stackView4 = UIStackView(arrangedSubviews: [stackView2, stackView3]) + stackView4.axis = .vertical + stackView4.alignment = .leading + stackView4.distribution = .equalSpacing + stackView4.spacing = Constants.padding / 2 + + return stackView4 + } else { + return stackView2 + } }() fileprivate var raceCoordinates: CLLocationCoordinate2D? { @@ -221,7 +252,7 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { } fileprivate var canDisplayEndDate: Bool { - guard let text = raceViewModel.endDateDesc else { return false } + guard let text = raceViewModel.endDateLabel else { return false } return text.count > 0 } @@ -323,8 +354,6 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { contentView.addSubview(headerView) headerView.snp.makeConstraints { $0.leading.trailing.equalToSuperview() - $0.width.equalTo(view.bounds.width) - $0.height.lessThanOrEqualTo(200) // very max if canDisplayMap { $0.top.equalTo(mapView.snp.bottom).offset(Constants.padding) @@ -370,7 +399,7 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { headerView.addSubview(leftStackView) leftStackView.snp.makeConstraints { $0.top.equalTo(rightStackView.snp.top) - $0.leading.equalToSuperview().offset(Constants.padding*1.5) + $0.leading.equalToSuperview().offset(Constants.padding) $0.trailing.equalTo(rightStackView.snp.leading).offset(-Constants.padding/2) } @@ -430,9 +459,30 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { fileprivate func populateContent() { titleLabel.text = raceViewModel.titleLabel.uppercased() subtitleLabel.attributedText = raceViewModel.subtitleLabel - joinButton.joinState = raceViewModel.joinState memberBadgeView.count = raceViewModel.participantCount - startDateButton.setTitle(raceViewModel.startDateDesc , for: .normal) + + configureJoinButton() + configureDateLabels() + configureLocationLabels() + configureMap() + + // Load the HTML on the next runloop + DispatchQueue.main.async { [weak self] in + guard let s = self else { return } + s.configureHTML() + } + + // lays out the content and helps calculating the content size + let contentRect: CGRect = scrollView.subviews.reduce(into: .zero) { rect, view in + rect = rect.union(view.frame) + } + + // Seems like this is not doing anything? + scrollView.contentSize = CGSize(width: contentRect.size.width, height: contentRect.size.height) + } + + fileprivate func configureJoinButton() { + joinButton.joinState = raceViewModel.joinState // showing an indicator if the user has joined, only if the race fee is still pending if race.isJoined && race.status == .open && race.isPayable { @@ -443,73 +493,89 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { miniJoinButton.joinState = .closed miniJoinButton.isHidden = true } + } + + fileprivate func configureDateLabels() { + var date1Label: String? + var date2Label: String? + var dateImage: UIImage? if canDisplayEndDate { - endDateButton.setTitle(raceViewModel.endDateDesc, for: .normal) + if raceViewModel.sameDay { + date1Label = raceViewModel.dateLabel?.components(separatedBy: "@").first?.trimmingCharacters(in: .whitespaces) + date2Label = raceViewModel.timeLabel + dateImage = ButtonImg.date_path2 + } else { + date1Label = raceViewModel.startDateLabel + date2Label = raceViewModel.endDateLabel + dateImage = ButtonImg.date_path1 + } + } else { + date1Label = raceViewModel.startDateLabel + dateImage = ButtonImg.cal_small } - if canDisplayAddress { - locationButton.setTitle(raceViewModel.fullLocationLabel, for: .normal) - // Bring the icon to the first line, if there are more than 1 line of text - if let label = locationButton.titleLabel, label.numberOfVisibleLines > 2 { - locationButton.imageEdgeInsets = UIEdgeInsets(top: -Constants.padding, left: -Constants.padding, bottom: 0, right: 0) - } + date1Button.setTitle(date1Label, for: .normal) + date2Button.setTitle(date2Label, for: .normal) + date2Button.isHidden = !canDisplayEndDate + dateIconView.image = dateImage + } + + fileprivate func configureLocationLabels() { + guard canDisplayAddress else { return } + + locationButton.setTitle(raceViewModel.fullLocationLabel, for: .normal) + + // Bring the icon to the first line, if there are more than 1 line of text + if let label = locationButton.titleLabel, label.numberOfVisibleLines > 2 { + locationButton.imageEdgeInsets = UIEdgeInsets(top: -Constants.padding, left: -Constants.padding, bottom: 0, right: 0) } + if canDisplayFee { feeLabel.text = raceViewModel.feeLabel } - endDateButton.isHidden = !canDisplayEndDate locationButton.isHidden = !canDisplayAddress feeLabel.isHidden = !canDisplayFee + } - // Load the HTML on the next runloop - DispatchQueue.main.async { [weak self] in - guard let s = self else { return } - - var html = "" - let spacing = Constants.padding * 3/4 + fileprivate func configureHTML() { + var html = "" + let spacing = Constants.padding * 3/4 - if s.canDisplayDescription { - let description = s.race.description.replaceHTMLColorTag(with: Color.gray300).stripHTMLFontTag().stripHTMLEdges() - html += "
\(description)
" - } - if s.canDisplayContent { - let content = s.race.content.replaceHTMLColorTag(with: Color.black).stripHTMLFontTag().stripHTMLEdges() - html += "
\(content)
" - } - if s.canDisplayItinerary { - let itinerary = s.race.description.replaceHTMLColorTag(with: Color.gray100).stripHTMLFontTag().stripHTMLEdges() - html += "
" - html += "
\(itinerary)
" - } - - s.htmlView.html = html + if canDisplayDescription { + let description = race.description.replaceHTMLColorTag(with: Color.gray300).stripHTMLFontTag().stripHTMLEdges() + html += "
\(description)
" + } + if canDisplayContent { + let content = race.content.replaceHTMLColorTag(with: Color.black).stripHTMLFontTag().stripHTMLEdges() + html += "
\(content)
" + } + if canDisplayItinerary { + let itinerary = race.description.replaceHTMLColorTag(with: Color.gray100).stripHTMLFontTag().stripHTMLEdges() + html += "
" + html += "
\(itinerary)
" } - if canDisplayMap, let coordinates = raceCoordinates { - let distance = CLLocationDistance(1000) - let region = MKCoordinateRegion(center: coordinates, latitudinalMeters: distance, longitudinalMeters: distance) + htmlView.html = html + } - let mapRect = MKCoordinateRegion.mapRectForCoordinateRegion(region) - let paddedMapRect = mapRect.offsetBy(dx: 0, dy: -1500) // TODO: Convert Screen points to Map points instead of harcoded value + fileprivate func configureMap() { + guard canDisplayMap, let coordinates = raceCoordinates else { return } - let location = MKPointAnnotation() - location.coordinate = coordinates + let distance = CLLocationDistance(1000) + let region = MKCoordinateRegion(center: coordinates, latitudinalMeters: distance, longitudinalMeters: distance) - DispatchQueue.main.async { - self.mapView.addAnnotation(location) - self.mapView.setVisibleMapRect(paddedMapRect, animated: false) - } - } + let mapRect = MKCoordinateRegion.mapRectForCoordinateRegion(region) + let paddedMapRect = mapRect.offsetBy(dx: 0, dy: -1500) // TODO: Convert Screen points to Map points instead of harcoded value - // lays out the content and helps calculating the content size - let contentRect: CGRect = scrollView.subviews.reduce(into: .zero) { rect, view in - rect = rect.union(view.frame) + let location = MKPointAnnotation() + location.coordinate = coordinates + + DispatchQueue.main.async { + self.mapView.addAnnotation(location) + self.mapView.setVisibleMapRect(paddedMapRect, animated: false) } - - // Seems like this is not doing anything? - scrollView.contentSize = CGSize(width: contentRect.size.width, height: contentRect.size.height) } // MARK: - Actions @@ -518,15 +584,15 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { showMapView() } + @objc func didPressDateButton(_ sender: UITapGestureRecognizer) { + raceController.saveInCalendar() + } + @objc fileprivate func didPressLocationButton(_ sender: UIButton) { guard canDisplayMap else { return } showMapView() } - @objc func didPressDateButton(_ sender: UITapGestureRecognizer) { - raceController.didPressCalendarButton() - } - @objc fileprivate func didPressJoinButton(_ sender: JoinButton) { let joinState = sender.joinState @@ -538,6 +604,11 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { } } + @objc fileprivate func didPressMembersBadge(_ sender: UIButton) { + guard let tabBarController = tabBarController as? RaceTabBarController else { return } + tabBarController.selectTab(.pilots) + } + func canInteract(with cell: FormTableViewCell) -> Bool { guard !cell.isLoading else { return false } guard !didTapCell else { return false } @@ -648,12 +719,12 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { func openZippyQSchedule(_ cell: FormTableViewCell) { let zippyqUrl = MGPWeb.getUrl(for: .zippyqView, value: race.id) - WebViewController.openUrl(zippyqUrl) + WebViewController.open(zippyqUrl) } func openLiveFPV(_ cell: FormTableViewCell) { guard let url = race.liveTimeEventUrl else { return } - WebViewController.openUrl(url) + WebViewController.open(url) } // MARK: - Data Update @@ -777,14 +848,15 @@ extension RaceDetailViewController: RichEditorDelegate { func richEditor(_ editor: RichEditorView, shouldInteractWith url: URL) -> Bool { - if Validator.isEmail().apply(url.absoluteString) { + if let link = DeepLink.create(from: url), ApplicationControl.shared.canHandleDeepLink(link) { + ApplicationControl.shared.handle(link) + } else if Validator.isEmail().apply(url.absoluteString) { // leave the system handle emails UIApplication.shared.open(url) } else { // open url using in-app browser, else the url is open on the WKWebView - WebViewController.openURL(url) + WebViewController.open(url) } - return false } } @@ -797,12 +869,11 @@ extension RaceDetailViewController: MKMapViewDelegate { guard annotation is MKPointAnnotation else { return nil } let identifier = "Annotation" - var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) if annotationView == nil { annotationView = MKPinAnnotationView(annotation: annotation, reuseIdentifier: identifier) - annotationView?.image = UIImage(named: "icn_map_annotation") + annotationView?.image = ButtonImg.map_annotation annotationView!.canShowCallout = true } else { annotationView!.annotation = annotation diff --git a/RaceSync/View Controllers/Races/RaceEditable.swift b/RaceSync/View Controllers/Races/RaceEditable.swift new file mode 100644 index 00000000..3b368e90 --- /dev/null +++ b/RaceSync/View Controllers/Races/RaceEditable.swift @@ -0,0 +1,46 @@ +// +// RaceEditable.swift +// RaceSync +// +// Created by Ignacio Romero Zurbuchen on 2025-12-18. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import UIKit +import RaceSyncAPI + +protocol RaceEditable: UIViewController { + + var tableView: UITableView { get } + var raceController: RaceController? { get set } + + func didLongPress(_ gesture: UIGestureRecognizer) + func loadContent(forced: Bool) + func raceViewModel(for index: Int) -> RaceViewModel? + + // Default implementation + func handleLongPress(_ gesture: UIGestureRecognizer) +} + +extension RaceEditable { + + func handleLongPress(_ gesture: UIGestureRecognizer) { + let location = gesture.location(in: tableView) + guard let indexPath = tableView.indexPathForRow(at: location) else { return } + + if gesture.state == .began { + tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) + } else { + tableView.deselectRow(at: indexPath, animated: true) + return + } + + guard let viewModel = raceViewModel(for: indexPath.row), viewModel.race.canBeEdited else { return } + + raceController = RaceController(with: viewModel.race) + raceController?.showContextualMenu(.edit, completion: { [weak self] status in + guard status else { return } + self?.loadContent(forced: true) + }) + } +} diff --git a/RaceSync/View Controllers/Races/RaceFeedController.swift b/RaceSync/View Controllers/Races/RaceFeedController.swift index 2f7cec58..97bc7dad 100644 --- a/RaceSync/View Controllers/Races/RaceFeedController.swift +++ b/RaceSync/View Controllers/Races/RaceFeedController.swift @@ -6,7 +6,7 @@ // Copyright © 2020 MultiGP Inc. All rights reserved. // -import UIKit +import Foundation import RaceSyncAPI import CoreLocation @@ -78,12 +78,12 @@ fileprivate extension RaceFeedController { guard forceFetch else { return } } - let filters: [RaceListFilters] = [.joined] - let sorting: RaceViewSorting = settings.showPastEvents ? .ascending : .descending + let filters: [RaceListFilters] = [.joined, .upcoming] + let sorting: RaceViewSorting = .descending raceApi.getMyRaces(filters: filters) { [weak self] (races, error) in - if let filteredRaces = self?.locallyFilteredRaces(races) { - let sortedViewModels = RaceViewModel.sortedViewModels(with: filteredRaces, sorting: sorting) + if let races = races { + let sortedViewModels = RaceViewModel.sortedViewModels(with: races, sorting: sorting) self?.raceCollection[.joined] = sortedViewModels completion(sortedViewModels, false, nil) } else { @@ -99,16 +99,16 @@ fileprivate extension RaceFeedController { guard forceFetch else { return } } - let filters: [RaceListFilters] = [.nearby] - let sorting: RaceViewSorting = settings.showPastEvents ? .ascending : .descending + let filters: [RaceListFilters] = [.nearby, .upcoming] + let sorting: RaceViewSorting = .descending let coordinate = LocationManager.shared.location?.coordinate let lat = coordinate?.latitude.string let long = coordinate?.longitude.string raceApi.getMyRaces(filters: filters, latitude: lat, longitude: long) { [weak self] (races, error) in - if let filteredRaces = self?.locallyFilteredRaces(races) { - let sortedViewModels = RaceViewModel.sortedViewModels(with: filteredRaces, sorting: sorting) + if let races = races { + let sortedViewModels = RaceViewModel.sortedViewModels(with: races, sorting: sorting) self?.raceCollection[.nearby] = sortedViewModels completion(sortedViewModels, false, nil) } else { @@ -125,12 +125,12 @@ fileprivate extension RaceFeedController { guard forceFetch else { return } } - let filters = [RaceListFilters]() - let sorting: RaceViewSorting = settings.showPastEvents ? .ascending : .descending + let filters: [RaceListFilters] = [.upcoming] + let sorting: RaceViewSorting = .descending raceApi.getRaces(with: filters, chapterIds: user.chapterIds) { [weak self] races, error in - if let filteredRaces = self?.locallyFilteredRaces(races) { - let sortedViewModels = RaceViewModel.sortedViewModels(with: filteredRaces, sorting: sorting) + if let races = races { + let sortedViewModels = RaceViewModel.sortedViewModels(with: races, sorting: sorting) self?.raceCollection[.chapters] = sortedViewModels completion(sortedViewModels, false, nil) } else { @@ -147,11 +147,11 @@ fileprivate extension RaceFeedController { } let filters: [RaceListFilters] = [.upcoming] - let sorting: RaceViewSorting = settings.showPastEvents ? .ascending : .descending + let sorting: RaceViewSorting = .descending raceApi.getRaces(with: filters, raceClass: `class`) { [weak self] (races, error) in - if let filteredRaces = self?.locallyFilteredRaces(races) { - let sortedViewModels = RaceViewModel.sortedViewModels(with: filteredRaces, sorting: sorting) + if let races = races { + let sortedViewModels = RaceViewModel.sortedViewModels(with: races, sorting: sorting) self?.raceCollection[.classes(`class`)] = sortedViewModels completion(sortedViewModels, false, nil) } else { @@ -167,13 +167,15 @@ fileprivate extension RaceFeedController { guard forceFetch else { return } } - let filters: [RaceListFilters] = [.series] - let sorting: RaceViewSorting = (settings.showPastEvents || !series.isActive()) ? .ascending : .descending + var filters: [RaceListFilters] = [.series] + if series.isActive() { filters += [.upcoming] } + + let sorting: RaceViewSorting = !series.isActive() ? .ascending : .descending - raceApi.getRaces(with: filters, startDate: "\(series.year)", pageSize: 150) { [weak self] (races, error) in + raceApi.getRaces(with: filters, startDate: "\(series.year)", pageSize: 300) { [weak self] (races, error) in - if let filteredRaces = series.isActive() ? self?.locallyFilteredRaces(races) : races { - let sortedViewModels = RaceViewModel.sortedViewModels(with: filteredRaces, sorting: sorting) + if let races = races { + let sortedViewModels = RaceViewModel.sortedViewModels(with: races, sorting: sorting) self?.raceCollection[.series(series)] = sortedViewModels completion(sortedViewModels, false, nil) } else { @@ -181,18 +183,4 @@ fileprivate extension RaceFeedController { } } } - - func locallyFilteredRaces(_ races: [Race]?) -> [Race]? { - guard !settings.showPastEvents else { return races } - - return races?.filter({ (race) -> Bool in - guard let startDate = race.startDate else { return false } - - if let endDate = race.endDate { - return endDate.isInToday || endDate.timeIntervalSinceNow.sign == .plus - } else { - return startDate.isInToday || startDate.timeIntervalSinceNow.sign == .plus - } - }) - } } diff --git a/RaceSync/View Controllers/Races/RaceFeedMenuViewController.swift b/RaceSync/View Controllers/Races/RaceFeedMenuViewController.swift index c6b620ab..21416d8d 100644 --- a/RaceSync/View Controllers/Races/RaceFeedMenuViewController.swift +++ b/RaceSync/View Controllers/Races/RaceFeedMenuViewController.swift @@ -36,7 +36,7 @@ class RaceFeedMenuViewController: UIViewController { fileprivate lazy var headerView: UIView = { let view = UIView() - let imageView = UIImageView(image: UIImage(named: "icn_settings_header")) + let imageView = UIImageView(image: LogoImg.watermark) view.addSubview(imageView) imageView.snp.makeConstraints { $0.centerX.equalToSuperview() @@ -74,20 +74,16 @@ class RaceFeedMenuViewController: UIViewController { var rows = [Row]() if isRaceFiltersEnabled { rows += [.raceFeedFilters]} rows += [.searchRadius, .measurement] - if isPastEventsEnabled { rows += [.showPastEvents]} return rows }() fileprivate let isRaceFiltersEnabled: Bool = true - fileprivate let isPastEventsEnabled: Bool = false fileprivate enum Constants { static let padding: CGFloat = UniversalConstants.padding static let cellHeight: CGFloat = 60 } - // MARK: - Initialization - // MARK: - Lifecycle Methods override func viewDidLoad() { @@ -124,15 +120,6 @@ class RaceFeedMenuViewController: UIViewController { // MARK: - Actions - @objc fileprivate func didChangeSwitchValue(_ sender: UISwitch) { - let row = rows[sender.tag] - - if row == .showPastEvents { - let settings = APIServices.shared.settings - settings.showPastEvents = !settings.showPastEvents // invert the value - } - } - @objc fileprivate func didPressCloseButton() { dismiss(animated: true) } @@ -253,16 +240,7 @@ extension RaceFeedMenuViewController: UITableViewDataSource { cell.detailTextLabel?.text = "\(settings.searchRadius) \(settings.lengthUnit.symbol)" } else if row == .measurement { cell.detailTextLabel?.text = settings.measurementSystem.title - } else if row == .showPastEvents { - cell.accessoryType = .none - let accessory = UISwitch() - - accessory.tag = rows.firstIndex(of: row) ?? 0 - accessory.addTarget(self, action: #selector(didChangeSwitchValue(_:)), for: .valueChanged) - accessory.isOn = settings.showPastEvents - cell.accessoryView = accessory } - return cell } diff --git a/RaceSync/View Controllers/Races/RaceFeedViewController.swift b/RaceSync/View Controllers/Races/RaceFeedViewController.swift index 6173874a..db0687d8 100644 --- a/RaceSync/View Controllers/Races/RaceFeedViewController.swift +++ b/RaceSync/View Controllers/Races/RaceFeedViewController.swift @@ -17,7 +17,7 @@ import CoreLocation Main view of the application, displaying lists of races filtered by different toggles. This view is very specific to that use case. For a more generic display of races, use RaceListViewController. */ -class RaceFeedViewController: UIViewController, ViewJoinable, Shimmable { +class RaceFeedViewController: UIViewController, ViewJoinable, Shimmable, RaceEditable { // MARK: - Public Variables @@ -38,10 +38,16 @@ class RaceFeedViewController: UIViewController, ViewJoinable, Shimmable { tableView.addGestureRecognizer(gesture) } + let longPress = UILongPressGestureRecognizer(target: self,action: #selector(didLongPress(_:))) + longPress.minimumPressDuration = 0.3 + longPress.delaysTouchesBegan = true + tableView.addGestureRecognizer(longPress) + return tableView }() var shimmeringView: ShimmeringView = defaultShimmeringView() + var raceController: RaceController? // MARK: - Private Variables @@ -94,8 +100,8 @@ class RaceFeedViewController: UIViewController, ViewJoinable, Shimmable { fileprivate lazy var searchButton: CustomButton = { let button = CustomButton(type: .system) button.addTarget(self, action: #selector(didPressSearchButton), for: .touchUpInside) - button.setImage(ButtonImg.search, for: .normal) - button.isHidden = true + button.setImage(SystemImg.search, for: .normal) + button.isHidden = !isRaceSearchEnabled return button }() @@ -140,6 +146,8 @@ class RaceFeedViewController: UIViewController, ViewJoinable, Shimmable { fileprivate let emptyStateNearbyRaces = EmptyStateViewModel(.noNearbydRaces) fileprivate let emptyStateSeriesRaces = EmptyStateViewModel(.noSeriesRaces) + fileprivate let isRaceSearchEnabled: Bool = true + fileprivate enum Constants { static let padding: CGFloat = UniversalConstants.padding } @@ -235,8 +243,8 @@ class RaceFeedViewController: UIViewController, ViewJoinable, Shimmable { // MARK: - Actions @objc fileprivate func didChangeSegment() { - // Cancelling previous race API requests to avoid overlaps - raceApi.cancelAll() + // Cancelling previous API requests to avoid overlaps + raceApi.cancelSearchRequests() // This should be triggered just once, when first requesting access to the user's location // and display the shimmer while retrieving the location and loading the nearby races. @@ -252,7 +260,9 @@ class RaceFeedViewController: UIViewController, ViewJoinable, Shimmable { } @objc fileprivate func didPressSearchButton(_ sender: Any) { - Clog.log("didPressSearchButton") + let vc = UniversalSearchViewController() + let nc = NavigationController(rootViewController: vc) + present(nc, animated: true) } @objc fileprivate func didPressFilterButton(_ sender: Any) { @@ -278,20 +288,14 @@ class RaceFeedViewController: UIViewController, ViewJoinable, Shimmable { loadContent(forced: true) } - fileprivate func openRaceDetail(_ viewModel: RaceViewModel) { - let vc = RaceTabBarController(with: viewModel.race) - vc.hidesBottomBarWhenPushed = true - navigationController?.pushViewController(vc, animated: true) - } - - @objc fileprivate func didSwipeHorizontally(_ sender: Any) { - guard let swipeGesture = sender as? UISwipeGestureRecognizer else { return } + @objc fileprivate func didSwipeHorizontally(_ gesture: UIGestureRecognizer) { + guard let gesture = gesture as? UISwipeGestureRecognizer else { return } var newIndex = segmentedControl.selectedSegmentIndex - if swipeGesture.direction == .left { + if gesture.direction == .left { newIndex += 1 - } else if swipeGesture.direction == .right { + } else if gesture.direction == .right { newIndex -= 1 } @@ -299,6 +303,16 @@ class RaceFeedViewController: UIViewController, ViewJoinable, Shimmable { segmentedControl.setSelectedSegment(newIndex) } + @objc func didLongPress(_ gesture: UIGestureRecognizer) { + handleLongPress(gesture) + } + + fileprivate func openRaceDetail(_ viewModel: RaceViewModel) { + let vc = RaceTabBarController(with: viewModel.race) + vc.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(vc, animated: true) + } + fileprivate func selectSegment(_ filter: RaceFilter) { let idx = raceFeedController.raceFilters.firstIndex(of: filter) ?? 0 @@ -332,6 +346,14 @@ class RaceFeedViewController: UIViewController, ViewJoinable, Shimmable { } } } + + func raceViewModel(for index: Int) -> RaceViewModel? { + guard let list = raceFeed else { return nil } + if index >= 0, index < list.count { + return list[index] + } + return nil + } } extension RaceFeedViewController: UITableViewDelegate { @@ -339,7 +361,7 @@ extension RaceFeedViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - if let viewModel = raceFeed?[indexPath.row] { + if let viewModel = raceViewModel(for: indexPath.row) { openRaceDetail(viewModel) } } @@ -357,7 +379,7 @@ extension RaceFeedViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as RaceTableViewCell - guard let viewModel = raceFeed?[indexPath.row] else { return cell } + guard let viewModel = raceViewModel(for: indexPath.row) else { return cell } cell.titleLabel.text = viewModel.titleLabel cell.dateLabel.text = viewModel.dateLabel //"Saturday Sept 14 @ 9:00 AM" @@ -389,7 +411,7 @@ extension RaceFeedViewController: APISettingsDelegate { updateSegmentedControl() raceFeedController.invalidateDataSource() loadContent(forced: true) - case .showPastEvents, .searchRadius: + case .searchRadius: raceFeedController.invalidateDataSource() loadContent(forced: true) case .measurement: diff --git a/RaceSync/View Controllers/Races/RaceFormViewController.swift b/RaceSync/View Controllers/Races/RaceFormViewController.swift index 7f332356..cf0789c9 100644 --- a/RaceSync/View Controllers/Races/RaceFormViewController.swift +++ b/RaceSync/View Controllers/Races/RaceFormViewController.swift @@ -250,7 +250,7 @@ class RaceFormViewController: UIViewController { } if data.sendNotification { - AlertUtil.presentAlertMessage("You are about to notify all the chapter members of \(data.chapterName). Are you sure?", title: "Heads Up", buttonTitle: "Send it!") { action in + AlertUtil.presentAlertMessage("You are about to notify all the chapter members of \(data.chapterName). Are you sure?", title: "Heads Up", okTitle: "Send it!") { action in handleSubmission() } } else { diff --git a/RaceSync/View Controllers/Races/RaceListViewController.swift b/RaceSync/View Controllers/Races/RaceListViewController.swift index 509dce3e..825f9b68 100644 --- a/RaceSync/View Controllers/Races/RaceListViewController.swift +++ b/RaceSync/View Controllers/Races/RaceListViewController.swift @@ -13,6 +13,8 @@ import UIKit /** Generic display of pre-loaded races. + + TODO: Needs an empty data set, for when races count = 0 */ class RaceListViewController: UIViewController, ViewJoinable { @@ -32,6 +34,7 @@ class RaceListViewController: UIViewController, ViewJoinable { fileprivate var raceList: [RaceViewModel] fileprivate let raceApi = RaceApi() fileprivate var seasonId: ObjectId? + fileprivate var seriesId: ObjectId? fileprivate var raceClass: RaceClass? fileprivate var raceName: String? @@ -46,7 +49,12 @@ class RaceListViewController: UIViewController, ViewJoinable { init(_ raceViewModels: [RaceViewModel], seasonId: ObjectId) { self.raceList = raceViewModels self.seasonId = seasonId + super.init(nibName: nil, bundle: nil) + } + init(_ raceViewModels: [RaceViewModel], seriesId: ObjectId) { + self.raceList = raceViewModels + self.seriesId = seriesId super.init(nibName: nil, bundle: nil) } @@ -59,7 +67,6 @@ class RaceListViewController: UIViewController, ViewJoinable { init(_ raceViewModels: [RaceViewModel], raceClass: RaceClass) { self.raceList = raceViewModels self.raceClass = raceClass - super.init(nibName: nil, bundle: nil) self.title = raceClass.title } @@ -67,7 +74,6 @@ class RaceListViewController: UIViewController, ViewJoinable { init(_ raceViewModels: [RaceViewModel], raceName: String) { self.raceList = raceViewModels self.raceName = raceName - super.init(nibName: nil, bundle: nil) self.title = raceName } @@ -101,6 +107,8 @@ class RaceListViewController: UIViewController, ViewJoinable { fileprivate func setupLayout() { + configureNavigationItems() + view.addSubview(tableView) tableView.snp.makeConstraints { $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) @@ -109,6 +117,11 @@ class RaceListViewController: UIViewController, ViewJoinable { } } + fileprivate func configureNavigationItems() { + title = "Races" + tabBarItem = UITabBarItem(title: title, image: SystemImg.flagCheckeredCrossed, selectedImage: nil) + } + // MARK: - Actions @objc fileprivate func didPressJoinButton(_ sender: JoinButton) { @@ -162,6 +175,11 @@ class RaceListViewController: UIViewController, ViewJoinable { } } } + + fileprivate func raceViewModel(for indexPath: IndexPath) -> RaceViewModel? { + guard indexPath.row < raceList.count else { return nil } + return raceList[indexPath.row] + } } extension RaceListViewController: UITableViewDelegate { @@ -169,8 +187,9 @@ extension RaceListViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - let viewModel = raceList[indexPath.row] - openRaceDetail(viewModel) + if let viewModel = raceViewModel(for: indexPath) { + openRaceDetail(viewModel) + } } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { @@ -185,18 +204,18 @@ extension RaceListViewController: UITableViewDataSource { } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let viewModel = raceViewModel(for: indexPath) else { return UITableViewCell() } let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as RaceTableViewCell - let viewModel = raceList[indexPath.row] cell.dateLabel.text = viewModel.startDateLabel //"Saturday Sept 14 @ 9:00 AM" cell.titleLabel.text = viewModel.titleLabel + cell.subtitleLabel.text = viewModel.locationLabel cell.joinButton.type = .race cell.joinButton.objectId = viewModel.race.id cell.joinButton.joinState = viewModel.joinState cell.joinButton.addTarget(self, action: #selector(didPressJoinButton), for: .touchUpInside) cell.memberBadgeView.count = viewModel.participantCount cell.avatarImageView.imageView.setImage(with: viewModel.imageUrl, placeholderImage: PlaceholderImg.medium) - cell.subtitleLabel.text = viewModel.distanceLabel return cell } } diff --git a/RaceSync/View Controllers/Races/RacePaymentsViewController.swift b/RaceSync/View Controllers/Races/RacePaymentsViewController.swift index 200d4bdf..42172836 100644 --- a/RaceSync/View Controllers/Races/RacePaymentsViewController.swift +++ b/RaceSync/View Controllers/Races/RacePaymentsViewController.swift @@ -67,12 +67,12 @@ class RacePaymentsViewController: UIViewController, RaceTabbable { }() fileprivate lazy var headerView: ColumnTableViewHeaderView = { - let header = ColumnTableViewHeaderView() - header.addColumn(with: Column.pilot.title, orientation: .left) // TODO: Let the subview do the chevron layout logic. Use an enum to track each column type - header.addColumn(with: Column.paid.title, orientation: .right) - header.addColumn(with: Column.received.title, orientation: .right) - header.addTarget(self, action: #selector(didPressColumnTitle)) - return header + let view = ColumnTableViewHeaderView() + view.addColumn(with: Column.pilot.title, orientation: .left) // TODO: Let the subview do the chevron layout logic. Use an enum to track each column type + view.addColumn(with: Column.paid.title, orientation: .right) + view.addColumn(with: Column.received.title, orientation: .right) + view.addTarget(self, action: #selector(didPressColumnTitle)) + return view }() fileprivate var isLoading: Bool = false { @@ -183,7 +183,7 @@ class RacePaymentsViewController: UIViewController, RaceTabbable { } raceApi.getRacePayments(with: race.id) { payments, error in - guard let payments = payments else { + guard let payments = payments, payments.count > 0 else { return self.finishLoading() } diff --git a/RaceSync/View Controllers/Races/RacePilotsViewController.swift b/RaceSync/View Controllers/Races/RacePilotsViewController.swift index 1bc168b3..861a53a4 100644 --- a/RaceSync/View Controllers/Races/RacePilotsViewController.swift +++ b/RaceSync/View Controllers/Races/RacePilotsViewController.swift @@ -32,13 +32,22 @@ class RacePilotsViewController: UIViewController, ViewJoinable, RaceTabbable, Pi tableView.delegate = self tableView.emptyDataSetDelegate = self tableView.emptyDataSetSource = self - tableView.tableFooterView = UIView() - tableView.backgroundColor = Color.gray50 tableView.register(cellType: FormTableViewCell.self) tableView.register(cellType: AvatarTableViewCell.self) + tableView.refreshControl = self.refreshControl + tableView.tableFooterView = UIView() + tableView.backgroundColor = Color.gray50 return tableView }() + fileprivate lazy var refreshControl: UIRefreshControl = { + let refreshControl = UIRefreshControl() + refreshControl.backgroundColor = Color.gray50 + refreshControl.tintColor = Color.blue + refreshControl.addTarget(self, action: #selector(didPullRefreshControl), for: .valueChanged) + return refreshControl + }() + fileprivate var userApi = UserApi() fileprivate var userViewModels = [UserViewModel]() @@ -61,6 +70,7 @@ class RacePilotsViewController: UIViewController, ViewJoinable, RaceTabbable, Pi fileprivate enum Constants { static let padding: CGFloat = UniversalConstants.padding + static let cellHeight: CGFloat = UniversalConstants.cellHeight static let buttonSpacing: CGFloat = 12 } @@ -113,16 +123,19 @@ class RacePilotsViewController: UIViewController, ViewJoinable, RaceTabbable, Pi } fileprivate func configureNavigationItems() { + navigationItem.rightBarButtonItem = raceController.navigationItems() - if race.canShowResults { - title = "Race Results" - tabBarItem = UITabBarItem(title: "Results", image: SystemImg.medal, selectedImage: SystemImg.medalFill) - } else { + if race.startDate.map({ !$0.isPassed }) ?? false { title = "Racing Pilots" tabBarItem = UITabBarItem(title: "Pilots", image: SystemImg.person, selectedImage: SystemImg.personFill) + } else { + if race.canShowResults { + title = "Race Results" + } else { + title = "Race in Progress" + } + tabBarItem = UITabBarItem(title: "Results", image: SystemImg.medal, selectedImage: SystemImg.medalFill) } - - navigationItem.rightBarButtonItem = raceController.navigationItems() } // MARK: - Actions @@ -151,6 +164,10 @@ class RacePilotsViewController: UIViewController, ViewJoinable, RaceTabbable, Pi didTapCell = loading } + @objc fileprivate func didPullRefreshControl() { + reloadRace() + } + func canInteract(with cell: AvatarTableViewCell) -> Bool { guard !cell.isLoading else { return false } guard !didTapCell else { return false } @@ -175,15 +192,20 @@ class RacePilotsViewController: UIViewController, ViewJoinable, RaceTabbable, Pi } func resetTableView() { - tableView.setContentOffset(.zero, animated: false) tableView.reloadData() invalidatePinnedView() + + tableView.setContentOffset(CGPoint(x: 0, y: -tableView.adjustedContentInset.top), animated: false) + + if refreshControl.isRefreshing { + refreshControl.endRefreshing() + } } // MARK: - Pinnable func canPinView() -> Bool { - return true + return myUserId != nil } func pinnedViewIndexPath() -> IndexPath? { @@ -193,9 +215,8 @@ class RacePilotsViewController: UIViewController, ViewJoinable, RaceTabbable, Pi return cached } - let source = userViewModels let section = showingExternalResults() ? 1 : 0 - guard let index = source.firstIndex(where: { $0.userId == userId }) else { + guard let index = userViewModels.firstIndex(where: { $0.userId == userId }) else { return nil } @@ -203,52 +224,6 @@ class RacePilotsViewController: UIViewController, ViewJoinable, RaceTabbable, Pi cachedPinnedIndexPath = indexPath return indexPath } - - func configure(_ view: T, forRowAt indexPath: IndexPath) where T : UITableViewCell { - guard let cell = view as? AvatarTableViewCell else { return } - - let viewModel = userViewModels[indexPath.row] - - cell.avatarImageView.imageView.setImage(with: viewModel.pictureUrl, placeholderImage: PlaceholderImg.medium) - cell.titleLabel.text = viewModel.displayName - cell.subtitleLabel.text = ResultEntryViewModel.noResultPlaceholder - cell.rankView.rank = nil - cell.textPill.text = nil - cell.textPill.style = .badge - cell.titleLabel.textColor = Color.black - cell.subtitleLabel.textColor = Color.gray300 - cell.rankView.titleLabel.textColor = Color.gray300 - cell.backgroundColor = Color.white - cell.selectedBackgroundView?.backgroundColor = Color.gray20 - - if race.canShowResults { - if let resultEntry = viewModel.resultEntry { - let resultEntryVM = ResultEntryViewModel(with: resultEntry, from: race) - - if resultEntryVM.resultLabel != nil { - cell.subtitleLabel.text = resultEntryVM.resultLabel - cell.rankView.rank = Int32(indexPath.row+1) - } - } - - if let score = viewModel.score, score > 0 { - let unit = (score == 1) ? "pt" : "pts" - cell.textPill.text = "\(score) \(unit)" - cell.textPill.style = .text - cell.rankView.rank = Int32(indexPath.row+1) - } - - if let userId = myUserId, viewModel.userId == userId { - cell.titleLabel.textColor = Color.white - cell.subtitleLabel.textColor = Color.gray20 - cell.rankView.titleLabel.textColor = Color.gray20 - cell.backgroundColor = Color.gray200 - cell.selectedBackgroundView?.backgroundColor = Color.gray300 - } - } else if race.raceClass != .esport { - cell.textPill.text = viewModel.channelLabel // only real races have frequencies - } - } } extension RacePilotsViewController: UITableViewDelegate { @@ -257,7 +232,7 @@ extension RacePilotsViewController: UITableViewDelegate { if showingExternalResults(), indexPath.section == externalResultSection { guard let url = race.liveTimeEventUrl else { return } - WebViewController.openUrl(url) + WebViewController.open(url) } else { showUserProfile(forUserAt: indexPath) } @@ -266,8 +241,15 @@ extension RacePilotsViewController: UITableViewDelegate { } func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - if race.canShowResults { - return section == externalResultSection ? nil : race.scoringFormat.title + + if (showingExternalResults() && section == externalResultSection) { + return nil + } + + if race.inProgress { + return "\(race.scoringFormat.title)\n\(raceController.currentRaceTitle())" + } else if race.canShowResults { + return "\(race.scoringFormat.title)" } else { return nil } @@ -317,7 +299,7 @@ extension RacePilotsViewController: UITableViewDataSource { if showingExternalResults(), indexPath.section == externalResultSection { return UniversalConstants.cellFormHeight } else { - return UniversalConstants.cellHeight + return Constants.cellHeight } } @@ -344,6 +326,66 @@ extension RacePilotsViewController: UITableViewDataSource { return cell } + + func configure(_ view: T, forRowAt indexPath: IndexPath) where T : UITableViewCell { + guard let cell = view as? AvatarTableViewCell else { return } + + let viewModel = userViewModels[indexPath.row] + + cell.avatarImageView.imageView.setImage(with: viewModel.pictureUrl, placeholderImage: PlaceholderImg.medium) + cell.titleLabel.text = viewModel.username + cell.subtitleLabel.text = ResultEntryViewModel.noResultPlaceholder + cell.rankView.rank = nil + cell.textPill.text = nil + cell.textPill.style = .badge + cell.titleLabel.textColor = Color.black + cell.subtitleLabel.textColor = Color.gray300 + cell.rankView.titleLabel.textColor = Color.gray300 + cell.backgroundColor = (indexPath.row % 2 == 0) ? Color.white : Color.gray20 + cell.selectedBackgroundView?.backgroundColor = Color.gray50 + + if race.canShowResults { + var score:Int32? = 0 + cell.rankView.rank = score + + if let resultEntry = viewModel.resultEntry { + let resultEntryVM = ResultEntryViewModel(with: resultEntry, from: race) + if (!race.isGQ) { score = viewModel.score } + + if resultEntryVM.resultLabel != nil { + cell.subtitleLabel.text = resultEntryVM.resultLabel + cell.rankView.rank = Int32(indexPath.row+1) + } + } else if let raceEntry = viewModel.raceEntry { + if (!race.isGQ) { score = raceEntry.score } + + if (score ?? 0 > 0) { + cell.rankView.rank = Int32(indexPath.row+1) + cell.subtitleLabel.text = ResultEntryViewModel.noTimesPlaceholder + } + } + + if let score = score, score > 0 { + let unit = (score == 1) ? "pt" : "pts" + cell.textPill.text = "\(score) \(unit)" + cell.textPill.style = .text + cell.rankView.rank = Int32(indexPath.row+1) + } + + if let userId = myUserId, viewModel.userId == userId { + cell.titleLabel.textColor = Color.white + cell.subtitleLabel.textColor = Color.gray20 + cell.rankView.titleLabel.textColor = Color.gray20 + cell.backgroundColor = Color.gray200 + cell.selectedBackgroundView?.backgroundColor = Color.gray300 + } + } + + // only real races have frequencies + if !race.hasEnded && race.raceClass != .esport { + cell.textPill.text = viewModel.channelLabel + } + } } extension RacePilotsViewController: UIScrollViewDelegate { @@ -371,8 +413,7 @@ extension RacePilotsViewController: EmptyDataSetSource { } func buttonTitle(forEmptyDataSet scrollView: UIScrollView, for state: UIControl.State) -> NSAttributedString? { - guard let startDate = race.startDate else { return nil } - if race.status == .open && !startDate.isPassed { + if race.status == .open && !race.hasStarted { return emptyStateNoPilots.buttonTitle(state) } else { return nil diff --git a/RaceSync/View Controllers/Races/RaceTabBarController.swift b/RaceSync/View Controllers/Races/RaceTabBarController.swift index 7af8aa10..18aa5537 100644 --- a/RaceSync/View Controllers/Races/RaceTabBarController.swift +++ b/RaceSync/View Controllers/Races/RaceTabBarController.swift @@ -12,8 +12,7 @@ import EmptyDataSet_Swift import RaceSyncAPI enum RaceTabs: Int { - case details, results, schedule - + case details, pilots, schedule, payments static let `default`: Self = .details } @@ -160,7 +159,7 @@ class RaceTabBarController: UITabBarController { // MARK: - Actions - fileprivate func selectTab(_ tab: RaceTabs) { + func selectTab(_ tab: RaceTabs) { selectedIndex = tab.rawValue } diff --git a/RaceSync/View Controllers/Search/SearchViewController.swift b/RaceSync/View Controllers/Search/DummyViewController.swift similarity index 89% rename from RaceSync/View Controllers/Search/SearchViewController.swift rename to RaceSync/View Controllers/Search/DummyViewController.swift index 31bc5635..5bf2cc86 100644 --- a/RaceSync/View Controllers/Search/SearchViewController.swift +++ b/RaceSync/View Controllers/Search/DummyViewController.swift @@ -1,5 +1,5 @@ // -// SearchViewController.swift +// DummyViewController.swift // RaceSync // // Created by Ignacio Romero Zurbuchen on 2019-11-15. @@ -8,7 +8,7 @@ import UIKit -class SearchViewController: UIViewController { +class DummyViewController: UIViewController { // MARK: - Private Variables @@ -48,14 +48,14 @@ class SearchViewController: UIViewController { } } -extension SearchViewController: UITableViewDelegate { +extension DummyViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) } } -extension SearchViewController: UITableViewDataSource { +extension DummyViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return 10 diff --git a/RaceSync/View Controllers/Search/UniversalSearchViewController.swift b/RaceSync/View Controllers/Search/UniversalSearchViewController.swift new file mode 100644 index 00000000..f2b5d81a --- /dev/null +++ b/RaceSync/View Controllers/Search/UniversalSearchViewController.swift @@ -0,0 +1,539 @@ +// +// UniversalSearchViewController.swift +// RaceSync +// +// Created by Ignacio Romero Zurbuchen on 2025-12-09. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import UIKit +import SnapKit +import RaceSyncAPI +import EmptyDataSet_Swift +import ShimmerSwift + +fileprivate typealias SearchResults = [Section : [Any]] +fileprivate typealias SearchResultsCompletionBlock = (_ results: SearchResults, _ error: NSError?) -> Void + +class UniversalSearchViewController: UIViewController, Shimmable { + + // MARK: - Private Variables + + lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.dataSource = self + tableView.delegate = self + tableView.register(cellType: RaceTableViewCell.self) + tableView.register(cellType: AvatarTableViewCell.self) + tableView.register(cellType: ChapterTableViewCell.self) + tableView.emptyDataSetSource = self + tableView.tableFooterView = UIView() + tableView.keyboardDismissMode = .interactive + return tableView + }() + + let shimmeringView: ShimmeringView = defaultShimmeringView() + + // MARK: - Private Variables + + fileprivate lazy var searchBar: UISearchBar = { + let searchBar = UISearchBar() + searchBar.delegate = self + searchBar.searchBarStyle = .minimal + searchBar.placeholder = "Search by name or id" + searchBar.barTintColor = .white + searchBar.isTranslucent = false + searchBar.backgroundImage = UIImage() + searchBar.tintColor = Color.blue + searchBar.showsSearchResultsButton = false + + if let textField = searchBar.value(forKey: "searchField") as? UITextField { + textField.returnKeyType = .done + } + + return searchBar + }() + + fileprivate lazy var segmentedControl: UISegmentedControl = { + let control = UISegmentedControl() + control.addTarget(self, action: #selector(didChangeSegment), for: .valueChanged) + control.isEnabled = false + + for section in Section.allCases { + control.insertSegment(withTitle: section.title, at: section.index, animated: false) + } + return control + }() + + + fileprivate lazy var headerView: UIView = { + let view = UIView() + view.backgroundColor = Color.navigationBarColor + view.tintColor = Color.blue + + let spacing = 10 + + view.addSubview(searchBar) + searchBar.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.equalToSuperview().offset(Constants.padding) + $0.trailing.equalToSuperview().offset(-Constants.padding) + $0.height.equalTo(Constants.searchBarHeight) + } + + view.addSubview(segmentedControl) + segmentedControl.snp.makeConstraints { + $0.top.equalTo(searchBar.snp.bottom).offset(spacing) + $0.leading.equalTo(searchBar.snp.leading).offset(8) + $0.trailing.equalTo(searchBar.snp.trailing).offset(-8) + $0.bottom.equalToSuperview().offset(-spacing) + } + + let separatorLine = UIView() + separatorLine.backgroundColor = Color.gray100 + view.addSubview(separatorLine) + separatorLine.snp.makeConstraints { + $0.height.equalTo(0.5) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(view.snp.bottom) + } + return view + }() + + fileprivate let raceApi = RaceApi() + fileprivate let userApi = UserApi() + fileprivate let chapterApi = ChapterApi() + + fileprivate var searchResults = SearchResults() + fileprivate let minQuery: Int = 3 + fileprivate var searchDebounceTimer: Timer? + fileprivate let searchDebounceInterval: TimeInterval = 0.5 + + fileprivate let emptyStateSearch = EmptyStateViewModel(.noSearchResults) + + fileprivate var isSearching: Bool { + guard let text = searchBar.text else { return false } + let query = text.trimmingCharacters(in: .whitespacesAndNewlines) + return query.count >= minQuery || query.containsEmoji + } + + fileprivate enum Constants { + static let padding: CGFloat = UniversalConstants.padding + static let avatarImageSize = CGSize(width: 50, height: 50) + static let searchBarHeight: CGFloat = 56 + static let headerViewHeight: CGFloat = 110 + } + + // MARK: - Lifecycle Methods + + override func viewDidLoad() { + super.viewDidLoad() + + configureNavigationItems() + setupLayout() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + hideNavigationShadow() + searchBar.becomeFirstResponder() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if let nc = navigationController, nc.viewControllers.count == 2 { + hideNavigationShadow(false) + } + + searchBar.resignFirstResponder() + } + + deinit { + invalidateSearchDebounce() + } + + // MARK: - Layout + + fileprivate func setupLayout() { + + view.backgroundColor = Color.white + + view.addSubview(headerView) + headerView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) + $0.height.equalTo(Constants.headerViewHeight) + $0.leading.trailing.equalToSuperview() + } + + view.addSubview(tableView) + tableView.snp.makeConstraints { + $0.top.equalTo(headerView.snp.bottom) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(view.snp.bottom) + } + + view.addSubview(shimmeringView) + shimmeringView.snp.makeConstraints { + $0.top.equalTo(tableView.snp.top) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(tableView.snp.bottom) + } + + selectedSection = .races + } + + fileprivate func configureNavigationItems() { + title = "Universal Search" + + navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) + } + + // MARK: - Data + + fileprivate func startSearch(with text: String) { + let query = text.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + + if query.count >= minQuery { + guard !query.containsEmoji else { return } + + isLoadingList(true) + searchResults = SearchResults() + resetTableView() + + search(with: query) { [weak self] results, error in + self?.searchResults = results + self?.resetTableView() + self?.updateAllSegments() + self?.isLoadingList(false) + } + } else { + searchResults = SearchResults() + resetTableView() + updateAllSegments() + } + + invalidateSearchDebounce() + } + + fileprivate func search(with query: String, _ completion: @escaping SearchResultsCompletionBlock) { + + // Cancel any pending search requests + cancelSearchRequests() + + let id = Int(query) != nil ? query : "" + let name = Int(query) == nil ? query : "" + + var results = SearchResults() + var firstError: NSError? + + let group = DispatchGroup() + + // Races + group.enter() + raceApi.getRaces(with: [], raceId: id, name: name) { objects, error in + defer { group.leave() } + + if let races = objects { + results[.races] = RaceViewModel.sortedViewModels(with: races, sorting: .ascending) as [Any] + } else if let error = error, firstError == nil { + firstError = error + } + } + + // Users + group.enter() + userApi.searchUser(with: query) { object, error in + defer { group.leave() } + + if let user = object { + results[.users] = UserViewModel.viewModels(with: [user]) + } else if let error = error, firstError == nil { + firstError = error + } + } + + // Chapters + group.enter() + chapterApi.searchChapter(with: query) { object, error in + defer { group.leave() } + + if let chapter = object { + results[.chapters] = ChapterViewModel.viewModels(with: [chapter]) + } else if let error = error, firstError == nil { + firstError = error + } + } + + // Single completion, once all three calls finished + group.notify(queue: .main) { + completion(results, firstError) + } + } + + fileprivate func cancelSearchRequests() { + raceApi.cancelSearchRequests() + userApi.cancelSearchRequests() + chapterApi.cancelSearchRequests() + } + + fileprivate var totalNumberOfItems: Int { + Section.allCases.reduce(0) { total, section in + total + (searchResults[section]?.count ?? 0) + } + } + + fileprivate func numberOfItems(in section: Section) -> Int { + guard let objects = searchResults[section] else { return 0 } + return objects.count + } + + fileprivate func raceViewModel(for index: Int) -> RaceViewModel? { + return viewModel(for: .races, at: index) as RaceViewModel? + } + + fileprivate func userViewModel(for index: Int) -> UserViewModel? { + return viewModel(for: .users, at: index) as UserViewModel? + } + + fileprivate func chapterViewModel(for index: Int) -> ChapterViewModel? { + return viewModel(for: .chapters, at: index) as ChapterViewModel? + } + + fileprivate func viewModel(for section: Section, at index: Int) -> T? { + guard let results = searchResults[section] as? [T], + results.indices.contains(index) + else { return nil } + return results[index] + } + + fileprivate var selectedSectionIdx: Int { + return segmentedControl.selectedSegmentIndex + } + + fileprivate var selectedSection: Section { + get { + return Section(index: selectedSectionIdx) + } + set { + segmentedControl.selectedSegmentIndex = newValue.index + } + } + + fileprivate func updateAllSegments() { + Section.allCases.forEach { section in + let count = searchResults[section]?.count ?? 0 + updateSegment(for: section, with: count) + } + } + + fileprivate func updateSegment(for section: Section, with count: Int) { + var title = section.title(with: count) + if !isSearching { title = section.title } + + segmentedControl.setTitle(title, forSegmentAt: section.index) + } + + fileprivate func resetTableView() { + segmentedControl.isEnabled = (totalNumberOfItems > 0) + tableView.setContentOffset(.zero, animated: false) + tableView.reloadData() + } + + fileprivate func invalidateSearchDebounce() { + searchDebounceTimer?.invalidate() + searchDebounceTimer = nil + } + + // MARK: - Actions + + @objc fileprivate func didChangeSegment() { + resetTableView() + } + + @objc fileprivate func didPressCloseButton() { + searchBar.resignFirstResponder() + dismiss(animated: true) + } +} + +extension UniversalSearchViewController: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + return Section.allCases.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard isSearching else { return 0 } + + if section == selectedSectionIdx { + return numberOfItems(in: selectedSection) + } else { + return 0 + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch selectedSection { + case .races: return raceTableViewCell(for: indexPath) + case .users: return userTableViewCell(for: indexPath) + case .chapters: return chapterTableViewCell(for: indexPath) + } + } + + func raceTableViewCell(for indexPath: IndexPath) -> RaceTableViewCell { + let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as RaceTableViewCell + guard let viewModel = raceViewModel(for: indexPath.row) else { return cell } + + cell.dateLabel.text = viewModel.startDateLabel //"Saturday Sept 14 @ 9:00 AM" + cell.titleLabel.text = viewModel.titleLabel + cell.subtitleLabel.text = viewModel.chapterLabel + cell.joinButton.isHidden = true + cell.memberBadgeView.isHidden = true + cell.avatarImageView.imageView.setImage(with: viewModel.imageUrl, placeholderImage: PlaceholderImg.medium, size: Constants.avatarImageSize) + cell.accessoryType = .disclosureIndicator + return cell + } + + func userTableViewCell(for indexPath: IndexPath) -> AvatarTableViewCell { + let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as AvatarTableViewCell + guard let viewModel = userViewModel(for: indexPath.row) else { return cell } + + cell.titleLabel.text = viewModel.displayName + cell.avatarImageView.imageView.setImage(with: viewModel.pictureUrl, placeholderImage: PlaceholderImg.medium, size: Constants.avatarImageSize) + cell.subtitleLabel.text = viewModel.fullName + cell.accessoryType = .disclosureIndicator + return cell + } + + func chapterTableViewCell(for indexPath: IndexPath) -> ChapterTableViewCell { + let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as ChapterTableViewCell + guard let viewModel = chapterViewModel(for: indexPath.row) else { return cell } + + cell.titleLabel.text = viewModel.titleLabel + cell.subtitleLabel.text = viewModel.locationLabel + cell.avatarImageView.imageView.setImage(with: viewModel.imageUrl, placeholderImage: PlaceholderImg.medium, size: Constants.avatarImageSize) + cell.accessoryType = .disclosureIndicator + return cell + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return UniversalConstants.cellHeight + } +} + +extension UniversalSearchViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + var vc: UIViewController? + + switch selectedSection { + case .races: + guard let vm = raceViewModel(for: indexPath.row) else { return } + vc = RaceTabBarController(with: vm.race) + case .users: + guard let vm = userViewModel(for: indexPath.row) else { return } + if let user = vm.user { + vc = UserViewController(with: user) + } + case .chapters: + guard let vm = chapterViewModel(for: indexPath.row) else { return } + vc = ChapterViewController(with: vm.chapter) + } + + if let vc = vc { + vc.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(vc, animated: true) + } + } +} + + +extension UniversalSearchViewController: UISearchBarDelegate { + + func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool { + return true + } + + func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + + } + + func searchBarShouldEndEditing(_ searchBar: UISearchBar) -> Bool { + return true + } + + func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + + if searchText.count > 0 { + // Cancel the previous pending search + invalidateSearchDebounce() + + // Start a new debounce timer + searchDebounceTimer = Timer.scheduledTimer(withTimeInterval: searchDebounceInterval, repeats: false) { [weak self] _ in + self?.startSearch(with: searchText) + } + } else { + startSearch(with: searchText) + } + } + + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.text = nil + searchBar.resignFirstResponder() + resetTableView() + } +} + +extension UniversalSearchViewController: EmptyDataSetSource { + + func title(forEmptyDataSet scrollView: UIScrollView) -> NSAttributedString? { + if isSearching { + return emptyStateSearch.title + } + return nil + } + + func verticalOffset(forEmptyDataSet scrollView: UIScrollView) -> CGFloat { + return -scrollView.frame.height/10 + } +} + +fileprivate enum Section: EnumTitle { + case races, users, chapters + + var title: String { plural } + + init(index: Int) { + self = Section.allCases[safe: index] ?? .races + } + + // Dynamic index based on position in allCases + var index: Int { Section.allCases.firstIndex(of: self) ?? 0 } + + func title(with count: Int) -> String { + let word = (count == 1) ? singular : plural + return "\(count) \(word)" + } + + private var singular: String { + switch self { + case .races: return "Race" + case .users: return "User" + case .chapters: return "Chapter" + } + } + + private var plural: String { "\(singular)s" } +} diff --git a/RaceSync/View Controllers/Series/SeriesDetailViewController.swift b/RaceSync/View Controllers/Series/SeriesDetailViewController.swift new file mode 100644 index 00000000..5d68fc04 --- /dev/null +++ b/RaceSync/View Controllers/Series/SeriesDetailViewController.swift @@ -0,0 +1,150 @@ +// +// SeriesDetailViewController.swift +// RaceSync +// +// Created by Ignacio Romero Zurbuchen on 2025-10-01. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import UIKit +import SnapKit +import RaceSyncAPI + +class SeriesDetailViewController: UIViewController { + + // MARK: - Public Variables + + let series: Series + + // MARK: - Private Variables + + fileprivate lazy var scrollView: UIScrollView = { + let view = UIScrollView() + view.showsVerticalScrollIndicator = false + view.backgroundColor = Color.white + view.isScrollEnabled = true + view.alwaysBounceVertical = true + view.delegate = self + return view + }() + + fileprivate let headerView = ProfileHeaderView() + + fileprivate enum Constants { + static let padding: CGFloat = UniversalConstants.padding + static let cellHeight: CGFloat = UniversalConstants.cellHeight + } + + // MARK: - Initialization + + init(with series: Series) { + self.series = series + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle Methods + + override func viewDidLoad() { + super.viewDidLoad() + + setupLayout() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + } + + // MARK: - Layout + + fileprivate func setupLayout() { + + configureNavigationItems() + + view.addSubview(scrollView) + scrollView.snp.makeConstraints { + $0.top.leading.trailing.bottom.equalToSuperview() + } + + let profileViewModel = ProfileViewModel(with: series) + headerView.viewModel = profileViewModel + let headerViewSize = headerView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + + scrollView.addSubview(headerView) + headerView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.size.equalTo(headerViewSize) + } + + scrollView.contentSize = view.bounds.size + } + + fileprivate func configureNavigationItems() { + title = "Details" + tabBarItem = UITabBarItem(title: title, image: SystemImg.calendarCclock, selectedImage: nil) + } +} + +//extension SeriesDetailViewController: UITableViewDelegate { +// +// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { +// tableView.deselectRow(at: indexPath, animated: true) +// } +//} +// +//extension SeriesDetailViewController: UITableViewDataSource { +// +// func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { +// return 10 +// } +// +// func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { +// return UITableViewCell() +// } +// +// func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { +// return Constants.cellHeight +// } +//} + +extension SeriesDetailViewController: UIScrollViewDelegate { + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + stretchHeaderView(with: scrollView.contentOffset) + } +} + +extension SeriesDetailViewController: ScrollToTop { + + func scrollToTop() { + scrollView.setContentOffset(.zero, animated: true) + } +} + +// MARK: - HeaderStretchable + +extension SeriesDetailViewController: HeaderStretchable { + + var targetHeaderView: StretchableView { + return headerView.backgroundView + } + + var targetHeaderViewSize: CGSize { + return headerView.backgroundViewSize + } + + var topLayoutInset: CGFloat { + return 0 + } + + var anchoredViews: [UIView]? { + return nil + } +} diff --git a/RaceSync/View Controllers/Series/SeriesStandingsViewController.swift b/RaceSync/View Controllers/Series/SeriesStandingsViewController.swift new file mode 100644 index 00000000..4b1ffdbd --- /dev/null +++ b/RaceSync/View Controllers/Series/SeriesStandingsViewController.swift @@ -0,0 +1,219 @@ +// +// SeriesStandingsViewController.swift +// RaceSync +// +// Created by Ignacio Romero Zurbuchen on 2025-10-01. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import UIKit +import SnapKit +import RaceSyncAPI + +class SeriesStandingsViewController: UIViewController, Pinnable { + + // MARK: - Public Variables + + let series: Series + + lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .grouped) + tableView.dataSource = self + tableView.delegate = self +// tableView.emptyDataSetSource = self + tableView.register(cellType: AvatarTableViewCell.self) + tableView.tableFooterView = UIView() + + let backgroundView = UIView() + backgroundView.backgroundColor = Color.gray20 + tableView.backgroundView = backgroundView + return tableView + }() + + // MARK: - Private Variables + + fileprivate var myUserId: ObjectId? { + get { return APIServices.shared.myUser?.id } + } + + var pinnedView: UIView? + var cachedPinnedIndexPath: IndexPath? + + fileprivate var userApi = UserApi() + + fileprivate enum Constants { + static let padding: CGFloat = UniversalConstants.padding + static let cellHeight: CGFloat = 86 + } + + // MARK: - Initialization + + init(with series: Series) { + self.series = series + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle Methods + + override func viewDidLoad() { + super.viewDidLoad() + + setupLayout() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + } + + // MARK: - Layout + + fileprivate func setupLayout() { + + configureNavigationItems() + + registerPinnedView(viewType: AvatarTableViewCell.self) + + view.addSubview(tableView) + tableView.snp.makeConstraints { + $0.top.bottom.leading.trailing.equalToSuperview() + } + } + + fileprivate func configureNavigationItems() { + title = "Leaderboard" + tabBarItem = UITabBarItem(title: title, image: SystemImg.trophy, selectedImage: SystemImg.trophyFill) + } + + // MARK: - Data Update + + fileprivate func result(at indexPath: IndexPath) -> SeriesResult? { + guard let results = series.pilotResults else { return nil } + return results[indexPath.row] + } + + // MARK: - Pinnable + + func canPinView() -> Bool { + return myUserId != nil + } + + func pinnedViewIndexPath() -> IndexPath? { + guard let userId = myUserId, let results = series.pilotResults else { return nil } + + if let cached = cachedPinnedIndexPath { + return cached + } + + guard let index = results.firstIndex(where: { $0.pilotId == userId }) else { + return nil + } + + let indexPath = IndexPath(row: index, section: 0) + cachedPinnedIndexPath = indexPath + return indexPath + } + + // MARK: - Actions + + func showUserProfile(forUserAt indexPath: IndexPath, from cell: AvatarTableViewCell) { + guard let result = result(at: indexPath), let pilotId = result.pilotId else { return } + + cell.isLoading = true + + userApi.getUser(with: pilotId) { [weak self] (user, error) in + if let user = user { + let vc = UserViewController(with: user) + self?.navigationController?.pushViewController(vc, animated: true) + } else if let _ = error { + // handle error + } + cell.isLoading = false + } + } +} + +extension SeriesStandingsViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let cell = tableView.cellForRow(at: indexPath) as? AvatarTableViewCell else { return } + tableView.deselectRow(at: indexPath, animated: true) + + showUserProfile(forUserAt: indexPath, from: cell) + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return series.typeString + } +} + +extension SeriesStandingsViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + if let results = series.pilotResults { + return results.count + } + return 0 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as AvatarTableViewCell + configure(cell, forRowAt: indexPath) + return cell + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return Constants.cellHeight + } + + func configure(_ view: T, forRowAt indexPath: IndexPath) where T : UITableViewCell { + guard let cell = view as? AvatarTableViewCell, let result = result(at: indexPath) else { return } + + cell.rankView.rank = Int32(indexPath.row + 1) + cell.titleLabel.text = result.displayName + cell.avatarImageView.imageView.setImage(with: result.imageUrl, placeholderImage: PlaceholderImg.medium) + cell.accessoryView = nil + + if series.type == .fastest3laps { + cell.subtitleLabel.text = TimeUtil.lapTimeFormat(seconds: result.score) + } else { + cell.subtitleLabel.text = result.score + } + + if let pilotId = result.pilotId, let userId = myUserId, pilotId == userId { + cell.titleLabel.textColor = Color.white + cell.subtitleLabel.textColor = Color.gray20 + cell.rankView.titleLabel.textColor = Color.gray20 + cell.backgroundColor = Color.gray200 + cell.selectedBackgroundView?.backgroundColor = Color.gray300 + } else { + cell.titleLabel.textColor = Color.black + cell.subtitleLabel.textColor = Color.gray300 + cell.rankView.titleLabel.textColor = Color.gray300 + cell.backgroundColor = (indexPath.row % 2 == 0) ? Color.white : Color.gray20 + cell.selectedBackgroundView?.backgroundColor = Color.gray50 + } + } +} + +extension SeriesStandingsViewController: UIScrollViewDelegate { + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard let results = series.pilotResults, results.count > 0 else { return } + layoutPinnedView() + } +} + +extension SeriesStandingsViewController: ScrollToTop { + + func scrollToTop() { + tableView.setContentOffset(.zero, animated: true) + } +} diff --git a/RaceSync/View Controllers/Series/SeriesTabBarController.swift b/RaceSync/View Controllers/Series/SeriesTabBarController.swift new file mode 100644 index 00000000..db8e3a57 --- /dev/null +++ b/RaceSync/View Controllers/Series/SeriesTabBarController.swift @@ -0,0 +1,180 @@ +// +// SeriesTabBarController.swift +// RaceSync +// +// Created by Ignacio Romero Zurbuchen on 2025-10-01. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import UIKit +import SnapKit +import EmptyDataSet_Swift +import RaceSyncAPI + +enum SeriesTabs: Int { + case details, races, standings + static let `default`: Self = .details +} + +class SeriesTabBarController: UITabBarController { + + // MARK: - Public Variables + + var seriesId: ObjectId + var series: Series? + + // MARK: - Private Variables + + fileprivate lazy var activityIndicatorView: ActivityLoadingView = { + let view = ActivityLoadingView(style: .medium) + view.title = "Loading Series..." + view.hidesWhenStopped = true + return view + }() + + fileprivate var initialSelectedIndex: Int = SeriesTabs.default.rawValue + + fileprivate let seriesApi = SeriesApi() + fileprivate var seriesViewModels: SeriesViewModel? + + // MARK: - Initialization + + init(with id: ObjectId) { + self.seriesId = id + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle Methods + + override func viewDidLoad() { + super.viewDidLoad() + + setupLayout() + loadSeries() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + } + + // MARK: - Layout + + fileprivate func setupLayout() { + + view.backgroundColor = Color.white + tabBar.isHidden = true // hiding temporarily, while the view loads + delegate = self + + view.addSubview(activityIndicatorView) + activityIndicatorView.snp.makeConstraints { + $0.centerX.centerY.equalToSuperview() + } + } + + fileprivate func configureViewControllers() { + guard let series = series else { return } + + var raceViewModels = [RaceViewModel]() + if let races = series.races { + raceViewModels += RaceViewModel.viewModels(with: races) + } + + var vcs = [UIViewController]() + vcs += [SeriesDetailViewController(with: series)] + vcs += [RaceListViewController(raceViewModels, seriesId: seriesId)] + vcs += [SeriesStandingsViewController(with: series)] + + configureTabBarController(with: vcs, selectedIndex: initialSelectedIndex) + + title = vcs.first?.title + tabBar.isHidden = false + } + + // MARK: - Data Update + + fileprivate func loadSeries() { + setLoading(true) + + seriesApi.view(series: seriesId) { [weak self] series, error in + guard let self = self else { return } + self.setLoading(false) + self.series = series + + if let error = error { + self.handleError(error) + } else { + self.configureViewControllers() + } + } + } + + fileprivate func setLoading(_ loading: Bool) { + activityIndicatorView.isLoading = loading + } + + // MARK: - Actions + + fileprivate func selectTab(_ tab: SeriesTabs) { + selectedIndex = tab.rawValue + } + + fileprivate func didSelectedIndex(_ index: Int) { + guard let vc = viewControllers?[index] else { return } + + title = vc.title + navigationItem.rightBarButtonItem = vc.navigationItem.rightBarButtonItem + } + + // MARK: - Error Handling + + fileprivate func handleError(_ error: Error) { + +// emptyStateError = EmptyStateViewModel(.errorRaces) +// +// // temporary scroll view used to display the error message +// let scrollView = UIScrollView() +// scrollView.contentInsetAdjustmentBehavior = .never +// scrollView.emptyDataSetDelegate = self +// scrollView.emptyDataSetSource = self +// +// view.addSubview(scrollView) +// scrollView.snp.makeConstraints { +// $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) +// $0.bottom.leading.trailing.equalToSuperview() +// } +// +// scrollView.reloadEmptyDataSet() + } +} + +extension SeriesTabBarController: UITabBarControllerDelegate { + + func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { + return true + } + + func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { + + (tabBar as? RoundedSelectionTabBar)?.updateSelectionFrame(animated: true) + + if tabBarController.selectedViewController == viewController { + // Notify the currently visible VC to scroll to top + if let topVC = viewController as? ScrollToTop { + topVC.scrollToTop() + } + } + + if let index = viewControllers?.lastIndex(of: viewController) { + didSelectedIndex(index) + } + } +} + diff --git a/RaceSync/View Controllers/Series/SeriesViewController.swift b/RaceSync/View Controllers/Series/SeriesViewController.swift index 992266da..a900add5 100644 --- a/RaceSync/View Controllers/Series/SeriesViewController.swift +++ b/RaceSync/View Controllers/Series/SeriesViewController.swift @@ -7,22 +7,95 @@ // import UIKit +import SnapKit +import RaceSyncAPI +import ShimmerSwift -class SeriesViewController: UIViewController { +class SeriesViewController: UIViewController, Shimmable { - // MARK: - Private Variables + // MARK: - Public Variables - fileprivate lazy var tableView: UITableView = { + lazy var tableView: UITableView = { let tableView = UITableView(frame: .zero, style: .plain) + tableView.backgroundView = UIView() + tableView.backgroundView?.backgroundColor = Color.clear + tableView.backgroundColor = Color.gray50 + tableView.contentInsetAdjustmentBehavior = .always tableView.dataSource = self tableView.delegate = self +// tableView.emptyDataSetSource = self +// tableView.emptyDataSetDelegate = self + tableView.register(cellType: SimpleTableViewCell.self) + tableView.tableHeaderView = self.sliderHeaderView + tableView.refreshControl = self.refreshControl tableView.tableFooterView = UIView() return tableView }() + var shimmeringView: ShimmeringView = defaultShimmeringView() + + // MARK: - Private Variables + + fileprivate lazy var headerView: UIView = { + let view = UIView() + view.backgroundColor = Color.navigationBarColor + view.tintColor = Color.blue + + let spacing = 10 + + view.addSubview(segmentedControl) + segmentedControl.snp.makeConstraints { + $0.top.equalToSuperview().offset(spacing) + $0.leading.equalToSuperview().offset(spacing*5) + $0.trailing.equalToSuperview().offset(-spacing*5) + $0.centerX.equalToSuperview() + } + + let separatorLine = UIView() + separatorLine.backgroundColor = Color.gray100 + view.addSubview(separatorLine) + separatorLine.snp.makeConstraints { + $0.height.equalTo(0.5) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(view.snp.bottom) + } + return view + }() + + fileprivate lazy var segmentedControl: UISegmentedControl = { + let items = ["My Series", "Popular", "All Series"] + let control = UISegmentedControl(items: items) + control.selectedSegmentIndex = 0 + control.addTarget(self, action: #selector(didChangeSegment), for: .valueChanged) + return control + }() + + fileprivate lazy var refreshControl: UIRefreshControl = { + let refreshControl = UIRefreshControl() + refreshControl.backgroundColor = Color.gray50 + refreshControl.tintColor = Color.blue + refreshControl.addTarget(self, action: #selector(didPullRefreshControl), for: .valueChanged) + return refreshControl + }() + + fileprivate lazy var sliderHeaderView: SliderTableViewHeaderView = { + let view = SliderTableViewHeaderView() + view.autoresizingMask = [.flexibleHeight, .flexibleWidth] + view.delegate = self + return view + }() + + fileprivate var isLoading: Bool { + shimmeringView.isShimmering + } + + fileprivate let seriesApi = SeriesApi() + fileprivate var seriesViewModels = [SeriesViewModel]() + fileprivate enum Constants { static let padding: CGFloat = UniversalConstants.padding - static let cellHeight: CGFloat = UniversalConstants.cellHeight + static let cellHeight: CGFloat = 100 + static let headerViewHeight: CGFloat = 51 } // MARK: - Lifecycle Methods @@ -35,22 +108,124 @@ class SeriesViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + + if seriesViewModels.count == 0 { + isLoadingList(true) + } else { + tableView.reloadData() + } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + + if seriesViewModels.count == 0 { + loadContent() + } } // MARK: - Layout fileprivate func setupLayout() { + configureNavigationItems() + + view.addSubview(headerView) + headerView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) + $0.height.equalTo(Constants.headerViewHeight) + $0.leading.trailing.equalToSuperview() + } + + view.addSubview(tableView) + tableView.snp.makeConstraints { + $0.top.equalTo(headerView.snp.bottom) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(view.snp.bottom) + } + + view.addSubview(shimmeringView) + shimmeringView.snp.makeConstraints { + $0.top.equalTo(tableView.snp.top) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(tableView.snp.bottom) + } } fileprivate func configureNavigationItems() { title = "Series" tabBarItem = UITabBarItem(title: title, image: SystemImg.stack, selectedImage: SystemImg.stackFill) - tabBarItem.isEnabled = false + tabBarItem.isEnabled = APIServices.shared.settings.isDev + } + + // MARK: - Data Update + + fileprivate func loadContent() { + + if !refreshControl.isRefreshing { + isLoadingList(true) + } + + seriesApi.getSeries { objects, error in + if let objects = objects { + self.seriesViewModels = SeriesViewModel.viewModels(with: objects) + } else if error != nil { + // self.emptyStateError = EmptyStateViewModel(.errorStandings) + } + + if self.refreshControl.isRefreshing { + self.refreshControl.endRefreshing() + } else { + self.isLoadingList(false) + } + + self.tableView.reloadData() + + self.tableView.tableHeaderView = self.sliderHeaderView + self.sliderHeaderView.reloadData() + } + } + + fileprivate func seriesViewModel(at index: Int) -> SeriesViewModel? { + return seriesViewModels[index] + } + + // MARK: - Actions + + fileprivate func showSeries(for index: Int) { + guard let viewModel = seriesViewModel(at: index) else { return } + + let vc = SeriesTabBarController(with: viewModel.series.id) + vc.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(vc, animated: true) + } + + @objc fileprivate func didChangeSegment() { +// seriesApi.cancelAll() + } + + @objc fileprivate func didPullRefreshControl() { + loadContent() + } + + // MARK: - Cell Configuration + + func configure(_ cell: T, forRowAt indexPath: IndexPath) where T : SimpleTableViewCell { + guard let viewModel = seriesViewModel(at: indexPath.row) else { return } + + cell.titleLabel.text = viewModel.titleLabel + cell.titleLabel.numberOfLines = 2 + cell.subtitleLabel.text = viewModel.typeLabel + cell.accessoryType = .disclosureIndicator + + let ratio = CGFloat(16.0/9.0) + let height = Constants.cellHeight - Constants.padding*2 + let size = CGSize(width: height * ratio, height: height) + cell.imageRatio = ratio + cell.iconImageView.setImage(with: viewModel.imageUrl, placeholderImage: PlaceholderImg.small, size: size) + cell.iconImageView.contentMode = .scaleAspectFill + cell.iconImageView.layer.cornerRadius = 6 + cell.iconImageView.layer.masksToBounds = true } } @@ -58,20 +233,39 @@ extension SeriesViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) + showSeries(for: indexPath.row) } } extension SeriesViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return 0 + return seriesViewModels.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - return UITableViewCell() + let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as SimpleTableViewCell + configure(cell, forRowAt: indexPath) + return cell } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return Constants.cellHeight } } + +extension SeriesViewController: SliderTableViewHeaderViewDelegate { + + func sliderNumberOfItems(_ slider: SliderTableViewHeaderView) -> Int { + return isLoading ? 0 : seriesViewModels.count + } + + func slider(_ slider: SliderTableViewHeaderView, imageFor view: UIImageView, at index: Int) { + guard let viewModel = seriesViewModel(at: index) else { return } + view.setImage(with: viewModel.imageUrl, placeholderImage: PlaceholderImg.medium) + } + + func slider(_ slider: SliderTableViewHeaderView, didSelectImageAt index: Int) { + showSeries(for: index) + } +} diff --git a/RaceSync/View Controllers/Settings/App Icon/AppIcon.swift b/RaceSync/View Controllers/Settings/App Icon/AppIcon.swift index 9d535249..954a956d 100644 --- a/RaceSync/View Controllers/Settings/App Icon/AppIcon.swift +++ b/RaceSync/View Controllers/Settings/App Icon/AppIcon.swift @@ -31,7 +31,7 @@ class AppIcon: ImmutableMappable, Descriptable { preview = UIImage(named: filename!) } else { filename = nil - preview = UIImage(named: "AppIcon60x60") + preview = LogoImg.app_icon } } @@ -40,6 +40,6 @@ class AppIcon: ImmutableMappable, Descriptable { type = 1 name = "" filename = nil - preview = UIImage(named: "AppIcon60x60") + preview = LogoImg.app_icon } } diff --git a/RaceSync/View Controllers/Settings/App Icon/AppIconViewController.swift b/RaceSync/View Controllers/Settings/App Icon/AppIconViewController.swift index 42d4d2fc..35e04c40 100644 --- a/RaceSync/View Controllers/Settings/App Icon/AppIconViewController.swift +++ b/RaceSync/View Controllers/Settings/App Icon/AppIconViewController.swift @@ -117,7 +117,7 @@ extension AppIconViewController: UITableViewDataSource { cell.accessoryType = .none if icon.isSelected() { - let imageView = UIImageView(image: UIImage(named: "icn_cell_checkmark")) + let imageView = UIImageView(image: ButtonImg.checkmark) imageView.tintColor = Color.blue cell.accessoryView = imageView } else { diff --git a/RaceSync/View Controllers/Settings/SettingsViewController.swift b/RaceSync/View Controllers/Settings/SettingsViewController.swift index 9941679a..acf99104 100644 --- a/RaceSync/View Controllers/Settings/SettingsViewController.swift +++ b/RaceSync/View Controllers/Settings/SettingsViewController.swift @@ -99,12 +99,11 @@ class SettingsViewController: UIViewController { sections = { let resources: [Row] = [.tracksGuide, .buildGuide, .seasonRules, .visitSite] var auth: [Row] = [.logout] - var about: [Row] = [.joinBeta] - if let user = APIServices.shared.myUser, user.isDevTeam, isDevModeEnabled { auth += [.switchEnv] } + var about: [Row] = [.feedback, .joinBeta] if UIApplication.shared.supportsAlternateIcons { about += [.appicon] } return [.notifications: [Row.notifications], .resources: resources, .about: about, .auth: auth] @@ -171,8 +170,27 @@ class SettingsViewController: UIViewController { }, cancel: nil) } - fileprivate func showFeatureFlags() { - Clog.log("showFeatureFlags") + fileprivate func sendFeedback() { + let subject = "RaceSync iOS Feedback" + let email = StringConstants.supportEmail + let device = UIDevice.current + + let diagnostics = """ + Device: \(device.model ) + OS: \(device.systemName ) \(device.systemVersion) + App Version: \(Bundle.main.releaseDescriptionPretty) + *************************** + \n + \n + """ + + guard let encodedSubject = subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return } + guard let encodedBody = diagnostics.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return } + guard let url = URL(string: "mailto:\(email)?subject=\(encodedSubject)&body=\(encodedBody)") else { return } + + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } } } @@ -188,25 +206,25 @@ extension SettingsViewController: UITableViewDelegate { togglePushNotifications() cell.isLoading = isTogglingPush case .tracksGuide: - WebViewController.openUrl(AppWebConstants.tracks) + WebViewController.open(AppWebConstants.tracks) case .buildGuide: - WebViewController.openUrl(AppWebConstants.obstaclesDoc) + WebViewController.open(AppWebConstants.obstaclesDoc) case .seasonRules: - WebViewController.openUrl(AppWebConstants.seasonRulesDoc) + WebViewController.open(AppWebConstants.seasonRulesDoc) case .appicon: let vc = AppIconViewController() vc.title = row.title navigationController?.pushViewController(vc, animated: true) case .joinBeta: - WebViewController.openUrl(AppWebConstants.betaSignup) + WebViewController.open(AppWebConstants.betaSignup) case .visitSite: - WebViewController.openUrl(AppWebConstants.homepage) + WebViewController.open(AppWebConstants.homepage) case .logout: logout() case .switchEnv: switchEnvironment() - case .featureFlags: - showFeatureFlags() + case .feedback: + sendFeedback() } } @@ -218,6 +236,19 @@ extension SettingsViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { return 30 } + + func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + guard let section = Section(rawValue: section) else { return nil } + + switch section { + case .auth: return "\(StringConstants.copyright)\n\(StringConstants.developedBy)" + default: return "" + } + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return Constants.cellHeight + } } extension SettingsViewController: UITableViewDataSource { @@ -257,19 +288,6 @@ extension SettingsViewController: UITableViewDataSource { } return cell } - - func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - guard let section = Section(rawValue: section) else { return nil } - - switch section { - case .auth: return "\(StringConstants.copyright)\n\(StringConstants.developedBy)" - default: return "" - } - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return Constants.cellHeight - } } fileprivate enum Section: Int, EnumTitle { @@ -290,24 +308,24 @@ fileprivate enum Row: Int, EnumTitle { case tracksGuide case buildGuide case seasonRules - case appicon - case joinBeta case visitSite + case feedback + case joinBeta + case appicon case logout - case featureFlags case switchEnv var title: String { switch self { case .notifications: return "Push Notifications" case .tracksGuide: return "MultiGP Tracks" - case .seasonRules: return "Season Rule Books" case .buildGuide: return "Obstacles Build Guide" + case .seasonRules: return "Season Rule Books" case .visitSite: return "Visit MultiGP.com" - case .appicon: return "Change App Icon" + case .feedback: return "Share Feedback" case .joinBeta: return "Join the Beta" + case .appicon: return "Change App Icon" case .logout: return "Logout" - case .featureFlags: return "Feature Flags" case .switchEnv: return "Switch to" } } @@ -320,10 +338,10 @@ fileprivate enum Row: Int, EnumTitle { case .buildGuide: return "icn_settings_buildguide" case .seasonRules: return "icn_settings_handbook" case .visitSite: return "icn_settings_mgp" - case .appicon: return "icn_settings_appicn" + case .feedback: return "icn_settings_feedback" case .joinBeta: return "icn_settings_beta" + case .appicon: return "icn_settings_appicn" case .logout: return "icn_settings_logout" - case .featureFlags: return "icn_settings_logout" case .switchEnv: return "icn_settings_logout" } } diff --git a/RaceSync/View Controllers/Standings/Pinnable.swift b/RaceSync/View Controllers/Standings/Pinnable.swift index 98d5429b..1104063c 100644 --- a/RaceSync/View Controllers/Standings/Pinnable.swift +++ b/RaceSync/View Controllers/Standings/Pinnable.swift @@ -142,6 +142,11 @@ extension Pinnable { } fileprivate func pinnedCellForRow(at indexPath: IndexPath) -> UITableViewCell { + guard tableView.window != nil else { + // Return a dummy cell when the tableview isn't visible on the foreground + return tableView.dequeueReusableCell(withIdentifier: pinnedCellIdentifier())! + } + let cell = tableView.dequeueReusableCell(forIndexPath: indexPath, identifier: pinnedCellIdentifier()) configure(cell, forRowAt: indexPath) return cell diff --git a/RaceSync/View Controllers/Standings/StandingViewModel.swift b/RaceSync/View Controllers/Standings/StandingViewModel.swift index 936921e7..1fcad944 100644 --- a/RaceSync/View Controllers/Standings/StandingViewModel.swift +++ b/RaceSync/View Controllers/Standings/StandingViewModel.swift @@ -33,10 +33,16 @@ class StandingViewModel: Descriptable { return (seasonKey == standing.season1) ? standing.season1Score : standing.season2Score } - self.score1Label = "Spring: \(Self.timeLabel(for: score(for: "\(standing.season.rawValue)Spring")))" - self.score2Label = "Summer: \(Self.timeLabel(for: score(for: "\(standing.season.rawValue)Summer")))" + if standing.season1 == "2023" { + self.score1Label = Self.timeLabel(for: standing.season1Score) + self.score2Label = "" + self.subtitleLabel = score1Label + } else { + self.score1Label = "Spring: \(Self.timeLabel(for: standing.season1Score))" + self.score2Label = "Summer: \(Self.timeLabel(for: standing.season2Score))" + self.subtitleLabel = [score1Label, score2Label].joined(separator: " | ") + } - self.subtitleLabel = [score1Label, score2Label].joined(separator: " | ") self.rank = Int32(standing.position) ?? 0 } @@ -53,7 +59,7 @@ class StandingViewModel: Descriptable { if value < 60 { let truncated = floor(value * 1_000) / 1_000 - return String(format: "%.3f", truncated) + return String(format: "%.3fs", truncated) } else { let minutes = Int(value) / 60 let seconds = value.truncatingRemainder(dividingBy: 60) diff --git a/RaceSync/View Controllers/Standings/StandingsController.swift b/RaceSync/View Controllers/Standings/StandingsController.swift new file mode 100644 index 00000000..777fdf7d --- /dev/null +++ b/RaceSync/View Controllers/Standings/StandingsController.swift @@ -0,0 +1,73 @@ +// +// StandingsController.swift +// RaceSync +// +// Created by Ignacio Romero Zurbuchen on 2025-12-23. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import Foundation +import RaceSyncAPI + +class StandingsController { + + // MARK: - Private Variables + + fileprivate let standingApi = StandingApi() + fileprivate var standingCollection = [StandingSeason: [StandingViewModel]]() + + // MARK: - Public Functions + + public func isEmpty(for season: StandingSeason) -> Bool { + return (viewModels(for: season)).count == 0 + } + + public func viewModel(at index: Int, for season: StandingSeason) -> StandingViewModel? { + let viewModels = viewModels(for: season) + return viewModels[safe: index] + } + + public func viewModels(for season: StandingSeason) -> [StandingViewModel] { + if let viewModels = standingCollection[season] { + return viewModels + } else { + return [StandingViewModel]() + } + } + + public func fetchStandings(for season: StandingSeason, _ completion: @escaping ObjectCompletionBlock<[StandingViewModel]>) { + + standingApi.getStandings(for: season) { (objects, error) in + if let objects = objects { + let vms = StandingViewModel.viewModels(with: objects) + self.standingCollection[season] = vms + completion(vms, nil) + + } else if error != nil { + completion([], error) + } + } + } + + public func filter(with text: String, length: Int, for season: StandingSeason) -> [StandingViewModel] { + let query = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard query.count >= length || query.containsEmoji else { return [] } + + let normalizedQuery = query.lowercased().folding(options: .diacriticInsensitive, locale: .current) + let viewModels = viewModels(for: season) + + return viewModels.filter { viewModel in + let label = viewModel.titleLabel + + if query.containsEmoji { + return label.contains(query) + } else { + let words = label.lowercased() + .folding(options: .diacriticInsensitive, locale: .current) + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + return words.contains { $0.hasPrefix(normalizedQuery) } + } + } + } +} diff --git a/RaceSync/View Controllers/Standings/StandingsListViewController.swift b/RaceSync/View Controllers/Standings/StandingsListViewController.swift new file mode 100644 index 00000000..7c9d246d --- /dev/null +++ b/RaceSync/View Controllers/Standings/StandingsListViewController.swift @@ -0,0 +1,130 @@ +// +// StandingsListViewController.swift +// RaceSync +// +// Created by Ignacio Romero Zurbuchen on 2025-12-29. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import UIKit +import SnapKit +import RaceSyncAPI + +class StandingsListViewController: UIViewController { + + // MARK: - Private Variables + + fileprivate lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .grouped) + tableView.register(cellType: UITableViewCell.self) + tableView.dataSource = self + tableView.delegate = self + tableView.tableFooterView = UIView() + + let backgroundView = UIView() + backgroundView.backgroundColor = Color.gray20 + tableView.backgroundView = backgroundView + + return tableView + }() + + fileprivate enum Constants { + static let padding: CGFloat = UniversalConstants.padding + static let cellHeight: CGFloat = UniversalConstants.cellHeight + } + + // MARK: - Lifecycle Methods + + override func viewDidLoad() { + super.viewDidLoad() + + setupLayout() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + } + + // MARK: - Layout + + fileprivate func setupLayout() { + + configureNavigationItems() + + view.addSubview(tableView) + tableView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom) + } + } + + fileprivate func configureNavigationItems() { + title = "Standings" + tabBarItem = UITabBarItem(title: title, image: SystemImg.trophy, selectedImage: SystemImg.trophyFill) + } +} + +extension StandingsListViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + let section = Section.allCases[indexPath.row] + let vc = StandingsViewController(with: section.season) + vc.title = section.shortTitle + + navigationController?.pushViewController(vc, animated: true) + } +} + +extension StandingsListViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return Section.allCases.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as UITableViewCell + + let section = Section.allCases[indexPath.row] + cell.textLabel?.text = section.title + cell.accessoryType = .disclosureIndicator + + return cell + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return Constants.cellHeight + } +} + +fileprivate enum Section: EnumTitle { + case gq2025 + case gq2024 + case gq2023 + + var title: String { + return "\(year) MultiGP Global Qualifier" + } + + var shortTitle: String { + return "MultiGP GQ \(year)" + } + + var season: StandingSeason { + switch self { + case .gq2025: return .y2025 + case .gq2024: return .y2024 + case .gq2023: return .y2023 + } + } + + var year: String { + season.rawValue + } +} diff --git a/RaceSync/View Controllers/Standings/StandingsViewController.swift b/RaceSync/View Controllers/Standings/StandingsViewController.swift index baf939c2..b36e9061 100644 --- a/RaceSync/View Controllers/Standings/StandingsViewController.swift +++ b/RaceSync/View Controllers/Standings/StandingsViewController.swift @@ -22,11 +22,11 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { tableView.dataSource = self tableView.delegate = self tableView.emptyDataSetSource = self - tableView.tableFooterView = UIView() tableView.register(cellType: AvatarTableViewCell.self) tableView.keyboardDismissMode = .onDrag tableView.verticalScrollIndicatorInsets = UIEdgeInsets(top: -1, left: 0, bottom: 0, right: 0) tableView.refreshControl = self.refreshControl + tableView.tableFooterView = UIView() let backgroundView = UIView() backgroundView.backgroundColor = Color.gray20 @@ -34,6 +34,10 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { return tableView }() + var shimmeringView: ShimmeringView = defaultShimmeringView() + + // MARK: - Private Variables + fileprivate lazy var searchBar: UISearchBar = { let searchBar = UISearchBar() searchBar.delegate = self @@ -84,17 +88,11 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { return view }() - var shimmeringView: ShimmeringView = defaultShimmeringView() - - // MARK: - Private Variables - - fileprivate let standingApi = StandingApi() + fileprivate let standingsController = StandingsController() + fileprivate var filteredViewModels = [StandingViewModel]() + fileprivate let season: StandingSeason fileprivate let userApi = UserApi() - fileprivate let season: StandingSeason = .y2025 - fileprivate var standingViewModels = [StandingViewModel]() - fileprivate var searchResult = [StandingViewModel]() - // Pinnable variables var pinnedView: UIView? var cachedPinnedIndexPath: IndexPath? @@ -131,7 +129,7 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - if standingViewModels.count == 0 { + if standingsController.isEmpty(for: season) { isLoadingList(true) } else { tableView.reloadData() @@ -141,11 +139,22 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - if standingViewModels.count == 0 { + if standingsController.isEmpty(for: season) { loadContent() } } + // MARK: - Initialization + + init(with season: StandingSeason) { + self.season = season + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + deinit { NotificationCenter.default.removeObserver(self) } @@ -189,14 +198,12 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { // MARK: - Data Update fileprivate func loadContent() { - if !refreshControl.isRefreshing { isLoadingList(true) } - standingApi.getStandings(for: season) { (objects, error) in + standingsController.fetchStandings(for: season) { objects, error in if let objects = objects { - self.standingViewModels = StandingViewModel.viewModels(with: objects) self.enableSearchBar(objects.count > 0) } else if error != nil { self.emptyStateError = EmptyStateViewModel(.errorStandings) @@ -213,13 +220,14 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { } fileprivate func standingViewModel(at indexPath: IndexPath) -> StandingViewModel? { - let viewModels = isSearching ? searchResult : standingViewModels - return viewModels[indexPath.row] + if isSearching { + return filteredViewModels[indexPath.row] + } else { + return standingsController.viewModel(at: indexPath.row, for: season) + } } - @objc fileprivate func didPullRefreshControl() { - loadContent() - } + // MARK: - Search @@ -232,42 +240,22 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { } } - var isSearching: Bool { + fileprivate var isSearching: Bool { guard let text = searchBar.text else { return false } let query = text.trimmingCharacters(in: .whitespacesAndNewlines) return query.count >= minQuery || query.containsEmoji } - func filterResults(with text: String) -> [StandingViewModel] { - let query = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard query.count >= minQuery || query.containsEmoji else { return [] } - - let normalizedQuery = query.lowercased().folding(options: .diacriticInsensitive, locale: .current) - - return standingViewModels.filter { viewModel in - let label = viewModel.titleLabel - - if query.containsEmoji { - return label.contains(query) - } else { - let words = label.lowercased() - .folding(options: .diacriticInsensitive, locale: .current) - .components(separatedBy: CharacterSet.alphanumerics.inverted) - .filter { !$0.isEmpty } - return words.contains { $0.hasPrefix(normalizedQuery) } - } - } + fileprivate func currentDataSource() -> [StandingViewModel] { + return isSearching ? filteredViewModels : standingsController.viewModels(for: season) } // MARK: - Cell Pinning func canPinView() -> Bool { guard let userId = myUserId else { return false } - - let source = isSearching ? searchResult : standingViewModels - guard source.firstIndex(where: { $0.standing.userId == userId }) != nil else { - return false - } + guard currentDataSource().firstIndex(where: { $0.standing.userId == userId }) != nil + else { return false } return true } @@ -276,9 +264,7 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { if let cachedPinnedIndexPath { return cachedPinnedIndexPath } - let source = isSearching ? searchResult : standingViewModels - - guard let index = source.firstIndex(where: { $0.standing.userId == userId }) else { + guard let index = currentDataSource().firstIndex(where: { $0.standing.userId == userId }) else { return nil } @@ -287,36 +273,6 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { return indexPath } - func configure(_ view: T, forRowAt indexPath: IndexPath) where T : UITableViewCell { - guard let cell = view as? AvatarTableViewCell, - let viewModel = standingViewModel(at: indexPath) else { return } - - cell.rankView.rank = viewModel.rank - cell.titleLabel.text = viewModel.titleLabel - cell.subtitleLabel.text = viewModel.subtitleLabel - cell.avatarImageView.isHidden = true - cell.accessoryView = nil - - if let userId = myUserId, viewModel.standing.userId == userId { - cell.titleLabel.textColor = Color.white - cell.subtitleLabel.textColor = Color.gray20 - cell.rankView.titleLabel.textColor = Color.gray20 - cell.backgroundColor = Color.gray200 - cell.selectedBackgroundView?.backgroundColor = Color.gray300 - - let image = ButtonImg.share?.withTintColor(.white) - let imageView = UIImageView(image: image) - imageView.tintColor = .white - cell.accessoryView = imageView - } else { - cell.titleLabel.textColor = Color.black - cell.subtitleLabel.textColor = Color.gray300 - cell.rankView.titleLabel.textColor = Color.gray300 - cell.backgroundColor = (indexPath.row % 2 == 0) ? Color.white : Color.gray20 - cell.selectedBackgroundView?.backgroundColor = Color.gray50 - } - } - // MARK: - Personal Standing Badge fileprivate func shouldPresentMyStandingBadge(_ indexPath: IndexPath) { @@ -349,14 +305,14 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { self.presenter = presenter } - @objc func userDidTakeScreenshot() { + @objc fileprivate func userDidTakeScreenshot() { // only trigger when this view is visible guard let view = viewIfLoaded, view.window != nil else { return } guard let cachedIndexPath = cachedPinnedIndexPath else { return } shouldPresentMyStandingBadge(cachedIndexPath) } - func resetTableView() { + fileprivate func resetTableView() { tableView.refreshControl = isSearching ? nil : refreshControl tableView.setContentOffset(.zero, animated: false) tableView.reloadData() @@ -364,25 +320,15 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { // resets it each time, so it can be recalculated invalidatePinnedView() } -} -extension StandingsViewController: UITableViewDelegate { - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let cell = tableView.cellForRow(at: indexPath) as? AvatarTableViewCell else { return } - tableView.deselectRow(at: indexPath, animated: true) - - // Present the standing badge instead, if it's me - if let cachedIndexPath = cachedPinnedIndexPath, indexPath == cachedIndexPath { - shouldPresentMyStandingBadge(indexPath) - return - } - - cell.isLoading = true + // MARK: - Actions + fileprivate func showUserProfile(forUserAt indexPath: IndexPath, from cell: AvatarTableViewCell) { guard let viewModel = standingViewModel(at: indexPath) else { return } guard !viewModel.standing.userId.isEmpty else { return } + cell.isLoading = true + userApi.getUser(with: viewModel.standing.userId) { [weak self] (user, error) in if let user = user { let vc = UserViewController(with: user) @@ -394,15 +340,35 @@ extension StandingsViewController: UITableViewDelegate { } } + @objc fileprivate func didPullRefreshControl() { + loadContent() + } +} + +extension StandingsViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let cell = tableView.cellForRow(at: indexPath) as? AvatarTableViewCell else { return } + tableView.deselectRow(at: indexPath, animated: true) + + // Present the standing badge instead, if it's me + if let cachedIndexPath = cachedPinnedIndexPath, indexPath == cachedIndexPath { + shouldPresentMyStandingBadge(indexPath) + return + } + + showUserProfile(forUserAt: indexPath, from: cell) + } + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - guard !standingViewModels.isEmpty else { + guard !standingsController.isEmpty(for: season) else { return nil } guard !isSearching else { - return searchResult.isEmpty ? nil : "Showing \(searchResult.count) Pilots" + return filteredViewModels.isEmpty ? nil : "Found \(filteredViewModels.count) Pilots" } - return season.sectionTitle + return "\(season.rawValue) MultiGP Global Qualifier\nFastest 3 Consecutive Laps" } func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { @@ -413,8 +379,7 @@ extension StandingsViewController: UITableViewDelegate { extension StandingsViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - guard !isSearching else { return searchResult.count } - return standingViewModels.count + return currentDataSource().count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { @@ -426,6 +391,36 @@ extension StandingsViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return Constants.cellHeight } + + func configure(_ view: T, forRowAt indexPath: IndexPath) where T : UITableViewCell { + guard let cell = view as? AvatarTableViewCell, + let viewModel = standingViewModel(at: indexPath) else { return } + + cell.rankView.rank = viewModel.rank + cell.titleLabel.text = viewModel.titleLabel + cell.subtitleLabel.text = viewModel.subtitleLabel + cell.avatarImageView.isHidden = true + cell.accessoryView = nil + + if let userId = myUserId, viewModel.standing.userId == userId { + cell.titleLabel.textColor = Color.white + cell.subtitleLabel.textColor = Color.gray20 + cell.rankView.titleLabel.textColor = Color.gray20 + cell.backgroundColor = Color.gray200 + cell.selectedBackgroundView?.backgroundColor = Color.gray300 + + let image = ButtonImg.share?.withTintColor(.white) + let imageView = UIImageView(image: image) + imageView.tintColor = .white + cell.accessoryView = imageView + } else { + cell.titleLabel.textColor = Color.black + cell.subtitleLabel.textColor = Color.gray300 + cell.rankView.titleLabel.textColor = Color.gray300 + cell.backgroundColor = (indexPath.row % 2 == 0) ? Color.white : Color.gray20 + cell.selectedBackgroundView?.backgroundColor = Color.gray50 + } + } } extension StandingsViewController: UIScrollViewDelegate { @@ -461,10 +456,10 @@ extension StandingsViewController: UISearchBarDelegate { searchBar.setShowsCancelButton(false, animated: true) } - func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + func searchBar(_ searchBar: UISearchBar, textDidChange text: String) { // Matches leading parts of any word, with robust tokenization and several insensitive cases - searchResult = filterResults(with: searchText) + filteredViewModels = standingsController.filter(with: text, length: minQuery, for: season) resetTableView() } diff --git a/RaceSync/View Models/EmptyStateViewModel.swift b/RaceSync/View Models/EmptyStateViewModel.swift index 446f0b92..bf355abf 100644 --- a/RaceSync/View Models/EmptyStateViewModel.swift +++ b/RaceSync/View Models/EmptyStateViewModel.swift @@ -123,7 +123,7 @@ struct EmptyStateViewModel: EmptyStateViewModelInterface { case .noRaceResults: text = "There are no race results available just yet." case .noRacePayments: - text = "There are no race payments yet." + text = "No payments found yet, or a network error occurred." case .noChapterMembers: text = "There are no registered members yet." case .noProfileRaces: diff --git a/RaceSync/View Models/ProfileViewModel.swift b/RaceSync/View Models/ProfileViewModel.swift index da52bfd4..bba94c48 100644 --- a/RaceSync/View Models/ProfileViewModel.swift +++ b/RaceSync/View Models/ProfileViewModel.swift @@ -44,7 +44,7 @@ class ProfileViewModel: Descriptable { self.topBadgeLabel = nil self.topBadgeImage = nil - self.leftBadgeImage = UIImage(named: "icn_race_small") + self.leftBadgeImage = ButtonImg.race_small self.leftSegmentLabel = "Races" if user.raceCount == 1 { self.leftBadgeLabel = "\(user.raceCount) Race" @@ -52,7 +52,7 @@ class ProfileViewModel: Descriptable { self.leftBadgeLabel = "\(user.raceCount) Races" } - self.rightBadgeImage = UIImage(named: "icn_chapter_small") + self.rightBadgeImage = ButtonImg.chapter_small self.rightSegmentLabel = "Chapters" if user.chapterCount == 1 { self.rightBadgeLabel = "\(user.chapterCount) Chapter" @@ -73,14 +73,21 @@ class ProfileViewModel: Descriptable { if let stringTier = chapter.tier, let tier = Int(stringTier) { let chapterTier = ChapterTier(rawValue: tier) - self.topBadgeLabel = chapterTier?.title - self.topBadgeImage = UIImage(named: "icn_badge") + + if chapter.isApproved { + self.topBadgeLabel = chapterTier?.title + self.topBadgeImage = ButtonImg.badge_small + } else { + self.topBadgeLabel = "Disabled" + self.topBadgeImage = SystemImg.badge_cross_small + } + } else { self.topBadgeLabel = nil self.topBadgeImage = nil } - self.leftBadgeImage = UIImage(named: "icn_race_small") + self.leftBadgeImage = ButtonImg.race_small self.leftSegmentLabel = "Races" if chapter.raceCount == 1 { self.leftBadgeLabel = "\(chapter.raceCount) Race" @@ -88,7 +95,7 @@ class ProfileViewModel: Descriptable { self.leftBadgeLabel = "\(chapter.raceCount) Races" } - self.rightBadgeImage = UIImage(named: "icn_member_small") + self.rightBadgeImage = ButtonImg.member_small self.rightSegmentLabel = "Members" if chapter.memberCount == 1 { self.rightBadgeLabel = "\(chapter.memberCount) Member" @@ -96,16 +103,50 @@ class ProfileViewModel: Descriptable { self.rightBadgeLabel = "\(chapter.memberCount) Members" } } + + init(with series: Series) { + self.type = .series + self.id = series.id + + self.title = series.name + self.pictureUrl = nil + self.backgroundUrl = series.mainImageUrl + + var description: String = series.typeString + if let date = series.startDate { + description += "\n" + description += "Started on: \(DateUtil.isoDateFormatter.string(from: date))" + } + if let date = series.endDate { + description += " to: \(DateUtil.isoDateFormatter.string(from: date))" + } + + self.displayName = description + + self.leftBadgeImage = ButtonImg.race_small + self.leftBadgeLabel = "\(series.raceApprovedCount) Race" + + self.rightBadgeImage = ButtonImg.member_small + self.rightBadgeLabel = "\(series.pilotCount) Pilots" + + self.locationName = "" + self.rightSegmentLabel = "" + self.leftSegmentLabel = "" + self.topBadgeLabel = nil + self.topBadgeImage = nil + } } public enum ProfileViewModelType: String { case user = "user" case chapter = "chapter" + case series = "series" var placeholder: UIImage? { switch self { - case .user: return PlaceholderImg.profileAvatar - case .chapter: return PlaceholderImg.profileAvatar + case .user: return PlaceholderImg.profileAvatar + case .chapter: return PlaceholderImg.profileAvatar + default: return nil } } } diff --git a/RaceSync/View Models/RaceViewModel.swift b/RaceSync/View Models/RaceViewModel.swift index b9e42c87..6735f04f 100644 --- a/RaceSync/View Models/RaceViewModel.swift +++ b/RaceSync/View Models/RaceViewModel.swift @@ -16,11 +16,13 @@ class RaceViewModel: Descriptable { let titleLabel: String let subtitleLabel: NSAttributedString + let dateLabel: String? + let timeLabel: String? let startDateLabel: String? - let startDateDesc: String? let endDateLabel: String? - let endDateDesc: String? + let sameDay: Bool + let locationLabel: String let fullLocationLabel: String let distanceLabel: String @@ -38,11 +40,13 @@ class RaceViewModel: Descriptable { self.race = race self.titleLabel = race.name self.subtitleLabel = Self.subtitleLabelAttributedString(for: race) - self.dateLabel = Self.combinedDateLabelString(for: race.startDate, and: race.endDate) // "Sat Sept 14 @ 9:00 AM" or "Sept 14 - Sept 16" - self.startDateLabel = Self.dateLabelString(for: race.startDate) // "Sat Sept 14 @ 9:00 AM" - self.startDateDesc = Self.fullDateLabelString(for: race.startDate) // "Saturday, September 14th @ 9:00 AM" - self.endDateLabel = Self.dateLabelString(for: race.endDate) // "Sat Sept 14 @ 5:00 PM" - self.endDateDesc = Self.fullDateLabelString(for: race.startDate, and: race.endDate) // "Saturday, September 14th @ 5:00 PM" or "@ 5:00 PM" + + self.dateLabel = Self.combinedDateLabelString(for: race.startDate, and: race.endDate) // "Sat, Sept 14 @ 9:00 AM" or "Sat, Sept 14 - Sun, Sept 15" + self.timeLabel = Self.combinedTimeLabelString(for: race.startDate, and: race.endDate) // "@ 9:00 AM" or "@ 9:00 AM - 4:00 PM" + self.startDateLabel = Self.dateLabelString(for: race.startDate) // "Sat, Sept 14 @ 9:00 AM" + self.endDateLabel = Self.dateLabelString(for: race.endDate) // "Sat, Sept 14 @ 5:00 PM" + self.sameDay = Self.datesAreSameDay(for: race.startDate, and: race.endDate) + self.locationLabel = Self.locationLabelString(for: race).stripHTML() self.fullLocationLabel = Self.fullLocationLabelString(for: race).stripHTML() self.distanceLabel = Self.distanceLabelString(for: race) // "309.4 mi" or "122 kms" @@ -155,6 +159,29 @@ extension RaceViewModel { return "\(startLabel) - \(endLabel)" } + static func combinedTimeLabelString(for startDate: Date?, and endDate: Date?) -> String? { + guard let startDate = startDate else { return nil } + + let startLabel = DateUtil.displayTimeFormatter2.string(from: startDate) + + guard let endDate, endDate.isInSameDay(date: startDate) else { + return startLabel + } + + let endLabel = DateUtil.displayTimeFormatter2.string(from: endDate) + + return "\(startLabel) - \(endLabel)" + } + + static func datesAreSameDay(for startDate: Date?, and endDate: Date?) -> Bool { + guard let startDate = startDate else { return false } + + guard let endDate, endDate.isInSameDay(date: startDate) else { + return false + } + return true + } + static func locationLabelString(for race: Race) -> String { return ViewModelHelper.locationLabel(for: race.city, state: race.state, country: race.country) } diff --git a/RaceSync/View Models/ResultEntryViewModel.swift b/RaceSync/View Models/ResultEntryViewModel.swift index e7e8523c..957bfa61 100644 --- a/RaceSync/View Models/ResultEntryViewModel.swift +++ b/RaceSync/View Models/ResultEntryViewModel.swift @@ -25,6 +25,7 @@ class ResultEntryViewModel: Descriptable { extension ResultEntryViewModel { static let noResultPlaceholder: String = "Did not complete laps" + static let noTimesPlaceholder: String = "Race times unavailable" static func combinedResults(from entries: [ResultEntry]?, for scoringFormat: ScoringFormat) -> [ResultEntry]? { guard let entries = entries else { return nil } diff --git a/RaceSync/View Models/SeriesViewModel.swift b/RaceSync/View Models/SeriesViewModel.swift new file mode 100644 index 00000000..ab18f132 --- /dev/null +++ b/RaceSync/View Models/SeriesViewModel.swift @@ -0,0 +1,52 @@ +// +// SeriesViewModel.swift +// RaceSync +// +// Created by Ignacio Romero Zurbuchen on 2025-09-25. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import Foundation +import RaceSyncAPI + +class SeriesViewModel: Descriptable { + + let series: Series + + let titleLabel: String + let subtitleLabel: String + let dateLabel: String + let typeLabel: String + let raceCount: Int + let pilotCount: Int + let imageUrl: String? + + // MARK: - Initialization + + init(with series: Series) { + self.series = series + self.titleLabel = series.name + self.dateLabel = Self.dateLabelString(for: series.startDate) // 14/09/2024 + self.typeLabel = series.typeString + self.subtitleLabel = "\(series.typeString) | Started: \(self.dateLabel)" + self.raceCount = Int(series.raceApprovedCount) + self.pilotCount = Int(series.pilotCount) + self.imageUrl = series.mainImageUrl + } + + static func viewModels(with objects:[Series]) -> [SeriesViewModel] { + var viewModels = [SeriesViewModel]() + for object in objects { + viewModels.append(SeriesViewModel(with: object)) + } + return viewModels + } +} + +extension SeriesViewModel { + + static func dateLabelString(for date: Date?) -> String { + guard let date = date else { return "" } + return DateUtil.isoDateFormatter.string(from: date) + } +} diff --git a/RaceSync/View Models/UserViewModel.swift b/RaceSync/View Models/UserViewModel.swift index f08fda7a..3e4b5858 100644 --- a/RaceSync/View Models/UserViewModel.swift +++ b/RaceSync/View Models/UserViewModel.swift @@ -60,6 +60,7 @@ class UserViewModel: Descriptable { self.fullPilotName = Self.fullName(entry.firstName, userName: entry.userName, lastName: entry.lastName) self.pictureUrl = entry.profilePictureUrl self.isJoined = true + self.score = entry.score if let band = entry.band, let channel = entry.channel { channelLabel = "\(band)\(channel)" @@ -81,6 +82,10 @@ class UserViewModel: Descriptable { self.pictureUrl = entry.profilePictureUrl self.isJoined = true + if let score = entry.score { + self.score = Int32(score) + } + if let band = entry.band, let channel = entry.channel { channelLabel = "\(band)\(channel)" } else { diff --git a/RaceSyncAPI/APISettings.swift b/RaceSyncAPI/APISettings.swift index 91140b10..e8fac468 100644 --- a/RaceSyncAPI/APISettings.swift +++ b/RaceSyncAPI/APISettings.swift @@ -13,11 +13,10 @@ public protocol APISettingsDelegate { } public enum APISettingsType: Int, EnumTitle { - case showPastEvents, raceFeedFilters, searchRadius, measurement, environment + case raceFeedFilters, searchRadius, measurement, environment public var title: String { switch self { - case .showPastEvents: return "Include Past Events" case .raceFeedFilters: return "Filter Races By" case .searchRadius: return "Search Radius" case .measurement: return "Measurement System" @@ -27,7 +26,6 @@ public enum APISettingsType: Int, EnumTitle { var key: String { switch self { - case .showPastEvents: return "\(APISettingsDomain).show_past_events" case .raceFeedFilters: return "\(APISettingsDomain).race_feed_filters" case .searchRadius: return "\(APISettingsDomain).search_radius" case .measurement: return "\(APISettingsDomain).measurement_system" @@ -42,14 +40,6 @@ public class APISettings { // MARK: - Settings Setters / Getters - public var showPastEvents: Bool { - get { - return bool(for: .showPastEvents) ?? false - } set { - save(newValue, type: .showPastEvents) - } - } - public var raceFeedFilters: [RaceFilter] { get { if let filters = filters(for: .raceFeedFilters), filters.count > 0 { diff --git a/RaceSyncAPI/Constants/APIConstants.swift b/RaceSyncAPI/Constants/APIConstants.swift index 4913c279..59185364 100644 --- a/RaceSyncAPI/Constants/APIConstants.swift +++ b/RaceSyncAPI/Constants/APIConstants.swift @@ -23,8 +23,9 @@ enum EndPoint { static let userSearch = "user/search" static let userUpdateProfile = "user/updateProfile" static let userSetPushNotification = "user/setPushNotification" + static let userUploadProfileImage = "user/uploadProfileImage" + static let userUploadProfileBackground = "user/uploadProfileBackground" - static let race = "race/" static let raceList = "race/list" static let raceListForChapter = "race/listForChapter" static let raceFindLocal = "race/findLocal" @@ -41,6 +42,9 @@ enum EndPoint { static let raceFinalize = "race/finalize" static let racePayments = "race/getRacePayments" + static let seriesList = "series/list" + static let seriesView = "series/view" + static let chapterList = "chapter/list" static let chapterFindLocal = "chapter/findLocal" static let chapterUsers = "chapter/users" @@ -115,6 +119,7 @@ public enum ParamKey { // Model attributes static let joined = "joined" static let isJoined = "isJoined" + static let isApproved = "isApproved" static let upcoming = "upcoming" static let past = "past" static let status = "status" @@ -123,10 +128,14 @@ public enum ParamKey { static let isQualifier = "isQualifier" static let retired = "retired" static let type = "type" + static let typeString = "typeString" static let count = "count" static let size = "size" - static let managedChapters = "managedChapters" + static let race = "race" static let races = "races" + static let chapter = "chapter" + static let chapters = "chapters" + static let managedChapters = "managedChapters" static let entries = "entries" static let schedule = "schedule" static let raceType = "raceType" @@ -141,6 +150,7 @@ public enum ParamKey { static let scoringDisabled = "scoringDisabled" static let scoringFormat = "scoringFormat" static let score = "score" + static let eloScore = "eloScore" static let totalLaps = "totalLaps" static let totalTime = "totalTime" static let fastest3Laps = "fastest3Laps" @@ -185,6 +195,10 @@ public enum ParamKey { static let amountDue = "amountdue" static let datePaid = "datepaid" static let paymentsEnabled = "paymentsEnabled" + static let approved = "approved" + static let pilotCount = "pilotCount" + static let chapterApprovedCount = "chapterApprovedCount" + static let raceApprovedCount = "raceApprovedCount" // Geo-location static let address = "address" diff --git a/RaceSyncAPI/Constants/MGPWebConstants.swift b/RaceSyncAPI/Constants/MGPWebConstants.swift index b6b3d20f..32b02b1a 100644 --- a/RaceSyncAPI/Constants/MGPWebConstants.swift +++ b/RaceSyncAPI/Constants/MGPWebConstants.swift @@ -1,5 +1,5 @@ // -// MGPWebConstant.swift +// MGPWebPath.swift // RaceSync // // Created by Ignacio Romero Zurbuchen on 2019-11-11. @@ -8,41 +8,55 @@ import Foundation -public enum MGPWebConstant: String { - case apiBase = "https://www.multigp.com/mgp/multigpwebservice/" - case s3Url = "https://multigp-storage-new.s3.us-east-2.amazonaws.com" +public enum MGPWebPath: String { + case apiBase = "/mgp/multigpwebservice/" + case raceView = "/races/view/?race" + case chapterView = "/chapters/view/?chapter" + case userView = "/pilots/view/?pilot" + case zippyqView = "/MultiGP/views/zippyq.php?raceId" + case chapterLeaderboard = "https://www.multigp.com/chapters/leaderboard/view/?chapter" - case raceView = "https://www.multigp.com/races/view/?race" - case chapterView = "https://www.multigp.com/chapters/view/?chapter" - case userView = "https://www.multigp.com/pilots/view/?pilot" - case zippyqView = "https://www.multigp.com/MultiGP/views/zippyq.php?raceId" - case viewZipperSeasonResults = "https://www.multigp.com/MultiGP/views/viewZipperSeasonResults.php" //?season1=2025Summer&season2=2025Spring&exportcsv=true - case processPayment = "https://www.multigp.com/MultiGP/views/processPayment.php" //?raceId=zzzzzz&pilotId=yyyy&user-agent=ios + case viewZipperSeasonResults = "/MultiGP/views/viewZipperSeasonResults.php" //?season1=2025Summer&season2=2025Spring&exportcsv=true + case processPayment = "/MultiGP/views/processPayment.php" //?raceId=zzzzzz&pilotId=yyyy&user-agent=ios } public class MGPWeb { - public static func getURL(for constant: MGPWebConstant) -> URL { - let url = getUrl(for: constant) - return URL(string: url)! + public static func baseURL() -> URL { + let host = APIServices.shared.settings.isDev ? "dev.multigp.com" : "www.multigp.com" + return URL(string: "https://\(host)/")! } - public static func getUrl(for constant: MGPWebConstant, value: String? = nil) -> String { + public static func getURL(for path: MGPWebPath? = nil, value: String? = nil) -> URL { + // Always recompute base URL dynamically + let base = baseURL() - var baseUrl = constant.rawValue - if APIServices.shared.settings.isDev { - baseUrl = constant.rawValue.replacingOccurrences(of: "www", with: "dev") + // If no path, return base directly + guard let path = path else { return base } + + // Remove only the first leading slash (not all slashes) + let raw = path.rawValue + let trimmedPath = raw.hasPrefix("/") ? String(raw.dropFirst()) : raw + + guard let url = URL(string: trimmedPath, relativeTo: base)?.absoluteURL else { + return base } - if let value = value { - return "\(baseUrl)=\(value.replacingOccurrences(of: " ", with: "-", options: .literal, range: nil))" - } else { - return baseUrl + if let value = value, !value.isEmpty { + let safeValue = value + .replacingOccurrences(of: " ", with: "-") + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value + + let combined = "\(url.absoluteString)=\(safeValue)" + return URL(string: combined) ?? url } + + return url } - public static func getURL(for constant: MGPWebConstant, value: String? = nil) -> URL? { - let url = Self.getUrl(for: constant, value: value) - return URL(string: url) + /// Convenience string version + public static func getUrl(for path: MGPWebPath, value: String? = nil) -> String { + return getURL(for: path, value: value).absoluteString } } + diff --git a/RaceSyncAPI/Enums/RaceEnums.swift b/RaceSyncAPI/Enums/RaceEnums.swift index e199cf55..4fbd00c5 100644 --- a/RaceSyncAPI/Enums/RaceEnums.swift +++ b/RaceSyncAPI/Enums/RaceEnums.swift @@ -121,3 +121,19 @@ public enum QualifyingType: String, EnumTitle { } } } + +public enum SeriesType: String, EnumTitle { + case overall = "0" + case collegiate = "1" + case prospec = "2" + case fastest3laps = "3" + + public var title: String { + switch self { + case .overall: return "Overall Points Scoring" + case .collegiate: return "Collegiate Scoring" + case .prospec: return "MultiGP ProSpec Scoring" + case .fastest3laps: return "Fastest 3 Consecutive laps" + } + } +} diff --git a/RaceSyncAPI/Extensions/Array+Extensions.swift b/RaceSyncAPI/Extensions/Array+Extensions.swift index 545f0c91..5ca26bf0 100644 --- a/RaceSyncAPI/Extensions/Array+Extensions.swift +++ b/RaceSyncAPI/Extensions/Array+Extensions.swift @@ -10,6 +10,10 @@ import Foundation public extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } + mutating func rearrange(from: Int, to: Int) { insert(remove(at: from), at: to) } diff --git a/RaceSyncAPI/Extensions/Date+Extensions.swift b/RaceSyncAPI/Extensions/Date+Extensions.swift index 7a666758..dfde6d2b 100644 --- a/RaceSyncAPI/Extensions/Date+Extensions.swift +++ b/RaceSyncAPI/Extensions/Date+Extensions.swift @@ -75,8 +75,14 @@ public extension Date { return self < Date() } - func isPassed(by days: Int) -> Bool { - guard let comparisonDate = Calendar.current.date(byAdding: .day, value: days, to: Date()) else { + func isPassed(days: Int? = nil, hours: Int? = nil) -> Bool { + // Pick the first non-nil argument (days has priority over hours) + guard let (component, value) = days.map({ (Calendar.Component.day, $0) }) ?? hours.map({ (Calendar.Component.hour, $0) }) + else { + return false + } + + guard let comparisonDate = Calendar.current.date(byAdding: component, value: value, to: Date()) else { return false } return self < comparisonDate diff --git a/RaceSyncAPI/Extensions/URL+Extensions.swift b/RaceSyncAPI/Extensions/URL+Extensions.swift index a6b53f7d..ff93e5b5 100644 --- a/RaceSyncAPI/Extensions/URL+Extensions.swift +++ b/RaceSyncAPI/Extensions/URL+Extensions.swift @@ -10,28 +10,6 @@ import Foundation public extension URL { - func appending(_ queryItem: String, value: String?) -> URL { - guard var urlComponents = URLComponents(string: absoluteString) else { return absoluteURL } - - // Create array of existing query items - var queryItems: [URLQueryItem] = urlComponents.queryItems ?? [] - - // Create query item - let queryItem = URLQueryItem(name: queryItem, value: value) - - // Append the new query item in the existing query items array - queryItems.append(queryItem) - - // Append updated query items array in the url component object - urlComponents.queryItems = queryItems - - // Returns the url from new url components - return urlComponents.url! - } - - /// second-level domain [SLD] - /// - /// i.e. `msk.ru, spb.ru` var SLD: String? { return host?.components(separatedBy: ".").suffix(2).joined(separator: ".") } diff --git a/RaceSyncAPI/Models/Chapter.swift b/RaceSyncAPI/Models/Chapter.swift index 0e960583..bd6820a5 100644 --- a/RaceSyncAPI/Models/Chapter.swift +++ b/RaceSyncAPI/Models/Chapter.swift @@ -20,6 +20,7 @@ public class Chapter: Mappable, Joinable, Descriptable { public var isJoined: Bool = false public var mainImageUrl: String? //mainImageFileName public var backgroundUrl: String? //backgroundFileName + public var isApproved: Bool = false public var phone: String = "" public var websiteUrl: String = "" @@ -66,6 +67,7 @@ public class Chapter: Mappable, Joinable, Descriptable { urlName <- map[ParamKey.urlName] description <- (map[ParamKey.description], MapperUtil.stringTransform) isJoined <- map[ParamKey.isJoined] + isApproved <- (map[ParamKey.isApproved], BooleanTransform()) // returns as String from API // special parsing due to API iconsistencies if let mainImageFileName = map.JSON[ParamKey.mainImageFileName] as? String, let backgroundFileName = map.JSON[ParamKey.backgroundFileName] as? String { diff --git a/RaceSyncAPI/Models/Extensions/Race+Extensions.swift b/RaceSyncAPI/Models/Extensions/Race+Extensions.swift index a767a363..5d6b1965 100644 --- a/RaceSyncAPI/Models/Extensions/Race+Extensions.swift +++ b/RaceSyncAPI/Models/Extensions/Race+Extensions.swift @@ -39,10 +39,15 @@ public extension Race { } var canBeFinalized: Bool { +#if DEBUG guard isMyChapter else { return false } guard ownerId == APIServices.shared.myUser?.id else { return false } guard let startDate = startDate, startDate.isPassed else { return false } return !isFinalized +#else + // The API finalize(id) still returns 500 error. Reported https://github.com/MultiGP/multigp-com/issues/93 + return false +#endif } var isGQ: Bool { @@ -81,17 +86,6 @@ public extension Race { func getMyPaymentUrl() -> URL? { guard let myUser = APIServices.shared.myUser else { return nil } - - let baseUrl = MGPWeb.getUrl(for: .processPayment) - let params: [(String, String)] = [ - ("raceId", "\(self.id)"), - ("pilotId", "\(myUser.id)"), - ("user-agent", "ios") - ] - - var components = URLComponents(string: baseUrl) - components?.queryItems = params.map { URLQueryItem(name: $0.0, value: $0.1) } - - return components?.url + return RaceApi.getPaymentUrl(for: self.id, user: myUser.id) } } diff --git a/RaceSyncAPI/Models/Race.swift b/RaceSyncAPI/Models/Race.swift index e3d4b041..8f19c112 100644 --- a/RaceSyncAPI/Models/Race.swift +++ b/RaceSyncAPI/Models/Race.swift @@ -131,9 +131,9 @@ public class Race: Mappable, Joinable, Descriptable { urlName <- map[ParamKey.urlName] liveTimeEventUrl <- map[ParamKey.liveTimeEventUrl] - description <- map[ParamKey.description] - content <- map[ParamKey.content] - itinerary <- map[ParamKey.itineraryContent] + description <- (map[ParamKey.description], HTMLLinkTransform(baseURL: MGPWeb.baseURL())) + content <- (map[ParamKey.content], HTMLLinkTransform(baseURL: MGPWeb.baseURL())) + itinerary <- (map[ParamKey.itineraryContent], HTMLLinkTransform(baseURL: MGPWeb.baseURL())) raceEntryCount <- map[ParamKey.raceEntryCount] participantCount <- map[ParamKey.participantCount] diff --git a/RaceSyncAPI/Models/Series.swift b/RaceSyncAPI/Models/Series.swift new file mode 100644 index 00000000..0079ed5b --- /dev/null +++ b/RaceSyncAPI/Models/Series.swift @@ -0,0 +1,73 @@ +// +// Series.swift +// RaceSyncAPI +// +// Created by Ignacio Romero Zurbuchen on 2025-09-21. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import Foundation +import ObjectMapper + +public class Series: Mappable, Descriptable { + + public var id: ObjectId = "" + public var name: String = "" + public var description: String = "" + public var startDate: Date? + public var endDate: Date? + public var type: SeriesType = .overall + public var typeString: String = "" + public var isApproved: Bool = false + public var ownerId: ObjectId = "" + public var mainImageUrl: String? + + public var pilotCount: Int32 = 0 + public var chapterCount: Int32 = 0 + public var chapterApprovedCount: Int32 = 0 + public var raceCount: Int32 = 0 + public var raceApprovedCount: Int32 = 0 + + public var races: [Race]? = nil + public var chapters: [Chapter]? = nil + public var pilotResults: [SeriesResult]? = nil + public var chapterResults: [SeriesResult]? = nil + + // MARK: - Initialization + + fileprivate static let requiredProperties = [ParamKey.id, ParamKey.name, ParamKey.ownerId] + + public required convenience init?(map: Map) { + for requiredProperty in Self.requiredProperties { + if map.JSON[requiredProperty] == nil { return nil } + } + + self.init() + self.mapping(map: map) + } + + public func mapping(map: Map) { + id <- map[ParamKey.id] + name <- (map[ParamKey.name], MapperUtil.stringTransform) + description <- map[ParamKey.description] + startDate <- (map[ParamKey.startDate], MapperUtil.dateTransform) + endDate <- (map[ParamKey.endDate], MapperUtil.dateTransform) + type <- (map[ParamKey.type], EnumTransform()) + typeString <- map[ParamKey.typeString] + isApproved <- map[ParamKey.approved] + ownerId <- map[ParamKey.ownerId] + mainImageUrl <- map[ParamKey.mainImageUrl] + + pilotCount <- map[ParamKey.pilotCount] + chapterCount <- map[ParamKey.chapterCount] + chapterApprovedCount <- map[ParamKey.chapterApprovedCount] + raceCount <- map[ParamKey.raceCount] + raceApprovedCount <- map[ParamKey.raceApprovedCount] + + races <- map[ParamKey.races] + chapters <- map[ParamKey.chapters] + + pilotResults <- map["pilot-results"] + chapterResults <- map["chapter-results"] + } +} diff --git a/RaceSyncAPI/Models/SeriesResult.swift b/RaceSyncAPI/Models/SeriesResult.swift new file mode 100644 index 00000000..50ecbcfb --- /dev/null +++ b/RaceSyncAPI/Models/SeriesResult.swift @@ -0,0 +1,87 @@ +// +// SeriesResult.swift +// RaceSyncAPI +// +// Created by Ignacio Romero Zurbuchen on 2025-10-02. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import Foundation +import ObjectMapper + +public enum SeriesResultType { + case pilot + case chapter +} + +public class SeriesResult: Mappable, Descriptable { + + public var type: SeriesResultType = .pilot + public var displayName: String = "" + public var country: String = "" + public var score: String = "" + public var eloScore: String = "" + public var imageUrl: String? + + public var pilotId: String? + public var chapterId: String? + + // MARK: - Init + public required init?(map: Map) { + // No hard "required properties", just return nil if totally empty + if map.JSON.isEmpty { return nil } + } + + public init() {} + + // MARK: - Mapping + public func mapping(map: Map) { + // Pilot or chapter id + pilotId <- map[ParamKey.pilotId] + chapterId <- map[ParamKey.chapterId] + + // Determine type from what exists + if pilotId != nil { type = .pilot } + else if chapterId != nil { type = .chapter } + + // Display name: may come under several keys + displayName <- (map[ParamKey.displayName], MapperUtil.stringTransform) + if displayName.isEmpty { + // fallback options + var firstName: String? + var lastName: String? + var userName: String? + + firstName <- (map[ParamKey.firstName], MapperUtil.stringTransform) + lastName <- (map[ParamKey.lastName], MapperUtil.stringTransform) + userName <- (map[ParamKey.userName], MapperUtil.stringTransform) + + if let fn = firstName, let ln = lastName, !fn.isEmpty || !ln.isEmpty { + displayName = [fn, ln].compactMap { $0 }.joined(separator: " ") + } else if let un = userName, !un.isEmpty { + displayName = un + } else { + // fallback for chapter + displayName <- (map[ParamKey.chapterName], MapperUtil.stringTransform) + } + } + + // Country (only present for pilots usually) + country <- (map[ParamKey.country], MapperUtil.stringTransform) + + // Score (numeric sometimes, string sometimes) + if let numericScore = map.JSON[ParamKey.score] { + score = String(describing: numericScore) + } else if let timingScore = map.JSON[ParamKey.fastest3Laps] { + score = String(describing: timingScore) + } + + eloScore <- (map[ParamKey.eloScore], MapperUtil.stringTransform) + + // Image / profile picture + imageUrl <- (map[ParamKey.profilePictureUrl], MapperUtil.stringTransform) + if imageUrl == nil { + imageUrl <- (map[ParamKey.mainImageFileName], MapperUtil.stringTransform) + } + } +} diff --git a/RaceSyncAPI/Models/Standing.swift b/RaceSyncAPI/Models/Standing.swift index 43e76b7c..1f55984b 100644 --- a/RaceSyncAPI/Models/Standing.swift +++ b/RaceSyncAPI/Models/Standing.swift @@ -11,8 +11,6 @@ import ObjectMapper public class Standing: Mappable, Descriptable { - public var season: StandingSeason = .y2025 - public var position: String = "" public var firstName: String = "" public var userName: String = "" diff --git a/RaceSyncAPI/Network/ChapterApi.swift b/RaceSyncAPI/Network/ChapterApi.swift index a5b47944..2926192e 100644 --- a/RaceSyncAPI/Network/ChapterApi.swift +++ b/RaceSyncAPI/Network/ChapterApi.swift @@ -42,6 +42,11 @@ public protocol ChapterApiInterface { /** */ func resign(chapter chapterId: ObjectId, completion: @escaping StatusCompletionBlock) + + /** + Cancels any active search API call + */ + func cancelSearchRequests() } public class ChapterApi: ChapterApiInterface { @@ -109,6 +114,10 @@ public class ChapterApi: ChapterApiInterface { repositoryAdapter.performAction(endpoint, completion: completion) } + + public func cancelSearchRequests() { + repositoryAdapter.networkAdapter.httpCancelRequests(with: EndPoint.chapterSearch) + } } fileprivate extension ChapterApi { diff --git a/RaceSyncAPI/Network/NetworkAdapter.swift b/RaceSyncAPI/Network/NetworkAdapter.swift index e7d7f01c..00975333 100644 --- a/RaceSyncAPI/Network/NetworkAdapter.swift +++ b/RaceSyncAPI/Network/NetworkAdapter.swift @@ -90,7 +90,10 @@ class NetworkAdapter { var httpHeaders: [String : String] = headers ?? [:] httpHeaders[ParamKey.apiKey] = APIServices.shared.credential.apiKey - httpHeaders[ParamKey.sessionId] = APISessionManager.getSessionId() + + if let sessionId = APISessionManager.getSessionId() { + httpHeaders[ParamKey.sessionId] = sessionId + } let fileName = "Image-\(UUID().uuidString).jpg" @@ -107,6 +110,7 @@ class NetworkAdapter { sessionDataTask.forEach { if let request = $0.currentRequest, let url = request.url { if url.absoluteString.contains(endpoint) { + $0.cancel() } } } diff --git a/RaceSyncAPI/Network/RaceApi.swift b/RaceSyncAPI/Network/RaceApi.swift index 540ced2a..64410841 100644 --- a/RaceSyncAPI/Network/RaceApi.swift +++ b/RaceSyncAPI/Network/RaceApi.swift @@ -31,6 +31,7 @@ public protocol RaceApiInterface { Gets a filtered set of races related to a specific User. - parameter filters: The list of compounding filters to compose the race query + - parameter raceId: The Race id (Optional) - parameter userId: The User id (Optional) - parameter name: The race name. (Optional) - parameter startDate: The race start date. This value can be a full date, or month, or year. (Optional) @@ -44,6 +45,7 @@ public protocol RaceApiInterface { - parameter completion: The closure to be called upon completion. Returns a transcient list of Race objects. */ func getRaces(with filters: [RaceListFilters], + raceId: ObjectId, userId: ObjectId, name: String?, startDate: String?, @@ -138,9 +140,9 @@ public protocol RaceApiInterface { completion: @escaping ObjectCompletionBlock<[RacePayment]>) /** - Cancels all the HTTP requests of race API endpoint + Cancels any active search API call */ - func cancelAll() + func cancelSearchRequests() } public class RaceApi: RaceApiInterface { @@ -162,6 +164,7 @@ public class RaceApi: RaceApiInterface { } public func getRaces(with filters: [RaceListFilters] = [RaceListFilters](), + raceId: ObjectId = "", userId: ObjectId = "", name: String? = nil, startDate: String? = nil, @@ -175,6 +178,10 @@ public class RaceApi: RaceApiInterface { let endpoint = EndPoint.raceList var params = parametersForRaces(with: filters, userId: userId, latitude: latitude, longitude: longitude, pageSize: pageSize) + if raceId.count > 0 { + params[ParamKey.id] = raceId + } + if let name = name, name.count > 0 { params[ParamKey.name] = name } @@ -300,14 +307,14 @@ public class RaceApi: RaceApiInterface { repositoryAdapter.getObjects(endpoint, type: RacePayment.self, keyPath: "data.paymentStatus", completion) } - public func cancelAll() { - repositoryAdapter.networkAdapter.httpCancelRequests(with: EndPoint.race) + public func cancelSearchRequests() { + repositoryAdapter.networkAdapter.httpCancelRequests(with: EndPoint.raceList) } } -fileprivate extension RaceApi { +extension RaceApi { - func parametersForRaces(with filters: [RaceListFilters], + fileprivate func parametersForRaces(with filters: [RaceListFilters], userId: ObjectId = "", latitude: String? = nil, longitude: String? = nil, pageSize: Int = StandardPageSize) -> Params { @@ -344,4 +351,19 @@ fileprivate extension RaceApi { return parameters } + + static func getPaymentUrl(for race: ObjectId, user: ObjectId) -> URL? { + let baseUrl = MGPWeb.getURL(for: .processPayment) + + let params: [(String, String)] = [ + ("raceId", "\(race)"), + ("pilotId", "\(user)"), + ("user-agent", "ios") + ] + + var components = URLComponents(string: baseUrl.absoluteString) + components?.queryItems = params.map { URLQueryItem(name: $0.0, value: $0.1) } + + return components?.url + } } diff --git a/RaceSyncAPI/Network/RepositoryAdapter.swift b/RaceSyncAPI/Network/RepositoryAdapter.swift index 945c057b..e67665f6 100644 --- a/RaceSyncAPI/Network/RepositoryAdapter.swift +++ b/RaceSyncAPI/Network/RepositoryAdapter.swift @@ -117,4 +117,43 @@ class RepositoryAdapter { } } } + + func uploadImage(_ data: Data, name: String, url: String, progressBlock: ProgressBlock?, _ completion: @escaping ObjectCompletionBlock) { + Clog.log("Starting request \(url)") + + // Multipart + networkAdapter.httpMultipartUpload(data, name: name, url: url) { (result) in + switch result { + case .success(let upload, _, _): + + upload.uploadProgress(closure: { (progress) in + print("Upload Progress: \(progress.fractionCompleted)") + }) + + upload.responseString { response in + var log: String = "+ Ended request with code \(String(describing: response.response?.statusCode)) " + + switch response.result { + case .success(let value): + let json = JSON.init(parseJSON: value) + if let errors = ErrorUtil.errors(fromJSONString: value) { + completion(nil, errors.first) + } else { + completion(json[ParamKey.url].rawValue as? String, nil) + } + case .failure: + let error = ErrorUtil.parseError(response) + log += " Network Error: \(error.debugDescription)" + completion(nil, error) + } + + Clog.log("\(log)") + } + + case .failure(let encodingError): + Clog.log("encodingError \(encodingError)") + print(encodingError) + } + } + } } diff --git a/RaceSyncAPI/Network/SeriesApi.swift b/RaceSyncAPI/Network/SeriesApi.swift new file mode 100644 index 00000000..15670e76 --- /dev/null +++ b/RaceSyncAPI/Network/SeriesApi.swift @@ -0,0 +1,42 @@ +// +// SeriesApi.swift +// RaceSyncAPI +// +// Created by Ignacio Romero Zurbuchen on 2025-09-25. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import Foundation +import Alamofire +import SwiftyJSON + +// MARK: - Interface + +public protocol SeriesApiInterface { + + func getSeries(_ currentPage: Int, pageSize: Int, _ completion: @escaping ObjectCompletionBlock<[Series]>) + + func view(series seriesId: ObjectId, + completion: @escaping ObjectCompletionBlock) +} + +public class SeriesApi: SeriesApiInterface { + + public init() {} + fileprivate let repositoryAdapter = RepositoryAdapter() + + public func getSeries(_ currentPage: Int = 0, pageSize: Int = StandardPageSize, _ completion: @escaping ObjectCompletionBlock<[Series]>) { + + let endpoint = EndPoint.seriesList + let parameters: Params = [:] + + repositoryAdapter.getObjects(endpoint, parameters: parameters, currentPage: currentPage, pageSize: pageSize, type: Series.self, completion) + } + + public func view(series seriesId: ObjectId, completion: @escaping ObjectCompletionBlock) { + + let endpoint = "\(EndPoint.seriesView)?\(ParamKey.id)=\(seriesId)" + + repositoryAdapter.getObject(endpoint, type: Series.self, completion) + } +} diff --git a/RaceSyncAPI/Network/StandingApi.swift b/RaceSyncAPI/Network/StandingApi.swift index 2497f91f..6e5d9fa9 100644 --- a/RaceSyncAPI/Network/StandingApi.swift +++ b/RaceSyncAPI/Network/StandingApi.swift @@ -33,11 +33,15 @@ public class StandingApi: StandingApiInterface { public func getStandings(for season: StandingSeason, _ completion: @escaping ObjectCompletionBlock<[Standing]>) { - guard let baseUrl = getStandingsUrl(for: season) else { return } + guard let baseUrl = Self.getStandingsUrl(for: season) else { return } // This is too fragile but no choice for now - let headers = ["position", "firstName", "userName", "lastName", "userId", "chapterName", - "email", "country", "season1", "season1Score", "season2", "season2Score"] + var headers = ["position", "firstName", "userName", "lastName", "userId", "chapterName", + "email", "country", "season1", "season1Score"] + + if season != .y2023 { + headers += ["season2", "season2Score"] + } fetchCSVAndConvertToJSON(from: baseUrl, knownHeaders: headers) { result in DispatchQueue.main.async { @@ -54,9 +58,9 @@ public class StandingApi: StandingApiInterface { } } -fileprivate extension StandingApi { +extension StandingApi { - func fetchCSVAndConvertToJSON(from url: URL, knownHeaders: [String]? = nil, completion: @escaping (Result<[[String: Any]], Error>) -> Void) { + fileprivate func fetchCSVAndConvertToJSON(from url: URL, knownHeaders: [String]? = nil, completion: @escaping (Result<[[String: Any]], Error>) -> Void) { Clog.log("Starting request \(String(describing: url))") URLSession.shared.dataTask(with: url) { data, _, error in if let error = error { @@ -75,7 +79,7 @@ fileprivate extension StandingApi { }.resume() } - func extractCleanCSV(from html: String, injectingHeaders headers: [String]? = nil) -> String { + fileprivate func extractCleanCSV(from html: String, injectingHeaders headers: [String]? = nil) -> String { var text = html.stripHTML(false) if let range = text.range(of: "\n1,") ?? text.range(of: "1,") { @@ -92,7 +96,7 @@ fileprivate extension StandingApi { return text } - private func parseCSV(_ csv: String) -> Result<[[String: Any]], Error> { + fileprivate func parseCSV(_ csv: String) -> Result<[[String: Any]], Error> { let lines = csv.components(separatedBy: .newlines).filter { !$0.isEmpty } guard let firstLine = lines.first else { @@ -113,16 +117,25 @@ fileprivate extension StandingApi { return .success(jsonArray) } - func getStandingsUrl(for season: StandingSeason) -> URL? { - let base = MGPWebConstant.viewZipperSeasonResults.rawValue + static func getStandingsUrl(for season: StandingSeason) -> URL? { + let baseUrl = MGPWeb.getURL(for: .viewZipperSeasonResults) + + var params = [(String, String)]() + + if season != .y2023 { + params += [ + ("season1", "\(season.rawValue)Summer"), + ("season2", "\(season.rawValue)Spring") + ] + } else { + params += [ + ("season1", "\(season.rawValue)") + ] + } - let params: [(String, String)] = [ - ("season1", "\(season.rawValue)Summer"), - ("season2", "\(season.rawValue)Spring"), - ("exportcsv", "true") - ] + params += [("exportcsv", "true")] - var components = URLComponents(string: base) + var components = URLComponents(string: baseUrl.absoluteString) components?.queryItems = params.map { URLQueryItem(name: $0.0, value: $0.1) } return components?.url diff --git a/RaceSyncAPI/Network/UserApi.swift b/RaceSyncAPI/Network/UserApi.swift index 07bade99..fb579c14 100644 --- a/RaceSyncAPI/Network/UserApi.swift +++ b/RaceSyncAPI/Network/UserApi.swift @@ -43,6 +43,15 @@ public protocol UserApiInterface { /** */ func registerPushNotification(forAction action: PushAction, deviceToken: String?, _ completion: @escaping StatusCompletionBlock) + + /** + */ + func uploadProfileImage(_ image: UIImage, imageType: ImageType, progressBlock: ProgressBlock?, _ completion: @escaping ObjectCompletionBlock) + + /** + Cancels any active search API call + */ + func cancelSearchRequests() } public class UserApi: UserApiInterface { @@ -100,4 +109,18 @@ public class UserApi: UserApiInterface { repositoryAdapter.performAction(endpoint, parameters: parameters, completion: completion) } + + public func uploadProfileImage(_ image: UIImage, imageType: ImageType, progressBlock: ProgressBlock? = nil, _ completion: @escaping ObjectCompletionBlock) { + guard let myUser = APIServices.shared.myUser else { return } + guard let data = image.jpegData(compressionQuality: 0.7) else { return } + + let endpoint = (imageType == .main) ? EndPoint.userUploadProfileImage : EndPoint.userUploadProfileBackground + let url = MGPWeb.getURL(for: .apiBase).absoluteString + "\(endpoint)?\(ParamKey.id)=\(myUser.id)" + + repositoryAdapter.uploadImage(data, name: imageType.key, url: url, progressBlock: progressBlock, completion) + } + + public func cancelSearchRequests() { + repositoryAdapter.networkAdapter.httpCancelRequests(with: EndPoint.userSearch) + } } diff --git a/RaceSyncAPI/Utils/CompletionBlock.swift b/RaceSyncAPI/Utils/CompletionBlock.swift index 334b9caa..d1c9c204 100644 --- a/RaceSyncAPI/Utils/CompletionBlock.swift +++ b/RaceSyncAPI/Utils/CompletionBlock.swift @@ -9,6 +9,7 @@ import Foundation public typealias VoidCompletionBlock = () -> Void +public typealias BoolCompletionBlock = (_ completed: Bool) -> Void public typealias CompletionBlock = (_ error: NSError?) -> Void public typealias StatusCompletionBlock = (_ status: Bool, _ error: NSError?) -> Void public typealias ObjectCompletionBlock = (_ object: T?, _ error: NSError?) -> Void diff --git a/RaceSyncAPI/Utils/DateUtil.swift b/RaceSyncAPI/Utils/DateUtil.swift index 9fcc80b2..8b868452 100644 --- a/RaceSyncAPI/Utils/DateUtil.swift +++ b/RaceSyncAPI/Utils/DateUtil.swift @@ -97,4 +97,16 @@ public extension DateUtil { formatter.dateFormat = "@ h:mm a" return formatter }() + + static let displayTimeFormatter2: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + return formatter + }() + + static let isoDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "dd/MM/yyyy" + return formatter + }() } diff --git a/RaceSyncAPI/Utils/DeepLink.swift b/RaceSyncAPI/Utils/DeepLink.swift new file mode 100644 index 00000000..49b1a1b8 --- /dev/null +++ b/RaceSyncAPI/Utils/DeepLink.swift @@ -0,0 +1,125 @@ +// +// DeepLink.swift +// RaceSync +// +// Created by Ignacio Romero Zurbuchen on 2025-08-31. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import Foundation + +public struct DeepLink { + public let domain: Domain + public let action: Action + public let parameters: [String: String] + + public enum Domain: String { + case race + case races + case pilto + case piltos + case chapters + } + + public enum Action: String { + case join + case view + } +} + +public extension DeepLink { + + static let scheme: String = "racesync" + + // there are 2 types of race domains, so a convience getter is needed + var isRace: Bool { + return [.race, .races].contains(domain) + } + + var absoluteString: String { + var urlString = "\(DeepLink.scheme)://\(domain.rawValue)/\(action.rawValue)" + + // Add query string if parameters exist + if !parameters.isEmpty { + // Map parameters to key=value pairs and join with & + let query = parameters.map { key, value -> String in + let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key + let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value + return "\(encodedKey)=\(encodedValue)" + }.joined(separator: "&") + + urlString.append("?\(query)") + } + return urlString + } + + static func create(from url: URL) -> DeepLink? { + + if url.scheme == DeepLink.scheme { + return link(fromAppUrl: url) + } else if let host = url.host, let mgpHost = MGPWeb.baseURL().host, host == mgpHost { + return link(fromWebUrl: url) + } else { + return nil + } + } + + fileprivate static func link(fromAppUrl url: URL) -> DeepLink? { + guard url.scheme == DeepLink.scheme else { return nil } + + guard let host = url.host, let domain = DeepLink.Domain(rawValue: host) else { + return nil + } + + guard + let component = url.pathComponents.dropFirst().first, + let action = DeepLink.Action(rawValue: component) + else { + return nil + } + + let params = queryParams(from: url) + + return DeepLink(domain: domain, action: action, parameters: params) + } + + fileprivate static func link(fromWebUrl url: URL) -> DeepLink? { + guard let baseHost = MGPWeb.baseURL().host else { return nil } + + // Only handle multigp.com domain (dev or prod) + guard let host = url.host, host == baseHost else { + return nil + } + + // Break down path components (drop leading slash) + let pathComponents = url.pathComponents.filter { $0 != "/" } + guard pathComponents.count >= 2 else { + return nil + } + + // Map first path component to DeepLink.Domain + let domainComponent = pathComponents[0].lowercased() + let actionComponent = pathComponents[1].lowercased() + guard let domain = DeepLink.Domain(rawValue: domainComponent) else { return nil } + guard let action = DeepLink.Action(rawValue: actionComponent) else { return nil } + + let params = queryParams(from: url) + + return DeepLink(domain: domain, action: action, parameters: params) + } + + fileprivate static func queryParams(from url: URL) -> [String: String] { + var params: [String: String] = [:] + if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems { + queryItems.forEach { item in + let value = item.value?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" + if item.name == ParamKey.race { + params[ParamKey.id] = value + } else { + params[item.name] = value + } + } + } + return params + } +} diff --git a/RaceSyncAPI/Utils/DeepLinkURLHandler.swift b/RaceSyncAPI/Utils/DeepLinkURLHandler.swift new file mode 100644 index 00000000..f5fbbe79 --- /dev/null +++ b/RaceSyncAPI/Utils/DeepLinkURLHandler.swift @@ -0,0 +1,64 @@ +// +// DeepLinkURLHandler.swift +// RaceSync +// +// Created by Ignacio Romero Zurbuchen on 2025-08-31. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import Foundation + +public extension Notification.Name { + static let joinedRaceViaDeeplink = Notification.Name("com.racecync.joinedRaceViaDeeplink") +} + +public class DeepLinkURLHandler: Descriptable { + + // MARK: - Public + + public static let shared = DeepLinkURLHandler() + + public func handle(url: URL) -> Bool { + guard let link = DeepLink.create(from: url) else { return false } + return handleDeepLink(link) + } + + // MARK: - Private + + fileprivate let raceApi = RaceApi() + + fileprivate init() {} +} + +extension DeepLinkURLHandler { + + // racesync://race/join?id=29941&pilotId=20676 + + func handleDeepLink(_ deepLink: DeepLink) -> Bool { + if (deepLink.domain == .race || deepLink.domain == .races) { + if deepLink.action == .join { + return handleJoiningRace(with: deepLink) + } else { + return false + } + } + return false + } + + func handleJoiningRace(with deepLink: DeepLink) -> Bool { + guard let myUser = APIServices.shared.myUser else { return false } + guard let raceId = deepLink.parameters[ParamKey.id], let pilotId = deepLink.parameters[ParamKey.pilotId] else { return false } + guard pilotId == myUser.id else { return false } + + raceApi.join(race: raceId) { (status, error) in + // Broadcast regardless if joined successful or not + // since this may be called, even if the race has already been joined + // in cases like paying fees after joining a race. + NotificationCenter.default.post( + name: .joinedRaceViaDeeplink, + object: deepLink + ) + } + return true + } +} diff --git a/RaceSyncAPI/Utils/ErrorUtil.swift b/RaceSyncAPI/Utils/ErrorUtil.swift index aa75f2a6..376746a1 100644 --- a/RaceSyncAPI/Utils/ErrorUtil.swift +++ b/RaceSyncAPI/Utils/ErrorUtil.swift @@ -20,6 +20,10 @@ enum ErrorCode: Int { class ErrorUtil { static func parseError(_ response: DataResponse) -> NSError { + if response.result.isFailure, let error = response.result.error { + return error as NSError + } + guard let data = response.data, let value = JSON(data).dictionaryObject else { return generalError } diff --git a/RaceSyncAPI/Utils/HTMLLinkTransform.swift b/RaceSyncAPI/Utils/HTMLLinkTransform.swift new file mode 100644 index 00000000..ce86782f --- /dev/null +++ b/RaceSyncAPI/Utils/HTMLLinkTransform.swift @@ -0,0 +1,68 @@ +// +// MapperUtil.swift +// RaceSyncAPI +// +// Created by Ignacio Romero Zurbuchen on 2025-09-26. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import Foundation +import ObjectMapper + +public struct HTMLLinkTransform: TransformType { + public typealias Object = String + public typealias JSON = String + + private let baseURL: URL + + public init(baseURL: URL) { + self.baseURL = baseURL + } + + public func transformFromJSON(_ value: Any?) -> String? { + guard var html = value as? String else { return nil } + + // Regex to capture href="something" + let pattern = #"href="([^"]+)""# + let regex = try? NSRegularExpression(pattern: pattern, options: []) + let nsString = html as NSString + var matches: [(NSRange, String)] = [] + + regex?.enumerateMatches(in: html, options: [], range: NSRange(location: 0, length: nsString.length)) { match, _, _ in + guard let match = match, match.numberOfRanges > 1 else { return } + let urlString = nsString.substring(with: match.range(at: 1)) + matches.append((match.range(at: 1), urlString)) + } + + // Replace from the back to avoid shifting indices + for (range, urlString) in matches.reversed() { + if let absolute = fixedURLString(urlString) { + html = (html as NSString).replacingCharacters(in: range, with: absolute) + } + } + + return html + } + + public func transformToJSON(_ value: String?) -> String? { + return value + } + + private func fixedURLString(_ original: String) -> String? { + // Already absolute? + if let url = URL(string: original), url.scheme != nil { + return original // leave as-is + } + + // Relative case — remove only the leading slash if present + let trimmed = original.hasPrefix("/") ? String(original.dropFirst()) : original + + // Use URL(string:relativeTo:) so query parameters stay intact + if let url = URL(string: trimmed, relativeTo: baseURL) { + return url.absoluteString + } + + // Fallback to concatenation + return baseURL.absoluteString + original + } +} diff --git a/RaceSyncAPI/Utils/LocationManager.swift b/RaceSyncAPI/Utils/LocationManager.swift index 3ad2b7c4..5de03e13 100644 --- a/RaceSyncAPI/Utils/LocationManager.swift +++ b/RaceSyncAPI/Utils/LocationManager.swift @@ -14,12 +14,8 @@ public class LocationManager: CLLocationManager { public static let shared = LocationManager() - public override var authorizationStatus: CLAuthorizationStatus { - get { return CLLocationManager.authorizationStatus() } - } - public var didRequestAuthorization: Bool { - get { return authorizationStatus != .notDetermined } + get { return self.authorizationStatus != .notDetermined } } // MARK: - Private Variables @@ -36,7 +32,7 @@ public class LocationManager: CLLocationManager { // MARK: - Public Functions public func requestsAuthorization(_ completion: CompletionBlock?) { - let authorization = CLLocationManager.authorizationStatus() + let authorization = self.authorizationStatus if authorization == .notDetermined { requestWhenInUseAuthorization() self.authorizationBlock = completion @@ -55,9 +51,7 @@ extension LocationManager: CLLocationManagerDelegate { } public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - if let location = locations.first { - // Clog.log("User Location updated \(location.coordinate)") - + if let _ = locations.first { authorizationBlock?(nil) authorizationBlock = nil } diff --git a/RaceSyncAPI/Utils/ObjectMapper+Extensions.swift b/RaceSyncAPI/Utils/ObjectMapper+Extensions.swift index f2540482..47aa9184 100644 --- a/RaceSyncAPI/Utils/ObjectMapper+Extensions.swift +++ b/RaceSyncAPI/Utils/ObjectMapper+Extensions.swift @@ -19,5 +19,4 @@ extension ImmutableMappable { return nil } } - } diff --git a/RaceSyncAPI/Utils/TimeUtil.swift b/RaceSyncAPI/Utils/TimeUtil.swift index ad909a68..f93bf0f0 100644 --- a/RaceSyncAPI/Utils/TimeUtil.swift +++ b/RaceSyncAPI/Utils/TimeUtil.swift @@ -11,25 +11,29 @@ import Foundation public class TimeUtil { public static func lapTimeFormat(seconds timeString: String) -> String { - guard let time = Double(timeString) else { return "" } - // Round up the total seconds - let totalSeconds = ceil(time * 1000) / 1000 + guard let raw = Double(timeString) else { return "" } - let minutes = Int(totalSeconds) / 60 - let seconds = Int(totalSeconds) % 60 - let milliseconds = Int((totalSeconds - floor(totalSeconds)) * 1000) + // Convert to integer milliseconds by truncation + let totalMs = Int(raw * 1000) - if totalSeconds < 60 { + let hours = totalMs / 3_600_000 + let minutes = (totalMs / 60_000) % 60 + let seconds = (totalMs / 1000) % 60 + let milliseconds = totalMs % 1000 + + if hours > 0 { + // Format into "H:MM:SS.mmm" + return String(format: "%d:%02d:%02d.%03d", hours, minutes, seconds, milliseconds) + } else if minutes > 0 { + // Format into "MM:SS.mmm" + return String(format: "%02d:%02d.%03d", minutes, seconds, milliseconds) + } else if seconds >= 10 { // Format into "SS.mmm" return String(format: "%02d.%03d", seconds, milliseconds) } else { - // Format into "M:SS.mmm" - return String(format: "%2d:%02d.%03d", minutes, seconds, milliseconds) + // Format into "S.mmm" + return String(format: "%d.%03d", seconds, milliseconds) } } } - - - - diff --git a/RaceSyncAPITests/DeepLinkTests.swift b/RaceSyncAPITests/DeepLinkTests.swift new file mode 100644 index 00000000..03ae954c --- /dev/null +++ b/RaceSyncAPITests/DeepLinkTests.swift @@ -0,0 +1,46 @@ +// +// DeepLinkTests.swift +// RaceSyncAPITests +// +// Created by Ignacio Romero Zurbuchen on 2025-09-26. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import XCTest +@testable import RaceSyncAPI + +final class DeepLinkTests: XCTestCase { + + override func setUpWithError() throws { } + + override func tearDownWithError() throws { } + + func testDeepLinkAbsoluteString() throws { + let expected = "racesync://race/view?id=29941" + let result = DeepLink(domain: .race, action: .view, parameters: ["id" : "29941"]) + + XCTAssertEqual(result.absoluteString, expected) + } + + func testConvertWebURLToDeeplink() throws { + let url = URL(string: "https://www.multigp.com/races/view/?race=30303/")! + let expected = "racesync://races/view?id=30303" + let result = DeepLink.create(from: url)! + + XCTAssertEqual(result.absoluteString, expected) + } + + func testRaceViewDeeplink() throws { + let expected = "racesync://races/view?id=29941" + let result = DeepLink.create(from: URL(string: expected)!)! + + XCTAssertEqual(result.absoluteString, expected) + } + + func testRaceJoinDeeplink() throws { + let expected = "racesync://race/join?id=29941&pilotId=20676" + let result = DeepLink.create(from: URL(string: expected)!)! + + XCTAssertEqual(result.absoluteString, expected) + } +} diff --git a/RaceSyncAPITests/DescriptableTests.swift b/RaceSyncAPITests/DescriptableTests.swift index 104820c4..79743871 100644 --- a/RaceSyncAPITests/DescriptableTests.swift +++ b/RaceSyncAPITests/DescriptableTests.swift @@ -7,7 +7,7 @@ // import XCTest -import RaceSyncAPI +@testable import RaceSyncAPI class DescriptableTests: XCTestCase { diff --git a/RaceSyncAPITests/MGPWebTests.swift b/RaceSyncAPITests/MGPWebTests.swift new file mode 100644 index 00000000..3cc41ba4 --- /dev/null +++ b/RaceSyncAPITests/MGPWebTests.swift @@ -0,0 +1,80 @@ +// +// MGPWebTests.swift +// RaceSyncAPITests +// +// Created by Ignacio Romero Zurbuchen on 2025-09-26. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import XCTest +@testable import RaceSyncAPI + +final class MGPWebTests: XCTestCase { + + override func setUpWithError() throws { } + + override func tearDownWithError() throws { } + + func testBaseURL() throws { + let expected = URL(string: "https://www.multigp.com/")! + let result = MGPWeb.baseURL() + + XCTAssertEqual(result, expected) + } + + func testAPIURL() throws { + let expected = URL(string: "https://www.multigp.com/mgp/multigpwebservice/")! + let result = MGPWeb.getURL(for: .apiBase) + + XCTAssertEqual(result, expected) + } + + func testRaceURL() throws { + let expected = URL(string: "https://www.multigp.com/races/view/?race=30578")! + let result = MGPWeb.getURL(for: .raceView, value: "30578") + + XCTAssertEqual(result, expected) + } + + func testChapterURL() throws { + let expected = URL(string: "https://www.multigp.com/chapters/view/?chapter=VanWhoop")! + let result = MGPWeb.getURL(for: .chapterView, value: "VanWhoop") + + XCTAssertEqual(result, expected) + } + + func testUserURL() throws { + let expected = URL(string: "https://www.multigp.com/pilots/view/?pilot=Zenith")! + let result = MGPWeb.getURL(for: .userView, value: "Zenith") + + XCTAssertEqual(result, expected) + } + + func testZippyQURL() throws { + let expected = URL(string: "https://www.multigp.com/MultiGP/views/zippyq.php?raceId=6666")! + let result = MGPWeb.getURL(for: .zippyqView, value: "6666") + + XCTAssertEqual(result, expected) + } + + func testChapterLeaderboardQURL() throws { + let expected = URL(string: "https://www.multigp.com/chapters/leaderboard/view/?chapter=16")! + let result = MGPWeb.getURL(for: .chapterLeaderboard, value: "16") + + XCTAssertEqual(result, expected) + } + + func testZipperSeasonResults() throws { + let expected = URL(string: "https://www.multigp.com/MultiGP/views/viewZipperSeasonResults.php?season1=2025Summer&season2=2025Spring&exportcsv=true")! + let result = StandingApi.getStandingsUrl(for: .y2025)! + + XCTAssertEqual(result, expected) + } + + func testPaymentURL() throws { + let expected = URL(string: "https://www.multigp.com/MultiGP/views/processPayment.php?raceId=30303&pilotId=24900&user-agent=ios")! + let result = RaceApi.getPaymentUrl(for: "30303", user: "24900") + + XCTAssertEqual(result, expected) + } +} diff --git a/RaceSyncAPITests/ParametersTests.swift b/RaceSyncAPITests/ParametersTests.swift index 3e238443..1c53780b 100644 --- a/RaceSyncAPITests/ParametersTests.swift +++ b/RaceSyncAPITests/ParametersTests.swift @@ -7,7 +7,7 @@ // import XCTest -import RaceSyncAPI +@testable import RaceSyncAPI import Alamofire class ParamsTests: XCTestCase { @@ -51,7 +51,7 @@ class ParamsTests: XCTestCase { let after: Params = ["foo": true] let result: Params = Params.diff(between: before, and: after) - XCTAssertEqual(result, ["foo": 1]) + XCTAssertEqual(result, ["foo": true]) } func testDifferentIntParams() throws { @@ -69,7 +69,7 @@ class ParamsTests: XCTestCase { let after: Params = ["foo1": 25, "foo2": false, "foo3": "hello world"] let result: Params = Params.diff(between: before, and: after) - XCTAssertEqual(result, ["foo2": 0, "foo3": "hello world"]) + XCTAssertEqual(result, ["foo2": false, "foo3": "hello world"]) } } diff --git a/RaceSyncAPITests/TimeUtilTests.swift b/RaceSyncAPITests/TimeUtilTests.swift new file mode 100644 index 00000000..05be2b03 --- /dev/null +++ b/RaceSyncAPITests/TimeUtilTests.swift @@ -0,0 +1,62 @@ +// +// TimeUtilTests.swift +// RaceSyncAPITests +// +// Created by Ignacio Romero Zurbuchen on 2025-12-21. +// Copyright © 2025 MultiGP Inc. All rights reserved. +// + +import XCTest +@testable import RaceSyncAPI + +final class TimeUtilTests: XCTestCase { + + override func setUpWithError() throws { } + + override func tearDownWithError() throws { } + + func testSecondConvertion() throws { + + let time = "9.204469" + let expected = "9.204" + let result = TimeUtil.lapTimeFormat(seconds: time) + + XCTAssertEqual(result, expected) + } + + func testSecondsConvertion() throws { + + let time = "30.400624" + let expected = "30.400" + let result = TimeUtil.lapTimeFormat(seconds: time) + + XCTAssertEqual(result, expected) + } + + func testMinuteConvertion() throws { + + let time = "157.645896" + let expected = "02:37.645" + let result = TimeUtil.lapTimeFormat(seconds: time) + + XCTAssertEqual(result, expected) + } + + func testMinutesConvertion() throws { + + let time = "1207.966584" + let expected = "20:07.966" + let result = TimeUtil.lapTimeFormat(seconds: time) + + XCTAssertEqual(result, expected) + } + + func testHourConvertion() throws { + + let time = "5150.390321" + let expected = "1:25:50.390" + let result = TimeUtil.lapTimeFormat(seconds: time) + + XCTAssertEqual(result, expected) + } +} diff --git a/build/XCBuildData/921763b6f623a70d8a6ef25fdb96b5da.xcbuilddata/build-request.json b/build/XCBuildData/921763b6f623a70d8a6ef25fdb96b5da.xcbuilddata/build-request.json new file mode 100644 index 00000000..38eab09c --- /dev/null +++ b/build/XCBuildData/921763b6f623a70d8a6ef25fdb96b5da.xcbuilddata/build-request.json @@ -0,0 +1,27 @@ +{ + "buildCommand" : { + "command" : "build", + "skipDependencies" : false, + "style" : "buildOnly" + }, + "configuredTargets" : [ + + ], + "continueBuildingAfterErrors" : false, + "dependencyScope" : "workspace", + "enableIndexBuildArena" : false, + "hideShellScriptEnvironment" : false, + "parameters" : { + "action" : "build", + "overrides" : { + + } + }, + "qos" : "utility", + "schemeCommand" : "launch", + "showNonLoggedProgress" : true, + "useDryRun" : false, + "useImplicitDependencies" : false, + "useLegacyBuildLocations" : false, + "useParallelTargets" : true +} \ No newline at end of file diff --git a/build/XCBuildData/921763b6f623a70d8a6ef25fdb96b5da.xcbuilddata/description.msgpack b/build/XCBuildData/921763b6f623a70d8a6ef25fdb96b5da.xcbuilddata/description.msgpack new file mode 100644 index 00000000..e56d3062 Binary files /dev/null and b/build/XCBuildData/921763b6f623a70d8a6ef25fdb96b5da.xcbuilddata/description.msgpack differ diff --git a/build/XCBuildData/921763b6f623a70d8a6ef25fdb96b5da.xcbuilddata/manifest.json b/build/XCBuildData/921763b6f623a70d8a6ef25fdb96b5da.xcbuilddata/manifest.json new file mode 100644 index 00000000..7391713b --- /dev/null +++ b/build/XCBuildData/921763b6f623a70d8a6ef25fdb96b5da.xcbuilddata/manifest.json @@ -0,0 +1 @@ +{"client":{"name":"basic","version":0,"file-system":"device-agnostic","perform-ownership-analysis":"no"},"targets":{"":[""]},"commands":{"":{"tool":"phony","inputs":[""],"outputs":[""]},"P0:::Gate WorkspaceHeaderMapVFSFilesWritten":{"tool":"phony","inputs":[],"outputs":[""]}}} \ No newline at end of file diff --git a/build/XCBuildData/921763b6f623a70d8a6ef25fdb96b5da.xcbuilddata/target-graph.txt b/build/XCBuildData/921763b6f623a70d8a6ef25fdb96b5da.xcbuilddata/target-graph.txt new file mode 100644 index 00000000..b83b1580 --- /dev/null +++ b/build/XCBuildData/921763b6f623a70d8a6ef25fdb96b5da.xcbuilddata/target-graph.txt @@ -0,0 +1 @@ +Target dependency graph (0 target) \ No newline at end of file diff --git a/build/XCBuildData/921763b6f623a70d8a6ef25fdb96b5da.xcbuilddata/task-store.msgpack b/build/XCBuildData/921763b6f623a70d8a6ef25fdb96b5da.xcbuilddata/task-store.msgpack new file mode 100644 index 00000000..6cef3fe3 Binary files /dev/null and b/build/XCBuildData/921763b6f623a70d8a6ef25fdb96b5da.xcbuilddata/task-store.msgpack differ