diff --git a/Documentation/App Store/RaceSync Liquid Icon.icon/Assets/icon-bkgd.png b/Documentation/App Store/RaceSync Liquid Icon.icon/Assets/icon-bkgd.png new file mode 100644 index 00000000..a8ca5b61 Binary files /dev/null and b/Documentation/App Store/RaceSync Liquid Icon.icon/Assets/icon-bkgd.png differ diff --git a/Documentation/App Store/RaceSync Liquid Icon.icon/Assets/icon-bkgd2.png b/Documentation/App Store/RaceSync Liquid Icon.icon/Assets/icon-bkgd2.png new file mode 100644 index 00000000..419bc04e Binary files /dev/null and b/Documentation/App Store/RaceSync Liquid Icon.icon/Assets/icon-bkgd2.png differ diff --git a/Documentation/App Store/RaceSync Liquid Icon.icon/Assets/icon.png b/Documentation/App Store/RaceSync Liquid Icon.icon/Assets/icon.png new file mode 100644 index 00000000..e1263a4c Binary files /dev/null and b/Documentation/App Store/RaceSync Liquid Icon.icon/Assets/icon.png differ diff --git a/Documentation/App Store/RaceSync Liquid Icon.icon/Assets/icon2.png b/Documentation/App Store/RaceSync Liquid Icon.icon/Assets/icon2.png new file mode 100644 index 00000000..79290569 Binary files /dev/null and b/Documentation/App Store/RaceSync Liquid Icon.icon/Assets/icon2.png differ diff --git a/Documentation/App Store/RaceSync Liquid Icon.icon/Assets/icon3.png b/Documentation/App Store/RaceSync Liquid Icon.icon/Assets/icon3.png new file mode 100644 index 00000000..c89946d4 Binary files /dev/null and b/Documentation/App Store/RaceSync Liquid Icon.icon/Assets/icon3.png differ diff --git a/Documentation/App Store/RaceSync Liquid Icon.icon/icon.json b/Documentation/App Store/RaceSync Liquid Icon.icon/icon.json new file mode 100644 index 00000000..7c28828e --- /dev/null +++ b/Documentation/App Store/RaceSync Liquid Icon.icon/icon.json @@ -0,0 +1,109 @@ +{ + "fill" : { + "automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000" + }, + "groups" : [ + { + "blend-mode-specializations" : [ + { + "appearance" : "dark", + "value" : "multiply" + } + ], + "blur-material" : 0.5, + "layers" : [ + { + "fill" : "none", + "glass" : false, + "hidden" : false, + "image-name" : "icon3.png", + "name" : "icon3" + }, + { + "blend-mode" : "normal", + "fill" : "none", + "glass" : false, + "hidden" : true, + "image-name" : "icon.png", + "name" : "icon", + "opacity" : 1 + }, + { + "blend-mode" : "normal", + "fill" : "none", + "glass" : false, + "hidden" : true, + "image-name" : "icon2.png", + "name" : "icon2", + "opacity" : 1 + } + ], + "lighting" : "individual", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + }, + { + "blend-mode-specializations" : [ + { + "appearance" : "dark", + "value" : "normal" + } + ], + "blur-material" : null, + "hidden" : false, + "layers" : [ + { + "blend-mode" : "plus-lighter", + "fill" : { + "linear-gradient" : [ + "srgb:1.00000,1.00000,1.00000,1.00000", + "srgb:0.38142,0.37284,0.90874,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 1 + }, + "stop" : { + "x" : 0.5, + "y" : 0.3 + } + } + }, + "glass" : false, + "hidden" : true, + "image-name" : "icon-bkgd2.png", + "name" : "icon-bkgd2", + "opacity" : 0.39 + }, + { + "blend-mode" : "normal", + "glass" : false, + "hidden" : false, + "image-name" : "icon-bkgd.png", + "name" : "icon-bkgd", + "opacity" : 1 + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "squares" : "shared" + } +} \ No newline at end of file diff --git a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-1024x1024.png b/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-1024x1024.png deleted file mode 100644 index 44c57987..00000000 Binary files a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-1024x1024.png and /dev/null differ diff --git a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-20x20@2x.png b/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-20x20@2x.png deleted file mode 100644 index 102d0b86..00000000 Binary files a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-20x20@2x.png and /dev/null differ diff --git a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-20x20@3x.png b/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-20x20@3x.png deleted file mode 100644 index eed7cdcf..00000000 Binary files a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-20x20@3x.png and /dev/null differ diff --git a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-29x29@2x.png b/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-29x29@2x.png deleted file mode 100644 index 536c207d..00000000 Binary files a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-29x29@2x.png and /dev/null differ diff --git a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-29x29@3x.png b/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-29x29@3x.png deleted file mode 100644 index 35711a22..00000000 Binary files a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-29x29@3x.png and /dev/null differ diff --git a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-40x40@2x.png b/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-40x40@2x.png deleted file mode 100644 index 2701e0c6..00000000 Binary files a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-40x40@2x.png and /dev/null differ diff --git a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-40x40@3x.png b/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-40x40@3x.png deleted file mode 100644 index 5a28b9a9..00000000 Binary files a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-40x40@3x.png and /dev/null differ diff --git a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-60x60@2x.png b/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-60x60@2x.png deleted file mode 100644 index 5fc34185..00000000 Binary files a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-60x60@2x.png and /dev/null differ diff --git a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-60x60@3x.png b/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-60x60@3x.png deleted file mode 100644 index 235dccfa..00000000 Binary files a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0-assets/Icon-App-60x60@3x.png and /dev/null differ diff --git a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0.psd b/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0.psd index e390548d..d9d7d150 100644 Binary files a/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0.psd and b/Documentation/App Store/RaceSync_iOS_Default_Icon_2.0.psd differ diff --git a/Documentation/App Store/RaceSync_screenshots.psd b/Documentation/App Store/RaceSync_screenshots.psd index c5488eda..f956dd47 100644 Binary files a/Documentation/App Store/RaceSync_screenshots.psd and b/Documentation/App Store/RaceSync_screenshots.psd differ diff --git a/Documentation/App Store/Screenshots/RaceSync_screenshots_1.png b/Documentation/App Store/Screenshots/RaceSync_screenshots_1.png index ebd1d287..8f7f9a84 100644 Binary files a/Documentation/App Store/Screenshots/RaceSync_screenshots_1.png and b/Documentation/App Store/Screenshots/RaceSync_screenshots_1.png differ diff --git a/Documentation/App Store/Screenshots/RaceSync_screenshots_2.png b/Documentation/App Store/Screenshots/RaceSync_screenshots_2.png index 7a643787..f4b15200 100644 Binary files a/Documentation/App Store/Screenshots/RaceSync_screenshots_2.png and b/Documentation/App Store/Screenshots/RaceSync_screenshots_2.png differ diff --git a/Documentation/App Store/Screenshots/RaceSync_screenshots_3.png b/Documentation/App Store/Screenshots/RaceSync_screenshots_3.png index da5d8fc1..07731c6d 100644 Binary files a/Documentation/App Store/Screenshots/RaceSync_screenshots_3.png and b/Documentation/App Store/Screenshots/RaceSync_screenshots_3.png differ diff --git a/Documentation/App Store/Screenshots/RaceSync_screenshots_4.png b/Documentation/App Store/Screenshots/RaceSync_screenshots_4.png index 1aa34fe6..fb61ab96 100644 Binary files a/Documentation/App Store/Screenshots/RaceSync_screenshots_4.png and b/Documentation/App Store/Screenshots/RaceSync_screenshots_4.png differ diff --git a/Documentation/App Store/Screenshots/RaceSync_screenshots_5.png b/Documentation/App Store/Screenshots/RaceSync_screenshots_5.png index 86b5dda9..131c7016 100644 Binary files a/Documentation/App Store/Screenshots/RaceSync_screenshots_5.png and b/Documentation/App Store/Screenshots/RaceSync_screenshots_5.png differ diff --git a/Documentation/App Store/Screenshots/RaceSync_screenshots_6.png b/Documentation/App Store/Screenshots/RaceSync_screenshots_6.png index 8e0542bb..02cff0bb 100644 Binary files a/Documentation/App Store/Screenshots/RaceSync_screenshots_6.png and b/Documentation/App Store/Screenshots/RaceSync_screenshots_6.png differ diff --git a/Documentation/App Store/Screenshots/RaceSync_screenshots_7.png b/Documentation/App Store/Screenshots/RaceSync_screenshots_7.png index 65ea366e..5788ff7a 100644 Binary files a/Documentation/App Store/Screenshots/RaceSync_screenshots_7.png and b/Documentation/App Store/Screenshots/RaceSync_screenshots_7.png differ diff --git a/Documentation/App Store/Screenshots/RaceSync_screenshots_8.png b/Documentation/App Store/Screenshots/RaceSync_screenshots_8.png index 528aa864..cc113c61 100644 Binary files a/Documentation/App Store/Screenshots/RaceSync_screenshots_8.png and b/Documentation/App Store/Screenshots/RaceSync_screenshots_8.png differ diff --git a/Documentation/App Store/Screenshots/RaceSync_screenshots_9.png b/Documentation/App Store/Screenshots/RaceSync_screenshots_9.png new file mode 100644 index 00000000..bce1a2fd Binary files /dev/null and b/Documentation/App Store/Screenshots/RaceSync_screenshots_9.png differ diff --git a/Documentation/Github/racesync_readme_screenshots_v2.0.png b/Documentation/Github/racesync_readme_screenshots_v2.0.png new file mode 100644 index 00000000..435ee77e Binary files /dev/null and b/Documentation/Github/racesync_readme_screenshots_v2.0.png differ diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 5d959834..ee0e5924 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -3,6 +3,23 @@ --- +## 2.1 + +### 2026 International Open's interactive schedule: + * Filter the different races per day. + * Fav the races you want to attend & get notified an hour before they start. + * Build your customized schedule, and filter with special macros. + +### iOS26 upgrade: + * Compiling with the latest iOS26 SDK + * Large UI refactoring to conform to iOS26's liquid glass UI + +### Fixes and Enhancements: + * General bug fixes + * Backwards compatibility fixing + +--- + ## 2.0 ### Introducing MultiGP Series: diff --git a/RaceSync.xcodeproj/project.pbxproj b/RaceSync.xcodeproj/project.pbxproj index 99feabed..857100c1 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 */; }; @@ -268,6 +267,20 @@ 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 /* Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EE42FBC09C800460A74 /* Event.swift */; }; + 4FE91EE92FBD2D6B00460A74 /* EventSessionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EE82FBD2D6B00460A74 /* EventSessionTableViewCell.swift */; }; + 4FE91EEB2FC2957900460A74 /* EventSessionBucketlist.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EEA2FC2957900460A74 /* EventSessionBucketlist.swift */; }; + 4FE91EEF2FC2C6EE00460A74 /* Notificationscheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EEE2FC2C6EE00460A74 /* Notificationscheduler.swift */; }; + 4FE91EF12FC2C72000460A74 /* NotificationScheduler+EventSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EF02FC2C72000460A74 /* NotificationScheduler+EventSession.swift */; }; + 4FE91EF32FC4252600460A74 /* EventStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EF22FC4252600460A74 /* EventStore.swift */; }; + 4FE91EF52FC426F100460A74 /* EventStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EF42FC426F100460A74 /* EventStore.swift */; }; + 4FE91EF62FC4273B00460A74 /* FileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EEC2FC29EC300460A74 /* FileStore.swift */; }; + 4FE91EFA2FC5266D00460A74 /* EventHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EF72FC5266D00460A74 /* EventHeaderView.swift */; }; + 4FE91EFB2FC5266D00460A74 /* EventsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EF82FC5266D00460A74 /* EventsController.swift */; }; + 4FE91EFC2FC5266D00460A74 /* EventsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE91EF92FC5266D00460A74 /* EventsViewController.swift */; }; + 4FE91EFE2FC6401700460A74 /* RaceSync.icon in Resources */ = {isa = PBXBuildFile; fileRef = 4FE91EFD2FC6401700460A74 /* RaceSync.icon */; }; 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 +460,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 = ""; }; @@ -598,6 +610,20 @@ 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 /* Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Event.swift; sourceTree = ""; }; + 4FE91EE82FBD2D6B00460A74 /* EventSessionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventSessionTableViewCell.swift; sourceTree = ""; }; + 4FE91EEA2FC2957900460A74 /* EventSessionBucketlist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventSessionBucketlist.swift; sourceTree = ""; }; + 4FE91EEC2FC29EC300460A74 /* FileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileStore.swift; sourceTree = ""; }; + 4FE91EEE2FC2C6EE00460A74 /* Notificationscheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notificationscheduler.swift; sourceTree = ""; }; + 4FE91EF02FC2C72000460A74 /* NotificationScheduler+EventSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationScheduler+EventSession.swift"; sourceTree = ""; }; + 4FE91EF22FC4252600460A74 /* EventStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventStore.swift; sourceTree = ""; }; + 4FE91EF42FC426F100460A74 /* EventStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventStore.swift; sourceTree = ""; }; + 4FE91EF72FC5266D00460A74 /* EventHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventHeaderView.swift; sourceTree = ""; }; + 4FE91EF82FC5266D00460A74 /* EventsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsController.swift; sourceTree = ""; }; + 4FE91EF92FC5266D00460A74 /* EventsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsViewController.swift; sourceTree = ""; }; + 4FE91EFD2FC6401700460A74 /* RaceSync.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = RaceSync.icon; 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 = ""; }; @@ -652,6 +678,7 @@ children = ( 4F004EA1295B828E009C46AA /* RaceData.swift */, 4F6CAD8E298342DE00E80B5F /* UserData.swift */, + 4FE91EF42FC426F100460A74 /* EventStore.swift */, ); path = Data; sourceTree = ""; @@ -682,6 +709,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 */, @@ -733,6 +761,7 @@ 4FA26825237A4005008970AC /* Constants */, 4F13424B2360B67D00A9DBDE /* Main.storyboard */, 4F1342502360B67D00A9DBDE /* LaunchScreen.storyboard */, + 4FE91EFD2FC6401700460A74 /* RaceSync.icon */, 4F13424E2360B67D00A9DBDE /* Assets.xcassets */, 4F64B34A2D22879C00FC5BF1 /* launch_bkgd.jpg */, 4F9DB79F23D3816800570483 /* RaceSync.entitlements */, @@ -1008,6 +1037,7 @@ children = ( 4FA1FC8A23D6D7C0006D4704 /* ApplicationControl.swift */, 4F6FA16D2E4C66940056E115 /* AppplicationPreferences.swift */, + 4FE91EEE2FC2C6EE00460A74 /* Notificationscheduler.swift */, 4FEADB732416B3D400F82F0D /* EventTracker.swift */, 4F00E065242930B3001DCFC4 /* RateMe */, ); @@ -1034,6 +1064,7 @@ 4F004E9B295A3027009C46AA /* SeasonApi.swift */, 4F004E9D295A3066009C46AA /* CourseApi.swift */, 4FE1DAD72DEAD443009143C4 /* StandingApi.swift */, + 4FE91EE22FBC098900460A74 /* EventApi.swift */, 4F5C1E3F238A7BAF00D756EB /* RepositoryAdapter.swift */, 4FA268232378F9DD008970AC /* NetworkAdapter.swift */, 4F87E4342727976E0061425B /* NetworkProxy.swift */, @@ -1075,6 +1106,7 @@ 4FA213FA29582CCB00C8E45A /* IntegerTransform.swift */, 4F6503372E45CE4900FA8AED /* FloatTransform.swift */, 4F1DC111297E42BA005EDAE0 /* BooleanTransform.swift */, + 4FE91EEC2FC29EC300460A74 /* FileStore.swift */, 4FD4E1E22381E175008816B3 /* Random.swift */, 4F81759A2411917900685F83 /* Clog.swift */, ); @@ -1097,12 +1129,27 @@ 4FDD499523B0461D009DD2DB /* Season.swift */, 4F004E99295A2CA1009C46AA /* Course.swift */, 4FE1DAD52DEAD225009143C4 /* Standing.swift */, + 4FE91EE42FBC09C800460A74 /* Event.swift */, 4FA26830237A58E1008970AC /* ApiError.swift */, 4F77D21A29940E5D009DEB41 /* Extensions */, ); path = Models; sourceTree = ""; }; + 4FA7FDA02F9E81FC00D33266 /* Events */ = { + isa = PBXGroup; + children = ( + 4FE91EF92FC5266D00460A74 /* EventsViewController.swift */, + 4FE91EF82FC5266D00460A74 /* EventsController.swift */, + 4FE91EF72FC5266D00460A74 /* EventHeaderView.swift */, + 4FE91EF22FC4252600460A74 /* EventStore.swift */, + 4FE91EE82FBD2D6B00460A74 /* EventSessionTableViewCell.swift */, + 4FE91EEA2FC2957900460A74 /* EventSessionBucketlist.swift */, + 4FE91EF02FC2C72000460A74 /* NotificationScheduler+EventSession.swift */, + ); + path = Events; + sourceTree = ""; + }; 4FD4E1D2237F9DEF008816B3 /* Search */ = { isa = PBXGroup; children = ( @@ -1143,6 +1190,7 @@ 4FD4E1D3237F9DF8008816B3 /* Races */, 4F521F802E6E18BD00AE7C03 /* Series */, 4FE1DAD92DEB6AE4009143C4 /* Standings */, + 4FA7FDA02F9E81FC00D33266 /* Events */, 4F5C1E41238CF68F00D756EB /* Profiles */, 4F714DE423CD769F000C4036 /* Map */, 4F8B4A082DDC7AB900B735DA /* Push Messages */, @@ -1187,7 +1235,6 @@ 4F536ED02F8AEB35006FF620 /* ApproveButton.swift */, 4FA26834237A80B9008970AC /* ActionButton.swift */, 4FD4E1C2237F960A008816B3 /* CustomButton.swift */, - 4F71B3272E490F760029D3CB /* RoundedSelectionTabBar.swift */, 4F8B4A0F2DDCEECD00B735DA /* BadgeHub.swift */, 4F791C9723A6105700F15FD7 /* TextPill.swift */, 4F6AE78C2D2DCD4B008636B7 /* RankView.swift */, @@ -1355,7 +1402,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1310; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 2640; ORGANIZATIONNAME = "MultiGP Inc."; TargetAttributes = { 4F1342432360B67D00A9DBDE = { @@ -1408,6 +1455,7 @@ 4FA213F82947AB5900C8E45A /* AppIcon-FMR@3x.png in Resources */, 4F80A65A296F9951008B19AF /* rich_editor.js in Resources */, 4F80A65D296F9951008B19AF /* rich_editor_style.css in Resources */, + 4FE91EFE2FC6401700460A74 /* RaceSync.icon in Resources */, 4FA213EE2946B5A300C8E45A /* AppIcon-S3@2x.png in Resources */, 4FB06AB5296B521000F14E59 /* AppIcon-MGP2@3x.png in Resources */, 4F1342522360B67D00A9DBDE /* LaunchScreen.storyboard in Resources */, @@ -1451,10 +1499,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 +1574,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,6 +1615,7 @@ 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 */, @@ -1621,9 +1678,11 @@ 4F73DAEA2DEE7300007B15F2 /* String+UIExtensions.swift in Sources */, 4FC86BA52D27E00E004297E7 /* MultiTextPickerViewController.swift in Sources */, 4F0B61432385EEFF00930D91 /* ViewModelHelper.swift in Sources */, + 4FE91EF12FC2C72000460A74 /* NotificationScheduler+EventSession.swift in Sources */, 4F8175A52411DDB900685F83 /* HomeController.swift in Sources */, 4F8376DC23EE87CC00256AEC /* RotatingIconView.swift in Sources */, 4F0B613C238528C300930D91 /* PasteboardLabel.swift in Sources */, + 4FE91EF32FC4252600460A74 /* EventStore.swift in Sources */, 4FD4E1DD237FBE8B008816B3 /* MemberBadgeView.swift in Sources */, 4F6425BA2F57CA4600A4199C /* UIView+Separator.swift in Sources */, 4FC50258242700DC0088320B /* RacePilotsPickerController.swift in Sources */, @@ -1646,6 +1705,9 @@ 4F2724402517DC5B000C7408 /* UIActivityViewController+Extensions.swift in Sources */, 4FA1AB3D23C94F5F007CF389 /* UIEdgeInsets+Extensions.swift in Sources */, 4F3750A92EF4C9510068FE16 /* RaceEditable.swift in Sources */, + 4FE91EFA2FC5266D00460A74 /* EventHeaderView.swift in Sources */, + 4FE91EFB2FC5266D00460A74 /* EventsController.swift in Sources */, + 4FE91EFC2FC5266D00460A74 /* EventsViewController.swift in Sources */, 4F6429122402DB00005E8036 /* ImageConstants.swift in Sources */, 4FD4E1C5237F9746008816B3 /* UserViewController.swift in Sources */, 4F6FA16E2E4C66940056E115 /* AppplicationPreferences.swift in Sources */, @@ -1660,7 +1722,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 */, @@ -1675,6 +1736,7 @@ 4FBE760F259B387E00312B66 /* User+UIExtensions.swift in Sources */, 4F8175A12411C35A00685F83 /* StandingsViewController.swift in Sources */, 4F90F0D22E8DD56D00D9F5AF /* SeriesStandingsViewController.swift in Sources */, + 4FE91EEF2FC2C6EE00460A74 /* Notificationscheduler.swift in Sources */, 4F8B866C296583EB00EAF695 /* TextEditorViewController.swift in Sources */, 4FB0EB6A23BB3B1F009ABAF0 /* FormTableViewCell.swift in Sources */, 4FD4E1CD237F98E7008816B3 /* RaceTabBarController.swift in Sources */, @@ -1692,6 +1754,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 */, @@ -1716,6 +1779,7 @@ 4FA213F02946C9FA00C8E45A /* AppIcon.swift in Sources */, 4F8B4A102DDCEECD00B735DA /* BadgeHub.swift in Sources */, 4FD53FAD23A0B01E00158206 /* UserViewModel.swift in Sources */, + 4FE91EEB2FC2957900460A74 /* EventSessionBucketlist.swift in Sources */, 4F03693626D457CB00E30821 /* AppIconViewController.swift in Sources */, 4F64B34D2D235EE300FC5BF1 /* UIView+Parallax.swift in Sources */, 4F86690423877041005E310A /* ChapterTableViewCell.swift in Sources */, @@ -1729,8 +1793,10 @@ 4F004E9A295A2CA1009C46AA /* Course.swift in Sources */, 4F8AFAFA2E8F37D000088304 /* SeriesResult.swift in Sources */, 4F1DC112297E42BA005EDAE0 /* BooleanTransform.swift in Sources */, + 4FE91EF62FC4273B00460A74 /* FileStore.swift in Sources */, 4FCAF9FA23AB226500ACE7D3 /* MapperUtil.swift in Sources */, 4F8669062388D3BC005E310A /* AuthApi.swift in Sources */, + 4FE91EF52FC426F100460A74 /* EventStore.swift in Sources */, 4F6C8A8F23657DE00017FD78 /* APICredential.swift in Sources */, 4F6A28E32D14F55200FF692D /* TimeUtil.swift in Sources */, 4F004EA2295B828E009C46AA /* RaceData.swift in Sources */, @@ -1770,6 +1836,7 @@ 4F8668F823864389005E310A /* Descriptable.swift in Sources */, 4FEBB27D23A30F99007514B4 /* Date+Extensions.swift in Sources */, 4FBADDF624D4E2DB00A7D291 /* Array+Extensions.swift in Sources */, + 4FE91EE52FBC09C800460A74 /* Event.swift in Sources */, 4FA26833237A5983008970AC /* ObjectMapper+Extensions.swift in Sources */, 4FA26831237A58E1008970AC /* ApiError.swift in Sources */, 4FC51B212409EC8E00D654D0 /* Chapter+Extensions.swift in Sources */, @@ -1783,6 +1850,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 +1960,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 +1982,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 +2024,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 +2039,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 +2050,14 @@ isa = XCBuildConfiguration; baseConfigurationReference = 02BF5AE129D7048EB6155053 /* Pods-RaceSync.debug.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = RaceSync; 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 = 110; 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 +2066,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 +2085,14 @@ isa = XCBuildConfiguration; baseConfigurationReference = 0A43AF1C49328F4DFB325FDA /* Pods-RaceSync.release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = RaceSync; 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 = 110; 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 +2101,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 +2120,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 +2146,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 +2163,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 +2189,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 +2205,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 +2228,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 ) -> Bool { - return true } @@ -82,3 +85,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { completionHandler(.newData) } } + +extension AppDelegate: UNUserNotificationCenterDelegate { + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + // app is foreground when notification fires + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + // user tapped the notification + } +} diff --git a/RaceSync/Assets.xcassets/AppIcon.appiconset/Contents.json b/RaceSync/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index bb97d4d0..00000000 --- a/RaceSync/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024.png b/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024.png deleted file mode 100644 index 44c57987..00000000 Binary files a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024.png and /dev/null differ diff --git a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 102d0b86..00000000 Binary files a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index eed7cdcf..00000000 Binary files a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index 536c207d..00000000 Binary files a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 35711a22..00000000 Binary files a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index 2701e0c6..00000000 Binary files a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index 5a28b9a9..00000000 Binary files a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index 5fc34185..00000000 Binary files a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index 235dccfa..00000000 Binary files a/RaceSync/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/RaceSync/Assets.xcassets/btn_arrow_bkgd.imageset/Contents.json b/RaceSync/Assets.xcassets/btn_arrow_bkgd.imageset/Contents.json deleted file mode 100644 index 35e2e056..00000000 --- a/RaceSync/Assets.xcassets/btn_arrow_bkgd.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "btn_arrow_bkgd.pdf", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/RaceSync/Assets.xcassets/btn_arrow_bkgd.imageset/btn_arrow_bkgd.pdf b/RaceSync/Assets.xcassets/btn_arrow_bkgd.imageset/btn_arrow_bkgd.pdf deleted file mode 100644 index f1517b04..00000000 Binary files a/RaceSync/Assets.xcassets/btn_arrow_bkgd.imageset/btn_arrow_bkgd.pdf and /dev/null differ diff --git a/RaceSync/Assets.xcassets/icn_button_aircraft.imageset/Contents.json b/RaceSync/Assets.xcassets/icn_button_aircraft.imageset/Contents.json deleted file mode 100644 index beec17a0..00000000 --- a/RaceSync/Assets.xcassets/icn_button_aircraft.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "icn_button_aircraft.pdf", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/RaceSync/Assets.xcassets/icn_button_aircraft.imageset/icn_button_aircraft.pdf b/RaceSync/Assets.xcassets/icn_button_aircraft.imageset/icn_button_aircraft.pdf deleted file mode 100644 index 83ab59ae..00000000 Binary files a/RaceSync/Assets.xcassets/icn_button_aircraft.imageset/icn_button_aircraft.pdf and /dev/null differ diff --git a/RaceSync/Assets.xcassets/icn_join_check.imageset/icn_join_check.pdf b/RaceSync/Assets.xcassets/icn_join_check.imageset/icn_join_check.pdf index 1ab204dd..3dd65dbc 100644 Binary files a/RaceSync/Assets.xcassets/icn_join_check.imageset/icn_join_check.pdf and b/RaceSync/Assets.xcassets/icn_join_check.imageset/icn_join_check.pdf differ diff --git a/RaceSync/Assets.xcassets/icn_join_cross.imageset/icn_join_cross.pdf b/RaceSync/Assets.xcassets/icn_join_cross.imageset/icn_join_cross.pdf index 4e0f46f2..475d0f6b 100644 Binary files a/RaceSync/Assets.xcassets/icn_join_cross.imageset/icn_join_cross.pdf and b/RaceSync/Assets.xcassets/icn_join_cross.imageset/icn_join_cross.pdf differ diff --git a/RaceSync/Assets.xcassets/icn_settings_io.pdf b/RaceSync/Assets.xcassets/icn_settings_io.pdf new file mode 100644 index 00000000..682e54f9 Binary files /dev/null and b/RaceSync/Assets.xcassets/icn_settings_io.pdf differ diff --git a/RaceSync/Assets.xcassets/icn_settings_mgp.imageset/icn_settings_mgp.pdf b/RaceSync/Assets.xcassets/icn_settings_mgp.imageset/icn_settings_mgp.pdf index 16772b0f..d4f45c0a 100644 Binary files a/RaceSync/Assets.xcassets/icn_settings_mgp.imageset/icn_settings_mgp.pdf and b/RaceSync/Assets.xcassets/icn_settings_mgp.imageset/icn_settings_mgp.pdf differ diff --git a/RaceSync/Assets.xcassets/placeholder_profile_background.imageset/placeholder_profile_background.jpg b/RaceSync/Assets.xcassets/placeholder_profile_background.imageset/placeholder_profile_background.jpg index 8a723dae..2fca9b21 100644 Binary files a/RaceSync/Assets.xcassets/placeholder_profile_background.imageset/placeholder_profile_background.jpg and b/RaceSync/Assets.xcassets/placeholder_profile_background.imageset/placeholder_profile_background.jpg differ diff --git a/RaceSync/Constants/AppWebConstants.swift b/RaceSync/Constants/AppWebConstants.swift index 6fa36f17..979c261d 100644 --- a/RaceSync/Constants/AppWebConstants.swift +++ b/RaceSync/Constants/AppWebConstants.swift @@ -19,6 +19,7 @@ public class AppWebConstants { static let tracks = "https://www.multigp.com/multigp-tracks/" static let obstaclesDoc = "https://www.multigp.com/multigp-drone-race-course-obstacles/" static let seasonRulesDoc = "https://www.multigp.com/organizer-resources/rule-book/" + static let io26RaceFormats = "https://www.multigp.com/io26/race-formats/" static let livefpv = "https://livefpv.com/" static let fpvscores = "https://fpvscores.com/" diff --git a/RaceSync/Constants/ImageConstants.swift b/RaceSync/Constants/ImageConstants.swift index 67c019e1..1b6b2d56 100644 --- a/RaceSync/Constants/ImageConstants.swift +++ b/RaceSync/Constants/ImageConstants.swift @@ -97,10 +97,27 @@ enum SystemImg { static let chevronRight = UIImage(systemName:"chevron.right") // iOS 13.0+ static let pin_small = UIImage(systemName:"mappin.and.ellipse") // iOS 13.0+ - static let globe = UIImage(systemName:"globe") // iOS 13.0+ static let search = UIImage(systemName: "magnifyingglass") // iOS 13.0+ static let badge_cross_small = UIImage(systemName: "xmark.square.fill") // iOS 13.0+ static let trashFill = UIImage(systemName:"trash.fill") // iOS 13.0+ + static let star = UIImage(systemName:"star") // iOS 13.0+ + static let starFill = UIImage(systemName:"star.fill") // iOS 13.0+ + + static var globe: UIImage? { + if #available(iOS 15.0, *) { + return UIImage(systemName:"globe.americas") // iOS 15.0+ + } else { + return UIImage(systemName:"globe") // iOS 13.0+ + } + } + + static var globeFill: UIImage? { + if #available(iOS 15.0, *) { + return UIImage(systemName:"globe.americas.fill") // iOS 15.0+ + } else { + return UIImage(systemName:"globe.fill") // iOS 13.0+ + } + } static var flagCheckeredCrossed: UIImage? { if #available(iOS 18.0, *) { diff --git a/RaceSync/RaceSync.icon/Assets/icon-bkgd.png b/RaceSync/RaceSync.icon/Assets/icon-bkgd.png new file mode 100644 index 00000000..a8ca5b61 Binary files /dev/null and b/RaceSync/RaceSync.icon/Assets/icon-bkgd.png differ diff --git a/RaceSync/RaceSync.icon/Assets/icon-bkgd2.png b/RaceSync/RaceSync.icon/Assets/icon-bkgd2.png new file mode 100644 index 00000000..419bc04e Binary files /dev/null and b/RaceSync/RaceSync.icon/Assets/icon-bkgd2.png differ diff --git a/RaceSync/RaceSync.icon/Assets/icon.png b/RaceSync/RaceSync.icon/Assets/icon.png new file mode 100644 index 00000000..e1263a4c Binary files /dev/null and b/RaceSync/RaceSync.icon/Assets/icon.png differ diff --git a/RaceSync/RaceSync.icon/Assets/icon2.png b/RaceSync/RaceSync.icon/Assets/icon2.png new file mode 100644 index 00000000..79290569 Binary files /dev/null and b/RaceSync/RaceSync.icon/Assets/icon2.png differ diff --git a/RaceSync/RaceSync.icon/Assets/icon3.png b/RaceSync/RaceSync.icon/Assets/icon3.png new file mode 100644 index 00000000..c89946d4 Binary files /dev/null and b/RaceSync/RaceSync.icon/Assets/icon3.png differ diff --git a/RaceSync/RaceSync.icon/icon.json b/RaceSync/RaceSync.icon/icon.json new file mode 100644 index 00000000..7c28828e --- /dev/null +++ b/RaceSync/RaceSync.icon/icon.json @@ -0,0 +1,109 @@ +{ + "fill" : { + "automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000" + }, + "groups" : [ + { + "blend-mode-specializations" : [ + { + "appearance" : "dark", + "value" : "multiply" + } + ], + "blur-material" : 0.5, + "layers" : [ + { + "fill" : "none", + "glass" : false, + "hidden" : false, + "image-name" : "icon3.png", + "name" : "icon3" + }, + { + "blend-mode" : "normal", + "fill" : "none", + "glass" : false, + "hidden" : true, + "image-name" : "icon.png", + "name" : "icon", + "opacity" : 1 + }, + { + "blend-mode" : "normal", + "fill" : "none", + "glass" : false, + "hidden" : true, + "image-name" : "icon2.png", + "name" : "icon2", + "opacity" : 1 + } + ], + "lighting" : "individual", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + }, + { + "blend-mode-specializations" : [ + { + "appearance" : "dark", + "value" : "normal" + } + ], + "blur-material" : null, + "hidden" : false, + "layers" : [ + { + "blend-mode" : "plus-lighter", + "fill" : { + "linear-gradient" : [ + "srgb:1.00000,1.00000,1.00000,1.00000", + "srgb:0.38142,0.37284,0.90874,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 1 + }, + "stop" : { + "x" : 0.5, + "y" : 0.3 + } + } + }, + "glass" : false, + "hidden" : true, + "image-name" : "icon-bkgd2.png", + "name" : "icon-bkgd2", + "opacity" : 0.39 + }, + { + "blend-mode" : "normal", + "glass" : false, + "hidden" : false, + "image-name" : "icon-bkgd.png", + "name" : "icon-bkgd", + "opacity" : 1 + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "squares" : "shared" + } +} \ No newline at end of file diff --git a/RaceSync/Tools/ApplicationControl.swift b/RaceSync/Tools/ApplicationControl.swift index aa3d84c4..84f7a39a 100644 --- a/RaceSync/Tools/ApplicationControl.swift +++ b/RaceSync/Tools/ApplicationControl.swift @@ -17,12 +17,18 @@ class ApplicationControl: NSObject { static let shared = ApplicationControl() var authApi = AuthApi() + + var isIOWindowEnable: Bool { + get { + // From May 22nd to June 16th + return Date().isBetween(day: 22, month: 5, andDay: 16, month: 6) + } + } // MARK: - Private Variables fileprivate var deeplinkObserver: NSObjectProtocol? - // MARK: - Initialization override init() { @@ -48,6 +54,7 @@ class ApplicationControl: NSObject { static func initializeRaceSyncAPI() { + // This was moved to the main Bundle, but it will break whenever the API framework is used on another bundle let path = Bundle.main.path(forResource: "credentials", ofType: "plist") let dict = NSDictionary(contentsOfFile: path ?? "") diff --git a/RaceSync/Tools/AppplicationPreferences.swift b/RaceSync/Tools/AppplicationPreferences.swift index ee61f466..cad0095a 100644 --- a/RaceSync/Tools/AppplicationPreferences.swift +++ b/RaceSync/Tools/AppplicationPreferences.swift @@ -16,8 +16,13 @@ class AppplicationPreferences { static let homeTab = "com.multigp.RaceSync.preferences.last_selected.home_tab" static let raceFilter = "com.multigp.RaceSync.preferences.last_selected.races_filters" static let seriesFilter = "com.multigp.RaceSync.preferences.last_selected.series_filters" + static let eventFilter = "com.multigp.RaceSync.preferences.last_selected.event_filters" } - + + private enum Prefs { + static let schedulerAlerts = "com.multigp.RaceSync.preferences.hide_notification_scheduler_alerts" + } + static var lastSelectedHomeTab: HomeTabs { get { let string = UserDefaults.standard.string(forKey: LastSelected.homeTab) @@ -50,4 +55,25 @@ class AppplicationPreferences { UserDefaults.standard.synchronize() } } + + static var lastSelectedEventFilter: EventSessionFilter { + get { + let string = UserDefaults.standard.string(forKey: LastSelected.eventFilter) + return string.flatMap { EventSessionFilter(title: $0) } ?? EventSessionFilter.all + } + set { + UserDefaults.standard.set(newValue.title, forKey: LastSelected.eventFilter) + UserDefaults.standard.synchronize() + } + } + + static var hideNotificationSchedulerAlerts: Bool { + get { + return UserDefaults.standard.bool(forKey: Prefs.schedulerAlerts) + } + set { + UserDefaults.standard.set(newValue, forKey: Prefs.schedulerAlerts) + UserDefaults.standard.synchronize() + } + } } diff --git a/RaceSync/Tools/EventTracker.swift b/RaceSync/Tools/EventTracker.swift index 6c45744b..8e653fbe 100644 --- a/RaceSync/Tools/EventTracker.swift +++ b/RaceSync/Tools/EventTracker.swift @@ -14,6 +14,10 @@ class EventTracker { static func configure() { configureRater() } + + static var isTestFlight: Bool { + Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" + } // MARK: - AppStore Rater diff --git a/RaceSync/Tools/Notificationscheduler.swift b/RaceSync/Tools/Notificationscheduler.swift new file mode 100644 index 00000000..08cb3587 --- /dev/null +++ b/RaceSync/Tools/Notificationscheduler.swift @@ -0,0 +1,72 @@ +// +// NotificationScheduler.swift +// RaceSync +// +// Created by Ignacio Romero Zurbuchen on 2026-05-23. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import Foundation +import UserNotifications + +/// Generic local notification scheduler. +class NotificationScheduler { + + // MARK: - Singleton + + static let shared = NotificationScheduler() + + // MARK: - Scheduling + + /// Schedules a local notification at the given trigger date. + /// - Parameters: + /// - identifier: Unique string to identify this notification (used to cancel it later). + /// - title: Notification title. + /// - body: Notification body. + /// - triggerDate: The exact date/time to fire the notification. + /// - userInfo: Optional dictionary attached to the notification content. + public func schedule(identifier: String, title: String, body: String, triggerDate: Date, userInfo: [String: Any] = [:] + ) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.userInfo = userInfo + + let components = Calendar.current.dateComponents( + [.year, .month, .day, .hour, .minute, .second], + from: triggerDate + ) + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + + center.add(request) { error in + if let error = error { + print("Failed to schedule '\(identifier)': \(error)") + } else { + print("Scheduled '\(identifier)' for \(triggerDate)") + } + } + } + + // MARK: - Cancellation + + public func cancel(identifier: String) { + center.removePendingNotificationRequests(withIdentifiers: [identifier]) + print("Cancelled '\(identifier)'") + } + + public func cancelAll() { + center.removeAllPendingNotificationRequests() + } + + // MARK: - Inspection + + public func getPending(completion: @escaping ([UNNotificationRequest]) -> Void) { + center.getPendingNotificationRequests { requests in + DispatchQueue.main.async { completion(requests) } + } + } + + private var center = UNUserNotificationCenter.current() +} diff --git a/RaceSync/UI Components/ApproveButton.swift b/RaceSync/UI Components/ApproveButton.swift index 7ec8406a..2b408f65 100644 --- a/RaceSync/UI Components/ApproveButton.swift +++ b/RaceSync/UI Components/ApproveButton.swift @@ -32,10 +32,30 @@ class ApproveButton: CustomButton { updateAnimation() } } - - fileprivate static let minHeight: CGFloat = 32 - fileprivate static let minWidth: CGFloat = 82 - fileprivate static let cornerRadius: CGFloat = 6 + + static let minHeight: CGFloat = { + if #available(iOS 26.0, *) { + return 36 + } else { + return 32 + } + }() + + static let minWidth: CGFloat = { + if #available(iOS 26.0, *) { + return 86 + } else { + return 82 + } + }() + + static let cornerRadius: CGFloat = { + if #available(iOS 26.0, *) { + return minHeight/2 + } else { + return 6 + } + }() // MARK: - Private Variables @@ -82,6 +102,7 @@ class ApproveButton: CustomButton { setContentHuggingPriority(.required, for: .horizontal) setContentCompressionResistancePriority(.required, for: .horizontal) + layer.cornerCurve = .continuous layer.cornerRadius = Self.cornerRadius layer.borderWidth = 0 } @@ -147,6 +168,15 @@ class ApproveButton: CustomButton { return CGSize(width: Self.minWidth, height: Self.minHeight) } } + + override func layoutSubviews() { + super.layoutSubviews() + + if #available(iOS 26.0, *) { + layer.cornerRadius = bounds.height / 2 + layer.cornerCurve = .continuous // matches Apple's "squircle" style + } + } } extension ApproveState { diff --git a/RaceSync/UI Components/DatePickerViewController.swift b/RaceSync/UI Components/DatePickerViewController.swift index 9e644070..94b23a1c 100644 --- a/RaceSync/UI Components/DatePickerViewController.swift +++ b/RaceSync/UI Components/DatePickerViewController.swift @@ -60,7 +60,7 @@ class DatePickerViewController: 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 DatePickerViewController: 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 Components/JoinButton.swift b/RaceSync/UI Components/JoinButton.swift index 8734a7a3..71e7f202 100644 --- a/RaceSync/UI Components/JoinButton.swift +++ b/RaceSync/UI Components/JoinButton.swift @@ -38,10 +38,24 @@ class JoinButton: CustomButton { } } - static let minHeight: CGFloat = 32 - static let minWidth: CGFloat = 76 - static let cornerRadius: CGFloat = 6 - + static let minHeight: CGFloat = { + if #available(iOS 26.0, *) { + return 38 + } else { + return 32 + } + }() + + var minWidth: CGFloat { + get { + if #available(iOS 26.0, *) { + return self.isCompact ? Self.minHeight : 80 + } else { + return 76 + } + } + } + // MARK: - Private Variables fileprivate lazy var spinnerView: UIActivityIndicatorView = { @@ -87,7 +101,7 @@ class JoinButton: CustomButton { setContentHuggingPriority(.required, for: .horizontal) setContentCompressionResistancePriority(.required, for: .horizontal) - layer.cornerRadius = Self.cornerRadius + layer.cornerCurve = .continuous layer.borderWidth = 0 } @@ -117,6 +131,12 @@ class JoinButton: CustomButton { tintColor = state.titleColor imageView?.tintColor = state.titleColor isUserInteractionEnabled = !isCompact + + if #available(iOS 26, *) { + // cornerRadius handled by layoutSubviews + } else { + layer.cornerRadius = 6 + } if let borderColor = state.outlineColor { layer.borderColor = borderColor.cgColor @@ -156,9 +176,17 @@ class JoinButton: CustomButton { // MARK: - Overrides override var intrinsicContentSize: CGSize { - return CGSize(width: Self.minWidth, height: Self.minHeight) + return CGSize(width: self.minWidth, height: Self.minHeight) } - + + override func layoutSubviews() { + super.layoutSubviews() + + if #available(iOS 26, *) { + layer.cornerRadius = bounds.height / 2 + } + } + override var isHighlighted: Bool { get { if !joinState.interactionEnabled { diff --git a/RaceSync/UI Components/MemberBadgeView.swift b/RaceSync/UI Components/MemberBadgeView.swift index 495ee14d..e9ac0449 100644 --- a/RaceSync/UI Components/MemberBadgeView.swift +++ b/RaceSync/UI Components/MemberBadgeView.swift @@ -15,6 +15,24 @@ class MemberBadgeView: CustomButton { setTitle("\(count)", for: .normal) } } + + fileprivate var cornerRadius: CGFloat { + get { + if #available(iOS 26.0, *) { + return Self.minHeight/2 + } else { + return 6 + } + } + } + + fileprivate static let minHeight: CGFloat = { + if #available(iOS 26.0, *) { + return 28 + } else { + return 26 + } + }() // MARK: - Initialization @@ -39,7 +57,7 @@ class MemberBadgeView: CustomButton { contentEdgeInsets = UIEdgeInsets(top: 5, left: 15, bottom: 5, right: 12) backgroundColor = Color.gray50 - layer.cornerRadius = 6 + layer.cornerRadius = cornerRadius } override var isSelected: Bool { diff --git a/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_bold.imageset/icns_toolbar_bold.pdf b/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_bold.imageset/icns_toolbar_bold.pdf index c480e10e..acd613d3 100644 Binary files a/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_bold.imageset/icns_toolbar_bold.pdf and b/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_bold.imageset/icns_toolbar_bold.pdf differ diff --git a/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_clear_format.imageset/icns_toolbar_clear_format.pdf b/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_clear_format.imageset/icns_toolbar_clear_format.pdf index 0c860968..1ade8ac2 100644 Binary files a/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_clear_format.imageset/icns_toolbar_clear_format.pdf and b/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_clear_format.imageset/icns_toolbar_clear_format.pdf differ diff --git a/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_italic.imageset/icns_toolbar_italic.pdf b/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_italic.imageset/icns_toolbar_italic.pdf index 56048ffb..f342dfdd 100644 Binary files a/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_italic.imageset/icns_toolbar_italic.pdf and b/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_italic.imageset/icns_toolbar_italic.pdf differ diff --git a/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_link.imageset/icns_toolbar_link.pdf b/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_link.imageset/icns_toolbar_link.pdf index 35019b27..688d8f70 100644 Binary files a/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_link.imageset/icns_toolbar_link.pdf and b/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_link.imageset/icns_toolbar_link.pdf differ diff --git a/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_strikethrough.imageset/icns_toolbar_strikethrough.pdf b/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_strikethrough.imageset/icns_toolbar_strikethrough.pdf index 9b0e6aff..997b0fdd 100644 Binary files a/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_strikethrough.imageset/icns_toolbar_strikethrough.pdf and b/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_strikethrough.imageset/icns_toolbar_strikethrough.pdf differ diff --git a/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_underline.imageset/icns_toolbar_underline.pdf b/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_underline.imageset/icns_toolbar_underline.pdf index 7d99b9d5..faf03438 100644 Binary files a/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_underline.imageset/icns_toolbar_underline.pdf and b/RaceSync/UI Components/RichEditorView/Assets.xcassets/icns_toolbar_underline.imageset/icns_toolbar_underline.pdf differ diff --git a/RaceSync/UI Components/RichEditorView/RichEditorToolbar.swift b/RaceSync/UI Components/RichEditorView/RichEditorToolbar.swift index c0a87f5a..5b6dfbbb 100644 --- a/RaceSync/UI Components/RichEditorView/RichEditorToolbar.swift +++ b/RaceSync/UI Components/RichEditorView/RichEditorToolbar.swift @@ -36,40 +36,36 @@ import SnapKit /// RichEditorToolbar is UIView that contains the toolbar for actions that can be performed on a RichEditorView class RichEditorToolbar: UIView { - /// The delegate to receive events that cannot be automatically completed weak var delegate: RichEditorToolbarDelegate? - - /// A reference to the RichEditorView that it should be performing actions on weak var editor: RichEditorView? - /// The list of options to be displayed on the toolbar var options: [RichEditorOption] = [] { didSet { updateToolbar() } } fileprivate lazy var scrollView: UIScrollView = { let scrollView = UIScrollView() - scrollView.frame = bounds - scrollView.autoresizingMask = [.flexibleWidth, .flexibleHeight] scrollView.showsHorizontalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false scrollView.backgroundColor = .clear + scrollView.keyboardDismissMode = .none return scrollView }() - fileprivate lazy var toolbar: UIToolbar = { - let toolbar = UIToolbar() - toolbar.autoresizingMask = .flexibleWidth - toolbar.setBackgroundImage(UIImage(), forToolbarPosition: .any, barMetrics: .default) - return toolbar + fileprivate lazy var stackView: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = Constants.padding + stack.alignment = .center + return stack }() fileprivate enum Constants { - static let padding: CGFloat = UniversalConstants.padding - static let buttonWidth: CGFloat = 28 + static let padding: CGFloat = UniversalConstants.padding * 2 + static let buttonSize: CGFloat = 30 } - // MARK: Initialization + // MARK: - Initialization override init(frame: CGRect) { super.init(frame: frame) @@ -78,93 +74,60 @@ class RichEditorToolbar: UIView { required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) + setupLayout() } - // MARK: View - - func setupLayout() { + // MARK: - Layout + + private func setupLayout() { autoresizingMask = .flexibleWidth backgroundColor = Color.navigationBarColor addSeparatorLine() addSubview(scrollView) - scrollView.addSubview(toolbar) + scrollView.addSubview(stackView) + + scrollView.snp.makeConstraints { $0.edges.equalToSuperview() } + stackView.snp.makeConstraints { + $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: Constants.padding, bottom: 0, right: Constants.padding)) + $0.height.equalTo(scrollView) + } updateToolbar() } - + func updateToolbar() { - var buttons = [UIBarButtonItem]() - - for i in 0.. 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..5b047e18 --- /dev/null +++ b/RaceSync/UI Extensions/UIBarButtonItem+Extensions.swift @@ -0,0 +1,41 @@ +// +// 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], target: AnyObject?) -> UIBarButtonItem { + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 12 + stack.alignment = .center + + for i in actions.indices.reversed() { + let action = actions[i] + let button = UIButton(type: .system) + button.tag = action.tag + button.setImage(action.image, for: .normal) + button.addTarget(target, 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..3931e6cd 100644 --- a/RaceSync/UI Utils/Appearance.swift +++ b/RaceSync/UI Utils/Appearance.swift @@ -44,9 +44,7 @@ fileprivate extension Appearance { if let mainWindow = UIApplication.shared.delegate?.window { mainWindow?.backgroundColor = Color.white - if #available(iOS 13.0, *) { - mainWindow?.overrideUserInterfaceStyle = .light - } + mainWindow?.overrideUserInterfaceStyle = .light } } @@ -54,30 +52,35 @@ fileprivate extension Appearance { let foregroundColor = Color.blue let backgroundColor = Color.navigationBarColor let backIndicatorImage = ButtonImg.back - let backgroundImage = UIImage.image(withColor: backgroundColor, imageSize: CGSize(width: 44, height: 44)) - let textAttributes = [NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 18), - NSAttributedString.Key.foregroundColor: Color.black] - - let navigationBarAppearance = UINavigationBarAppearance() - navigationBarAppearance.configureWithTransparentBackground() - navigationBarAppearance.backgroundColor = backgroundColor - navigationBarAppearance.shadowColor = Color.gray100 - navigationBarAppearance.titleTextAttributes = textAttributes - UINavigationBar.appearance().standardAppearance = navigationBarAppearance - UINavigationBar.appearance().compactAppearance = navigationBarAppearance - UINavigationBar.appearance().scrollEdgeAppearance = navigationBarAppearance - - // set the color and font for the title - let barAppearance = UINavigationBar.appearance() - barAppearance.barTintColor = backgroundColor - barAppearance.tintColor = foregroundColor - barAppearance.barStyle = .default - barAppearance.setBackgroundImage(backgroundImage, for: .default) - barAppearance.isOpaque = false - barAppearance.isTranslucent = true - barAppearance.backIndicatorImage = backIndicatorImage?.withRenderingMode(.alwaysTemplate) - barAppearance.backIndicatorTransitionMaskImage = backIndicatorImage - barAppearance.titleTextAttributes = textAttributes + let textAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 18), + .foregroundColor: Color.black + ] + + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = backgroundColor + appearance.shadowColor = Color.gray100 + appearance.titleTextAttributes = textAttributes + appearance.setBackIndicatorImage( + backIndicatorImage?.withRenderingMode(.alwaysTemplate), + transitionMaskImage: backIndicatorImage + ) + + UINavigationBar.appearance().standardAppearance = appearance + UINavigationBar.appearance().compactAppearance = appearance + UINavigationBar.appearance().scrollEdgeAppearance = appearance + UINavigationBar.appearance().tintColor = foregroundColor + + let buttonAppearance = UIBarButtonItemAppearance() + buttonAppearance.normal.titleTextAttributes = [.foregroundColor: foregroundColor] + + let doneButtonAppearance = UIBarButtonItemAppearance() + doneButtonAppearance.normal.titleTextAttributes = [.foregroundColor: foregroundColor] + + appearance.buttonAppearance = buttonAppearance + appearance.doneButtonAppearance = doneButtonAppearance + appearance.backButtonAppearance = buttonAppearance } static func configureTabBarAppearance() { @@ -90,12 +93,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 +116,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/EventHeaderView.swift b/RaceSync/View Controllers/Events/EventHeaderView.swift new file mode 100644 index 00000000..91f54c12 --- /dev/null +++ b/RaceSync/View Controllers/Events/EventHeaderView.swift @@ -0,0 +1,418 @@ +// +// EventHeaderView.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-05-19. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import UIKit +import SnapKit + +protocol EventHeaderViewDelegate: AnyObject { + func headerView(_ headerView: EventHeaderView, didSelectDate date: Date) + func headerView(_ headerView: EventHeaderView, didSelectFilter title: String) +} + +// --------------------------------------------------------------------------- +// MARK: – EventHeaderView +// --------------------------------------------------------------------------- + +class EventHeaderView: UIView { + + // MARK: - Public + + weak var delegate: EventHeaderViewDelegate? + + var selectedDate: Date? { + guard selectedDateIndex < dates.count else { return nil } + return dates[selectedDateIndex] + } + + var selectedFilterTitle: String? { + guard let index = selectedFilterIndex, index < filters.count else { return nil } + return filters[index] + } + + func selectFilter(titled title: String) { + guard let index = filters.firstIndex(of: title) else { return } + selectFilter(at: index, notify: false) + } + + var isEnabled: Bool = true { + didSet { + let allButtons = dateStackView.arrangedSubviews.compactMap { $0 as? UIButton } + + filterStackView.arrangedSubviews.compactMap { $0 as? UIButton } + allButtons.forEach { $0.isEnabled = isEnabled } + + if #available(iOS 26, *) { + if !isEnabled { + // Strip selected state so glass loses its tint + dateButton(at: selectedDateIndex)?.isSelected = false + dateButton(at: selectedDateIndex)?.setNeedsUpdateConfiguration() + if let filterIndex = selectedFilterIndex { + filterButton(at: filterIndex)?.isSelected = false + filterButton(at: filterIndex)?.setNeedsUpdateConfiguration() + } + } else { + // Restore selected appearance + dateButton(at: selectedDateIndex)?.isSelected = true + dateButton(at: selectedDateIndex)?.setNeedsUpdateConfiguration() + if let filterIndex = selectedFilterIndex { + filterButton(at: filterIndex)?.isSelected = true + filterButton(at: filterIndex)?.setNeedsUpdateConfiguration() + } + } + } else { + if !isEnabled { + dateButton(at: selectedDateIndex)?.backgroundColor = Color.gray20 + filterButton(at: selectedFilterIndex ?? -1)?.backgroundColor = Color.gray200 + } else { + select(dateButton(at: selectedDateIndex)) + selectedFilterIndex.map { select(filterButton(at: $0)) } + } + } + } + } + + + // MARK: - Private + + fileprivate let dates: [Date] + fileprivate let filters: [String] + fileprivate let timezone: TimeZone + + fileprivate var selectedDateIndex: Int = 0 + fileprivate var selectedFilterIndex: Int? = nil + + fileprivate lazy var dateScrollView: UIScrollView = { + let sv = UIScrollView() + sv.showsHorizontalScrollIndicator = false + sv.alwaysBounceHorizontal = true + return sv + }() + + fileprivate lazy var dateStackView: UIStackView = { + let sv = UIStackView() + sv.axis = .horizontal + sv.distribution = .fillEqually + sv.spacing = 12 + sv.isLayoutMarginsRelativeArrangement = true + sv.layoutMargins = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) + sv.tintColor = tintColor + return sv + }() + + fileprivate lazy var filterStackView: UIStackView = { + let sv = UIStackView() + sv.axis = .horizontal + sv.spacing = 12 + sv.distribution = .fill + sv.alignment = .center + sv.isLayoutMarginsRelativeArrangement = true + sv.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 4, right: 16) + + // to match the background from dateStackView, caused by the liquid glass effect + if #available(iOS 26, *) { + sv.backgroundColor = UIColor(hex: "f9f9f9") + } + + return sv + }() + + fileprivate enum Constants { + static let dateRowHeight: CGFloat = 60 + static let filterRowHeight: CGFloat = 40 + } + + // MARK: - Init + + init(dates: [Date], timezone: TimeZone, filters: [String]? = nil) { + self.dates = dates + self.filters = filters ?? [] + self.timezone = timezone + super.init(frame: .zero) + + setupLayout() + setupDateButtons() + setupFilterButtons() + + let initialIndex = Self.initialDateIndex(from: dates, timezone: timezone) + selectDate(at: initialIndex, notify: false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Layout + + fileprivate func setupLayout() { + backgroundColor = Color.navigationBarColor + addSeparatorLine(.bottom) + + addSubview(dateScrollView) + dateScrollView.addSubview(dateStackView) + + dateScrollView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.height.equalTo(Constants.dateRowHeight) + } + + dateStackView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.height.equalToSuperview() + $0.width.greaterThanOrEqualTo(self.snp.width) + } + + if !filters.isEmpty { + addSubview(filterStackView) + filterStackView.snp.makeConstraints { + $0.top.equalTo(dateScrollView.snp.bottom) + $0.leading.trailing.bottom.equalToSuperview() + $0.height.equalTo(Constants.filterRowHeight) + } + } + } + + fileprivate func setupDateButtons() { + for (index, date) in dates.enumerated() { + let button = makeDateButton(for: date, index: index) + dateStackView.addArrangedSubview(button) + } + } + + fileprivate func setupFilterButtons() { + guard !filters.isEmpty else { return } + + for (index, title) in filters.enumerated() { + let button = makeFilterButton(title: title, index: index) + filterStackView.addArrangedSubview(button) + } + + // Trailing spacer to keep buttons left-aligned + let spacer = UIView() + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + filterStackView.addArrangedSubview(spacer) + } + + // MARK: - Button Factories + + fileprivate func makeDateButton(for date: Date, index: Int) -> UIButton { + let button = UIButton(type: .system) + button.tag = index + button.titleLabel?.numberOfLines = 2 + button.titleLabel?.textAlignment = .center + button.addTarget(self, action: #selector(didTapDateButton(_:)), for: .touchUpInside) + + if #available(iOS 26, *) { + var config = UIButton.Configuration.glass() + config.attributedTitle = AttributedString(attributedTitle(for: date)) + button.configuration = config + } else { + button.setAttributedTitle(attributedTitle(for: date), for: .normal) + button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 8, bottom: 6, right: 8) + button.backgroundColor = Color.gray20 + button.layer.cornerRadius = 8 + button.layer.cornerCurve = .continuous + button.layer.borderWidth = 1 + button.layer.borderColor = Color.gray50.cgColor + } + + return button + } + + fileprivate func makeFilterButton(title: String, index: Int) -> UIButton { + let button = UIButton(type: .system) + button.tag = index + button.setContentHuggingPriority(.defaultHigh, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + button.addTarget(self, action: #selector(didTapFilterButton(_:)), for: .touchUpInside) + + if #available(iOS 26, *) { + var config = UIButton.Configuration.glass() + config.title = title + config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12) + config.background.backgroundColor = Color.gray100.withAlphaComponent(0.5) // default unselected background + button.configuration = config + + button.configurationUpdateHandler = { [weak self, title] button in + guard let self else { return } + var updated = button.configuration + let active = button.isSelected && button.isEnabled + let disabled = !button.isEnabled + + updated?.background.backgroundColor = active + ? tintColor.withAlphaComponent(0.2) + : Color.gray100.withAlphaComponent(0.5) + + updated?.attributedTitle = AttributedString(NSAttributedString( + string: title, // captured directly, never nil + attributes: [ + .font: UIFont.systemFont(ofSize: 12, weight: .semibold), + .foregroundColor: disabled ? Color.gray100 : (active ? tintColor as UIColor : Color.black) + ] + )) + + button.configuration = updated + } + } else { + button.setTitle(title, for: .normal) + button.setTitleColor(Color.black, for: .normal) + button.titleLabel?.font = UIFont.systemFont(ofSize: 12, weight: .semibold) + button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) + button.layer.cornerRadius = 8 + button.layer.cornerCurve = .continuous + button.backgroundColor = Color.gray100.withAlphaComponent(0.5) // match iOS 26 unselected + } + + return button + } + + // MARK: - Selection + + fileprivate func selectDate(at index: Int, notify: Bool = true) { + guard index < dates.count else { return } + + // Deselect previous + deselect(dateButton(at: selectedDateIndex)) + + selectedDateIndex = index + select(dateButton(at: index)) + + if notify, let date = selectedDate { + delegate?.headerView(self, didSelectDate: date) + } + } + + fileprivate func selectFilter(at index: Int, notify: Bool = true) { + // Deselect previous + if let prev = selectedFilterIndex { + deselect(filterButton(at: prev)) + } + + selectedFilterIndex = index + select(filterButton(at: index)) + + if notify, let title = selectedFilterTitle { + delegate?.headerView(self, didSelectFilter: title) + } + } + + fileprivate func select(_ button: UIButton?) { + guard let button else { return } + + if #available(iOS 26, *) { + button.isSelected = true + button.setNeedsUpdateConfiguration() + } else { + if dateStackView.arrangedSubviews.contains(button) { + button.isSelected = true + button.setTitleColor(Color.white, for: .normal) + button.backgroundColor = tintColor + button.layer.borderColor = tintColor.cgColor + } else { + button.setTitleColor(tintColor, for: .normal) // tint text only + button.backgroundColor = tintColor.withAlphaComponent(0.2) + button.layer.borderColor = tintColor.withAlphaComponent(0.3).cgColor + } + } + } + + fileprivate func deselect(_ button: UIButton?) { + guard let button else { return } + + if #available(iOS 26, *) { + button.isSelected = false + button.setNeedsUpdateConfiguration() + } else { + if dateStackView.arrangedSubviews.contains(button) { + button.isSelected = false + button.setTitleColor(Color.black, for: .normal) + button.backgroundColor = Color.gray20 + button.layer.borderColor = Color.gray50.cgColor + // Re-apply attributed title to reset color + if let index = dateStackView.arrangedSubviews.firstIndex(of: button), index < dates.count { + button.setAttributedTitle(attributedTitle(for: dates[index]), for: .normal) + } + } else { + button.setTitleColor(Color.black, for: .normal) + button.backgroundColor = Color.gray100.withAlphaComponent(0.75) + } + } + } + + private static func initialDateIndex(from dates: [Date], timezone: TimeZone) -> Int { + guard let initialDate = dates.initialDate(timezone: timezone), + let index = dates.firstIndex(of: initialDate) else { return 0 } + return index + } + + // MARK: - Button Accessors + + fileprivate func dateButton(at index: Int) -> UIButton? { + dateStackView.arrangedSubviews[safe: index] as? UIButton + } + + fileprivate func filterButton(at index: Int) -> UIButton? { + filterStackView.arrangedSubviews[safe: index] as? UIButton + } + + // MARK: - Actions + + @objc fileprivate func didTapDateButton(_ sender: UIButton) { + guard sender.tag != selectedDateIndex else { return } + selectDate(at: sender.tag) + } + + @objc fileprivate func didTapFilterButton(_ sender: UIButton) { + guard sender.tag != selectedFilterIndex else { return } + selectFilter(at: sender.tag) + } + + // MARK: - Attributed Title + + fileprivate func attributedTitle(for date: Date) -> NSAttributedString { + let f = DateFormatter() + f.timeZone = timezone + + f.dateFormat = "EEE" + let dayName = f.string(from: date) + + f.dateFormat = "MMM d" + let dayDate = f.string(from: date) + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + + let result = NSMutableAttributedString() + result.append(NSAttributedString(string: dayName + "\n", attributes: [ + .font: UIFont.systemFont(ofSize: 12, weight: .semibold), + .paragraphStyle: paragraph + ])) + result.append(NSAttributedString(string: dayDate, attributes: [ + .font: UIFont.systemFont(ofSize: 11, weight: .regular), + .paragraphStyle: paragraph + ])) + return result + } +} + +// --------------------------------------------------------------------------- +// MARK: – Array safe subscript +// --------------------------------------------------------------------------- + +fileprivate extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} + +public extension Array where Element == Date { + func initialDate(timezone: TimeZone) -> Date? { + var calendar = Calendar.current + calendar.timeZone = timezone + let today = Date() + return first(where: { calendar.isDate($0, inSameDayAs: today) }) ?? first + } +} diff --git a/RaceSync/View Controllers/Events/EventSessionBucketlist.swift b/RaceSync/View Controllers/Events/EventSessionBucketlist.swift new file mode 100644 index 00000000..90e4aa86 --- /dev/null +++ b/RaceSync/View Controllers/Events/EventSessionBucketlist.swift @@ -0,0 +1,125 @@ +// +// EventSessionBucketlist.swift +// RaceSync +// +// Created by Ignacio Romero Zurbuchen on 2026-05-23. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import Foundation +import RaceSyncAPI +import ObjectMapper + +/// Persists favorited EventSession objects per day, scoped to a specific event. +class EventSessionBucketlist { + + // MARK: - Init + + init(eventName: String, timezone: TimeZone) { + let filename = FileStore.sanitizedFileName(from: eventName) + self.fileURL = FileStore.url(for: filename) + self.timezone = timezone + load() + } + + // MARK: - Public API + + /// Saves a single session for the given day. Skips duplicates by id. + func save(_ session: EventSession, for day: Date) { + let key = dayKey(for: day) + var bucket = buckets[key] ?? [] + guard !bucket.contains(where: { $0.id == session.id }) else { return } + bucket.append(session) + buckets[key] = bucket + persist() + } + + /// Saves an array of sessions for the given day. Skips duplicates by id. + func save(_ sessions: [EventSession], for day: Date) { + let key = dayKey(for: day) + var bucket = buckets[key] ?? [] + for session in sessions { + guard !bucket.contains(where: { $0.id == session.id }) else { continue } + bucket.append(session) + } + buckets[key] = bucket + persist() + } + + /// Returns all saved sessions for the given day + func load(for day: Date) -> [EventSession] { + return buckets[dayKey(for: day)] ?? [] + } + + /// Deletes a single session from the given day's bucket. + func delete(_ session: EventSession, for day: Date) { + let key = dayKey(for: day) + buckets[key]?.removeAll { $0.id == session.id } + if buckets[key]?.isEmpty == true { buckets.removeValue(forKey: key) } + persist() + } + + /// Deletes all sessions across all days. + func deleteAll() { + buckets.removeAll() + persist() + } + + /// Returns the number of saved sessions for the given day. + func count(for day: Date) -> Int { + return buckets[dayKey(for: day)]?.count ?? 0 + } + + // MARK: - Private + + fileprivate var buckets: [String: [EventSession]] = [:] + fileprivate let fileURL: URL + fileprivate let timezone: TimeZone + + fileprivate lazy var dayFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "dd-MM-yyyy" + f.timeZone = timezone + return f + }() + + fileprivate func dayKey(for date: Date) -> String { + return dayFormatter.string(from: date) + } + + fileprivate func persist() { + // Serialize: [String: [[String: Any]]] using ObjectMapper + var raw: [String: [[String: Any]]] = [:] + for (key, sessions) in buckets { + raw[key] = sessions.map { $0.toJSON() } + } + + guard let data = try? JSONSerialization.data(withJSONObject: raw, options: .prettyPrinted) else { + print("[EventSessionBucketlist] Serialization failed") + return + } + do { + try data.write(to: fileURL) + } catch { + print("[EventSessionBucketlist] Write failed: \(error)") + } + } + + fileprivate func load() { + guard let data = try? Data(contentsOf: fileURL), + let raw = try? JSONSerialization.jsonObject(with: data) as? [String: [[String: Any]]] else { + return + } + for (key, jsonArray) in raw { + buckets[key] = jsonArray.compactMap { EventSession(JSON: $0) } + } + } +} + +extension EventSessionBucketlist { + + func contains(_ session: EventSession, for day: Date?) -> Bool { + guard let day = day else { return false } + return buckets[dayKey(for: day)]?.contains(where: { $0.id == session.id }) ?? false + } +} diff --git a/RaceSync/View Controllers/Events/EventSessionTableViewCell.swift b/RaceSync/View Controllers/Events/EventSessionTableViewCell.swift new file mode 100644 index 00000000..12e18501 --- /dev/null +++ b/RaceSync/View Controllers/Events/EventSessionTableViewCell.swift @@ -0,0 +1,152 @@ +// +// EventSessionTableViewCell.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-05-19. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import UIKit + +class EventSessionTableViewCell: UITableViewCell { + + var isFavorite: Bool = false { + didSet { + starImageView.tintColor = isFavorite ? Color.yellow : Color.gray100 + starImageView.image = isFavorite ? SystemImg.starFill : SystemImg.star + } + } + + static let cellHeight: CGFloat = 90 + + // MARK: - Public Variables + + lazy var titleLabel: UppercasedLabel = { + let label = UppercasedLabel() + label.font = UIFont.systemFont(ofSize: 16, weight: .semibold) + label.textColor = Color.gray500 + label.numberOfLines = 2 + return label + }() + + lazy var subtitleLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 15, weight: .semibold) + label.textColor = Color.gray300 + label.numberOfLines = 1 + 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 + + fileprivate lazy var starImageView: UIImageView = { + let view = UIImageView(frame: CGRect(x: 0, y: 0, width: 26, height: 24)) + view.backgroundColor = Color.clear + return view + }() + + 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.yellow + 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) + } + + starImageView.frame.size = .init(width: 26, height: 24) + accessoryView = starImageView + } + + override func prepareForReuse() { + super.prepareForReuse() + + titleLabel.text = nil + subtitleLabel.text = nil + startTimeLabel.text = nil + endTimeLabel.text = nil + } +} + +class UppercasedLabel: UILabel { + override var text: String? { + get { super.text } + set { super.text = newValue?.uppercased() } + } +} diff --git a/RaceSync/View Controllers/Events/EventStore.swift b/RaceSync/View Controllers/Events/EventStore.swift new file mode 100644 index 00000000..bbdb34f1 --- /dev/null +++ b/RaceSync/View Controllers/Events/EventStore.swift @@ -0,0 +1,39 @@ +// +// EventStore.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-05-24. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import Foundation +import RaceSyncAPI +import ObjectMapper + +class EventStore { + + static let shared = EventStore() + + func save(_ event: Event) { + guard let json = Mapper().toJSONString(event, prettyPrint: true), + let data = json.data(using: .utf8) else { + print("[EventStore] Serialization failed") + return + } + do { + try data.write(to: fileURL) + } catch { + print("[EventStore] Write failed: \(error)") + } + } + + func load() -> Event? { + guard let data = try? Data(contentsOf: fileURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return Mapper().map(JSONObject: json) + } + + private let fileURL = FileStore.url(for: "io26_event.json") +} diff --git a/RaceSync/View Controllers/Events/EventsController.swift b/RaceSync/View Controllers/Events/EventsController.swift new file mode 100644 index 00000000..c1879f00 --- /dev/null +++ b/RaceSync/View Controllers/Events/EventsController.swift @@ -0,0 +1,139 @@ +// +// EventsController.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-05-18. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import UIKit +import RaceSyncAPI + +enum EventSessionFilter: EnumTitle, Hashable { + case all, mySchedule, spec, openFly + + public var title: String { + switch self { + case .all: return "All" + case .mySchedule: return "My Schedule" + case .spec: return "Spec" + case .openFly: return "Open Fly" + } + } +} + +class EventsController { + + // MARK: - Public Variables + + let eventApi = EventApi() + var io26Event: Event? + var ios26Dates: [Date] = EventSession.io26Dates(from: "2026-06-10", to: "2026-06-14") + var bucketlist = EventSessionBucketlist(eventName: "mgp_io26", timezone: MGPEventTimeZone!) + + var selectedDate: Date? + var selectedFilter: EventSessionFilter = AppplicationPreferences.lastSelectedEventFilter + + // MARK: - Public Functions + + func didFetchEvents() -> Bool { + io26Event != nil + } + + func fetchIO26Event(_ completion: @escaping ObjectCompletionBlock) { + eventApi.getIO26Event { [weak self] event, error in + if let event { + self?.io26Event = event + completion(event, nil) + } else { + completion(nil, error) + } + } + } + + func reloadSessions() -> [EventSession] { + if selectedDate == nil { + selectedDate = ios26Dates.initialDate(timezone: MGPEventTimeZone!) + } + + guard let date = selectedDate else { return [] } + return mergedSessions(for: date, with: .scheduled, filter: selectedFilter) + } + + func track(for session: EventSession) -> EventTrack? { + io26Event?.tracks?.first { $0.id == session.trackId } + } + + func color(for track: EventTrack?) -> UIColor { + guard let id = track?.id else { return Color.gray300 } + return trackColors[id] ?? Color.gray300 + } + + // MARK: - Private + + private let trackColors: [String: UIColor] = [ + "main_stage": UIColor(hex: "4a6cf7"), + "world_cup_1": UIColor(hex: "e8384f"), + "all_skills": UIColor(hex: "ca8a04"), + "whoopville": UIColor(hex: "9b59b6"), + "world_cup_2": UIColor(hex: "f06070"), + "spec": UIColor(hex: "22c55e"), + "gq_rookie": UIColor(hex: "06b6d4"), + "tiny_trainier": UIColor(hex: "2dd4bf") + ] + + func sessions(for date: Date, with status: EventStatus? = nil, id trackId: ObjectId? = nil) -> [EventSession] { + 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 + } + } + + func mergedSessions(for date: Date, with status: EventStatus? = nil, id trackId: ObjectId? = nil, filter: EventSessionFilter = .all) -> [EventSession] { + let sessions = sessions(for: date, with: status, id: trackId) + .sorted { ($0.startTime ?? .distantPast) < ($1.startTime ?? .distantPast) } + + var merged: [EventSession] = [] + + for session in sessions { + let match = merged.last(where: { + $0.activity == session.activity && + $0.trackId == session.trackId && + isConsecutive($0, session) + }) + + if let match { + match.startTime = min(match.startTime ?? .distantFuture, session.startTime ?? .distantFuture) + match.endTime = session.endTime + } else { + merged.append(session.copy()) + } + } + + return apply(filter: filter, to: merged, for: date) + } + + private func apply(filter: EventSessionFilter, to sessions: [EventSession], for date: Date) -> [EventSession] { + switch filter { + case .all: + return sessions + case .mySchedule: + return sessions.filter { bucketlist.contains($0, for: date) } + case .spec: + return sessions.filter { $0.activity.containsAny(["spec", "AER"], caseInsensitive: true) } + case .openFly: + return sessions.filter { $0.activity.containsAny(["open fly", "openfly"], caseInsensitive: true) } + } + } + + private func isConsecutive(_ a: EventSession, _ b: EventSession) -> Bool { + guard let endA = a.endTime, let startB = b.startTime else { return false } + 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..3fcebd3d --- /dev/null +++ b/RaceSync/View Controllers/Events/EventsViewController.swift @@ -0,0 +1,314 @@ +// +// 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 +import EmptyDataSet_Swift + +class EventsViewController: UIViewController, Shimmable { + + // MARK: - Public Variables + + lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.dataSource = self + tableView.delegate = self + tableView.emptyDataSetSource = self + tableView.emptyDataSetDelegate = self + tableView.tableFooterView = UIView() + tableView.backgroundView = UIView() + tableView.backgroundView?.backgroundColor = Color.clear + tableView.backgroundColor = Color.gray50 + tableView.contentInsetAdjustmentBehavior = .always + tableView.register(cellType: EventSessionTableViewCell.self) + tableView.refreshControl = refreshControl + return tableView + }() + + var shimmeringView: ShimmeringView = defaultShimmeringView() + + // MARK: - Private Variables + + fileprivate lazy var refreshControl: UIRefreshControl = { + let rc = UIRefreshControl() + rc.backgroundColor = Color.gray50 + rc.tintColor = Color.blue + rc.addTarget(self, action: #selector(didPullRefreshControl), for: .valueChanged) + return rc + }() + + fileprivate lazy var headerView: EventHeaderView = { + let view = EventHeaderView( + dates: eventsController.ios26Dates, + timezone: MGPEventTimeZone!, + filters: EventSessionFilter.allCases.map { $0.title } + ) + view.delegate = self + view.selectFilter(titled: eventsController.selectedFilter.title) + view.isEnabled = false + return view + }() + + fileprivate let eventsController = EventsController() + fileprivate var selectedSessions = [EventSession]() + + fileprivate lazy var timeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "h:mm a" + f.timeZone = MGPEventTimeZone + return f + }() + + fileprivate let emptyStateNoEvents = EmptyStateViewModel(.noEvents) + fileprivate var emptyStateError: EmptyStateViewModel? + + fileprivate enum Constants { + static let padding: CGFloat = UniversalConstants.padding + static let headerViewHeight: CGFloat = 100 + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + 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() + } + } + + // MARK: - Layout + + fileprivate func setupLayout() { + configureNavigationItems() + + view.addSubview(headerView) + headerView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) + $0.leading.trailing.equalToSuperview() + $0.height.equalTo(Constants.headerViewHeight) + } + + view.addSubview(tableView) + tableView.snp.makeConstraints { + $0.top.equalTo(headerView.snp.bottom) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview() + } + + view.addSubview(shimmeringView) + shimmeringView.snp.makeConstraints { + $0.top.equalTo(tableView.snp.top) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview() + } + } + + fileprivate func configureNavigationItems() { + title = "IO26" + tabBarItem = UITabBarItem(title: title, image: SystemImg.globe, selectedImage: SystemImg.globeFill) + tabBarItem.isEnabled = true + } + + // MARK: - Data + + fileprivate func loadContent() { + if !refreshControl.isRefreshing { + isLoadingList(true) + } + + eventsController.fetchIO26Event { [weak self] event, error in + guard let self else { return } + + if let error { + handleError(error) + } else { + selectedSessions = eventsController.reloadSessions() + headerView.isEnabled = (event != nil) + } + + if refreshControl.isRefreshing { + refreshControl.endRefreshing() + tableView.reloadData() + } else { + isLoadingList(false) + } + } + } + + // MARK: - Actions + + @objc fileprivate func didPullRefreshControl() { + loadContent() + } + + // MARK: - Error Handling + + fileprivate func handleError(_ error: NSError) { + + emptyStateError = EmptyStateViewModel(.error(error)) + tableView.reloadEmptyDataSet() + } +} + +extension EventsViewController: EventHeaderViewDelegate { + + func headerView(_ headerView: EventHeaderView, didSelectDate date: Date) { + eventsController.selectedDate = date + selectedSessions = eventsController.reloadSessions() + tableView.reloadData() + } + + func headerView(_ headerView: EventHeaderView, didSelectFilter title: String) { + eventsController.selectedFilter = EventSessionFilter(title: title) ?? .all + selectedSessions = eventsController.reloadSessions() + tableView.reloadData() + + AppplicationPreferences.lastSelectedEventFilter = eventsController.selectedFilter + } +} + +extension EventsViewController: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + eventsController.io26Event != nil ? 1 : 0 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + selectedSessions.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) { + guard let cell = view as? EventSessionTableViewCell, + indexPath.row < selectedSessions.count else { return } + + let session = selectedSessions[indexPath.row] + let track = eventsController.track(for: session) + + cell.backgroundColor = Color.white + 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 + cell.startTimeLabel.text = session.startTime.map { timeFormatter.string(from: $0) } + cell.endTimeLabel.text = session.endTime.map { timeFormatter.string(from: $0) } + cell.isFavorite = eventsController.bucketlist.contains(session, for: eventsController.selectedDate) + } +} + +extension EventsViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard indexPath.row < selectedSessions.count, let date = eventsController.selectedDate else { return } + + let session = selectedSessions[indexPath.row] + let bucketlist = eventsController.bucketlist + + if bucketlist.contains(session, for: date) { + bucketlist.delete(session, for: date) + NotificationScheduler.shared.cancel(for: session) + } else { + bucketlist.save(session, for: date) + NotificationScheduler.shared.schedule(for: session) + } + + guard let cell = tableView.cellForRow(at: indexPath) else { return } + + selectedSessions = eventsController.reloadSessions() + + UIView.animate(withDuration: 0.6) { + cell.selectedBackgroundView?.alpha = 0 + } completion: { _ in + tableView.deselectRow(at: indexPath, animated: false) + cell.selectedBackgroundView?.alpha = 1 + + tableView.beginUpdates() + if self.eventsController.selectedFilter == .mySchedule { + tableView.deleteRows(at: [indexPath], with: .automatic) // must delete the row in this unique case + } else { + tableView.reloadRows(at: [indexPath], with: .automatic) + } + tableView.endUpdates() + } + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + EventSessionTableViewCell.cellHeight + } +} + +extension EventsViewController: EmptyDataSetSource { + + func title(forEmptyDataSet scrollView: UIScrollView) -> NSAttributedString? { + + if emptyStateError != nil { + return emptyStateError?.title + } else { + return emptyStateNoEvents.title + } + } + + func description(forEmptyDataSet scrollView: UIScrollView) -> NSAttributedString? { + + if let error = emptyStateError { return error.description } + let filter = eventsController.selectedFilter + + if filter == .mySchedule { return emptyStateNoEvents.description } + + let suffix = filter == .all ? "" : " for \(filter.title)" + return EmptyStateViewModel.attributtedStringForDescription( + "There are no events scheduled this day\(suffix)." + ) + } + + func image(forEmptyDataSet scrollView: UIScrollView) -> UIImage? { + return nil + } + + func verticalOffset(forEmptyDataSet scrollView: UIScrollView) -> CGFloat { + return -Constants.headerViewHeight + } + + func backgroundColor(forEmptyDataSet scrollView: UIScrollView) -> UIColor? { + return Color.white + } +} + +extension EventsViewController: EmptyDataSetDelegate { + + func emptyDataSetShouldAllowScroll(_ scrollView: UIScrollView) -> Bool { + return false + } +} diff --git a/RaceSync/View Controllers/Events/NotificationScheduler+EventSession.swift b/RaceSync/View Controllers/Events/NotificationScheduler+EventSession.swift new file mode 100644 index 00000000..62e6de11 --- /dev/null +++ b/RaceSync/View Controllers/Events/NotificationScheduler+EventSession.swift @@ -0,0 +1,121 @@ +// +// NotificationScheduler+EventSession.swift +// RaceSync +// +// Created by Ignacio Romero Zurbubach on 2026-05-23. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import Foundation +import RaceSyncAPI +import ObjectMapper + +// MARK: - EventSession Notification Keys + +enum SessionNotificationKey { + static let sessionJSON = "sessionJSON" + static let categoryIdentifier = "EventSession" +} + +// MARK: - NotificationScheduler + EventSession + +extension NotificationScheduler { + + /// Schedules a local notification 1 hour before the session's startTime. + /// Silently skips if startTime is missing or already in the past. + func schedule(for session: EventSession, for hours: Double = 1) { + let center = UNUserNotificationCenter.current() + + center.getNotificationSettings { [weak self] settings in + switch settings.authorizationStatus { + case .authorized, .provisional: + self?.scheduleNotification(for: session, for: hours) + + case .notDetermined: + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + if let error = error { + print("Auth error: \(error)") + return + } + if granted { + self?.scheduleNotification(for: session, for: hours) + } + } + + case .denied, .ephemeral: + print("Notifications not authorized, skipping session \(session.id).") + + @unknown default: + break + } + } + } + + private func scheduleNotification(for session: EventSession, for hours: Double = 1) { + + guard let startTime = session.startTime else { + print("Session \(session.id) has no startTime, skipping.") + return + } + + let triggerDate = startTime.addingTimeInterval(-3600*hours) // 1 or more hours before + + guard triggerDate > Date() else { + print("Session \(session.id) trigger date is in the past, skipping.") + return + } + + let title = "🔔 IO16: Your next event is up!" + let body = sessionNotificationBody(for: session) + + // Pack the full session as JSON into userInfo for use when the notification is tapped + let userInfo: [String: Any] = [ + SessionNotificationKey.sessionJSON: session.toJSONString() ?? "", + SessionNotificationKey.categoryIdentifier: true + ] + + schedule(identifier: session.id, title: title, body: body, triggerDate: triggerDate, userInfo: userInfo) + + if !AppplicationPreferences.hideNotificationSchedulerAlerts { + AlertUtil.presentAlertMessage("We will notify you one (1) hour before '\(session.activity)' starts on \(session.dayName).", + title: "Added to your bucket list", + okTitle: "Don't Show Again", cancelTitle: "OK") { action in + AppplicationPreferences.hideNotificationSchedulerAlerts = true + } + } + } + + /// Cancels the scheduled notification for the given session. + func cancel(for session: EventSession) { + cancel(identifier: session.id) + + if !AppplicationPreferences.hideNotificationSchedulerAlerts { + AlertUtil.presentAlertMessage("", + title: "Removed from bucket list", + okTitle: "Don't Show Again", cancelTitle: "OK") { action in + AppplicationPreferences.hideNotificationSchedulerAlerts = true + } + } + } + + /// Reconstructs an EventSession from a notification's userInfo, if present. + func session(from userInfo: [AnyHashable: Any]) -> EventSession? { + guard let jsonString = userInfo[SessionNotificationKey.sessionJSON] as? String else { return nil } + return EventSession(JSONString: jsonString) + } + + // MARK: - Private + + private func sessionNotificationBody(for session: EventSession) -> String { + + if let start = session.startTime { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + formatter.timeZone = MGPEventTimeZone + + return "'\(session.activity)' starts at \(formatter.string(from: start))." + } else { + return "'\(session.activity)' starts in 1 hour." // in track.name + } + } +} 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..df35a783 100644 --- a/RaceSync/View Controllers/HomeTabBarController.swift +++ b/RaceSync/View Controllers/HomeTabBarController.swift @@ -11,8 +11,7 @@ import SnapKit import RaceSyncAPI enum HomeTabs: Int { - case races, series, standings - + case races, series, standings, events static let `default`: Self = .series } @@ -30,7 +29,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 +37,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 +52,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 +59,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 +76,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 +85,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 +102,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 +149,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, target: self) + } + let rightStackView = UIStackView(arrangedSubviews: [chapterProfileButton, userProfileButton]) rightStackView.axis = .horizontal rightStackView.distribution = .fillEqually @@ -214,7 +216,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 ApplicationControl.shared.isIOWindowEnable { vcs += [eventsVC] } + let tab = AppPrefs.lastSelectedHomeTab configureTabBarController(with: vcs, selectedIndex: tab.rawValue) @@ -271,17 +278,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 +297,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 +329,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..fbea606b 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, target: self) + } 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..d2548874 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, target: self) + } 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/PushMessagesStore.swift b/RaceSync/View Controllers/Push Messages/PushMessagesStore.swift index 363e4bd6..dc6a6ae8 100644 --- a/RaceSync/View Controllers/Push Messages/PushMessagesStore.swift +++ b/RaceSync/View Controllers/Push Messages/PushMessagesStore.swift @@ -7,6 +7,7 @@ // import Foundation +import RaceSyncAPI class PushMessagesStore { @@ -89,9 +90,8 @@ class PushMessagesStore { fileprivate var messages: [PushMessage] = [] fileprivate let syncQueue = DispatchQueue(label: "PushMessagesStore.syncQueue") - private var fileURL: URL { - let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - return docs.appendingPathComponent("pushMessages.json") + private var fileURL: URL { + return FileStore.url(for: "pushMessages.json") } fileprivate func saveMessages() { diff --git a/RaceSync/View Controllers/Push Messages/PushMessagesViewController.swift b/RaceSync/View Controllers/Push Messages/PushMessagesViewController.swift index 197b5383..b90530d1 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() { @@ -219,9 +220,6 @@ class PushMessagesViewController: UIViewController { fileprivate func populateDummySource() { // messages += [ // PushMessage(title: "📣 Round 28 is up next", detail: "Get ready to race on round 28. Your channel is R1 LHCP.", timestamp: 1747793038), -// PushMessage(title: "📣 Round 17 is up next", detail: "Get ready to race on round 17. Your channel is R5 LHCP.", timestamp: 1747790038), -// PushMessage(title: "📣 Round 7 is up next", detail: "Get ready to race on round 7. Your channel is R2 RHCP.", timestamp: 1747779532), -// PushMessage(title: "📣 Round 2 is up next", detail: "Get ready to race on round 2. Your channel is R1 LHCP.", timestamp: 1747778078), // PushMessage(title: "📌 NERDs published a new race!", detail: "Save the date! July 22nd NERDs will host '2025 MultiGP Summer Global Qualifier'.", timestamp: 1747774078), // PushMessage(title: "💸 Payment received!", detail: "HeadsupFPV paid $23.00 USD for '2025 MultiGP Spring GQ - Last Chance'. 6 pilots have paid so far.", timestamp: 1747772048), // PushMessage(title: "✅ HeadsupFPV joing your race", detail: "HeadsupFPV joined '2025 MultiGP Spring GQ - Last Chance'. 12 pilots have joined so far!", timestamp: 1747773038) 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..692a441c 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, target: self)] } - - 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..8af1bd69 100644 --- a/RaceSync/View Controllers/Races/RaceDetailViewController.swift +++ b/RaceSync/View Controllers/Races/RaceDetailViewController.swift @@ -107,6 +107,8 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .regular) button.titleLabel?.numberOfLines = 2 button.tintColor = Color.black + button.contentHorizontalAlignment = .left + button.contentEdgeInsets = .zero return button } @@ -132,8 +134,13 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { fileprivate lazy var locationButton: PasteboardButton = { let button = contextualButton() + button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .regular) + button.titleLabel?.numberOfLines = 3 button.addTarget(self, action: #selector(didPressLocationButton), for: .touchUpInside) button.tintColor = Color.link + button.contentHorizontalAlignment = .left + button.contentEdgeInsets = .zero + button.titleEdgeInsets = .zero return button }() @@ -183,10 +190,14 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { let topStackView = UIStackView(arrangedSubviews: [miniJoinButton, joinButton]) topStackView.axis = .horizontal topStackView.distribution = .equalSpacing - topStackView.spacing = Constants.padding/4 + topStackView.spacing = Constants.padding/2 miniJoinButton.snp.makeConstraints { - $0.width.height.equalTo(Constants.minButtonHeight) + $0.width.height.equalTo(JoinButton.minHeight) + } + + joinButton.snp.makeConstraints { + $0.height.greaterThanOrEqualTo(JoinButton.minHeight) } var subviews: [UIView] = [topStackView, feeLabel, memberBadgeView] @@ -194,7 +205,8 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { stackView.axis = .vertical stackView.alignment = .trailing stackView.distribution = .equalSpacing - stackView.spacing = Constants.padding/2 + stackView.spacing = Constants.padding * 3/4 + stackView.backgroundColor = Color.clear return stackView }() @@ -211,6 +223,7 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { stackView2.alignment = .center stackView2.distribution = .fill stackView2.spacing = Constants.padding * 3/4 + stackView2.backgroundColor = Color.clear if canDisplayAddress { let stackView3 = UIStackView(arrangedSubviews: [locationIconView, locationButton]) @@ -225,7 +238,7 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { stackView4.alignment = .leading stackView4.distribution = .equalSpacing stackView4.spacing = Constants.padding / 2 - + stackView4.backgroundColor = Color.clear return stackView4 } else { return stackView2 @@ -269,6 +282,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 @@ -279,7 +293,6 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { static let mapHeight: CGFloat = UIScreen.main.bounds.height/3 // 1/3 of the screen static let cellHeight: CGFloat = 50 static let maxButtonSize: CGFloat = 100 - static let minButtonHeight: CGFloat = 32 static let buttonSpacing: CGFloat = 12 static let htmlpadding: CGFloat = 12 } @@ -378,15 +391,14 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { headerView.addSubview(rightStackView) rightStackView.snp.makeConstraints { $0.top.equalTo(titleLabel.snp.bottom).offset(Constants.padding) - $0.width.greaterThanOrEqualTo(Constants.maxButtonSize) - $0.trailing.equalToSuperview().offset(-Constants.padding) + $0.trailing.equalToSuperview().inset(Constants.padding) } headerView.addSubview(leftStackView) leftStackView.snp.makeConstraints { $0.top.equalTo(rightStackView.snp.top) $0.leading.equalToSuperview().offset(Constants.padding) - $0.trailing.equalTo(rightStackView.snp.leading).offset(-Constants.padding/2) + $0.trailing.lessThanOrEqualTo(rightStackView.snp.leading).offset(-Constants.padding/2) } headerView.snp.makeConstraints { @@ -415,15 +427,13 @@ class RaceDetailViewController: UIViewController, ViewJoinable, RaceTabbable { scrollView.addSubview(contentView) contentView.snp.makeConstraints { - $0.top.leading.trailing.bottom.equalToSuperview() + $0.edges.equalToSuperview() } view.addSubview(scrollView) scrollView.snp.makeConstraints { + $0.edges.equalToSuperview() $0.width.equalTo(UIScreen.main.bounds.width) - $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) - $0.leading.trailing.equalToSuperview() - $0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom) } } @@ -431,13 +441,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 +625,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 +643,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 +793,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 +834,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 +904,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/RaceListViewController.swift b/RaceSync/View Controllers/Races/RaceListViewController.swift index f5e33878..517655d8 100644 --- a/RaceSync/View Controllers/Races/RaceListViewController.swift +++ b/RaceSync/View Controllers/Races/RaceListViewController.swift @@ -119,10 +119,7 @@ class RaceListViewController: UIViewController, ViewJoinable { view.addSubview(tableView) tableView.snp.makeConstraints { - $0.width.equalTo(UIScreen.main.bounds.width) - $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) - $0.leading.trailing.equalToSuperview() - $0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom) + $0.edges.equalToSuperview() } } diff --git a/RaceSync/View Controllers/Races/RacePaymentsViewController.swift b/RaceSync/View Controllers/Races/RacePaymentsViewController.swift index 8eef8683..11105d85 100644 --- a/RaceSync/View Controllers/Races/RacePaymentsViewController.swift +++ b/RaceSync/View Controllers/Races/RacePaymentsViewController.swift @@ -101,7 +101,7 @@ class RacePaymentsViewController: UIViewController, RaceTabbable { fileprivate enum Constants { static let padding: CGFloat = UniversalConstants.padding - static let cellHeight: CGFloat = UniversalConstants.cellHeight + static let cellHeight: CGFloat = 80 static let buttonSpacing: CGFloat = 12 } @@ -150,10 +150,8 @@ class RacePaymentsViewController: UIViewController, RaceTabbable { view.addSubview(tableView) tableView.snp.makeConstraints { + $0.edges.equalToSuperview() $0.width.equalTo(UIScreen.main.bounds.width) - $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) - $0.leading.trailing.equalToSuperview() - $0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom) } view.addSubview(activityIndicatorView) @@ -168,7 +166,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..e0fec5f5 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 }() @@ -118,15 +124,12 @@ class RacePilotsViewController: UIViewController, ViewJoinable, RaceTabbable, Pi view.addSubview(tableView) tableView.snp.makeConstraints { + $0.edges.equalToSuperview() $0.width.equalTo(UIScreen.main.bounds.width) - $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) - $0.leading.trailing.equalToSuperview() - $0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom) } } fileprivate func configureNavigationItems() { - navigationItem.rightBarButtonItem = raceController.navigationItems() if race.canShowResults { title = "Race Results" @@ -135,6 +138,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 +177,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..691c9421 100644 --- a/RaceSync/View Controllers/Races/RaceScheduleViewController.swift +++ b/RaceSync/View Controllers/Races/RaceScheduleViewController.swift @@ -111,10 +111,8 @@ class RaceScheduleViewController: UIViewController, RaceTabbable { view.addSubview(webView) webView.snp.makeConstraints { + $0.edges.equalToSuperview() $0.width.equalTo(UIScreen.main.bounds.width) - $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) - $0.leading.trailing.equalToSuperview() - $0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom) } } @@ -122,7 +120,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..d0ba8c53 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() { @@ -239,8 +239,7 @@ class RaceTabBarController: UITabBarController { view.addSubview(scrollView) scrollView.snp.makeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) - $0.bottom.leading.trailing.equalToSuperview() + $0.edges.equalToSuperview() } scrollView.reloadEmptyDataSet() @@ -255,8 +254,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..e27f5bed 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] = [.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, target: self)] } - - 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..7807ee83 100644 --- a/RaceSync/View Controllers/Series/SeriesDetailViewController.swift +++ b/RaceSync/View Controllers/Series/SeriesDetailViewController.swift @@ -135,10 +135,8 @@ class SeriesDetailViewController: UIViewController { view.addSubview(scrollView) scrollView.snp.makeConstraints { - $0.width.equalTo(UIScreen.main.bounds.width) $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) - $0.leading.trailing.equalToSuperview() - $0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom) + $0.bottom.leading.trailing.equalToSuperview() } } @@ -146,7 +144,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..b6314270 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 }() @@ -113,10 +113,7 @@ class SeriesPickerViewController: UIViewController { view.addSubview(tableView) tableView.snp.makeConstraints { - $0.width.equalTo(UIScreen.main.bounds.width) - $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) - $0.leading.trailing.equalToSuperview() - $0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom) + $0.edges.equalToSuperview() } } diff --git a/RaceSync/View Controllers/Series/SeriesStandingsViewController.swift b/RaceSync/View Controllers/Series/SeriesStandingsViewController.swift index 8a82ae29..e8a3c664 100644 --- a/RaceSync/View Controllers/Series/SeriesStandingsViewController.swift +++ b/RaceSync/View Controllers/Series/SeriesStandingsViewController.swift @@ -155,27 +155,24 @@ class SeriesStandingsViewController: UIViewController, Pinnable { tableView.snp.makeConstraints { if showsSegmentedControl { $0.top.equalTo(headerView.snp.bottom) + $0.bottom.leading.trailing.equalToSuperview() } else { - $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) + $0.edges.equalToSuperview() } - - $0.width.equalTo(UIScreen.main.bounds.width) - $0.leading.trailing.equalToSuperview() - $0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom) } } 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..0439f64d 100644 --- a/RaceSync/View Controllers/Settings/SettingsViewController.swift +++ b/RaceSync/View Controllers/Settings/SettingsViewController.swift @@ -78,9 +78,7 @@ class SettingsViewController: UIViewController { view.addSubview(tableView) tableView.snp.makeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) - $0.leading.trailing.equalToSuperview() - $0.bottom.equalTo(view.snp.bottom) + $0.edges.equalToSuperview() } } @@ -88,8 +86,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 @@ -97,7 +94,12 @@ class SettingsViewController: UIViewController { func loadSections() { sections = { - let resources: [Row] = [.buildGuide, .seasonRules, .visitSite] + var resources: [Row] = [.buildGuide, .seasonRules] + if ApplicationControl.shared.isIOWindowEnable { + resources += [.ioSite] + } + resources += [.visitSite] + var auth: [Row] = [.logout] if let user = APIServices.shared.myUser, user.isDevTeam, isDevModeEnabled { auth += [.switchEnv] @@ -221,6 +223,8 @@ extension SettingsViewController: UITableViewDelegate { WebViewController.open(AppWebConstants.github) case .visitSite: WebViewController.open(AppWebConstants.homepage) + case .ioSite: + WebViewController.open(AppWebConstants.io26RaceFormats) case .logout: logout() case .switchEnv: @@ -312,6 +316,7 @@ fileprivate enum Row: Int, EnumTitle { case tracksGuide case buildGuide case seasonRules + case ioSite case visitSite case feedback case joinBeta @@ -326,6 +331,7 @@ fileprivate enum Row: Int, EnumTitle { case .tracksGuide: return "MultiGP Tracks" case .buildGuide: return "Obstacles Build Guide" case .seasonRules: return "Season Rule Books" + case .ioSite: return "IO26 Race Formats" case .visitSite: return "Visit MultiGP.com" case .feedback: return "Share Feedback" case .joinBeta: return "Join the Beta" @@ -343,6 +349,7 @@ fileprivate enum Row: Int, EnumTitle { case .tracksGuide: return "icn_settings_tracks" case .buildGuide: return "icn_settings_buildguide" case .seasonRules: return "icn_settings_handbook" + case .ioSite: return "icn_settings_io" case .visitSite: return "icn_settings_mgp" case .feedback: return "icn_settings_feedback" case .joinBeta: return "icn_settings_testflight" diff --git a/RaceSync/View Controllers/Standings/StandingsViewController.swift b/RaceSync/View Controllers/Standings/StandingsViewController.swift index 4d9b24b5..b35b3395 100644 --- a/RaceSync/View Controllers/Standings/StandingsViewController.swift +++ b/RaceSync/View Controllers/Standings/StandingsViewController.swift @@ -97,13 +97,14 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { let view = UIView() view.backgroundColor = Color.clear view.isUserInteractionEnabled = true + view.tintColor = Color.blue if !isEmpty { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineSpacing = 2 let label = UILabel() - label.attributedText = NSAttributedString(string: headerTitle, attributes: [.paragraphStyle: paragraphStyle]) + label.attributedText = NSAttributedString(string: headerTitle.uppercased(), attributes: [.paragraphStyle: paragraphStyle]) label.font = .systemFont(ofSize: 13) label.textColor = UIColor(hex: "3D3D42").withAlphaComponent(0.6) //Color.gray20 label.textAlignment = .left @@ -116,6 +117,10 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { } } + guard isRootTabBar else { + return view + } + let buttonTitle = isEmpty ? "View Past Standings" : "Past Standings" let button = CustomButton(type: .system) @@ -147,7 +152,7 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { fileprivate var headerTitle: String { get { if isRootTabBar { - return "\(season.rawValue) MultiGP Global Qualifier\nFastest 3 Consecutive Laps".uppercased() + return "\(season.rawValue) MultiGP Global Qualifier\nFastest 3 Consecutive Laps" } else { return "Fastest 3 Consecutive Laps - \(season.pilotCount) pilots" } @@ -316,7 +321,6 @@ class StandingsViewController: UIViewController, Shimmable, Pinnable { } - // MARK: - Search fileprivate func enableSearchBar(_ enable: Bool) { @@ -490,18 +494,14 @@ extension StandingsViewController: UITableViewDelegate { return count == 1 ? "Found \(count) Pilot" : "Found \(count) Pilots" } - if !standingsController.isEmpty(for: season) { - return headerTitle - } else { - return nil - } + return nil } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { guard !shimmeringView.isShimmering else { return nil } - guard isRootTabBar && !isSearching else { + guard !isSearching else { return nil } @@ -509,10 +509,10 @@ extension StandingsViewController: UITableViewDelegate { } func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - if !isRootTabBar || isSearching { - return 40 - } else { + if isRootTabBar && !isSearching { return 60 // 2 lines + } else { + return 50 } } } diff --git a/RaceSync/View Models/EmptyStateViewModel.swift b/RaceSync/View Models/EmptyStateViewModel.swift index 0d9c1ac2..b40ed7dd 100644 --- a/RaceSync/View Models/EmptyStateViewModel.swift +++ b/RaceSync/View Models/EmptyStateViewModel.swift @@ -32,6 +32,7 @@ enum EmptyState { case noRacePayments case noChapters case noChapterMembers + case noEvents case noSeries case noJoinedSeries @@ -87,6 +88,8 @@ struct EmptyStateViewModel: EmptyStateViewModelInterface { text = "No Race Payments" case .noChapters, .noMyProfileChapters, .noProfileChapters: text = "No Chapters" + case .noEvents: + text = "No Events" case .noPushMessages: text = "No Messages" case .noPushAuthorized: @@ -117,27 +120,29 @@ 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 .noEvents: + text = "You haven't favorited any events for this date yet." case .noPushMessages: text = "You don't have any messages yet." case .noPushAuthorized: 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/Data/EventStore.swift b/RaceSyncAPI/Data/EventStore.swift new file mode 100644 index 00000000..59fc3917 --- /dev/null +++ b/RaceSyncAPI/Data/EventStore.swift @@ -0,0 +1,38 @@ +// +// EventStore.swift +// RaceSyncAPI +// +// Created by Ignacio Romero on 2026-05-24. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import Foundation +import ObjectMapper + +class EventStore { + + static let shared = EventStore() + + func save(_ event: Event) { + guard let json = Mapper().toJSONString(event, prettyPrint: true), + let data = json.data(using: .utf8) else { + print("[EventStore] Serialization failed") + return + } + do { + try data.write(to: fileURL) + } catch { + print("[EventStore] Write failed: \(error)") + } + } + + func load() -> Event? { + guard let data = try? Data(contentsOf: fileURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return Mapper().map(JSONObject: json) + } + + private let fileURL = FileStore.url(for: "io26_event.json") +} 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/Extensions/Date+Extensions.swift b/RaceSyncAPI/Extensions/Date+Extensions.swift index dfde6d2b..502b53dd 100644 --- a/RaceSyncAPI/Extensions/Date+Extensions.swift +++ b/RaceSyncAPI/Extensions/Date+Extensions.swift @@ -138,10 +138,22 @@ public extension Date { let year = calendar.component(.year, from: self) guard let startDate = calendar.date(from: DateComponents(year: year, month: startMonth, day: startDay)), - let endDate = calendar.date(from: DateComponents(year: year, month: endMonth, day: endDay)) else { + let endDate = calendar.date(from: DateComponents(year: year, month: endMonth, day: endDay)), + let endOfDay = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDate) else { return false } - - return self >= startDate && self <= endDate + return self >= startDate && self <= endOfDay } + + func isPast(day: Int, month: Int) -> Bool { + let calendar = Calendar.current + let year = calendar.component(.year, from: self) + + guard let targetDate = calendar.date(from: DateComponents(year: year, month: month, day: day)), + let endOfDay = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: targetDate) else { + return false + } + + return self > endOfDay + } } diff --git a/RaceSyncAPI/Extensions/String+Extensions.swift b/RaceSyncAPI/Extensions/String+Extensions.swift index 9796fb5b..1f798f10 100644 --- a/RaceSyncAPI/Extensions/String+Extensions.swift +++ b/RaceSyncAPI/Extensions/String+Extensions.swift @@ -60,6 +60,11 @@ public extension String { return nil } + + func containsAny(_ keywords: [String], caseInsensitive: Bool = true) -> Bool { + let options: String.CompareOptions = caseInsensitive ? .caseInsensitive : [] + return keywords.contains { range(of: $0, options: options) != nil } + } } public extension String { diff --git a/RaceSyncAPI/Models/Event.swift b/RaceSyncAPI/Models/Event.swift new file mode 100644 index 00000000..2f55d499 --- /dev/null +++ b/RaceSyncAPI/Models/Event.swift @@ -0,0 +1,189 @@ +// +// 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 Event: Mappable, Descriptable { + + public var name: String = "" + public var venue: String = "" + public var lastUpdated: Date? + + public var tracks: [EventTrack]? = nil + public var sessions: [EventSession]? = 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 EventTrack: 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 EventSession: 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: EventStatus = .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()) + + _rawDate <- map["date"] + _rawStartTime <- map["startTime"] + _rawEndTime <- map["endTime"] + + // Only parse dates when deserializing + if map.mappingType == .fromJSON { + date = Self.parseDate(_rawDate) + startTime = Self.parseDateTime(date: _rawDate, time: _rawStartTime) + endTime = Self.parseDateTime(date: _rawDate, time: _rawEndTime) + } + } + + fileprivate var _rawDate: String? + fileprivate var _rawStartTime: String? + fileprivate var _rawEndTime: String? +} + +public enum EventStatus: String, EnumTitle { + + public var title: String { + return self.rawValue.capitalized + } + + case closed = "closed" + case scheduled = "scheduled" +} + +extension EventSession { + + public static func io26Dates(from start: String, to end: String) -> [Date] { + guard let startDate = dateFormatter.date(from: start), + let endDate = dateFormatter.date(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 { + return dateFormatter.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 EventSession: Hashable { + public static func == (lhs: EventSession, rhs: EventSession) -> Bool { + lhs.id == rhs.id + } + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +extension EventSession { + + public func copy() -> EventSession { + let copy = EventSession() + copy.id = id + copy.dayName = dayName + copy.trackId = trackId + copy.activity = activity + copy.status = status + copy.date = date + copy.startTime = startTime + copy.endTime = endTime + copy._rawDate = _rawDate + copy._rawStartTime = _rawStartTime + copy._rawEndTime = _rawEndTime + 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/Models/SeriesResult.swift b/RaceSyncAPI/Models/SeriesResult.swift index ac52a419..fa0f1449 100644 --- a/RaceSyncAPI/Models/SeriesResult.swift +++ b/RaceSyncAPI/Models/SeriesResult.swift @@ -43,8 +43,8 @@ public class SeriesResult: Mappable, Descriptable { // MARK: - Mapping public func mapping(map: Map) { // Pilot or chapter id - pilotId <- map[ParamKey.pilotId] - chapterId <- map[ParamKey.chapterId] + pilotId <- (map[ParamKey.pilotId], MapperUtil.anyStringTransform) + chapterId <- (map[ParamKey.chapterId], MapperUtil.anyStringTransform) raceCount <- map[ParamKey.raceCount] // Determine type from what exists diff --git a/RaceSyncAPI/Network/EventApi.swift b/RaceSyncAPI/Network/EventApi.swift new file mode 100644 index 00000000..46f34abd --- /dev/null +++ b/RaceSyncAPI/Network/EventApi.swift @@ -0,0 +1,81 @@ +// +// 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 EventApiInterface { + + /** + */ + func getIO26Event(_ completion: @escaping ObjectCompletionBlock) +} + +public class EventApi: EventApiInterface { + + public init() {} + + public func getIO26Event(_ completion: @escaping ObjectCompletionBlock) { + + // Return cache immediately if available + if let cached = EventStore.shared.load() { + completion(cached, nil) + +#if DEBUG + return +#endif + } + + let url = URL(string: "https://script.google.com/macros/s/AKfycbwxgL-ib1uq1EMyfkjrpvmdoMSxzKGG5x--MV4GAMExkM3UEV5FHovTM_UKbTtALQBj/exec")! + + // Always fetch from network and overwrite cache + fetchEvent(from: url) { result in + DispatchQueue.main.async { + var log = "+ Ended request with " + + switch result { + case .success(let json): + let model = Mapper().map(JSONObject: json) + log += "\(model?.name ?? "")" + if let model { EventStore.shared.save(model) } + completion(model, nil) + + case .failure(let error): + let err = error as NSError + log += " Network Error: \(err.debugDescription)" + completion(nil, err) + } + + Clog.log(log) + } + } + } +} + +extension EventApi { + + 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() + } +} diff --git a/RaceSyncAPI/Utils/DeepLink.swift b/RaceSyncAPI/Utils/DeepLink.swift index 49b1a1b8..81f37602 100644 --- a/RaceSyncAPI/Utils/DeepLink.swift +++ b/RaceSyncAPI/Utils/DeepLink.swift @@ -16,8 +16,8 @@ public struct DeepLink { public enum Domain: String { case race case races - case pilto - case piltos + case pilot + case pilots case chapters } diff --git a/RaceSyncAPI/Utils/FileStore.swift b/RaceSyncAPI/Utils/FileStore.swift new file mode 100644 index 00000000..1a2e07e3 --- /dev/null +++ b/RaceSyncAPI/Utils/FileStore.swift @@ -0,0 +1,27 @@ +// +// FileStore.swift +// RaceSync +// +// Created by Ignacio Romero on 2026-05-23. +// Copyright © 2026 MultiGP Inc. All rights reserved. +// + +import Foundation + +public class FileStore { + + public static func url(for filename: String) -> URL { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + return docs.appendingPathComponent(filename) + } + + /// Converts a human-readable name into a snake_cased lowercased filename + /// e.g. "MultiGP International Open 2026" → "MultiGP_International_Open_2026.json" + public static func sanitizedFileName(from name: String, extension ext: String = "json") -> String { + let snake = name + .components(separatedBy: .whitespaces) + .filter { !$0.isEmpty } + .joined(separator: "_") + return "\(snake).\(ext)".lowercased() + } +} diff --git a/RaceSyncAPI/Utils/MapperUtil.swift b/RaceSyncAPI/Utils/MapperUtil.swift index 6280b15a..71fdf0f0 100644 --- a/RaceSyncAPI/Utils/MapperUtil.swift +++ b/RaceSyncAPI/Utils/MapperUtil.swift @@ -10,6 +10,17 @@ import Foundation import ObjectMapper public class MapperUtil { + + public static let anyStringTransform = TransformOf( + fromJSON: { value in + guard let value else { return nil } + if let str = value as? String { return str } + if let int = value as? Int { return String(int) } + if let double = value as? Double { return String(Int(double)) } + return nil + }, + toJSON: { _ in nil } + ) public static let dateTransform = TransformOf(fromJSON: { (value: String?) -> Date? in guard let value = value else { return nil }