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..23c4c4bd 100644 --- a/Pods/Pods.xcodeproj/project.pbxproj +++ b/Pods/Pods.xcodeproj/project.pbxproj @@ -15,7 +15,6 @@ dependencies = ( ); name = SwiftLint; - productName = SwiftLint; }; /* End PBXAggregateTarget section */ @@ -550,15 +549,15 @@ 02BC1EE72505F95C1F138DB4332F8EB5 /* EnumOperators.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EnumOperators.swift; path = Sources/EnumOperators.swift; sourceTree = ""; }; 03386EABB878BFBB29B99A880A2CEF4B /* ShimmeringView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ShimmeringView.swift; path = Shimmer/ShimmeringView.swift; sourceTree = ""; }; 03ED913B1F1A41E56A052EF0EAB085E5 /* QRCode-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "QRCode-prefix.pch"; sourceTree = ""; }; - 0407367F0FB4022AB2F84DE03BC033D9 /* ja.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = ja.lproj; path = "Objective-C/TOCropViewController/Resources/ja.lproj"; sourceTree = ""; }; + 0407367F0FB4022AB2F84DE03BC033D9 /* ja.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = ja.lproj; path = "Objective-C/TOCropViewController/Resources/ja.lproj"; sourceTree = ""; }; 042686C4EE5BDBAA89F77CE3AAC9C5AA /* SnapKit.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = SnapKit.release.xcconfig; sourceTree = ""; }; 05A1F067891DCD8B7558CCCCFD76C23A /* Service.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Service.swift; path = Sources/Valet/Internal/Service.swift; sourceTree = ""; }; 06322BDB87A69F139216AE43258350B2 /* Constraint.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Constraint.swift; path = Sources/Constraint.swift; sourceTree = ""; }; 06440A266E5B6DF31D5C15C77F64673E /* Bool+StringConvertible.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Bool+StringConvertible.swift"; path = "SwiftValidators/Classes/Extensions/Bool+StringConvertible.swift"; sourceTree = ""; }; - 07B171DA08B77CF504A53199CBC21858 /* zh-Hans.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = "zh-Hans.lproj"; path = "Objective-C/TOCropViewController/Resources/zh-Hans.lproj"; sourceTree = ""; }; + 07B171DA08B77CF504A53199CBC21858 /* zh-Hans.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = "zh-Hans.lproj"; path = "Objective-C/TOCropViewController/Resources/zh-Hans.lproj"; sourceTree = ""; }; 0864E8B7DB2B4D777159697333EF9B3E /* EmptyDataSet-Swift-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "EmptyDataSet-Swift-umbrella.h"; sourceTree = ""; }; 09089A4C62E20748A2928AD967F52337 /* AlamofireNetworkActivityIndicator-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "AlamofireNetworkActivityIndicator-umbrella.h"; sourceTree = ""; }; - 090BAC6EB695C134D75CB36D57BD8ACE /* ca.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = ca.lproj; path = "Objective-C/TOCropViewController/Resources/ca.lproj"; sourceTree = ""; }; + 090BAC6EB695C134D75CB36D57BD8ACE /* ca.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = ca.lproj; path = "Objective-C/TOCropViewController/Resources/ca.lproj"; sourceTree = ""; }; 097BEF949D1E32D263BFAAA4D4677E17 /* ConstraintMaker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintMaker.swift; path = Sources/ConstraintMaker.swift; sourceTree = ""; }; 09C7F2ACEB2CD64411DB7C17127F4CAE /* AlamofireImage.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = AlamofireImage.modulemap; sourceTree = ""; }; 0A8AD4E957574FC768E0E98545156130 /* Valet-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "Valet-prefix.pch"; sourceTree = ""; }; @@ -598,7 +597,7 @@ 23CFB460A0891E4C192435A527AEAB52 /* ConstraintLayoutSupportDSL.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintLayoutSupportDSL.swift; path = Sources/ConstraintLayoutSupportDSL.swift; sourceTree = ""; }; 245320B7827F04771B52A55D951EBF2B /* TOCropViewController.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = TOCropViewController.release.xcconfig; sourceTree = ""; }; 248345AEE038026E370BF33BDFC9E488 /* Valet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = Valet.debug.xcconfig; sourceTree = ""; }; - 2540F98D5D42FBC04C81A64B25BA902A /* tr.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = tr.lproj; path = "Objective-C/TOCropViewController/Resources/tr.lproj"; sourceTree = ""; }; + 2540F98D5D42FBC04C81A64B25BA902A /* tr.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = tr.lproj; path = "Objective-C/TOCropViewController/Resources/tr.lproj"; sourceTree = ""; }; 2562CB49C31D6F75EDF3EABFD006146D /* SwiftValidators.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = SwiftValidators.modulemap; sourceTree = ""; }; 25AABB9C0C256C9AFB656C92B533F430 /* ConstraintInsetTarget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintInsetTarget.swift; path = Sources/ConstraintInsetTarget.swift; sourceTree = ""; }; 265F734C7ED173A2372C03C1F53B2F3B /* AlamofireImage.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = AlamofireImage.debug.xcconfig; sourceTree = ""; }; @@ -612,7 +611,7 @@ 2B96AF0B3E009A54CDA2C0366F54E4A6 /* Accessibility.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Accessibility.swift; path = Sources/Valet/Accessibility.swift; sourceTree = ""; }; 2CFD9198526E8707656AF3E540ECC7BB /* MultipartFormData.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MultipartFormData.swift; path = Source/MultipartFormData.swift; sourceTree = ""; }; 3010060ADB5E90FD4BB7B3F423F06E82 /* TOCropToolbar.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = TOCropToolbar.m; path = "Objective-C/TOCropViewController/Views/TOCropToolbar.m"; sourceTree = ""; }; - 3234491BD4DF78FB369DE46A5D815986 /* ko.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = ko.lproj; path = "Objective-C/TOCropViewController/Resources/ko.lproj"; sourceTree = ""; }; + 3234491BD4DF78FB369DE46A5D815986 /* ko.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = ko.lproj; path = "Objective-C/TOCropViewController/Resources/ko.lproj"; sourceTree = ""; }; 33435D32BFADCA9A1F5ED333CCC49416 /* TOCropViewController.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = TOCropViewController.h; path = "Objective-C/TOCropViewController/TOCropViewController.h"; sourceTree = ""; }; 336660096B97B7B94E317CF722129953 /* SecureEnclave.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SecureEnclave.swift; path = Sources/Valet/SecureEnclave.swift; sourceTree = ""; }; 33FC4D1F80F9AF4FB9D8E85F46AA513C /* TransitionType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransitionType.swift; path = Presentr/TransitionType.swift; sourceTree = ""; }; @@ -630,13 +629,13 @@ 3BC1C3638359F7406B4914F112E34D53 /* Int+StringConvertible.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Int+StringConvertible.swift"; path = "SwiftValidators/Classes/Extensions/Int+StringConvertible.swift"; sourceTree = ""; }; 3BCEBB4E8A56384666BB930CA96739BE /* CodableTransform.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CodableTransform.swift; path = Sources/CodableTransform.swift; sourceTree = ""; }; 3C4C6441C2A0FA16778E031ED41CEC3A /* Presentr.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = Presentr.modulemap; sourceTree = ""; }; - 3CBF66C385AAF8E8D9DAFD090765FA3D /* fa.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = fa.lproj; path = "Objective-C/TOCropViewController/Resources/fa.lproj"; sourceTree = ""; }; - 3CD64035961DF47BD114AE1579D21325 /* en.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = en.lproj; path = "Objective-C/TOCropViewController/Resources/en.lproj"; sourceTree = ""; }; + 3CBF66C385AAF8E8D9DAFD090765FA3D /* fa.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = fa.lproj; path = "Objective-C/TOCropViewController/Resources/fa.lproj"; sourceTree = ""; }; + 3CD64035961DF47BD114AE1579D21325 /* en.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = en.lproj; path = "Objective-C/TOCropViewController/Resources/en.lproj"; sourceTree = ""; }; 3DA5C4F0562783D9BD0CD0BA4619B353 /* UIImage+AlamofireImage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "UIImage+AlamofireImage.swift"; path = "Source/UIImage+AlamofireImage.swift"; sourceTree = ""; }; 3DA92CD2F25660DDAB4869389ED5623F /* Pods-RaceSyncAPITests-acknowledgements.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-RaceSyncAPITests-acknowledgements.plist"; sourceTree = ""; }; 3DFF52CE62A7FF6D05CB0FB131EE5784 /* ShimmerSwift.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = ShimmerSwift.release.xcconfig; sourceTree = ""; }; 3EECE9E60E4FFF9243CB75147CC4E85F /* PickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PickerView.swift; path = Pod/Classes/PickerView.swift; sourceTree = ""; }; - 3F32DD51C70D0E668B205CDD301EDC80 /* ms.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = ms.lproj; path = "Objective-C/TOCropViewController/Resources/ms.lproj"; sourceTree = ""; }; + 3F32DD51C70D0E668B205CDD301EDC80 /* ms.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = ms.lproj; path = "Objective-C/TOCropViewController/Resources/ms.lproj"; sourceTree = ""; }; 4016990C3F7293F997D45B5D06E6CBCD /* ConstraintPriorityTarget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintPriorityTarget.swift; path = Sources/ConstraintPriorityTarget.swift; sourceTree = ""; }; 40DAD0F96FCBAE32BBBA4DC77FA60DCF /* SwiftyJSON-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "SwiftyJSON-umbrella.h"; sourceTree = ""; }; 458B68C25410DBD3B55836C8A4916173 /* UIButton+AlamofireImage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "UIButton+AlamofireImage.swift"; path = "Source/UIButton+AlamofireImage.swift"; sourceTree = ""; }; @@ -648,7 +647,7 @@ 4A0284603642777258D0EF22D32448A1 /* SwiftCompatibility.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SwiftCompatibility.swift; path = Sources/Valet/SwiftCompatibility.swift; sourceTree = ""; }; 4A999B80BCE294E3498627316C242DF4 /* TOCropViewConstants.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = TOCropViewConstants.h; path = "Objective-C/TOCropViewController/Constants/TOCropViewConstants.h"; sourceTree = ""; }; 4AEAE60FC41D7E1647C96FBF3DFC01E6 /* ConstraintMakerExtendable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintMakerExtendable.swift; path = Sources/ConstraintMakerExtendable.swift; sourceTree = ""; }; - 4BD29460106DBFBFBFB824E76817DD1F /* cs.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = cs.lproj; path = "Objective-C/TOCropViewController/Resources/cs.lproj"; sourceTree = ""; }; + 4BD29460106DBFBFBFB824E76817DD1F /* cs.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = cs.lproj; path = "Objective-C/TOCropViewController/Resources/cs.lproj"; sourceTree = ""; }; 4C2EAD68D63F77E5C7A6EA7E8982143E /* Image.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Image.swift; path = Source/Image.swift; sourceTree = ""; }; 4D44089AC6C9062011E31A3572654E67 /* SwiftyJSON.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = SwiftyJSON.release.xcconfig; sourceTree = ""; }; 4DD29359D563140CFEB42B142BC8EBD7 /* Float+StringConvertible.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Float+StringConvertible.swift"; path = "SwiftValidators/Classes/Extensions/Float+StringConvertible.swift"; sourceTree = ""; }; @@ -669,8 +668,8 @@ 557138F3AE32B5D9C151C2E3570DCB6D /* AlamofireObjectMapper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AlamofireObjectMapper.swift; path = AlamofireObjectMapper/AlamofireObjectMapper.swift; sourceTree = ""; }; 55F41BC28DA11F08F8BE346E523886EE /* TransformType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TransformType.swift; path = Sources/TransformType.swift; sourceTree = ""; }; 560B0365D135E28BAE43FBD7E1E67F14 /* BackgroundView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BackgroundView.swift; path = Presentr/BackgroundView.swift; sourceTree = ""; }; - 570891122F373EF522ADBD95A39618FB /* pt-BR.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = "pt-BR.lproj"; path = "Objective-C/TOCropViewController/Resources/pt-BR.lproj"; sourceTree = ""; }; - 5787EB1BC372530EB04F8F473B6DD856 /* es.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = es.lproj; path = "Objective-C/TOCropViewController/Resources/es.lproj"; sourceTree = ""; }; + 570891122F373EF522ADBD95A39618FB /* pt-BR.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = "pt-BR.lproj"; path = "Objective-C/TOCropViewController/Resources/pt-BR.lproj"; sourceTree = ""; }; + 5787EB1BC372530EB04F8F473B6DD856 /* es.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = es.lproj; path = "Objective-C/TOCropViewController/Resources/es.lproj"; sourceTree = ""; }; 58A10BF450077BEB0A6D5847D0CD22CA /* ValueProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ValueProvider.swift; path = SwiftValidators/Classes/Helpers/ValueProvider.swift; sourceTree = ""; }; 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 = ""; }; @@ -689,21 +688,21 @@ 615E73324A15D6A0D1C59DA2BD7E7D11 /* MapError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MapError.swift; path = Sources/MapError.swift; sourceTree = ""; }; 61912ED9F94F651CB3073FF01D148E68 /* CIColorExtension.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CIColorExtension.swift; path = QRCode/CIColorExtension.swift; sourceTree = ""; }; 627D5F9ACF8C5C1514042B917B0C161A /* ISO8601DateTransform.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ISO8601DateTransform.swift; path = Sources/ISO8601DateTransform.swift; sourceTree = ""; }; - 635EAD26DF27F13E5D2FD93B8C94882A /* ro.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = ro.lproj; path = "Objective-C/TOCropViewController/Resources/ro.lproj"; sourceTree = ""; }; + 635EAD26DF27F13E5D2FD93B8C94882A /* ro.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = ro.lproj; path = "Objective-C/TOCropViewController/Resources/ro.lproj"; sourceTree = ""; }; 63CE47F09DE2DA8A938D462B4417968B /* CrossDissolveAnimation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CrossDissolveAnimation.swift; path = Presentr/CrossDissolveAnimation.swift; sourceTree = ""; }; 63E6A8ACDAEBAADDC72A3C2B35B6E63F /* ConstraintView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintView.swift; path = Sources/ConstraintView.swift; sourceTree = ""; }; 63EAB1B97E2AD553EBCE027CCB520F44 /* ShimmerSwift-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "ShimmerSwift-umbrella.h"; sourceTree = ""; }; 6431AD40F26A684414530A8A53944944 /* CloudAccessibility.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CloudAccessibility.swift; path = Sources/Valet/CloudAccessibility.swift; sourceTree = ""; }; - 644C49C88C43BFE4C849FD169FF29FDF /* da-DK.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = "da-DK.lproj"; path = "Objective-C/TOCropViewController/Resources/da-DK.lproj"; sourceTree = ""; }; + 644C49C88C43BFE4C849FD169FF29FDF /* da-DK.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = "da-DK.lproj"; path = "Objective-C/TOCropViewController/Resources/da-DK.lproj"; sourceTree = ""; }; 647DC4962EDD2413986DD3872A12407F /* TOCropViewController.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = TOCropViewController.debug.xcconfig; sourceTree = ""; }; - 6505013B0B1699E8DAE12B05228312E0 /* pl.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = pl.lproj; path = "Objective-C/TOCropViewController/Resources/pl.lproj"; sourceTree = ""; }; + 6505013B0B1699E8DAE12B05228312E0 /* pl.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = pl.lproj; path = "Objective-C/TOCropViewController/Resources/pl.lproj"; sourceTree = ""; }; 651EB9A1F43DB6AAEE59D00F5CF103F6 /* ObjectMapper.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = ObjectMapper.release.xcconfig; sourceTree = ""; }; 6533C2ACD22BEC594369EF7D4CA18D09 /* SwiftyJSON-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "SwiftyJSON-dummy.m"; sourceTree = ""; }; 65F6128DFC60BED919848419646BF635 /* AlamofireImage-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "AlamofireImage-Info.plist"; sourceTree = ""; }; 660AF4585A9B9A3636933E96A811A0A7 /* QRCode.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = QRCode.modulemap; sourceTree = ""; }; - 67CE9C8E0A50D86D4372FBC3AEA48273 /* fi.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = fi.lproj; path = "Objective-C/TOCropViewController/Resources/fi.lproj"; sourceTree = ""; }; + 67CE9C8E0A50D86D4372FBC3AEA48273 /* fi.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = fi.lproj; path = "Objective-C/TOCropViewController/Resources/fi.lproj"; sourceTree = ""; }; 6808BEB5196C750DEA0E603748190384 /* QRCode-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "QRCode-Info.plist"; sourceTree = ""; }; - 693D2FF436EFE2F7940D1C823BDC7134 /* Montserrat-Regular.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; name = "Montserrat-Regular.ttf"; path = "Presentr/Montserrat-Regular.ttf"; sourceTree = ""; }; + 693D2FF436EFE2F7940D1C823BDC7134 /* Montserrat-Regular.ttf */ = {isa = PBXFileReference; includeInIndex = 1; name = "Montserrat-Regular.ttf"; path = "Presentr/Montserrat-Regular.ttf"; sourceTree = ""; }; 69B3D834EEA530B518444B58A6E782AA /* ConstraintRelatableTarget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintRelatableTarget.swift; path = Sources/ConstraintRelatableTarget.swift; sourceTree = ""; }; 6B32ED0DF5B793BF6D1D850A8F2A1056 /* Typealiases.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Typealiases.swift; path = Sources/Typealiases.swift; sourceTree = ""; }; 6B3EF277155C3182E546EE055370A6D9 /* LayoutConstraintItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LayoutConstraintItem.swift; path = Sources/LayoutConstraintItem.swift; sourceTree = ""; }; @@ -712,24 +711,24 @@ 6CBED53E772C49E75DD07A62701A7EAE /* Pods-RaceSyncAPI-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "Pods-RaceSyncAPI-umbrella.h"; sourceTree = ""; }; 6F046B765565A33D06BC90EFF3261439 /* NSString+StringConvertible.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "NSString+StringConvertible.swift"; path = "SwiftValidators/Classes/Extensions/NSString+StringConvertible.swift"; sourceTree = ""; }; 6F42E88EEFB3924E871A02FFB1C7FA42 /* PickerView-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "PickerView-umbrella.h"; sourceTree = ""; }; - 6F9CEA880380C32102ABB321BB3E1513 /* Base.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = Base.lproj; path = "Objective-C/TOCropViewController/Resources/Base.lproj"; sourceTree = ""; }; + 6F9CEA880380C32102ABB321BB3E1513 /* Base.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = Base.lproj; path = "Objective-C/TOCropViewController/Resources/Base.lproj"; sourceTree = ""; }; 72B38AFCFF411B93A20B04BED75A7538 /* ConstraintDescription.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintDescription.swift; path = Sources/ConstraintDescription.swift; sourceTree = ""; }; 742E55A03EBD421219F3097CE7E6EEB5 /* Shimmer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Shimmer.swift; path = Shimmer/Shimmer.swift; sourceTree = ""; }; 7436A3D1DB5B23385055CB2F79FB81DF /* EmptyDataSet-Swift.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "EmptyDataSet-Swift.release.xcconfig"; sourceTree = ""; }; 74F6CF8746DD6AD2B533B29D50B7C35B /* IntegerOperators.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IntegerOperators.swift; path = Sources/IntegerOperators.swift; sourceTree = ""; }; 758F81FF55699CE33B500B6E85BF8AA9 /* Pods-RaceSyncAPI.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-RaceSyncAPI.debug.xcconfig"; sourceTree = ""; }; 7630DEFC2786743CE9887FB255C9C385 /* EmptyDataSetDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EmptyDataSetDelegate.swift; path = "EmptyDataSet-Swift/Sources/EmptyDataSetDelegate.swift"; sourceTree = ""; }; - 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 = ""; }; + 764750FFDE0F3D1ABABA176974F34458 /* id.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = id.lproj; path = "Objective-C/TOCropViewController/Resources/id.lproj"; sourceTree = ""; }; + 76AA8B4BFD3C779337860B1427C89EE9 /* SourceSansPro-Regular.ttf */ = {isa = PBXFileReference; includeInIndex = 1; 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; }; 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 = ""; }; - 7B1E856278D59DDB8ECA79D47188D283 /* zh-Hant.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = "zh-Hant.lproj"; path = "Objective-C/TOCropViewController/Resources/zh-Hant.lproj"; sourceTree = ""; }; + 7B1E856278D59DDB8ECA79D47188D283 /* zh-Hant.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = "zh-Hant.lproj"; path = "Objective-C/TOCropViewController/Resources/zh-Hant.lproj"; sourceTree = ""; }; 7B64DCB01620F6522FEEA0531693C14B /* ConstraintRelation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintRelation.swift; path = Sources/ConstraintRelation.swift; sourceTree = ""; }; - 7BFC41FBDD28A520C23BCD209CABB079 /* de.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = de.lproj; path = "Objective-C/TOCropViewController/Resources/de.lproj"; sourceTree = ""; }; - 7C855AAD6B939C598B45813835979DDC /* pt.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = pt.lproj; path = "Objective-C/TOCropViewController/Resources/pt.lproj"; sourceTree = ""; }; + 7BFC41FBDD28A520C23BCD209CABB079 /* de.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = de.lproj; path = "Objective-C/TOCropViewController/Resources/de.lproj"; sourceTree = ""; }; + 7C855AAD6B939C598B45813835979DDC /* pt.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = pt.lproj; path = "Objective-C/TOCropViewController/Resources/pt.lproj"; sourceTree = ""; }; 7D76BE4EA8A02D0386F706D981D0DBF2 /* DataTransform.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DataTransform.swift; path = Sources/DataTransform.swift; sourceTree = ""; }; 7DC8362AEF7E825CC62FA3251863D926 /* Valet.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = Valet.h; path = Sources/Valet/Valet.h; sourceTree = ""; }; 7DCD8A9578DC3D4106CEF3906385038D /* Presentr.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = Presentr.release.xcconfig; sourceTree = ""; }; @@ -744,14 +743,14 @@ 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 = ""; }; 8B74BC02657C9AAF9FAA771F4E2B6626 /* ObjectMapper.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = ObjectMapper.debug.xcconfig; sourceTree = ""; }; - 8BB5130A5DFFD6A983C9B0C3BE62E43D /* hu.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = hu.lproj; path = "Objective-C/TOCropViewController/Resources/hu.lproj"; sourceTree = ""; }; + 8BB5130A5DFFD6A983C9B0C3BE62E43D /* hu.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = hu.lproj; path = "Objective-C/TOCropViewController/Resources/hu.lproj"; sourceTree = ""; }; 8C5F09281A80E9D543BCD461641130E5 /* KeychainQueryConvertible.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = KeychainQueryConvertible.swift; path = Sources/Valet/KeychainQueryConvertible.swift; sourceTree = ""; }; 8CFFA6A5CE1F7EB87273F2A782C1C634 /* SecureEnclaveAccessControl.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SecureEnclaveAccessControl.swift; path = Sources/Valet/SecureEnclaveAccessControl.swift; sourceTree = ""; }; 8D4FDAE03F0F67BC86DF5F2AFBFD6AA5 /* TOCropViewController-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "TOCropViewController-prefix.pch"; sourceTree = ""; }; 8E54C3AA64C80BCEFC65761BF527AF3F /* AlamofireNetworkActivityIndicator-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "AlamofireNetworkActivityIndicator-prefix.pch"; sourceTree = ""; }; 8EF5C8BC9D0F7A1C9FEA6832295FB46A /* ObjectMapper-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "ObjectMapper-Info.plist"; sourceTree = ""; }; 8F9A43897DF30F44D92EA7209F0402EE /* SwiftValidators.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = SwiftValidators.debug.xcconfig; sourceTree = ""; }; - 8FD454EF5271F8B21D415E918129094D /* ru.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = ru.lproj; path = "Objective-C/TOCropViewController/Resources/ru.lproj"; sourceTree = ""; }; + 8FD454EF5271F8B21D415E918129094D /* ru.lproj */ = {isa = PBXFileReference; includeInIndex = 1; 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; }; @@ -765,8 +764,8 @@ 979486118B3E90C08386079D57962701 /* SnapKit */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = SnapKit; 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 = ""; }; + 9D940727FF8FB9C785EB98E56350EF41 /* Podfile */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; indentWidth = 2; lastKnownFileType = text; name = Podfile; path = ../Podfile; sourceTree = SOURCE_ROOT; tabWidth = 2; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; + 9EF7C727DA9F66F824468359E25C8931 /* sk.lproj */ = {isa = PBXFileReference; includeInIndex = 1; 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; }; 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 = ""; }; @@ -778,7 +777,7 @@ A33B8F2B05D81F7846067D8EED4A17E9 /* Pods-RaceSyncAPITests-frameworks.sh */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.script.sh; path = "Pods-RaceSyncAPITests-frameworks.sh"; sourceTree = ""; }; A43FF6F4380CA0DCFD5FB2DE6E5AC7AC /* EmptyDataSet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EmptyDataSet.swift; path = "EmptyDataSet-Swift/Sources/EmptyDataSet.swift"; sourceTree = ""; }; A4A97984E531AAA1D2F250878AF53793 /* ConstraintMakerFinalizable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintMakerFinalizable.swift; path = Sources/ConstraintMakerFinalizable.swift; sourceTree = ""; }; - A51854268DB0D071ECC333D98F79B49A /* fr.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = fr.lproj; path = "Objective-C/TOCropViewController/Resources/fr.lproj"; sourceTree = ""; }; + A51854268DB0D071ECC333D98F79B49A /* fr.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = fr.lproj; path = "Objective-C/TOCropViewController/Resources/fr.lproj"; sourceTree = ""; }; A5FE8E180224BEC271ED1B85B793236A /* DispatchQueue+Alamofire.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "DispatchQueue+Alamofire.swift"; path = "Source/DispatchQueue+Alamofire.swift"; sourceTree = ""; }; A63932D6C68A5EFC885693D9D21AD39E /* ObjectMapper-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "ObjectMapper-umbrella.h"; sourceTree = ""; }; A724F0F2429F1AD4C5CC9B1E0AE872EA /* ConstraintDSL.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintDSL.swift; path = Sources/ConstraintDSL.swift; sourceTree = ""; }; @@ -800,7 +799,7 @@ 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; }; - B39AA227428FA269171268A080203DC7 /* ar.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = ar.lproj; path = "Objective-C/TOCropViewController/Resources/ar.lproj"; sourceTree = ""; }; + B39AA227428FA269171268A080203DC7 /* ar.lproj */ = {isa = PBXFileReference; includeInIndex = 1; 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 = ""; }; @@ -820,7 +819,7 @@ BDFA796618283A8CDBEBA940B7E16227 /* PickerView-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "PickerView-Info.plist"; sourceTree = ""; }; BE05E96F331F5A6C4CBA209795084656 /* ConstraintInsets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintInsets.swift; path = Sources/ConstraintInsets.swift; sourceTree = ""; }; BF58869DBA283425AB8B270457EDF3E0 /* ConstraintMultiplierTarget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConstraintMultiplierTarget.swift; path = Sources/ConstraintMultiplierTarget.swift; sourceTree = ""; }; - C1BE762175D0ED5673FDE6136AE674DC /* nl.lproj */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = nl.lproj; path = "Objective-C/TOCropViewController/Resources/nl.lproj"; sourceTree = ""; }; + C1BE762175D0ED5673FDE6136AE674DC /* nl.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = nl.lproj; path = "Objective-C/TOCropViewController/Resources/nl.lproj"; sourceTree = ""; }; C2141F5FD928923D018E1359E9D27957 /* Pods-RaceSyncAPI-acknowledgements.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-RaceSyncAPI-acknowledgements.plist"; sourceTree = ""; }; C3366596D8CA547B3CC72F7FBB5A8E3F /* SwiftLint.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = SwiftLint.debug.xcconfig; sourceTree = ""; }; C3B2A0B779CE31153B2C0D8A7F8DC7FE /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.0.sdk/System/Library/Frameworks/Security.framework; sourceTree = DEVELOPER_DIR; }; @@ -848,7 +847,7 @@ D92DA9F456FB93C0EF410807CB76213A /* SwiftValidators */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = SwiftValidators; 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 = ""; }; + DB15B6847942950ACB4F4A353FA4D49E /* vi.lproj */ = {isa = PBXFileReference; includeInIndex = 1; name = vi.lproj; path = "Objective-C/TOCropViewController/Resources/vi.lproj"; sourceTree = ""; }; DBBF9AE88D89919A22330FE50B21674D /* PickerView.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = PickerView.release.xcconfig; sourceTree = ""; }; DD4A44043F1C31BFF217009943DABFDD /* Request.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Request.swift; path = Source/Request.swift; sourceTree = ""; }; DD60D3AE149A6DE094E2E988D1352A5C /* Valet.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = Valet.modulemap; sourceTree = ""; }; @@ -859,7 +858,7 @@ 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; }; 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 = ""; }; + E3FDD0561854BE0C0D2F59DC075C0240 /* fa-IR.lproj */ = {isa = PBXFileReference; includeInIndex = 1; 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 = ""; }; E532E7ABC7B408347BC2B8490489E7DB /* Alamofire-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "Alamofire-umbrella.h"; sourceTree = ""; }; E5D6C14A704332931BFFDABE420D66DF /* AlamofireImage-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "AlamofireImage-umbrella.h"; sourceTree = ""; }; @@ -895,7 +894,7 @@ F5306B0B2A4BF6D10D9E2739A7D875D2 /* DictionaryTransform.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DictionaryTransform.swift; path = Sources/DictionaryTransform.swift; sourceTree = ""; }; 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 = ""; }; + F634837B520C4C4D5BA6E5696C44764E /* it.lproj */ = {isa = PBXFileReference; includeInIndex = 1; 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; }; 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 = ""; }; @@ -1173,6 +1172,7 @@ 9105866C9588A0C3F324688AFC322F78 /* Resources */, DA897D748A7AA852BC1AAACF6AEEACEC /* Support Files */, ); + name = TOCropViewController; path = TOCropViewController; sourceTree = ""; }; @@ -1227,6 +1227,7 @@ FF05A602FF4A263A74DC8422711B53AA /* NetworkActivityIndicatorManager.swift */, 105FD5DE65E9228EF1B1CB73137C5E19 /* Support Files */, ); + name = AlamofireNetworkActivityIndicator; path = AlamofireNetworkActivityIndicator; sourceTree = ""; }; @@ -1267,6 +1268,7 @@ E94EBE1E4752C965AD397CFBCBA13755 /* Valet.swift */, B7EA7B241AEE923AFE85643C35286606 /* Support Files */, ); + name = Valet; path = Valet; sourceTree = ""; }; @@ -1275,6 +1277,7 @@ children = ( CE5E444FE9256C22C65C1CE37E047A86 /* Support Files */, ); + name = SwiftLint; path = SwiftLint; sourceTree = ""; }; @@ -1349,6 +1352,7 @@ A8C9F3A9D3429112ECDF00D17B765DB0 /* UIImageView+AlamofireImage.swift */, 6C00C372C13F80B90C88B7E0354E4799 /* Support Files */, ); + name = AlamofireImage; path = AlamofireImage; sourceTree = ""; }; @@ -1381,6 +1385,7 @@ 95F56D4F9AFF45DABB76A7D328665B98 /* URLTransform.swift */, 00ACA4A8ACCA2CCE0C047CE3D208800D /* Support Files */, ); + name = ObjectMapper; path = ObjectMapper; sourceTree = ""; }; @@ -1436,6 +1441,7 @@ 3EECE9E60E4FFF9243CB75147CC4E85F /* PickerView.swift */, 5345AECA2A4929B9326B6CEE64465251 /* Support Files */, ); + name = PickerView; path = PickerView; sourceTree = ""; }; @@ -1459,6 +1465,7 @@ 58A10BF450077BEB0A6D5847D0CD22CA /* ValueProvider.swift */, BC5A1C0423AAE3E3A5EB05ECA272A844 /* Support Files */, ); + name = SwiftValidators; path = SwiftValidators; sourceTree = ""; }; @@ -1514,6 +1521,7 @@ F4C2E147016A802F1094E972C64F7232 /* Validation.swift */, 086500C00D50B5E35F3C4257D7E175D3 /* Support Files */, ); + name = Alamofire; path = Alamofire; sourceTree = ""; }; @@ -1559,6 +1567,7 @@ 10BA6D962D3AC364EC15C27259E5E839 /* UILayoutSupport+Extensions.swift */, 28BAEB4546FC39A6C643A954F5305A9B /* Support Files */, ); + name = SnapKit; path = SnapKit; sourceTree = ""; }; @@ -1608,6 +1617,7 @@ 0F296DC89E3A9678AD43048246CA676F /* SwiftyJSON.swift */, B53BE93E67669EE7AAB0E76220538314 /* Support Files */, ); + name = SwiftyJSON; path = SwiftyJSON; sourceTree = ""; }; @@ -1617,6 +1627,7 @@ 557138F3AE32B5D9C151C2E3570DCB6D /* AlamofireObjectMapper.swift */, 4AE13A824677DE840458C9B51DE5636B /* Support Files */, ); + name = AlamofireObjectMapper; path = AlamofireObjectMapper; sourceTree = ""; }; @@ -1629,6 +1640,7 @@ 21BB85CCFDA66AD5304845D7F41DCD2D /* UIImageViewExtension.swift */, AB8E10F5FD137CCB3F130CBF87726BC4 /* Support Files */, ); + name = QRCode; path = QRCode; sourceTree = ""; }; @@ -1656,6 +1668,7 @@ BC37246E664BC79521839A788033992E /* Resources */, 64530CA6805D31A48CE6556D561B18F5 /* Support Files */, ); + name = Presentr; path = Presentr; sourceTree = ""; }; @@ -1723,6 +1736,7 @@ A2AFF4D3B8686896DB128581146ECBCA /* EmptyDataSetView+Extension.swift */, 760DCF2CAA063F5839DADE3175242A41 /* Support Files */, ); + name = "EmptyDataSet-Swift"; path = "EmptyDataSet-Swift"; sourceTree = ""; }; @@ -1735,6 +1749,7 @@ 03386EABB878BFBB29B99A880A2CEF4B /* ShimmeringView.swift */, 0A18570FAEAD25C30A3E672C523EDCC4 /* Support Files */, ); + name = ShimmerSwift; path = ShimmerSwift; sourceTree = ""; }; diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 0c706702..bbf247f5 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,6 +1,29 @@ # App Store Release Notes +--- + +## 1.9 + +### Introducing MultiGP Series: +You can now browse official MultiGP Series, join the open races and check out the live leaderboards. + +### Fixes and enhancements: + + * + +## 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: diff --git a/RaceSync.xcodeproj/project.pbxproj b/RaceSync.xcodeproj/project.pbxproj index 3a4dbe0f..a6d6454d 100644 --- a/RaceSync.xcodeproj/project.pbxproj +++ b/RaceSync.xcodeproj/project.pbxproj @@ -27,12 +27,17 @@ 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 */; }; 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 +48,8 @@ 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 */; }; + 4F2B7E872E86309300643697 /* SeriesApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2B7E862E86309300643697 /* SeriesApi.swift */; }; + 4F2B7E892E8635D200643697 /* SeriesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2B7E882E8635D200643697 /* SeriesViewModel.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 +137,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 +150,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 +191,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 +203,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 */; }; @@ -292,16 +303,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 +331,10 @@ 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 = ""; }; 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 +355,8 @@ 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 = ""; }; + 4F2B7E862E86309300643697 /* SeriesApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesApi.swift; sourceTree = ""; }; + 4F2B7E882E8635D200643697 /* SeriesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesViewModel.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 +445,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 +458,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 +503,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 = ""; }; @@ -729,6 +741,8 @@ 4F13427C2360DA4C00A9DBDE /* RaceSyncAPITests.swift */, 4FEBB27923A2DAFF007514B4 /* DescriptableTests.swift */, 4F9A888C2974AAD400B52092 /* ParametersTests.swift */, + 4F0F2F462E87532F006788A7 /* DeepLinkTests.swift */, + 4F0F2F422E872FBA006788A7 /* MGPWebTests.swift */, 4F13427E2360DA4C00A9DBDE /* Info.plist */, ); path = RaceSyncAPITests; @@ -794,6 +808,9 @@ isa = PBXGroup; children = ( 4F521F812E6E18D800AE7C03 /* SeriesViewController.swift */, + 4F90F0D32E8DD60700D9F5AF /* SeriesTabBarController.swift */, + 4F90F0CD2E8DD04100D9F5AF /* SeriesDetailViewController.swift */, + 4F90F0D12E8DD56D00D9F5AF /* SeriesStandingsViewController.swift */, ); path = Series; sourceTree = ""; @@ -925,6 +942,7 @@ isa = PBXGroup; children = ( 4F8F2C102384903F00DC4907 /* RaceViewModel.swift */, + 4F2B7E882E8635D200643697 /* SeriesViewModel.swift */, 4F86690123876E71005E310A /* ChapterViewModel.swift */, 4F0B61462385F69E00930D91 /* ProfileViewModel.swift */, 4FD53FAC23A0B01E00158206 /* UserViewModel.swift */, @@ -940,8 +958,6 @@ children = ( 4FA1FC8A23D6D7C0006D4704 /* ApplicationControl.swift */, 4F6FA16D2E4C66940056E115 /* AppplicationPreferences.swift */, - 4FC05C162E65004400EB97A4 /* DeepLinkURLHandler.swift */, - 4FC05C142E64FFB700EB97A4 /* DeepLink.swift */, 4FEADB732416B3D400F82F0D /* EventTracker.swift */, 4F00E065242930B3001DCFC4 /* RateMe */, ); @@ -963,6 +979,7 @@ 4F8669052388D3BC005E310A /* AuthApi.swift */, 4FD4E1BB237DCAA3008816B3 /* UserApi.swift */, 4F8F2C0B2383DF1E00DC4907 /* RaceApi.swift */, + 4F2B7E862E86309300643697 /* SeriesApi.swift */, 4F8668F923868359005E310A /* ChapterApi.swift */, 4F004E9B295A3027009C46AA /* SeasonApi.swift */, 4F004E9D295A3066009C46AA /* CourseApi.swift */, @@ -989,6 +1006,8 @@ 4FA26828237A4CCE008970AC /* Utils */ = { isa = PBXGroup; children = ( + 4FC05C142E64FFB700EB97A4 /* DeepLink.swift */, + 4FC05C162E65004400EB97A4 /* DeepLinkURLHandler.swift */, 4F8B8664295C112B00EAF695 /* EnumTitle.swift */, 4F00E077242963F1001DCFC4 /* Joinable.swift */, 4F8668F723864389005E310A /* Descriptable.swift */, @@ -1001,6 +1020,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 +1039,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 */, @@ -1106,6 +1128,7 @@ 4FD53FB223A0E9B700158206 /* AvatarImageView.swift */, 4F8668FB23870E1E005E310A /* SegmentedTableViewHeaderView.swift */, 4F65033D2E467EFB00FA8AED /* ColumnTableViewHeaderView.swift */, + 4F0F2F482E888100006788A7 /* SliderTableViewHeaderView.swift */, 4FD4E1DA237FBE36008816B3 /* JoinButton.swift */, 4FA26834237A80B9008970AC /* ActionButton.swift */, 4FD4E1C2237F960A008816B3 /* CustomButton.swift */, @@ -1198,7 +1221,7 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( - 4F13427F2360DA4C00A9DBDE /* RaceSyncAPI.h in Headers */, + 4FA706072EC33873006EAE70 /* RaceSyncAPI.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1211,7 +1234,6 @@ buildPhases = ( 9096BCDFA897EAE64E1192AF /* [CP] Check Pods Manifest.lock */, 4F1342872360DA4C00A9DBDE /* Embed Frameworks */, - 4F95B7682596968200631A27 /* Embed Watch Content */, 4F1342402360B67D00A9DBDE /* Sources */, 4F1342412360B67D00A9DBDE /* Frameworks */, 4F1342422360B67D00A9DBDE /* Resources */, @@ -1481,7 +1503,6 @@ 4F00E072242932D7001DCFC4 /* RateMe.swift in Sources */, 4F71B32B2E4910100029D3CB /* Collection+Extensions.swift in Sources */, 4FD4E1D1237F9DC8008816B3 /* SearchViewController.swift in Sources */, - 4FC05C152E64FFB800EB97A4 /* DeepLink.swift in Sources */, 4F5488962D2146C80056EA59 /* RaceScheduleViewController.swift in Sources */, 4F8668F423860900005E310A /* UniversalConstants.swift in Sources */, 4FF16CC02E70EC9300B4FC51 /* ViewJoinableRegistry.swift in Sources */, @@ -1491,6 +1512,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,6 +1544,7 @@ 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 */, 4F714DE323CD5EAA000C4036 /* SafariActivity.swift in Sources */, @@ -1540,6 +1563,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 */, @@ -1581,8 +1605,8 @@ 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 */, @@ -1599,6 +1623,7 @@ 4F5488982D2236950056EA59 /* String+HTML.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 +1655,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 +1667,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 +1684,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 +1701,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 */, @@ -1702,6 +1733,8 @@ buildActionMask = 2147483647; files = ( 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,7 +1909,7 @@ CODE_SIGN_ENTITLEMENTS = RaceSync/RaceSync.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 84; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = TJ4PB66YQS; GCC_WARN_INHIBIT_ALL_WARNINGS = NO; @@ -1887,7 +1920,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.8; + MARKETING_VERSION = 1.9; PRODUCT_BUNDLE_IDENTIFIER = com.multigp.RaceSyncApp; PRODUCT_NAME = RaceSync; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1913,7 +1946,7 @@ CODE_SIGN_ENTITLEMENTS = RaceSync/RaceSync.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 84; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = TJ4PB66YQS; GCC_WARN_INHIBIT_ALL_WARNINGS = NO; @@ -1924,7 +1957,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.8; + MARKETING_VERSION = 1.9; PRODUCT_BUNDLE_IDENTIFIER = com.multigp.RaceSyncApp; PRODUCT_NAME = RaceSync; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1946,13 +1979,14 @@ 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; INFOPLIST_FILE = RaceSyncAPI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -1964,9 +1998,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,7 +2023,8 @@ 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; @@ -2005,9 +2042,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_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..943a21a4 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") @@ -30,8 +55,29 @@ enum ButtonImg { 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 { diff --git a/RaceSync/Tools/ApplicationControl.swift b/RaceSync/Tools/ApplicationControl.swift index 2073aab4..ffab3819 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) }) } } @@ -158,27 +158,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..1c9cbb52 100644 --- a/RaceSync/UI Components/Profile Header/ProfileHeaderView.swift +++ b/RaceSync/UI Components/Profile Header/ProfileHeaderView.swift @@ -31,8 +31,6 @@ class ProfileHeaderView: UIView { var isEditable: Bool = false { didSet { cameraButton.isHidden = !isEditable - avatarView.isUserInteractionEnabled = isEditable - backgroundView.isUserInteractionEnabled = isEditable } } @@ -40,9 +38,9 @@ class ProfileHeaderView: UIView { lazy var locationButton: PasteboardButton = { let button = PasteboardButton(type: .system) - button.tintColor = Color.red + button.tintColor = Color.link button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .regular) - button.setImage(UIImage(named: "icn_pin_small"), for: .normal) + button.setImage(ButtonImg.pin_small, for: .normal) button.titleEdgeInsets = UIEdgeInsets(top: -1, left: 0, bottom: 0, right: -Constants.padding) button.shouldHighlight = true return button @@ -50,7 +48,7 @@ class ProfileHeaderView: UIView { 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 +78,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 +96,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 +118,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,7 +127,7 @@ 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 @@ -152,7 +150,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 +184,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 +233,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 +254,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 +262,17 @@ 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 } if viewModel.topBadgeLabel != nil { 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/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/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 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/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..e829f43f 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 { @@ -55,6 +55,6 @@ class MultiGPActivity: SafariActivity { } override var activityImage: UIImage? { - return UIImage(named: "icn_activity_mgp") + return LogoImg.activity_mgp } } 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/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/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..df121c8b 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() + }() + 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() @@ -224,7 +224,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..24fffdb7 100644 --- a/RaceSync/View Controllers/Profiles/ChapterViewController.swift +++ b/RaceSync/View Controllers/Profiles/ChapterViewController.swift @@ -322,7 +322,7 @@ extension ChapterViewController: UITableViewDataSource { } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return UniversalConstants.cellHeight + return RaceTableViewCell.height } func raceTableViewCell(for indexPath: IndexPath) -> RaceTableViewCell { @@ -357,7 +357,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..5d409b48 100644 --- a/RaceSync/View Controllers/Profiles/UserViewController.swift +++ b/RaceSync/View Controllers/Profiles/UserViewController.swift @@ -21,7 +21,7 @@ class UserViewController: ProfileViewController, ViewJoinable { 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 }() @@ -56,7 +56,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) @@ -308,7 +307,7 @@ extension UserViewController: UITableViewDataSource { } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return Constants.cellHeight + return UserRaceTableViewCell.height } func userRaceTableViewCell(for indexPath: IndexPath) -> UserRaceTableViewCell { 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..58b551b0 100644 --- a/RaceSync/View Controllers/Races/RaceController.swift +++ b/RaceSync/View Controllers/Races/RaceController.swift @@ -22,14 +22,13 @@ class RaceController { // MARK: - Private - fileprivate let ignoreFinalizingError: Bool = true // The API finalize(id) still returns 500 error. Reported https://github.com/MultiGP/multigp-com/issues/93 - 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 @@ -157,16 +156,19 @@ class RaceController { @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 + ActionSheetUtil.presentActionSheet( + withTitle: "Save the race details to your calendar?", + buttonTitle: "Save to Calendar", completion: { (action) in CalendarUtil.add(event) }) } @objc public func didPressShareButton() { 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 { @@ -188,7 +190,8 @@ class RaceController { @objc fileprivate func didPressZippyQButton() { 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) @@ -197,7 +200,7 @@ class RaceController { // 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 +236,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 +244,17 @@ class RaceController { } @objc private func raceActionTapped(_ sender: UIButton) { - guard let option = RaceAction.allCases[safe: sender.tag] else { return } + guard let option = RaceAction(rawValue: sender.tag) else { return } switch option { - case .edit: didPressEditButton() - case .calendar: didPressCalendarButton() - case .share: didPressShareButton() - case .zippyQ: didPressZippyQButton() + case .edit: + didPressEditButton() + case .calendar: + didPressCalendarButton() + case .share: + didPressShareButton() + case .zippyQ: + didPressZippyQButton() } } @@ -271,9 +278,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 +296,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 +306,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 +382,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) @@ -404,8 +412,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..ad30f5f2 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 }() @@ -102,43 +102,55 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { fileprivate lazy var locationButton: PasteboardButton = { let button = PasteboardButton(type: .system) - button.tintColor = Color.red + button.tintColor = Color.link 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) return button }() - fileprivate lazy var startDateButton: PasteboardButton = { + fileprivate lazy var date1Button: 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 button.addTarget(self, action: #selector(didPressDateButton), for: .touchUpInside) return button }() - fileprivate lazy var endDateButton: PasteboardButton = { + fileprivate lazy var date2Button: 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 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 locationIconView: UIImageView = { + let view = UIImageView() + view.image = ButtonImg.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 +206,33 @@ 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 + + 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 }() fileprivate var raceCoordinates: CLLocationCoordinate2D? { @@ -221,7 +253,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 +355,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 +400,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 +460,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 +494,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 @@ -648,12 +715,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 +844,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 +865,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/RaceFeedController.swift b/RaceSync/View Controllers/Races/RaceFeedController.swift index 2f7cec58..964024e6 100644 --- a/RaceSync/View Controllers/Races/RaceFeedController.swift +++ b/RaceSync/View Controllers/Races/RaceFeedController.swift @@ -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..35100f24 100644 --- a/RaceSync/View Controllers/Races/RaceFeedViewController.swift +++ b/RaceSync/View Controllers/Races/RaceFeedViewController.swift @@ -235,7 +235,7 @@ class RaceFeedViewController: UIViewController, ViewJoinable, Shimmable { // MARK: - Actions @objc fileprivate func didChangeSegment() { - // Cancelling previous race API requests to avoid overlaps + // Cancelling previous API requests to avoid overlaps raceApi.cancelAll() // This should be triggered just once, when first requesting access to the user's location @@ -389,7 +389,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/RaceListViewController.swift b/RaceSync/View Controllers/Races/RaceListViewController.swift index 509dce3e..feb825c8 100644 --- a/RaceSync/View Controllers/Races/RaceListViewController.swift +++ b/RaceSync/View Controllers/Races/RaceListViewController.swift @@ -32,6 +32,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 +47,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 +65,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 +72,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 +105,8 @@ class RaceListViewController: UIViewController, ViewJoinable { fileprivate func setupLayout() { + configureNavigationItems() + view.addSubview(tableView) tableView.snp.makeConstraints { $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) @@ -109,6 +115,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 +173,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 +185,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,8 +202,8 @@ 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 @@ -196,7 +213,6 @@ extension RaceListViewController: UITableViewDataSource { 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..691dcde1 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 } @@ -151,6 +161,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 +189,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 +212,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 +221,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 +229,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) } @@ -317,7 +289,7 @@ extension RacePilotsViewController: UITableViewDataSource { if showingExternalResults(), indexPath.section == externalResultSection { return UniversalConstants.cellFormHeight } else { - return UniversalConstants.cellHeight + return Constants.cellHeight } } @@ -344,6 +316,52 @@ 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.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 = (indexPath.row % 2 == 0) ? Color.white : Color.gray20 + cell.selectedBackgroundView?.backgroundColor = Color.gray50 + + 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: UIScrollViewDelegate { diff --git a/RaceSync/View Controllers/Races/RaceTabBarController.swift b/RaceSync/View Controllers/Races/RaceTabBarController.swift index 7af8aa10..b735b28e 100644 --- a/RaceSync/View Controllers/Races/RaceTabBarController.swift +++ b/RaceSync/View Controllers/Races/RaceTabBarController.swift @@ -13,7 +13,6 @@ import RaceSyncAPI enum RaceTabs: Int { case details, results, schedule - static let `default`: Self = .details } 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..e041fe7d 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,125 @@ 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 +234,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/StandingsViewController.swift b/RaceSync/View Controllers/Standings/StandingsViewController.swift index baf939c2..dc0913fa 100644 --- a/RaceSync/View Controllers/Standings/StandingsViewController.swift +++ b/RaceSync/View Controllers/Standings/StandingsViewController.swift @@ -22,8 +22,8 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { tableView.dataSource = self tableView.delegate = self tableView.emptyDataSetSource = self - tableView.tableFooterView = UIView() tableView.register(cellType: AvatarTableViewCell.self) + tableView.tableFooterView = UIView() tableView.keyboardDismissMode = .onDrag tableView.verticalScrollIndicatorInsets = UIEdgeInsets(top: -1, left: 0, bottom: 0, right: 0) tableView.refreshControl = self.refreshControl @@ -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,10 +88,6 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { return view }() - var shimmeringView: ShimmeringView = defaultShimmeringView() - - // MARK: - Private Variables - fileprivate let standingApi = StandingApi() fileprivate let userApi = UserApi() @@ -189,7 +189,6 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { // MARK: - Data Update fileprivate func loadContent() { - if !refreshControl.isRefreshing { isLoadingList(true) } @@ -217,10 +216,6 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { return viewModels[indexPath.row] } - @objc fileprivate func didPullRefreshControl() { - loadContent() - } - // MARK: - Search fileprivate func enableSearchBar(_ enable: Bool) { @@ -287,36 +282,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) { @@ -364,25 +329,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 + 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,6 +349,26 @@ 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 { return nil @@ -426,6 +401,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 { 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..20f3566a 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" @@ -74,13 +74,13 @@ 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") + self.topBadgeImage = ButtonImg.badge_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 +88,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 +96,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/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/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..e94cf684 100644 --- a/RaceSyncAPI/Constants/APIConstants.swift +++ b/RaceSyncAPI/Constants/APIConstants.swift @@ -41,6 +41,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" @@ -123,10 +126,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 +148,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 +193,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..a0abec6b 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,53 @@ 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" - - 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 +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 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() + + // 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 - var baseUrl = constant.rawValue - if APIServices.shared.settings.isDev { - baseUrl = constant.rawValue.replacingOccurrences(of: "www", with: "dev") + 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/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/Extensions/Race+Extensions.swift b/RaceSyncAPI/Models/Extensions/Race+Extensions.swift index a767a363..6801e356 100644 --- a/RaceSyncAPI/Models/Extensions/Race+Extensions.swift +++ b/RaceSyncAPI/Models/Extensions/Race+Extensions.swift @@ -39,6 +39,9 @@ public extension Race { } var canBeFinalized: Bool { + // The API finalize(id) still returns 500 error. Reported https://github.com/MultiGP/multigp-com/issues/93 + return false + guard isMyChapter else { return false } guard ownerId == APIServices.shared.myUser?.id else { return false } guard let startDate = startDate, startDate.isPassed else { return false } @@ -81,17 +84,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/Network/RaceApi.swift b/RaceSyncAPI/Network/RaceApi.swift index 540ced2a..af844d8c 100644 --- a/RaceSyncAPI/Network/RaceApi.swift +++ b/RaceSyncAPI/Network/RaceApi.swift @@ -305,9 +305,9 @@ public class RaceApi: RaceApiInterface { } } -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 +344,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/SeriesApi.swift b/RaceSyncAPI/Network/SeriesApi.swift new file mode 100644 index 00000000..f13893e0 --- /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 + var 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..3322d3bf 100644 --- a/RaceSyncAPI/Network/StandingApi.swift +++ b/RaceSyncAPI/Network/StandingApi.swift @@ -33,7 +33,7 @@ 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", @@ -54,9 +54,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 +75,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 +92,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,8 +113,8 @@ 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) let params: [(String, String)] = [ ("season1", "\(season.rawValue)Summer"), @@ -122,7 +122,7 @@ fileprivate extension StandingApi { ("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/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/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/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/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..b3bf5876 --- /dev/null +++ b/RaceSyncAPITests/MGPWebTests.swift @@ -0,0 +1,73 @@ +// +// 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 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/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