diff --git a/Documentation/Github/racesync_readme_screenshots_v2.1.png b/Documentation/Github/racesync_readme_screenshots_v2.1.png new file mode 100644 index 00000000..42822b3f Binary files /dev/null and b/Documentation/Github/racesync_readme_screenshots_v2.1.png differ diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index ee0e5924..48cead84 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -3,6 +3,18 @@ --- +## 2.1.3 + +### New Features: + * Displaying the pilot limit of a race, if available, on the race detail view + * Allowing to update the pilot limit from within the app (race organizers only) + +### Fixes and Enhancements: + * Optimized network calls to make the UI feel faster. + * General bug fixes. + +--- + ## 2.1 ### 2026 International Open's interactive schedule: diff --git a/RaceSync.xcodeproj/project.pbxproj b/RaceSync.xcodeproj/project.pbxproj index a958736f..bfeb82e2 100644 --- a/RaceSync.xcodeproj/project.pbxproj +++ b/RaceSync.xcodeproj/project.pbxproj @@ -19,7 +19,6 @@ 4F00E072242932D7001DCFC4 /* RateMe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F00E071242932D7001DCFC4 /* RateMe.swift */; }; 4F00E075242932F2001DCFC4 /* RateMeDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F00E073242932F2001DCFC4 /* RateMeDefaults.swift */; }; 4F00E076242932F2001DCFC4 /* RateMeConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F00E074242932F2001DCFC4 /* RateMeConstants.swift */; }; - 4F00E078242963F1001DCFC4 /* Joinable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F00E077242963F1001DCFC4 /* Joinable.swift */; }; 4F03693626D457CB00E30821 /* AppIconViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F03693526D457CB00E30821 /* AppIconViewController.swift */; }; 4F03693D26D457DE00E30821 /* AppIconManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F03693C26D457DE00E30821 /* AppIconManager.swift */; }; 4F0B613C238528C300930D91 /* PasteboardLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0B613B238528C300930D91 /* PasteboardLabel.swift */; }; @@ -127,6 +126,7 @@ 4F791C9823A6105700F15FD7 /* TextPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F791C9723A6105700F15FD7 /* TextPill.swift */; }; 4F791C9A23A66BA900F15FD7 /* NSAttributedString+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F791C9923A66BA900F15FD7 /* NSAttributedString+Extensions.swift */; }; 4F7974B524A4A4D4003D623A /* ImageEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7974B424A4A4D4003D623A /* ImageEnums.swift */; }; + 4F7B9A012F2E4C0A00ABCDEF /* ZippyqApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7B9A022F2E4C0A00ABCDEF /* ZippyqApi.swift */; }; 4F7BE2672722354F00592297 /* UIViewController+Navigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7BE2662722354F00592297 /* UIViewController+Navigation.swift */; }; 4F7C8E3724D8AE16008FE125 /* ProfileAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7C8E3624D8AE15008FE125 /* ProfileAvatarView.swift */; }; 4F80A655296F9951008B19AF /* RichEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F80A646296F9950008B19AF /* RichEditorView.swift */; }; @@ -168,7 +168,7 @@ 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 */; }; - 4F95A3282F7E1FA9004AE24F /* StandingSeasonEnum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F95A3272F7E1FA9004AE24F /* StandingSeasonEnum.swift */; }; + 4F95A3282F7E1FA9004AE24F /* StandingSeasonEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F95A3272F7E1FA9004AE24F /* StandingSeasonEnums.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 */; }; 4F9A886B297473AF00B52092 /* Parameters+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F9A886A297473AF00B52092 /* Parameters+Extensions.swift */; }; @@ -269,6 +269,16 @@ 4FE1DADF2DEB9EEF009143C4 /* StandingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE1DADE2DEB9EEF009143C4 /* StandingViewModel.swift */; }; 4FE272362580A36D00EC121B /* SimpleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE272352580A36D00EC121B /* SimpleTableViewCell.swift */; }; 4FE4C0A22945E0EF00F47F0A /* RaceListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE4C0A12945E0EF00F47F0A /* RaceListViewController.swift */; }; + 4FE645133018072F008CBD26 /* ZippyQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE645123018072D008CBD26 /* ZippyQueue.swift */; }; + 4FE64516301820B7008CBD26 /* ZippyqViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE64515301820B7008CBD26 /* ZippyqViewController.swift */; }; + 4FE645183019CCEA008CBD26 /* ZippyqEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE645173019CCEA008CBD26 /* ZippyqEnums.swift */; }; + 4FE64519301A501D008CBD26 /* CollapsableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE6451C301B1001008CBD26 /* CollapsableHeaderView.swift */; }; + 4FE6451B3019F001008CBD26 /* Frequency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE6451A3019F001008CBD26 /* Frequency.swift */; }; + 4FE6451F301B9001008CBD26 /* ZippyqRoundViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE6451E301B9001008CBD26 /* ZippyqRoundViewModel.swift */; }; + 4FE64521301B9001008CBD26 /* ZippyqFrequencyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE64520301B9001008CBD26 /* ZippyqFrequencyViewModel.swift */; }; + 4FE64523301C1001008CBD26 /* ZippyqFrequencyTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE64522301C1001008CBD26 /* ZippyqFrequencyTableViewCell.swift */; }; + 4FE64525301C2001008CBD26 /* FrequencyColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE64524301C2001008CBD26 /* FrequencyColor.swift */; }; + 4FE64527301C3001008CBD26 /* FrequencyActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE64526301C3001008CBD26 /* FrequencyActionButton.swift */; }; 4FE91EE12FB670F000460A74 /* UIBarButtonItem+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EE02FB670F000460A74 /* UIBarButtonItem+Extensions.swift */; }; 4FE91EE32FBC098900460A74 /* EventApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EE22FBC098900460A74 /* EventApi.swift */; }; 4FE91EE52FBC09C800460A74 /* Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EE42FBC09C800460A74 /* Event.swift */; }; @@ -356,7 +366,6 @@ 4F00E071242932D7001DCFC4 /* RateMe.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RateMe.swift; sourceTree = ""; }; 4F00E073242932F2001DCFC4 /* RateMeDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RateMeDefaults.swift; sourceTree = ""; }; 4F00E074242932F2001DCFC4 /* RateMeConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RateMeConstants.swift; sourceTree = ""; }; - 4F00E077242963F1001DCFC4 /* Joinable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Joinable.swift; sourceTree = ""; }; 4F03693526D457CB00E30821 /* AppIconViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconViewController.swift; sourceTree = ""; }; 4F03693C26D457DE00E30821 /* AppIconManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconManager.swift; sourceTree = ""; }; 4F0B613B238528C300930D91 /* PasteboardLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteboardLabel.swift; sourceTree = ""; }; @@ -467,6 +476,7 @@ 4F791C9723A6105700F15FD7 /* TextPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextPill.swift; sourceTree = ""; }; 4F791C9923A66BA900F15FD7 /* NSAttributedString+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Extensions.swift"; sourceTree = ""; }; 4F7974B424A4A4D4003D623A /* ImageEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageEnums.swift; sourceTree = ""; }; + 4F7B9A022F2E4C0A00ABCDEF /* ZippyqApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZippyqApi.swift; sourceTree = ""; }; 4F7BE2662722354F00592297 /* UIViewController+Navigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Navigation.swift"; sourceTree = ""; }; 4F7C8E3624D8AE15008FE125 /* ProfileAvatarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileAvatarView.swift; sourceTree = ""; }; 4F80A646296F9950008B19AF /* RichEditorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RichEditorView.swift; sourceTree = ""; }; @@ -509,7 +519,7 @@ 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 = ""; }; - 4F95A3272F7E1FA9004AE24F /* StandingSeasonEnum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandingSeasonEnum.swift; sourceTree = ""; }; + 4F95A3272F7E1FA9004AE24F /* StandingSeasonEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandingSeasonEnums.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 = ""; }; 4F9A886A297473AF00B52092 /* Parameters+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Parameters+Extensions.swift"; sourceTree = ""; }; @@ -613,6 +623,16 @@ 4FE1DADE2DEB9EEF009143C4 /* StandingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandingViewModel.swift; sourceTree = ""; }; 4FE272352580A36D00EC121B /* SimpleTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SimpleTableViewCell.swift; sourceTree = ""; }; 4FE4C0A12945E0EF00F47F0A /* RaceListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RaceListViewController.swift; sourceTree = ""; }; + 4FE645123018072D008CBD26 /* ZippyQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZippyQueue.swift; sourceTree = ""; }; + 4FE64515301820B7008CBD26 /* ZippyqViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZippyqViewController.swift; sourceTree = ""; }; + 4FE645173019CCEA008CBD26 /* ZippyqEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZippyqEnums.swift; sourceTree = ""; }; + 4FE6451A3019F001008CBD26 /* Frequency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Frequency.swift; sourceTree = ""; }; + 4FE6451C301B1001008CBD26 /* CollapsableHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsableHeaderView.swift; sourceTree = ""; }; + 4FE6451E301B9001008CBD26 /* ZippyqRoundViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZippyqRoundViewModel.swift; sourceTree = ""; }; + 4FE64520301B9001008CBD26 /* ZippyqFrequencyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZippyqFrequencyViewModel.swift; sourceTree = ""; }; + 4FE64522301C1001008CBD26 /* ZippyqFrequencyTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZippyqFrequencyTableViewCell.swift; sourceTree = ""; }; + 4FE64524301C2001008CBD26 /* FrequencyColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrequencyColor.swift; sourceTree = ""; }; + 4FE64526301C3001008CBD26 /* FrequencyActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrequencyActionButton.swift; sourceTree = ""; }; 4FE91EE02FB670F000460A74 /* UIBarButtonItem+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBarButtonItem+Extensions.swift"; sourceTree = ""; }; 4FE91EE22FBC098900460A74 /* EventApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventApi.swift; sourceTree = ""; }; 4FE91EE42FBC09C800460A74 /* Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Event.swift; sourceTree = ""; }; @@ -916,6 +936,7 @@ children = ( 4FC57C32258E1362006E210F /* AppUtil.swift */, 4F6C8A97236A9E840017FD78 /* Color.swift */, + 4FE64524301C2001008CBD26 /* FrequencyColor.swift */, 4FD4E1DE237FEC0A008816B3 /* Appearance.swift */, 4F91BC892398FBCE00539E95 /* ViewJoinable.swift */, 4FF16CBF2E70EC9300B4FC51 /* ViewJoinableRegistry.swift */, @@ -960,7 +981,8 @@ 4FDD499723B0470E009DD2DB /* ChapterEnums.swift */, 4F7974B424A4A4D4003D623A /* ImageEnums.swift */, 4F5614A32DDC4C2800EAD42A /* MessageEnums.swift */, - 4F95A3272F7E1FA9004AE24F /* StandingSeasonEnum.swift */, + 4F95A3272F7E1FA9004AE24F /* StandingSeasonEnums.swift */, + 4FE645173019CCEA008CBD26 /* ZippyqEnums.swift */, ); path = Enums; sourceTree = ""; @@ -1029,6 +1051,8 @@ 4F6A28E62D15046900FF692D /* ResultEntryViewModel.swift */, 4F2B7E882E8635D200643697 /* SeriesViewModel.swift */, 4F21086A2F90142B007C7716 /* SeriesResultViewModel.swift */, + 4FE6451E301B9001008CBD26 /* ZippyqRoundViewModel.swift */, + 4FE64520301B9001008CBD26 /* ZippyqFrequencyViewModel.swift */, 4F9DB7AB23D5A1DE00570483 /* EmptyStateViewModel.swift */, 4F0B61422385EEFF00930D91 /* ViewModelHelper.swift */, ); @@ -1069,6 +1093,7 @@ 4F004E9D295A3066009C46AA /* CourseApi.swift */, 4FE1DAD72DEAD443009143C4 /* StandingApi.swift */, 4FE91EE22FBC098900460A74 /* EventApi.swift */, + 4F7B9A022F2E4C0A00ABCDEF /* ZippyqApi.swift */, 4F5C1E3F238A7BAF00D756EB /* RepositoryAdapter.swift */, 4FA268232378F9DD008970AC /* NetworkAdapter.swift */, 4F87E4342727976E0061425B /* NetworkProxy.swift */, @@ -1094,7 +1119,6 @@ 4FC05C142E64FFB700EB97A4 /* DeepLink.swift */, 4FC05C162E65004400EB97A4 /* DeepLinkURLHandler.swift */, 4F8B8664295C112B00EAF695 /* EnumTitle.swift */, - 4F00E077242963F1001DCFC4 /* Joinable.swift */, 4F8668F723864389005E310A /* Descriptable.swift */, 4FEADB7124147D2D00F82F0D /* LocationManager.swift */, 4FA26829237A4CE9008970AC /* CompletionBlock.swift */, @@ -1133,6 +1157,8 @@ 4FDD499523B0461D009DD2DB /* Season.swift */, 4F004E99295A2CA1009C46AA /* Course.swift */, 4FE1DAD52DEAD225009143C4 /* Standing.swift */, + 4FE645123018072D008CBD26 /* ZippyQueue.swift */, + 4FE6451A3019F001008CBD26 /* Frequency.swift */, 4FE91EE42FBC09C800460A74 /* Event.swift */, 4FA26830237A58E1008970AC /* ApiError.swift */, 4F77D21A29940E5D009DEB41 /* Extensions */, @@ -1191,6 +1217,7 @@ 4F8175A42411DDB900685F83 /* HomeController.swift */, 4FE1DADC2DEB6F6D009143C4 /* HomeTabBarController.swift */, 4FD4E1D3237F9DF8008816B3 /* Races */, + 4FE645143018209D008CBD26 /* ZippyQ */, 4F521F802E6E18BD00AE7C03 /* Series */, 4FE1DAD92DEB6AE4009143C4 /* Standings */, 4FA7FDA02F9E81FC00D33266 /* Events */, @@ -1211,6 +1238,7 @@ 4FE272352580A36D00EC121B /* SimpleTableViewCell.swift */, 4F536ED62F8C585F006FF620 /* SimpleTableViewCell+Configuration.swift */, 4FD53FAE23A0B20900158206 /* AvatarTableViewCell.swift */, + 4FE64522301C1001008CBD26 /* ZippyqFrequencyTableViewCell.swift */, 4FD4E1D8237FB1EA008816B3 /* RaceTableViewCell.swift */, 4FD53FB023A0D48F00158206 /* UserRaceTableViewCell.swift */, 4F86690323877041005E310A /* ChapterTableViewCell.swift */, @@ -1231,12 +1259,14 @@ 4F9DB7B123D68EB100570483 /* NavigationController.swift */, 4F64F67F257B97E000574F4D /* WebViewController.swift */, 4FD53FB223A0E9B700158206 /* AvatarImageView.swift */, + 4FE6451C301B1001008CBD26 /* CollapsableHeaderView.swift */, 4F8668FB23870E1E005E310A /* SegmentedTableViewHeaderView.swift */, 4F65033D2E467EFB00FA8AED /* ColumnTableViewHeaderView.swift */, 4F0F2F482E888100006788A7 /* SliderTableViewHeaderView.swift */, 4FD4E1DA237FBE36008816B3 /* JoinButton.swift */, 4F536ED02F8AEB35006FF620 /* ApproveButton.swift */, 4FA26834237A80B9008970AC /* ActionButton.swift */, + 4FE64526301C3001008CBD26 /* FrequencyActionButton.swift */, 4FD4E1C2237F960A008816B3 /* CustomButton.swift */, 4F8B4A0F2DDCEECD00B735DA /* BadgeHub.swift */, 4F791C9723A6105700F15FD7 /* TextPill.swift */, @@ -1283,6 +1313,14 @@ path = Standings; sourceTree = ""; }; + 4FE645143018209D008CBD26 /* ZippyQ */ = { + isa = PBXGroup; + children = ( + 4FE64515301820B7008CBD26 /* ZippyqViewController.swift */, + ); + path = ZippyQ; + sourceTree = ""; + }; 4FEBB27B23A30F7D007514B4 /* Extensions */ = { isa = PBXGroup; children = ( @@ -1523,15 +1561,19 @@ inputFileListPaths = ( ); inputPaths = ( + "${SRCROOT}/Scripts/copy-credentials.sh", + "${SRCROOT}/../credentials/credentials.plist", + "${SRCROOT}/../credentials/credentials-debug.plist", ); name = "[MGP] Copy Credentials plist to framework bundle"; outputFileListPaths = ( ); outputPaths = ( + "${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/credentials.plist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "#!/bin/bash\nset -euo pipefail\n\nFILENAME=\"credentials.plist\"\nDEBUG_FILENAME=\"credentials-debug.plist\"\n\n# Pick source file by configuration\nif [[ \"${CONFIGURATION}\" == \"Debug\" ]]; then\n SRC_NAME=\"${DEBUG_FILENAME}\"\nelse\n SRC_NAME=\"${FILENAME}\"\nfi\n\n# Search upwards from SRCROOT for a credentials/ folder\nSEARCH_DIR=\"${SRCROOT}\"\nwhile [[ \"${SEARCH_DIR}\" != \"/\" ]]; do\n if [[ -d \"${SEARCH_DIR}/credentials\" ]]; then\n SRC_PATH=\"${SEARCH_DIR}/credentials/${SRC_NAME}\"\n break\n fi\n SEARCH_DIR=\"$(dirname \"${SEARCH_DIR}\")\"\ndone\n\nif [[ -z \"${SRC_PATH:-}\" ]]; then\n echo \"ERROR: Could not find credentials/ folder above SRCROOT=${SRCROOT}\" >&2\n exit 1\nfi\n\nif [[ ! -f \"${SRC_PATH}\" ]]; then\n echo \"ERROR: Source file not found: ${SRC_PATH}\" >&2\n exit 1\nfi\n\n# Destination is always the app bundle — no framework hunting needed\nDEST_PATH=\"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/${FILENAME}\"\n\necho \"Copying: ${SRC_PATH} → ${DEST_PATH}\"\ncp -fv \"${SRC_PATH}\" \"${DEST_PATH}\"\necho \"Done.\"\n"; + shellScript = "/bin/bash \"${SRCROOT}/Scripts/copy-credentials.sh\"\n"; }; 7F2DC3FC3A2F8DECDD85A749 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -1637,6 +1679,11 @@ 4F536ED12F8AEB35006FF620 /* ApproveButton.swift in Sources */, 4F86690223876E71005E310A /* ChapterViewModel.swift in Sources */, 4F8F2C112384903F00DC4907 /* RaceViewModel.swift in Sources */, + 4FE6451F301B9001008CBD26 /* ZippyqRoundViewModel.swift in Sources */, + 4FE64521301B9001008CBD26 /* ZippyqFrequencyViewModel.swift in Sources */, + 4FE64523301C1001008CBD26 /* ZippyqFrequencyTableViewCell.swift in Sources */, + 4FE64525301C2001008CBD26 /* FrequencyColor.swift in Sources */, + 4FE64527301C3001008CBD26 /* FrequencyActionButton.swift in Sources */, 4FF16CBE2E70EC7D00B4FC51 /* WeakRef.swift in Sources */, 4F791C9A23A66BA900F15FD7 /* NSAttributedString+Extensions.swift in Sources */, 4FD4E1C7237F97AD008816B3 /* ChapterViewController.swift in Sources */, @@ -1701,6 +1748,7 @@ 4FD4E1CB237F98A0008816B3 /* RaceDetailViewController.swift in Sources */, 4F0F8F802EFB42FC004EB3D3 /* StandingsController.swift in Sources */, 4FD4E1C1237DDFB4008816B3 /* RaceFeedViewController.swift in Sources */, + 4FE64519301A501D008CBD26 /* CollapsableHeaderView.swift in Sources */, 4FBFADEC2FCD69B70097AAC3 /* HapticEngine.swift in Sources */, 4FE272362580A36D00EC121B /* SimpleTableViewCell.swift in Sources */, 4FCD99B62976242A0094C562 /* RaceFeedMenuViewController.swift in Sources */, @@ -1783,6 +1831,7 @@ 4FA213F02946C9FA00C8E45A /* AppIcon.swift in Sources */, 4F8B4A102DDCEECD00B735DA /* BadgeHub.swift in Sources */, 4FD53FAD23A0B01E00158206 /* UserViewModel.swift in Sources */, + 4FE64516301820B7008CBD26 /* ZippyqViewController.swift in Sources */, 4FE91EEB2FC2957900460A74 /* EventSessionBucketlist.swift in Sources */, 4F03693626D457CB00E30821 /* AppIconViewController.swift in Sources */, 4F64B34D2D235EE300FC5BF1 /* UIView+Parallax.swift in Sources */, @@ -1824,8 +1873,10 @@ 4F6FA16C2E4C12550056E115 /* Bool+Extensions.swift in Sources */, 4F004E9C295A3027009C46AA /* SeasonApi.swift in Sources */, 4F6A28E52D15005D00FF692D /* RaceSchedule.swift in Sources */, + 4F7B9A012F2E4C0A00ABCDEF /* ZippyqApi.swift in Sources */, 4F81759C241192AF00685F83 /* Clog.swift in Sources */, 4FEBB27823A2023A007514B4 /* RaceEntry.swift in Sources */, + 4FE645183019CCEA008CBD26 /* ZippyqEnums.swift in Sources */, 4F87E4352727976E0061425B /* NetworkProxy.swift in Sources */, 4F0F2F412E871E23006788A7 /* HTMLLinkTransform.swift in Sources */, 4FD4E1E32381E175008816B3 /* Random.swift in Sources */, @@ -1847,12 +1898,14 @@ 4FC51B232409EEB400D654D0 /* Race+Extensions.swift in Sources */, 4F9DB7AE23D614CF00570483 /* APISettings.swift in Sources */, 4F0F2F442E87525D006788A7 /* DeepLink.swift in Sources */, + 4FE645133018072F008CBD26 /* ZippyQueue.swift in Sources */, + 4FE6451B3019F001008CBD26 /* Frequency.swift in Sources */, 4FA213FC29582D1B00C8E45A /* IntegerTransform.swift in Sources */, 4F7974B524A4A4D4003D623A /* ImageEnums.swift in Sources */, 4F9A886B297473AF00B52092 /* Parameters+Extensions.swift in Sources */, 4FC51B1D2409B3A700D654D0 /* APIUnitSystem.swift in Sources */, 4FA2682A237A4CE9008970AC /* CompletionBlock.swift in Sources */, - 4F95A3282F7E1FA9004AE24F /* StandingSeasonEnum.swift in Sources */, + 4F95A3282F7E1FA9004AE24F /* StandingSeasonEnums.swift in Sources */, 4FD4E1BE237DCB24008816B3 /* User.swift in Sources */, 4FE91EE32FBC098900460A74 /* EventApi.swift in Sources */, 4FA1FC9123D7B571006D4704 /* APIEnvironment.swift in Sources */, @@ -1865,7 +1918,6 @@ 4FE1DAD62DEAD225009143C4 /* Standing.swift in Sources */, 4F5C1E4D2390600400D756EB /* APISessionManager.swift in Sources */, 4FDD499623B0461D009DD2DB /* Season.swift in Sources */, - 4F00E078242963F1001DCFC4 /* Joinable.swift in Sources */, 4FC3F17725A7AB3B0039B94C /* String+Extensions.swift in Sources */, 4FD4E1BC237DCAA3008816B3 /* UserApi.swift in Sources */, 4F8668FA23868359005E310A /* ChapterApi.swift in Sources */, @@ -2058,10 +2110,11 @@ CLANG_ENABLE_MODULES = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CODE_SIGN_ENTITLEMENTS = RaceSync/RaceSync.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 120; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = TJ4PB66YQS; GCC_WARN_INHIBIT_ALL_WARNINGS = NO; INFOPLIST_FILE = RaceSync/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports"; @@ -2070,7 +2123,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.1.2; + MARKETING_VERSION = 2.2; PRODUCT_BUNDLE_IDENTIFIER = com.multigp.RaceSyncApp; PRODUCT_NAME = RaceSync; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2093,10 +2146,11 @@ CLANG_ENABLE_MODULES = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CODE_SIGN_ENTITLEMENTS = RaceSync/RaceSync.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 117; + CURRENT_PROJECT_VERSION = 120; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = TJ4PB66YQS; GCC_WARN_INHIBIT_ALL_WARNINGS = NO; INFOPLIST_FILE = RaceSync/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports"; @@ -2105,7 +2159,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.1.2; + MARKETING_VERSION = 2.2; PRODUCT_BUNDLE_IDENTIFIER = com.multigp.RaceSyncApp; PRODUCT_NAME = RaceSync; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/RaceSync.xcworkspace/contents.xcworkspacedata b/RaceSync.xcworkspace/contents.xcworkspacedata index f4510886..692ddb22 100644 --- a/RaceSync.xcworkspace/contents.xcworkspacedata +++ b/RaceSync.xcworkspace/contents.xcworkspacedata @@ -20,4 +20,11 @@ location = "group:README.md"> + + + + diff --git a/RaceSync/Assets.xcassets/launch_bkgd.imageset/Contents.json b/RaceSync/Assets.xcassets/launch_bkgd.imageset/Contents.json index 6676b246..5ffe08af 100644 --- a/RaceSync/Assets.xcassets/launch_bkgd.imageset/Contents.json +++ b/RaceSync/Assets.xcassets/launch_bkgd.imageset/Contents.json @@ -3,6 +3,15 @@ { "filename" : "launch_bkgd.jpg", "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal" } ], "info" : { diff --git a/RaceSync/Assets.xcassets/multigp_logo_splash.imageset/Contents.json b/RaceSync/Assets.xcassets/multigp_logo_splash.imageset/Contents.json index 9d667580..368f8470 100644 --- a/RaceSync/Assets.xcassets/multigp_logo_splash.imageset/Contents.json +++ b/RaceSync/Assets.xcassets/multigp_logo_splash.imageset/Contents.json @@ -3,6 +3,16 @@ { "filename" : "multigp_logo_splash.pdf", "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "multigp_logo_splash-dark.pdf", + "idiom" : "universal" } ], "info" : { diff --git a/RaceSync/Assets.xcassets/multigp_logo_splash.imageset/multigp_logo_splash-dark.pdf b/RaceSync/Assets.xcassets/multigp_logo_splash.imageset/multigp_logo_splash-dark.pdf new file mode 100644 index 00000000..387547c9 Binary files /dev/null and b/RaceSync/Assets.xcassets/multigp_logo_splash.imageset/multigp_logo_splash-dark.pdf differ diff --git a/RaceSync/Assets.xcassets/placeholder_shimmer_list.imageset/Contents.json b/RaceSync/Assets.xcassets/placeholder_shimmer_list.imageset/Contents.json index 7053c97a..e8d321e2 100644 --- a/RaceSync/Assets.xcassets/placeholder_shimmer_list.imageset/Contents.json +++ b/RaceSync/Assets.xcassets/placeholder_shimmer_list.imageset/Contents.json @@ -1,12 +1,22 @@ { "images" : [ { - "idiom" : "universal", - "filename" : "placeholder_shimmer_list.pdf" + "filename" : "placeholder_shimmer_list.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "placeholder_shimmer_list_dark.pdf", + "idiom" : "universal" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/RaceSync/Assets.xcassets/placeholder_shimmer_list.imageset/placeholder_shimmer_list_dark.pdf b/RaceSync/Assets.xcassets/placeholder_shimmer_list.imageset/placeholder_shimmer_list_dark.pdf new file mode 100644 index 00000000..4341f443 Binary files /dev/null and b/RaceSync/Assets.xcassets/placeholder_shimmer_list.imageset/placeholder_shimmer_list_dark.pdf differ diff --git a/RaceSync/Assets.xcassets/racesync_logo_header.imageset/Contents.json b/RaceSync/Assets.xcassets/racesync_logo_header.imageset/Contents.json index a0ea1ede..415003de 100644 --- a/RaceSync/Assets.xcassets/racesync_logo_header.imageset/Contents.json +++ b/RaceSync/Assets.xcassets/racesync_logo_header.imageset/Contents.json @@ -3,6 +3,16 @@ { "filename" : "racesync_logo_header.pdf", "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "racesync_logo_header-dark.pdf", + "idiom" : "universal" } ], "info" : { diff --git a/RaceSync/Assets.xcassets/racesync_logo_header.imageset/racesync_logo_header-dark.pdf b/RaceSync/Assets.xcassets/racesync_logo_header.imageset/racesync_logo_header-dark.pdf new file mode 100644 index 00000000..4c6fb1e8 Binary files /dev/null and b/RaceSync/Assets.xcassets/racesync_logo_header.imageset/racesync_logo_header-dark.pdf differ diff --git a/RaceSync/Assets.xcassets/racesync_logo_splash.imageset/Contents.json b/RaceSync/Assets.xcassets/racesync_logo_splash.imageset/Contents.json index 2c393269..0f3534fb 100644 --- a/RaceSync/Assets.xcassets/racesync_logo_splash.imageset/Contents.json +++ b/RaceSync/Assets.xcassets/racesync_logo_splash.imageset/Contents.json @@ -3,6 +3,16 @@ { "filename" : "racesync_logo_splash.pdf", "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "racesync_logo_splash-dark.pdf", + "idiom" : "universal" } ], "info" : { diff --git a/RaceSync/Assets.xcassets/racesync_logo_splash.imageset/racesync_logo_splash-dark.pdf b/RaceSync/Assets.xcassets/racesync_logo_splash.imageset/racesync_logo_splash-dark.pdf new file mode 100644 index 00000000..b886db9a Binary files /dev/null and b/RaceSync/Assets.xcassets/racesync_logo_splash.imageset/racesync_logo_splash-dark.pdf differ diff --git a/RaceSync/Assets.xcassets/racesync_logo_splash.imageset/racesync_logo_splash.pdf b/RaceSync/Assets.xcassets/racesync_logo_splash.imageset/racesync_logo_splash.pdf index 978005c1..3f3f30ab 100644 Binary files a/RaceSync/Assets.xcassets/racesync_logo_splash.imageset/racesync_logo_splash.pdf and b/RaceSync/Assets.xcassets/racesync_logo_splash.imageset/racesync_logo_splash.pdf differ diff --git a/RaceSync/Base.lproj/LaunchScreen.storyboard b/RaceSync/Base.lproj/LaunchScreen.storyboard index f0449667..85f98682 100644 --- a/RaceSync/Base.lproj/LaunchScreen.storyboard +++ b/RaceSync/Base.lproj/LaunchScreen.storyboard @@ -1,10 +1,11 @@ - - + + - + + @@ -16,36 +17,35 @@ - + - - + + - - + @@ -74,5 +74,8 @@ + + + diff --git a/RaceSync/Base.lproj/Main.storyboard b/RaceSync/Base.lproj/Main.storyboard index c1ffaf83..1fc7cb9f 100644 --- a/RaceSync/Base.lproj/Main.storyboard +++ b/RaceSync/Base.lproj/Main.storyboard @@ -1,10 +1,11 @@ - + - + + @@ -16,36 +17,34 @@ - + - - + + - - + @@ -81,5 +80,8 @@ + + + diff --git a/RaceSync/Constants/ImageConstants.swift b/RaceSync/Constants/ImageConstants.swift index d3ff9eb9..491926ec 100644 --- a/RaceSync/Constants/ImageConstants.swift +++ b/RaceSync/Constants/ImageConstants.swift @@ -67,14 +67,14 @@ enum ButtonImg { static let radius = UIImage(named: "icn_settings_radius") static let checkmark = UIImage(named: "icn_cell_checkmark") - static let cal_small = UIImage(named: "icn_calendar_small") + static let cal_small = UIImage(named: "icn_calendar_small")?.withRenderingMode(.alwaysTemplate) 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 trophy = UIImage(named: "icn_trophy_qualifier") + static let date_path2 = UIImage(named: "icn_date_path_progress")?.withRenderingMode(.alwaysTemplate) + static let date_path1 = UIImage(named: "icn_date_path_continuous")?.withRenderingMode(.alwaysTemplate) + static let trophy = UIImage(named: "icn_trophy_qualifier")?.withRenderingMode(.alwaysTemplate) } enum SystemImg { @@ -166,4 +166,21 @@ enum SystemImg { return UIImage(systemName:"") } } + + static var bulletList: UIImage? { + if #available(iOS 16.0, *) { + return UIImage(systemName:"list.bullet.clipboard") // iOS 16.0+ + } else { + return UIImage(systemName:"list.bullet") // iOS 13.0+ + } + } + + static var bulletListFill: UIImage? { + if #available(iOS 16.0, *) { + return UIImage(systemName:"list.bullet.clipboard.fill") // iOS 16.0+ + } else { + return UIImage(systemName:"list.bullet") // iOS 13.0+ + } + } + } diff --git a/RaceSync/RaceSyncAPI Extensions/Race+UIExtensions.swift b/RaceSync/RaceSyncAPI Extensions/Race+UIExtensions.swift index 418f39fc..9a4f8b73 100644 --- a/RaceSync/RaceSyncAPI Extensions/Race+UIExtensions.swift +++ b/RaceSync/RaceSyncAPI Extensions/Race+UIExtensions.swift @@ -39,8 +39,8 @@ extension Race { } } - var canShowSchedule: Bool { - guard hasStarted && !hasEnded else { return false } + var canShowZippyQ: Bool { +// guard hasStarted && !hasEnded else { return false } return isZippyQEnabled } diff --git a/RaceSync/Tools/AppplicationPreferences.swift b/RaceSync/Tools/AppplicationPreferences.swift index cad0095a..b94d861d 100644 --- a/RaceSync/Tools/AppplicationPreferences.swift +++ b/RaceSync/Tools/AppplicationPreferences.swift @@ -7,20 +7,42 @@ // import RaceSyncAPI +import UIKit typealias AppPrefs = AppplicationPreferences +enum AppAppearance: String, CaseIterable { + case system, light, dark + + var title: String { + switch self { + case .system: return "System" + case .light: return "Light" + case .dark: return "Dark" + } + } + + var userInterfaceStyle: UIUserInterfaceStyle { + switch self { + case .system: return .unspecified + case .light: return .light + case .dark: return .dark + } + } +} + class AppplicationPreferences { private enum LastSelected { - static let homeTab = "com.multigp.RaceSync.preferences.last_selected.home_tab" - static let raceFilter = "com.multigp.RaceSync.preferences.last_selected.races_filters" - static let seriesFilter = "com.multigp.RaceSync.preferences.last_selected.series_filters" - static let eventFilter = "com.multigp.RaceSync.preferences.last_selected.event_filters" + static let homeTab = "com.multigp.racesync.preferences.last_selected.home_tab" + static let raceFilter = "com.multigp.racesync.preferences.last_selected.races_filters" + static let seriesFilter = "com.multigp.racesync.preferences.last_selected.series_filters" + static let eventFilter = "com.multigp.racesync.preferences.last_selected.event_filters" } private enum Prefs { - static let schedulerAlerts = "com.multigp.RaceSync.preferences.hide_notification_scheduler_alerts" + static let schedulerAlerts = "com.multigp.racesync.preferences.hide_notification_scheduler_alerts" + static let appearance = "com.multigp.racesync.preferences.appearance" } static var lastSelectedHomeTab: HomeTabs { @@ -76,4 +98,14 @@ class AppplicationPreferences { UserDefaults.standard.synchronize() } } + + static var appearance: AppAppearance { + get { + UserDefaults.standard.string(forKey: Prefs.appearance) + .flatMap(AppAppearance.init(rawValue:)) ?? .system + } + set { + UserDefaults.standard.set(newValue.rawValue, forKey: Prefs.appearance) + } + } } diff --git a/RaceSync/Tools/RateMe/RateMeDefaults.swift b/RaceSync/Tools/RateMe/RateMeDefaults.swift index 6c0385fe..e2361b43 100755 --- a/RaceSync/Tools/RateMe/RateMeDefaults.swift +++ b/RaceSync/Tools/RateMe/RateMeDefaults.swift @@ -111,7 +111,7 @@ extension UserDefaults { } var keyValue: String { - return "com.multigp.RaceSync.rateme.\(self)" + return "com.multigp.racesync.rateme.\(self)" } static func synchronize() { diff --git a/RaceSync/UI Components/ActionButton.swift b/RaceSync/UI Components/ActionButton.swift index b90ceede..0d1dbc9c 100644 --- a/RaceSync/UI Components/ActionButton.swift +++ b/RaceSync/UI Components/ActionButton.swift @@ -80,7 +80,7 @@ class ActionButton: CustomButton { if showsShadow { layer.masksToBounds = false layer.shadowOffset = CGSize(width: 0, height: 1) - layer.shadowColor = Color.black.cgColor + layer.shadowColor = UIColor.black.cgColor layer.shadowRadius = 4.0 layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: layer.cornerRadius).cgPath layer.shadowOpacity = 0.1 diff --git a/RaceSync/UI Components/AvatarImageView.swift b/RaceSync/UI Components/AvatarImageView.swift index e52b4ea8..9f20db1a 100644 --- a/RaceSync/UI Components/AvatarImageView.swift +++ b/RaceSync/UI Components/AvatarImageView.swift @@ -62,9 +62,15 @@ class AvatarImageView: UIView { return CGSize(width: height, height: height) } + override func layoutSubviews() { + super.layoutSubviews() + + imageView.layer.cornerRadius = bounds.height / 2 + } + func updateShadow() { if showShadow { - layer.shadowColor = Color.black.cgColor + layer.shadowColor = UIColor.black.cgColor layer.shadowOffset = CGSize(width: 0, height: 1.0) layer.shadowOpacity = 0.25 layer.shadowRadius = 1.25 @@ -74,4 +80,3 @@ class AvatarImageView: UIView { } } } - diff --git a/RaceSync/UI Components/CollapsableHeaderView.swift b/RaceSync/UI Components/CollapsableHeaderView.swift new file mode 100644 index 00000000..cf69bb13 --- /dev/null +++ b/RaceSync/UI Components/CollapsableHeaderView.swift @@ -0,0 +1,198 @@ +// +// CollapsableHeaderView.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-07-29. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import UIKit +import SnapKit + +class CollapsableHeaderView: UITableViewHeaderFooterView { + + // MARK: - Public Variables + + static let identifier = "CollapsableHeaderView" + static let headerHeight: CGFloat = 58 + + var title: String? { + didSet { titleLabel.text = title } + } + + var contextualText: String? { + didSet { contextualLabel.text = contextualText } + } + + var isExpanded: Bool = false { + didSet { + chevronImageView.image = UIImage(systemName: isExpanded ? "chevron.up" : "chevron.down") + separatorView.isHidden = isExpanded + } + } + + var selectedBackgroundColor: UIColor? = Color.gray50 + var didTapView: (() -> Void)? + + lazy var textPill: TextPill = { + let pill = TextPill(style: .badge) + if #available(iOS 16.0, *) { + pill.titleLabel.font = UIFont.systemFont(ofSize: 13, weight: .semibold, width: .condensed) + } else { + pill.titleLabel.font = UIFont.systemFont(ofSize: 11, weight: .semibold) + } + pill.isHidden = true + return pill + }() + + // MARK: - Private Variables + + fileprivate lazy var titleLabel: UILabel = { + let label = UILabel() + if #available(iOS 16.0, *) { + label.font = UIFont.systemFont(ofSize: 25, weight: .medium, width: .condensed) + } else { + label.font = UIFont.systemFont(ofSize: 22, weight: .medium) + } + label.textColor = Color.blue + return label + }() + + fileprivate lazy var contextualLabel: UILabel = { + let label = UILabel() + if #available(iOS 16.0, *) { + label.font = UIFont.systemFont(ofSize: 16, weight: .medium, width: .condensed) + } else { + label.font = UIFont.systemFont(ofSize: 14, weight: .medium) + } + label.textColor = Color.gray200 + label.textAlignment = .right + return label + }() + + fileprivate lazy var chevronImageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Color.gray400 + imageView.contentMode = .scaleAspectFit + return imageView + }() + + fileprivate lazy var leadingStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [titleLabel, textPill]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = Constants.badgeSpacing + return stackView + }() + + fileprivate lazy var trailingStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [contextualLabel, chevronImageView]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = Constants.padding/2 + return stackView + }() + + fileprivate lazy var separatorView: UIView = { + let view = UIView() + view.backgroundColor = Color.gray100 + return view + }() + + fileprivate var isTouching = false + + fileprivate enum Constants { + static let padding: CGFloat = UniversalConstants.padding + static let badgeSpacing: CGFloat = 10 + static let chevronSize: CGFloat = 16 + static let separatorHeight: CGFloat = 0.5 + } + + override init(reuseIdentifier: String?) { + super.init(reuseIdentifier: reuseIdentifier) + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + didTapView = nil + setHighlighted(false) + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + + isTouching = true + setHighlighted(true) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + super.touchesMoved(touches, with: event) + + guard let touch = touches.first else { return } + isTouching = bounds.contains(touch.location(in: self)) + setHighlighted(isTouching) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + + let shouldInvokeTap = isTouching + isTouching = false + setHighlighted(false) + + if shouldInvokeTap { + didTapView?() + } + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + super.touchesCancelled(touches, with: event) + + isTouching = false + setHighlighted(false) + } +} + +private extension CollapsableHeaderView { + + func setupLayout() { + backgroundView = UIView() + backgroundView?.backgroundColor = Color.tableBackground + contentView.backgroundColor = Color.tableBackground + isAccessibilityElement = true + accessibilityTraits = .button + + contentView.addSubview(leadingStackView) + leadingStackView.snp.makeConstraints { + $0.leading.equalToSuperview().offset(Constants.padding) + $0.centerY.equalToSuperview() + } + + contentView.addSubview(trailingStackView) + trailingStackView.snp.makeConstraints { + $0.leading.greaterThanOrEqualTo(leadingStackView.snp.trailing).offset(Constants.padding).priority(.high) + $0.trailing.equalToSuperview().offset(-Constants.padding) + $0.centerY.equalToSuperview() + } + + chevronImageView.snp.makeConstraints { + $0.width.height.equalTo(Constants.chevronSize) + } + + contentView.addSubview(separatorView) + separatorView.snp.makeConstraints { + $0.leading.trailing.bottom.equalToSuperview() + $0.height.equalTo(Constants.separatorHeight) + } + } + + func setHighlighted(_ highlighted: Bool) { + contentView.backgroundColor = highlighted ? selectedBackgroundColor : Color.tableBackground + } +} diff --git a/RaceSync/UI Components/FrequencyActionButton.swift b/RaceSync/UI Components/FrequencyActionButton.swift new file mode 100644 index 00000000..cc693d8b --- /dev/null +++ b/RaceSync/UI Components/FrequencyActionButton.swift @@ -0,0 +1,167 @@ +// +// FrequencyActionButton.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-07-29. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import UIKit +import SnapKit + +class FrequencyActionButton: CustomButton { + + var action: ZippyqFrequencyAction? { + didSet { + updateAppearance() + } + } + + var isLoading = false { + didSet { + updateLoadingState() + } + } + + override var isEnabled: Bool { + didSet { + alpha = isEnabled ? 1 : 0.4 + if !isLoading { + isUserInteractionEnabled = isEnabled + } + } + } + + private enum Constants { + static let height: CGFloat = 32 + static let horizontalPadding: CGFloat = 12 + } + + private lazy var spinnerView: UIActivityIndicatorView = { + let view = UIActivityIndicatorView(style: .medium) + addSubview(view) + view.snp.makeConstraints { + $0.center.equalToSuperview() + } + return view + }() + + init() { + super.init(frame: .zero) + setupLayout() + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupLayout() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayout() { + setContentHuggingPriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .horizontal) + contentEdgeInsets = UIEdgeInsets(top: 4, left: Constants.horizontalPadding, bottom: 4, right: Constants.horizontalPadding) + layer.cornerCurve = .continuous + isHidden = true + } + + private func updateAppearance() { + guard let action else { + isHidden = true + return + } + + isHidden = false + setTitleColor(action.titleColor, for: .normal) + titleLabel?.font = action.font + backgroundColor = action.backgroundColor + updateLoadingState() + } + + private func updateLoadingState() { + guard let action else { + spinnerView.stopAnimating() + return + } + + setTitle(action.title, for: .normal) + titleLabel?.alpha = isLoading ? 0 : 1 + isUserInteractionEnabled = !isLoading && isEnabled + + if isLoading { + spinnerView.color = action.titleColor + spinnerView.startAnimating() + } else { + spinnerView.stopAnimating() + } + } + + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize(width: size.width, height: Constants.height) + } + + override func layoutSubviews() { + super.layoutSubviews() + + if #available(iOS 26.0, *) { + layer.cornerRadius = bounds.height / 2 + } else { + layer.cornerRadius = 6 + } + } + + override func sendAction(_ action: Selector, to target: Any?, for event: UIEvent?) { + guard !isLoading else { return } + + isLoading = true + super.sendAction(action, to: target, for: event) + } +} + +extension ZippyqFrequencyAction { + + var failureTitle: String { + switch self { + case .addMe: return "Can't be added!" + case .`switch`: return "Can't switch!" + case .remove: return "Can't be removed!" + } + } + + fileprivate var title: String { + switch self { + case .addMe: return "Add Me" + case .`switch`: return "Switch" + case .remove: return "Remove" + } + } + + fileprivate var backgroundColor: UIColor { + switch self { + case .addMe: return Color.green + case .`switch`: return Color.green + case .remove: return Color.lightRed + } + } + + fileprivate var titleColor: UIColor { + switch self { + case .addMe: return Color.light + case .`switch`: return Color.light + case .remove: return Color.light + } + } + + fileprivate var font: UIFont { + switch self { + case .addMe, .`switch`: + return UIFont.systemFont(ofSize: 14, weight: .semibold) + default: return UIFont.systemFont(ofSize: 14, weight: .regular) + + } + } +} diff --git a/RaceSync/UI Components/ImageExportViewController.swift b/RaceSync/UI Components/ImageExportViewController.swift index 1e13c719..4bac57ac 100644 --- a/RaceSync/UI Components/ImageExportViewController.swift +++ b/RaceSync/UI Components/ImageExportViewController.swift @@ -48,7 +48,7 @@ class ImageExportViewController: UIViewController { lazy var imageView: UIImageView = { let imageView = UIImageView() - imageView.backgroundColor = Color.white + imageView.backgroundColor = UIColor.white imageView.layer.cornerRadius = Constants.cornerRadius/2 imageView.layer.masksToBounds = true imageView.isUserInteractionEnabled = true @@ -74,7 +74,7 @@ class ImageExportViewController: UIViewController { lazy var captionLabel: PasteboardLabel = { let label = PasteboardLabel() label.font = UIFont.systemFont(ofSize: 17, weight: .regular) - label.textColor = Color.black + label.textColor = UIColor.black label.textAlignment = .center return label }() @@ -86,8 +86,8 @@ class ImageExportViewController: UIViewController { button.setTitle("Save to Photos", for: .normal) button.addTarget(self, action: #selector(didPressPhotosButton), for: .touchUpInside) button.titleLabel?.font = UIFont.systemFont(ofSize: 19, weight: .regular) - button.tintColor = Color.black - button.backgroundColor = Color.white + button.tintColor = UIColor.black + button.backgroundColor = UIColor.white button.imageEdgeInsets = UIEdgeInsets(left: -50) button.titleEdgeInsets = UIEdgeInsets(left: -30) button.layer.cornerRadius = Constants.cornerRadius/2 @@ -102,8 +102,8 @@ class ImageExportViewController: UIViewController { button.setTitle("Share to...", for: .normal) button.addTarget(self, action: #selector(didPressShareButton), for: .touchUpInside) button.titleLabel?.font = UIFont.systemFont(ofSize: 19, weight: .regular) - button.tintColor = Color.black - button.backgroundColor = Color.white + button.tintColor = UIColor.black + button.backgroundColor = UIColor.white button.imageEdgeInsets = UIEdgeInsets(left: -30) button.titleEdgeInsets = UIEdgeInsets(left: 0) button.layer.cornerRadius = Constants.cornerRadius/2 @@ -118,8 +118,8 @@ class ImageExportViewController: UIViewController { button.setTitle("Share to Instagram", for: .normal) button.addTarget(self, action: #selector(didPressInstagramButton), for: .touchUpInside) button.titleLabel?.font = UIFont.systemFont(ofSize: 19, weight: .regular) - button.tintColor = Color.black - button.backgroundColor = Color.white + button.tintColor = UIColor.black + button.backgroundColor = UIColor.white button.imageEdgeInsets = UIEdgeInsets(left: -20) button.titleEdgeInsets = UIEdgeInsets(left: 0) button.layer.cornerRadius = Constants.cornerRadius/2 @@ -174,7 +174,7 @@ class ImageExportViewController: UIViewController { captionLabel.text = caption - view.backgroundColor = Color.black.withAlphaComponent(0.7) + view.backgroundColor = UIColor.black.withAlphaComponent(0.7) view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(dissmissView))) view.addSubview(imageView) diff --git a/RaceSync/UI Components/JoinButton.swift b/RaceSync/UI Components/JoinButton.swift index 71e7f202..74917a82 100644 --- a/RaceSync/UI Components/JoinButton.swift +++ b/RaceSync/UI Components/JoinButton.swift @@ -140,7 +140,7 @@ class JoinButton: CustomButton { if let borderColor = state.outlineColor { layer.borderColor = borderColor.cgColor - layer.borderWidth = 1 + layer.borderWidth = 1.5 } else { layer.borderWidth = 0 } @@ -255,11 +255,11 @@ extension JoinState { var titleColor: UIColor { switch self { - case .notJoined: return Color.green - case .joined: return Color.white - case .closed: return Color.black + case .notJoined: return Color.dynamic(light: Color.green, dark: Color.black) + case .joined: return Color.dynamic(light: Color.white, dark: Color.black) + case .closed: return Color.dynamic(light: Color.black, dark: Color.gray400) case .notPaid(fee: _): - return Color.green + return Color.dynamic(light: Color.green, dark: Color.black) } } diff --git a/RaceSync/UI Components/Profile Header/ProfileAvatarView.swift b/RaceSync/UI Components/Profile Header/ProfileAvatarView.swift index 3ce6b943..4ed047ec 100644 --- a/RaceSync/UI Components/Profile Header/ProfileAvatarView.swift +++ b/RaceSync/UI Components/Profile Header/ProfileAvatarView.swift @@ -51,7 +51,7 @@ class ProfileAvatarView: DimmableView { backgroundColor = Color.clear dimmableView = imageView - layer.shadowColor = Color.black.cgColor + layer.shadowColor = UIColor.black.cgColor layer.shadowOffset = CGSize(width: 0, height: 2.0) layer.shadowOpacity = 0.35 layer.shadowRadius = 2.5 diff --git a/RaceSync/UI Components/Profile Header/ProfileHeaderView.swift b/RaceSync/UI Components/Profile Header/ProfileHeaderView.swift index de7e5626..24a9670a 100644 --- a/RaceSync/UI Components/Profile Header/ProfileHeaderView.swift +++ b/RaceSync/UI Components/Profile Header/ProfileHeaderView.swift @@ -71,7 +71,7 @@ class ProfileHeaderView: UIView { button.tintColor = Color.white button.hitTestEdgeInsets = UIEdgeInsets(proportionally: -20) button.addTarget(self, action: #selector(didTapCameraButton), for: .touchUpInside) - button.layer.shadowColor = Color.black.cgColor + button.layer.shadowColor = UIColor.black.cgColor button.layer.shadowOffset = CGSize(width: 0, height: 2.0) button.layer.shadowOpacity = 0.35 button.layer.shadowRadius = 2.5 @@ -120,11 +120,11 @@ class ProfileHeaderView: UIView { fileprivate lazy var topBadgeButton: UIButton = { let button = UIButton(type: .system) - button.tintColor = Color.white + button.tintColor = UIColor.white button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium) button.titleEdgeInsets = UIEdgeInsets(right: -Constants.padding/2) button.isUserInteractionEnabled = false - button.layer.shadowColor = Color.black.cgColor + button.layer.shadowColor = UIColor.black.cgColor button.layer.shadowOffset = CGSize(width: 0, height: 1.0) button.layer.shadowOpacity = 0.7 button.layer.shadowRadius = 1.0 diff --git a/RaceSync/UI Components/RichEditorView/Resources/editor/rich_editor_style.css b/RaceSync/UI Components/RichEditorView/Resources/editor/rich_editor_style.css index ab2bdd95..b84ed961 100644 --- a/RaceSync/UI Components/RichEditorView/Resources/editor/rich_editor_style.css +++ b/RaceSync/UI Components/RichEditorView/Resources/editor/rich_editor_style.css @@ -18,18 +18,24 @@ :root { color-scheme: light dark; + --editor-background: transparent; + --editor-text: #434343; + --editor-placeholder: #8a8a8a; + --editor-link: currentColor; + --editor-border: #777777; } html { height: 100%; + background-color: var(--editor-background); } body { min-height: 100%; overflow: auto; font: -apple-system-body; - background-color: white; - color: 434343; + background-color: var(--editor-background); + color: var(--editor-text); } body, p, ul, ol { @@ -96,7 +102,10 @@ h6 { } @media (prefers-color-scheme: dark) { - body, #editor { + :root { + --editor-text: #ececff; + --editor-placeholder: #a5a5bd; + --editor-border: #68687d; } } @@ -105,7 +114,7 @@ h6 { position: absolute; top: 12px; left: 16px; - color: #cccccc; + color: var(--editor-placeholder); } #container { @@ -137,9 +146,9 @@ video { height: auto !important; } -a { color: #8d181b; } /** red color */ +a { color: var(--editor-link); } table { overflow-x: scroll; } th, td { max-width: 200px; min-width: 50px; } -table, td, th{ border-collapse: collapse; border: 1px solid #777; } +table, td, th{ border-collapse: collapse; border: 1px solid var(--editor-border); } td { padding: 5px 10px; height: 35px; } diff --git a/RaceSync/UI Components/RichEditorView/RichEditorToolbar.swift b/RaceSync/UI Components/RichEditorView/RichEditorToolbar.swift index 5b6dfbbb..e1a6b8b7 100644 --- a/RaceSync/UI Components/RichEditorView/RichEditorToolbar.swift +++ b/RaceSync/UI Components/RichEditorView/RichEditorToolbar.swift @@ -81,7 +81,7 @@ class RichEditorToolbar: UIView { private func setupLayout() { autoresizingMask = .flexibleWidth - backgroundColor = Color.navigationBarColor + backgroundColor = Color.viewTint addSeparatorLine() addSubview(scrollView) diff --git a/RaceSync/UI Components/RichEditorView/RichEditorView.swift b/RaceSync/UI Components/RichEditorView/RichEditorView.swift index b2aab18b..20fb7a83 100644 --- a/RaceSync/UI Components/RichEditorView/RichEditorView.swift +++ b/RaceSync/UI Components/RichEditorView/RichEditorView.swift @@ -64,6 +64,9 @@ class RichEditorView: UIView, UIScrollViewDelegate, WKNavigationDelegate, UIGest /// The internal WKWebView that is used to display the text. fileprivate lazy var webView: RichEditorWebView = { let view = RichEditorWebView() + view.isOpaque = false + view.backgroundColor = .clear + view.scrollView.backgroundColor = .clear view.scrollView.alwaysBounceVertical = true view.scrollView.showsVerticalScrollIndicator = true view.scrollView.alwaysBounceHorizontal = false @@ -111,6 +114,13 @@ class RichEditorView: UIView, UIScrollViewDelegate, WKNavigationDelegate, UIGest /// Whether or not the editor has finished loading or not yet. fileprivate var isEditorLoaded = false + + /// The color used for links in the editor's HTML content. + var linkColor: UIColor = Color.link { + didSet { + updateLinkColor() + } + } /// Value that stores whether or not the content should be editable when the editor is loaded. /// Is basically `isEditingEnabled` before the editor is loaded. @@ -172,6 +182,18 @@ class RichEditorView: UIView, UIScrollViewDelegate, WKNavigationDelegate, UIGest tapRecognizer.delegate = self addGestureRecognizer(tapRecognizer) } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) else { return } + updateLinkColor() + } + + fileprivate func updateLinkColor() { + guard isEditorLoaded else { return } + let color = linkColor.resolvedColor(with: traitCollection).toHexString() + runJS("document.documentElement.style.setProperty('--editor-link', '\(color)')") + } // MARK: - Rich Text Editing @@ -602,6 +624,7 @@ class RichEditorView: UIView, UIScrollViewDelegate, WKNavigationDelegate, UIGest contentEditable = editingEnabledVar placeholder = placeholderText lineHeight = DefaultInnerLineHeight + updateLinkColor() delegate?.richEditorDidLoad?(self) } updateHeight() diff --git a/RaceSync/UI Components/SegmentedTableViewHeaderView.swift b/RaceSync/UI Components/SegmentedTableViewHeaderView.swift index f9ceb823..d7ea6988 100644 --- a/RaceSync/UI Components/SegmentedTableViewHeaderView.swift +++ b/RaceSync/UI Components/SegmentedTableViewHeaderView.swift @@ -42,11 +42,20 @@ class SegmentedTableViewHeaderView: UITableViewHeaderFooterView { fatalError("init(coder:) has not been implemented") } + // MARK: - Super Overrides + + override func didMoveToWindow() { + super.didMoveToWindow() + + // Locks the view from changing modes automatically + Appearance.applyUserInterfaceStyle(to: self) + } + // MARK: - Layout fileprivate func setupLayout() { backgroundView = UIView() - backgroundView?.backgroundColor = Color.navigationBarColor + backgroundView?.backgroundColor = Color.barBackground addSubview(segmentedControl) segmentedControl.snp.makeConstraints { diff --git a/RaceSync/UI Components/SliderTableViewHeaderView.swift b/RaceSync/UI Components/SliderTableViewHeaderView.swift index 8acffc3e..a2f41f5b 100644 --- a/RaceSync/UI Components/SliderTableViewHeaderView.swift +++ b/RaceSync/UI Components/SliderTableViewHeaderView.swift @@ -334,7 +334,7 @@ private final class SliderImageCell: UICollectionViewCell { let view = UIView() view.layer.shadowRadius = 2 view.layer.shadowOpacity = 0.35 - view.layer.shadowColor = Color.black.cgColor + view.layer.shadowColor = UIColor.black.cgColor view.layer.shadowOffset = CGSize(width: 0, height: 2.0) view.layer.masksToBounds = false return view @@ -369,4 +369,3 @@ private final class SliderImageCell: UICollectionViewCell { shadowView.layer.shadowPath = UIBezierPath(roundedRect: shadowView.bounds, cornerRadius: cornerRadius).cgPath } } - diff --git a/RaceSync/UI Components/WebViewController.swift b/RaceSync/UI Components/WebViewController.swift index d63e3340..e8249ca1 100644 --- a/RaceSync/UI Components/WebViewController.swift +++ b/RaceSync/UI Components/WebViewController.swift @@ -61,7 +61,7 @@ class WebViewController: SFSafariViewController { // MARK: - Layout func configureLayout() { - preferredBarTintColor = Color.navigationBarColor + preferredBarTintColor = Color.viewTint preferredControlTintColor = Color.blue dismissButtonStyle = .close } diff --git a/RaceSync/UI Extensions/String+HTML.swift b/RaceSync/UI Extensions/String+HTML.swift index 04bd9f9e..28533bbb 100644 --- a/RaceSync/UI Extensions/String+HTML.swift +++ b/RaceSync/UI Extensions/String+HTML.swift @@ -11,9 +11,12 @@ import UIKit extension String { func replaceHTMLColorTag(with color: UIColor) -> String { - let colorHex = color.toHexString() + replaceHTMLColorTag(with: color.toHexString()) + } + + func replaceHTMLColorTag(with cssColor: String) -> String { let pattern = #"color:\s*#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"# - return self.replacingOccurrences(of: pattern, with: colorHex, options: .regularExpression, range: nil) + return self.replacingOccurrences(of: pattern, with: "color: \(cssColor)", options: .regularExpression, range: nil) } func stripHTMLFontTag() -> String { diff --git a/RaceSync/UI Utils/ActionSheetUtil.swift b/RaceSync/UI Utils/ActionSheetUtil.swift index c2bfaed5..d66140a0 100644 --- a/RaceSync/UI Utils/ActionSheetUtil.swift +++ b/RaceSync/UI Utils/ActionSheetUtil.swift @@ -10,18 +10,32 @@ import UIKit class ActionSheetUtil { - static func presentActionSheet(withTitle title: String, message: String? = nil, buttonTitle: String? = nil, completion: @escaping AlertCompletionBlock, cancel: AlertCompletionBlock? = nil) { + static func presentActionSheet(withTitle title: String, message: String? = nil, buttonTitles: [String], disabledButtonTitles: Set = [], completion: @escaping AlertCompletionBlock, cancel: AlertCompletionBlock? = nil) { guard let topMostVC = UIViewController.topMostViewController() else { return } let alert = UIAlertController(title: title, message: message, preferredStyle: .actionSheet) alert.view.tintColor = Color.blue - alert.addAction(UIAlertAction(title: buttonTitle ?? "Ok", style: .default, handler: completion)) + for buttonTitle in buttonTitles { + let action = UIAlertAction(title: buttonTitle, style: .default, handler: completion) + action.isEnabled = !disabledButtonTitles.contains(buttonTitle) + alert.addAction(action) + } alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: cancel)) topMostVC.present(alert, animated: true) } + static func presentActionSheet(withTitle title: String, message: String? = nil, buttonTitle: String? = nil, completion: @escaping AlertCompletionBlock, cancel: AlertCompletionBlock? = nil) { + presentActionSheet( + withTitle: title, + message: message, + buttonTitles: [buttonTitle ?? "Ok"], + completion: completion, + cancel: cancel + ) + } + static func presentDestructiveActionSheet(withTitle title: String, message: String? = nil, destructiveTitle: String? = nil, completion: AlertCompletionBlock? = nil, cancel: AlertCompletionBlock? = nil) { guard let topMostVC = UIViewController.topMostViewController() else { return } diff --git a/RaceSync/UI Utils/Appearance.swift b/RaceSync/UI Utils/Appearance.swift index 3931e6cd..25b0746b 100644 --- a/RaceSync/UI Utils/Appearance.swift +++ b/RaceSync/UI Utils/Appearance.swift @@ -11,7 +11,17 @@ import AlamofireImage import Presentr class Appearance { + + static let isDarkModeEnabled: Bool = { + #if DEBUG + return true + #else + return false + #endif + }() + static func configureUIAppearance() { + applyUserInterfaceStyle() configureViewAppearance() configureNavigationBarAppearance() configureTabBarAppearance() @@ -33,24 +43,62 @@ class Appearance { presenter.cornerRadius = 10 return presenter } + + static func applyUserInterfaceStyle() { + guard let window = UIApplication.shared.delegate?.window else { return } + + window?.overrideUserInterfaceStyle = isDarkModeEnabled + ? AppPrefs.appearance.userInterfaceStyle + : .light + window?.rootViewController?.view.setNeedsLayout() + window?.rootViewController?.view.setNeedsDisplay() + } + + static func applyUserInterfaceStyle(to view: UIView) { + guard let window = view.window else { return } + + view.overrideUserInterfaceStyle = isDarkModeEnabled + ? window.traitCollection.userInterfaceStyle + : .light + } + + static func applyTransparentStyle(to navigationItem: UINavigationItem) { + guard #available(iOS 26, *) else { return } + + let appearance = navigationBarOpaqueAppearance(opaque: false, shadow: false) + appearance.backgroundEffect = nil + appearance.backgroundImage = UIImage() + appearance.shadowImage = UIImage() + navigationItem.standardAppearance = appearance + navigationItem.scrollEdgeAppearance = appearance + navigationItem.compactAppearance = appearance + navigationItem.compactScrollEdgeAppearance = appearance + } + + static func applyOpaqueStyle(to navigationItem: UINavigationItem, shadow: Bool = true) { + guard #available(iOS 26, *) else { return } + + let appearance = navigationBarOpaqueAppearance(opaque: true, shadow: shadow) + navigationItem.standardAppearance = appearance + navigationItem.scrollEdgeAppearance = appearance + navigationItem.compactAppearance = appearance + navigationItem.compactScrollEdgeAppearance = appearance + } } fileprivate extension Appearance { - + static func configureViewAppearance() { let windowAppearance = UIWindow.appearance() - windowAppearance.tintColor = Color.blue - + windowAppearance.tintColor = Color.buttonTint + if let mainWindow = UIApplication.shared.delegate?.window { mainWindow?.backgroundColor = Color.white - - mainWindow?.overrideUserInterfaceStyle = .light } } + + static func navigationBarOpaqueAppearance(opaque: Bool = true, shadow: Bool = true) -> UINavigationBarAppearance { - static func configureNavigationBarAppearance() { - let foregroundColor = Color.blue - let backgroundColor = Color.navigationBarColor let backIndicatorImage = ButtonImg.back let textAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 18), @@ -58,15 +106,29 @@ fileprivate extension Appearance { ] let appearance = UINavigationBarAppearance() - appearance.configureWithOpaqueBackground() - appearance.backgroundColor = backgroundColor - appearance.shadowColor = Color.gray100 appearance.titleTextAttributes = textAttributes appearance.setBackIndicatorImage( backIndicatorImage?.withRenderingMode(.alwaysTemplate), transitionMaskImage: backIndicatorImage ) + + if opaque { + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = Color.barBackground + appearance.shadowColor = shadow ? Color.gray100 : .clear + } else { + appearance.configureWithTransparentBackground() + appearance.backgroundColor = Color.clear + appearance.shadowColor = .clear + } + + return appearance + } + static func configureNavigationBarAppearance() { + let foregroundColor = Color.buttonTint + let appearance = navigationBarOpaqueAppearance() + UINavigationBar.appearance().standardAppearance = appearance UINavigationBar.appearance().compactAppearance = appearance UINavigationBar.appearance().scrollEdgeAppearance = appearance @@ -84,8 +146,8 @@ fileprivate extension Appearance { } static func configureTabBarAppearance() { - let foregroundColor = Color.blue - let backgroundColor = Color.navigationBarColor + let foregroundColor = Color.tabBarForeground + let backgroundColor = Color.barBackground let unselectedItemTintColor = Color.gray300 let backgroundImage = UIImage.image(withColor: backgroundColor, imageSize: CGSize(width: 44, height: 44)) @@ -120,8 +182,8 @@ fileprivate extension Appearance { } static func configureToolBarAppearance() { - let foregroundColor = Color.blue - let backgroundColor = Color.navigationBarColor + let foregroundColor = Color.buttonTint + let backgroundColor = Color.barBackground // set the color and font for the title let toolBarAppearance = UIToolbar.appearance() diff --git a/RaceSync/UI Utils/Color.swift b/RaceSync/UI Utils/Color.swift index 509ea45d..97f2d58a 100644 --- a/RaceSync/UI Utils/Color.swift +++ b/RaceSync/UI Utils/Color.swift @@ -10,32 +10,51 @@ import UIKit // Color Palette public struct Color { + // Main colors - public static let red: UIColor = #colorLiteral(red: 0.5529411765, green: 0.09411764706, blue: 0.1058823529, alpha: 1) // #8d181b // Links and text action items - public static let blue: UIColor = #colorLiteral(red: 0.1333333333, green: 0.168627451, blue: 0.3568627451, alpha: 1) // #222b5b // UI buttons and interactive elements + public static let light = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1) // Always white regardless of color mode + public static let dark = #colorLiteral(red: 0.057, green: 0.057, blue: 0.06, alpha: 1) // Always black regardless of color mode public static let green: UIColor = #colorLiteral(red: 0.2196078431, green: 0.4941176471, blue: 0.1607843137, alpha: 1) // #387e29 public static let yellow: UIColor = #colorLiteral(red: 0.9764705882, green: 0.8431372549, blue: 0.2862745098, alpha: 1) // #f9d749 // Special elements (GQ Trophee) + public static let red = dynamic(light: #colorLiteral(red: 0.5529411765, green: 0.09411764706, blue: 0.1058823529, alpha: 1), dark: #colorLiteral(red: 0.768627451, green: 0.1254901961, blue: 0.2, alpha: 1)) // #8d181b // Links and text action items + public static let blue = dynamic(light: #colorLiteral(red: 0.1333333333, green: 0.168627451, blue: 0.3568627451, alpha: 1), dark: #colorLiteral(red: 0.3256023209, green: 0.6782188073, blue: 0.8992619779, alpha: 1)) // #222b5b // UI buttons and interactive elements // Secondary Colors public static let lightRed: UIColor = #colorLiteral(red: 0.9764705882, green: 0.2705882353, blue: 0.231372549, alpha: 1) // #f9453b // badges public static let lightBlue: UIColor = #colorLiteral(red: 0.4588235294, green: 0.7450980392, blue: 0.8588235294, alpha: 1) // #75bedb - // Grayscale - public static let white: UIColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1) // #FFFFFF - public static let gray20: UIColor = #colorLiteral(red: 0.9607843137, green: 0.9647058824, blue: 0.988627451, alpha: 1) // #F5F6FC // lite-backgrounds - public static let gray50: UIColor = #colorLiteral(red: 0.9294117647, green: 0.9254901961, blue: 0.9533333333, alpha: 1) // #EDECF3 // backgrounds - public static let gray100: UIColor = #colorLiteral(red: 0.7921568627, green: 0.7921568627, blue: 0.8121568627, alpha: 1) // #CACACF // bar shadow, separator lines - public static let gray200: UIColor = #colorLiteral(red: 0.5568627451, green: 0.5568627451, blue: 0.5964705882, alpha: 1) // #8E8E98 - public static let gray300: UIColor = #colorLiteral(red: 0.4274509804, green: 0.4274509804, blue: 0.4670588235, alpha: 1) // #6D6D77 // sub-titles - public static let gray400: UIColor = #colorLiteral(red: 0.262745098, green: 0.262745098, blue: 0.282745098, alpha: 1) // #434348 // caption - public static let gray500: UIColor = #colorLiteral(red: 0.1921568627, green: 0.1921568627, blue: 0.2200000000, alpha: 1) // #313138 - public static let black: UIColor = #colorLiteral(red: 0.0431372549, green: 0.0431372549, blue: 0.0431372549, alpha: 1) // #0B0B0B // titles / text + // Grayscales + public static let white = dynamic(light: #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1), dark: #colorLiteral(red: 0.057, green: 0.057, blue: 0.06, alpha: 1)) + public static let gray20 = dynamic(light: #colorLiteral(red: 0.9607843137, green: 0.9647058824, blue: 0.988627451, alpha: 1), dark: #colorLiteral(red: 0.1746, green: 0.1746, blue: 0.18, alpha: 1)) // lite-backgrounds + public static let gray50 = dynamic(light: #colorLiteral(red: 0.9294117647, green: 0.9254901961, blue: 0.9533333333, alpha: 1), dark: #colorLiteral(red: 0.2362159737, green: 0.2362159737, blue: 0.2486483934, alpha: 1)) // backgrounds + public static let gray100 = dynamic(light: #colorLiteral(red: 0.7921568627, green: 0.7921568627, blue: 0.8121568627, alpha: 1), dark: #colorLiteral(red: 0.3270185178, green: 0.3270185178, blue: 0.3442300187, alpha: 1)) // bar shadow, separator lines + public static let gray200 = dynamic(light: #colorLiteral(red: 0.5568627451, green: 0.5568627451, blue: 0.5964705882, alpha: 1), dark: #colorLiteral(red: 0.5666470588, green: 0.5666470588, blue: 0.5964705882, alpha: 1)) + public static let gray300 = dynamic(light: #colorLiteral(red: 0.4274509804, green: 0.4274509804, blue: 0.4670588235, alpha: 1), dark: #colorLiteral(red: 0.6780392157, green: 0.6780392157, blue: 0.7137254902, alpha: 1)) // sub-titles + public static let gray400 = dynamic(light: #colorLiteral(red: 0.262745098, green: 0.262745098, blue: 0.282745098, alpha: 1), dark: #colorLiteral(red: 0.76, green: 0.76, blue: 0.8, alpha: 1)) // caption + public static let gray500 = dynamic(light: #colorLiteral(red: 0.1921568627, green: 0.1921568627, blue: 0.2200000000, alpha: 1), dark: #colorLiteral(red: 0.8717647059, green: 0.8717647059, blue: 0.9176470588, alpha: 1)) + public static let black = dynamic(light: #colorLiteral(red: 0.05458580635, green: 0.05458580635, blue: 0.05627402716, alpha: 1), dark: #colorLiteral(red: 0.97, green: 0.97, blue: 1, alpha: 1)) // titles / text 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.98) - public static let link = Color.red + public static let cellColor = dynamic(light: #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1), dark: #colorLiteral(red: 0.048, green: 0.048, blue: 0.06, alpha: 1)) + public static let cellColor2 = dynamic(light: #colorLiteral(red: 0.9702, green: 0.9702, blue: 0.98, alpha: 1), dark: #colorLiteral(red: 0.072, green: 0.072, blue: 0.09, alpha: 1)) - public static let pinned = #colorLiteral(red: 0.3921568627, green: 0.4196078431, blue: 0.5490196078, alpha: 1) // #646b8c // standings pinned cell background - public static let pinnedSelected = #colorLiteral(red: 0.1843137255, green: 0.2196078431, blue: 0.3960784314, alpha: 1) // #2f3865 // standings pinned cell selected background + public static let buttonTint = dynamic(light: Color.blue, dark: Color.light) + public static let viewTint = dynamic(light: #colorLiteral(red: 1, green: 1, blue: 1, alpha: 0.98), dark: #colorLiteral(red: 0.048, green: 0.048, blue: 0.06, alpha: 0.98)) + public static let barBackground = dynamic(light: #colorLiteral(red: 1, green: 1, blue: 1, alpha: 0.98), dark: #colorLiteral(red: 0.048, green: 0.048, blue: 0.06, alpha: 0.98)) + public static let tabBarForeground = dynamic(light: Color.blue, dark: Color.light) + public static let tableBackground = dynamic(light: #colorLiteral(red: 1, green: 1, blue: 1, alpha: 0.98), dark: #colorLiteral(red: 0.054, green: 0.054, blue: 0.06, alpha: 0.98)) + + public static let link = dynamic(light: Color.red, dark: Color.blue) + + public static let pinned = dynamic(light: #colorLiteral(red: 0.3921568627, green: 0.4196078431, blue: 0.5490196078, alpha: 1), dark: #colorLiteral(red: 0.2475, green: 0.28125, blue: 0.45, alpha: 1)) // standings pinned cell background + public static let pinnedSelected = dynamic(light: #colorLiteral(red: 0.1843137255, green: 0.2196078431, blue: 0.3960784314, alpha: 1), dark: #colorLiteral(red: 0.2142857143, green: 0.2292857143, blue: 0.3, alpha: 1)) // standings pinned cell selected background +} + +public extension Color { + static func dynamic(light: UIColor, dark: UIColor) -> UIColor { + UIColor { traits in + traits.userInterfaceStyle == .dark ? dark : light + } + } } diff --git a/RaceSync/UI Utils/FrequencyColor.swift b/RaceSync/UI Utils/FrequencyColor.swift new file mode 100644 index 00000000..159d529d --- /dev/null +++ b/RaceSync/UI Utils/FrequencyColor.swift @@ -0,0 +1,37 @@ +// +// FrequencyColor.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-07-29. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import UIKit + +/// Provides UI colors for VTX frequencies. +/// +/// The frequency ranges are derived from Betaflight's default VTX Frequency +/// LED-strip overlay. RaceSync may adapt the displayed colors to maintain +/// contrast with its interface. +/// Source: https://www.betaflight.com/docs/development/LedStrip#vtx-frequency +/// +final class FrequencyColor { + + static func color(for frequency: String) -> UIColor { + guard let frequency = Int(frequency) else { return Color.gray200 } + return color(for: frequency) + } + + static func color(for frequency: Int) -> UIColor { + switch frequency { + case ...5672: return Color.gray100 + case 5673...5711: return .systemRed + case 5712...5750: return .systemOrange + case 5751...5789: return .systemYellow + case 5790...5829: return .systemGreen + case 5830...5867: return .systemBlue + case 5868...5906: return UIColor(red: 0.58, green: 0, blue: 0.83, alpha: 1) // Dark violet + default: return UIColor(red: 1, green: 0.50, blue: 0.67, alpha: 1) + } + } +} diff --git a/RaceSync/View Cells/RaceTableViewCell.swift b/RaceSync/View Cells/RaceTableViewCell.swift index eb62609e..3bb4cf44 100644 --- a/RaceSync/View Cells/RaceTableViewCell.swift +++ b/RaceSync/View Cells/RaceTableViewCell.swift @@ -116,7 +116,8 @@ class RaceTableViewCell: UITableViewCell { let selectedBackgroundView = UIView() selectedBackgroundView.backgroundColor = Color.gray20 self.selectedBackgroundView = selectedBackgroundView - + self.backgroundView = UIView() + contentView.addSubview(avatarImageView) avatarImageView.snp.makeConstraints { $0.height.width.equalTo(Constants.imageHeight) diff --git a/RaceSync/View Cells/SimpleTableViewCell+Configuration.swift b/RaceSync/View Cells/SimpleTableViewCell+Configuration.swift index 20b3c959..17109390 100644 --- a/RaceSync/View Cells/SimpleTableViewCell+Configuration.swift +++ b/RaceSync/View Cells/SimpleTableViewCell+Configuration.swift @@ -25,5 +25,8 @@ extension SimpleTableViewCell { cell.iconImageView.contentMode = .center cell.iconImageView.layer.cornerRadius = 6 cell.iconImageView.layer.masksToBounds = true + + cell.backgroundColor = Color.white + cell.selectedBackgroundView?.backgroundColor = Color.gray20 } } diff --git a/RaceSync/View Cells/ZippyqFrequencyTableViewCell.swift b/RaceSync/View Cells/ZippyqFrequencyTableViewCell.swift new file mode 100644 index 00000000..afc9f273 --- /dev/null +++ b/RaceSync/View Cells/ZippyqFrequencyTableViewCell.swift @@ -0,0 +1,210 @@ +// +// ZippyqFrequencyTableViewCell.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-07-29. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import UIKit +import SnapKit +import AlamofireImage + +class ZippyqFrequencyTableViewCell: UITableViewCell { + + // MARK: - Public Variables + + lazy var frequencyIndicatorView: UIView = { + let view = UIView() + view.layer.cornerRadius = Constants.indicatorWidth / 2 + view.layer.masksToBounds = true + return view + }() + + lazy var channelLabel: UILabel = { + let label = UILabel() + if #available(iOS 16.0, *) { + label.font = UIFont.systemFont(ofSize: 22, weight: .medium, width: .condensed) + } else { + label.font = UIFont.systemFont(ofSize: 20, weight: .medium) + } + label.textColor = Color.blue + label.textAlignment = .center + return label + }() + + lazy var avatarImageView: AvatarImageView = { + return AvatarImageView(withHeight: Constants.avatarHeight, showShadow: false) + }() + + lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 16, weight: .semibold) + label.textColor = Color.black + return label + }() + + lazy var subtitleLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 14, weight: .regular) + label.textColor = Color.gray300 + return label + }() + + lazy var resultLabel: UILabel = { + let label = UILabel() + label.font = UIFont.monospacedDigitSystemFont(ofSize: 17, weight: .semibold) + label.textColor = Color.blue + label.textAlignment = .right + return label + }() + + lazy var actionButton: FrequencyActionButton = { + return FrequencyActionButton() + }() + + // MARK: - Private Variables + + fileprivate lazy var separatorView: UIView = { + let view = UIView() + view.backgroundColor = Color.gray100 + return view + }() + + fileprivate lazy var textStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + stackView.axis = .vertical + stackView.alignment = .leading + stackView.spacing = 3 + return stackView + }() + + fileprivate enum Constants { + static let padding: CGFloat = UniversalConstants.padding + static let indicatorWidth: CGFloat = 6 + static let indicatorHeight: CGFloat = 36 + static let channelWidth: CGFloat = 46 + static let avatarHeight: CGFloat = 36 + static let spacing: CGFloat = 12 + static let separatorHeight: CGFloat = 0.5 + static let currentUserBackgroundColor = Color.dynamic( + light: UIColor(hex: "f3f2fc"), dark: UIColor(hex: "263238") + ) + } + + // MARK: - Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupLayout() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Layout + + fileprivate func setupLayout() { + backgroundColor = Color.white + backgroundView = UIView() + backgroundView?.backgroundColor = Color.white + selectedBackgroundView = UIView() + selectedBackgroundView?.backgroundColor = Color.gray50 + + contentView.addSubview(separatorView) + separatorView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.trailing.equalToSuperview().inset(Constants.padding) + $0.height.equalTo(Constants.separatorHeight) + } + + contentView.addSubview(frequencyIndicatorView) + frequencyIndicatorView.snp.makeConstraints { + $0.leading.equalToSuperview().offset(Constants.padding) + $0.centerY.equalToSuperview() + $0.width.equalTo(Constants.indicatorWidth) + $0.height.equalTo(Constants.indicatorHeight) + } + + contentView.addSubview(channelLabel) + channelLabel.snp.makeConstraints { + $0.leading.equalTo(frequencyIndicatorView.snp.trailing) + $0.centerY.equalToSuperview() + $0.width.equalTo(Constants.channelWidth) + } + + contentView.addSubview(avatarImageView) + avatarImageView.snp.makeConstraints { + $0.leading.equalTo(channelLabel.snp.trailing).offset(Constants.padding/2) + $0.centerY.equalToSuperview() + $0.size.equalTo(Constants.avatarHeight) + } + + contentView.addSubview(resultLabel) + resultLabel.snp.makeConstraints { + $0.trailing.equalToSuperview().inset(Constants.padding) + $0.centerY.equalToSuperview() + } + + contentView.addSubview(actionButton) + actionButton.snp.makeConstraints { + $0.trailing.equalToSuperview().inset(Constants.padding) + $0.centerY.equalToSuperview() + } + + contentView.addSubview(textStackView) + textStackView.snp.makeConstraints { + $0.leading.equalTo(avatarImageView.snp.trailing).offset(Constants.padding) + $0.trailing.lessThanOrEqualTo(resultLabel.snp.leading).inset(Constants.spacing) + $0.trailing.lessThanOrEqualTo(actionButton.snp.leading).inset(Constants.spacing) + $0.centerY.equalToSuperview() + } + } + + // MARK: - Configuration + + func configure(with viewModel: ZippyqFrequencyViewModel, showsTopSeparator: Bool) { + let backgroundColor = viewModel.isCurrentUser ? Constants.currentUserBackgroundColor : Color.white + self.backgroundColor = backgroundColor + backgroundView?.backgroundColor = backgroundColor + + titleLabel.text = viewModel.titleLabel + titleLabel.textColor = viewModel.isAssigned ? Color.black : Color.gray200 + subtitleLabel.text = viewModel.subtitleLabel + resultLabel.text = viewModel.resultLabel + resultLabel.isHidden = viewModel.resultLabel == nil + actionButton.action = viewModel.action + actionButton.isEnabled = viewModel.isActionEnabled + separatorView.isHidden = !showsTopSeparator + + frequencyIndicatorView.backgroundColor = viewModel.frequencyColor + channelLabel.text = viewModel.channelLabel + + if viewModel.isAssigned { + avatarImageView.imageView.backgroundColor = Color.white + avatarImageView.imageView.setImage(with: viewModel.imageUrl, + placeholderImage: PlaceholderImg.medium, + size: CGSize(width: Constants.avatarHeight, height: Constants.avatarHeight)) + } else { + avatarImageView.imageView.af_cancelImageRequest() + avatarImageView.imageView.image = nil + avatarImageView.imageView.backgroundColor = Color.gray100 + } + } + + override func prepareForReuse() { + super.prepareForReuse() + + channelLabel.text = nil + avatarImageView.imageView.image = nil + titleLabel.text = nil + subtitleLabel.text = nil + resultLabel.text = nil + resultLabel.isHidden = true + actionButton.action = nil + actionButton.isEnabled = false + actionButton.isLoading = false + separatorView.isHidden = true + } +} diff --git a/RaceSync/View Controllers/Events/EventHeaderView.swift b/RaceSync/View Controllers/Events/EventHeaderView.swift index f6747813..97d1abae 100644 --- a/RaceSync/View Controllers/Events/EventHeaderView.swift +++ b/RaceSync/View Controllers/Events/EventHeaderView.swift @@ -21,6 +21,16 @@ protocol EventHeaderViewDelegate: AnyObject { class EventHeaderView: UIView { + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) else { return } + + for button in dateStackView.arrangedSubviews.compactMap({ $0 as? UIButton }) { + let color = button.isSelected ? tintColor : Color.gray50 + button.layer.borderColor = color?.resolvedColor(with: traitCollection).cgColor + } + } + // MARK: - Public weak var delegate: EventHeaderViewDelegate? @@ -170,7 +180,7 @@ class EventHeaderView: UIView { // MARK: - Layout fileprivate func setupLayout() { - backgroundColor = Color.navigationBarColor + backgroundColor = Color.viewTint addSeparatorLine(.bottom) addSubview(dateScrollView) diff --git a/RaceSync/View Controllers/Events/EventsViewController.swift b/RaceSync/View Controllers/Events/EventsViewController.swift index 37378b59..ba9461f2 100644 --- a/RaceSync/View Controllers/Events/EventsViewController.swift +++ b/RaceSync/View Controllers/Events/EventsViewController.swift @@ -342,7 +342,7 @@ extension EventsViewController: UITableViewDelegate { extension EventsViewController: MapViewControllerDelegate { func mapViewController(_ mapViewController: MapViewController, didSelectLocation location: MapViewLocation) { - guard let track = eventsController.io26Event?.tracks?.first(where: { $0.name == location.name }) else { return } + guard let _ = eventsController.io26Event?.tracks?.first(where: { $0.name == location.name }) else { return } // } diff --git a/RaceSync/View Controllers/Gallery/GalleryViewController.swift b/RaceSync/View Controllers/Gallery/GalleryViewController.swift index 70fe346b..9ae4c55b 100644 --- a/RaceSync/View Controllers/Gallery/GalleryViewController.swift +++ b/RaceSync/View Controllers/Gallery/GalleryViewController.swift @@ -151,6 +151,11 @@ class GalleryViewController: UIViewController { super.init(coder: aDecoder) } + deinit { + timer?.cancel() + timer = nil + } + // MARK: - View Lifecyle override func viewDidLoad() { @@ -295,30 +300,31 @@ class GalleryViewController: UIViewController { timer?.cancel() timer = nil - func animate() { - let alpha: CGFloat = hide ? 0 : 1 - let duration: TimeInterval = animated ? 1 : 0 - - UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseInOut, .allowUserInteraction], animations: { () -> Void in - self.pageControl.alpha = alpha - self.navigationBar.alpha = alpha - self.setNeedsStatusBarAppearanceUpdate() - }, completion: nil) - } - if delay > 0 { // timer used to delay the fade-out animation. This was needed since UIView's animation API delay didn't affect the status bar. timer = DispatchSource.makeTimerSource(flags: [], queue: .main) timer?.schedule(deadline: .now() + delay) - timer?.setEventHandler { - animate() + timer?.setEventHandler { [weak self] in + self?.animateChrome(hidden: hide, animated: animated) + self?.timer = nil } timer?.resume() } else { - animate() + animateChrome(hidden: hide, animated: animated) } } + fileprivate func animateChrome(hidden: Bool, animated: Bool) { + let alpha: CGFloat = hidden ? 0 : 1 + let duration: TimeInterval = animated ? 1 : 0 + + UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseInOut, .allowUserInteraction], animations: { [weak self] in + self?.pageControl.alpha = alpha + self?.navigationBar.alpha = alpha + self?.setNeedsStatusBarAppearanceUpdate() + }, completion: nil) + } + // MARK: - Actions @objc func didTapPageControl(_ sender: Any) -> () { diff --git a/RaceSync/View Controllers/HomeTabBarController.swift b/RaceSync/View Controllers/HomeTabBarController.swift index 4b75f27d..19aef71f 100644 --- a/RaceSync/View Controllers/HomeTabBarController.swift +++ b/RaceSync/View Controllers/HomeTabBarController.swift @@ -119,6 +119,15 @@ class HomeTabBarController: UITabBarController { setupLayout() } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) else { return } + + let borderColor = Color.gray100.resolvedColor(with: traitCollection).cgColor + userProfileButton.layer.borderColor = borderColor + chapterProfileButton.layer.borderColor = borderColor + } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) diff --git a/RaceSync/View Controllers/Login/LoginViewController.swift b/RaceSync/View Controllers/Login/LoginViewController.swift index e0849520..efdcc52f 100644 --- a/RaceSync/View Controllers/Login/LoginViewController.swift +++ b/RaceSync/View Controllers/Login/LoginViewController.swift @@ -34,12 +34,14 @@ class LoginViewController: UIViewController { let label = UILabel() label.text = titleText label.font = UIFont.systemFont(ofSize: 13, weight: .medium) - label.textColor = Color.gray200 + label.textColor = Color.dynamic(light: Color.gray200, dark: Color.gray400) return label }() fileprivate lazy var emailField: UITextField = { let textField = UITextField() + textField.tintColor = Color.black + textField.textColor = Color.black textField.delegate = self textField.placeholder = "Email" textField.keyboardType = .emailAddress @@ -54,6 +56,8 @@ class LoginViewController: UIViewController { fileprivate lazy var passwordField: UITextField = { let textField = UITextField() + textField.tintColor = Color.black + textField.textColor = Color.black textField.delegate = self textField.placeholder = "Password" textField.keyboardType = .`default` @@ -87,11 +91,13 @@ class LoginViewController: UIViewController { }() fileprivate lazy var loginButton: ActionButton = { + let titleColor = Color.dynamic(light: Color.blue, dark: Color.black) + let button = ActionButton(type: .system) button.titleLabel?.font = UIFont.systemFont(ofSize: 21, weight: .regular) - button.setTitleColor(Color.blue, for: .normal) + button.setTitleColor(titleColor, for: .normal) button.setTitle("Login", for: .normal) - button.backgroundColor = Color.white.withAlphaComponent(0.8) + button.backgroundColor = Color.white.withAlphaComponent(0.7) button.layer.cornerRadius = Constants.padding/2 button.layer.borderColor = Color.gray100.cgColor button.layer.borderWidth = 0.5 @@ -114,7 +120,7 @@ class LoginViewController: UIViewController { let label = "By tapping “Login” you will accept our " + link + "." let attributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 14, weight: .medium), - NSAttributedString.Key.foregroundColor: Color.gray200] + NSAttributedString.Key.foregroundColor: Color.dynamic(light: Color.gray200, dark: Color.gray400)] let linkAttributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 14, weight: .medium), NSAttributedString.Key.foregroundColor: Color.link] @@ -177,6 +183,12 @@ class LoginViewController: UIViewController { ) } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) else { return } + loginButton.layer.borderColor = Color.gray100.resolvedColor(with: traitCollection).cgColor + } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -212,7 +224,7 @@ class LoginViewController: UIViewController { // MARK: - Layout fileprivate func setupLayout() { - + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapView)) view.addGestureRecognizer(tapGestureRecognizer) @@ -245,7 +257,7 @@ class LoginViewController: UIViewController { func addline(under view: UIView) { let separatorLine = UIView() - separatorLine.backgroundColor = Color.gray100 + separatorLine.backgroundColor = Color.gray200 loginFormView.addSubview(separatorLine) separatorLine.snp.makeConstraints { $0.top.equalTo(view.snp.bottom).offset(Constants.padding/2) diff --git a/RaceSync/View Controllers/Profiles/ChapterViewController.swift b/RaceSync/View Controllers/Profiles/ChapterViewController.swift index 6b1273c9..438fd58a 100644 --- a/RaceSync/View Controllers/Profiles/ChapterViewController.swift +++ b/RaceSync/View Controllers/Profiles/ChapterViewController.swift @@ -159,10 +159,10 @@ class ChapterViewController: ProfileViewController, ViewJoinable, RaceEditable { } override func didSelectRow(at indexPath: IndexPath) { - if selectedSegment == .left, let viewModel = raceViewModel(for: indexPath.row) { + if selectedSegment == .left, let viewModel = raceViewModel(at: indexPath.row) { let vc = RaceTabBarController(with: viewModel.race) navigationController?.pushViewController(vc, animated: true) - } else if selectedSegment == .right, let viewModel = userViewModel(for: indexPath.row), let user = viewModel.user { + } else if selectedSegment == .right, let viewModel = userViewModel(at: indexPath.row), let user = viewModel.user { let vc = UserViewController(with: user) navigationController?.pushViewController(vc, animated: true) } @@ -312,14 +312,14 @@ class ChapterViewController: ProfileViewController, ViewJoinable, RaceEditable { } } - func raceViewModel(for index: Int) -> RaceViewModel? { + func raceViewModel(at index: Int) -> RaceViewModel? { if index >= 0, index < raceViewModels.count { return raceViewModels[index] } return nil } - func userViewModel(for index: Int) -> UserViewModel? { + func userViewModel(at index: Int) -> UserViewModel? { if index >= 0, index < userViewModels.count { return userViewModels[index] } @@ -353,7 +353,7 @@ extension ChapterViewController: UITableViewDataSource { func raceTableViewCell(for indexPath: IndexPath) -> RaceTableViewCell { let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as RaceTableViewCell - guard let viewModel = raceViewModel(for: indexPath.row) else { return cell } + guard let viewModel = raceViewModel(at: indexPath.row) else { return cell } cell.dateLabel.text = viewModel.startDateLabel //"Saturday Sept 14 @ 9:00 AM" cell.titleLabel.text = viewModel.titleLabel @@ -378,7 +378,7 @@ extension ChapterViewController: UITableViewDataSource { func avatarTableViewCell(for indexPath: IndexPath) -> AvatarTableViewCell { let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as AvatarTableViewCell - guard let viewModel = userViewModel(for: indexPath.row) else { return cell } + guard let viewModel = userViewModel(at: indexPath.row) else { return cell } cell.titleLabel.text = viewModel.displayName cell.avatarImageView.imageView.setImage(with: viewModel.pictureUrl, placeholderImage: PlaceholderImg.medium, size: Constants.avatarImageSize) diff --git a/RaceSync/View Controllers/Profiles/ProfileViewController.swift b/RaceSync/View Controllers/Profiles/ProfileViewController.swift index a28ba52b..198bdb18 100644 --- a/RaceSync/View Controllers/Profiles/ProfileViewController.swift +++ b/RaceSync/View Controllers/Profiles/ProfileViewController.swift @@ -74,6 +74,8 @@ class ProfileViewController: UIViewController, Shimmable { return button }() + fileprivate var isNavigationBarOpaque = false + fileprivate enum Constants { static let padding: CGFloat = UniversalConstants.padding } @@ -112,6 +114,8 @@ class ProfileViewController: UIViewController, Shimmable { // Using a custom button title in this case, to display the id of a User/Chapter on tap navigationItem.titleView = titleButton + + Appearance.applyTransparentStyle(to: navigationItem) headerView.topLayoutInset = topOffset headerView.viewModel = profileViewModel @@ -208,6 +212,17 @@ extension ProfileViewController: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { stretchHeaderView(with: scrollView.contentOffset) + + let shouldUseOpaqueNavigationBar = scrollView.contentOffset.y >= headerView.frame.height - topOffset + guard shouldUseOpaqueNavigationBar != isNavigationBarOpaque else { return } + + isNavigationBarOpaque = shouldUseOpaqueNavigationBar + + if shouldUseOpaqueNavigationBar { + Appearance.applyOpaqueStyle(to: navigationItem) + } else { + Appearance.applyTransparentStyle(to: navigationItem) + } } } diff --git a/RaceSync/View Controllers/Profiles/UserViewController.swift b/RaceSync/View Controllers/Profiles/UserViewController.swift index a27904bc..b7d04fe9 100644 --- a/RaceSync/View Controllers/Profiles/UserViewController.swift +++ b/RaceSync/View Controllers/Profiles/UserViewController.swift @@ -142,10 +142,10 @@ class UserViewController: ProfileViewController, ViewJoinable, RaceEditable { } override func didSelectRow(at indexPath: IndexPath) { - if selectedSegment == .left, let viewModel = raceViewModel(for: indexPath.row) { + if selectedSegment == .left, let viewModel = raceViewModel(at: indexPath.row) { let vc = RaceTabBarController(with: viewModel.race) navigationController?.pushViewController(vc, animated: true) - } else if selectedSegment == .right, let viewModel = chapterViewModel(for: indexPath.row) { + } else if selectedSegment == .right, let viewModel = chapterViewModel(at: indexPath.row) { let vc = ChapterViewController(with: viewModel.chapter) navigationController?.pushViewController(vc, animated: true) } @@ -158,8 +158,8 @@ class UserViewController: ProfileViewController, ViewJoinable, RaceEditable { func getQRImage(with userId: String) -> UIImage? { var qrCode = QRCode(userId) qrCode?.size = CGSize(width: 270, height: 270) - qrCode?.color = CIColor(color: Color.black) - qrCode?.backgroundColor = CIColor(color: Color.white) + qrCode?.color = CIColor(color: UIColor.black) + qrCode?.backgroundColor = CIColor(color: UIColor.white) return qrCode?.image } @@ -302,14 +302,14 @@ class UserViewController: ProfileViewController, ViewJoinable, RaceEditable { } } - func raceViewModel(for index: Int) -> RaceViewModel? { + func raceViewModel(at index: Int) -> RaceViewModel? { if index >= 0, index < raceViewModels.count { return raceViewModels[index] } return nil } - func chapterViewModel(for index: Int) -> ChapterViewModel? { + func chapterViewModel(at index: Int) -> ChapterViewModel? { if index >= 0, index < chapterViewModels.count { return chapterViewModels[index] } @@ -343,7 +343,7 @@ extension UserViewController: UITableViewDataSource { func userRaceTableViewCell(for indexPath: IndexPath) -> UserRaceTableViewCell { let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as UserRaceTableViewCell - guard let viewModel = raceViewModel(for: indexPath.row) else { return cell } + guard let viewModel = raceViewModel(at: indexPath.row) else { return cell } cell.dateLabel.text = viewModel.startDateLabel //"Saturday Sept 14 @ 9:00 AM" cell.titleLabel.text = viewModel.titleLabel @@ -358,7 +358,7 @@ extension UserViewController: UITableViewDataSource { func chapterTableViewCell(for indexPath: IndexPath) -> ChapterTableViewCell { let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as ChapterTableViewCell - guard let viewModel = chapterViewModel(for: indexPath.row) else { return cell } + guard let viewModel = chapterViewModel(at: indexPath.row) else { return cell } cell.titleLabel.text = viewModel.titleLabel cell.subtitleLabel.text = viewModel.locationLabel @@ -443,4 +443,3 @@ extension UserViewController: EmptyDataSetDelegate { return !shimmeringView.isShimmering } } - diff --git a/RaceSync/View Controllers/Races/RaceController.swift b/RaceSync/View Controllers/Races/RaceController.swift index 692a441c..22a8bd6d 100644 --- a/RaceSync/View Controllers/Races/RaceController.swift +++ b/RaceSync/View Controllers/Races/RaceController.swift @@ -271,7 +271,7 @@ class RaceController { let item = option.makeButton(target: self, action: #selector(raceActionTapped(_:))) item.tag = option.rawValue return item - }.interspersed(with: UIBarButtonItem.spacer()) + } } else { // Still needed for versions of iOS previous to iOS26 let actions = filtered.map { (image: $0.image, selector: #selector(raceActionTapped(_:)), tag: $0.rawValue) } diff --git a/RaceSync/View Controllers/Races/RaceDetailViewController.swift b/RaceSync/View Controllers/Races/RaceDetailViewController.swift index 21256732..9e884e84 100644 --- a/RaceSync/View Controllers/Races/RaceDetailViewController.swift +++ b/RaceSync/View Controllers/Races/RaceDetailViewController.swift @@ -67,11 +67,20 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { label.numberOfLines = 1 return label }() + + fileprivate lazy var limitLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 13, weight: .regular) + label.textColor = Color.gray300 + label.textAlignment = .right + label.numberOfLines = 1 + return label + }() fileprivate lazy var rotatingIconView: RotatingIconView = { let view = RotatingIconView() view.tintColor = Color.yellow - view.imageView.image = ButtonImg.trophy?.withRenderingMode(.alwaysTemplate) + view.imageView.image = ButtonImg.trophy view.imageView.tintColor = Color.yellow return view }() @@ -129,6 +138,7 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { let view = UIImageView() view.contentMode = .scaleAspectFit view.backgroundColor = Color.clear + view.tintColor = Color.black return view }() @@ -200,7 +210,7 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { $0.height.greaterThanOrEqualTo(JoinButton.minHeight) } - var subviews: [UIView] = [topStackView, feeLabel, memberBadgeView] + var subviews: [UIView] = [topStackView, feeLabel, limitLabel, memberBadgeView] let stackView = UIStackView(arrangedSubviews: subviews) stackView.axis = .vertical stackView.alignment = .trailing @@ -272,9 +282,18 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { return raceCoordinate != nil } + var shouldUseTransparentNavigationBar: Bool { + guard #available(iOS 26, *) else { return false } + return canDisplayMap + } + fileprivate var canDisplayFee: Bool { return raceViewModel.feeLabel.count > 0 } + + fileprivate var canDisplayLimit: Bool { + return raceViewModel.limitLabel.count > 0 + } fileprivate var tableViewRows = [Row]() fileprivate var didTapCell: Bool = false @@ -285,6 +304,7 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { fileprivate var seriesApi = SeriesApi() fileprivate var htmlViewHeightConstraint: Constraint? + fileprivate var mapTopConstraint: Constraint? // fileprivate let ignoreFinalizingError: Bool = true // The API finalize(id) still returns 500 error. Reported https://github.com/MultiGP/multigp-com/issues/93 fileprivate enum Constants { @@ -326,6 +346,11 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { super.viewDidAppear(animated) } + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + mapTopConstraint?.update(offset: -view.safeAreaInsets.top) + } + deinit { unregisterJoinable() } @@ -344,7 +369,7 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { if canDisplayMap { contentView.addSubview(mapView) mapView.snp.makeConstraints { - $0.top.equalToSuperview().offset(-topOffset) + mapTopConstraint = $0.top.equalToSuperview().offset(-view.safeAreaInsets.top).constraint $0.leading.trailing.equalToSuperview() $0.height.equalTo(Constants.mapHeight) } @@ -531,11 +556,11 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { locationButton.imageEdgeInsets = UIEdgeInsets(top: -Constants.padding, left: -Constants.padding, bottom: 0, right: 0) } - if canDisplayFee { - feeLabel.text = raceViewModel.feeLabel - } - + limitLabel.text = raceViewModel.limitLabel + feeLabel.text = raceViewModel.feeLabel + locationButton.isHidden = !canDisplayAddress + limitLabel.isHidden = !canDisplayLimit feeLabel.isHidden = !canDisplayFee } @@ -545,15 +570,15 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { let race = raceViewModel.race if race.description.stripHTML().count > 0 { - let description = race.description.replaceHTMLColorTag(with: Color.gray300).stripHTMLFontTag().stripHTMLEdges() + let description = race.description.replaceHTMLColorTag(with: "currentColor").stripHTMLFontTag().stripHTMLEdges() html += "
\(description)
" } if race.content.stripHTML().count > 0 { - let content = race.content.replaceHTMLColorTag(with: Color.black).stripHTMLFontTag().stripHTMLEdges() - html += "
\(content)
" + let content = race.content.replaceHTMLColorTag(with: "currentColor").stripHTMLFontTag().stripHTMLEdges() + html += "
\(content)
" } if race.itinerary.stripHTML().count > 0 { - let itinerary = race.description.replaceHTMLColorTag(with: Color.gray100).stripHTMLFontTag().stripHTMLEdges() + let itinerary = race.itinerary.replaceHTMLColorTag(with: "currentColor").stripHTMLFontTag().stripHTMLEdges() html += "
" html += "
\(itinerary)
" } @@ -881,6 +906,20 @@ extension RaceDetailViewController: RichEditorDelegate { } } +// MARK: - UIScrollView Delegate + +extension RaceDetailViewController: UIScrollViewDelegate { + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard canDisplayMap, mapView.bounds.height > 0 else { return } + + let overscroll = max(0, -scrollView.contentOffset.y - scrollView.adjustedContentInset.top) + let scale = 1 + overscroll / mapView.bounds.height + + mapView.transform = CGAffineTransform(a: scale, b: 0, c: 0, d: scale, tx: 0, ty: -overscroll / 2) + } +} + // MARK: - MKMapView Delegate extension RaceDetailViewController: MKMapViewDelegate { diff --git a/RaceSync/View Controllers/Races/RaceEditable.swift b/RaceSync/View Controllers/Races/RaceEditable.swift index 3b368e90..85b9f9a7 100644 --- a/RaceSync/View Controllers/Races/RaceEditable.swift +++ b/RaceSync/View Controllers/Races/RaceEditable.swift @@ -16,7 +16,7 @@ protocol RaceEditable: UIViewController { func didLongPress(_ gesture: UIGestureRecognizer) func loadContent(forced: Bool) - func raceViewModel(for index: Int) -> RaceViewModel? + func raceViewModel(at index: Int) -> RaceViewModel? // Default implementation func handleLongPress(_ gesture: UIGestureRecognizer) @@ -35,7 +35,7 @@ extension RaceEditable { return } - guard let viewModel = raceViewModel(for: indexPath.row), viewModel.race.canBeEdited else { return } + guard let viewModel = raceViewModel(at: indexPath.row), viewModel.race.canBeEdited else { return } raceController = RaceController(with: viewModel.race) raceController?.showContextualMenu(.edit, completion: { [weak self] status in diff --git a/RaceSync/View Controllers/Races/RaceFeedController.swift b/RaceSync/View Controllers/Races/RaceFeedController.swift index 3356d426..b7aa21f8 100644 --- a/RaceSync/View Controllers/Races/RaceFeedController.swift +++ b/RaceSync/View Controllers/Races/RaceFeedController.swift @@ -24,6 +24,7 @@ class RaceFeedController { fileprivate let api = RaceApi() fileprivate var collection = [RaceFilter: [RaceViewModel]]() + fileprivate static let processingQueue = DispatchQueue(label: "com.multigp.racesync.race-feed.processing", qos: .userInitiated) fileprivate var settings: APISettings { get { return APIServices.shared.settings } @@ -71,6 +72,15 @@ class RaceFeedController { fileprivate extension RaceFeedController { + static func makeViewModels(from races: [Race], sorting: RaceViewSorting, completion: @escaping ([RaceViewModel]) -> Void) { + processingQueue.async { + let viewModels = RaceViewModel.sortedViewModels(with: races, sorting: sorting) + DispatchQueue.main.async { + completion(viewModels) + } + } + } + func getJoinedRaces(_ forceFetch: Bool = false, _ completion: RaceFeedControllerCompletionBlock<[RaceViewModel]>?) { if let viewModels = collection[.joined] { @@ -84,9 +94,10 @@ fileprivate extension RaceFeedController { api.getMyRaces(filters: filters) { [weak self] (races, error) in if let races = races { let filtered = races.filter { !$0.hasEnded(extendedByDays: 1) } - let sortedViewModels = RaceViewModel.sortedViewModels(with: filtered, sorting: sorting) - self?.collection[.joined] = sortedViewModels - completion?(sortedViewModels, false, nil) + Self.makeViewModels(from: filtered, sorting: sorting) { viewModels in + self?.collection[.joined] = viewModels + completion?(viewModels, false, nil) + } } else { completion?(nil, false, error) } @@ -109,9 +120,10 @@ fileprivate extension RaceFeedController { api.getMyRaces(filters: filters, latitude: lat, longitude: long) { [weak self] (races, error) in if let races = races { - let sortedViewModels = RaceViewModel.sortedViewModels(with: races, sorting: sorting) - self?.collection[.nearby] = sortedViewModels - completion?(sortedViewModels, false, nil) + Self.makeViewModels(from: races, sorting: sorting) { viewModels in + self?.collection[.nearby] = viewModels + completion?(viewModels, false, nil) + } } else { completion?(nil, false, error) } @@ -131,9 +143,10 @@ fileprivate extension RaceFeedController { api.getRaces(with: filters, chapterIds: user.chapterIds) { [weak self] races, error in if let races = races { - let sortedViewModels = RaceViewModel.sortedViewModels(with: races, sorting: sorting) - self?.collection[.chapters] = sortedViewModels - completion?(sortedViewModels, false, nil) + Self.makeViewModels(from: races, sorting: sorting) { viewModels in + self?.collection[.chapters] = viewModels + completion?(viewModels, false, nil) + } } else { completion?(nil, false, error) } @@ -152,9 +165,10 @@ fileprivate extension RaceFeedController { api.getRaces(with: filters, raceClass: `class`) { [weak self] (races, error) in if let races = races { - let sortedViewModels = RaceViewModel.sortedViewModels(with: races, sorting: sorting) - self?.collection[.classes(`class`)] = sortedViewModels - completion?(sortedViewModels, false, nil) + Self.makeViewModels(from: races, sorting: sorting) { viewModels in + self?.collection[.classes(`class`)] = viewModels + completion?(viewModels, false, nil) + } } else { completion?(nil, false, error) } @@ -176,9 +190,10 @@ fileprivate extension RaceFeedController { api.getRaces(with: filters, startDate: "\(series.year)", pageSize: 300) { [weak self] (races, error) in if let races = races { - let sortedViewModels = RaceViewModel.sortedViewModels(with: races, sorting: sorting) - self?.collection[.series(series)] = sortedViewModels - completion?(sortedViewModels, false, nil) + Self.makeViewModels(from: races, sorting: sorting) { viewModels in + self?.collection[.series(series)] = viewModels + completion?(viewModels, false, nil) + } } else { completion?(nil, false, error) } diff --git a/RaceSync/View Controllers/Races/RaceFeedViewController.swift b/RaceSync/View Controllers/Races/RaceFeedViewController.swift index 0eaf1ae7..46d91aba 100644 --- a/RaceSync/View Controllers/Races/RaceFeedViewController.swift +++ b/RaceSync/View Controllers/Races/RaceFeedViewController.swift @@ -31,7 +31,8 @@ class RaceFeedViewController: UIViewController, ViewJoinable, Shimmable, RaceEdi tableView.refreshControl = self.refreshControl tableView.tableFooterView = UIView() tableView.contentInsetAdjustmentBehavior = .always - + tableView.backgroundColor = Color.cellColor + for direction in [UISwipeGestureRecognizer.Direction.left, UISwipeGestureRecognizer.Direction.right] { let gesture = UISwipeGestureRecognizer(target: self, action: #selector(didSwipeHorizontally(_:))) gesture.direction = direction @@ -53,8 +54,8 @@ class RaceFeedViewController: UIViewController, ViewJoinable, Shimmable, RaceEdi fileprivate lazy var headerView: UIView = { let view = UIView() - view.backgroundColor = Color.navigationBarColor - view.tintColor = Color.blue + view.backgroundColor = Color.barBackground + view.tintColor = Color.buttonTint let spacing: CGFloat = 10 let buttonWidth: CGFloat = 30 @@ -120,7 +121,7 @@ class RaceFeedViewController: UIViewController, ViewJoinable, Shimmable, RaceEdi } } - func raceViewModel(for index: Int) -> RaceViewModel? { + func raceViewModel(at index: Int) -> RaceViewModel? { guard let list = raceFeedController.viewModels(for: selectedRaceFilter) else { return nil } if index >= 0, index < list.count { return list[index] @@ -203,6 +204,7 @@ class RaceFeedViewController: UIViewController, ViewJoinable, Shimmable, RaceEdi deinit { unregisterJoinable() + APIServices.shared.settings.remove(self) } // MARK: - Layout @@ -353,7 +355,7 @@ extension RaceFeedViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - if let viewModel = raceViewModel(for: indexPath.row) { + if let viewModel = raceViewModel(at: indexPath.row) { openRaceDetail(viewModel) } } @@ -368,11 +370,16 @@ extension RaceFeedViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return feedCount() } - + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as RaceTableViewCell - guard let viewModel = raceViewModel(for: indexPath.row) else { return cell } - + configure(cell, forRowAt: indexPath) + return cell + } + + func configure(_ view: T, forRowAt indexPath: IndexPath) where T : UITableViewCell { + guard let cell = view as? RaceTableViewCell, + let viewModel = raceViewModel(at: indexPath.row) else { return } cell.titleLabel.text = viewModel.titleLabel cell.dateLabel.text = viewModel.dateLabel //"Saturday Sept 14 @ 9:00 AM" cell.joinButton.type = .race @@ -381,6 +388,7 @@ extension RaceFeedViewController: 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.backgroundView?.backgroundColor = Color.cellColor if selectedRaceFilter == .joined { cell.subtitleLabel.text = viewModel.locationLabel @@ -389,8 +397,6 @@ extension RaceFeedViewController: UITableViewDataSource { } else { cell.subtitleLabel.text = viewModel.chapterLabel } - - return cell } } diff --git a/RaceSync/View Controllers/Races/RaceForm.swift b/RaceSync/View Controllers/Races/RaceForm.swift index 476c4bde..6550eac9 100644 --- a/RaceSync/View Controllers/Races/RaceForm.swift +++ b/RaceSync/View Controllers/Races/RaceForm.swift @@ -31,7 +31,7 @@ enum RaceFormSection: Int { } enum RaceFormRow: Int, EnumTitle { - case chapter, `class`, content, endDate, fee, feeRequired, format, location, name, + case chapter, `class`, content, endDate, limit, fee, feeRequired, format, location, name, notify, privacy, rounds, schedule, scoring, season, startDate, zDepth, zIterator, zNoKiosk public var title: String { @@ -40,6 +40,7 @@ enum RaceFormRow: Int, EnumTitle { case .class: return "Race Class" case .content: return "Description" case .endDate: return "End Date" + case .limit: return "Pilot Limit" case .fee: return "Race Fee" case .feeRequired: return "Payment Required to Join" case .format: return "Race Format" @@ -64,6 +65,7 @@ enum RaceFormRow: Int, EnumTitle { case .class: return nil case .content: return "Enter the details of this event" case .endDate: return nil + case .limit: return "Leave blank for no limit" case .fee: return "Race Fee (USD)" case .feeRequired: return nil case .format: return nil @@ -109,6 +111,8 @@ extension RaceFormRow { return QualifyingType(rawValue: raceData.qualifying)?.title case .privacy: return EventType(rawValue: raceData.privacy)?.title + case .limit: + return (raceData.pilotLimit > 0) ? "\(raceData.pilotLimit)" : "No limit" case .fee: return String(format: "$%.2f USD", raceData.fee) case .feeRequired: @@ -148,7 +152,7 @@ extension RaceFormRow { var formType: FormType { switch self { - case .name, .fee, .rounds, .zDepth, .zIterator: + case .name, .limit, .fee, .rounds, .zDepth, .zIterator: return .textfield case .startDate, .endDate: return .datePicker @@ -163,7 +167,7 @@ extension RaceFormRow { var keyboardType: UIKeyboardType { switch self { - case .fee: + case .limit, .fee: return UIKeyboardType.decimalPad case .rounds, .zDepth, .zIterator: return UIKeyboardType.numberPad diff --git a/RaceSync/View Controllers/Races/RaceFormViewController.swift b/RaceSync/View Controllers/Races/RaceFormViewController.swift index dc14ddbe..fb13cdc6 100644 --- a/RaceSync/View Controllers/Races/RaceFormViewController.swift +++ b/RaceSync/View Controllers/Races/RaceFormViewController.swift @@ -94,7 +94,7 @@ class RaceFormViewController: UIViewController { // Needs to be computed each time, since there are dynamic values fileprivate var sections: [RaceFormSection: [RaceFormRow]] { get { - var general: [RaceFormRow] = [.name, .startDate, .endDate, .chapter, .location, .season, .privacy] + var general: [RaceFormRow] = [.name, .startDate, .endDate, .chapter, .location, .season, .privacy, .limit] // Payments are enabled at a chapter level if let chapter = chapters.filter ({ return $0.id == data.chapterId }).first, chapter.paymentsEnabled { @@ -429,6 +429,8 @@ extension RaceFormViewController { func text(for row: RaceFormRow) -> String? { switch row { + case .limit: + return (data.pilotLimit > 0) ? "\(data.pilotLimit)" : nil // blank field case .fee: return (data.fee > 0) ? String(format: "%.2f", data.fee) : nil // blank field default: @@ -654,6 +656,8 @@ extension RaceFormViewController: FormBaseViewControllerDelegate { if let value = EventType(title: item)?.rawValue { data.privacy = value } + case .limit: + data.pilotLimit = Int32(item) ?? 0 case .fee: let amount = Float32(item) ?? 0 if amount == 0 { data.feeRequired = false } diff --git a/RaceSync/View Controllers/Races/RaceListViewController.swift b/RaceSync/View Controllers/Races/RaceListViewController.swift index dfd7e041..410db98e 100644 --- a/RaceSync/View Controllers/Races/RaceListViewController.swift +++ b/RaceSync/View Controllers/Races/RaceListViewController.swift @@ -164,7 +164,7 @@ class RaceListViewController: UIViewController, ViewJoinable { // MARK: - Data Update - fileprivate func raceViewModel(for indexPath: IndexPath) -> RaceViewModel? { + fileprivate func raceViewModel(at indexPath: IndexPath) -> RaceViewModel? { guard indexPath.row < raceList.count else { return nil } return raceList[indexPath.row] } @@ -292,7 +292,7 @@ extension RaceListViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - if let viewModel = raceViewModel(for: indexPath) { + if let viewModel = raceViewModel(at: indexPath) { openRaceDetail(viewModel) } } @@ -313,7 +313,7 @@ extension RaceListViewController: UITableViewDataSource { } func raceTableViewCell(for indexPath: IndexPath) -> RaceTableViewCell { - guard let viewModel = raceViewModel(for: indexPath) else { return RaceTableViewCell() } + guard let viewModel = raceViewModel(at: indexPath) else { return RaceTableViewCell() } let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as RaceTableViewCell cell.dateLabel.text = viewModel.startDateLabel //"Saturday Sept 14 @ 9:00 AM" diff --git a/RaceSync/View Controllers/Races/RacePaymentsViewController.swift b/RaceSync/View Controllers/Races/RacePaymentsViewController.swift index 11105d85..16255991 100644 --- a/RaceSync/View Controllers/Races/RacePaymentsViewController.swift +++ b/RaceSync/View Controllers/Races/RacePaymentsViewController.swift @@ -90,6 +90,7 @@ class RacePaymentsViewController: UIViewController, RaceTabbable { fileprivate var userApi = UserApi() fileprivate var userPaymentPairs: [(user: UserViewModel, payment: RacePayment?)] = [] + fileprivate var userViewModelCache = [ObjectId: UserViewModel]() fileprivate var sortingColumn: Column = .pilot fileprivate var sortingAscending = true @@ -103,6 +104,7 @@ class RacePaymentsViewController: UIViewController, RaceTabbable { static let padding: CGFloat = UniversalConstants.padding static let cellHeight: CGFloat = 80 static let buttonSpacing: CGFloat = 12 + static let maximumConcurrentUserRequests = 4 } // MARK: - Initialization @@ -189,30 +191,66 @@ class RacePaymentsViewController: UIViewController, RaceTabbable { } let userPilotIds = Set(userViewModels.map { $0.userId }) - let extraPayments = payments.filter { !userPilotIds.contains($0.pilotId) } + let missingPilotIds = Set(payments.map { $0.pilotId }) + .subtracting(userPilotIds) + .filter { !$0.isEmpty } self.calculateTotals(from: payments) - guard !extraPayments.isEmpty else { + guard !missingPilotIds.isEmpty else { self.pair(payments: payments, with: userViewModels) return self.finishLoading() } - var counter = extraPayments.count + self.loadUserViewModels(for: missingPilotIds) { [weak self] additionalUsers in + guard let self else { return } - for payment in extraPayments { - self.userApi.getUser(with: payment.pilotId) { user, _ in - if let user = user { - userViewModels.append(UserViewModel(with: user)) - } - counter -= 1 - if counter == 0 { - self.pair(payments: payments, with: userViewModels) - self.finishLoading() + userViewModels.append(contentsOf: additionalUsers) + self.pair(payments: payments, with: userViewModels) + self.finishLoading() + } + } + } + + fileprivate func loadUserViewModels(for userIds: Set, completion: @escaping ([UserViewModel]) -> Void) { + var users = userIds.compactMap { userViewModelCache[$0] } + var unresolvedIds = Array(userIds.filter { userViewModelCache[$0] == nil }) + + guard !unresolvedIds.isEmpty else { + return completion(users) + } + + func loadNextBatch() { + let batchSize = min(Constants.maximumConcurrentUserRequests, unresolvedIds.count) + let ids = Array(unresolvedIds.prefix(batchSize)) + unresolvedIds.removeFirst(batchSize) + + let group = DispatchGroup() + + for id in ids { + group.enter() + userApi.getUser(with: id) { [weak self] user, _ in + DispatchQueue.main.async { + defer { group.leave() } + guard let self, let user else { return } + + let viewModel = UserViewModel(with: user) + self.userViewModelCache[id] = viewModel + users.append(viewModel) } } } + + group.notify(queue: .main) { + if unresolvedIds.isEmpty { + completion(users) + } else { + loadNextBatch() + } + } } + + loadNextBatch() } fileprivate func calculateTotals(from payments: [RacePayment]) { diff --git a/RaceSync/View Controllers/Races/RacePilotsPickerController.swift b/RaceSync/View Controllers/Races/RacePilotsPickerController.swift index 82962136..b960d117 100644 --- a/RaceSync/View Controllers/Races/RacePilotsPickerController.swift +++ b/RaceSync/View Controllers/Races/RacePilotsPickerController.swift @@ -298,7 +298,7 @@ extension RacePilotsPickerController: UITableViewDataSource { fileprivate func chapterUserViewCell(for indexPath: IndexPath) -> ChapterUserTableViewCell { let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as ChapterUserTableViewCell - guard let viewModel = userViewModel(for: indexPath) else { return cell } + guard let viewModel = userViewModel(at: indexPath) else { return cell } // Find external match, for checking if joined or not. let otherViewModel = externalUserViewModels?.first { $0.userId == viewModel.userId } @@ -325,7 +325,7 @@ extension RacePilotsPickerController: UITableViewDataSource { return cell } - fileprivate func userViewModel(for indexPath: IndexPath) -> UserViewModel? { + fileprivate func userViewModel(at indexPath: IndexPath) -> UserViewModel? { if isSearching { return searchResult[indexPath.row] } else { diff --git a/RaceSync/View Controllers/Races/RaceScheduleViewController.swift b/RaceSync/View Controllers/Races/RaceScheduleViewController.swift index 691c9421..7ec17ce7 100644 --- a/RaceSync/View Controllers/Races/RaceScheduleViewController.swift +++ b/RaceSync/View Controllers/Races/RaceScheduleViewController.swift @@ -99,10 +99,14 @@ class RaceScheduleViewController: UIViewController, RaceTabbable { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + + startPolling() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + + stopPolling() } // MARK: - Layout @@ -130,21 +134,22 @@ class RaceScheduleViewController: UIViewController, RaceTabbable { if let url = URL(string: zippyqUrl) { webView.load(URLRequest(url: url)) + } + } - if reloadTimer != nil { - reloadTimer?.invalidate() - reloadTimer = nil - } + fileprivate func startPolling() { + guard isWebPollEnabled, reloadTimer == nil else { return } - // Start reload timer - if isWebPollEnabled { - reloadTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { [weak self] _ in - self?.webView.reload() - } - } + reloadTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { [weak self] _ in + self?.webView.reload() } } + fileprivate func stopPolling() { + reloadTimer?.invalidate() + reloadTimer = nil + } + // RaceTabbable func reloadContent() { loadContent() diff --git a/RaceSync/View Controllers/Races/RaceTabBarController.swift b/RaceSync/View Controllers/Races/RaceTabBarController.swift index 8ef0ad90..43c801e2 100644 --- a/RaceSync/View Controllers/Races/RaceTabBarController.swift +++ b/RaceSync/View Controllers/Races/RaceTabBarController.swift @@ -150,7 +150,7 @@ class RaceTabBarController: UITabBarController { vcs += [RacePilotsViewController(with: controller)] if let race = race { - if race.canShowSchedule { vcs += [RaceScheduleViewController(with: controller)] } + if race.canShowZippyQ { vcs += [ZippyqViewController(with: controller)] } if race.canManagePayments { vcs += [RacePaymentsViewController(with: controller)] } } @@ -170,6 +170,13 @@ class RaceTabBarController: UITabBarController { title = vc.title navigationItem.rightBarButtonItems = vc.navigationItem.rightBarButtonItems + + if let detailVC = vc as? RaceDetailViewController, + detailVC.shouldUseTransparentNavigationBar { + Appearance.applyTransparentStyle(to: navigationItem) + } else { + Appearance.applyOpaqueStyle(to: navigationItem, shadow: true) + } } @objc fileprivate func didPressTitleButton() { diff --git a/RaceSync/View Controllers/Search/UniversalSearchViewController.swift b/RaceSync/View Controllers/Search/UniversalSearchViewController.swift index 8979d2cd..2d7a8393 100644 --- a/RaceSync/View Controllers/Search/UniversalSearchViewController.swift +++ b/RaceSync/View Controllers/Search/UniversalSearchViewController.swift @@ -68,7 +68,7 @@ class UniversalSearchViewController: UIViewController, Shimmable { fileprivate lazy var headerView: UIView = { let view = UIView() - view.backgroundColor = Color.navigationBarColor + view.backgroundColor = Color.barBackground view.tintColor = Color.blue let spacing = 10 @@ -286,15 +286,15 @@ class UniversalSearchViewController: UIViewController, Shimmable { return objects.count } - fileprivate func raceViewModel(for index: Int) -> RaceViewModel? { + fileprivate func raceViewModel(at index: Int) -> RaceViewModel? { return viewModel(for: .races, at: index) as RaceViewModel? } - fileprivate func userViewModel(for index: Int) -> UserViewModel? { + fileprivate func userViewModel(at index: Int) -> UserViewModel? { return viewModel(for: .users, at: index) as UserViewModel? } - fileprivate func chapterViewModel(for index: Int) -> ChapterViewModel? { + fileprivate func chapterViewModel(at index: Int) -> ChapterViewModel? { return viewModel(for: .chapters, at: index) as ChapterViewModel? } @@ -381,7 +381,7 @@ extension UniversalSearchViewController: UITableViewDataSource { func raceTableViewCell(for indexPath: IndexPath) -> RaceTableViewCell { let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as RaceTableViewCell - guard let viewModel = raceViewModel(for: indexPath.row) else { return cell } + guard let viewModel = raceViewModel(at: indexPath.row) else { return cell } cell.dateLabel.text = viewModel.startDateLabel //"Saturday Sept 14 @ 9:00 AM" cell.titleLabel.text = viewModel.titleLabel @@ -395,7 +395,7 @@ extension UniversalSearchViewController: UITableViewDataSource { func userTableViewCell(for indexPath: IndexPath) -> AvatarTableViewCell { let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as AvatarTableViewCell - guard let viewModel = userViewModel(for: indexPath.row) else { return cell } + guard let viewModel = userViewModel(at: indexPath.row) else { return cell } cell.titleLabel.text = viewModel.displayName cell.avatarImageView.imageView.setImage(with: viewModel.pictureUrl, placeholderImage: PlaceholderImg.medium, size: Constants.avatarImageSize) @@ -406,7 +406,7 @@ extension UniversalSearchViewController: UITableViewDataSource { func chapterTableViewCell(for indexPath: IndexPath) -> ChapterTableViewCell { let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as ChapterTableViewCell - guard let viewModel = chapterViewModel(for: indexPath.row) else { return cell } + guard let viewModel = chapterViewModel(at: indexPath.row) else { return cell } cell.titleLabel.text = viewModel.titleLabel cell.subtitleLabel.text = viewModel.locationLabel @@ -429,15 +429,15 @@ extension UniversalSearchViewController: UITableViewDelegate { switch selectedSection { case .races: - guard let vm = raceViewModel(for: indexPath.row) else { return } + guard let vm = raceViewModel(at: indexPath.row) else { return } vc = RaceTabBarController(with: vm.race) case .users: - guard let vm = userViewModel(for: indexPath.row) else { return } + guard let vm = userViewModel(at: indexPath.row) else { return } if let user = vm.user { vc = UserViewController(with: user) } case .chapters: - guard let vm = chapterViewModel(for: indexPath.row) else { return } + guard let vm = chapterViewModel(at: indexPath.row) else { return } vc = ChapterViewController(with: vm.chapter) } diff --git a/RaceSync/View Controllers/Series/SeriesDetailViewController.swift b/RaceSync/View Controllers/Series/SeriesDetailViewController.swift index 26761c91..081e8adb 100644 --- a/RaceSync/View Controllers/Series/SeriesDetailViewController.swift +++ b/RaceSync/View Controllers/Series/SeriesDetailViewController.swift @@ -24,6 +24,11 @@ class SeriesDetailViewController: UIViewController { var seriesApi: SeriesApi { get { return seriesController.seriesApi } } + + var shouldUseTransparentNavigationBar: Bool { + guard #available(iOS 26, *) else { return false } + return series.mainImageUrl != nil + } // MARK: - Private Variables @@ -161,8 +166,8 @@ class SeriesDetailViewController: UIViewController { let vSpacing = Constants.padding * 3/4 - let content = series.description.replaceHTMLColorTag(with: Color.black).stripHTMLFontTag().stripHTMLEdges() - htmlView.html = "
\(content)
" + let content = series.description.replaceHTMLColorTag(with: "currentColor").stripHTMLFontTag().stripHTMLEdges() + htmlView.html = "
\(content)
" htmlView.isUserInteractionEnabled = true } } diff --git a/RaceSync/View Controllers/Series/SeriesFeedController.swift b/RaceSync/View Controllers/Series/SeriesFeedController.swift index 53b5d84b..3222a809 100644 --- a/RaceSync/View Controllers/Series/SeriesFeedController.swift +++ b/RaceSync/View Controllers/Series/SeriesFeedController.swift @@ -34,6 +34,7 @@ class SeriesFeedController { fileprivate let api = SeriesApi() fileprivate var collection = [SeriesFilter: [SeriesViewModel]]() + fileprivate static let processingQueue = DispatchQueue(label: "com.multigp.racesync.series-feed.processing", qos: .userInitiated) // MARK: - Data @@ -58,32 +59,40 @@ class SeriesFeedController { api.getSeries { objects, error in if let objects = objects { - self.collection[.joined] = self.getJoinedSeries(from: objects) - self.collection[.regionals] = self.getRegionalSeries(from: objects) - self.collection[.all] = self.getAllSeries(from: objects) - completion?(self.collection[filter], false, nil) + Self.processingQueue.async { + let collection: [SeriesFilter: [SeriesViewModel]] = [ + .joined: Self.getJoinedSeries(from: objects), + .regionals: Self.getRegionalSeries(from: objects), + .all: Self.getAllSeries(from: objects) + ] + + DispatchQueue.main.async { [weak self] in + self?.collection = collection + completion?(collection[filter], false, nil) + } + } } else if error != nil { completion?(nil, false, error) } } } - fileprivate func getJoinedSeries(from objects: [Series]) -> [SeriesViewModel] { + fileprivate static func getJoinedSeries(from objects: [Series]) -> [SeriesViewModel] { return sortedSeries(objects.filter { $0.isJoined == true }, prioritizeRecent: true) .map { SeriesViewModel(with: $0) } } - fileprivate func getRegionalSeries(from objects: [Series]) -> [SeriesViewModel] { + fileprivate static func getRegionalSeries(from objects: [Series]) -> [SeriesViewModel] { return sortedSeries(objects.filter { $0.scoreType == .regionals }, prioritizeJoined: true) .map { SeriesViewModel(with: $0) } } - fileprivate func getAllSeries(from objects: [Series]) -> [SeriesViewModel] { + fileprivate static func getAllSeries(from objects: [Series]) -> [SeriesViewModel] { return sortedSeries(objects, prioritizeRecent: true) .map { SeriesViewModel(with: $0) } } - fileprivate func sortedSeries(_ objects: [Series], prioritizeJoined: Bool = false, prioritizeRecent: Bool = false) -> [Series] { + fileprivate static func sortedSeries(_ objects: [Series], prioritizeJoined: Bool = false, prioritizeRecent: Bool = false) -> [Series] { return objects.sorted { let now = Date() diff --git a/RaceSync/View Controllers/Series/SeriesFeedViewController.swift b/RaceSync/View Controllers/Series/SeriesFeedViewController.swift index 370ecd07..da37eedc 100644 --- a/RaceSync/View Controllers/Series/SeriesFeedViewController.swift +++ b/RaceSync/View Controllers/Series/SeriesFeedViewController.swift @@ -20,7 +20,7 @@ class SeriesFeedViewController: UIViewController, Shimmable { let tableView = UITableView(frame: .zero, style: .plain) tableView.backgroundView = UIView() tableView.backgroundView?.backgroundColor = Color.clear - tableView.backgroundColor = Color.gray50 + tableView.backgroundColor = Color.gray20 tableView.contentInsetAdjustmentBehavior = .always tableView.dataSource = self tableView.delegate = self @@ -38,7 +38,7 @@ class SeriesFeedViewController: UIViewController, Shimmable { fileprivate lazy var headerView: UIView = { let view = UIView() - view.backgroundColor = Color.navigationBarColor + view.backgroundColor = Color.barBackground view.tintColor = Color.blue let spacing: CGFloat = 10 @@ -80,7 +80,7 @@ class SeriesFeedViewController: UIViewController, Shimmable { fileprivate lazy var refreshControl: UIRefreshControl = { let refreshControl = UIRefreshControl() - refreshControl.backgroundColor = Color.gray50 + refreshControl.backgroundColor = Color.gray20 refreshControl.tintColor = Color.blue refreshControl.addTarget(self, action: #selector(didPullRefreshControl), for: .valueChanged) return refreshControl @@ -89,7 +89,7 @@ class SeriesFeedViewController: UIViewController, Shimmable { fileprivate lazy var sliderHeaderView: SliderTableViewHeaderView = { let view = SliderTableViewHeaderView() view.autoresizingMask = [.flexibleHeight, .flexibleWidth] - view.backgroundColor = Color.gray50 + view.backgroundColor = Color.gray20 view.autoScrollInterval = 5.0 view.delegate = self return view diff --git a/RaceSync/View Controllers/Series/SeriesStandingsViewController.swift b/RaceSync/View Controllers/Series/SeriesStandingsViewController.swift index d47a2cc2..114c6641 100644 --- a/RaceSync/View Controllers/Series/SeriesStandingsViewController.swift +++ b/RaceSync/View Controllers/Series/SeriesStandingsViewController.swift @@ -50,7 +50,7 @@ class SeriesStandingsViewController: UIViewController, Pinnable { fileprivate lazy var headerView: UIView = { let view = UIView() - view.backgroundColor = Color.navigationBarColor + view.backgroundColor = Color.barBackground view.tintColor = Color.blue let spacing: CGFloat = 10 diff --git a/RaceSync/View Controllers/Settings/App Icon/AppIconViewController.swift b/RaceSync/View Controllers/Settings/App Icon/AppIconViewController.swift index 35e04c40..9d8302dd 100644 --- a/RaceSync/View Controllers/Settings/App Icon/AppIconViewController.swift +++ b/RaceSync/View Controllers/Settings/App Icon/AppIconViewController.swift @@ -110,14 +110,15 @@ extension AppIconViewController: UITableViewDataSource { cell.textLabel?.text = icon.title cell.imageView?.image = icon.preview?.rounded(with: 60 / 4) - cell.imageView?.layer.shadowColor = Color.black.cgColor + cell.imageView?.layer.shadowColor = UIColor.black.cgColor cell.imageView?.layer.shadowOffset = CGSize(width: 0, height: 2.0) cell.imageView?.layer.shadowOpacity = 0.2 cell.imageView?.layer.shadowRadius = 3 cell.accessoryType = .none - + if icon.isSelected() { - let imageView = UIImageView(image: ButtonImg.checkmark) + let image = ButtonImg.checkmark?.withRenderingMode(.alwaysTemplate) + let imageView = UIImageView(image: image) 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 0439f64d..9fc0a5c1 100644 --- a/RaceSync/View Controllers/Settings/SettingsViewController.swift +++ b/RaceSync/View Controllers/Settings/SettingsViewController.swift @@ -108,7 +108,12 @@ class SettingsViewController: UIViewController { var about: [Row] = [.feedback, .joinBeta, .viewProject] if UIApplication.shared.supportsAlternateIcons { about += [.appicon] } - return [.notifications: [Row.notifications], .resources: resources, .about: about, .auth: auth] + var settings: [Row] = [.notifications] + if Appearance.isDarkModeEnabled { + settings.append(.appearance) + } + + return [.settings: settings, .resources: resources, .about: about, .auth: auth] }() } @@ -172,6 +177,21 @@ class SettingsViewController: UIViewController { }, cancel: nil) } + fileprivate func selectAppearance() { + let appearances = AppAppearance.allCases + ActionSheetUtil.presentActionSheet( + withTitle: "Appearance Mode", + buttonTitles: appearances.map(\.title), + disabledButtonTitles: [AppPrefs.appearance.title] + ) { [weak self] action in + guard let title = action.title, + let appearance = appearances.first(where: { $0.title == title }) else { return } + AppPrefs.appearance = appearance + Appearance.applyUserInterfaceStyle() + self?.tableView.reloadData() + } + } + fileprivate func sendFeedback() { let subject = "RaceSync iOS Feedback" let email = StringConstants.supportEmail @@ -204,6 +224,8 @@ extension SettingsViewController: UITableViewDelegate { guard let section = Section(rawValue: indexPath.section), let row = sections[section]?[indexPath.row] else { return } switch row { + case .appearance: + selectAppearance() case .notifications: togglePushNotifications() cell.isLoading = isTogglingPush @@ -275,11 +297,14 @@ extension SettingsViewController: UITableViewDataSource { cell.textLabel?.text = row.title cell.textLabel?.textColor = Color.black cell.detailTextLabel?.text = nil - cell.imageView?.image = UIImage.init(named: row.imageName) + cell.imageView?.image = row.image + cell.imageView?.tintColor = Color.black cell.accessoryType = .disclosureIndicator cell.isLoading = false - if row == .notifications { + if row == .appearance { + cell.detailTextLabel?.text = AppPrefs.appearance.title + } else if row == .notifications { cell.detailTextLabel?.text = PushMessagesController.shared.isPushNotificationsEnabled() ? "Enabled" : "Disabled" cell.isLoading = isTogglingPush } else if row == .appicon { @@ -299,11 +324,11 @@ extension SettingsViewController: UITableViewDataSource { } fileprivate enum Section: Int, EnumTitle { - case notifications, resources, about, auth + case settings, resources, about, auth var title: String { switch self { - case .notifications: return "" + case .settings: return "" case .resources: return "Resources" case .about: return "RaceSync iOS \(Bundle.main.releaseDescriptionPretty)" case .auth: return "" @@ -312,6 +337,7 @@ fileprivate enum Section: Int, EnumTitle { } fileprivate enum Row: Int, EnumTitle { + case appearance case notifications case tracksGuide case buildGuide @@ -327,6 +353,7 @@ fileprivate enum Row: Int, EnumTitle { var title: String { switch self { + case .appearance: return "Appearance" case .notifications: return "Push Notifications" case .tracksGuide: return "MultiGP Tracks" case .buildGuide: return "Obstacles Build Guide" @@ -345,6 +372,7 @@ fileprivate enum Row: Int, EnumTitle { // For including icons to each row. Look for icons at https://thenounproject.com/ var imageName: String { switch self { + case .appearance: return "circle.lefthalf.filled" case .notifications: return "icn_settings_apns" case .tracksGuide: return "icn_settings_tracks" case .buildGuide: return "icn_settings_buildguide" @@ -359,4 +387,9 @@ fileprivate enum Row: Int, EnumTitle { case .switchEnv: return "icn_settings_logout" } } + + var image: UIImage? { + let image = UIImage(named: self.imageName) ?? UIImage(systemName: self.imageName) + return image?.withRenderingMode(.alwaysTemplate) + } } diff --git a/RaceSync/View Controllers/Standings/StandingsViewController.swift b/RaceSync/View Controllers/Standings/StandingsViewController.swift index fa2817a6..c8e7aca5 100644 --- a/RaceSync/View Controllers/Standings/StandingsViewController.swift +++ b/RaceSync/View Controllers/Standings/StandingsViewController.swift @@ -68,7 +68,7 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { fileprivate lazy var headerView: UIView = { let view = UIView() - view.backgroundColor = Color.navigationBarColor + view.backgroundColor = Color.barBackground view.tintColor = Color.blue view.addSubview(searchBar) @@ -85,7 +85,7 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { fileprivate lazy var toolbar: UIView = { let view = UIView() - view.backgroundColor = Color.navigationBarColor + view.backgroundColor = Color.barBackground view.addSeparatorLine() return view }() @@ -514,7 +514,7 @@ extension StandingsViewController: UITableViewDataSource { cell.titleLabel.textColor = Color.black cell.subtitleLabel.textColor = Color.gray300 cell.rankView.titleLabel.textColor = Color.gray300 - cell.backgroundView?.backgroundColor = (indexPath.row % 2 == 0) ? Color.white : Color.gray20 + cell.backgroundView?.backgroundColor = (indexPath.row % 2 == 0) ? Color.cellColor : Color.cellColor2 cell.selectedBackgroundView?.backgroundColor = Color.gray50 } } diff --git a/RaceSync/View Controllers/ZippyQ/ZippyqViewController.swift b/RaceSync/View Controllers/ZippyQ/ZippyqViewController.swift new file mode 100644 index 00000000..6d7fa3b4 --- /dev/null +++ b/RaceSync/View Controllers/ZippyQ/ZippyqViewController.swift @@ -0,0 +1,356 @@ +// +// ZippyqViewController.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-07-27. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import UIKit +import SnapKit +import RaceSyncAPI + +class ZippyqViewController: UIViewController, RaceTabbable { + + // MARK: - Public Variables + + var raceController: RaceController + + var race: Race { + get { return raceController.race! } + } + + // MARK: - Private Variables + + fileprivate lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .grouped) + tableView.register(cellType: ZippyqFrequencyTableViewCell.self) + tableView.register(CollapsableHeaderView.self, forHeaderFooterViewReuseIdentifier: CollapsableHeaderView.identifier) + tableView.separatorStyle = .none + tableView.dataSource = self + tableView.delegate = self + tableView.tableFooterView = UIView() + return tableView + }() + + fileprivate let zippyqAPI = ZippyqApi() + fileprivate var roundViewModels = [ZippyqRoundViewModel]() + fileprivate var revisionHash: ZippyqRevisionHash? + fileprivate var expandedQueueKeys = Set() + fileprivate var hasInitializedExpandedQueues = false + + fileprivate let refreshInterval: TimeInterval = 10.0 + fileprivate var refreshTimer: Timer? + fileprivate let isPollEnabled: Bool = true + + fileprivate enum Constants { + static let cellHeight: CGFloat = 60 + } + + // MARK: - Initialization + + init(with controller: RaceController) { + self.raceController = controller + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + refreshTimer?.invalidate() + } + + // MARK: - Lifecycle Methods + + override func viewDidLoad() { + super.viewDidLoad() + + setupLayout() + configureNavigationItems() + loadContent() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if revisionHash == nil { + loadContent() + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + startPolling() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + stopPolling() + } + + // MARK: - Layout + + fileprivate func setupLayout() { + + view.addSubview(tableView) + tableView.snp.makeConstraints { + $0.top.leading.trailing.bottom.equalToSuperview() + } + } + + fileprivate func configureNavigationItems() { + title = "ZippyQ" + tabBarItem = UITabBarItem(title: title, image: SystemImg.bulletList, selectedImage: SystemImg.bulletListFill) + + navigationItem.rightBarButtonItems = raceController.navigationItems() + } + + // MARK: - Data Update + + fileprivate func loadRevision() { + + zippyqAPI.getRevision(for: race.id, revision: revisionHash) { [weak self] hash, error in + if let error { + Clog.log("ZippyQ getRevision failed: \(error.localizedDescription)") + } else { + self?.loadContent(with: hash?.value) + } + } + } + + fileprivate func loadContent(with hash: ZippyqRevisionHash? = nil) { + + if let hash = hash, hash == revisionHash { + Clog.log("NO need to fetch ZippyQ content") + return // no need to reload + } + + revisionHash = hash // saving for later use + + zippyqAPI.getQueues(for: race.id) { [weak self] response, error in + if let response { + self?.updateContent(with: response) + } else if let error { + Clog.log("ZippyQ getQueues failed: \(error.localizedDescription)") + } + } + } + + fileprivate func updateContent(with response: ZippyqResponse) { + let nextQueuedIndex = response.queues.firstIndex(where: { $0.status == .queued }) + roundViewModels = response.queues.enumerated().map { index, queue in + ZippyqRoundViewModel( + with: queue, + frequencies: response.frequencies, + pilotStats: response.pilotStats, + maximumPackCount: race.maxZippyqDepth, + isUpNext: index == nextQueuedIndex + ) + } + initializeExpandedQueuesIfNeeded() + tableView.reloadData() + } + + fileprivate func startPolling() { + guard isPollEnabled, refreshTimer == nil else { return } + + refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { [weak self] _ in + self?.loadRevision() + } + } + + fileprivate func stopPolling() { + refreshTimer?.invalidate() + refreshTimer = nil + } + + // RaceTabbable + func reloadContent() { + loadContent() + } +} + +// MARK: - Queue State + +private extension ZippyqViewController { + + func initializeExpandedQueuesIfNeeded() { + guard !hasInitializedExpandedQueues else { return } + + for round in roundViewModels where round.badge == .live /*|| round.badge == .upNext */ { + expandedQueueKeys.insert(round.id) + } + hasInitializedExpandedQueues = true + } + + func isQueueExpanded(at section: Int) -> Bool { + return expandedQueueKeys.contains(roundViewModels[section].id) + } + + func toggleQueue(at section: Int) { + + let key = roundViewModels[section].id + var scroll: Bool = false + + if expandedQueueKeys.contains(key) { + expandedQueueKeys.remove(key) + } else { + expandedQueueKeys.insert(key) + scroll = true + } + + tableView.reloadSections(IndexSet(integer: section), with: .automatic) + if scroll { tableView.scrollToRow(at: IndexPath(row: 0, section: section), at: .top, animated: true) } + } +} + +// MARK: - UITableView DataSource + +extension ZippyqViewController: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + return roundViewModels.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return isQueueExpanded(at: section) ? roundViewModels[section].frequencyViewModels.count : 0 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + return frequencyTableViewCell(for: indexPath) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return Constants.cellHeight + } + + fileprivate func frequencyTableViewCell(for indexPath: IndexPath) -> ZippyqFrequencyTableViewCell { + let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as ZippyqFrequencyTableViewCell + let viewModel = roundViewModels[indexPath.section].frequencyViewModels[indexPath.row] + cell.configure(with: viewModel, showsTopSeparator: indexPath.row > 0) + configureActionButton(for: cell) + return cell + } + + fileprivate func configureActionButton(for cell: ZippyqFrequencyTableViewCell) { + cell.actionButton.removeTarget(nil, action: nil, for: .touchUpInside) + + switch cell.actionButton.action { + case .addMe: + cell.actionButton.addTarget(self, action: #selector(didTapAddMe(_:)), for: .touchUpInside) + case .switch: + cell.actionButton.addTarget(self, action: #selector(didTapSwitch(_:)), for: .touchUpInside) + case .remove: + cell.actionButton.addTarget(self, action: #selector(didTapRemove(_:)), for: .touchUpInside) + case nil: + break + } + } + + fileprivate func frequencyViewModel(for actionButton: FrequencyActionButton) -> ZippyqFrequencyViewModel? { + let location = actionButton.convert(CGPoint(x: actionButton.bounds.midX, y: actionButton.bounds.midY), to: tableView) + guard let indexPath = tableView.indexPathForRow(at: location) else { return nil } + return roundViewModels[indexPath.section].frequencyViewModels[indexPath.row] + } + + @objc fileprivate func didTapAddMe(_ sender: FrequencyActionButton) { + guard let viewModel = frequencyViewModel(for: sender), + let user = APIServices.shared.myUser else { + sender.isLoading = false + return + } + + zippyqAPI.addPilot(to: race.id, pilotId: user.id, + slot: viewModel.slot, cycle: viewModel.cycle, + heat: viewModel.heat) { [weak self, weak sender] response, error in + self?.completeAction(sender, with: response, error: error) + } + } + + @objc fileprivate func didTapSwitch(_ sender: FrequencyActionButton) { + guard let viewModel = frequencyViewModel(for: sender), + let user = APIServices.shared.myUser else { + sender.isLoading = false + return + } + + zippyqAPI.addPilot(to: race.id, pilotId: user.id, + slot: viewModel.slot, cycle: viewModel.cycle, + heat: viewModel.heat) { [weak self, weak sender] response, error in + self?.completeAction(sender, with: response, error: error) + } + } + + @objc fileprivate func didTapRemove(_ sender: FrequencyActionButton) { + guard let viewModel = frequencyViewModel(for: sender), + let user = viewModel.user, + APIServices.shared.isCurrentUser(user) else { + sender.isLoading = false + return + } + + zippyqAPI.removePilot(from: race.id, pilotId: user.id, + slot: viewModel.slot, cycle: viewModel.cycle, + heat: viewModel.heat) { [weak self, weak sender] response, error in + self?.completeAction(sender, with: response, error: error) + } + } + + fileprivate func completeAction(_ actionButton: FrequencyActionButton?, + with response: ZippyqResponse?, + error: NSError?) { + actionButton?.isLoading = false + + if let response { + updateContent(with: response) + } else if let error { + let title = actionButton?.action?.failureTitle ?? "Error" + AlertUtil.presentAlertMessage(error.localizedDescription, title: title, delay: 0.25) + + Clog.log("ZippyQ action failed: \(error.localizedDescription)") + } + } + + fileprivate func showUserProfile(_ user: User) { + let viewController = UserViewController(with: user) + navigationController?.pushViewController(viewController, animated: true) + } +} + +// MARK: - UITableView Delegate + +extension ZippyqViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + let viewModel = roundViewModels[indexPath.section].frequencyViewModels[indexPath.row] + guard let user = viewModel.user else { return } + + showUserProfile(user) + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let viewModel = roundViewModels[section] + guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: CollapsableHeaderView.identifier) as? CollapsableHeaderView else { return nil } + + header.title = viewModel.titleLabel + header.contextualText = viewModel.contextualLabel + header.isExpanded = isQueueExpanded(at: section) + header.textPill.text = viewModel.badge.title + header.textPill.titleLabel.textColor = viewModel.badge == .live ? Color.light : Color.blue + header.textPill.backgroundColor = viewModel.badge == .live ? Color.green : Color.yellow + header.didTapView = { [weak self] in + self?.toggleQueue(at: section) + } + return header + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return CollapsableHeaderView.headerHeight + } +} diff --git a/RaceSync/View Models/ProfileViewModel.swift b/RaceSync/View Models/ProfileViewModel.swift index 4f737700..0996abfc 100644 --- a/RaceSync/View Models/ProfileViewModel.swift +++ b/RaceSync/View Models/ProfileViewModel.swift @@ -37,9 +37,9 @@ class ProfileViewModel: Descriptable { self.type = .user self.id = user.id - self.title = ViewModelHelper.titleLabel(for: user.userName, country: user.country) + self.title = ViewModelHelper.titleLabel(for: user.userName) self.mainTextLabel = user.displayName.uppercased() - self.secondaryTextLabel = ViewModelHelper.locationLabel(for: user.city, state: user.state) + self.secondaryTextLabel = ViewModelHelper.locationLabel(for: user.city, state: user.state, country: user.country) self.backgroundUrl = user.profileBackgroundUrl self.pictureUrl = user.profilePictureUrl diff --git a/RaceSync/View Models/RaceViewModel.swift b/RaceSync/View Models/RaceViewModel.swift index b4cc804c..f2c8f977 100644 --- a/RaceSync/View Models/RaceViewModel.swift +++ b/RaceSync/View Models/RaceViewModel.swift @@ -28,6 +28,7 @@ class RaceViewModel: Descriptable { let distanceLabel: String let distance: Double let participantCount: Int + let limitLabel: String let feeLabel: String let chapterLabel: String let seriesLabel: String @@ -58,6 +59,7 @@ class RaceViewModel: Descriptable { self.ownerLabel = race.ownerUserName self.seasonLabel = race.seasonName self.imageUrl = Self.imageUrl(for: race) + self.limitLabel = Self.limitLabelString(for: race) self.feeLabel = Self.feeLabelString(for: race) } @@ -286,16 +288,24 @@ extension RaceViewModel { return attributedString } + + static func limitLabelString(for race: Race) -> String { + guard race.pilotLimit > 0 else { + return "" + } + return "Limited to \(race.pilotLimit) pilot\(race.pilotLimit == 1 ? "" : "s")" + } static func feeLabelString(for race: Race) -> String { - if race.fee > 0 { - if race.amountPaid > 0 { - return String(format: "✓ Paid $%.2f", race.amountPaid) - } else if race.amountDue > 0 { - return String(format: "Fee: %.2f USD", race.fee) - } + guard race.fee > 0 else { + return "" + } + + if race.amountPaid > 0 { + return String(format: "✓ Paid $%.2f", race.amountPaid) + } else { + return String(format: "Fee: %.2f USD", race.fee) } - return "" } } diff --git a/RaceSync/View Models/ZippyqFrequencyViewModel.swift b/RaceSync/View Models/ZippyqFrequencyViewModel.swift new file mode 100644 index 00000000..364d669a --- /dev/null +++ b/RaceSync/View Models/ZippyqFrequencyViewModel.swift @@ -0,0 +1,92 @@ +// +// ZippyqFrequencyViewModel.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-07-29. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import UIKit +import RaceSyncAPI + +enum ZippyqFrequencyAction { + case addMe + case `switch` + case remove +} + +class ZippyqFrequencyViewModel { + + let frequency: Frequency + let slot: Int + let cycle: Int + let heat: Int + let channelLabel: String + let frequencyColor: UIColor + let user: User? + let titleLabel: String? + let isAssigned: Bool + let isCurrentUser: Bool + let subtitleLabel: String? + let imageUrl: String? + let resultLabel: String? + let action: ZippyqFrequencyAction? + let isActionEnabled: Bool + + init(with frequency: Frequency, + queue: ZippyQueue, + pilotStats: ZippyqPilotCollection, + maximumPackCount: Int32) { + + self.frequency = frequency + slot = Int(frequency.slot) + cycle = Int(queue.cycle) + heat = Int(queue.heat) + channelLabel = frequency.channelLabel + frequencyColor = FrequencyColor.color(for: frequency.frequency) + + let entry = queue.entries.first { $0.frequency?.frequency == frequency.frequency } + user = entry?.user + resultLabel = queue.status == .running ? entry?.fastest3Laps : nil + isCurrentUser = APIServices.shared.isCurrentUser(entry?.user) + + if let user = entry?.user, let stats = pilotStats[user.id] { + let userViewModel = UserViewModel(with: user) + titleLabel = userViewModel.username + isAssigned = true + subtitleLabel = "Pack \(stats.usedCount) of \(maximumPackCount)" + imageUrl = userViewModel.pictureUrl + } else { + titleLabel = "Empty" + isAssigned = false + subtitleLabel = nil + imageUrl = nil + } + + guard queue.status == .queued, let myUserId = APIServices.shared.myUser?.id else { + action = nil + isActionEnabled = false + return + } + + let myEntry = queue.entries.first { $0.user?.id == myUserId } + let isMyFrequency = entry?.user?.id == myUserId + let isEmpty = entry == nil + let queuedCount = pilotStats[myUserId]?.queuedCount ?? 0 + let canQueueAnotherPack = queuedCount < maximumPackCount + + if isMyFrequency { + action = .remove + isActionEnabled = true + } else if myEntry != nil, isEmpty { + action = .switch + isActionEnabled = true + } else if myEntry == nil, isEmpty { + action = .addMe + isActionEnabled = canQueueAnotherPack + } else { + action = nil + isActionEnabled = false + } + } +} diff --git a/RaceSync/View Models/ZippyqRoundViewModel.swift b/RaceSync/View Models/ZippyqRoundViewModel.swift new file mode 100644 index 00000000..32943df7 --- /dev/null +++ b/RaceSync/View Models/ZippyqRoundViewModel.swift @@ -0,0 +1,65 @@ +// +// ZippyqRoundViewModel.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-07-29. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import RaceSyncAPI + +class ZippyqRoundViewModel { + + let id: String + let titleLabel: String + let contextualLabel: String + let badge: ZippyqRoundBadge + let frequencyViewModels: [ZippyqFrequencyViewModel] + + init(with queue: ZippyQueue, + frequencies: [Frequency], + pilotStats: ZippyqPilotCollection, + maximumPackCount: Int32, + isUpNext: Bool) { + + id = "\(queue.cycle):\(queue.heat)" + titleLabel = "Round \(queue.cycle)" + + let viewModels = frequencies.map { + ZippyqFrequencyViewModel( + with: $0, + queue: queue, + pilotStats: pilotStats, + maximumPackCount: maximumPackCount + ) + } + frequencyViewModels = queue.status == .queued ? viewModels : viewModels.filter { $0.isAssigned } + + let pilotCount = queue.entries.count + switch queue.status { + case .running: + badge = .live + contextualLabel = "\(pilotCount) Racing" + case .queued: + badge = isUpNext ? .upNext : .none + contextualLabel = "\(pilotCount) Waiting" + case .previous: + badge = .none + contextualLabel = "\(pilotCount) Raced" + } + } +} + +enum ZippyqRoundBadge: Equatable { + case none + case live + case upNext + + var title: String? { + switch self { + case .none: return nil + case .live: return "Live".uppercased() + case .upNext: return "Up Next".uppercased() + } + } +} diff --git a/RaceSyncAPI/APIServices.swift b/RaceSyncAPI/APIServices.swift index 2aae3678..3cc18589 100644 --- a/RaceSyncAPI/APIServices.swift +++ b/RaceSyncAPI/APIServices.swift @@ -33,6 +33,11 @@ public class APIServices { Clog.log("Did set my User with id: \(String(describing: myUser?.id))") } } + + public func isCurrentUser(_ user: User?) -> Bool { + let currentUserId = APIServices.shared.myUser?.id + return currentUserId.map { user?.id == $0 } ?? false + } // My Home Chapter public var myChapter: Chapter? { diff --git a/RaceSyncAPI/APISessionManager.swift b/RaceSyncAPI/APISessionManager.swift index 97136789..ab92e737 100644 --- a/RaceSyncAPI/APISessionManager.swift +++ b/RaceSyncAPI/APISessionManager.swift @@ -78,7 +78,7 @@ public class APISessionManager { fileprivate static let valet = Valet.valet(with: Identifier(nonEmpty: "RaceSync")!, accessibility: .whenUnlocked) - fileprivate static let sessionIdKey = "com.multigp.RaceSync.session.id" - fileprivate static let sessionEmailKey = "com.multigp.RaceSync.session.email" - fileprivate static let sessionPwdKey = "com.multigp.RaceSync.session.pwd" + fileprivate static let sessionIdKey = "com.multigp.racesync.session.id" + fileprivate static let sessionEmailKey = "com.multigp.racesync.session.email" + fileprivate static let sessionPwdKey = "com.multigp.racesync.session.pwd" } diff --git a/RaceSyncAPI/APISettings.swift b/RaceSyncAPI/APISettings.swift index e8fac468..6efb07bb 100644 --- a/RaceSyncAPI/APISettings.swift +++ b/RaceSyncAPI/APISettings.swift @@ -34,7 +34,7 @@ public enum APISettingsType: Int, EnumTitle { } } -public let APISettingsDomain: String = "com.multigp.RaceSync.settings" +public let APISettingsDomain: String = "com.multigp.racesync.settings" public class APISettings { diff --git a/RaceSyncAPI/Constants/APIConstants.swift b/RaceSyncAPI/Constants/APIConstants.swift index afd463bb..97e359fe 100644 --- a/RaceSyncAPI/Constants/APIConstants.swift +++ b/RaceSyncAPI/Constants/APIConstants.swift @@ -42,6 +42,11 @@ enum EndPoint { static let raceFinalize = "race/finalize" static let racePayments = "race/getRacePayments" + static let zippyQList = "zippyq/list" + static let zippyQRevision = "zippyq/revision" + static let zippyQAddPilot = "zippyq/addPilot" + static let zippyQRemovePilot = "zippyq/removePilot" + static let seriesList = "series/list" static let seriesView = "series/view" static let seriesApprove = "series/approve" @@ -177,10 +182,15 @@ public enum ParamKey { static let batteryRestriction = "batteryRestriction" static let propellerSizeRestriction = "propellerSizeRestriction" static let frequency = "frequency" + static let frequencies = "frequencies" + static let cycle = "cycle" + static let heat = "heat" + static let slot = "slot" static let group = "group" static let groupSlot = "groupSlot" static let band = "band" static let channel = "channel" + static let channelLabel = "channelLabel" static let chapterCount = "chapterCount" static let raceCount = "raceCount" static let memberCount = "memberCount" @@ -194,6 +204,7 @@ public enum ParamKey { static let isPublic = "isPublic" static let pushNotificationEnabled = "pushNotificationEnabled" static let query = "query" + static let pilotLimit = "pilotLimit" static let fee = "fee" static let paymentRequiredToJoin = "paymentRequiredToJoin" static let amountPaid = "amountpaid" diff --git a/RaceSyncAPI/Data/RaceData.swift b/RaceSyncAPI/Data/RaceData.swift index 2203b075..69d48473 100644 --- a/RaceSyncAPI/Data/RaceData.swift +++ b/RaceSyncAPI/Data/RaceData.swift @@ -23,6 +23,7 @@ public struct RaceData: Descriptable { public var content: String? = nil // Default race values, useful for new race creation + public var pilotLimit: Int32 = 0 public var fee: Float32 = 0.0 public var feeRequired: Bool = false public var format: String = ScoringFormat.fastest3Laps.rawValue @@ -68,6 +69,7 @@ public struct RaceData: Descriptable { self.qualifying = race.disableSlotAutoPopulation.rawValue self.privacy = race.type.rawValue self.status = race.status.rawValue + self.pilotLimit = race.pilotLimit self.fee = race.fee self.feeRequired = race.isPaymentRequiredToJoin self.funfly = race.scoringDisabled @@ -99,6 +101,7 @@ public struct RaceData: Descriptable { params[ParamKey.disableSlotAutoPopulation] = qualifying params[ParamKey.type] = privacy params[ParamKey.status] = status + params[ParamKey.pilotLimit] = pilotLimit params[ParamKey.fee] = fee params[ParamKey.paymentRequiredToJoin] = feeRequired.intValue params[ParamKey.scoringDisabled] = funfly diff --git a/RaceSyncAPI/Enums/StandingSeasonEnum.swift b/RaceSyncAPI/Enums/StandingSeasonEnums.swift similarity index 100% rename from RaceSyncAPI/Enums/StandingSeasonEnum.swift rename to RaceSyncAPI/Enums/StandingSeasonEnums.swift diff --git a/RaceSyncAPI/Enums/ZippyqEnums.swift b/RaceSyncAPI/Enums/ZippyqEnums.swift new file mode 100644 index 00000000..b62aabc4 --- /dev/null +++ b/RaceSyncAPI/Enums/ZippyqEnums.swift @@ -0,0 +1,23 @@ +// +// ZippyqEnums.swift +// RaceSyncAPI +// +// Created by Ignacio Romero on 2026-07-28. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import Foundation + +public enum ZippyqStatus: String, EnumTitle { + case running = "running" + case queued = "queued" + case previous = "previous" + + public var title: String { + switch self { + case .running: return "Running" + case .queued: return "Queued" + case .previous: return "Previous" + } + } +} diff --git a/RaceSyncAPI/Models/Frequency.swift b/RaceSyncAPI/Models/Frequency.swift new file mode 100644 index 00000000..bd1e34c1 --- /dev/null +++ b/RaceSyncAPI/Models/Frequency.swift @@ -0,0 +1,28 @@ +// +// Frequency.swift +// RaceSyncAPI +// +// Created by Ignacio Romero on 2026-07-28. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import Foundation +import ObjectMapper + +public class Frequency: Mappable { + public var slot: Int32 = 0 + public var frequency: String = "" + public var band: String = "" + public var channel: String = "" + public var channelLabel: String = "" + + public required init?(map: Map) {} + + public func mapping(map: Map) { + slot <- (map[ParamKey.slot], IntegerTransform()) + frequency <- (map[ParamKey.frequency], MapperUtil.anyStringTransform) + band <- map[ParamKey.band] + channel <- (map[ParamKey.channel], MapperUtil.anyStringTransform) + channelLabel <- map[ParamKey.channelLabel] + } +} diff --git a/RaceSyncAPI/Models/Race.swift b/RaceSyncAPI/Models/Race.swift index 6ba84e6a..daa98984 100644 --- a/RaceSyncAPI/Models/Race.swift +++ b/RaceSyncAPI/Models/Race.swift @@ -73,6 +73,7 @@ public class Race: Mappable, Descriptable { public var batteryRestriction: String = "" public var propSizeRestriction: String = "" + public var pilotLimit: Int32 = 0 public var fee: Float = 0 public var isPaymentRequiredToJoin: Bool = false public var amountDue: Float = 0 @@ -168,7 +169,8 @@ public class Race: Mappable, Descriptable { sizeRestriction <- map[ParamKey.sizeRestriction] batteryRestriction <- map[ParamKey.batteryRestriction] propSizeRestriction <- map[ParamKey.propellerSizeRestriction] - + + pilotLimit <- map[ParamKey.pilotLimit] fee <- (map[ParamKey.fee], FloatTransform()) isPaymentRequiredToJoin <- (map[ParamKey.paymentRequiredToJoin], BooleanTransform()) amountDue <- (map[ParamKey.amountDue], FloatTransform()) diff --git a/RaceSyncAPI/Models/ZippyQueue.swift b/RaceSyncAPI/Models/ZippyQueue.swift new file mode 100644 index 00000000..824f7030 --- /dev/null +++ b/RaceSyncAPI/Models/ZippyQueue.swift @@ -0,0 +1,101 @@ +// +// ZippyQueue.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-07-27. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import Foundation +import ObjectMapper + +public typealias ZippyqPilotCollection = [ObjectId: ZippyqPilotStats] + +public class ZippyqResponse: Mappable { + public var queues: [ZippyQueue] = [] + public var pilotStats: ZippyqPilotCollection = [:] + public var frequencies: [Frequency] = [] + + public required init?(map: Map) {} + + public func mapping(map: Map) { + queues <- map[ParamKey.data] + pilotStats <- map["pilotStats"] + frequencies <- map[ParamKey.frequencies] + } +} + +public class ZippyQueue: Mappable { + public var cycle: Int32 = 0 + public var heat: Int32 = 0 + public var status: ZippyqStatus = .previous + public var entries: [ZippyqEntry] = [] + + public required init?(map: Map) {} + + public func mapping(map: Map) { + cycle <- (map[ParamKey.cycle], IntegerTransform()) + heat <- (map[ParamKey.heat], IntegerTransform()) + status <- (map[ParamKey.status], EnumTransform()) + entries <- map[ParamKey.entries] + } +} + +public class ZippyqEntry: Mappable { + + public var id: ObjectId = "" + public var raceEntryId: ObjectId = "" + public var cycle: Int32 = 0 + public var heat: Int32 = 0 + public var slot: Int32 = 0 + public var score: String? + public var totalLaps: String? + public var totalTime: String? + public var fastest3Laps: String? + public var fastest2Laps: String? + public var fastestLap: String? + public var frequency: Frequency? + public var user: User? + + public required init?(map: Map) {} + + public func mapping(map: Map) { + id <- (map[ParamKey.id], MapperUtil.anyStringTransform) + raceEntryId <- (map[ParamKey.raceEntryId], MapperUtil.anyStringTransform) + cycle <- (map[ParamKey.cycle], IntegerTransform()) + heat <- (map[ParamKey.heat], IntegerTransform()) + slot <- (map[ParamKey.slot], IntegerTransform()) + score <- map[ParamKey.score] + totalLaps <- map[ParamKey.totalLaps] + totalTime <- map[ParamKey.totalTime] + fastest3Laps <- map[ParamKey.fastest3Laps] + fastest2Laps <- map[ParamKey.fastest2Laps] + fastestLap <- map[ParamKey.fastestLap] + frequency <- map[ParamKey.frequency] + user <- map["user"] + } +} + +public class ZippyqPilotStats: Mappable { + public var usedCount: Int32 = 0 + public var queuedCount: Int32 = 0 + public var nextRounds: [String] = [] + + public required init?(map: Map) {} + + public func mapping(map: Map) { + usedCount <- (map["usedCount"], IntegerTransform()) + queuedCount <- (map["queuedCount"], IntegerTransform()) + nextRounds <- map["nextRounds"] + } +} + +public class ZippyqRevision: Mappable { + public var value: String = "" + + public required init?(map: Map) {} + + public func mapping(map: Map) { + value <- map["revision"] + } +} diff --git a/RaceSyncAPI/Network/RepositoryAdapter.swift b/RaceSyncAPI/Network/RepositoryAdapter.swift index 51bea434..7b022c84 100644 --- a/RaceSyncAPI/Network/RepositoryAdapter.swift +++ b/RaceSyncAPI/Network/RepositoryAdapter.swift @@ -14,12 +14,13 @@ import SwiftyJSON class RepositoryAdapter { let networkAdapter = NetworkAdapter(serverUri: MGPWeb.getUrl(for: .apiBase)) + private static let responseQueue = DispatchQueue(label: "com.multigp.racesync.repository.response", qos: .userInitiated, attributes: .concurrent) - func getObject(_ endPoint: String, parameters: Params? = nil, type: Element.Type, keyPath: String = ParamKey.data, _ completion: @escaping ObjectCompletionBlock) { + func getObject(_ endPoint: String, parameters: Params? = nil, type: Element.Type, keyPath: String? = ParamKey.data, _ completion: @escaping ObjectCompletionBlock) { networkAdapter.httpRequest(endPoint, method: .post, parameters: parameters) { (request) in Clog.log("Starting request \(String(describing: request.request?.url)) with parameters \(String(describing: parameters))") - request.responseObject(keyPath: keyPath, completionHandler: { (response: DataResponse) in + request.responseObject(queue: Self.responseQueue, keyPath: keyPath, completionHandler: { (response: DataResponse) in Clog.log("Ended request with code \(String(describing: response.response?.statusCode))") if let response = response.response { @@ -30,16 +31,16 @@ class RepositoryAdapter { switch response.result { case .success(let value): - let json = JSON(value) - if let errors = ErrorUtil.errors(fromJSON: json) { - completion(nil, errors.first) + if let data = response.data, + let errors = ErrorUtil.errors(fromJSON: JSON(data)) { + Self.completeOnMain(completion, object: nil, error: errors.first) } else { - completion(value, nil) + Self.completeOnMain(completion, object: value, error: nil) } case .failure: let error = ErrorUtil.parseError(response) Clog.log("Network error \(error.debugDescription)") - completion(nil, error) + Self.completeOnMain(completion, object: nil, error: error) } }) } @@ -56,7 +57,7 @@ class RepositoryAdapter { networkAdapter.httpRequest(finalEndpoint, method: .post, parameters: parameters) { (request) in Clog.log("Starting request \(String(describing: request.request?.url)) with parameters \(String(describing: parameters))") - request.responseArray(keyPath: keyPath, completionHandler: { (response: DataResponse<[Element]>) in + request.responseArray(queue: Self.responseQueue, keyPath: keyPath, completionHandler: { (response: DataResponse<[Element]>) in var log: String = "+ Ended request with code \(String(describing: response.response?.statusCode)) " if let code = response.response?.statusCode, code == 401 { @@ -67,7 +68,7 @@ class RepositoryAdapter { switch response.value { case .none: log += "(0 objects)" - completion([], nil) + Self.completeOnMain(completion, object: [], error: nil) Clog.log("\(log)") return default: @@ -78,15 +79,15 @@ class RepositoryAdapter { case .success(let value): let json = JSON(value) if let errors = ErrorUtil.errors(fromJSON: json) { - completion(nil, errors.first) + Self.completeOnMain(completion, object: nil, error: errors.first) log += " Network Error: \(errors.first.debugDescription)" } else { - completion(value, nil) + Self.completeOnMain(completion, object: value, error: nil) log += "(\(value.count) objects)" } case .failure: let error = ErrorUtil.parseError(response) - completion(nil, error) + Self.completeOnMain(completion, object: nil, error: error) log += " Network Error: \(error.debugDescription)" } @@ -98,7 +99,7 @@ class RepositoryAdapter { func performAction(_ endPoint: String, parameters: Params? = nil, completion: @escaping StatusCompletionBlock) { networkAdapter.httpRequest(endPoint, method: .post, parameters: parameters) { (request) in Clog.log("Starting request \(String(describing: request.request?.url)) with parameters \(String(describing: parameters))") - request.responseJSON { (response) in + request.responseJSON(queue: Self.responseQueue) { (response) in Clog.log("Ended request with code \(String(describing: response.response?.statusCode))") if let code = response.response?.statusCode, code == 401 { @@ -109,12 +110,12 @@ class RepositoryAdapter { case .success(let value): let json = JSON(value) if let errors = ErrorUtil.errors(fromJSON: json) { - completion(false, errors.first) + Self.completeOnMain(completion, status: false, error: errors.first) } else { - completion(json[ParamKey.status].bool ?? false, nil) + Self.completeOnMain(completion, status: json[ParamKey.status].bool ?? false, error: nil) } case .failure: - completion(false, response.error as NSError?) + Self.completeOnMain(completion, status: false, error: response.error as NSError?) } } } @@ -159,3 +160,18 @@ class RepositoryAdapter { } } } + +private extension RepositoryAdapter { + + static func completeOnMain(_ completion: @escaping ObjectCompletionBlock, object: Element?, error: NSError?) { + DispatchQueue.main.async { + completion(object, error) + } + } + + static func completeOnMain(_ completion: @escaping StatusCompletionBlock, status: Bool, error: NSError?) { + DispatchQueue.main.async { + completion(status, error) + } + } +} diff --git a/RaceSyncAPI/Network/ZippyqApi.swift b/RaceSyncAPI/Network/ZippyqApi.swift new file mode 100644 index 00000000..406bf88c --- /dev/null +++ b/RaceSyncAPI/Network/ZippyqApi.swift @@ -0,0 +1,94 @@ +// +// ZippyqApi.swift +// RaceSyncAPI +// +// Created by Ignacio Romero on 2026-07-27. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import Foundation +import ObjectMapper + +public typealias ZippyqRevisionHash = String + +public protocol ZippyqApiInterface { + + func getQueues(for raceId: ObjectId, completion: @escaping ObjectCompletionBlock) + + func getRevision(for raceId: ObjectId, + revision: ZippyqRevisionHash?, + completion: @escaping ObjectCompletionBlock) + + func addPilot(to raceId: ObjectId, + pilotId: ObjectId, + slot: Int?, + cycle: Int?, + heat: Int?, + completion: @escaping ObjectCompletionBlock) + + func removePilot(from raceId: ObjectId, + pilotId: ObjectId, + slot: Int?, + cycle: Int?, + heat: Int?, + completion: @escaping ObjectCompletionBlock) +} + +public class ZippyqApi: ZippyqApiInterface { + + fileprivate let repositoryAdapter = RepositoryAdapter() + + public init() {} + + public func getQueues(for raceId: ObjectId, completion: @escaping ObjectCompletionBlock) { + let endpoint = "\(EndPoint.zippyQList)?\(ParamKey.raceId)=\(raceId)" + repositoryAdapter.getObject(endpoint, type: ZippyqResponse.self, keyPath: nil, completion) + } + + public func getRevision(for raceId: ObjectId, + revision: ZippyqRevisionHash?, + completion: @escaping ObjectCompletionBlock) { + let endpoint = "\(EndPoint.zippyQRevision)?\(ParamKey.raceId)=\(raceId)" + repositoryAdapter.getObject(endpoint, type: ZippyqRevision.self, completion) + } + + public func addPilot(to raceId: ObjectId, + pilotId: ObjectId, + slot: Int? = nil, + cycle: Int? = nil, + heat: Int? = nil, + completion: @escaping ObjectCompletionBlock) { + performPilotAction(EndPoint.zippyQAddPilot, raceId: raceId, pilotId: pilotId, + slot: slot, cycle: cycle, heat: heat, completion: completion) + } + + public func removePilot(from raceId: ObjectId, + pilotId: ObjectId, + slot: Int? = nil, + cycle: Int? = nil, + heat: Int? = nil, + completion: @escaping ObjectCompletionBlock) { + performPilotAction(EndPoint.zippyQRemovePilot, raceId: raceId, pilotId: pilotId, + slot: slot, cycle: cycle, heat: heat, completion: completion) + } +} + +fileprivate extension ZippyqApi { + + func performPilotAction(_ endpoint: String, + raceId: ObjectId, + pilotId: ObjectId, + slot: Int?, + cycle: Int?, + heat: Int?, + completion: @escaping ObjectCompletionBlock) { + + var params: Params = [ParamKey.raceId: raceId] + if let slot { params[ParamKey.slot] = slot } + if let cycle { params[ParamKey.cycle] = cycle } + if let heat { params[ParamKey.heat] = heat } + + let endpoint = "\(endpoint)?\(ParamKey.pilotId)=\(pilotId)" + repositoryAdapter.getObject(endpoint, parameters: params, type: ZippyqResponse.self, keyPath: nil, completion) + } +} diff --git a/RaceSyncAPI/Utils/Joinable.swift b/RaceSyncAPI/Utils/Joinable.swift deleted file mode 100644 index 29ac6f12..00000000 --- a/RaceSyncAPI/Utils/Joinable.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// Joinable.swift -// RaceSyncAPI -// -// Created by Ignacio Romero Zurbuchen on 2020-03-23. -// Copyright © 2020 MultiGP Inc. All rights reserved. -// - -import Foundation - diff --git a/Scripts/copy-credentials.sh b/Scripts/copy-credentials.sh new file mode 100644 index 00000000..d7bd41a4 --- /dev/null +++ b/Scripts/copy-credentials.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -euo pipefail + +FILENAME="credentials.plist" +DEBUG_FILENAME="credentials-debug.plist" + +# Pick source file by configuration +if [[ "${CONFIGURATION}" == "Debug" ]]; then + SRC_NAME="${DEBUG_FILENAME}" +else + SRC_NAME="${FILENAME}" +fi + +# Search upwards from SRCROOT for a credentials/ folder +SEARCH_DIR="${SRCROOT}" +while [[ "${SEARCH_DIR}" != "/" ]]; do + if [[ -d "${SEARCH_DIR}/credentials" ]]; then + SRC_PATH="${SEARCH_DIR}/credentials/${SRC_NAME}" + break + fi + SEARCH_DIR="$(dirname "${SEARCH_DIR}")" +done + +if [[ -z "${SRC_PATH:-}" ]]; then + echo "ERROR: Could not find credentials/ folder above SRCROOT=${SRCROOT}" >&2 + exit 1 +fi + +if [[ ! -f "${SRC_PATH}" ]]; then + echo "ERROR: Source file not found: ${SRC_PATH}" >&2 + exit 1 +fi + +# Destination is always the app bundle — no framework hunting needed +DEST_PATH="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/${FILENAME}" + +echo "Copying: ${SRC_PATH} → ${DEST_PATH}" +cp -fv "${SRC_PATH}" "${DEST_PATH}" +echo "Done."