diff --git a/RaceSync.xcodeproj/project.pbxproj b/RaceSync.xcodeproj/project.pbxproj index 99feabed..8e57308a 100644 --- a/RaceSync.xcodeproj/project.pbxproj +++ b/RaceSync.xcodeproj/project.pbxproj @@ -121,7 +121,6 @@ 4F714DE123CD4906000C4036 /* CalendarActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F714DE023CD4906000C4036 /* CalendarActivity.swift */; }; 4F714DE323CD5EAA000C4036 /* SafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F714DE223CD5EAA000C4036 /* SafariActivity.swift */; }; 4F71B3262E47C1880029D3CB /* PaypalActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F71B3252E47C1880029D3CB /* PaypalActivity.swift */; }; - 4F71B3282E490F760029D3CB /* RoundedSelectionTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F71B3272E490F760029D3CB /* RoundedSelectionTabBar.swift */; }; 4F71B32B2E4910100029D3CB /* Collection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F71B32A2E4910100029D3CB /* Collection+Extensions.swift */; }; 4F73DAEA2DEE7300007B15F2 /* String+UIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F73DAE92DEE7300007B15F2 /* String+UIExtensions.swift */; }; 4F791C9823A6105700F15FD7 /* TextPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F791C9723A6105700F15FD7 /* TextPill.swift */; }; @@ -207,6 +206,7 @@ 4FA26833237A5983008970AC /* ObjectMapper+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA26832237A5983008970AC /* ObjectMapper+Extensions.swift */; }; 4FA26835237A80B9008970AC /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA26834237A80B9008970AC /* ActionButton.swift */; }; 4FA706072EC33873006EAE70 /* RaceSyncAPI.h in Headers */ = {isa = PBXBuildFile; fileRef = 4F13426F2360DA4B00A9DBDE /* RaceSyncAPI.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 4FA7FDA22F9E821300D33266 /* EventsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA7FDA12F9E821300D33266 /* EventsViewController.swift */; }; 4FAAA88523AA15EA00A004DC /* MapKit+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAAA88423AA15EA00A004DC /* MapKit+Extensions.swift */; }; 4FAAF8242E80FFF5002CF62E /* Series.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAAF8232E80FFF5002CF62E /* Series.swift */; }; 4FAC24A823DC3E06009AD585 /* UILabel+LinesCount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAC24A723DC3E06009AD585 /* UILabel+LinesCount.swift */; }; @@ -268,6 +268,11 @@ 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 */; }; + 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 /* MGPEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EE42FBC09C800460A74 /* MGPEvent.swift */; }; + 4FE91EE72FBC3AFB00460A74 /* EventsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EE62FBC3AFB00460A74 /* EventsController.swift */; }; + 4FE91EE92FBD2D6B00460A74 /* EventSessionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EE82FBD2D6B00460A74 /* EventSessionTableViewCell.swift */; }; 4FE96E9224E3E687009A7F53 /* HeaderStretchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE96E9124E3E687009A7F53 /* HeaderStretchable.swift */; }; 4FEADB7224147D2D00F82F0D /* LocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FEADB7124147D2D00F82F0D /* LocationManager.swift */; }; 4FEADB742416B3D400F82F0D /* EventTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FEADB732416B3D400F82F0D /* EventTracker.swift */; }; @@ -447,7 +452,6 @@ 4F714DE023CD4906000C4036 /* CalendarActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarActivity.swift; sourceTree = ""; }; 4F714DE223CD5EAA000C4036 /* SafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariActivity.swift; sourceTree = ""; }; 4F71B3252E47C1880029D3CB /* PaypalActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaypalActivity.swift; sourceTree = ""; }; - 4F71B3272E490F760029D3CB /* RoundedSelectionTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedSelectionTabBar.swift; sourceTree = ""; }; 4F71B32A2E4910100029D3CB /* Collection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Extensions.swift"; sourceTree = ""; }; 4F73DAE92DEE7300007B15F2 /* String+UIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+UIExtensions.swift"; sourceTree = ""; }; 4F791C9723A6105700F15FD7 /* TextPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextPill.swift; sourceTree = ""; }; @@ -536,6 +540,7 @@ 4FA26830237A58E1008970AC /* ApiError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiError.swift; sourceTree = ""; }; 4FA26832237A5983008970AC /* ObjectMapper+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ObjectMapper+Extensions.swift"; sourceTree = ""; }; 4FA26834237A80B9008970AC /* ActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; + 4FA7FDA12F9E821300D33266 /* EventsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsViewController.swift; sourceTree = ""; }; 4FAAA88423AA15EA00A004DC /* MapKit+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MapKit+Extensions.swift"; sourceTree = ""; }; 4FAAF8232E80FFF5002CF62E /* Series.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Series.swift; sourceTree = ""; }; 4FAC24A723DC3E06009AD585 /* UILabel+LinesCount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+LinesCount.swift"; sourceTree = ""; }; @@ -598,6 +603,11 @@ 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 = ""; }; + 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 /* MGPEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MGPEvent.swift; sourceTree = ""; }; + 4FE91EE62FBC3AFB00460A74 /* EventsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsController.swift; sourceTree = ""; }; + 4FE91EE82FBD2D6B00460A74 /* EventSessionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventSessionTableViewCell.swift; sourceTree = ""; }; 4FE96E9124E3E687009A7F53 /* HeaderStretchable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeaderStretchable.swift; sourceTree = ""; }; 4FEADB7124147D2D00F82F0D /* LocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManager.swift; sourceTree = ""; }; 4FEADB732416B3D400F82F0D /* EventTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventTracker.swift; sourceTree = ""; }; @@ -682,6 +692,7 @@ 4FAC24A723DC3E06009AD585 /* UILabel+LinesCount.swift */, 4FA1AB3C23C94F5F007CF389 /* UIEdgeInsets+Extensions.swift */, 4FEBB27523A1F9DA007514B4 /* UITabBarController+Extensions.swift */, + 4FE91EE02FB670F000460A74 /* UIBarButtonItem+Extensions.swift */, 4F791C9923A66BA900F15FD7 /* NSAttributedString+Extensions.swift */, 4FAAA88423AA15EA00A004DC /* MapKit+Extensions.swift */, 4F9DB7AF23D618D100570483 /* UISegmentedControl+Extensions.swift */, @@ -1034,6 +1045,7 @@ 4F004E9B295A3027009C46AA /* SeasonApi.swift */, 4F004E9D295A3066009C46AA /* CourseApi.swift */, 4FE1DAD72DEAD443009143C4 /* StandingApi.swift */, + 4FE91EE22FBC098900460A74 /* EventApi.swift */, 4F5C1E3F238A7BAF00D756EB /* RepositoryAdapter.swift */, 4FA268232378F9DD008970AC /* NetworkAdapter.swift */, 4F87E4342727976E0061425B /* NetworkProxy.swift */, @@ -1097,12 +1109,23 @@ 4FDD499523B0461D009DD2DB /* Season.swift */, 4F004E99295A2CA1009C46AA /* Course.swift */, 4FE1DAD52DEAD225009143C4 /* Standing.swift */, + 4FE91EE42FBC09C800460A74 /* MGPEvent.swift */, 4FA26830237A58E1008970AC /* ApiError.swift */, 4F77D21A29940E5D009DEB41 /* Extensions */, ); path = Models; sourceTree = ""; }; + 4FA7FDA02F9E81FC00D33266 /* Events */ = { + isa = PBXGroup; + children = ( + 4FA7FDA12F9E821300D33266 /* EventsViewController.swift */, + 4FE91EE62FBC3AFB00460A74 /* EventsController.swift */, + 4FE91EE82FBD2D6B00460A74 /* EventSessionTableViewCell.swift */, + ); + path = Events; + sourceTree = ""; + }; 4FD4E1D2237F9DEF008816B3 /* Search */ = { isa = PBXGroup; children = ( @@ -1143,6 +1166,7 @@ 4FD4E1D3237F9DF8008816B3 /* Races */, 4F521F802E6E18BD00AE7C03 /* Series */, 4FE1DAD92DEB6AE4009143C4 /* Standings */, + 4FA7FDA02F9E81FC00D33266 /* Events */, 4F5C1E41238CF68F00D756EB /* Profiles */, 4F714DE423CD769F000C4036 /* Map */, 4F8B4A082DDC7AB900B735DA /* Push Messages */, @@ -1187,7 +1211,6 @@ 4F536ED02F8AEB35006FF620 /* ApproveButton.swift */, 4FA26834237A80B9008970AC /* ActionButton.swift */, 4FD4E1C2237F960A008816B3 /* CustomButton.swift */, - 4F71B3272E490F760029D3CB /* RoundedSelectionTabBar.swift */, 4F8B4A0F2DDCEECD00B735DA /* BadgeHub.swift */, 4F791C9723A6105700F15FD7 /* TextPill.swift */, 4F6AE78C2D2DCD4B008636B7 /* RankView.swift */, @@ -1355,7 +1378,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1310; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 2640; ORGANIZATIONNAME = "MultiGP Inc."; TargetAttributes = { 4F1342432360B67D00A9DBDE = { @@ -1451,10 +1474,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RaceSync/Pods-RaceSync-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RaceSync/Pods-RaceSync-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RaceSync/Pods-RaceSync-frameworks.sh\"\n"; @@ -1522,10 +1549,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RaceSyncAPITests/Pods-RaceSyncAPITests-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RaceSyncAPITests/Pods-RaceSyncAPITests-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RaceSyncAPITests/Pods-RaceSyncAPITests-frameworks.sh\"\n"; @@ -1559,12 +1590,14 @@ 4F03693D26D457DE00E30821 /* AppIconManager.swift in Sources */, 4F3D641123FE664A00DE6DF2 /* TextFieldViewController.swift in Sources */, 4FC5025A2428710D0088320B /* RaceTabbable.swift in Sources */, + 4FE91EE12FB670F000460A74 /* UIBarButtonItem+Extensions.swift in Sources */, 4F00E072242932D7001DCFC4 /* RateMe.swift in Sources */, 4F71B32B2E4910100029D3CB /* Collection+Extensions.swift in Sources */, 4FD4E1D1237F9DC8008816B3 /* DummyViewController.swift in Sources */, 4F5488962D2146C80056EA59 /* RaceScheduleViewController.swift in Sources */, 4F8668F423860900005E310A /* UniversalConstants.swift in Sources */, 4F536ED72F8C5869006FF620 /* SimpleTableViewCell+Configuration.swift in Sources */, + 4FA7FDA22F9E821300D33266 /* EventsViewController.swift in Sources */, 4FF16CC02E70EC9300B4FC51 /* ViewJoinableRegistry.swift in Sources */, 4F791C9823A6105700F15FD7 /* TextPill.swift in Sources */, 4F8B4A162DDD250D00B735DA /* MessageViewCell.swift in Sources */, @@ -1660,7 +1693,6 @@ 4F605603258C9FB4008AF93F /* UIScrollView+Extensions.swift in Sources */, 4F004EA7295B83F6009C46AA /* RaceForm.swift in Sources */, 4F8B4A0A2DDC7AEF00B735DA /* PushMessagesViewController.swift in Sources */, - 4F71B3282E490F760029D3CB /* RoundedSelectionTabBar.swift in Sources */, 4FC57C33258E1362006E210F /* AppUtil.swift in Sources */, 4F80A660296F9951008B19AF /* RichEditorToolbar.swift in Sources */, 4F7BE2672722354F00592297 /* UIViewController+Navigation.swift in Sources */, @@ -1692,6 +1724,7 @@ 4F5488982D2236950056EA59 /* String+HTML.swift in Sources */, 4F2726E12EE8C59E00001C86 /* UniversalSearchViewController.swift in Sources */, 4F5001E523823C940025A593 /* FlagEmojiGenerator.swift in Sources */, + 4FE91EE92FBD2D6B00460A74 /* EventSessionTableViewCell.swift in Sources */, 4F5C1E4B238EEC5200D756EB /* MapViewController.swift in Sources */, 4F90F0CE2E8DD04100D9F5AF /* SeriesDetailViewController.swift in Sources */, 4FAAA88523AA15EA00A004DC /* MapKit+Extensions.swift in Sources */, @@ -1714,6 +1747,7 @@ 4F8B4A142DDD15B100B735DA /* PushMessageViewModel.swift in Sources */, 4F21086B2F90142B007C7716 /* SeriesResultViewModel.swift in Sources */, 4FA213F02946C9FA00C8E45A /* AppIcon.swift in Sources */, + 4FE91EE72FBC3AFB00460A74 /* EventsController.swift in Sources */, 4F8B4A102DDCEECD00B735DA /* BadgeHub.swift in Sources */, 4FD53FAD23A0B01E00158206 /* UserViewModel.swift in Sources */, 4F03693626D457CB00E30821 /* AppIconViewController.swift in Sources */, @@ -1770,6 +1804,7 @@ 4F8668F823864389005E310A /* Descriptable.swift in Sources */, 4FEBB27D23A30F99007514B4 /* Date+Extensions.swift in Sources */, 4FBADDF624D4E2DB00A7D291 /* Array+Extensions.swift in Sources */, + 4FE91EE52FBC09C800460A74 /* MGPEvent.swift in Sources */, 4FA26833237A5983008970AC /* ObjectMapper+Extensions.swift in Sources */, 4FA26831237A58E1008970AC /* ApiError.swift in Sources */, 4FC51B212409EC8E00D654D0 /* Chapter+Extensions.swift in Sources */, @@ -1783,6 +1818,7 @@ 4FA2682A237A4CE9008970AC /* CompletionBlock.swift in Sources */, 4F95A3282F7E1FA9004AE24F /* StandingSeasonEnum.swift in Sources */, 4FD4E1BE237DCB24008816B3 /* User.swift in Sources */, + 4FE91EE32FBC098900460A74 /* EventApi.swift in Sources */, 4FA1FC9123D7B571006D4704 /* APIEnvironment.swift in Sources */, 4FA2681A2378BE4A008970AC /* APIConstants.swift in Sources */, 4F5C1E40238A7BAF00D756EB /* RepositoryAdapter.swift in Sources */, @@ -1892,6 +1928,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = TJ4PB66YQS; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -1913,6 +1950,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -1954,6 +1992,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = TJ4PB66YQS; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -1968,6 +2007,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; @@ -1978,16 +2018,14 @@ isa = XCBuildConfiguration; baseConfigurationReference = 02BF5AE129D7048EB6155053 /* Pods-RaceSync.debug.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CODE_SIGN_ENTITLEMENTS = RaceSync/RaceSync.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 103; + CURRENT_PROJECT_VERSION = 106; 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"; @@ -1996,7 +2034,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.3; + MARKETING_VERSION = 2.1; PRODUCT_BUNDLE_IDENTIFIER = com.multigp.RaceSyncApp; PRODUCT_NAME = RaceSync; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2015,16 +2053,14 @@ isa = XCBuildConfiguration; baseConfigurationReference = 0A43AF1C49328F4DFB325FDA /* Pods-RaceSync.release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CODE_SIGN_ENTITLEMENTS = RaceSync/RaceSync.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 103; + CURRENT_PROJECT_VERSION = 106; 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"; @@ -2033,7 +2069,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.3; + MARKETING_VERSION = 2.1; PRODUCT_BUNDLE_IDENTIFIER = com.multigp.RaceSyncApp; PRODUCT_NAME = RaceSync; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2052,17 +2088,15 @@ baseConfigurationReference = 40DB5CAFF0B302311BAF739B /* Pods-RaceSyncAPI.debug.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; - CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEAD_CODE_STRIPPING = NO; DEBUG_INFORMATION_FORMAT = dwarf; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = TJ4PB66YQS; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = NO; + ENABLE_MODULE_VERIFIER = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; INFOPLIST_FILE = RaceSyncAPI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -2080,7 +2114,6 @@ PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; - STRIP_INSTALLED_PRODUCT = NO; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -2098,13 +2131,11 @@ baseConfigurationReference = 0B33D87E0F5A0D3C15F5A09A /* Pods-RaceSyncAPI.release.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; - CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEAD_CODE_STRIPPING = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = TJ4PB66YQS; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -2126,7 +2157,6 @@ PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; - STRIP_INSTALLED_PRODUCT = NO; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -2143,7 +2173,6 @@ baseConfigurationReference = C0AD3F0FAC42488B672FD23B /* Pods-RaceSyncAPITests.debug.xcconfig */; buildSettings = { CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = TJ4PB66YQS; INFOPLIST_FILE = RaceSyncAPITests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2167,7 +2196,6 @@ baseConfigurationReference = 39172015E1CE1AD9586A8B50 /* Pods-RaceSyncAPITests.release.xcconfig */; buildSettings = { CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = TJ4PB66YQS; INFOPLIST_FILE = RaceSyncAPITests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/RaceSync.xcodeproj/xcshareddata/xcschemes/RaceSync [Dev].xcscheme b/RaceSync.xcodeproj/xcshareddata/xcschemes/RaceSync [Dev].xcscheme index 6f8ae17d..75e907c5 100644 --- a/RaceSync.xcodeproj/xcshareddata/xcschemes/RaceSync [Dev].xcscheme +++ b/RaceSync.xcodeproj/xcshareddata/xcschemes/RaceSync [Dev].xcscheme @@ -1,6 +1,6 @@ Void)? - - convenience init(image: UIImage? = nil, handler: (() -> Void)? = nil) { - self.init(image: image, style: .plain, target: nil, action: nil) - target = self - action = #selector(RichBarButtonItem.buttonWasTapped) - actionHandler = handler - } - convenience init(title: String = "", handler: (() -> Void)? = nil) { - self.init(title: title, style: .plain, target: nil, action: nil) - target = self - action = #selector(RichBarButtonItem.buttonWasTapped) - actionHandler = handler + // Content size is driven by Auto Layout — no manual calculation needed + scrollView.layoutIfNeeded() } - @objc func buttonWasTapped() { - actionHandler?() + @objc private func buttonTapped(_ sender: UIButton) { + guard sender.tag < options.count else { return } + options[sender.tag].action(self) } } diff --git a/RaceSync/UI Components/RoundedSelectionTabBar.swift b/RaceSync/UI Components/RoundedSelectionTabBar.swift deleted file mode 100644 index d49dc945..00000000 --- a/RaceSync/UI Components/RoundedSelectionTabBar.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// RoundedSelectionTabBar.swift -// RaceSync -// -// Created by Ignacio Romero Zurbuchen on 2025-08-10. -// Copyright © 2025 MultiGP Inc. All rights reserved. -// - -import UIKit - -class RoundedSelectionTabBar: UITabBar { - - override init(frame: CGRect) { - super.init(frame: frame) - setupSelectionBackground() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setupSelectionBackground() - } - - fileprivate func setupSelectionBackground() { - selectionBackground.backgroundColor = Color.gray50.withAlphaComponent(0.5) - selectionBackground.layer.cornerRadius = 8 - selectionBackground.layer.masksToBounds = true - insertSubview(selectionBackground, at: 0) // Behind tab bar items - } - - override func layoutSubviews() { - super.layoutSubviews() - updateSelectionFrame(animated: false) - } - - func updateSelectionFrame(animated: Bool) { - guard let items = items, let selectedItem = selectedItem, - let index = items.firstIndex(of: selectedItem), - let tabBarButton = orderedTabBarButtons[safe: index] else { return } - - let inset: CGFloat = 16 - var targetFrame = tabBarButton.frame.insetBy(dx: inset, dy: inset/8) - targetFrame.size.height += inset/2 - - if animated { - UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut, .beginFromCurrentState]) { - self.selectionBackground.frame = targetFrame - } completion: { finished in - // - } - } else { - selectionBackground.frame = targetFrame - } - } - - fileprivate let selectionBackground = UIView() -} - -extension UITabBar { - var orderedTabBarButtons: [UIControl] { - return subviews - .compactMap { $0 as? UIControl } - .sorted { $0.frame.origin.x < $1.frame.origin.x } - } -} diff --git a/RaceSync/UI Components/TextEditorViewController.swift b/RaceSync/UI Components/TextEditorViewController.swift index 3bf5de31..7dcd09ea 100644 --- a/RaceSync/UI Components/TextEditorViewController.swift +++ b/RaceSync/UI Components/TextEditorViewController.swift @@ -101,7 +101,7 @@ class TextEditorViewController: UIViewController { view.backgroundColor = Color.white let rightBarButtonTitle = "Save" - let rightBarButtonItem = UIBarButtonItem(title: rightBarButtonTitle, style: .done, target: self, action: #selector(didPressSaveButton)) + let rightBarButtonItem = UIBarButtonItem(title: rightBarButtonTitle, style: .plain, target: self, action: #selector(didPressSaveButton)) rightBarButtonItem.isEnabled = canSaveChanges() navigationItem.rightBarButtonItem = rightBarButtonItem diff --git a/RaceSync/UI Components/TextFieldViewController.swift b/RaceSync/UI Components/TextFieldViewController.swift index 7c3f3727..dffad8c4 100644 --- a/RaceSync/UI Components/TextFieldViewController.swift +++ b/RaceSync/UI Components/TextFieldViewController.swift @@ -56,7 +56,7 @@ class TextFieldViewController: FormBaseViewController { fileprivate lazy var rightBarButtonItem: UIBarButtonItem = { let title = self.delegate?.formViewControllerRightBarButtonTitle?(self) ?? "OK" - let barButtonItem = UIBarButtonItem(title: title, style: .done, target: self, action: #selector(didPressOKButton)) + let barButtonItem = UIBarButtonItem(title: title, style: .plain, target: self, action: #selector(didPressOKButton)) barButtonItem.isEnabled = allowSelection(with: textField.text) return barButtonItem }() @@ -113,7 +113,7 @@ class TextFieldViewController: FormBaseViewController { textField.text = item if let nc = navigationController, nc.viewControllers.count == 1 { - navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) + navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .plain, target: self, action: #selector(didPressCloseButton)) } navigationItem.rightBarButtonItem = rightBarButtonItem diff --git a/RaceSync/UI Components/TextPickerViewController.swift b/RaceSync/UI Components/TextPickerViewController.swift index 43de0854..e7ffeeef 100644 --- a/RaceSync/UI Components/TextPickerViewController.swift +++ b/RaceSync/UI Components/TextPickerViewController.swift @@ -56,7 +56,7 @@ class TextPickerViewController: FormBaseViewController { fileprivate lazy var rightBarButtonItem: UIBarButtonItem = { let title = self.delegate?.formViewControllerRightBarButtonTitle?(self) ?? "OK" - let barButtonItem = UIBarButtonItem(title: title, style: .done, target: self, action: #selector(didPressOKButton)) + let barButtonItem = UIBarButtonItem(title: title, style: .plain, target: self, action: #selector(didPressOKButton)) return barButtonItem }() @@ -118,7 +118,7 @@ class TextPickerViewController: FormBaseViewController { fileprivate func configureButtonBarItems() { if let nc = navigationController, nc.viewControllers.count == 1 { - navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) + navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .plain, target: self, action: #selector(didPressCloseButton)) } navigationItem.rightBarButtonItem = rightBarButtonItem diff --git a/RaceSync/UI Extensions/UIBarButtonItem+Extensions.swift b/RaceSync/UI Extensions/UIBarButtonItem+Extensions.swift new file mode 100644 index 00000000..f0c7bc39 --- /dev/null +++ b/RaceSync/UI Extensions/UIBarButtonItem+Extensions.swift @@ -0,0 +1,40 @@ +// +// UIBarButtonItem+Extensions.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-05-14. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import UIKit + +typealias BarButtonAction = (image: UIImage?, selector: Selector, tag: Int) + +extension UIBarButtonItem { + + class func spacer(width: CGFloat = 0) -> Self { + let item = Self(barButtonSystemItem: .fixedSpace, target: nil, action: nil) + item.width = width + return item + } + + // Useful for versions of iOS previous to iOS26, where the UIBarButtonItem needed to be laid out + // separately without too much space in between + static func stackedBarButtonItem(for actions: [BarButtonAction]) -> UIBarButtonItem { + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 12 + stack.alignment = .center + + for action in actions { + let button = UIButton(type: .system) + button.tag = action.tag + button.setImage(action.image, for: .normal) + button.addTarget(self, action: action.selector, for: .touchUpInside) + button.frame = CGRect(origin: .zero, size: CGSize(width: 32, height: 32)) + stack.addArrangedSubview(button) + } + + return UIBarButtonItem(customView: stack) + } +} diff --git a/RaceSync/UI Extensions/UITabBarController+Extensions.swift b/RaceSync/UI Extensions/UITabBarController+Extensions.swift index 630332fe..02cd1bce 100644 --- a/RaceSync/UI Extensions/UITabBarController+Extensions.swift +++ b/RaceSync/UI Extensions/UITabBarController+Extensions.swift @@ -13,8 +13,6 @@ extension UITabBarController { func configureTabBarController(with vcs: [UIViewController], selectedIndex: Int) { guard self.viewControllers == nil else { return } // only once - self.setValue(RoundedSelectionTabBar(), forKey: "tabBar") - self.viewControllers = vcs // Trick to pre-load each view controller diff --git a/RaceSync/UI Utils/Appearance.swift b/RaceSync/UI Utils/Appearance.swift index d60aae4b..3740c523 100644 --- a/RaceSync/UI Utils/Appearance.swift +++ b/RaceSync/UI Utils/Appearance.swift @@ -90,12 +90,20 @@ fileprivate extension Appearance { tabBarAppearance.configureWithTransparentBackground() tabBarAppearance.backgroundColor = backgroundColor tabBarAppearance.shadowColor = Color.gray100 + + // TODO: This isn't working on iOS26 but let's revisit at another time. The idea is to give more separation to each tab. + if #available(iOS 18.0, *) { + tabBarAppearance.stackedItemPositioning = .centered + tabBarAppearance.stackedItemSpacing = 80 + tabBarAppearance.stackedItemWidth = 40 + } + UITabBar.appearance().standardAppearance = tabBarAppearance if #available(iOS 15.0, *) { UITabBar.appearance().scrollEdgeAppearance = tabBarAppearance } - + // set the color and font for the title let barAppearance = UITabBar.appearance() barAppearance.barTintColor = backgroundColor @@ -105,6 +113,7 @@ fileprivate extension Appearance { barAppearance.backgroundImage = backgroundImage barAppearance.isOpaque = false barAppearance.isTranslucent = true + } static func configureToolBarAppearance() { diff --git a/RaceSync/View Cells/SimpleTableViewCell.swift b/RaceSync/View Cells/SimpleTableViewCell.swift index 8c64b4e7..135fd940 100644 --- a/RaceSync/View Cells/SimpleTableViewCell.swift +++ b/RaceSync/View Cells/SimpleTableViewCell.swift @@ -22,6 +22,7 @@ class SimpleTableViewCell: UITableViewCell { imageViewWidthConstraint?.update(offset: Constants.imageHeight * imageRatio) } } + fileprivate var imageViewWidthConstraint: Constraint? lazy var iconImageView: UIImageView = { @@ -44,10 +45,8 @@ class SimpleTableViewCell: UITableViewCell { label.textColor = Color.gray300 return label }() - - // MARK: - Private Variables - - fileprivate lazy var labelStackView: UIStackView = { + + lazy var labelStackView: UIStackView = { let stackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) stackView.axis = .vertical stackView.distribution = .fillProportionally @@ -56,6 +55,8 @@ class SimpleTableViewCell: UITableViewCell { return stackView }() + // MARK: - Private Variables + fileprivate enum Constants { static let padding: CGFloat = UniversalConstants.padding static let imageHeight: CGFloat = UniversalConstants.cellAvatarHeight @@ -91,8 +92,18 @@ class SimpleTableViewCell: UITableViewCell { contentView.addSubview(labelStackView) labelStackView.snp.makeConstraints { $0.leading.equalTo(iconImageView.snp.trailing).offset(Constants.padding) - $0.trailing.equalToSuperview().offset(-Constants.padding) + $0.trailing.equalToSuperview().inset(Constants.padding) $0.centerY.equalToSuperview() } } + + override func prepareForReuse() { + super.prepareForReuse() + + iconImageView.image = nil + titleLabel.text = nil + subtitleLabel.text = nil + + accessoryView = nil + } } diff --git a/RaceSync/View Controllers/Events/EventSessionTableViewCell.swift b/RaceSync/View Controllers/Events/EventSessionTableViewCell.swift new file mode 100644 index 00000000..ac4d8972 --- /dev/null +++ b/RaceSync/View Controllers/Events/EventSessionTableViewCell.swift @@ -0,0 +1,127 @@ +// +// EventSessionTableViewCell.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-05-19. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import UIKit + +class EventSessionTableViewCell: UITableViewCell { + + static let cellHeight: CGFloat = 72 + + lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 17, weight: .semibold) + label.textColor = Color.black + return label + }() + + lazy var subtitleLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 15, weight: .semibold) + label.textColor = Color.gray300 + return label + }() + + lazy var startTimeLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 13, weight: .regular) + label.textColor = Color.gray300 + label.textAlignment = .center + return label + }() + + lazy var endTimeLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 13, weight: .regular) + label.textColor = Color.gray300 + label.textAlignment = .center + return label + }() + + lazy var iconView: UIImageView = { + let view = UIImageView(frame: CGRect(x: 0, y: 0, width: 20, height: 20)) + view.image = SystemImg.pin_small?.withRenderingMode(.alwaysTemplate) + view.contentMode = .scaleAspectFit + view.backgroundColor = Color.clear + return view + }() + + // MARK: - Private Variables + + lazy var labelStackView: UIStackView = { + let stackView2 = UIStackView(arrangedSubviews: [iconView, subtitleLabel]) + stackView2.axis = .horizontal + stackView2.alignment = .center + stackView2.distribution = .fill + stackView2.spacing = Constants.padding / 2 + + let stackView = UIStackView(arrangedSubviews: [titleLabel, stackView2]) + stackView.axis = .vertical + stackView.distribution = .fill + stackView.alignment = .leading + stackView.spacing = 5 + return stackView + }() + + fileprivate lazy var smallLabelStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [startTimeLabel, endTimeLabel]) + stackView.axis = .vertical + stackView.distribution = .fill + stackView.alignment = .leading + stackView.spacing = 5 + return stackView + }() + + fileprivate enum Constants { + static let padding: CGFloat = UniversalConstants.padding + } + + // 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() { + + let selectedBackgroundView = UIView() + selectedBackgroundView.backgroundColor = Color.gray20 + self.selectedBackgroundView = selectedBackgroundView + + contentView.addSubview(smallLabelStackView) + smallLabelStackView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(Constants.padding) + $0.width.equalTo(60) // fixed width so it never shifts + } + + contentView.addSubview(labelStackView) + labelStackView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(smallLabelStackView.snp.trailing).offset(Constants.padding) + $0.trailing.equalToSuperview().inset(Constants.padding) + } + } + + override func prepareForReuse() { + super.prepareForReuse() + + titleLabel.text = nil + subtitleLabel.text = nil + startTimeLabel.text = nil + endTimeLabel.text = nil + + accessoryView = nil + } +} diff --git a/RaceSync/View Controllers/Events/EventsController.swift b/RaceSync/View Controllers/Events/EventsController.swift new file mode 100644 index 00000000..2ef36dc4 --- /dev/null +++ b/RaceSync/View Controllers/Events/EventsController.swift @@ -0,0 +1,114 @@ +// +// EventsController.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-05-18. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import UIKit +import RaceSyncAPI + +class EventsController { + + // MARK: - Public Variables + + let eventApi = MGPEventApi() + var io26Event: MGPEvent? + var ios26Dates: [Date] = MGPEventSession.io26Dates(from: "2026-06-10", to: "2026-06-14") + + // MARK: - Private Variables + + + // MARK: - Public Functions + + public func track(for session: MGPEventSession) -> MGPEventTrack? { + guard let tracks = io26Event?.tracks else { return nil } + + let trackId = session.trackId + return tracks.first(where: { $0.id == trackId }) + } + + public func fetchIO26Event(_ completion: @escaping ObjectCompletionBlock) { + eventApi.getIO26Event { event, error in + if let event = event { + self.io26Event = event + completion(event, nil) + } else if error != nil { + completion(nil, error) + } + } + } + + public func didFetchEvents() -> Bool { + return io26Event != nil + } + + public func io26Sessions(for date: Date, with status: MGPEventStatus? = nil, id trackId: ObjectId? = nil) -> [MGPEventSession] { + guard let sessions = io26Event?.sessions else { return [] } + + let calendar = Calendar.current + return sessions.filter { session in + guard let sessionDate = session.date, + calendar.isDate(sessionDate, inSameDayAs: date) else { return false } + if let status, session.status != status { return false } + if let trackId, session.trackId != trackId { return false } + return true + } + } + + public func io26MergedSessions(for date: Date, with status: MGPEventStatus? = nil, id trackId: ObjectId? = nil) -> [MGPEventSession] { + let sessions = io26Sessions(for: date, with: status, id: trackId) + .sorted { ($0.startTime ?? .distantPast) < ($1.startTime ?? .distantPast) } + + var merged: [MGPEventSession] = [] + + for session in sessions { + let match = merged.last(where: { + $0.activity == session.activity && + $0.trackId == session.trackId && + isConsecutive($0, session) + }) + + if let match { + match.endTime = session.endTime + } else { + merged.append(session.copy()) + } + } + + return merged + } + + public func color(for track: MGPEventTrack?) -> UIColor { + guard let id = track?.id else { return Color.gray300 } + + if id == "main_stage" { + return UIColor(hex: "4a6cf7") + } else if id == "world_cup_1" { + return UIColor(hex: "e8384f") + } else if id == "all_skills" { + return UIColor(hex: "ca8a04") + } else if id == "whoopville" { + return UIColor(hex: "9b59b6") + } else if id == "world_cup_2" { + return UIColor(hex: "f06070") + } else if id == "spec" { + return UIColor(hex: "22c55e") + } else if id == "gq_rookie" { + return UIColor(hex: "06b6d4") + } else if id == "tiny_trainier" { + return UIColor(hex: "2dd4bf") + } + + return Color.gray300 + } + + private func isConsecutive(_ a: MGPEventSession, _ b: MGPEventSession) -> Bool { + guard let endA = a.endTime, let startB = b.startTime else { return false } + // Allow up to 5 min gap to account for any scheduling slack + return startB.timeIntervalSince(endA) <= 300 + } + + +} diff --git a/RaceSync/View Controllers/Events/EventsViewController.swift b/RaceSync/View Controllers/Events/EventsViewController.swift new file mode 100644 index 00000000..7cde2efb --- /dev/null +++ b/RaceSync/View Controllers/Events/EventsViewController.swift @@ -0,0 +1,377 @@ +// +// EventsViewController.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-04-26. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import UIKit +import SnapKit +import RaceSyncAPI +import ShimmerSwift + +class EventsViewController: UIViewController, Shimmable { + + // MARK: - Public Variables + + lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.backgroundView = UIView() + tableView.backgroundView?.backgroundColor = Color.clear + tableView.backgroundColor = Color.gray50 + tableView.contentInsetAdjustmentBehavior = .always + tableView.dataSource = self + tableView.delegate = self + tableView.register(cellType: EventSessionTableViewCell.self) + tableView.tableFooterView = UIView() + tableView.refreshControl = self.refreshControl + return tableView + }() + + var shimmeringView: ShimmeringView = defaultShimmeringView() + + // MARK: - Private Variables + + fileprivate lazy var refreshControl: UIRefreshControl = { + let refreshControl = UIRefreshControl() + refreshControl.backgroundColor = Color.gray50 + refreshControl.tintColor = Color.blue + refreshControl.addTarget(self, action: #selector(didPullRefreshControl), for: .valueChanged) + return refreshControl + }() + + fileprivate lazy var headerScrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.showsHorizontalScrollIndicator = false + scrollView.alwaysBounceHorizontal = true + scrollView.isUserInteractionEnabled = false + scrollView.alpha = 0.7 + return scrollView + }() + + fileprivate lazy var headerView: UIView = { + let view = UIView() + view.backgroundColor = Color.navigationBarColor + view.tintColor = Color.blue + + view.addSubview(headerScrollView) + headerScrollView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .fillEqually + stackView.spacing = 12 + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) + + headerScrollView.addSubview(stackView) + stackView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.height.equalToSuperview() + // Min width fits 5 buttons, expands if more + $0.width.greaterThanOrEqualTo(view.snp.width) + } + + for date in eventsController.ios26Dates { + let button = UIButton(type: .system) + button.titleLabel?.numberOfLines = 2 + button.titleLabel?.textAlignment = .center + button.setAttributedTitle(attributedTitle(for: date), for: .normal) + button.addTarget(self, action: #selector(didTapDateButton(_:)), for: .touchUpInside) + button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 8, bottom: 6, right: 8) + button.backgroundColor = Color.gray20 + button.tag = eventsController.ios26Dates.firstIndex(of: date)! + + if #available(iOS 26, *) { + var config = UIButton.Configuration.glass() + button.configuration = config + } else { + button.layer.cornerRadius = 8 + button.layer.cornerCurve = .continuous + button.layer.borderWidth = 1 + button.layer.borderColor = Color.gray50.cgColor + } + + stackView.addArrangedSubview(button) + + if date == selectedDate { + selectedButton = button + } + } + + view.addSeparatorLine(.bottom) + + return view + }() + + fileprivate func attributedTitle(for date: Date) -> NSAttributedString { + let f = DateFormatter() + f.timeZone = MGPEventTimeZone + + f.dateFormat = "EEE" + let dayName = f.string(from: date) // "Wed" + + f.dateFormat = "MMM d" + let dayDate = f.string(from: date) // "Jun 10" + + let result = NSMutableAttributedString() + result.append(NSAttributedString(string: dayName + "\n", attributes: [ + .font: UIFont.systemFont(ofSize: 12, weight: .semibold) + ])) + result.append(NSAttributedString(string: dayDate, attributes: [ + .font: UIFont.systemFont(ofSize: 11, weight: .regular) + ])) + return result + } + + private func select(_ button: UIButton?) { + guard let button else { return } + + if #available(iOS 26, *) { + button.isSelected = true + } else { + button.setTitleColor(Color.white, for: .normal) + button.backgroundColor = Color.blue + button.layer.borderColor = Color.blue.cgColor + } + + selectedButton = button + + if let date = selectedDate { + selectedSessions = eventsController.io26MergedSessions(for: date, with: .scheduled) + } + } + + private func deselectButton() { + guard let button = selectedButton else { return } + + if #available(iOS 26, *) { + button.isSelected = false + } else { + button.setTitleColor(Color.blue, for: .normal) + button.backgroundColor = Color.gray20 + button.layer.borderColor = Color.gray50.cgColor + } + + selectedButton = nil + } + + fileprivate let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "h:mm a" + f.timeZone = MGPEventTimeZone + return f + }() + + fileprivate let eventsController = EventsController() + fileprivate var selectedSessions: [MGPEventSession]? + fileprivate var favedSessions = Set() + + fileprivate var selectedDate: Date? + fileprivate var selectedButton: UIButton? + + fileprivate enum Constants { + static let padding: CGFloat = UniversalConstants.padding + static let headerViewHeight: CGFloat = 60 + } + + // MARK: - Lifecycle Methods + + override func viewDidLoad() { + super.viewDidLoad() + + selectedDate = eventsController.ios26Dates.first + + setupLayout() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + hideNavigationShadow() + + if !eventsController.didFetchEvents() { + isLoadingList(true) + } else { + tableView.reloadData() + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if !eventsController.didFetchEvents() { + loadContent() + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + } + + // MARK: - Layout + + fileprivate func setupLayout() { + + configureNavigationItems() + + view.addSubview(headerView) + headerView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) + $0.height.equalTo(Constants.headerViewHeight) + $0.leading.trailing.equalToSuperview() + } + + view.addSubview(tableView) + tableView.snp.makeConstraints { + $0.top.equalTo(headerView.snp.bottom) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(view.snp.bottom) + } + + view.addSubview(shimmeringView) + shimmeringView.snp.makeConstraints { + $0.top.equalTo(tableView.snp.top) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(view.snp.bottom) + } + } + + fileprivate func configureNavigationItems() { + title = "IO26" + tabBarItem = UITabBarItem(title: title, image: SystemImg.globe, selectedImage: SystemImg.globeFill) + tabBarItem.isEnabled = true + } + + // MARK: - Data Update + + fileprivate func loadContent() { + + if !refreshControl.isRefreshing { + isLoadingList(true) + } + + eventsController.fetchIO26Event { event, error in + + let enabled = event != nil + self.headerScrollView.isUserInteractionEnabled = enabled + self.headerScrollView.alpha = enabled ? 1 : 0.7 + + if enabled { + self.select(self.selectedButton) + } + + if self.refreshControl.isRefreshing { + self.refreshControl.endRefreshing() + self.tableView.reloadData() + } else { + self.isLoadingList(false) + } + } + } + + fileprivate func resetTableView() { + tableView.setContentOffset(.zero, animated: false) + tableView.reloadData() + } + + // MARK: - Actions + + @objc fileprivate func didPullRefreshControl() { + loadContent() + } + + @objc private func didTapDateButton(_ button: UIButton) { + let newDate = eventsController.ios26Dates[button.tag] as Date + + if newDate == selectedDate { + return + } + + deselectButton() + selectedDate = newDate + select(button) + + tableView.reloadData() + } +} + +extension EventsViewController: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + guard let _ = eventsController.io26Event else { + return 0 + } + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard let sessions = selectedSessions, sessions.count > 0 else { + return 0 + } + return sessions.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as EventSessionTableViewCell + configure(cell, forRowAt: indexPath) + return cell + } + + func configure(_ view: T, forRowAt indexPath: IndexPath) where T : UITableViewCell { + guard let sessions = selectedSessions, sessions.count > 0 else { return } + guard let cell = view as? EventSessionTableViewCell else { return } + + let session = sessions[indexPath.row] + let track = eventsController.track(for: session) + + cell.titleLabel.text = "\(session.activity)" + cell.titleLabel.textColor = Color.black + + cell.subtitleLabel.text = track?.name + cell.subtitleLabel.textColor = eventsController.color(for: track) + cell.iconView.tintColor = cell.subtitleLabel.textColor + + if let startTime = session.startTime { + cell.startTimeLabel.text = timeFormatter.string(from: startTime) + } + + if let endTime = session.endTime { + cell.endTimeLabel.text = timeFormatter.string(from: endTime) + } + + cell.backgroundColor = (indexPath.row % 2 == 0) ? Color.white : Color.gray20 + + let starImage = favedSessions.contains(session) ? SystemImg.starFill : SystemImg.star + let starColor = favedSessions.contains(session) ? Color.yellow : Color.gray100 + cell.accessoryView = UIImageView(image: starImage) + cell.accessoryView?.tintColor = starColor + } +} + +extension EventsViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let sessions = selectedSessions, sessions.count > 0 else { return } + +// guard let cell = tableView.cellForRow(at: indexPath) as? EventSessionTableViewCell else { return } +// tableView.deselectRow(at: indexPath, animated: true) + + let session = sessions[indexPath.row] + + if favedSessions.contains(session) { + favedSessions.remove(session) + } else { + favedSessions.insert(session) + } + + tableView.reloadRows(at: [indexPath], with: .none) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return EventSessionTableViewCell.cellHeight + } +} diff --git a/RaceSync/View Controllers/Gallery/GalleryViewController.swift b/RaceSync/View Controllers/Gallery/GalleryViewController.swift index 02f82baf..70fe346b 100644 --- a/RaceSync/View Controllers/Gallery/GalleryViewController.swift +++ b/RaceSync/View Controllers/Gallery/GalleryViewController.swift @@ -61,8 +61,8 @@ class GalleryViewController: UIViewController { NSAttributedString.Key.foregroundColor: Color.black] let navigationItem = UINavigationItem(title: title ?? "") - navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) - navigationItem.rightBarButtonItem = UIBarButtonItem(image: ButtonImg.share, style: .done, target: self, action: #selector(didPressShareButton)) + navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .plain, target: self, action: #selector(didPressCloseButton)) + navigationItem.rightBarButtonItem = UIBarButtonItem(image: ButtonImg.share, style: .plain, target: self, action: #selector(didPressShareButton)) if #available(iOS 15.0, *) { let navigationBarAppearance = UINavigationBarAppearance() diff --git a/RaceSync/View Controllers/HomeTabBarController.swift b/RaceSync/View Controllers/HomeTabBarController.swift index 0c492873..4d3dfd44 100644 --- a/RaceSync/View Controllers/HomeTabBarController.swift +++ b/RaceSync/View Controllers/HomeTabBarController.swift @@ -11,14 +11,15 @@ import SnapKit import RaceSyncAPI enum HomeTabs: Int { - case races, series, standings - + case races, series, standings, events static let `default`: Self = .series } class HomeTabBarController: UITabBarController { // MARK: - Private Variables + + fileprivate let isEventsTabEnable: Bool = true fileprivate lazy var raceFeedVC: RaceFeedViewController = { let settings = APIServices.shared.settings @@ -30,7 +31,7 @@ class HomeTabBarController: UITabBarController { fileprivate lazy var seriesVC: SeriesFeedViewController = { return SeriesFeedViewController() }() - + fileprivate lazy var standingsVC: StandingsViewController = { let vc = StandingsViewController(with: .y2026) vc.title = "Standings" @@ -38,6 +39,10 @@ class HomeTabBarController: UITabBarController { vc.isRootTabBar = true return vc }() + + fileprivate lazy var eventsVC: EventsViewController = { + return EventsViewController() + }() fileprivate lazy var titleView: UIView = { let view = UIView() @@ -49,20 +54,6 @@ class HomeTabBarController: UITabBarController { return view }() - fileprivate lazy var notificationsButton: CustomButton = { - let button = CustomButton(type: .system) - button.addTarget(self, action: #selector(didPressNotificationsButton), for: .touchUpInside) - button.setImage(ButtonImg.notifications, for: .normal) - return button - }() - - fileprivate lazy var settingsButton: CustomButton = { - let button = CustomButton(type: .system) - button.addTarget(self, action: #selector(didPressSettingsButton), for: .touchUpInside) - button.setImage(ButtonImg.settings, for: .normal) - return button - }() - fileprivate lazy var userProfileButton: UIButton = { let button = UIButton(type: .system) button.addTarget(self, action: #selector(didPressUserProfileButton), for: .touchUpInside) @@ -70,7 +61,8 @@ class HomeTabBarController: UITabBarController { if let placeholder = PlaceholderImg.small?.withRenderingMode(.alwaysOriginal) { button.setImage(placeholder, for: .normal) // 32x32 - button.layer.cornerRadius = placeholder.size.width / 2 + button.layer.cornerRadius = Constants.miniProfileSize.width / 2 + button.layer.cornerCurve = .continuous button.layer.borderWidth = 0.5 button.layer.borderColor = Color.gray100.cgColor button.layer.masksToBounds = true @@ -86,7 +78,8 @@ class HomeTabBarController: UITabBarController { if let placeholder = PlaceholderImg.small?.withRenderingMode(.alwaysOriginal) { button.setImage(placeholder, for: .normal) // 32x32 - button.layer.cornerRadius = placeholder.size.width / 2 + button.layer.cornerRadius = Constants.miniProfileSize.width / 2 + button.layer.cornerCurve = .continuous button.layer.borderWidth = 0.5 button.layer.borderColor = Color.gray100.cgColor button.layer.masksToBounds = true @@ -94,15 +87,15 @@ class HomeTabBarController: UITabBarController { return button }() - fileprivate lazy var badgeHub: BadgeHub = { - let hub = BadgeHub(view: notificationsButton) - hub.setCircleColor(Color.lightRed, label: Color.white) - hub.setCircleBorderColor(Color.white, borderWidth: 1) - hub.setMaxCount(to: 100) - hub.scaleCircleSize(by: 0.7) - hub.moveCircleBy(x: 35.0, y: 0) - return hub - }() +// fileprivate lazy var badgeHub: BadgeHub = { +// let hub = BadgeHub(barButtonItem: notificationsButton) +// hub.setCircleColor(Color.lightRed, label: Color.white) +// hub.setCircleBorderColor(Color.white, borderWidth: 1) +// hub.setMaxCount(to: 100) +// hub.scaleCircleSize(by: 0.7) +// hub.moveCircleBy(x: 35.0, y: 0) +// return hub +// }() fileprivate let presenter = Appearance.defaultPresenter() fileprivate let userApi = UserApi() @@ -111,9 +104,15 @@ class HomeTabBarController: UITabBarController { fileprivate enum Constants { static let padding: CGFloat = UniversalConstants.padding static let buttonSpacing: CGFloat = 12 - static let miniProfileSize: CGSize = CGSize(width: 32, height: 32) + static let miniProfileSize: CGSize = { + if #available(iOS 26.0, *) { + CGSize(width: 38, height: 38) + } else { + CGSize(width: 32, height: 32) + } + }() } - + // MARK: - Lifecycle Methods override func viewDidLoad() { @@ -152,15 +151,20 @@ class HomeTabBarController: UITabBarController { fileprivate func configureNavigationItems() { navigationItem.titleView = titleView - - let leftStackSubviews = [notificationsButton, settingsButton] - let leftStackView = UIStackView(arrangedSubviews: leftStackSubviews) - leftStackView.axis = .horizontal - leftStackView.distribution = .fillEqually - leftStackView.alignment = .leading - leftStackView.spacing = Constants.padding - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: leftStackView) - + + let leftActions: [BarButtonAction] = [ + (ButtonImg.notifications, #selector(didPressNotificationsButton), 0), + (ButtonImg.settings, #selector(didPressSettingsButton), 0) + ] + + if #available(iOS 26, *) { + navigationItem.leftBarButtonItems = leftActions.map { action in + UIBarButtonItem(image: action.image, style: .plain, target: self, action: action.selector) + }.interspersed(with: UIBarButtonItem.spacer()) + } else { + navigationItem.leftBarButtonItem = UIBarButtonItem.stackedBarButtonItem(for: leftActions) + } + let rightStackView = UIStackView(arrangedSubviews: [chapterProfileButton, userProfileButton]) rightStackView.axis = .horizontal rightStackView.distribution = .fillEqually @@ -214,7 +218,12 @@ class HomeTabBarController: UITabBarController { // MARK: - Data Update fileprivate func loadContent() { - let vcs: [UIViewController] = [raceFeedVC, seriesVC, standingsVC] + var vcs = [UIViewController]() + vcs += [raceFeedVC] + vcs += [seriesVC] + vcs += [standingsVC] + if isEventsTabEnable { vcs += [eventsVC] } + let tab = AppPrefs.lastSelectedHomeTab configureTabBarController(with: vcs, selectedIndex: tab.rawValue) @@ -271,17 +280,15 @@ class HomeTabBarController: UITabBarController { fileprivate func updateUserProfileImage() { let imageUrl = APIServices.shared.myUser?.miniProfilePictureUrl let placeholder = PlaceholderImg.small?.withRenderingMode(.alwaysOriginal) - + userProfileButton.isHidden = false - userProfileButton.setImage(with: imageUrl, placeholderImage: placeholder, forState: .normal, size: Constants.miniProfileSize) { (image) in - // - } + userProfileButton.setImage(with: imageUrl, placeholderImage: placeholder, forState: .normal, size: Constants.miniProfileSize) } fileprivate func updateChapterProfileImage() { let imageUrl = APIServices.shared.myChapter?.miniProfilePictureUrl let placeholder = PlaceholderImg.small?.withRenderingMode(.alwaysOriginal) - + chapterProfileButton.isHidden = false chapterProfileButton.setImage(with: imageUrl, placeholderImage: placeholder, forState: .normal, size: Constants.miniProfileSize) } @@ -292,6 +299,16 @@ class HomeTabBarController: UITabBarController { } } +extension UIImage { + func roundedImage(ofSize size: CGSize) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).addClip() + draw(in: CGRect(origin: .zero, size: size)) + } + } +} + extension HomeTabBarController: ChapterPickerViewControllerDelegate { func pickerController(_ viewController: ChapterPickerViewController, didPickChapter chapter: Chapter) { @@ -314,8 +331,6 @@ extension HomeTabBarController: UITabBarControllerDelegate { func tabBarController(_ controller: UITabBarController, didSelect viewController: UIViewController) { - (tabBar as? RoundedSelectionTabBar)?.updateSelectionFrame(animated: true) - if let vcs = viewControllers, vcs.contains(viewController) { hideNavigationShadow() } else { diff --git a/RaceSync/View Controllers/Login/LoginViewController.swift b/RaceSync/View Controllers/Login/LoginViewController.swift index 9054a8f3..f4f4affc 100644 --- a/RaceSync/View Controllers/Login/LoginViewController.swift +++ b/RaceSync/View Controllers/Login/LoginViewController.swift @@ -96,6 +96,13 @@ class LoginViewController: UIViewController { button.layer.borderColor = Color.gray100.cgColor button.layer.borderWidth = 0.5 button.addTarget(self, action:#selector(didPressLoginButton), for: .touchUpInside) + + if #available(iOS 26.0, *) { + button.layer.cornerRadius = Constants.actionButtonHeight/2 + } else { + button.layer.cornerRadius = Constants.padding/2 + } + return button }() diff --git a/RaceSync/View Controllers/Map/MapViewController.swift b/RaceSync/View Controllers/Map/MapViewController.swift index 0ddec97a..ff4c1340 100644 --- a/RaceSync/View Controllers/Map/MapViewController.swift +++ b/RaceSync/View Controllers/Map/MapViewController.swift @@ -47,7 +47,7 @@ class MapViewController: UIViewController { }() fileprivate lazy var navigationBarButtonItem: UIBarButtonItem = { - return UIBarButtonItem(image: ButtonImg.directions, style: .done, target: self, action: #selector(didPressDirectionsButton)) + return UIBarButtonItem(image: ButtonImg.directions, style: .plain, target: self, action: #selector(didPressDirectionsButton)) }() fileprivate enum Constants { @@ -90,7 +90,8 @@ class MapViewController: UIViewController { // MARK: - Layout fileprivate func setupLayout() { - navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) + navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .plain, target: self, action: #selector(didPressCloseButton)) + if showsDirection { navigationItem.rightBarButtonItem = navigationBarButtonItem } diff --git a/RaceSync/View Controllers/Profiles/ChapterViewController.swift b/RaceSync/View Controllers/Profiles/ChapterViewController.swift index 958fb752..4e7764cd 100644 --- a/RaceSync/View Controllers/Profiles/ChapterViewController.swift +++ b/RaceSync/View Controllers/Profiles/ChapterViewController.swift @@ -124,30 +124,24 @@ class ChapterViewController: ProfileViewController, ViewJoinable, RaceEditable { } fileprivate func configureBarButtonItems() { - - var buttons = [UIButton]() - + // Build the action list + var actions: [BarButtonAction] = [ + (ButtonImg.share, #selector(didPressShareButton), 0) + ] if canCreateRaces { - let addButton = CustomButton(type: .system) - addButton.addTarget(self, action: #selector(didPressAddButton), for: .touchUpInside) - addButton.setImage(ButtonImg.add, for: .normal) - buttons += [addButton] + actions.append((ButtonImg.add, #selector(didPressAddButton), 0)) } - let shareButton = CustomButton(type: .system) - shareButton.addTarget(self, action: #selector(didPressShareButton), for: .touchUpInside) - shareButton.setImage(ButtonImg.share, for: .normal) - buttons += [shareButton] - - let rightStackView = UIStackView(arrangedSubviews: buttons) - rightStackView.axis = .horizontal - rightStackView.distribution = .fillEqually - rightStackView.alignment = .lastBaseline - rightStackView.spacing = Constants.buttonSpacing - navigationItem.rightBarButtonItem = UIBarButtonItem(customView: rightStackView) + if #available(iOS 26, *) { + navigationItem.rightBarButtonItems = actions.map { action in + UIBarButtonItem(image: action.image, style: .plain, target: self, action: action.selector) + }.interspersed(with: UIBarButtonItem.spacer()) + } else { + navigationItem.rightBarButtonItem = UIBarButtonItem.stackedBarButtonItem(for: actions) + } if navigationController?.viewControllers.count == 1 { - navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) + navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .plain, target: self, action: #selector(didPressCloseButton)) } } diff --git a/RaceSync/View Controllers/Profiles/UserViewController.swift b/RaceSync/View Controllers/Profiles/UserViewController.swift index 8d5f459f..45e03d06 100644 --- a/RaceSync/View Controllers/Profiles/UserViewController.swift +++ b/RaceSync/View Controllers/Profiles/UserViewController.swift @@ -22,14 +22,6 @@ class UserViewController: ProfileViewController, ViewJoinable, RaceEditable { // MARK: - Private Variables - fileprivate lazy var qrButton: UIButton = { - let button = UIButton(type: .system) - button.addTarget(self, action: #selector(didPressQRButton), for: .touchUpInside) - button.setImage(ButtonImg.qrcode, for: .normal) - button.setBackgroundImage(nil, for: .normal) - return button - }() - fileprivate var user: User fileprivate let raceApi = RaceApi() fileprivate let chapterApi = ChapterApi() @@ -113,28 +105,27 @@ class UserViewController: ProfileViewController, ViewJoinable, RaceEditable { headerView.avatarView.isUserInteractionEnabled = isPhotoEditale headerView.delegate = self } - + fileprivate func configureBarButtonItems() { - var buttons = [UIButton]() - + // Build the action list + var actions: [BarButtonAction] = [ + (ButtonImg.share, #selector(didPressShareButton), 0) + ] if user.isMe { - buttons += [qrButton] + actions.append((ButtonImg.qrcode, #selector(didPressQRButton), 0)) } - let shareButton = CustomButton(type: .system) - shareButton.addTarget(self, action: #selector(didPressShareButton), for: .touchUpInside) - shareButton.setImage(ButtonImg.share, for: .normal) - buttons += [shareButton] - - let rightStackView = UIStackView(arrangedSubviews: buttons) - rightStackView.axis = .horizontal - rightStackView.distribution = .fillEqually - rightStackView.alignment = .lastBaseline - rightStackView.spacing = Constants.buttonSpacing - navigationItem.rightBarButtonItem = UIBarButtonItem(customView: rightStackView) + if #available(iOS 26, *) { + navigationItem.rightBarButtonItems = actions.map { action in + UIBarButtonItem(image: action.image, style: .plain, target: self, action: action.selector) + }.interspersed(with: UIBarButtonItem.spacer()) + } else { + // Still needed for versions of iOS previous to iOS26 + navigationItem.rightBarButtonItem = UIBarButtonItem.stackedBarButtonItem(for: actions) + } if navigationController?.viewControllers.count == 1 { - navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) + navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .plain, target: self, action: #selector(didPressCloseButton)) } } diff --git a/RaceSync/View Controllers/Push Messages/PushMessagesViewController.swift b/RaceSync/View Controllers/Push Messages/PushMessagesViewController.swift index 197b5383..144d0a28 100644 --- a/RaceSync/View Controllers/Push Messages/PushMessagesViewController.swift +++ b/RaceSync/View Controllers/Push Messages/PushMessagesViewController.swift @@ -110,13 +110,14 @@ class PushMessagesViewController: UIViewController { fileprivate func configureNavigationItems() { title = "Messages" - let leftBtnItem = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) + let leftBtnItem = UIBarButtonItem(image: ButtonImg.close, style: .plain, target: self, action: #selector(didPressCloseButton)) navigationItem.leftBarButtonItem = leftBtnItem - let rightBtnItem = UIBarButtonItem(title: "Clear All", style: .done, target: self, action: #selector(didPressClearButton)) + let rightBtnItem = UIBarButtonItem(title: "Clear", style: .plain, target: self, action: #selector(didPressClearButton)) rightBtnItem.isEnabled = false - if #available(iOS 16.0, *) { rightBtnItem.isHidden = true } navigationItem.rightBarButtonItem = rightBtnItem + + if #available(iOS 16.0, *) { rightBtnItem.isHidden = true } } fileprivate func updateClearButton() { diff --git a/RaceSync/View Controllers/Races/ChapterPickerViewController.swift b/RaceSync/View Controllers/Races/ChapterPickerViewController.swift index cde104fd..dc0f1e76 100644 --- a/RaceSync/View Controllers/Races/ChapterPickerViewController.swift +++ b/RaceSync/View Controllers/Races/ChapterPickerViewController.swift @@ -57,7 +57,7 @@ class ChapterPickerViewController: UIViewController, Shimmable { // MARK: - Private Variables fileprivate lazy var rightBarButtonItem: UIBarButtonItem = { - let item = UIBarButtonItem(title: "Save", style: .done, target: self, action: #selector(didPressSaveButton)) + let item = UIBarButtonItem(title: "Save", style: .plain, target: self, action: #selector(didPressSaveButton)) item.isEnabled = canSave() return item }() @@ -110,7 +110,7 @@ class ChapterPickerViewController: UIViewController, Shimmable { // Adds a close button in case of being presented modally if navigationController?.viewControllers.count == 1 { - navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) + navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .plain, target: self, action: #selector(didPressCloseButton)) } navigationItem.rightBarButtonItem = rightBarButtonItem diff --git a/RaceSync/View Controllers/Races/RaceController.swift b/RaceSync/View Controllers/Races/RaceController.swift index 0d1132b6..0c2678ca 100644 --- a/RaceSync/View Controllers/Races/RaceController.swift +++ b/RaceSync/View Controllers/Races/RaceController.swift @@ -239,45 +239,44 @@ class RaceController { enum RaceAction: Int, CaseIterable { case edit, calendar, share, zippyQ - - func makeButton(target: Any?, action: Selector) -> UIButton { - let button = CustomButton(type: .system) - var image: UIImage? - + + var image: UIImage? { switch self { - case .edit: image = ButtonImg.edit - case .calendar: image = ButtonImg.calendar - case .share: image = ButtonImg.share - case .zippyQ: image = ButtonImg.safari + case .edit: return ButtonImg.edit + case .calendar: return ButtonImg.calendar + case .share: return ButtonImg.share + case .zippyQ: return ButtonImg.safari } - - button.setImage(image, for: .normal) - button.addTarget(target, action: action, for: .touchUpInside) - return button + } + + func makeButton(target: Any?, action: Selector) -> UIBarButtonItem { + return UIBarButtonItem(image: image, style: .plain, target: target, action: action) } } - func navigationItems(for options: [RaceAction] = [.edit, .calendar, .share]) -> UIBarButtonItem? { - guard let race = race else { return nil } - guard !options.isEmpty else { return nil } + func navigationItems(for options: [RaceAction] = [.edit, .calendar, .share]) -> [UIBarButtonItem] { + guard let race, !options.isEmpty else { return [] } - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.distribution = .fillEqually - stackView.alignment = .lastBaseline - stackView.spacing = 12 - - for option in options { - if (option == .edit && !race.canBeEdited) { continue } - if (option == .calendar && !race.canCreateCalendarEvent()) { continue } - if (option == .zippyQ && !race.isZippyQEnabled) { continue } - - let button = option.makeButton(target: self, action: #selector(raceActionTapped(_:))) - button.tag = option.rawValue - stackView.addArrangedSubview(button) + let filtered = options.filter { option in + switch option { + case .edit: return race.canBeEdited + case .calendar: return race.canCreateCalendarEvent() + case .zippyQ: return race.isZippyQEnabled + case .share: return true + } + }.sorted { $0.rawValue > $1.rawValue } + + if #available(iOS 26, *) { + return filtered.map { option in + 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) } + return [UIBarButtonItem.stackedBarButtonItem(for: actions)] } - - return UIBarButtonItem(customView: stackView) } @objc private func raceActionTapped(_ sender: UIButton) { @@ -470,6 +469,19 @@ class RaceController { } } +extension Array where Element == UIBarButtonItem { + func interspersedIfNeeded() -> [UIBarButtonItem] { + guard #available(iOS 26, *) else { + // iOS 18 and below: manually add small fixed spacing between items + let space = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil) + space.width = 5 + return interspersed(with: space) + } + // iOS 26+: Liquid Glass handles its own inter-button spacing + return self + } +} + extension RaceController: RaceFormViewControllerDelegate { func raceFormViewController(_ viewController: RaceFormViewController, didUpdateRace race: Race) { diff --git a/RaceSync/View Controllers/Races/RaceDetailViewController.swift b/RaceSync/View Controllers/Races/RaceDetailViewController.swift index 2b82d66f..28e04c67 100644 --- a/RaceSync/View Controllers/Races/RaceDetailViewController.swift +++ b/RaceSync/View Controllers/Races/RaceDetailViewController.swift @@ -269,6 +269,7 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { fileprivate var raceViewModel: RaceViewModel fileprivate var chapterApi = ChapterApi() fileprivate var userApi = UserApi() + fileprivate var seriesApi = SeriesApi() fileprivate var htmlViewHeightConstraint: Constraint? // fileprivate let ignoreFinalizingError: Bool = true // The API finalize(id) still returns 500 error. Reported https://github.com/MultiGP/multigp-com/issues/93 @@ -431,13 +432,14 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { title = "Details" tabBarItem = UITabBarItem(title: title, image: SystemImg.calendarCclock, selectedImage: nil) - navigationItem.rightBarButtonItem = raceController.navigationItems() + navigationItem.rightBarButtonItems = raceController.navigationItems() } fileprivate func loadRows() { tableViewRows = [ !race.ownerUserName.isEmpty && !race.ownerId.isEmpty ? Row.owner : nil, raceViewModel.chapterLabel.isEmpty ? nil : Row.chapter, + raceViewModel.seriesLabel.isEmpty ? nil : Row.series, raceViewModel.seasonLabel.isEmpty ? nil : Row.season, race.isZippyQEnabled ? Row.zippyQ : nil, raceViewModel.subtitleLabel.string.isEmpty ? nil : Row.class, @@ -614,7 +616,7 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { let vc = UserViewController(with: user) self?.navigationController?.pushViewController(vc, animated: true) } else if let _ = error { - // handle error + // TODO: Handle error } self?.setLoading(cell, loading: false) } @@ -632,39 +634,54 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { let vc = RaceListViewController(sortedViewModels, raceClass: raceClass) self?.navigationController?.pushViewController(vc, animated: true) } else if let _ = error { - // handle error + // TODO: Handle error } self?.setLoading(cell, loading: false) } } - func showSeasonRaces(_ cell: FormTableViewCell) { - guard canInteract(with: cell), let seasonId = race.seasonId else { return } + func showChapterProfile(_ cell: FormTableViewCell) { + guard canInteract(with: cell) else { return } setLoading(cell, loading: true) - raceApi.getRaces(seasonId: seasonId) { [weak self] (races, error) in - if let races = races { - let sortedViewModels = RaceViewModel.sortedViewModels(with: races) - let vc = RaceListViewController(sortedViewModels, seasonId: seasonId) - vc.title = self?.race.seasonName + chapterApi.getChapter(with: race.chapterId) { [weak self] (chapter, error) in + if let chapter = chapter { + let vc = ChapterViewController(with: chapter) self?.navigationController?.pushViewController(vc, animated: true) } else if let _ = error { - // handle error + // TODO: Handle error } self?.setLoading(cell, loading: false) } } - func showChapterProfile(_ cell: FormTableViewCell) { - guard canInteract(with: cell) else { return } + func showSeriesDetail(_ cell: FormTableViewCell) { + guard canInteract(with: cell), let seriesId = race.seriesId else { return } setLoading(cell, loading: true) - chapterApi.getChapter(with: race.chapterId) { [weak self] (chapter, error) in - if let chapter = chapter { - let vc = ChapterViewController(with: chapter) + seriesApi.view(series: seriesId) { [weak self] (series, error) in + if let series = series { + let vc = SeriesTabBarController(with: series) + self?.navigationController?.pushViewController(vc, animated: true) + } else if let _ = error { + // TODO: Handle error + } + self?.setLoading(cell, loading: false) + } + } + + func showSeasonRaces(_ cell: FormTableViewCell) { + guard canInteract(with: cell), let seasonId = race.seasonId else { return } + setLoading(cell, loading: true) + + raceApi.getRaces(seasonId: seasonId) { [weak self] (races, error) in + if let races = races { + let sortedViewModels = RaceViewModel.sortedViewModels(with: races) + let vc = RaceListViewController(sortedViewModels, seasonId: seasonId) + vc.title = self?.race.seasonName self?.navigationController?.pushViewController(vc, animated: true) } else if let _ = error { - // handle error + // TODO: Handle error } self?.setLoading(cell, loading: false) } @@ -767,6 +784,8 @@ extension RaceDetailViewController: UITableViewDelegate { showUserProfile(cell) } else if row == .chapter { showChapterProfile(cell) + } else if row == .series { + showSeriesDetail(cell) } else if row == .season { showSeasonRaces(cell) } else if row == .zippyQ { @@ -806,7 +825,9 @@ extension RaceDetailViewController: UITableViewDataSource { if row == .chapter { cell.detailTextLabel?.text = raceViewModel.chapterLabel } else if row == .owner { - cell.detailTextLabel?.text = race.ownerUserName + cell.detailTextLabel?.text = raceViewModel.ownerLabel + } else if row == .series { + cell.detailTextLabel?.text = raceViewModel.seriesLabel } else if row == .season { cell.detailTextLabel?.text = raceViewModel.seasonLabel } else if row == .zippyQ { @@ -874,12 +895,13 @@ extension RaceDetailViewController: MKMapViewDelegate { } fileprivate enum Row: Int, EnumTitle, CaseIterable { - case chapter, owner, season, zippyQ, `class`, results + case chapter, owner, series, season, zippyQ, `class`, results var title: String { switch self { case .chapter: return "Chapter" case .owner: return "Coordinator" + case .series: return "Series" case .season: return "Season" case .zippyQ: return "ZippyQ" case .class: return "Class" diff --git a/RaceSync/View Controllers/Races/RaceFeedMenuViewController.swift b/RaceSync/View Controllers/Races/RaceFeedMenuViewController.swift index 21416d8d..da9d75e3 100644 --- a/RaceSync/View Controllers/Races/RaceFeedMenuViewController.swift +++ b/RaceSync/View Controllers/Races/RaceFeedMenuViewController.swift @@ -109,7 +109,7 @@ class RaceFeedMenuViewController: UIViewController { // Adds a close button in case of being presented modally if navigationController?.viewControllers.count == 1 { - navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) + navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .plain, target: self, action: #selector(didPressCloseButton)) } view.addSubview(tableView) diff --git a/RaceSync/View Controllers/Races/RaceFormViewController.swift b/RaceSync/View Controllers/Races/RaceFormViewController.swift index 23bc2495..dc14ddbe 100644 --- a/RaceSync/View Controllers/Races/RaceFormViewController.swift +++ b/RaceSync/View Controllers/Races/RaceFormViewController.swift @@ -47,8 +47,10 @@ class RaceFormViewController: UIViewController { }() fileprivate lazy var rightBarButtonItem: UIBarButtonItem = { - let title = (currentSection == .specific) ? "Save" : "Next" - let item = UIBarButtonItem(title: title, style: .done, target: self, action: #selector(goNextSection)) + let action = #selector(goNextSection) + let item = currentSection == .general + ? UIBarButtonItem(title: "Next", style: .plain, target: self, action: action) + : UIBarButtonItem(barButtonSystemItem: .done, target: self, action: action) item.isEnabled = canGoNextSection() return item }() @@ -177,7 +179,7 @@ class RaceFormViewController: UIViewController { // Adds a close button in case of being presented modally if navigationController?.viewControllers.count == 1 { - navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) + navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .plain, target: self, action: #selector(didPressCloseButton)) } view.addSubview(tableView) diff --git a/RaceSync/View Controllers/Races/RacePaymentsViewController.swift b/RaceSync/View Controllers/Races/RacePaymentsViewController.swift index 8eef8683..6cdaa5bd 100644 --- a/RaceSync/View Controllers/Races/RacePaymentsViewController.swift +++ b/RaceSync/View Controllers/Races/RacePaymentsViewController.swift @@ -168,7 +168,7 @@ class RacePaymentsViewController: UIViewController, RaceTabbable { tabBarItem = UITabBarItem(title: title, image: SystemImg.banknote, selectedImage: SystemImg.banknoteFill) tabBarItem.isEnabled = true - navigationItem.rightBarButtonItem = raceController.navigationItems() + navigationItem.rightBarButtonItems = raceController.navigationItems() } // MARK: - Content diff --git a/RaceSync/View Controllers/Races/RacePilotsPickerController.swift b/RaceSync/View Controllers/Races/RacePilotsPickerController.swift index 2a71b064..82962136 100644 --- a/RaceSync/View Controllers/Races/RacePilotsPickerController.swift +++ b/RaceSync/View Controllers/Races/RacePilotsPickerController.swift @@ -116,7 +116,7 @@ class RacePilotsPickerController: UIViewController, Shimmable { fileprivate func setupLayout() { title = "Add/Remove Pilots" - navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) + navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .plain, target: self, action: #selector(didPressCloseButton)) view.backgroundColor = Color.white @@ -193,7 +193,7 @@ class RacePilotsPickerController: UIViewController, Shimmable { raceApi.forceJoin(race: race.id, pilotId: id) { (status, error) in completion(status ? .joined : .notJoined) if let error = error { - AlertUtil.presentAlertMessage("Couldn't add this user to the race. Please try again later. \(error.localizedDescription)", title: "Error", delay: 0.5) + AlertUtil.presentAlertMessage("Couldn't add this pilot to the race. Please try again later. \(error.localizedDescription)", title: "Error", delay: 0.5) } } } @@ -203,7 +203,7 @@ class RacePilotsPickerController: UIViewController, Shimmable { raceApi.forceResign(race: race.id, pilotId: id) { (status, error) in completion(status ? .notJoined : .joined) if let error = error { - AlertUtil.presentAlertMessage("Couldn't remove this user from the race. Please try again later. \(error.localizedDescription)", title: "Error", delay: 0.5) + AlertUtil.presentAlertMessage("Couldn't remove this pilot from the race. Please try again later. \(error.localizedDescription)", title: "Error", delay: 0.5) } } } diff --git a/RaceSync/View Controllers/Races/RacePilotsViewController.swift b/RaceSync/View Controllers/Races/RacePilotsViewController.swift index 64bbf0dd..e42f57b0 100644 --- a/RaceSync/View Controllers/Races/RacePilotsViewController.swift +++ b/RaceSync/View Controllers/Races/RacePilotsViewController.swift @@ -37,6 +37,12 @@ class RacePilotsViewController: UIViewController, ViewJoinable, RaceTabbable, Pi tableView.refreshControl = self.refreshControl tableView.tableFooterView = UIView() tableView.backgroundColor = Color.gray50 + + let longPress = UILongPressGestureRecognizer(target: self,action: #selector(didLongPress(_:))) + longPress.minimumPressDuration = 0.3 + longPress.delaysTouchesBegan = true + tableView.addGestureRecognizer(longPress) + return tableView }() @@ -126,7 +132,6 @@ class RacePilotsViewController: UIViewController, ViewJoinable, RaceTabbable, Pi } fileprivate func configureNavigationItems() { - navigationItem.rightBarButtonItem = raceController.navigationItems() if race.canShowResults { title = "Race Results" @@ -135,6 +140,8 @@ class RacePilotsViewController: UIViewController, ViewJoinable, RaceTabbable, Pi title = "Racing Pilots" tabBarItem = UITabBarItem(title: "Pilots", image: SystemImg.person, selectedImage: SystemImg.personFill) } + + navigationItem.rightBarButtonItems = raceController.navigationItems() } // MARK: - Actions @@ -172,6 +179,41 @@ class RacePilotsViewController: UIViewController, ViewJoinable, RaceTabbable, Pi guard !didTapCell else { return false } return true } + + @objc func didLongPress(_ gesture: UIGestureRecognizer) { + let location = gesture.location(in: tableView) + guard let indexPath = tableView.indexPathForRow(at: location) else { return } + + guard gesture.state == .began else { + tableView.deselectRow(at: indexPath, animated: true) + return + } + + tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) + guard race.canBeEdited else { return } + + let viewModel = userViewModels[indexPath.row] + let deselect = { [weak self] in self?.tableView.deselectRow(at: indexPath, animated: true) } + + ActionSheetUtil.presentDestructiveActionSheet( + withTitle: "Remove \(viewModel.username) from this race?", + destructiveTitle: "Yes, Remove", + completion: { [weak self] _ in + guard let self else { return } + raceApi.forceResign(race: race.id, pilotId: viewModel.userId) { status, error in + if let error { + AlertUtil.presentAlertMessage( + "Couldn't remove this pilot from the race. Please try again later. \(error.localizedDescription)", + title: "Error", delay: 0.5) + } else { + self.reloadRace() + } + deselect() + } + }, + cancel: { _ in deselect() } + ) + } // MARK: - Data Update diff --git a/RaceSync/View Controllers/Races/RaceScheduleViewController.swift b/RaceSync/View Controllers/Races/RaceScheduleViewController.swift index 11219ca5..2cdb7933 100644 --- a/RaceSync/View Controllers/Races/RaceScheduleViewController.swift +++ b/RaceSync/View Controllers/Races/RaceScheduleViewController.swift @@ -122,7 +122,7 @@ class RaceScheduleViewController: UIViewController, RaceTabbable { title = "Schedule" tabBarItem = UITabBarItem(title: title, image: SystemImg.flagCheckered, selectedImage: nil) - navigationItem.rightBarButtonItem = raceController.navigationItems(for: [.zippyQ, .share]) + navigationItem.rightBarButtonItems = raceController.navigationItems(for: [.zippyQ, .share]) } // MARK: - Data Update diff --git a/RaceSync/View Controllers/Races/RaceTabBarController.swift b/RaceSync/View Controllers/Races/RaceTabBarController.swift index 3480ac3c..8023efe6 100644 --- a/RaceSync/View Controllers/Races/RaceTabBarController.swift +++ b/RaceSync/View Controllers/Races/RaceTabBarController.swift @@ -33,7 +33,7 @@ class RaceTabBarController: UITabBarController { var isDismissable: Bool = false { didSet { if isDismissable { - navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) + navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .plain, target: self, action: #selector(didPressCloseButton)) navigationItem.backBarButtonItem = nil } else { navigationItem.leftBarButtonItem = nil @@ -169,7 +169,7 @@ class RaceTabBarController: UITabBarController { guard let vc = viewControllers?[index] else { return } title = vc.title - navigationItem.rightBarButtonItem = vc.navigationItem.rightBarButtonItem + navigationItem.rightBarButtonItems = vc.navigationItem.rightBarButtonItems } @objc fileprivate func didPressTitleButton() { @@ -255,8 +255,6 @@ extension RaceTabBarController: UITabBarControllerDelegate { func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { - (tabBar as? RoundedSelectionTabBar)?.updateSelectionFrame(animated: true) - if let index = viewControllers?.lastIndex(of: viewController) { didSelectedIndex(index) } diff --git a/RaceSync/View Controllers/Search/UniversalSearchViewController.swift b/RaceSync/View Controllers/Search/UniversalSearchViewController.swift index 1af06931..8979d2cd 100644 --- a/RaceSync/View Controllers/Search/UniversalSearchViewController.swift +++ b/RaceSync/View Controllers/Search/UniversalSearchViewController.swift @@ -184,7 +184,7 @@ class UniversalSearchViewController: UIViewController, Shimmable { fileprivate func configureNavigationItems() { title = "Universal Search" - navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) + navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .plain, target: self, action: #selector(didPressCloseButton)) } // MARK: - Data diff --git a/RaceSync/View Controllers/Series/SeriesController.swift b/RaceSync/View Controllers/Series/SeriesController.swift index e2fbb485..b75155f6 100644 --- a/RaceSync/View Controllers/Series/SeriesController.swift +++ b/RaceSync/View Controllers/Series/SeriesController.swift @@ -57,38 +57,41 @@ class SeriesController { // MARK: - Navigation Action Builders enum SeriesAction: Int, CaseIterable { - case share - - func makeButton(target: Any?, action: Selector) -> UIButton { - let button = CustomButton(type: .system) - var image: UIImage? - + case edit, share + + var image: UIImage? { switch self { - case .share: image = ButtonImg.share + case .edit: return ButtonImg.edit + case .share: return ButtonImg.share } + } - button.setImage(image, for: .normal) - button.addTarget(target, action: action, for: .touchUpInside) - return button + func makeButton(target: Any?, action: Selector) -> UIBarButtonItem { + return UIBarButtonItem(image: image, style: .plain, target: target, action: action) } } - func navigationItems(for options: [SeriesAction] = [.share]) -> UIBarButtonItem? { - guard !options.isEmpty else { return nil } - - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.distribution = .fillEqually - stackView.alignment = .lastBaseline - stackView.spacing = 12 - - for option in options { - let button = option.makeButton(target: self, action: #selector(seriesActionTapped(_:))) - button.tag = option.rawValue - stackView.addArrangedSubview(button) + func navigationItems(for options: [SeriesAction] = [.edit, .share]) -> [UIBarButtonItem]{ + guard !options.isEmpty else { return [UIBarButtonItem]() } + + let filtered = options.filter { option in + switch option { + case .edit: return series.canBeEdited + case .share: return true + } + }.sorted { $0.rawValue > $1.rawValue } + + if #available(iOS 26, *) { + return filtered.map { option in + let item = option.makeButton(target: self, action: #selector(seriesActionTapped(_:))) + item.tag = option.rawValue + return item + }.interspersed(with: UIBarButtonItem.spacer()) + } else { + // Still needed for versions of iOS previous to iOS26 + let actions = options.map { (image: $0.image, selector: #selector(seriesActionTapped(_:)), tag: $0.rawValue) } + return [UIBarButtonItem.stackedBarButtonItem(for: actions)] } - - return UIBarButtonItem(customView: stackView) } @objc private func seriesActionTapped(_ sender: UIButton) { @@ -101,6 +104,8 @@ class SeriesController { menuCompletion = completion switch action { + case .edit: + return case .share: showShareMenu() } diff --git a/RaceSync/View Controllers/Series/SeriesDetailViewController.swift b/RaceSync/View Controllers/Series/SeriesDetailViewController.swift index 6841a6aa..c1547a36 100644 --- a/RaceSync/View Controllers/Series/SeriesDetailViewController.swift +++ b/RaceSync/View Controllers/Series/SeriesDetailViewController.swift @@ -146,7 +146,7 @@ class SeriesDetailViewController: UIViewController { title = "Details" tabBarItem = UITabBarItem(title: title, image: SystemImg.calendarCclock, selectedImage: nil) - navigationItem.rightBarButtonItem = seriesController.navigationItems() + navigationItem.rightBarButtonItems = seriesController.navigationItems() } fileprivate func populateContent() { diff --git a/RaceSync/View Controllers/Series/SeriesPickerViewController.swift b/RaceSync/View Controllers/Series/SeriesPickerViewController.swift index 43edfcb9..65be45a6 100644 --- a/RaceSync/View Controllers/Series/SeriesPickerViewController.swift +++ b/RaceSync/View Controllers/Series/SeriesPickerViewController.swift @@ -30,13 +30,13 @@ class SeriesPickerViewController: UIViewController { // MARK: - Private Variables fileprivate lazy var rightBarButtonItem: UIBarButtonItem = { - let item = UIBarButtonItem(title: "Join", style: .done, target: self, action: #selector(didPressJoinButton)) + let item = UIBarButtonItem(title: "Join", style: .plain, target: self, action: #selector(didPressJoinButton)) item.isEnabled = false return item }() fileprivate lazy var leftBarButtonItem: UIBarButtonItem = { - let item = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) + let item = UIBarButtonItem(image: ButtonImg.close, style: .plain, target: self, action: #selector(didPressCloseButton)) item.isEnabled = true return item }() diff --git a/RaceSync/View Controllers/Series/SeriesStandingsViewController.swift b/RaceSync/View Controllers/Series/SeriesStandingsViewController.swift index 8a82ae29..85f7e957 100644 --- a/RaceSync/View Controllers/Series/SeriesStandingsViewController.swift +++ b/RaceSync/View Controllers/Series/SeriesStandingsViewController.swift @@ -168,14 +168,14 @@ class SeriesStandingsViewController: UIViewController, Pinnable { fileprivate func configureNavigationItems() { if showsSegmentedControl { - title = "Leaderboards" + title = "Rankings" } else { - title = "Leaderboard" + title = "Rankings" } tabBarItem = UITabBarItem(title: title, image: SystemImg.trophy, selectedImage: SystemImg.trophyFill) - navigationItem.rightBarButtonItem = seriesController.navigationItems() + navigationItem.rightBarButtonItems = seriesController.navigationItems() } // MARK: - Data Update diff --git a/RaceSync/View Controllers/Series/SeriesTabBarController.swift b/RaceSync/View Controllers/Series/SeriesTabBarController.swift index 523a51a1..8b8a82b1 100644 --- a/RaceSync/View Controllers/Series/SeriesTabBarController.swift +++ b/RaceSync/View Controllers/Series/SeriesTabBarController.swift @@ -65,6 +65,13 @@ class SeriesTabBarController: UITabBarController { self.title = "Details" } + init(with series: Series) { + self.seriesId = series.id + self.series = series + super.init(nibName: nil, bundle: nil) + self.title = "Details" + } + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -75,7 +82,10 @@ class SeriesTabBarController: UITabBarController { super.viewDidLoad() setupLayout() - loadSeries() + + if series == nil { + loadSeries() + } } override func viewWillAppear(_ animated: Bool) { @@ -97,9 +107,8 @@ class SeriesTabBarController: UITabBarController { tabBar.isHidden = true // hiding temporarily, while the view loads delegate = self - view.addSubview(activityIndicatorView) - activityIndicatorView.snp.makeConstraints { - $0.centerX.centerY.equalToSuperview() + if series != nil { + configureViewControllers() } } @@ -114,7 +123,7 @@ class SeriesTabBarController: UITabBarController { let controller = SeriesController(with: series) let raceListVC = RaceListViewController(raceViewModels, series: series) - raceListVC.navigationItem.rightBarButtonItem = controller.navigationItems() + raceListVC.navigationItem.rightBarButtonItems = controller.navigationItems() var vcs = [UIViewController]() vcs += [SeriesDetailViewController(with: controller)] @@ -125,17 +134,25 @@ class SeriesTabBarController: UITabBarController { title = vcs.first?.title tabBar.isHidden = false - navigationItem.rightBarButtonItem = controller.navigationItems() + navigationItem.rightBarButtonItems = controller.navigationItems() } // MARK: - Data Update fileprivate func loadSeries() { - setLoading(true) + + view.addSubview(activityIndicatorView) + activityIndicatorView.snp.makeConstraints { + $0.centerX.centerY.equalToSuperview() + } + + activityIndicatorView.isLoading = true seriesApi.view(series: seriesId) { [weak self] series, error in guard let self = self else { return } - self.setLoading(false) + self.activityIndicatorView.isLoading = false + self.activityIndicatorView.removeFromSuperview() + self.series = series if let error = error { @@ -146,10 +163,6 @@ class SeriesTabBarController: UITabBarController { } } - fileprivate func setLoading(_ loading: Bool) { - activityIndicatorView.isLoading = loading - } - // MARK: - Actions fileprivate func selectTab(_ tab: SeriesTabs) { @@ -207,8 +220,6 @@ extension SeriesTabBarController: UITabBarControllerDelegate { func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { - (tabBar as? RoundedSelectionTabBar)?.updateSelectionFrame(animated: true) - if let index = viewControllers?.lastIndex(of: viewController) { didSelectedIndex(index) } diff --git a/RaceSync/View Controllers/Settings/SettingsViewController.swift b/RaceSync/View Controllers/Settings/SettingsViewController.swift index 49ed2ab7..491543c6 100644 --- a/RaceSync/View Controllers/Settings/SettingsViewController.swift +++ b/RaceSync/View Controllers/Settings/SettingsViewController.swift @@ -88,8 +88,7 @@ class SettingsViewController: UIViewController { title = "Settings" tabBarItem = UITabBarItem(title: title, image: SystemImg.gearshape, selectedImage: SystemImg.gearshapeFill) - let leftBtnItem = UIBarButtonItem(image: ButtonImg.close, style: .done, target: self, action: #selector(didPressCloseButton)) - navigationItem.leftBarButtonItem = leftBtnItem + navigationItem.leftBarButtonItem = UIBarButtonItem(image: ButtonImg.close, style: .plain, target: self, action: #selector(didPressCloseButton)) } // MARK: - Actions diff --git a/RaceSync/View Controllers/Standings/StandingsViewController.swift b/RaceSync/View Controllers/Standings/StandingsViewController.swift index 4d9b24b5..56ccf31b 100644 --- a/RaceSync/View Controllers/Standings/StandingsViewController.swift +++ b/RaceSync/View Controllers/Standings/StandingsViewController.swift @@ -316,7 +316,6 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { } - // MARK: - Search fileprivate func enableSearchBar(_ enable: Bool) { diff --git a/RaceSync/View Models/EmptyStateViewModel.swift b/RaceSync/View Models/EmptyStateViewModel.swift index 0d9c1ac2..9fc5d89c 100644 --- a/RaceSync/View Models/EmptyStateViewModel.swift +++ b/RaceSync/View Models/EmptyStateViewModel.swift @@ -117,25 +117,25 @@ struct EmptyStateViewModel: EmptyStateViewModelInterface { case .noJoinedRaces, .noMyProfileRaces: text = "You haven't joined any upcoming races yet." case .noSeriesRaces: - text = "There are no \(Date().thisYear()) GQ races available just yet." + text = "There are no \(Date().thisYear()) GQ races available yet." case .noNearbydRaces: text = "There are no races available in a \(settings.searchRadius)\(settings.lengthUnit.symbol) radius." case .noRacePilots: text = "There are no registered pilots yet." case .noRaceResults: - text = "There are no race results available just yet." + text = "There are no race results available yet." case .noSeries, .noJoinedSeries: text = "There are no series available yet under this category." case .noSeriesResults: - text = "There are no series results available just yet." + text = "There are no series results available yet." case .noRacePayments: text = "No payments found yet, or a network error occurred." case .noChapterMembers: text = "There are no registered members yet." case .noProfileRaces: - text = "This user hasn't joined any races yet." + text = "This pilot hasn't joined any races yet." case .noProfileChapters: - text = "This user hasn't joined any chapters yet." + text = "This pilot hasn't joined any chapters yet." case .noMyProfileChapters: text = "You haven't joined any chapters yet." case .noPushMessages: diff --git a/RaceSync/View Models/RaceViewModel.swift b/RaceSync/View Models/RaceViewModel.swift index d63a0cb7..b4cc804c 100644 --- a/RaceSync/View Models/RaceViewModel.swift +++ b/RaceSync/View Models/RaceViewModel.swift @@ -30,6 +30,7 @@ class RaceViewModel: Descriptable { let participantCount: Int let feeLabel: String let chapterLabel: String + let seriesLabel: String let ownerLabel: String let seasonLabel: String let imageUrl: String? @@ -53,6 +54,7 @@ class RaceViewModel: Descriptable { self.distance = Self.distance(for: race) self.participantCount = Int(race.participantCount) ?? 0 self.chapterLabel = race.chapterName + self.seriesLabel = race.seriesName self.ownerLabel = race.ownerUserName self.seasonLabel = race.seasonName self.imageUrl = Self.imageUrl(for: race) diff --git a/RaceSyncAPI/Constants/APIConstants.swift b/RaceSyncAPI/Constants/APIConstants.swift index beed8626..afd463bb 100644 --- a/RaceSyncAPI/Constants/APIConstants.swift +++ b/RaceSyncAPI/Constants/APIConstants.swift @@ -86,6 +86,7 @@ public enum ParamKey { static public let locationId = "locationId" static public let ownerId = "ownerId" static public let courseId = "courseId" + static public let seriesId = "seriesId" static public let parentCourseId = "parentCourseId" static public let parentRaceId = "parentRaceId" static public let homeChapterId = "homeChapterId" @@ -117,6 +118,7 @@ public enum ParamKey { static let chapterName = "chapterName" static let seasonName = "seasonName" static let courseName = "courseName" + static let seriesName = "seriesName" static let pilotUserName = "pilotUserName" static let pilotName = "pilotName" static let urlName = "urlName" diff --git a/RaceSyncAPI/Extensions/Array+Extensions.swift b/RaceSyncAPI/Extensions/Array+Extensions.swift index 5ca26bf0..d51fc35f 100644 --- a/RaceSyncAPI/Extensions/Array+Extensions.swift +++ b/RaceSyncAPI/Extensions/Array+Extensions.swift @@ -17,6 +17,11 @@ public extension Array { mutating func rearrange(from: Int, to: Int) { insert(remove(at: from), at: to) } + + func interspersed(with separator: Element) -> [Element] { + guard count > 1 else { return self } + return dropLast().flatMap { [$0, separator] } + [last!] + } } public extension Array where Element: Equatable { diff --git a/RaceSyncAPI/Models/MGPEvent.swift b/RaceSyncAPI/Models/MGPEvent.swift new file mode 100644 index 00000000..952b3cb0 --- /dev/null +++ b/RaceSyncAPI/Models/MGPEvent.swift @@ -0,0 +1,188 @@ +// +// Event.swift +// RaceSyncAPI +// +// Created by Ignacio Romero on 2026-05-18. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import Foundation +import ObjectMapper + +public let MGPEventTimeZone: TimeZone? = TimeZone(identifier: "America/Indiana/Indianapolis") + +public class MGPEvent: Mappable, Descriptable { + + public var name: String = "" + public var venue: String = "" + public var lastUpdated: Date? + + public var tracks: [MGPEventTrack]? = nil + public var sessions: [MGPEventSession]? = nil + + // MARK: - Initialization + + public required convenience init?(map: Map) { + self.init() + self.mapping(map: map) + } + + public func mapping(map: Map) { + name <- map["event"] + venue <- map["venue"] + lastUpdated <- (map["lastUpdated"], MapperUtil.dateTransform) + + tracks <- map["tracks"] + sessions <- map["sessions"] + } +} + +public class MGPEventTrack: Mappable, Descriptable { + + public var id: ObjectId = "" + public var name: String = "" + public var location: String = "" + + // MARK: - Initialization + + public required convenience init?(map: Map) { + self.init() + self.mapping(map: map) + } + + public func mapping(map: Map) { + id <- map["id"] + name <- map["name"] + location <- map["location"] + } +} + +public class MGPEventSession: Mappable, Descriptable { + + public var id: ObjectId = "" + + public var date: Date? + public var startTime: Date? + public var endTime: Date? + public var dayName: String = "" + + public var trackId: ObjectId = "" + public var activity: String = "" + public var status: MGPEventStatus = .closed + + // MARK: - Initialization + + public required convenience init?(map: Map) { + self.init() + self.mapping(map: map) + } + + public func mapping(map: Map) { + id <- map["id"] + dayName <- map["day"] + activity <- map["activity"] + trackId <- map["trackId"] + status <- (map["status"], EnumTransform()) + + // Local vars — never stored on self + var rawDate: String? + var rawStartTime: String? + var rawEndTime: String? + + rawDate <- map["date"] + rawStartTime <- map["startTime"] + rawEndTime <- map["endTime"] + + date = Self.parseDate(rawDate) + startTime = Self.parseDateTime(date: rawDate, time: rawStartTime) + endTime = Self.parseDateTime(date: rawDate, time: rawEndTime) + } +} + +public enum MGPEventStatus: String, EnumTitle { + + public var title: String { + return self.rawValue.capitalized + } + + case closed = "closed" + case scheduled = "scheduled" +} + +extension MGPEventSession { + + public static func io26Dates(from start: String, to end: String) -> [Date] { + guard let startDate = io26Date(from: start), + let endDate = io26Date(from: end), + startDate <= endDate else { return [] } + + var dates: [Date] = [] + var current = startDate + let calendar = Calendar.current + + while current <= endDate { + dates.append(current) + guard let next = calendar.date(byAdding: .day, value: 1, to: current) else { break } + current = next + } + + return dates + } + + public static func io26Date(from string: String) -> Date? { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = MGPEventTimeZone + + return formatter.date(from: string) + } + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.timeZone = MGPEventTimeZone + return f + }() + + private static let dateTimeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm" + f.timeZone = MGPEventTimeZone + return f + }() + + private static func parseDate(_ dateString: String?) -> Date? { + guard let dateString else { return nil } + return dateFormatter.date(from: dateString) + } + + private static func parseDateTime(date dateString: String?, time timeString: String?) -> Date? { + guard let dateString, let timeString else { return nil } + return dateTimeFormatter.date(from: "\(dateString) \(timeString)") + } +} + +extension MGPEventSession: Hashable { + public static func == (lhs: MGPEventSession, rhs: MGPEventSession) -> Bool { + lhs.id == rhs.id + } + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +extension MGPEventSession { + + public func copy() -> MGPEventSession { + let copy = MGPEventSession() + copy.id = id + copy.date = date + copy.startTime = startTime + copy.endTime = endTime + copy.dayName = dayName + copy.trackId = trackId + copy.activity = activity + copy.status = status + return copy + } +} diff --git a/RaceSyncAPI/Models/Race.swift b/RaceSyncAPI/Models/Race.swift index 4ca2b3e0..6ba84e6a 100644 --- a/RaceSyncAPI/Models/Race.swift +++ b/RaceSyncAPI/Models/Race.swift @@ -65,6 +65,8 @@ public class Race: Mappable, Descriptable { public var seasonName: String = "" public var courseId: ObjectId? public var courseName: String = "" + public var seriesId: ObjectId? + public var seriesName: String = "" public var typeRestriction: String = "" public var sizeRestriction: String = "" @@ -159,6 +161,8 @@ public class Race: Mappable, Descriptable { seasonName <- (map[ParamKey.seasonName], MapperUtil.stringTransform) courseId <- map[ParamKey.courseId] courseName <- (map[ParamKey.courseName], MapperUtil.stringTransform) + seriesId <- map[ParamKey.seriesId] + seriesName <- (map[ParamKey.seriesName], MapperUtil.stringTransform) typeRestriction <- map[ParamKey.typeRestriction] sizeRestriction <- map[ParamKey.sizeRestriction] diff --git a/RaceSyncAPI/Network/EventApi.swift b/RaceSyncAPI/Network/EventApi.swift new file mode 100644 index 00000000..a4c979ce --- /dev/null +++ b/RaceSyncAPI/Network/EventApi.swift @@ -0,0 +1,71 @@ +// +// EventApi.swift +// RaceSyncAPI +// +// Created by Ignacio Romero on 2026-05-18. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import Foundation +import ObjectMapper + +// MARK: - Interface +public protocol MGPEventApiInterface { + + /** + */ + func getIO26Event(_ completion: @escaping ObjectCompletionBlock) +} + +public class MGPEventApi: MGPEventApiInterface { + + public init() {} + + public func getIO26Event(_ completion: @escaping ObjectCompletionBlock) { + + let url = URL(string: "https://script.google.com/macros/s/AKfycbwxgL-ib1uq1EMyfkjrpvmdoMSxzKGG5x--MV4GAMExkM3UEV5FHovTM_UKbTtALQBj/exec")! + + fetchEvent(from: url) { result in + DispatchQueue.main.async { + var log: String = "+ Ended request with " + + switch result { + case .success(let json): + let model = Mapper().map(JSONObject: json) + log += "\(model?.name ?? "")" + completion(model, nil) + + case .failure(let error): + let err = error as NSError + log += " Network Error: \(err.debugDescription)" + completion(nil, err) + } + + Clog.log("\(log)") + } + } + } +} + + +extension MGPEventApi { + + fileprivate func fetchEvent(from url: URL, completion: @escaping (Result<[String: Any], Error>) -> Void) { + + Clog.log("Starting request \(String(describing: url))") + + URLSession.shared.dataTask(with: url) { data, _, error in + if let error = error { + return completion(.failure(error)) + } + + guard let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return completion(.failure(NSError(domain: "InvalidData", code: 0))) + } + + completion(.success(json)) + + }.resume() + } +}