Aggiunge app iOS nativa con parità multi-sport e test unitari.

Porting SwiftUI da Android (auth, hub, wizard, broadcast RTMP, overlay, scoring board-aware), reload hub al ritorno da diretta, decodifica score tollerante e documentazione allineata.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Emiliano Frascaro
2026-06-14 21:37:32 +02:00
parent b798e0ea06
commit 585332e32e
101 changed files with 7910 additions and 5 deletions

4
native/ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
build/
DerivedData/
*.xcuserstate
xcuserdata/

View File

@@ -0,0 +1,234 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {};
objectVersion = 60;
objects = {
5B23ED0F23434BC3A23B142F /* MatchLiveTvApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveTvApp.swift; path = MatchLiveTv/App/MatchLiveTvApp.swift; sourceTree = "<group>"; };
D7A94757D18C46C9821833B7 /* ApiInstant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstant.swift; path = MatchLiveTv/Core/ApiInstant.swift; sourceTree = "<group>"; };
1443F18A02BB4890A01A56F9 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppConfig.swift; path = MatchLiveTv/Core/AppConfig.swift; sourceTree = "<group>"; };
E717B8A6A51E499490C0334B /* ColorHex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ColorHex.swift; path = MatchLiveTv/Core/ColorHex.swift; sourceTree = "<group>"; };
6E6A356C81724C51BF41C25D /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = "<group>"; };
DC18E1BC596E42DBB8BBBBFD /* MediaUrl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MediaUrl.swift; path = MatchLiveTv/Core/MediaUrl.swift; sourceTree = "<group>"; };
C3DCAD32C3F24439A75920C1 /* TokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TokenStore.swift; path = MatchLiveTv/Core/TokenStore.swift; sourceTree = "<group>"; };
B4B5CF0BD5534CF7BE51E3CB /* UserFacingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UserFacingError.swift; path = MatchLiveTv/Core/UserFacingError.swift; sourceTree = "<group>"; };
E14CA2E05DE7400C852121AD /* ApiDtos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiDtos.swift; path = MatchLiveTv/Data/API/ApiDtos.swift; sourceTree = "<group>"; };
BD1CEFBFF4D54361A4C3B4D2 /* MatchLiveAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveAPI.swift; path = MatchLiveTv/Data/API/MatchLiveAPI.swift; sourceTree = "<group>"; };
66C7207107804520B8B87462 /* AppContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppContainer.swift; path = MatchLiveTv/Data/AppContainer.swift; sourceTree = "<group>"; };
BE6E2D81675945268458DEE8 /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = "<group>"; };
E902041CF4A04479B72760FE /* SessionCableService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionCableService.swift; path = MatchLiveTv/Data/Cable/SessionCableService.swift; sourceTree = "<group>"; };
4AE94D0FE137473D8821B0E3 /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AuthRepository.swift; path = MatchLiveTv/Data/Repository/AuthRepository.swift; sourceTree = "<group>"; };
8D20E18A8913489FABFBFFB5 /* MatchRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchRepository.swift; path = MatchLiveTv/Data/Repository/MatchRepository.swift; sourceTree = "<group>"; };
BDDCAF6027D24CB2B273A3D6 /* MatchSessionLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSessionLauncher.swift; path = MatchLiveTv/Data/Repository/MatchSessionLauncher.swift; sourceTree = "<group>"; };
2A5CCF3B585A4095941A6B21 /* ScoreRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreRepository.swift; path = MatchLiveTv/Data/Repository/ScoreRepository.swift; sourceTree = "<group>"; };
4436BE62A6004577BE8356C1 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; };
CF167DF2CBC14C2A97D49C1D /* ScoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreController.swift; path = MatchLiveTv/Data/Scoring/ScoreController.swift; sourceTree = "<group>"; };
0BE371ACB29A48DF928B90DD /* WizardSessionHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardSessionHolder.swift; path = MatchLiveTv/Data/WizardSessionHolder.swift; sourceTree = "<group>"; };
73A8C9597075432FB3DFCFFA /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = "<group>"; };
116561D032ED4DF8A4174E59 /* MatchScoringRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRules.swift; path = MatchLiveTv/Domain/MatchScoringRules.swift; sourceTree = "<group>"; };
AC559FEC305D475CA3F1DB21 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Models.swift; path = MatchLiveTv/Domain/Models.swift; sourceTree = "<group>"; };
3247EDBBC6114F269C89736C /* ScoreState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreState.swift; path = MatchLiveTv/Domain/ScoreState.swift; sourceTree = "<group>"; };
15B20863445A4EC28321DBFF /* BroadcastModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastModels.swift; path = MatchLiveTv/Streaming/BroadcastModels.swift; sourceTree = "<group>"; };
51B7115C3000422A8DCA41ED /* LiveBroadcastCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastCoordinator.swift; path = MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift; sourceTree = "<group>"; };
7A433A01B5BB4FE0B8E51234 /* LiveBroadcastEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastEngine.swift; path = MatchLiveTv/Streaming/LiveBroadcastEngine.swift; sourceTree = "<group>"; };
C296D6280BCD4DC0BB467CC1 /* CompactScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CompactScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/CompactScoreboardElement.swift; sourceTree = "<group>"; };
3F851B63647B4122A2753EBD /* OverlayCanvasRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayCanvasRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayCanvasRenderer.swift; sourceTree = "<group>"; };
420794A1247149B7B931DAEA /* OverlayLogoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayLogoCache.swift; path = MatchLiveTv/Streaming/Overlay/OverlayLogoCache.swift; sourceTree = "<group>"; };
48C5D1CE81864C2FBB3188EA /* OverlayMappings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayMappings.swift; path = MatchLiveTv/Streaming/Overlay/OverlayMappings.swift; sourceTree = "<group>"; };
228DEA0D7B0D48D6BED86141 /* OverlayRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift; sourceTree = "<group>"; };
0F9F57E16B374D3AB379C1FF /* OverlayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayState.swift; path = MatchLiveTv/Streaming/Overlay/OverlayState.swift; sourceTree = "<group>"; };
0ED2AA45ECFA4193ACC31063 /* ScoreboardElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreboardElement.swift; path = MatchLiveTv/Streaming/Overlay/ScoreboardElement.swift; sourceTree = "<group>"; };
1F2B2A3AC4C948038C03E8BB /* SponsorElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SponsorElement.swift; path = MatchLiveTv/Streaming/Overlay/SponsorElement.swift; sourceTree = "<group>"; };
3465550280DE4F5E8D485F69 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = "<group>"; };
42A4881EE59B42FEB7749122 /* LivePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LivePreviewView.swift; path = MatchLiveTv/Streaming/Preview/LivePreviewView.swift; sourceTree = "<group>"; };
2B7C7A905479455F98F00DC3 /* BroadcastControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastControlsOverlay.swift; path = MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift; sourceTree = "<group>"; };
8EBF7DD17E77419A83DC0496 /* BroadcastScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastScreen.swift; path = MatchLiveTv/UI/Broadcast/BroadcastScreen.swift; sourceTree = "<group>"; };
2F0BE30BA75440D5B0A0F9A6 /* LiveScoreActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveScoreActions.swift; path = MatchLiveTv/UI/Broadcast/LiveScoreActions.swift; sourceTree = "<group>"; };
1EDDB8ABF0ED4F24A5932D18 /* MatchLiveWordmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchLiveWordmark.swift; path = MatchLiveTv/UI/Components/MatchLiveWordmark.swift; sourceTree = "<group>"; };
F2D8C0FDD5EB4A3792E4D32E /* MatchPrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchPrimaryButton.swift; path = MatchLiveTv/UI/Components/MatchPrimaryButton.swift; sourceTree = "<group>"; };
BDCE69667BE74DB9AF3E29E8 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
6E72DCB8E06D42038B6E8B69 /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = "<group>"; };
DC49A86D45D745E297107891 /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
456228041D2F44E99258698B /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginScreen.swift; path = MatchLiveTv/UI/Login/LoginScreen.swift; sourceTree = "<group>"; };
6C792176D0544EED87B303C0 /* MatchesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchesScreen.swift; path = MatchLiveTv/UI/Matches/MatchesScreen.swift; sourceTree = "<group>"; };
ED410204E5694627AEA787DD /* AppNavHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppNavHost.swift; path = MatchLiveTv/UI/Navigation/AppNavHost.swift; sourceTree = "<group>"; };
EFF711C0619445C3944D3442 /* ModalRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ModalRoutes.swift; path = MatchLiveTv/UI/Navigation/ModalRoutes.swift; sourceTree = "<group>"; };
98EA33F31CE34BC88EC99E64 /* Routes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Routes.swift; path = MatchLiveTv/UI/Navigation/Routes.swift; sourceTree = "<group>"; };
097406CB65A24473B7F8A915 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = "<group>"; };
F18D3BA8A2604783AFA7BADC /* SplashScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SplashScreen.swift; path = MatchLiveTv/UI/Splash/SplashScreen.swift; sourceTree = "<group>"; };
8CF22D513753479E96E37546 /* KeepScreenOn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeepScreenOn.swift; path = MatchLiveTv/UI/System/KeepScreenOn.swift; sourceTree = "<group>"; };
3C42C3E8D4D24A6EBC8A140F /* ScreenOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScreenOrientation.swift; path = MatchLiveTv/UI/System/ScreenOrientation.swift; sourceTree = "<group>"; };
AED45F6291AC4F7FB7B31393 /* MatchColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchColors.swift; path = MatchLiveTv/UI/Theme/MatchColors.swift; sourceTree = "<group>"; };
E4A2CDEC380147D89E36B359 /* StepMatchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepMatchScreen.swift; path = MatchLiveTv/UI/Wizard/StepMatchScreen.swift; sourceTree = "<group>"; };
4E0B262FA3774D72901529E8 /* StepNetworkTestScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepNetworkTestScreen.swift; path = MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift; sourceTree = "<group>"; };
00F0EEBEE0C642FAB6298D79 /* StepTransmissionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepTransmissionScreen.swift; path = MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift; sourceTree = "<group>"; };
64C5CD27F7354DDB9A7FCC5F /* TeamBrandingEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamBrandingEditor.swift; path = MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift; sourceTree = "<group>"; };
EC0F495A7A484769ADA89E96 /* TeamColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TeamColorPicker.swift; path = MatchLiveTv/UI/Wizard/TeamColorPicker.swift; sourceTree = "<group>"; };
A768FA73B328428EAC69BF16 /* WizardComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardComponents.swift; path = MatchLiveTv/UI/Wizard/WizardComponents.swift; sourceTree = "<group>"; };
5B9EE57FDB9148A4B541B8CF /* WizardShellScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardShellScreen.swift; path = MatchLiveTv/UI/Wizard/WizardShellScreen.swift; sourceTree = "<group>"; };
2540D3CA40CC4181B3D4AFF3 /* ApiInstantTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstantTests.swift; path = MatchLiveTvTests/ApiInstantTests.swift; sourceTree = "<group>"; };
CD155C0D547D4495B9E6308A /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = "<group>"; };
42D318A6B43D4D4A898DBBE0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MatchLiveTv/Resources/Assets.xcassets; sourceTree = "<group>"; };
82B310B2B49243A296C9C581 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MatchLiveTv/Resources/Info.plist; sourceTree = "<group>"; };
A42C110EE8DB4ECC893951AD /* MatchLiveTv.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MatchLiveTv.app; sourceTree = BUILT_PRODUCTS_DIR; };
CA19230D3F7D4C6A95406F87 /* MatchLiveTvTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MatchLiveTvTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
0B182AE2B64440FBAD820078 /* HaishinKit */ = {isa = XCSwiftPackageProductDependency; package = F80AB1FECA6A40798C87634A; productName = HaishinKit; };
F0E1D34F0B3545A99C3EF141 /* RTMPHaishinKit */ = {isa = XCSwiftPackageProductDependency; package = F80AB1FECA6A40798C87634A; productName = RTMPHaishinKit; };
C43733BB396340269131C87F /* MatchLiveTvApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B23ED0F23434BC3A23B142F /* MatchLiveTvApp.swift */; };
3879FE4999AD42D29DD88BC9 /* ApiInstant.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A94757D18C46C9821833B7 /* ApiInstant.swift */; };
F9B0B951CC0E45F3AD9E17AE /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1443F18A02BB4890A01A56F9 /* AppConfig.swift */; };
FDC36E1F2673422E9F09A84D /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = E717B8A6A51E499490C0334B /* ColorHex.swift */; };
9BF13DD9192942E8A69CD50B /* DeviceTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6A356C81724C51BF41C25D /* DeviceTelemetry.swift */; };
82F9B0A9BD714835A41E20CE /* MediaUrl.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC18E1BC596E42DBB8BBBBFD /* MediaUrl.swift */; };
6A6460E2BBBA44C6964B2281 /* TokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DCAD32C3F24439A75920C1 /* TokenStore.swift */; };
09C3F7CBE03C479FB3E4D484 /* UserFacingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B5CF0BD5534CF7BE51E3CB /* UserFacingError.swift */; };
882F9D01445F454BBBF95AAE /* ApiDtos.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14CA2E05DE7400C852121AD /* ApiDtos.swift */; };
A9C47B24D37C48D59221AB15 /* MatchLiveAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1CEFBFF4D54361A4C3B4D2 /* MatchLiveAPI.swift */; };
E45FC9EC93854268B13B2814 /* AppContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C7207107804520B8B87462 /* AppContainer.swift */; };
035C3532E9224C11A63DE8D0 /* ActionCableClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6E2D81675945268458DEE8 /* ActionCableClient.swift */; };
BD0F196EE104494FA4959E43 /* SessionCableService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E902041CF4A04479B72760FE /* SessionCableService.swift */; };
E16AD6EDD2E34A1BB16942CC /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE94D0FE137473D8821B0E3 /* AuthRepository.swift */; };
4D58338481074C47B177E866 /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D20E18A8913489FABFBFFB5 /* MatchRepository.swift */; };
5B0F3899F7B14F84AEF5CADA /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDDCAF6027D24CB2B273A3D6 /* MatchSessionLauncher.swift */; };
535A5DC767124965A7E6ED4D /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5CCF3B585A4095941A6B21 /* ScoreRepository.swift */; };
1C27C8DFDE544D75B1D49507 /* SessionRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4436BE62A6004577BE8356C1 /* SessionRepository.swift */; };
8F6BCEEF9168476FBA147FD3 /* ScoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF167DF2CBC14C2A97D49C1D /* ScoreController.swift */; };
A2BFF50DA9294532A2F6D52B /* WizardSessionHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BE371ACB29A48DF928B90DD /* WizardSessionHolder.swift */; };
A5EA10DEDBB34A4A95243C8A /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73A8C9597075432FB3DFCFFA /* MatchHubFilter.swift */; };
71857E5125D14E0BAC132C3B /* MatchScoringRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 116561D032ED4DF8A4174E59 /* MatchScoringRules.swift */; };
965F2CEB66984469BD3BB2F6 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC559FEC305D475CA3F1DB21 /* Models.swift */; };
6247A346FC674616A8833D87 /* ScoreState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247EDBBC6114F269C89736C /* ScoreState.swift */; };
21F2A3C208DA4D9A8D8A2AD0 /* BroadcastModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15B20863445A4EC28321DBFF /* BroadcastModels.swift */; };
C1967324B50641B1AAA7153D /* LiveBroadcastCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B7115C3000422A8DCA41ED /* LiveBroadcastCoordinator.swift */; };
6A39E943F02842819BA0F11D /* LiveBroadcastEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A433A01B5BB4FE0B8E51234 /* LiveBroadcastEngine.swift */; };
456274BB9DFA482EB52A77FF /* CompactScoreboardElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C296D6280BCD4DC0BB467CC1 /* CompactScoreboardElement.swift */; };
FFF0200A28BD4C55BF288A04 /* OverlayCanvasRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F851B63647B4122A2753EBD /* OverlayCanvasRenderer.swift */; };
EED95CFC9C044C46B8C748E8 /* OverlayLogoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420794A1247149B7B931DAEA /* OverlayLogoCache.swift */; };
196A4353FEBF48FB8086B6C3 /* OverlayMappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48C5D1CE81864C2FBB3188EA /* OverlayMappings.swift */; };
73067AA8AEFA446C898CBB36 /* OverlayRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 228DEA0D7B0D48D6BED86141 /* OverlayRenderer.swift */; };
AD747FCF7E8A40FB8FB0C09D /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9F57E16B374D3AB379C1FF /* OverlayState.swift */; };
4A2583194D364768910F0A2E /* ScoreboardElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED2AA45ECFA4193ACC31063 /* ScoreboardElement.swift */; };
63FB60603C30400A9412B97A /* SponsorElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2B2A3AC4C948038C03E8BB /* SponsorElement.swift */; };
476CEF2BD80A4F5ABAF4BF8F /* WatermarkElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3465550280DE4F5E8D485F69 /* WatermarkElement.swift */; };
E2B229D77E7B4B0EB3E20F3A /* LivePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A4881EE59B42FEB7749122 /* LivePreviewView.swift */; };
455B81C72AAC40269A4B4EDE /* BroadcastControlsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B7C7A905479455F98F00DC3 /* BroadcastControlsOverlay.swift */; };
2E49953E828A4F1A87AF47AF /* BroadcastScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EBF7DD17E77419A83DC0496 /* BroadcastScreen.swift */; };
87870520F2C5472DBEF4FAA0 /* LiveScoreActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0BE30BA75440D5B0A0F9A6 /* LiveScoreActions.swift */; };
7F20ADEFF13D42EB969FA227 /* MatchLiveWordmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EDDB8ABF0ED4F24A5932D18 /* MatchLiveWordmark.swift */; };
F05637D278B04B9E9F914685 /* MatchPrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2D8C0FDD5EB4A3792E4D32E /* MatchPrimaryButton.swift */; };
05E7701D345F47A48EB3B1A8 /* MatchScreenScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDCE69667BE74DB9AF3E29E8 /* MatchScreenScaffold.swift */; };
D0CA4D05E24D44B48662A467 /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E72DCB8E06D42038B6E8B69 /* MatchSecondaryButton.swift */; };
093B423F42B840AC8DC4BBFE /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC49A86D45D745E297107891 /* MatchStatusBadge.swift */; };
6478C806699D4AF3AB895AC6 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 456228041D2F44E99258698B /* LoginScreen.swift */; };
E981731C73724090B93301FC /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C792176D0544EED87B303C0 /* MatchesScreen.swift */; };
D7EF5FD3160546A18B1F3268 /* AppNavHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED410204E5694627AEA787DD /* AppNavHost.swift */; };
DE2C6A8E89FF4C019599320F /* ModalRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFF711C0619445C3944D3442 /* ModalRoutes.swift */; };
FC4AD7E92B4248BF8D440AB5 /* Routes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98EA33F31CE34BC88EC99E64 /* Routes.swift */; };
08FB4930AAC1443B8A6AA17A /* BroadcastPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 097406CB65A24473B7F8A915 /* BroadcastPermissions.swift */; };
AA896C55DB9741B78A173671 /* SplashScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18D3BA8A2604783AFA7BADC /* SplashScreen.swift */; };
F2C1D718393C4A7695FE56CD /* KeepScreenOn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CF22D513753479E96E37546 /* KeepScreenOn.swift */; };
C06B35642D68409788DAC85F /* ScreenOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C42C3E8D4D24A6EBC8A140F /* ScreenOrientation.swift */; };
CA27CA0592C1459AA5895B03 /* MatchColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = AED45F6291AC4F7FB7B31393 /* MatchColors.swift */; };
0D790785EC384F4F999DF393 /* StepMatchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4A2CDEC380147D89E36B359 /* StepMatchScreen.swift */; };
33D5A9835E1C476FA513CD7B /* StepNetworkTestScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0B262FA3774D72901529E8 /* StepNetworkTestScreen.swift */; };
16773703D9B34B4D99E37DAB /* StepTransmissionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F0EEBEE0C642FAB6298D79 /* StepTransmissionScreen.swift */; };
439C3F3C9E8E4512AAE9A6F9 /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C5CD27F7354DDB9A7FCC5F /* TeamBrandingEditor.swift */; };
1895A7F0D8A04AB086C8B0E0 /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC0F495A7A484769ADA89E96 /* TeamColorPicker.swift */; };
E606392321674632B0B5A9C3 /* WizardComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A768FA73B328428EAC69BF16 /* WizardComponents.swift */; };
437D99BC2B8446CA87766221 /* WizardShellScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B9EE57FDB9148A4B541B8CF /* WizardShellScreen.swift */; };
76DE047F93C0417FB8D60984 /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2540D3CA40CC4181B3D4AFF3 /* ApiInstantTests.swift */; };
AB9147175A564C9B848A5E43 /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD155C0D547D4495B9E6308A /* MatchScoringRulesTests.swift */; };
9392A07F3B5343F4BE8538C9 /* Assets in Resources */ = {isa = PBXBuildFile; fileRef = 42D318A6B43D4D4A898DBBE0; };
7AE60FABDB154D8286A2902F = {isa = PBXGroup; children = (25BA0F6BCD6248C8B1090B10, 9CDEE1193E594846AF90F5A3, CECA644368B34BD1AF81B7D4); sourceTree = "<group>"; };
25BA0F6BCD6248C8B1090B10 = {isa = PBXGroup; children = (5B23ED0F23434BC3A23B142F, D7A94757D18C46C9821833B7, 1443F18A02BB4890A01A56F9, E717B8A6A51E499490C0334B, 6E6A356C81724C51BF41C25D, DC18E1BC596E42DBB8BBBBFD, C3DCAD32C3F24439A75920C1, B4B5CF0BD5534CF7BE51E3CB, E14CA2E05DE7400C852121AD, BD1CEFBFF4D54361A4C3B4D2, 66C7207107804520B8B87462, BE6E2D81675945268458DEE8, E902041CF4A04479B72760FE, 4AE94D0FE137473D8821B0E3, 8D20E18A8913489FABFBFFB5, BDDCAF6027D24CB2B273A3D6, 2A5CCF3B585A4095941A6B21, 4436BE62A6004577BE8356C1, CF167DF2CBC14C2A97D49C1D, 0BE371ACB29A48DF928B90DD, 73A8C9597075432FB3DFCFFA, 116561D032ED4DF8A4174E59, AC559FEC305D475CA3F1DB21, 3247EDBBC6114F269C89736C, 15B20863445A4EC28321DBFF, 51B7115C3000422A8DCA41ED, 7A433A01B5BB4FE0B8E51234, C296D6280BCD4DC0BB467CC1, 3F851B63647B4122A2753EBD, 420794A1247149B7B931DAEA, 48C5D1CE81864C2FBB3188EA, 228DEA0D7B0D48D6BED86141, 0F9F57E16B374D3AB379C1FF, 0ED2AA45ECFA4193ACC31063, 1F2B2A3AC4C948038C03E8BB, 3465550280DE4F5E8D485F69, 42A4881EE59B42FEB7749122, 2B7C7A905479455F98F00DC3, 8EBF7DD17E77419A83DC0496, 2F0BE30BA75440D5B0A0F9A6, 1EDDB8ABF0ED4F24A5932D18, F2D8C0FDD5EB4A3792E4D32E, BDCE69667BE74DB9AF3E29E8, 6E72DCB8E06D42038B6E8B69, DC49A86D45D745E297107891, 456228041D2F44E99258698B, 6C792176D0544EED87B303C0, ED410204E5694627AEA787DD, EFF711C0619445C3944D3442, 98EA33F31CE34BC88EC99E64, 097406CB65A24473B7F8A915, F18D3BA8A2604783AFA7BADC, 8CF22D513753479E96E37546, 3C42C3E8D4D24A6EBC8A140F, AED45F6291AC4F7FB7B31393, E4A2CDEC380147D89E36B359, 4E0B262FA3774D72901529E8, 00F0EEBEE0C642FAB6298D79, 64C5CD27F7354DDB9A7FCC5F, EC0F495A7A484769ADA89E96, A768FA73B328428EAC69BF16, 5B9EE57FDB9148A4B541B8CF, 42D318A6B43D4D4A898DBBE0, 82B310B2B49243A296C9C581); name = MatchLiveTv; sourceTree = "<group>"; };
9CDEE1193E594846AF90F5A3 = {isa = PBXGroup; children = (2540D3CA40CC4181B3D4AFF3, CD155C0D547D4495B9E6308A); name = MatchLiveTvTests; sourceTree = "<group>"; };
CECA644368B34BD1AF81B7D4 = {isa = PBXGroup; children = (A42C110EE8DB4ECC893951AD, CA19230D3F7D4C6A95406F87); name = Products; sourceTree = "<group>"; };
75685F0D94844D25990466AB = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (C43733BB396340269131C87F, 3879FE4999AD42D29DD88BC9, F9B0B951CC0E45F3AD9E17AE, FDC36E1F2673422E9F09A84D, 9BF13DD9192942E8A69CD50B, 82F9B0A9BD714835A41E20CE, 6A6460E2BBBA44C6964B2281, 09C3F7CBE03C479FB3E4D484, 882F9D01445F454BBBF95AAE, A9C47B24D37C48D59221AB15, E45FC9EC93854268B13B2814, 035C3532E9224C11A63DE8D0, BD0F196EE104494FA4959E43, E16AD6EDD2E34A1BB16942CC, 4D58338481074C47B177E866, 5B0F3899F7B14F84AEF5CADA, 535A5DC767124965A7E6ED4D, 1C27C8DFDE544D75B1D49507, 8F6BCEEF9168476FBA147FD3, A2BFF50DA9294532A2F6D52B, A5EA10DEDBB34A4A95243C8A, 71857E5125D14E0BAC132C3B, 965F2CEB66984469BD3BB2F6, 6247A346FC674616A8833D87, 21F2A3C208DA4D9A8D8A2AD0, C1967324B50641B1AAA7153D, 6A39E943F02842819BA0F11D, 456274BB9DFA482EB52A77FF, FFF0200A28BD4C55BF288A04, EED95CFC9C044C46B8C748E8, 196A4353FEBF48FB8086B6C3, 73067AA8AEFA446C898CBB36, AD747FCF7E8A40FB8FB0C09D, 4A2583194D364768910F0A2E, 63FB60603C30400A9412B97A, 476CEF2BD80A4F5ABAF4BF8F, E2B229D77E7B4B0EB3E20F3A, 455B81C72AAC40269A4B4EDE, 2E49953E828A4F1A87AF47AF, 87870520F2C5472DBEF4FAA0, 7F20ADEFF13D42EB969FA227, F05637D278B04B9E9F914685, 05E7701D345F47A48EB3B1A8, D0CA4D05E24D44B48662A467, 093B423F42B840AC8DC4BBFE, 6478C806699D4AF3AB895AC6, E981731C73724090B93301FC, D7EF5FD3160546A18B1F3268, DE2C6A8E89FF4C019599320F, FC4AD7E92B4248BF8D440AB5, 08FB4930AAC1443B8A6AA17A, AA896C55DB9741B78A173671, F2C1D718393C4A7695FE56CD, C06B35642D68409788DAC85F, CA27CA0592C1459AA5895B03, 0D790785EC384F4F999DF393, 33D5A9835E1C476FA513CD7B, 16773703D9B34B4D99E37DAB, 439C3F3C9E8E4512AAE9A6F9, 1895A7F0D8A04AB086C8B0E0, E606392321674632B0B5A9C3, 437D99BC2B8446CA87766221); runOnlyForDeploymentPostprocessing = 0; };
BC137E765FCA41FB85A944F8 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (76DE047F93C0417FB8D60984, AB9147175A564C9B848A5E43); runOnlyForDeploymentPostprocessing = 0; };
E2036A7DDA8F4F7491399F71 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (9392A07F3B5343F4BE8538C9); runOnlyForDeploymentPostprocessing = 0; };
ECE339972AA84978AE88BB46 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
1C9A61B330DB4A6AA59CFEF4 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
9773A4B6329B4CD2AA4CA51D = {isa = XCBuildConfiguration; buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
ENABLE_TESTABILITY = YES;
}; name = Debug; };
C101F6805BCD491CBA3F987D = {isa = XCBuildConfiguration; buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
}; name = Release; };
D8F8880914704BA9B087EE7B = {isa = XCBuildConfiguration; buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MatchLiveTv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MatchLiveTv";
}; name = Debug; };
2627164A4CA343B2A4C15A2E = {isa = XCBuildConfiguration; buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MatchLiveTv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MatchLiveTv";
}; name = Release; };
7BD2F8EE33E8434AB17CE373 = {isa = XCBuildConfiguration; buildSettings = {IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; }; name = Debug; };
68B5EA19B4E24E5D968BF94C = {isa = XCBuildConfiguration; buildSettings = {IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; }; name = Release; };
6ED2FEB21E7E44408B6A0677 = {isa = XCConfigurationList; buildConfigurations = (9773A4B6329B4CD2AA4CA51D, C101F6805BCD491CBA3F987D); defaultConfigurationName = Release; };
EB832D1A0EA94AE69A6F2B98 = {isa = XCConfigurationList; buildConfigurations = (D8F8880914704BA9B087EE7B, 2627164A4CA343B2A4C15A2E); defaultConfigurationName = Release; };
8683854CF3914406BD918C9D = {isa = XCConfigurationList; buildConfigurations = (7BD2F8EE33E8434AB17CE373, 68B5EA19B4E24E5D968BF94C); defaultConfigurationName = Release; };
ACA5F32E253C473B9AD5D069 = {isa = PBXNativeTarget; buildConfigurationList = 6ED2FEB21E7E44408B6A0677; buildPhases = (75685F0D94844D25990466AB, ECE339972AA84978AE88BB46, E2036A7DDA8F4F7491399F71); buildRules = (); dependencies = (); name = MatchLiveTv; packageProductDependencies = (0B182AE2B64440FBAD820078, F0E1D34F0B3545A99C3EF141); productName = MatchLiveTv; productReference = A42C110EE8DB4ECC893951AD; productType = "com.apple.product-type.application"; };
8BE99E481DB24808AD87CDAA = {isa = PBXContainerItemProxy; containerPortal = 715925D32BC14CF8A5A9128B /* Project object */; proxyType = 1; remoteGlobalIDString = ACA5F32E253C473B9AD5D069; remoteInfo = MatchLiveTv; };
056ABE5A2CDA4D3E88485D37 = {isa = PBXTargetDependency; target = ACA5F32E253C473B9AD5D069 /* MatchLiveTv */; targetProxy = 8BE99E481DB24808AD87CDAA /* PBXContainerItemProxy */; };
A5EE823E98D34E7CB20F3F9E = {isa = PBXNativeTarget; buildConfigurationList = EB832D1A0EA94AE69A6F2B98; buildPhases = (BC137E765FCA41FB85A944F8, 1C9A61B330DB4A6AA59CFEF4); buildRules = (); dependencies = (056ABE5A2CDA4D3E88485D37); name = MatchLiveTvTests; productName = MatchLiveTvTests; productReference = CA19230D3F7D4C6A95406F87; productType = "com.apple.product-type.bundle.unit-test"; };
F80AB1FECA6A40798C87634A = {isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/shogo4405/HaishinKit.swift"; requirement = {kind = upToNextMajorVersion; minimumVersion = 2.0.0;}; };
715925D32BC14CF8A5A9128B = {isa = PBXProject; attributes = {BuildIndependentTargetsInParallel = 0; LastSwiftUpdateCheck = 1600;}; buildConfigurationList = 8683854CF3914406BD918C9D; compatibilityVersion = "Xcode 15.0"; developmentRegion = it; mainGroup = 7AE60FABDB154D8286A2902F; packageReferences = (F80AB1FECA6A40798C87634A); productRefGroup = CECA644368B34BD1AF81B7D4; targets = (ACA5F32E253C473B9AD5D069, A5EE823E98D34E7CB20F3F9E); };
};
rootObject = 715925D32BC14CF8A5A9128B /* Project object */;
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace version="1.0">
<FileRef location="self:"></FileRef>
</Workspace>

View File

@@ -0,0 +1,24 @@
{
"originHash" : "a02bd664b99b04b792f4c4ac4642c59cbac3f1660c60b2b1b9ca68761e3a17f7",
"pins" : [
{
"identity" : "haishinkit.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/shogo4405/HaishinKit.swift",
"state" : {
"revision" : "dc880cb540b8feeb98f64e8b7dcfaaf320b6b2bd",
"version" : "2.2.5"
}
},
{
"identity" : "logboard",
"kind" : "remoteSourceControl",
"location" : "https://github.com/shogo4405/Logboard.git",
"state" : {
"revision" : "8f41c63afb903040b77049ee2efa8c257b8c0d50",
"version" : "2.6.0"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="1600" version="1.7">
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
<BuildActionEntries>
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="ACA5F32E253C473B9AD5D069" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildActionEntry>
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5EE823E98D34E7CB20F3F9E" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
<Testables>
<TestableReference skipped="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5EE823E98D34E7CB20F3F9E" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="ACA5F32E253C473B9AD5D069" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="ACA5F32E253C473B9AD5D069" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration="Debug"/>
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
</Scheme>

View File

@@ -0,0 +1,14 @@
import SwiftUI
@main
struct MatchLiveTvApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
MatchLiveTheme {
AppNavHost()
}
}
}
}

View File

@@ -0,0 +1,61 @@
import Foundation
enum ApiInstant {
static func parse(_ raw: String?) -> Date? {
let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !value.isEmpty else { return nil }
if let date = formatter.date(from: value) ?? fallbackFormatter.date(from: value) {
return date
}
let normalized = value.replacingOccurrences(of: " ", with: "T")
if normalized != value {
return formatter.date(from: normalized) ?? fallbackFormatter.date(from: normalized)
}
return ISO8601DateFormatter().date(from: value)
}
static func isScheduledFuture(_ raw: String?, now: Date = Date()) -> Bool {
guard let date = parse(raw) else { return false }
return date > now
}
static func formatMatchDate(_ raw: String?) -> String? {
guard let date = parse(raw) else { return nil }
let f = DateFormatter()
f.locale = Locale(identifier: "it_IT")
f.dateFormat = "EEE d MMM yyyy · HH:mm"
return f.string(from: date)
}
private static let formatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static let fallbackFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
static let decoder: JSONDecoder = {
let d = JSONDecoder()
d.keyDecodingStrategy = .convertFromSnakeCase
d.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let value = try container.decode(String.self)
if let date = formatter.date(from: value) ?? fallbackFormatter.date(from: value) {
return date
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(value)")
}
return d
}()
static let encoder: JSONEncoder = {
let e = JSONEncoder()
e.keyEncodingStrategy = .convertToSnakeCase
return e
}()
}

View File

@@ -0,0 +1,23 @@
import Foundation
enum AppConfig {
static var apiBaseUrl: String {
let raw = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String
?? "https://www.matchlivetv.it"
return raw.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
}
static var apiV1: String { "\(apiBaseUrl)/api/v1" }
static var cableUrl: String {
guard let url = URL(string: apiBaseUrl),
let host = url.host else {
return "wss://www.matchlivetv.it/cable"
}
let wsScheme = url.scheme == "https" ? "wss" : "ws"
if let port = url.port, port != 80 && port != 443 {
return "\(wsScheme)://\(host):\(port)/cable"
}
return "\(wsScheme)://\(host)/cable"
}
}

View File

@@ -0,0 +1,58 @@
import SwiftUI
import UIKit
enum ColorHex {
static func normalizeHexColor(_ input: String?, fallback: String = "#E53935") -> String {
guard let input, !input.isEmpty else { return fallback }
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
let pattern = #"^#?[0-9A-Fa-f]{6}$"#
if trimmed.range(of: pattern, options: .regularExpression) != nil {
return trimmed.hasPrefix("#") ? trimmed : "#\(trimmed)"
}
return fallback
}
static func parseColorHex(_ hex: String?, fallback: UIColor = UIColor(red: 1, green: 0.18, blue: 0.18, alpha: 1)) -> UIColor {
let normalized = normalizeHexColor(hex, fallback: "#FF2D2D")
let stripped = normalized.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
guard stripped.count == 6, let value = Int(stripped, radix: 16) else { return fallback }
let r = CGFloat((value >> 16) & 0xFF) / 255
let g = CGFloat((value >> 8) & 0xFF) / 255
let b = CGFloat(value & 0xFF) / 255
return UIColor(red: r, green: g, blue: b, alpha: 1)
}
static func swiftUIColor(_ hex: String?, fallback: Color = MatchColors.primaryRed) -> Color {
let ui = parseColorHex(hex, fallback: UIColor(fallback))
return Color(ui)
}
static func colorToHex(_ color: UIColor) -> String {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
color.getRed(&r, green: &g, blue: &b, alpha: &a)
return String(format: "#%02X%02X%02X", Int(r * 255), Int(g * 255), Int(b * 255))
}
static func placeholderTeamColor(seed: String) -> String {
let palette = ["#FF2D2D", "#1E3A8A", "#22C55E", "#F5C518", "#8B5CF6", "#EC4899", "#06B6D4", "#F97316"]
let hash = abs(seed.hashValue)
return palette[hash % palette.count]
}
static func hsvComponents(from hex: String) -> (hue: Double, saturation: Double, brightness: Double) {
let ui = parseColorHex(hex)
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
ui.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
return (Double(h * 360), Double(s), Double(b))
}
static func hsvToHex(hue: Double, saturation: Double, brightness: Double) -> String {
let color = UIColor(
hue: CGFloat(hue / 360),
saturation: CGFloat(saturation),
brightness: CGFloat(brightness),
alpha: 1
)
return colorToHex(color)
}
}

View File

@@ -0,0 +1,58 @@
import Foundation
import UIKit
import Network
struct DeviceHealth: Sendable {
let batteryPercent: Int
let batteryTempC: Double?
let thermalLevel: Int
let thermalLabel: String
let networkType: String
}
enum DeviceTelemetry {
static func snapshot() -> DeviceHealth {
UIDevice.current.isBatteryMonitoringEnabled = true
let level = Int((UIDevice.current.batteryLevel >= 0 ? UIDevice.current.batteryLevel : 1) * 100)
let thermal = ProcessInfo.processInfo.thermalState
let (thermalLevel, label) = thermalInfo(thermal)
return DeviceHealth(
batteryPercent: level,
batteryTempC: nil,
thermalLevel: thermalLevel,
thermalLabel: label,
networkType: currentNetworkType()
)
}
private static func thermalInfo(_ state: ProcessInfo.ThermalState) -> (Int, String) {
switch state {
case .nominal: return (0, "OK")
case .fair: return (1, "Caldo")
case .serious: return (2, "Surriscaldamento")
case .critical: return (3, "Troppo caldo")
@unknown default: return (0, "OK")
}
}
private static func currentNetworkType() -> String {
let monitor = NWPathMonitor()
let semaphore = DispatchSemaphore(value: 0)
var result = "Sconosciuto"
monitor.pathUpdateHandler = { path in
if path.usesInterfaceType(.wifi) {
result = "WiFi"
} else if path.usesInterfaceType(.cellular) {
result = "4G"
} else if path.usesInterfaceType(.wiredEthernet) {
result = "Ethernet"
}
semaphore.signal()
}
let queue = DispatchQueue(label: "network.type")
monitor.start(queue: queue)
_ = semaphore.wait(timeout: .now() + 0.5)
monitor.cancel()
return result
}
}

View File

@@ -0,0 +1,11 @@
import Foundation
enum MediaUrl {
static func resolve(_ path: String?) -> String? {
guard let path, !path.isEmpty else { return nil }
if path.hasPrefix("http://") || path.hasPrefix("https://") { return path }
let base = AppConfig.apiBaseUrl
if path.hasPrefix("/") { return base + path }
return "\(base)/\(path)"
}
}

View File

@@ -0,0 +1,111 @@
import Foundation
import Security
struct StoredSession: Equatable, Sendable {
let user: User
let accessToken: String
let refreshToken: String
}
@MainActor
final class TokenStore: ObservableObject {
@Published private(set) var session: StoredSession?
@Published private(set) var activeTeamId: String?
private let defaults = UserDefaults.standard
private let service = "com.matchlivetv.match-live-tv.auth"
init() {
session = loadSession()
activeTeamId = defaults.string(forKey: Keys.activeTeamId)
}
func saveSession(_ session: StoredSession) {
self.session = session
saveKeychain(key: Keys.accessToken, value: session.accessToken)
saveKeychain(key: Keys.refreshToken, value: session.refreshToken)
defaults.set(session.user.id, forKey: Keys.userId)
defaults.set(session.user.email, forKey: Keys.userEmail)
defaults.set(session.user.name, forKey: Keys.userName)
defaults.set(session.user.role, forKey: Keys.userRole)
}
func saveActiveTeamId(_ teamId: String) {
activeTeamId = teamId
defaults.set(teamId, forKey: Keys.activeTeamId)
}
func clear() {
session = nil
activeTeamId = nil
deleteKeychain(key: Keys.accessToken)
deleteKeychain(key: Keys.refreshToken)
defaults.removeObject(forKey: Keys.userId)
defaults.removeObject(forKey: Keys.userEmail)
defaults.removeObject(forKey: Keys.userName)
defaults.removeObject(forKey: Keys.userRole)
defaults.removeObject(forKey: Keys.activeTeamId)
}
private func loadSession() -> StoredSession? {
guard let access = loadKeychain(key: Keys.accessToken),
let refresh = loadKeychain(key: Keys.refreshToken),
let id = defaults.string(forKey: Keys.userId) else { return nil }
return StoredSession(
user: User(
id: id,
email: defaults.string(forKey: Keys.userEmail) ?? "",
name: defaults.string(forKey: Keys.userName) ?? "",
role: defaults.string(forKey: Keys.userRole) ?? "coach"
),
accessToken: access,
refreshToken: refresh
)
}
private enum Keys {
static let accessToken = "access_token"
static let refreshToken = "refresh_token"
static let userId = "user_id"
static let userEmail = "user_email"
static let userName = "user_name"
static let userRole = "user_role"
static let activeTeamId = "active_team_id"
}
private func saveKeychain(key: String, value: String) {
let data = Data(value.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
var add = query
add[kSecValueData as String] = data
SecItemAdd(add as CFDictionary, nil)
}
private func loadKeychain(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
private func deleteKeychain(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
enum UserFacingError {
static func isCancellation(_ error: Error) -> Bool {
if error is CancellationError { return true }
if let urlError = error as? URLError, urlError.code == .cancelled { return true }
let ns = error as NSError
return ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled
}
static func message(for error: Error, fallback: String = "Operazione non riuscita") -> String? {
if isCancellation(error) { return nil }
if let api = error as? LocalizedError, let description = api.errorDescription, !description.isEmpty {
return description
}
let text = error.localizedDescription
return text.isEmpty ? fallback : text
}
}

View File

@@ -0,0 +1,475 @@
import Foundation
struct LoginRequest: Encodable {
let email: String
let password: String
}
struct RefreshRequest: Encodable {
let refreshToken: String
}
struct LoginResponse: Decodable {
let user: UserDto
let accessToken: String
let refreshToken: String
func toDomain() -> (User, AuthTokens) {
(user.toDomain(), AuthTokens(accessToken: accessToken, refreshToken: refreshToken))
}
}
struct UserDto: Decodable {
let id: String
let email: String
let name: String
let role: String?
func toDomain() -> User {
User(id: id, email: email, name: name, role: role ?? "coach")
}
}
struct SportDto: Decodable {
let key: String
let label: String
let board: String
let overlay: String
let allowedOverlays: [String]?
let defaultRules: [String: Int]?
func toDomain() -> SportOption {
SportOption(
key: key,
label: label,
board: board,
overlay: overlay,
allowedOverlays: allowedOverlays ?? [],
defaultRules: defaultRules ?? [:]
)
}
}
struct TeamDto: Decodable {
let id: String
let name: String
let sport: String?
let sportKey: String?
let boardType: String?
let canStream: Bool?
let clubName: String?
let youtubeEnabled: Bool?
let youtubeConnected: Bool?
let youtubeSelectable: Bool?
let youtubeChannelTitle: String?
let youtubeUsesPlatformChannel: Bool?
let youtubeTeamChannelTitle: String?
let planName: String?
let recordingsEnabled: Bool?
let phoneDownloadEnabled: Bool?
let billingUrl: String?
let staffManageUrl: String?
let logoUrl: String?
let primaryColor: String?
let secondaryColor: String?
func toDomain() -> Team {
Team(
id: id,
name: name,
sport: sportKey ?? sport ?? "pallavolo",
sportKey: sportKey ?? sport ?? "pallavolo",
boardType: boardType ?? "volley",
clubName: clubName,
logoUrl: logoUrl,
primaryColor: primaryColor,
secondaryColor: secondaryColor,
canStream: canStream ?? true,
youtubeEnabled: youtubeEnabled ?? false,
youtubeConnected: youtubeConnected ?? false,
youtubeSelectable: youtubeSelectable ?? false,
youtubeChannelTitle: youtubeChannelTitle,
youtubeUsesPlatformChannel: youtubeUsesPlatformChannel ?? false,
youtubeTeamChannelTitle: youtubeTeamChannelTitle,
planName: planName,
recordingsEnabled: recordingsEnabled ?? false,
phoneDownloadEnabled: phoneDownloadEnabled ?? false,
billingUrl: billingUrl,
staffManageUrl: staffManageUrl
)
}
}
struct MatchDto: Decodable {
let id: String
let teamId: String
let teamName: String?
let opponentName: String
let location: String?
let scheduledAt: String?
let setsToWin: Int?
let sportKey: String?
let sport: String?
let sportLabel: String?
let boardType: String?
let overlayKind: String?
let effectiveOverlayKind: String?
let category: String?
let scoringRules: ScoringRulesBody?
let activeSessionId: String?
let activeSessionStatus: String?
let streamCompleted: Bool?
let coachHubVisible: Bool?
let homePrimaryColor: String?
let homeSecondaryColor: String?
let homeLogoUrl: String?
let opponentPrimaryColor: String?
let opponentLogoUrl: String?
func toDomain() -> Match {
Match(
id: id,
teamId: teamId,
teamName: teamName ?? "",
opponentName: opponentName,
location: location,
scheduledAt: scheduledAt,
sportKey: sportKey ?? sport ?? "pallavolo",
sportLabel: sportLabel,
boardType: boardType ?? "volley",
overlayKind: overlayKind,
effectiveOverlayKind: effectiveOverlayKind ?? overlayKind ?? "volley",
setsToWin: setsToWin ?? 3,
category: category,
scoringRules: scoringRules?.toDomain(),
activeSessionId: activeSessionId,
activeSessionStatus: activeSessionStatus,
streamCompleted: streamCompleted ?? false,
coachHubVisible: coachHubVisible,
homePrimaryColor: homePrimaryColor,
homeSecondaryColor: homeSecondaryColor,
homeLogoUrl: homeLogoUrl,
opponentPrimaryColor: opponentPrimaryColor,
opponentLogoUrl: opponentLogoUrl
)
}
}
struct StreamSessionDto: Decodable {
let id: String
let matchId: String
let status: String?
let platform: String?
let rtmpIngestUrl: String?
let hlsPlaybackUrl: String?
let watchPageUrl: String?
let shareUrl: String?
let youtubeWatchUrl: String?
let youtubeReady: Bool?
let privacyStatus: String?
let targetBitrate: Int?
let targetFps: Int?
let startedAt: String?
let score: ScoreStateDto?
func toDomain() -> StreamSession {
StreamSession(
id: id,
matchId: matchId,
status: status ?? "idle",
platform: platform ?? "matchlivetv",
rtmpIngestUrl: rtmpIngestUrl,
hlsPlaybackUrl: hlsPlaybackUrl,
watchPageUrl: watchPageUrl,
shareUrl: shareUrl,
youtubeWatchUrl: youtubeWatchUrl,
youtubeReady: youtubeReady ?? false,
privacyStatus: privacyStatus ?? "public",
targetBitrate: targetBitrate ?? 2_500_000,
targetFps: targetFps ?? 30,
startedAt: startedAt,
score: score?.toDomain()
)
}
}
struct SetPartialDto: Codable {
let set: Int?
let home: Int?
let away: Int?
}
/// Decodifica tollerante per campi JSONB (`score_states.data`).
private enum FlexibleJSON: Decodable {
case int(Int)
case bool(Bool)
case string(String)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let v = try? container.decode(Bool.self) { self = .bool(v); return }
if let v = try? container.decode(Int.self) { self = .int(v); return }
if let v = try? container.decode(String.self) { self = .string(v); return }
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
}
var intValue: Int? {
switch self {
case .int(let v): return v
case .string(let s): return Int(s)
case .bool(let v): return v ? 1 : 0
}
}
var boolValue: Bool? {
switch self {
case .bool(let v): return v
case .int(let v): return v != 0
case .string(let s): return (s as NSString).boolValue
}
}
}
struct ScoreStateDto: Decodable {
let boardType: String?
let homeSets: Int?
let awaySets: Int?
let homePoints: Int?
let awayPoints: Int?
let currentSet: Int?
let setPartials: [SetPartialDto]?
let timeoutHome: Bool?
let timeoutAway: Bool?
let period: String?
let clockSecs: Int?
let clockRunning: Bool?
enum CodingKeys: String, CodingKey {
case boardType = "board_type"
case homeSets = "home_sets"
case awaySets = "away_sets"
case homePoints = "home_points"
case awayPoints = "away_points"
case currentSet = "current_set"
case setPartials = "set_partials"
case timeoutHome = "timeout_home"
case timeoutAway = "timeout_away"
case period
case clockSecs = "clock_secs"
case clockRunning = "clock_running"
case data
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
boardType = try c.decodeIfPresent(String.self, forKey: .boardType)
homeSets = Self.decodeInt(c, key: .homeSets)
awaySets = Self.decodeInt(c, key: .awaySets)
homePoints = Self.decodeInt(c, key: .homePoints)
awayPoints = Self.decodeInt(c, key: .awayPoints)
currentSet = Self.decodeInt(c, key: .currentSet)
setPartials = try c.decodeIfPresent([SetPartialDto].self, forKey: .setPartials)
timeoutHome = Self.decodeBool(c, key: .timeoutHome)
timeoutAway = Self.decodeBool(c, key: .timeoutAway)
period = Self.decodeString(c, key: .period)
nestedData = try c.decodeIfPresent([String: FlexibleJSON].self, forKey: .data)
clockSecs = Self.decodeInt(c, key: .clockSecs) ?? nestedData?["clock_secs"]?.intValue
clockRunning = Self.decodeBool(c, key: .clockRunning) ?? nestedData?["clock_running"]?.boolValue
}
private let nestedData: [String: FlexibleJSON]?
private static func decodeInt(_ c: KeyedDecodingContainer<CodingKeys>, key: CodingKeys) -> Int? {
if let v = try? c.decodeIfPresent(Int.self, forKey: key) { return v }
if let s = try? c.decodeIfPresent(String.self, forKey: key), let v = Int(s) { return v }
return nil
}
private static func decodeBool(_ c: KeyedDecodingContainer<CodingKeys>, key: CodingKeys) -> Bool? {
if let v = try? c.decodeIfPresent(Bool.self, forKey: key) { return v }
if let i = try? c.decodeIfPresent(Int.self, forKey: key) { return i != 0 }
return nil
}
private static func decodeString(_ c: KeyedDecodingContainer<CodingKeys>, key: CodingKeys) -> String? {
if let s = try? c.decodeIfPresent(String.self, forKey: key) { return s }
if let i = try? c.decodeIfPresent(Int.self, forKey: key) { return String(i) }
return nil
}
func toDomain() -> ScoreState {
let board = boardType ?? "volley"
let homePts = homePoints ?? nestedData?["home_score"]?.intValue ?? 0
let awayPts = awayPoints ?? nestedData?["away_score"]?.intValue ?? 0
let overtime = nestedData?["overtime"]?.boolValue ?? false
return ScoreState(
homeSets: homeSets ?? 0,
awaySets: awaySets ?? 0,
homePoints: homePts,
awayPoints: awayPts,
currentSet: max(currentSet ?? 1, 1),
setPartials: (setPartials ?? []).map {
SetPartial(set: $0.set ?? 1, home: $0.home ?? 0, away: $0.away ?? 0)
},
timeoutHome: timeoutHome ?? false,
timeoutAway: timeoutAway ?? false,
boardType: board,
period: parsePeriodNumber(period, currentSet: currentSet),
periodLabel: period,
clockSecs: clockSecs ?? 0,
clockRunning: clockRunning ?? false,
overtime: overtime
)
}
private func parsePeriodNumber(_ label: String?, currentSet: Int?) -> Int {
if let text = label {
if let m = text.range(of: #"Q(\d+)"#, options: .regularExpression) {
let num = text[m].dropFirst()
if let v = Int(num) { return v }
}
if let v = Int(text), v > 0 { return v }
}
return max(currentSet ?? 1, 1)
}
}
struct ScoreActionRequest: Encodable {
let scoreAction: String
}
struct ScoreSyncRequest: Encodable {
let homeSets: Int
let awaySets: Int
let homePoints: Int
let awayPoints: Int
let currentSet: Int
let setPartials: [SetPartialDto]
let clockSecs: Int?
let clockRunning: Bool?
let period: Int?
let homeScore: Int?
let awayScore: Int?
}
struct CreateSessionRequest: Encodable {
let platform: String
let privacyStatus: String
let qualityPreset: String
let targetBitrate: Int
let targetFps: Int
let youtubeChannel: String?
}
struct ScoringRulesBody: Codable {
let setsToWin: Int?
let pointsPerSet: Int?
let pointsDecidingSet: Int?
let minPointLead: Int?
let periods: Int?
let periodDurationSecs: Int?
let overtimeDurationSecs: Int?
func toDomain() -> ScoringRules {
ScoringRules(
pointsPerSet: pointsPerSet ?? 25,
pointsDecidingSet: pointsDecidingSet ?? 15,
minPointLead: minPointLead ?? 2,
periods: periods ?? 4,
periodDurationSecs: periodDurationSecs ?? 600,
overtimeDurationSecs: overtimeDurationSecs ?? 300
)
}
}
struct UpdateMatchBodyFull: Encodable {
let opponentName: String
let location: String?
let scheduledAt: String?
let setsToWin: Int
let sportKey: String?
let overlayKind: String?
let category: String?
let opponentPrimaryColor: String?
let scoringRules: [String: Int]?
}
struct UpdateMatchRequestFull: Encodable {
let match: UpdateMatchBodyFull
}
struct UpdateTeamBody: Encodable {
let primaryColor: String?
let secondaryColor: String?
}
struct UpdateTeamRequest: Encodable {
let team: UpdateTeamBody
}
struct NetworkTestResponse: Decodable {
let ready: Bool
let targetUploadMbps: Double?
let qualityPreset: String?
let targetBitrate: Int?
let targetFps: Int?
}
struct RegiaLinkResponse: Decodable {
let regiaUrl: String
}
struct CreateMatchBody: Encodable {
let opponentName: String
let sport: String
let setsToWin: Int
let scheduledAt: String?
let location: String?
}
struct CreateMatchRequest: Encodable {
let match: CreateMatchBody
}
struct NetworkTestRequest: Encodable {
let downloadMbps: Double
let uploadMbps: Double
let latencyMs: Int
let networkType: String
}
struct TelemetryRequest: Encodable {
let deviceRole: String
let batteryLevel: Int?
let networkType: String?
let signalStrength: Int?
let currentBitrate: Int?
let targetBitrate: Int?
let fps: Int?
}
extension ScoringRules {
func toBody(setsToWin: Int? = nil) -> ScoringRulesBody {
ScoringRulesBody(
setsToWin: setsToWin,
pointsPerSet: pointsPerSet,
pointsDecidingSet: pointsDecidingSet,
minPointLead: minPointLead,
periods: periods,
periodDurationSecs: periodDurationSecs,
overtimeDurationSecs: overtimeDurationSecs
)
}
func toMap(setsToWin: Int? = nil) -> [String: Int] {
var map: [String: Int] = [:]
if let setsToWin { map["sets_to_win"] = setsToWin }
map["points_per_set"] = pointsPerSet
map["points_deciding_set"] = pointsDecidingSet
map["min_point_lead"] = minPointLead
map["periods"] = periods
map["period_duration_secs"] = periodDurationSecs
map["overtime_duration_secs"] = overtimeDurationSecs
return map
}
}

View File

@@ -0,0 +1,289 @@
import Foundation
enum APIError: LocalizedError {
case invalidURL
case http(Int, String?)
case decoding(Error)
case unauthorized
var errorDescription: String? {
switch self {
case .invalidURL: return "URL non valido"
case .http(let code, let body): return Self.friendlyHttpMessage(code: code, body: body)
case .decoding: return "Risposta del server non valida"
case .unauthorized: return "Sessione scaduta. Accedi di nuovo."
}
}
private static func friendlyHttpMessage(code: Int, body: String?) -> String {
if let body, !body.isEmpty,
let data = body.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let message = json["message"] as? String, !message.isEmpty {
return message
}
if let error = json["error"] as? String, !error.isEmpty, !error.hasPrefix("{") {
if code >= 500 { return "Errore del server. Riprova tra qualche istante." }
return error
}
}
if let body, !body.isEmpty, !body.hasPrefix("{") {
return body
}
switch code {
case 500, 502, 503: return "Errore del server. Riprova tra qualche istante."
case 404: return "Risorsa non trovata"
case 403: return "Operazione non consentita"
default: return "Errore HTTP \(code)"
}
}
}
final class MatchLiveAPI: @unchecked Sendable {
private let session: URLSession
private var accessToken: String?
private let baseURL: URL
init(baseURL: URL = URL(string: AppConfig.apiV1)!, session: URLSession = .shared) {
self.baseURL = baseURL
self.session = session
}
func setAccessToken(_ token: String?) {
accessToken = token
}
// MARK: - Auth
func login(email: String, password: String) async throws -> LoginResponse {
try await post("auth/login", body: LoginRequest(email: email, password: password))
}
func refresh(refreshToken: String) async throws -> LoginResponse {
try await post("auth/refresh", body: RefreshRequest(refreshToken: refreshToken))
}
func me() async throws -> UserDto {
try await get("auth/me")
}
func logout() async throws {
try await postVoid("auth/logout")
}
// MARK: - Sports & Teams
func sports() async throws -> [SportDto] {
try await get("sports")
}
func teams() async throws -> [TeamDto] {
try await get("teams")
}
func team(id: String) async throws -> TeamDto {
try await get("teams/\(id)")
}
func updateTeam(id: String, body: UpdateTeamRequest) async throws -> TeamDto {
try await patch("teams/\(id)", body: body)
}
func updateTeamMultipart(
id: String,
primaryColor: String?,
secondaryColor: String?,
logoData: Data?,
logoFilename: String?
) async throws -> TeamDto {
var fields: [MultipartField] = []
if let primaryColor { fields.append(.text("team[primary_color]", primaryColor)) }
if let secondaryColor { fields.append(.text("team[secondary_color]", secondaryColor)) }
if let logoData, let logoFilename {
fields.append(.file("logo_file", logoData, filename: logoFilename, mime: "image/png"))
}
return try await multipartPatch("teams/\(id)", fields: fields)
}
// MARK: - Matches
func matches(teamId: String) async throws -> [MatchDto] {
try await get("teams/\(teamId)/matches")
}
func createMatch(teamId: String, body: CreateMatchRequest) async throws -> MatchDto {
try await post("teams/\(teamId)/matches", body: body)
}
func match(id: String) async throws -> MatchDto {
try await get("matches/\(id)")
}
func updateMatch(id: String, body: UpdateMatchRequestFull) async throws -> MatchDto {
try await patch("matches/\(id)", body: body)
}
func updateMatchMultipart(id: String, fields: [MultipartField]) async throws -> MatchDto {
try await multipartPatch("matches/\(id)", fields: fields)
}
func deleteMatch(id: String) async throws {
try await delete("matches/\(id)")
}
// MARK: - Sessions
func createSession(matchId: String, body: CreateSessionRequest) async throws -> StreamSessionDto {
try await post("matches/\(matchId)/sessions", body: body)
}
func session(id: String) async throws -> StreamSessionDto {
try await get("sessions/\(id)")
}
func startSession(id: String) async throws -> StreamSessionDto {
try await patch("sessions/\(id)/start", body: EmptyBody())
}
func stopSession(id: String) async throws -> StreamSessionDto {
try await patch("sessions/\(id)/stop", body: EmptyBody())
}
func pauseSession(id: String) async throws -> StreamSessionDto {
try await patch("sessions/\(id)/pause", body: EmptyBody())
}
func resumeSession(id: String) async throws -> StreamSessionDto {
try await patch("sessions/\(id)/resume", body: EmptyBody())
}
func networkTest(sessionId: String, body: NetworkTestRequest) async throws -> NetworkTestResponse {
try await post("sessions/\(sessionId)/network_test", body: body)
}
func regiaLink(sessionId: String) async throws -> RegiaLinkResponse {
try await post("sessions/\(sessionId)/regia_link", body: EmptyBody())
}
func syncScore(sessionId: String, body: ScoreSyncRequest) async throws -> StreamSessionDto {
try await patch("sessions/\(sessionId)/score", body: body)
}
func applyScoreAction(sessionId: String, body: ScoreActionRequest) async throws -> StreamSessionDto {
try await post("sessions/\(sessionId)/score_action", body: body)
}
func postTelemetry(sessionId: String, body: TelemetryRequest) async throws {
try await postVoid("sessions/\(sessionId)/telemetry", body: body)
}
// MARK: - HTTP helpers
private struct EmptyBody: Encodable {}
private func get<T: Decodable>(_ path: String) async throws -> T {
try await request(path, method: "GET")
}
private func post<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
try await request(path, method: "POST", body: body)
}
private func postVoid<B: Encodable>(_ path: String, body: B = EmptyBody()) async throws {
let _: EmptyResponse = try await request(path, method: "POST", body: body)
}
private func postVoid(_ path: String) async throws {
let _: EmptyResponse = try await request(path, method: "POST", body: EmptyBody())
}
private func patch<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
try await request(path, method: "PATCH", body: body)
}
private func delete(_ path: String) async throws {
let _: EmptyResponse = try await request(path, method: "DELETE")
}
private func multipartPatch<T: Decodable>(_ path: String, fields: [MultipartField]) async throws -> T {
let boundary = "Boundary-\(UUID().uuidString)"
var request = URLRequest(url: baseURL.appendingPathComponent(path))
request.httpMethod = "PATCH"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") }
request.httpBody = MultipartBuilder.build(fields: fields, boundary: boundary)
return try await execute(request)
}
private func request<T: Decodable, B: Encodable>(
_ path: String,
method: String,
body: B? = nil as EmptyBody?
) async throws -> T {
var request = URLRequest(url: baseURL.appendingPathComponent(path))
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Accept")
if body != nil {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try ApiInstant.encoder.encode(body)
}
if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") }
return try await execute(request)
}
private func execute<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidURL }
if http.statusCode == 401 { throw APIError.unauthorized }
guard (200..<300).contains(http.statusCode) else {
let body = String(data: data, encoding: .utf8)
throw APIError.http(http.statusCode, body)
}
if T.self == EmptyResponse.self {
return EmptyResponse() as! T
}
do {
return try ApiInstant.decoder.decode(T.self, from: data)
} catch {
throw APIError.decoding(error)
}
}
}
private struct EmptyResponse: Decodable {}
enum MultipartField {
case text(String, String)
case file(String, Data, filename: String, mime: String)
}
enum MultipartBuilder {
static func build(fields: [MultipartField], boundary: String) -> Data {
var data = Data()
let crlf = "\r\n"
for field in fields {
switch field {
case .text(let name, let value):
data.append("--\(boundary)\(crlf)")
data.append("Content-Disposition: form-data; name=\"\(name)\"\(crlf)\(crlf)")
data.append(value)
data.append(crlf)
case .file(let name, let fileData, let filename, let mime):
data.append("--\(boundary)\(crlf)")
data.append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\(crlf)")
data.append("Content-Type: \(mime)\(crlf)\(crlf)")
data.append(fileData)
data.append(crlf)
}
}
data.append("--\(boundary)--\(crlf)")
return data
}
}
private extension Data {
mutating func append(_ string: String) {
if let d = string.data(using: .utf8) { append(d) }
}
}

View File

@@ -0,0 +1,37 @@
import Foundation
@MainActor
final class AppContainer: ObservableObject {
let tokenStore: TokenStore
let api: MatchLiveAPI
let authRepository: AuthRepository
let matchRepository: MatchRepository
let sessionRepository: SessionRepository
let scoreRepository: ScoreRepository
let sessionCable = SessionCableService()
let scoreController: ScoreController
let wizardSession = WizardSessionHolder()
let broadcastCoordinator = LiveBroadcastCoordinator()
init() {
let api = MatchLiveAPI()
let tokenStore = TokenStore()
self.api = api
self.tokenStore = tokenStore
authRepository = AuthRepository(api: api, tokenStore: tokenStore) { token in
api.setAccessToken(token)
OverlayLogoCache.configure(token: token)
}
matchRepository = MatchRepository(api: api, tokenStore: tokenStore)
sessionRepository = SessionRepository(api: api)
scoreRepository = ScoreRepository(api: api)
scoreController = ScoreController(scoreRepository: scoreRepository, sessionCable: sessionCable)
}
func bootstrapAuth() {
if let session = tokenStore.session {
api.setAccessToken(session.accessToken)
OverlayLogoCache.configure(token: session.accessToken)
}
}
}

View File

@@ -0,0 +1,134 @@
import Foundation
final class ActionCableClient: NSObject, URLSessionWebSocketDelegate, @unchecked Sendable {
protocol Listener: AnyObject {
func onConnected()
func onDisconnected()
func onChannelMessage(_ payload: [String: Any])
}
private let cableUrl: URL
private let accessToken: String
private var webSocket: URLSessionWebSocketTask?
private var channelIdentifier: String?
private weak var listener: Listener?
private let session: URLSession
init(cableUrl: String, accessToken: String) {
self.cableUrl = URL(string: cableUrl)!
self.accessToken = accessToken
let config = URLSessionConfiguration.default
self.session = URLSession(configuration: config)
super.init()
}
func connect(channel: String, params: [String: String], listener: Listener) {
disconnect()
self.listener = listener
var identifier: [String: Any] = ["channel": channel]
params.forEach { identifier[$0.key] = $0.value }
channelIdentifier = jsonString(identifier)
var request = URLRequest(url: cableUrl)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
webSocket = session.webSocketTask(with: request)
webSocket?.resume()
receiveLoop()
sendSubscribe()
}
func performReceive(payload: [String: Any]) {
guard let identifier = channelIdentifier else { return }
var data = payload
data["action"] = "receive"
let envelope: [String: Any] = [
"command": "message",
"identifier": identifier,
"data": jsonString(data) ?? "{}",
]
send(json: envelope)
}
func disconnect() {
webSocket?.cancel(with: .goingAway, reason: nil)
webSocket = nil
channelIdentifier = nil
listener?.onDisconnected()
}
private func sendSubscribe() {
guard let identifier = channelIdentifier else { return }
send(json: ["command": "subscribe", "identifier": identifier])
}
private func receiveLoop() {
webSocket?.receive { [weak self] result in
guard let self else { return }
switch result {
case .success(let message):
switch message {
case .string(let text):
self.handleIncoming(text)
case .data(let data):
if let text = String(data: data, encoding: .utf8) { self.handleIncoming(text) }
@unknown default:
break
}
self.receiveLoop()
case .failure:
self.listener?.onDisconnected()
}
}
}
private func handleIncoming(_ text: String) {
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
if let type = json["type"] as? String, type == "ping" {
let pong: [String: Any] = ["type": "pong", "message": json["message"] ?? Date().timeIntervalSince1970]
send(json: pong)
return
}
if json["type"] as? String == "confirm_subscription" {
listener?.onConnected()
return
}
if json["type"] as? String == "disconnect" {
listener?.onDisconnected()
return
}
if let message = json["message"] {
var payload: [String: Any]?
if let dict = message as? [String: Any] {
if let nested = dict["data"] as? [String: Any] {
payload = nested
} else if dict["method"] as? String == "receive_message",
let nested = dict["data"] as? [String: Any] {
payload = nested
} else {
payload = dict
}
} else if let str = message as? String,
let d = str.data(using: .utf8),
let dict = try? JSONSerialization.jsonObject(with: d) as? [String: Any] {
payload = dict
}
if let payload { listener?.onChannelMessage(payload) }
}
}
private func send(json: [String: Any]) {
guard let text = jsonString(json) else { return }
webSocket?.send(.string(text)) { _ in }
}
private func jsonString(_ object: Any) -> String? {
guard let data = try? JSONSerialization.data(withJSONObject: object),
let text = String(data: data, encoding: .utf8) else { return nil }
return text
}
}

View File

@@ -0,0 +1,107 @@
import Foundation
@MainActor
final class SessionCableService: ObservableObject {
@Published private(set) var connected = false
var onScoreUpdate: ((ScoreState) -> Void)?
var onPauseStream: (() -> Void)?
var onResumeStream: (() -> Void)?
var onStopStream: (() -> Void)?
private var client: ActionCableClient?
private var intentionalDisconnect = false
private var reconnectAttempt = 0
private var reconnectTask: Task<Void, Never>?
private var sessionId: String?
private var accessToken: String?
private var deviceRole = "camera"
func connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
self.sessionId = sessionId
self.accessToken = accessToken
self.deviceRole = deviceRole
intentionalDisconnect = false
reconnectAttempt = 0
openSocket()
}
func disconnect() {
intentionalDisconnect = true
reconnectTask?.cancel()
client?.disconnect()
client = nil
connected = false
}
func sendScoreUpdate(_ score: ScoreState) {
client?.performReceive(payload: score.cablePayload())
}
private func openSocket() {
guard let sessionId, let accessToken else { return }
let cable = ActionCableClient(cableUrl: AppConfig.cableUrl, accessToken: accessToken)
cable.connect(
channel: "SessionChannel",
params: ["session_id": sessionId, "device_role": deviceRole],
listener: self
)
client = cable
}
private func scheduleReconnect() {
guard !intentionalDisconnect else { return }
reconnectAttempt += 1
let delay = min(30.0, pow(2.0, Double(min(reconnectAttempt - 1, 4))))
reconnectTask?.cancel()
reconnectTask = Task {
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard !Task.isCancelled, !intentionalDisconnect else { return }
openSocket()
}
}
}
extension SessionCableService: ActionCableClient.Listener {
nonisolated func onConnected() {
Task { @MainActor in
connected = true
reconnectAttempt = 0
}
}
nonisolated func onDisconnected() {
Task { @MainActor in
connected = false
scheduleReconnect()
}
}
nonisolated func onChannelMessage(_ payload: [String: Any]) {
Task { @MainActor in
let type = payload["type"] as? String ?? ""
switch type {
case "score_update":
onScoreUpdate?(ScoreState.fromCablePayload(payload))
case "command":
let command = payload["command"] as? String ?? ""
switch command {
case "pause_stream": onPauseStream?()
case "resume_stream": onResumeStream?()
case "stop_stream": onStopStream?()
default: break
}
case "stream_event":
let event = payload["event"] as? String ?? ""
switch event {
case "paused": onPauseStream?()
case "resumed": onResumeStream?()
case "ended": onStopStream?()
default: break
}
default:
break
}
}
}
}

View File

@@ -0,0 +1,50 @@
import Foundation
@MainActor
final class AuthRepository {
private let api: MatchLiveAPI
private let tokenStore: TokenStore
private let onTokenChanged: (String?) -> Void
init(api: MatchLiveAPI, tokenStore: TokenStore, onTokenChanged: @escaping (String?) -> Void) {
self.api = api
self.tokenStore = tokenStore
self.onTokenChanged = onTokenChanged
}
var currentSession: StoredSession? { tokenStore.session }
func login(email: String, password: String) async throws -> StoredSession {
let response = try await api.login(email: email, password: password)
let (user, tokens) = response.toDomain()
let session = StoredSession(user: user, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken)
tokenStore.saveSession(session)
onTokenChanged(tokens.accessToken)
return session
}
func validateOrRefresh() async -> StoredSession? {
guard let stored = tokenStore.session else { return nil }
do {
_ = try await api.me()
return stored
} catch {
return try? await refresh(stored.refreshToken)
}
}
func refresh(_ refreshToken: String) async throws -> StoredSession {
let response = try await api.refresh(refreshToken: refreshToken)
let (user, tokens) = response.toDomain()
let session = StoredSession(user: user, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken)
tokenStore.saveSession(session)
onTokenChanged(tokens.accessToken)
return session
}
func logout() async {
try? await api.logout()
tokenStore.clear()
onTokenChanged(nil)
}
}

View File

@@ -0,0 +1,145 @@
import Foundation
import UIKit
@MainActor
final class MatchRepository {
private let api: MatchLiveAPI
private let tokenStore: TokenStore
init(api: MatchLiveAPI, tokenStore: TokenStore) {
self.api = api
self.tokenStore = tokenStore
}
func fetchSports() async throws -> [SportOption] {
try await api.sports().map { $0.toDomain() }
}
func fetchTeams() async throws -> [Team] {
try await api.teams().map { $0.toDomain() }
}
func fetchTeam(teamId: String) async -> Team? {
if let dto = try? await api.team(id: teamId) { return dto.toDomain() }
let teams = (try? await fetchTeams()) ?? []
return teams.first { $0.id == teamId }
}
func resolveActiveTeam(teams: [Team]) -> Team? {
guard !teams.isEmpty else { return nil }
let selected = teams.first { $0.id == tokenStore.activeTeamId } ?? teams[0]
tokenStore.saveActiveTeamId(selected.id)
return selected
}
func fetchMatch(matchId: String) async throws -> Match {
try await api.match(id: matchId).toDomain()
}
func fetchMatchesForTeam(teamId: String) async throws -> [Match] {
let matches = try await api.matches(teamId: teamId).map { $0.toDomain() }
return MatchHubFilter.sortMatches(matches.filter { MatchHubFilter.coachHubVisible($0) })
}
func fetchMatches() async throws -> [Match] {
let teams = try await fetchTeams()
guard let team = resolveActiveTeam(teams: teams) else { return [] }
return try await fetchMatchesForTeam(teamId: team.id)
}
func selectTeam(teamId: String) {
tokenStore.saveActiveTeamId(teamId)
}
func createQuickMatch(teamId: String) async throws -> Match {
try await api.createMatch(
teamId: teamId,
body: CreateMatchRequest(match: CreateMatchBody(opponentName: "Avversario", sport: "volleyball", setsToWin: 3, scheduledAt: nil, location: nil))
).toDomain()
}
func createScheduledMatch(teamId: String, opponentName: String, scheduledAt: Date, location: String?) async throws -> Match {
let iso = ISO8601DateFormatter().string(from: scheduledAt)
return try await api.createMatch(
teamId: teamId,
body: CreateMatchRequest(
match: CreateMatchBody(
opponentName: opponentName,
sport: "volleyball",
setsToWin: 3,
scheduledAt: iso,
location: location?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
)
)
).toDomain()
}
func updateTeamBranding(teamId: String, primaryColor: String, secondaryColor: String?, logoImage: UIImage?) async throws -> Team {
if let logoImage, let data = logoImage.pngData() {
return try await api.updateTeamMultipart(
id: teamId,
primaryColor: primaryColor,
secondaryColor: secondaryColor,
logoData: data,
logoFilename: "logo.png"
).toDomain()
}
return try await api.updateTeam(
id: teamId,
body: UpdateTeamRequest(team: UpdateTeamBody(primaryColor: primaryColor, secondaryColor: secondaryColor))
).toDomain()
}
func updateMatch(
matchId: String,
opponentName: String,
location: String?,
scheduledAt: String?,
setsToWin: Int,
category: String?,
opponentPrimaryColor: String?,
scoringRules: ScoringRules?,
sportKey: String? = nil,
overlayKind: String? = nil,
opponentLogoImage: UIImage? = nil
) async throws -> Match {
let rulesMap = scoringRules?.toMap(setsToWin: setsToWin)
let body = UpdateMatchBodyFull(
opponentName: opponentName,
location: location?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
scheduledAt: scheduledAt,
setsToWin: setsToWin,
sportKey: sportKey,
overlayKind: overlayKind,
category: category?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
opponentPrimaryColor: opponentPrimaryColor,
scoringRules: rulesMap
)
if let opponentLogoImage, let data = opponentLogoImage.pngData() {
var fields: [MultipartField] = [
.text("match[opponent_name]", opponentName),
.text("match[sets_to_win]", "\(setsToWin)"),
]
if let location = body.location { fields.append(.text("match[location]", location)) }
if let scheduledAt { fields.append(.text("match[scheduled_at]", scheduledAt)) }
if let category = body.category { fields.append(.text("match[category]", category)) }
if let color = opponentPrimaryColor { fields.append(.text("match[opponent_primary_color]", color)) }
if let rules = scoringRules {
fields.append(.text("match[scoring_rules][points_per_set]", "\(rules.pointsPerSet)"))
fields.append(.text("match[scoring_rules][points_deciding_set]", "\(rules.pointsDecidingSet)"))
fields.append(.text("match[scoring_rules][min_point_lead]", "\(rules.minPointLead)"))
}
fields.append(.file("opponent_logo_file", data, filename: "opponent.png", mime: "image/png"))
return try await api.updateMatchMultipart(id: matchId, fields: fields).toDomain()
}
return try await api.updateMatch(id: matchId, body: UpdateMatchRequestFull(match: body)).toDomain()
}
func deleteMatch(matchId: String) async throws {
try await api.deleteMatch(id: matchId)
}
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}

View File

@@ -0,0 +1,15 @@
import Foundation
@MainActor
enum MatchSessionLauncher {
static func resumeBroadcastSession(
match: Match,
sessionRepository: SessionRepository
) async throws -> String {
guard let sessionId = match.activeSessionId else {
throw APIError.http(400, "Nessuna sessione attiva")
}
_ = try await sessionRepository.fetchSession(id: sessionId)
return sessionId
}
}

View File

@@ -0,0 +1,49 @@
import Foundation
@MainActor
final class ScoreRepository {
private let api: MatchLiveAPI
private static let periodBoards: Set<String> = ["basket", "timed"]
init(api: MatchLiveAPI) {
self.api = api
}
func syncScore(sessionId: String, score: ScoreState) async throws -> StreamSession {
let partials = score.setPartials.map { SetPartialDto(set: $0.set, home: $0.home, away: $0.away) }
var request = ScoreSyncRequest(
homeSets: score.homeSets,
awaySets: score.awaySets,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
setPartials: partials,
clockSecs: nil,
clockRunning: nil,
period: nil,
homeScore: nil,
awayScore: nil
)
if Self.periodBoards.contains(score.boardType) {
request = ScoreSyncRequest(
homeSets: score.homeSets,
awaySets: score.awaySets,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
setPartials: partials,
clockSecs: score.clockSecs,
clockRunning: score.clockRunning,
period: score.period,
homeScore: score.homePoints,
awayScore: score.awayPoints
)
}
return try await api.syncScore(sessionId: sessionId, body: request).toDomain()
}
func applyScoreAction(sessionId: String, action: String) async throws -> ScoreState? {
let response = try await api.applyScoreAction(sessionId: sessionId, body: ScoreActionRequest(scoreAction: action))
return response.score?.toDomain()
}
}

View File

@@ -0,0 +1,95 @@
import Foundation
@MainActor
final class SessionRepository {
private let api: MatchLiveAPI
init(api: MatchLiveAPI) {
self.api = api
}
func createSession(
matchId: String,
platform: String = "matchlivetv",
privacyStatus: String = "public",
qualityPreset: String = "720p_30_2.5mbps",
targetBitrate: Int = 2_500_000,
targetFps: Int = 30,
youtubeChannel: String? = nil
) async throws -> StreamSession {
try await api.createSession(
matchId: matchId,
body: CreateSessionRequest(
platform: platform,
privacyStatus: privacyStatus,
qualityPreset: qualityPreset,
targetBitrate: targetBitrate,
targetFps: targetFps,
youtubeChannel: youtubeChannel
)
).toDomain()
}
func fetchSession(id: String) async throws -> StreamSession {
try await api.session(id: id).toDomain()
}
func startSession(id: String) async throws -> StreamSession {
try await api.startSession(id: id).toDomain()
}
func stopSession(id: String) async throws -> StreamSession {
try await api.stopSession(id: id).toDomain()
}
func pauseSession(id: String) async throws -> StreamSession {
try await api.pauseSession(id: id).toDomain()
}
func resumeSession(id: String) async throws -> StreamSession {
try await api.resumeSession(id: id).toDomain()
}
func submitNetworkTest(
sessionId: String,
downloadMbps: Double,
uploadMbps: Double,
latencyMs: Int,
networkType: String
) async throws -> NetworkTestResponse {
try await api.networkTest(
sessionId: sessionId,
body: NetworkTestRequest(
downloadMbps: downloadMbps,
uploadMbps: uploadMbps,
latencyMs: latencyMs,
networkType: networkType
)
)
}
func createRegiaLink(sessionId: String) async throws -> String {
try await api.regiaLink(sessionId: sessionId).regiaUrl
}
func postTelemetry(
sessionId: String,
health: DeviceHealth,
currentBitrate: Int?,
targetBitrate: Int?,
fps: Int?
) async throws {
try await api.postTelemetry(
sessionId: sessionId,
body: TelemetryRequest(
deviceRole: "camera",
batteryLevel: health.batteryPercent,
networkType: health.networkType,
signalStrength: nil,
currentBitrate: currentBitrate,
targetBitrate: targetBitrate,
fps: fps
)
)
}
}

View File

@@ -0,0 +1,159 @@
import Foundation
@MainActor
final class ScoreController: ObservableObject {
@Published private(set) var score = ScoreState()
private let scoreRepository: ScoreRepository
private let sessionCable: SessionCableService
private var sessionId: String?
private var suppressRemoteUntil: Date = .distantPast
private var syncTask: Task<Void, Never>?
private var syncGeneration = 0
init(scoreRepository: ScoreRepository, sessionCable: SessionCableService) {
self.scoreRepository = scoreRepository
self.sessionCable = sessionCable
}
func bind(sessionId: String, initial: ScoreState?) {
self.sessionId = sessionId
if let initial { score = initial }
}
func applyRemote(_ remote: ScoreState) {
if remote == score { return }
if Date() < suppressRemoteUntil && remote.progressKey() <= score.progressKey() { return }
score = remote
}
func incrementHome() {
score.homePoints += 1
schedulePush()
}
func incrementAway() {
score.awayPoints += 1
schedulePush()
}
func decrementHome() {
if score.homePoints > 0 { score.homePoints -= 1 }
schedulePush()
}
func decrementAway() {
if score.awayPoints > 0 { score.awayPoints -= 1 }
schedulePush()
}
func applyAction(_ action: String) {
applyOptimistic(action)
scheduleAction(action)
}
/// Azione punteggio via API (volley/racket): il server è fonte di verità.
func applyBoardAction(_ action: String) async {
syncTask?.cancel()
syncGeneration += 1
guard let sessionId else { return }
do {
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
suppressRemoteUntil = Date().addingTimeInterval(0.8)
score = updated
sessionCable.sendScoreUpdate(updated)
}
} catch {
// Mantieni lo stato locale; la UI può mostrare errore a livello schermata se serve.
}
}
/// Chiude il set corrente tramite `close_set` sul motore backend.
func closeSetAsync() async -> Bool {
syncTask?.cancel()
syncGeneration += 1
guard let sessionId else { return false }
do {
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: "close_set") {
suppressRemoteUntil = Date().addingTimeInterval(1.2)
score = updated
sessionCable.sendScoreUpdate(updated)
return true
}
return false
} catch {
return false
}
}
private func applyOptimistic(_ action: String) {
switch action {
case "home_point": score.homePoints += 1
case "away_point": score.awayPoints += 1
case "home_point_2": score.homePoints += 2
case "away_point_2": score.awayPoints += 2
case "home_point_3": score.homePoints += 3
case "away_point_3": score.awayPoints += 3
case "home_undo": score.homePoints = max(0, score.homePoints - 1)
case "away_undo": score.awayPoints = max(0, score.awayPoints - 1)
default: break
}
}
private func scheduleAction(_ action: String) {
syncTask?.cancel()
syncGeneration += 1
let generation = syncGeneration
syncTask = Task {
guard let sessionId else { return }
if let updated = try? await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
guard generation == syncGeneration else { return }
suppressRemoteUntil = Date().addingTimeInterval(0.5)
score = updated
sessionCable.sendScoreUpdate(updated)
}
}
}
func closeSet() {
let current = score
let homeWon = current.homePoints > current.awayPoints
var partials = current.setPartials
if current.homePoints > 0 || current.awayPoints > 0 {
partials.append(SetPartial(set: current.currentSet, home: current.homePoints, away: current.awayPoints))
}
score = ScoreState(
homeSets: homeWon ? current.homeSets + 1 : current.homeSets,
awaySets: homeWon ? current.awaySets : current.awaySets + 1,
homePoints: 0,
awayPoints: 0,
currentSet: current.currentSet + 1,
setPartials: partials,
timeoutHome: false,
timeoutAway: false,
boardType: current.boardType,
period: current.period,
periodLabel: current.periodLabel,
clockSecs: current.clockSecs,
clockRunning: current.clockRunning,
overtime: current.overtime
)
schedulePush()
}
private func schedulePush() {
syncTask?.cancel()
syncGeneration += 1
let generation = syncGeneration
syncTask = Task {
guard let sessionId else { return }
let local = score
sessionCable.sendScoreUpdate(local)
if let remote = try? await scoreRepository.syncScore(sessionId: sessionId, score: local).score {
guard generation == syncGeneration else { return }
suppressRemoteUntil = Date().addingTimeInterval(0.5)
score = remote
}
}
}
}

View File

@@ -0,0 +1,14 @@
import Foundation
@MainActor
final class WizardSessionHolder: ObservableObject {
@Published var match: Match?
@Published var session: StreamSession?
@Published var team: Team?
func reset() {
match = nil
session = nil
team = nil
}
}

View File

@@ -0,0 +1,43 @@
import Foundation
enum MatchHubFilter {
private static let resumableStatuses: Set<String> = ["connecting", "live", "reconnecting", "paused"]
static func coachHubVisible(_ match: Match, now: Date = Date()) -> Bool {
if let visible = match.coachHubVisible { return visible }
if match.hasActiveSession {
let status = match.activeSessionStatus ?? ""
if resumableStatuses.contains(status) || status == "idle" { return true }
}
if match.streamCompleted { return false }
return ApiInstant.isScheduledFuture(match.scheduledAt, now: now)
}
static func isScheduledFuture(_ match: Match, now: Date = Date()) -> Bool {
ApiInstant.isScheduledFuture(match.scheduledAt, now: now)
}
static func isDraft(_ match: Match) -> Bool {
!match.hasActiveSession && (match.scheduledAt == nil || match.scheduledAt?.isEmpty == true)
}
static func sortMatches(_ matches: [Match]) -> [Match] {
matches.sorted { lhs, rhs in
let lDate = lhs.scheduledAt ?? ""
let rDate = rhs.scheduledAt ?? ""
if lDate != rDate { return lDate > rDate }
return lhs.id > rhs.id
}
}
}
enum MatchPresentation {
static func statusLabel(for match: Match) -> String {
if match.canResumeCamera { return "RIPRENDI" }
if match.hasActiveSession { return "IN CORSO" }
if ApiInstant.isScheduledFuture(match.scheduledAt) { return "PROGRAMMATA" }
return "AVVIA"
}
}

View File

@@ -0,0 +1,68 @@
import Foundation
enum ScoringSide: Sendable {
case home
case away
}
struct MatchScoringContext: Sendable {
let setsToWin: Int
let pointsPerSet: Int
let pointsDecidingSet: Int
let minPointLead: Int
let boardType: String
var maxSets: Int { (setsToWin * 2) - 1 }
var usesSetLogic: Bool {
boardType == "volley" || boardType == "racket"
}
init(match: Match) {
let rules = match.scoringRules ?? ScoringRules()
setsToWin = match.setsToWin
pointsPerSet = rules.pointsPerSet
pointsDecidingSet = rules.pointsDecidingSet
minPointLead = rules.minPointLead
boardType = match.boardType.isEmpty ? "volley" : match.boardType
}
func pointsTarget(currentSet: Int) -> Int {
currentSet >= maxSets ? pointsDecidingSet : pointsPerSet
}
func setWinnerFromPoints(homePoints: Int, awayPoints: Int, currentSet: Int) -> ScoringSide? {
let target = pointsTarget(currentSet: currentSet)
if homePoints >= target, homePoints - awayPoints >= minPointLead { return .home }
if awayPoints >= target, awayPoints - homePoints >= minPointLead { return .away }
return nil
}
func matchWinner(homeSets: Int, awaySets: Int) -> ScoringSide? {
if homeSets >= setsToWin { return .home }
if awaySets >= setsToWin { return .away }
return nil
}
}
enum MatchScoringRules {
static func rules(for match: Match) -> ScoringRules {
match.scoringRules ?? ScoringRules()
}
static func setsToWin(for match: Match) -> Int {
match.setsToWin
}
static func maxSets(for match: Match) -> Int {
MatchScoringContext(match: match).maxSets
}
static func pointsTarget(for match: Match, currentSet: Int) -> Int {
MatchScoringContext(match: match).pointsTarget(currentSet: currentSet)
}
}
func scoringSideName(_ side: ScoringSide, homeName: String, awayName: String) -> String {
side == .home ? homeName : awayName
}

View File

@@ -0,0 +1,137 @@
import Foundation
struct User: Codable, Equatable, Sendable {
let id: String
let email: String
let name: String
let role: String
}
struct AuthTokens: Sendable {
let accessToken: String
let refreshToken: String
}
struct SportOption: Identifiable, Equatable, Sendable {
var id: String { key }
let key: String
let label: String
let board: String
let overlay: String
let allowedOverlays: [String]
let defaultRules: [String: Int]
}
struct Team: Identifiable, Equatable, Sendable {
let id: String
let name: String
let sport: String
let sportKey: String
let boardType: String
let clubName: String?
let logoUrl: String?
let primaryColor: String?
let secondaryColor: String?
let canStream: Bool
let youtubeEnabled: Bool
let youtubeConnected: Bool
let youtubeSelectable: Bool
let youtubeChannelTitle: String?
let youtubeUsesPlatformChannel: Bool
let youtubeTeamChannelTitle: String?
let planName: String?
let recordingsEnabled: Bool
let phoneDownloadEnabled: Bool
let billingUrl: String?
let staffManageUrl: String?
var canUseYoutube: Bool { youtubeEnabled }
var isYoutubeReady: Bool { youtubeSelectable }
var effectiveYoutubeChannel: String? {
guard isYoutubeReady else { return nil }
return youtubeUsesPlatformChannel ? "platform" : "team"
}
var youtubeDestinationLabel: String {
if youtubeUsesPlatformChannel { return "Canale Match Live TV" }
let name = youtubeTeamChannelTitle?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
?? clubName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
?? "società"
return "Canale società · \(name)"
}
}
struct ScoringRules: Equatable, Sendable {
var pointsPerSet: Int = 25
var pointsDecidingSet: Int = 15
var minPointLead: Int = 2
var periods: Int = 4
var periodDurationSecs: Int = 600
var overtimeDurationSecs: Int = 300
}
struct Match: Identifiable, Equatable, Sendable {
let id: String
let teamId: String
let teamName: String
let opponentName: String
let location: String?
let scheduledAt: String?
let sportKey: String
let sportLabel: String?
let boardType: String
let overlayKind: String?
let effectiveOverlayKind: String
let setsToWin: Int
let category: String?
let scoringRules: ScoringRules?
let activeSessionId: String?
let activeSessionStatus: String?
let streamCompleted: Bool
let coachHubVisible: Bool?
let homePrimaryColor: String?
let homeSecondaryColor: String?
let homeLogoUrl: String?
let opponentPrimaryColor: String?
let opponentLogoUrl: String?
var hasActiveSession: Bool { activeSessionId != nil }
var canResumeCamera: Bool {
guard let status = activeSessionStatus, activeSessionId != nil else { return false }
return ["connecting", "live", "reconnecting", "paused"].contains(status)
}
}
struct StreamSession: Identifiable, Equatable, Sendable {
let id: String
let matchId: String
let status: String
let platform: String
let rtmpIngestUrl: String?
let hlsPlaybackUrl: String?
let watchPageUrl: String?
let shareUrl: String?
let youtubeWatchUrl: String?
let youtubeReady: Bool
let privacyStatus: String
let targetBitrate: Int
let targetFps: Int
let startedAt: String?
let score: ScoreState?
var isLive: Bool { status == "live" || status == "reconnecting" }
var isPaused: Bool { status == "paused" }
func watchShareUrl() -> String? {
if platform == "youtube", let url = youtubeWatchUrl, !url.isEmpty { return url }
if let url = shareUrl, !url.isEmpty { return url }
if let url = watchPageUrl, !url.isEmpty { return url }
return nil
}
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}

View File

@@ -0,0 +1,113 @@
import Foundation
struct SetPartial: Equatable, Codable, Sendable {
let set: Int
let home: Int
let away: Int
}
struct ScoreState: Equatable, Sendable {
var homeSets: Int = 0
var awaySets: Int = 0
var homePoints: Int = 0
var awayPoints: Int = 0
var currentSet: Int = 1
var setPartials: [SetPartial] = []
var timeoutHome: Bool = false
var timeoutAway: Bool = false
var boardType: String = "volley"
var period: Int = 1
var periodLabel: String?
var clockSecs: Int = 0
var clockRunning: Bool = false
var overtime: Bool = false
func cablePayload() -> [String: Any] {
var payload: [String: Any] = [
"type": "score_update",
"board_type": boardType,
"home_sets": homeSets,
"away_sets": awaySets,
"home_points": homePoints,
"away_points": awayPoints,
"current_set": currentSet,
"set_partials": setPartials.map { ["set": $0.set, "home": $0.home, "away": $0.away] },
"timeout_home": timeoutHome,
"timeout_away": timeoutAway,
]
if Self.periodBoards.contains(boardType) {
payload["period"] = periodLabel ?? "\(period)"
payload["clock_secs"] = clockSecs
payload["clock_running"] = clockRunning
payload["home_score"] = homePoints
payload["away_score"] = awayPoints
payload["overtime"] = overtime
}
if boardType == "timer" {
payload["clock_secs"] = clockSecs
payload["clock_running"] = clockRunning
}
return payload
}
func progressKey() -> Int64 {
Int64(currentSet) * 10_000_000_000
+ Int64(homeSets) * 1_000_000
+ Int64(awaySets) * 100_000
+ Int64(homePoints) * 1_000
+ Int64(awayPoints)
+ Int64(clockSecs)
}
static let periodBoards: Set<String> = ["basket", "timed"]
static func fromCablePayload(_ data: [String: Any]) -> ScoreState {
let boardType = data["board_type"] as? String ?? "volley"
let nested = data["data"] as? [String: Any]
let partialsRaw = (data["set_partials"] as? [Any]) ?? (data["setPartials"] as? [Any]) ?? []
let periodNumber = intFrom(nested, key: "period").flatMap { $0 > 0 ? $0 : nil }
?? max(intField(data, snake: "current_set", camel: "currentSet"), 1)
return ScoreState(
homeSets: intField(data, snake: "home_sets", camel: "homeSets"),
awaySets: intField(data, snake: "away_sets", camel: "awaySets"),
homePoints: intField(data, snake: "home_points", camel: "homePoints"),
awayPoints: intField(data, snake: "away_points", camel: "awayPoints"),
currentSet: max(intField(data, snake: "current_set", camel: "currentSet"), 1),
setPartials: partialsRaw.compactMap { item -> SetPartial? in
guard let row = item as? [String: Any] else { return nil }
return SetPartial(
set: (row["set"] as? NSNumber)?.intValue ?? 1,
home: (row["home"] as? NSNumber)?.intValue ?? 0,
away: (row["away"] as? NSNumber)?.intValue ?? 0
)
},
timeoutHome: data["timeout_home"] as? Bool ?? false,
timeoutAway: data["timeout_away"] as? Bool ?? false,
boardType: boardType,
period: periodNumber,
periodLabel: data["period"] as? String,
clockSecs: {
let direct = intField(data, snake: "clock_secs", camel: "clockSecs")
if direct > 0 || data["clock_secs"] != nil || data["clockSecs"] != nil { return direct }
return intFrom(nested, key: "clock_secs") ?? 0
}(),
clockRunning: data["clock_running"] as? Bool ?? nested?["clock_running"] as? Bool ?? false,
overtime: nested?["overtime"] as? Bool ?? false
)
}
private static func intField(_ data: [String: Any], snake: String, camel: String) -> Int {
let raw = data[snake] ?? data[camel]
if let n = raw as? NSNumber { return n.intValue }
if let s = raw as? String, let v = Int(s) { return v }
return 0
}
private static func intFrom(_ data: [String: Any]?, key: String) -> Int? {
guard let data else { return nil }
if let n = data[key] as? NSNumber { return n.intValue }
if let s = data[key] as? String, let v = Int(s) { return v }
return nil
}
}

View File

@@ -0,0 +1,6 @@
{
"colors" : [
{ "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0.176", "green" : "0.176", "red" : "1.000" } }, "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

View File

@@ -0,0 +1,6 @@
{
"images" : [
{ "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

View File

@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }

View File

@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "CopertinaCanale.png", "idiom" : "universal", "scale" : "1x" },
{ "idiom" : "universal", "scale" : "2x" },
{ "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "CopertinaCanale_2.png", "idiom" : "universal", "scale" : "1x" },
{ "idiom" : "universal", "scale" : "2x" },
{ "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "CopertinaCanale_3.png", "idiom" : "universal", "scale" : "1x" },
{ "idiom" : "universal", "scale" : "2x" },
{ "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "CopertinaCanale_4.png", "idiom" : "universal", "scale" : "1x" },
{ "idiom" : "universal", "scale" : "2x" },
{ "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "CopertinaCanale_5.png", "idiom" : "universal", "scale" : "1x" },
{ "idiom" : "universal", "scale" : "2x" },
{ "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "CopertinaCanale_6.png", "idiom" : "universal", "scale" : "1x" },
{ "idiom" : "universal", "scale" : "2x" },
{ "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "logo-dark-gradient-256.png", "idiom" : "universal", "scale" : "1x" },
{ "idiom" : "universal", "scale" : "2x" },
{ "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "logo-dark-gradient.png", "idiom" : "universal", "scale" : "1x" },
{ "idiom" : "universal", "scale" : "2x" },
{ "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

View File

@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "logo-white-m-256.png", "idiom" : "universal", "scale" : "1x" },
{ "idiom" : "universal", "scale" : "2x" },
{ "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "logo-white-m.png", "idiom" : "universal", "scale" : "1x" },
{ "idiom" : "universal", "scale" : "2x" },
{ "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_BASE_URL</key>
<string>https://www.matchlivetv.it</string>
<key>CFBundleDevelopmentRegion</key>
<string>it</string>
<key>CFBundleDisplayName</key>
<string>Match Live TV</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.0.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.matchlivetv.match-live-tv</string>
<key>CFBundleURLSchemes</key>
<array>
<string>matchlivetv</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>21</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Match Live TV usa la fotocamera per trasmettere le partite in diretta.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Match Live TV usa il microfono per l'audio della diretta.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Match Live TV usa le foto per i loghi delle squadre.</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string></string>
<key>UIImageName</key>
<string>logo-dark-gradient-256</string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,26 @@
import Foundation
struct BroadcastConfig: Sendable {
let rtmpUrl: String
var width: Int = 1280
var height: Int = 720
var videoBitrate: Int = 2_500_000
var audioBitrate: Int = 128_000
var fps: Int = 30
var rotation: Int = 0
var portrait: Bool = false
var maxReconnectAttempts: Int = 10
var reconnectDelayMs: Int = 5_000
}
enum BroadcastPhase: String, Sendable {
case idle, preview, connecting, live, paused, reconnecting, error
}
struct BroadcastMetrics: Sendable {
var phase: BroadcastPhase = .idle
var bitrateKbps: Int = 0
var fps: Int = 0
var connected: Bool = false
var lastError: String?
}

View File

@@ -0,0 +1,45 @@
import Foundation
import AVFoundation
@MainActor
final class LiveBroadcastCoordinator: ObservableObject {
let engine = LiveBroadcastEngine()
@Published private(set) var metrics = BroadcastMetrics()
init() {
engine.setMetricsListener { [weak self] value in
Task { @MainActor in self?.metrics = value }
}
}
func updateOverlay(_ state: OverlayState) {
engine.updateOverlay(state)
}
func configureBackgroundAudio() {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
try? session.setActive(true)
}
func startBroadcast(config: BroadcastConfig) async throws {
configureBackgroundAudio()
try await engine.startBroadcast(config: config)
}
func pauseBroadcast() async {
await engine.pauseBroadcast()
}
func resumeBroadcast(config: BroadcastConfig) async throws {
try await engine.resumeBroadcast(config: config)
}
func stopBroadcast() async {
await engine.stopBroadcast()
}
func release() async {
await engine.release()
}
}

View File

@@ -0,0 +1,176 @@
import AVFoundation
import Foundation
import HaishinKit
import RTMPHaishinKit
import UIKit
struct RTMPPublishTarget: Sendable {
let connectURL: String
let streamName: String
}
enum RTMPUrlParser {
static func parse(_ urlString: String) -> RTMPPublishTarget? {
guard let url = URL(string: urlString), url.scheme?.hasPrefix("rtmp") == true else { return nil }
let pathParts = url.path.split(separator: "/").map(String.init)
guard pathParts.count >= 2 else { return nil }
let app = pathParts.dropLast().joined(separator: "/")
let streamName = pathParts.last ?? ""
var connect = "rtmp://\(url.host ?? "")"
if let port = url.port { connect += ":\(port)" }
connect += "/\(app)"
return RTMPPublishTarget(connectURL: connect, streamName: streamName)
}
}
@MainActor
final class LiveBroadcastEngine: ObservableObject {
@Published private(set) var metrics = BroadcastMetrics()
let mixer = MediaMixer()
let connection = RTMPConnection()
let stream: RTMPStream
private let overlayRenderer = OverlayRenderer()
private var config: BroadcastConfig?
private var phase: BroadcastPhase = .idle
private var metricsListener: ((BroadcastMetrics) -> Void)?
private var reconnectAttempts = 0
private var publishTask: Task<Void, Never>?
private var pipelineConfigured = false
private var isPortrait = false
init() {
stream = RTMPStream(connection: connection)
}
func setMetricsListener(_ block: ((BroadcastMetrics) -> Void)?) {
metricsListener = block
emitMetrics()
}
func updateOverlay(_ state: OverlayState) {
overlayRenderer.update(state: state, isPortrait: isPortrait)
}
func bindPreview(to view: MTHKView) async {
await stream.addOutput(view)
}
func preparePreview(config: BroadcastConfig) async throws {
self.config = config
try await configurePipeline(config)
setPhase(.preview)
}
func startBroadcast(config: BroadcastConfig) async throws {
self.config = config
publishTask?.cancel()
publishTask = nil
try await configurePipeline(config)
setPhase(.connecting)
try await Task.sleep(nanoseconds: 350_000_000)
try await publish(config: config)
}
func pauseBroadcast() async {
try? await stream.close()
setPhase(.paused)
}
func resumeBroadcast(config: BroadcastConfig) async throws {
self.config = config
if !pipelineConfigured {
try await configurePipeline(config)
}
setPhase(.connecting)
try await publish(config: config)
}
func stopBroadcast() async {
publishTask?.cancel()
publishTask = nil
overlayRenderer.detach()
try? await stream.close()
try? await connection.close()
try? await mixer.stopRunning()
pipelineConfigured = false
setPhase(.idle)
reconnectAttempts = 0
}
func release() async {
await stopBroadcast()
}
private func configurePipeline(_ config: BroadcastConfig) async throws {
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw APIError.http(403, "Permessi camera e microfono richiesti")
}
if pipelineConfigured { return }
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
try audioSession.setActive(true)
isPortrait = config.portrait
var videoSettings = await mixer.videoMixerSettings
videoSettings.mode = .offscreen
try await mixer.setVideoMixerSettings(videoSettings)
await configureScreenSize(width: config.width, height: config.height)
if let microphone = AVCaptureDevice.default(for: .audio) {
try await mixer.attachAudio(microphone, track: 0)
}
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
try await mixer.attachVideo(camera, track: 0)
}
try await mixer.addOutput(stream)
try await mixer.startRunning()
overlayRenderer.attach(mixer: mixer, width: config.width, height: config.height, isPortrait: config.portrait)
var streamSettings = await stream.videoSettings
streamSettings.videoSize = .init(width: config.width, height: config.height)
streamSettings.bitRate = config.videoBitrate
streamSettings.maxKeyFrameIntervalDuration = 2
try await stream.setVideoSettings(streamSettings)
var audioSettings = await stream.audioSettings
audioSettings.bitRate = config.audioBitrate
try await stream.setAudioSettings(audioSettings)
pipelineConfigured = true
}
private func configureScreenSize(width: Int, height: Int) async {
await Task { @ScreenActor in
mixer.screen.size = CGSize(width: width, height: height)
mixer.screen.backgroundColor = UIColor.clear.cgColor
}.value
}
private func publish(config: BroadcastConfig) async throws {
guard let target = RTMPUrlParser.parse(config.rtmpUrl) else {
throw APIError.http(400, "URL RTMP non valido")
}
try await connection.connect(target.connectURL)
try await stream.publish(target.streamName)
reconnectAttempts = 0
setPhase(.live)
}
private func setPhase(_ newPhase: BroadcastPhase, error: String? = nil) {
phase = newPhase
metrics = BroadcastMetrics(
phase: newPhase,
bitrateKbps: metrics.bitrateKbps,
fps: config?.fps ?? metrics.fps,
connected: newPhase == .live || newPhase == .reconnecting,
lastError: error
)
metricsListener?(metrics)
}
private func emitMetrics() {
metricsListener?(metrics)
}
}

View File

@@ -0,0 +1,109 @@
import UIKit
struct CompactScoreboardElement: OverlayElement {
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout) {
guard let compact = state.compactScoreboard else { return }
let kind = state.overlayKind
guard kind == .basket || kind == .timed || kind == .racket || kind == .volley else { return }
let scale = CGFloat(layout.canvasHeight) / 720
let pad = max(8, Int((10 * scale).rounded()))
let barW = max(3, Int((4 * scale).rounded()))
let logoSize = max(20, Int((28 * scale).rounded()))
let gap = max(4, Int((6 * scale).rounded()))
let totalW = max(320, Int((CGFloat(layout.canvasWidth) * 0.62).rounded()))
let mainH = max(56, Int((CGFloat(layout.canvasHeight) * 0.09).rounded()))
let metaH = max(16, Int((CGFloat(layout.canvasHeight) * 0.028).rounded()))
let totalH = mainH + metaH + pad
let left = CGFloat(layout.marginPx)
let top = CGFloat(layout.marginPx)
let rect = CGRect(x: left, y: top, width: CGFloat(totalW), height: CGFloat(totalH))
context.setFillColor(UIColor(white: 0.047, alpha: 0.82).cgColor)
context.addPath(UIBezierPath(roundedRect: rect, cornerRadius: 10 * scale).cgPath)
context.fillPath()
let centerX = rect.midX
let rowCenterY = top + CGFloat(pad) + CGFloat(mainH) * 0.58
let sideMaxW = CGFloat(totalW) * 0.34
let scoreZoneHalf = CGFloat(totalW) * 0.11
let labelSize = max(13, CGFloat(mainH) * 0.28)
let scoreSize = max(24, CGFloat(mainH) * 0.46)
let labelFont = UIFont.boldSystemFont(ofSize: labelSize)
let scoreFont = UIFont.boldSystemFont(ofSize: scoreSize)
drawTeamSide(context: context, compact: compact, isHome: true, rectLeft: rect.minX + CGFloat(pad), rectRight: centerX - scoreZoneHalf, centerY: rowCenterY, barW: barW, logoSize: logoSize, gap: gap, maxWidth: sideMaxW, labelFont: labelFont)
drawTeamSide(context: context, compact: compact, isHome: false, rectLeft: centerX + scoreZoneHalf, rectRight: rect.maxX - CGFloat(pad), centerY: rowCenterY, barW: barW, logoSize: logoSize, gap: gap, maxWidth: sideMaxW, labelFont: labelFont)
let scoreText = "\(compact.homeScore) - \(compact.awayScore)"
let scoreAttrs: [NSAttributedString.Key: Any] = [.font: scoreFont, .foregroundColor: UIColor.white]
let scoreNSString = scoreText as NSString
let scoreTextSize = scoreNSString.size(withAttributes: scoreAttrs)
scoreNSString.draw(at: CGPoint(x: centerX - scoreTextSize.width / 2, y: rowCenterY + scoreSize * 0.32 - scoreTextSize.height), withAttributes: scoreAttrs)
if let period = compact.periodLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !period.isEmpty {
let metaSize = max(11, CGFloat(metaH) * 0.72)
let metaFont = UIFont.systemFont(ofSize: metaSize)
let metaAttrs: [NSAttributedString.Key: Any] = [.font: metaFont, .foregroundColor: UIColor(white: 0.67, alpha: 1)]
let metaNSString = period as NSString
let metaSize2 = metaNSString.size(withAttributes: metaAttrs)
metaNSString.draw(at: CGPoint(x: centerX - metaSize2.width / 2, y: rect.maxY - CGFloat(pad) * 0.45 - metaSize2.height), withAttributes: metaAttrs)
}
}
private func drawTeamSide(
context: CGContext,
compact: CompactScoreboardState,
isHome: Bool,
rectLeft: CGFloat,
rectRight: CGFloat,
centerY: CGFloat,
barW: Int,
logoSize: Int,
gap: Int,
maxWidth: CGFloat,
labelFont: UIFont
) {
let accent = isHome ? compact.homeAccentColor : compact.awayAccentColor
let logoUrl = isHome ? compact.homeLogoUrl : compact.awayLogoUrl
let logo = OverlayLogoCache.get(logoUrl)
let rawName = isHome ? compact.homeTeamName : compact.awayTeamName
let name = ellipsize(rawName.trimmingCharacters(in: .whitespacesAndNewlines), font: labelFont, maxWidth: maxWidth * 0.55)
let attrs: [NSAttributedString.Key: Any] = [.font: labelFont, .foregroundColor: UIColor.white]
if isHome {
var x = rectLeft
context.setFillColor(accent.cgColor)
context.fill(CGRect(x: x, y: centerY - CGFloat(logoSize) * 0.45, width: CGFloat(barW), height: CGFloat(logoSize) * 0.9))
x += CGFloat(barW + gap)
if let logo, let cg = logo.cgImage {
context.draw(cg, in: CGRect(x: x, y: centerY - CGFloat(logoSize) / 2, width: CGFloat(logoSize), height: CGFloat(logoSize)))
x += CGFloat(logoSize + gap)
}
(name as NSString).draw(at: CGPoint(x: x, y: centerY + labelFont.pointSize * 0.35 - labelFont.lineHeight), withAttributes: attrs)
} else {
var x = rectRight
context.setFillColor(accent.cgColor)
context.fill(CGRect(x: x - CGFloat(barW), y: centerY - CGFloat(logoSize) * 0.45, width: CGFloat(barW), height: CGFloat(logoSize) * 0.9))
x -= CGFloat(barW + gap)
let nameSize = (name as NSString).size(withAttributes: attrs)
(name as NSString).draw(at: CGPoint(x: x - nameSize.width, y: centerY + labelFont.pointSize * 0.35 - labelFont.lineHeight), withAttributes: attrs)
x -= CGFloat(gap)
if let logo, let cg = logo.cgImage {
x -= CGFloat(logoSize)
context.draw(cg, in: CGRect(x: x, y: centerY - CGFloat(logoSize) / 2, width: CGFloat(logoSize), height: CGFloat(logoSize)))
}
}
}
private func ellipsize(_ text: String, font: UIFont, maxWidth: CGFloat) -> String {
let safeMax = max(48, maxWidth)
let attrs: [NSAttributedString.Key: Any] = [.font: font]
if (text as NSString).size(withAttributes: attrs).width <= safeMax { return text }
var result = text
while result.count > 1 && (result as NSString).size(withAttributes: attrs).width > safeMax {
result = String(result.dropLast())
}
return result + ""
}
}

View File

@@ -0,0 +1,49 @@
import UIKit
final class OverlayCanvasRenderer {
private let elements: [OverlayElement] = [
WatermarkElement(),
ScoreboardElement(),
CompactScoreboardElement(),
SponsorElement(),
]
private var canvasWidth = 0
private var canvasHeight = 0
func setCanvasSize(width: Int, height: Int) {
canvasWidth = width
canvasHeight = height
}
func render(state: OverlayState, isPortraitContent: Bool) -> CGImage? {
guard canvasWidth > 0, canvasHeight > 0 else { return nil }
let size = CGSize(width: canvasWidth, height: canvasHeight)
let margin = max(12, Int((CGFloat(canvasWidth) * 0.012).rounded()))
let layout = OverlayLayout(
canvasWidth: canvasWidth,
canvasHeight: canvasHeight,
marginPx: margin,
isPortraitContent: isPortraitContent
)
// scale = 1.0: pixel dimensions must match HaishinKit screen (1280x720), not Retina points.
let format = UIGraphicsImageRendererFormat()
format.scale = 1.0
let renderer = UIGraphicsImageRenderer(size: size, format: format)
let image = renderer.image { ctx in
let context = ctx.cgContext
context.clear(CGRect(origin: .zero, size: size))
elements.forEach { $0.draw(in: context, state: state, layout: layout) }
}
guard let cgImage = image.cgImage,
cgImage.width == canvasWidth,
cgImage.height == canvasHeight else { return nil }
return cgImage
}
func release() {
canvasWidth = 0
canvasHeight = 0
}
}

View File

@@ -0,0 +1,72 @@
import Foundation
import UIKit
enum OverlayLogoCache {
private static var authToken: String?
private static var cache: [String: UIImage] = [:]
private static let lock = NSLock()
static func configure(token: String?) {
lock.lock()
authToken = token
lock.unlock()
}
static func get(_ url: String?) -> UIImage? {
guard let url, !url.isEmpty else { return nil }
lock.lock()
if let cached = cache[url] {
lock.unlock()
return cached
}
lock.unlock()
return nil
}
static func preload(_ url: String?) async {
guard let resolved = MediaUrl.resolve(url) else { return }
lock.lock()
if cache[resolved] != nil {
lock.unlock()
return
}
lock.unlock()
guard let image = await download(resolved) else { return }
lock.lock()
cache[resolved] = image
lock.unlock()
}
static func preloadAll(_ urls: [String?]) async {
await withTaskGroup(of: Void.self) { group in
for url in urls {
group.addTask { await preload(url) }
}
}
}
static func clear() {
lock.lock()
cache.removeAll()
lock.unlock()
}
private static func download(_ urlString: String) async -> UIImage? {
guard let url = URL(string: urlString) else { return nil }
var request = URLRequest(url: url, timeoutInterval: 20)
if let authToken { request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") }
guard let (data, response) = try? await URLSession.shared.data(for: request),
let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
let image = UIImage(data: data) else { return nil }
return scale(image, maxSide: 128)
}
private static func scale(_ image: UIImage, maxSide: CGFloat) -> UIImage {
let size = image.size
let ratio = min(maxSide / size.width, maxSide / size.height, 1)
if ratio >= 1 { return image }
let newSize = CGSize(width: size.width * ratio, height: size.height * ratio)
let renderer = UIGraphicsImageRenderer(size: newSize)
return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: newSize)) }
}
}

View File

@@ -0,0 +1,45 @@
import Foundation
import UIKit
extension ScoreState {
func toScoreboardState(
homeTeamName: String,
awayTeamName: String,
homeAccentColor: UIColor = ColorHex.parseColorHex("#FF2D2D"),
awayAccentColor: UIColor = ColorHex.parseColorHex("#1E3A8A"),
homeLogoUrl: String? = nil,
awayLogoUrl: String? = nil
) -> ScoreboardState {
var columns: [ScoreboardSetColumn] = setPartials.map {
ScoreboardSetColumn(setNumber: $0.set, homePoints: $0.home, awayPoints: $0.away, isCurrent: false)
}
columns.append(
ScoreboardSetColumn(setNumber: currentSet, homePoints: homePoints, awayPoints: awayPoints, isCurrent: true)
)
return ScoreboardState(
homeTeamName: homeTeamName,
awayTeamName: awayTeamName,
columns: columns,
homeAccentColor: homeAccentColor,
awayAccentColor: awayAccentColor,
homeLogoUrl: homeLogoUrl,
awayLogoUrl: awayLogoUrl
)
}
}
func formatOverlayClock(_ secs: Int, countUp: Bool = false) -> String {
if secs <= 0 && !countUp { return "0:00" }
return String(format: "%d:%02d", secs / 60, secs % 60)
}
extension BroadcastPhase {
func toOverlayStatus() -> BroadcastOverlayStatus {
switch self {
case .live: return .live
case .paused: return .paused
case .connecting, .reconnecting, .error: return .connecting
case .preview, .idle: return .preview
}
}
}

View File

@@ -0,0 +1,122 @@
import UIKit
import HaishinKit
final class OverlayRenderer: @unchecked Sendable {
private let canvasRenderer = OverlayCanvasRenderer()
private var streamWidth = 0
private var streamHeight = 0
private var isPortraitContent = false
private var attached = false
private var lastFingerprint = Int.min
private var pendingState: OverlayState?
private weak var mixer: MediaMixer?
private let lock = NSLock()
private var screenTask: Task<Void, Never>?
@ScreenActor private var overlayObject: ImageScreenObject?
func attach(mixer: MediaMixer, width: Int, height: Int, isPortrait: Bool = false) {
lock.lock()
self.mixer = mixer
streamWidth = width
streamHeight = height
isPortraitContent = isPortrait
canvasRenderer.setCanvasSize(width: width, height: height)
attached = true
lastFingerprint = Int.min
let state = pendingState
lock.unlock()
enqueueScreen {
self.overlayObject = nil
}
if let state { pushState(state) }
}
func update(state: OverlayState, isPortrait: Bool = false) {
lock.lock()
isPortraitContent = isPortrait
pendingState = state
let isAttached = attached
lock.unlock()
guard isAttached else { return }
pushState(state)
}
func refreshOrientation(isPortrait: Bool) {
lock.lock()
isPortraitContent = isPortrait
lastFingerprint = Int.min
let state = pendingState
let isAttached = attached
lock.unlock()
guard isAttached, let state else { return }
pushState(state)
}
func detach() {
lock.lock()
attached = false
lastFingerprint = Int.min
pendingState = nil
let mixerRef = mixer
mixer = nil
lock.unlock()
canvasRenderer.release()
enqueueScreen {
if let overlay = self.overlayObject {
mixerRef?.screen.removeChild(overlay)
self.overlayObject = nil
}
}
}
private func pushState(_ state: OverlayState) {
let fp = state.fingerprint()
lock.lock()
if fp == lastFingerprint {
lock.unlock()
return
}
lastFingerprint = fp
let portrait = isPortraitContent
let width = streamWidth
let height = streamHeight
guard let mixer else {
lock.unlock()
return
}
lock.unlock()
guard let cgImage = canvasRenderer.render(state: state, isPortraitContent: portrait) else { return }
guard cgImage.width == width, cgImage.height == height else { return }
enqueueScreen {
let size = CGSize(width: cgImage.width, height: cgImage.height)
if self.overlayObject == nil {
let overlay = ImageScreenObject()
overlay.horizontalAlignment = .left
overlay.verticalAlignment = .top
overlay.size = size
do {
try mixer.screen.addChild(overlay)
self.overlayObject = overlay
} catch {
return
}
}
guard let overlay = self.overlayObject else { return }
overlay.size = size
overlay.cgImage = cgImage
}
}
private func enqueueScreen(_ operation: @escaping @ScreenActor () async -> Void) {
let previous = screenTask
screenTask = Task {
await previous?.value
await operation()
}
}
}

View File

@@ -0,0 +1,129 @@
import Foundation
import UIKit
struct ScoreboardSetColumn: Equatable {
let setNumber: Int
let homePoints: Int
let awayPoints: Int
let isCurrent: Bool
}
struct ScoreboardState: Equatable {
let homeTeamName: String
let awayTeamName: String
let columns: [ScoreboardSetColumn]
var homeAccentColor: UIColor = UIColor(red: 1, green: 0.176, blue: 0.176, alpha: 1)
var awayAccentColor: UIColor = UIColor(red: 0.118, green: 0.227, blue: 0.541, alpha: 1)
var homeLogoUrl: String?
var awayLogoUrl: String?
}
struct CompactScoreboardState: Equatable {
let homeTeamName: String
let awayTeamName: String
let homeScore: Int
let awayScore: Int
var periodLabel: String?
var homeAccentColor: UIColor = UIColor(red: 1, green: 0.176, blue: 0.176, alpha: 1)
var awayAccentColor: UIColor = UIColor(red: 0.118, green: 0.227, blue: 0.541, alpha: 1)
var homeLogoUrl: String?
var awayLogoUrl: String?
}
enum OverlayKind: String, Equatable {
case none, volley, basket, timed, racket
static func fromApi(_ value: String?) -> OverlayKind {
switch value?.lowercased() {
case "none", "timer": return .none
case "basket": return .basket
case "timed": return .timed
case "racket": return .racket
default: return .volley
}
}
}
enum BroadcastOverlayStatus: Equatable {
case preview, connecting, live, paused
}
struct OverlayState: Equatable {
var overlayKind: OverlayKind = .volley
var scoreboard: ScoreboardState?
var compactScoreboard: CompactScoreboardState?
var watermarkVisible: Bool = true
var broadcastStatus: BroadcastOverlayStatus = .live
var sponsorText: String?
func fingerprint() -> Int {
var result = overlayKind.hashValue
result = mixHash(result, watermarkVisible.hashValue)
result = mixHash(result, broadcastStatus.hashValue)
result = mixHash(result, sponsorText?.hashValue ?? 0)
result = mixHash(result, scoreboard?.fingerprint() ?? 0)
result = mixHash(result, compactScoreboard?.fingerprint() ?? 0)
return result
}
}
struct OverlayLayout {
let canvasWidth: Int
let canvasHeight: Int
let marginPx: Int
let isPortraitContent: Bool
}
protocol OverlayElement {
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout)
}
private extension ScoreboardState {
func fingerprint() -> Int {
var result = homeTeamName.hashValue
result = mixHash(result, awayTeamName.hashValue)
result = mixHash(result, colorFingerprint(homeAccentColor))
result = mixHash(result, colorFingerprint(awayAccentColor))
result = mixHash(result, homeLogoUrl?.hashValue ?? 0)
result = mixHash(result, awayLogoUrl?.hashValue ?? 0)
result = mixHash(result, logoLoadedKey(homeLogoUrl))
result = mixHash(result, logoLoadedKey(awayLogoUrl))
for col in columns {
result = mixHash(result, col.setNumber)
result = mixHash(result, col.homePoints)
result = mixHash(result, col.awayPoints)
result = mixHash(result, col.isCurrent.hashValue)
}
return result
}
}
private extension CompactScoreboardState {
func fingerprint() -> Int {
var result = homeTeamName.hashValue
result = mixHash(result, awayTeamName.hashValue)
result = mixHash(result, homeScore)
result = mixHash(result, awayScore)
result = mixHash(result, colorFingerprint(homeAccentColor))
result = mixHash(result, colorFingerprint(awayAccentColor))
result = mixHash(result, periodLabel?.hashValue ?? 0)
result = mixHash(result, logoLoadedKey(homeLogoUrl))
result = mixHash(result, logoLoadedKey(awayLogoUrl))
return result
}
}
/// Kotlin `Int` wraps on overflow; Swift debug traps unless we use wrapping ops.
private func mixHash(_ hash: Int, _ value: Int) -> Int {
31 &* hash &+ value
}
private func logoLoadedKey(_ url: String?) -> Int {
OverlayLogoCache.get(url) != nil ? 1 : 0
}
private func colorFingerprint(_ color: UIColor) -> Int {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
color.getRed(&r, green: &g, blue: &b, alpha: &a)
return Int(r * 255) << 16 | Int(g * 255) << 8 | Int(b * 255)
}

View File

@@ -0,0 +1,185 @@
import UIKit
struct ScoreboardElement: OverlayElement {
private static let sizeBoost: CGFloat = 1.1
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout) {
guard state.overlayKind == .volley || state.overlayKind == .racket,
let board = state.scoreboard, !board.columns.isEmpty else { return }
let scale = CGFloat(layout.canvasHeight) / 720 * Self.sizeBoost
let pad = max(6, Int((8 * scale).rounded()))
let colorBarW = max(4, Int((5 * scale).rounded()))
let logoSize = max(16, Int((24 * scale).rounded()))
let logoGap = max(3, Int((4 * scale).rounded()))
let teamW = Int((158 * scale).rounded())
let colW = max(22, Int((28 * scale).rounded()))
let brandH = max(14, Int((18 * scale).rounded()))
let headH = max(12, Int((16 * scale).rounded()))
let rowH = max(18, Int((22 * scale).rounded()))
let footerH = max(14, Int((18 * scale).rounded()))
let n = board.columns.count
let logoSlotW = logoSlotWidth(board, logoSize: logoSize, logoGap: logoGap)
let boxW = pad * 2 + colorBarW + logoSlotW + teamW + n * colW
let boxH = pad * 2 + brandH + headH + rowH * 2 + footerH
let left = CGFloat(layout.marginPx)
let top = CGFloat(layout.marginPx)
let bgPath = UIBezierPath(roundedRect: CGRect(x: left, y: top, width: CGFloat(boxW), height: CGFloat(boxH)), cornerRadius: 6 * scale)
context.saveGState()
context.setFillColor(UIColor(white: 0.047, alpha: 0.82).cgColor)
context.addPath(bgPath.cgPath)
context.fillPath()
context.restoreGState()
drawBrandHeader(context: context, left: left, top: top, pad: pad, scale: scale, brandH: brandH)
let headerSize = max(8, 10 * scale)
let headerFont = UIFont.boldSystemFont(ofSize: headerSize)
let headerAttrs: [NSAttributedString.Key: Any] = [
.font: headerFont,
.foregroundColor: UIColor(white: 0.53, alpha: 1),
]
let scoresLeft = left + CGFloat(pad + colorBarW + logoSlotW + teamW)
let headerBaseline = top + CGFloat(pad + brandH + headH) - 2 * scale
for (index, column) in board.columns.enumerated() {
let cx = scoresLeft + CGFloat(index * colW + colW / 2)
let text = "\(column.setNumber)" as NSString
let size = text.size(withAttributes: headerAttrs)
text.draw(at: CGPoint(x: cx - size.width / 2, y: headerBaseline - size.height), withAttributes: headerAttrs)
}
let homeRowTop = top + CGFloat(pad + brandH + headH)
let awayRowTop = homeRowTop + CGFloat(rowH)
drawTeamRow(context: context, board: board, isHome: true, rowTop: homeRowTop, left: left, pad: pad, colorBarW: colorBarW, logoSize: logoSize, logoGap: logoGap, logoSlotW: logoSlotW, teamW: teamW, colW: colW, rowH: rowH, scoresLeft: scoresLeft, scale: scale)
drawTeamRow(context: context, board: board, isHome: false, rowTop: awayRowTop, left: left, pad: pad, colorBarW: colorBarW, logoSize: logoSize, logoGap: logoGap, logoSlotW: logoSlotW, teamW: teamW, colW: colW, rowH: rowH, scoresLeft: scoresLeft, scale: scale)
let currentSet = board.columns.last(where: { $0.isCurrent })?.setNumber ?? board.columns.count
let footerText = ordinalSetLabel(currentSet)
let footerSize = max(8, 10 * scale)
let footerAttrs: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: footerSize),
.foregroundColor: UIColor(white: 0.8, alpha: 1),
]
let footerBaseline: CGFloat = top + CGFloat(boxH - pad) - 2 * scale
let footerX: CGFloat = left + CGFloat(pad + colorBarW) + 4 * scale
let footerY: CGFloat = footerBaseline - footerSize
let footerPoint = CGPoint(x: footerX, y: footerY)
(footerText as NSString).draw(at: footerPoint, withAttributes: footerAttrs)
}
private func logoSlotWidth(_ board: ScoreboardState, logoSize: Int, logoGap: Int) -> Int {
let needs = !(board.homeLogoUrl?.isEmpty ?? true) || !(board.awayLogoUrl?.isEmpty ?? true)
return needs ? logoSize + logoGap : 0
}
private func drawBrandHeader(context: CGContext, left: CGFloat, top: CGFloat, pad: Int, scale: CGFloat, brandH: Int) {
let textSize = max(7, 9 * scale)
let matchFont = UIFont.boldSystemFont(ofSize: textSize)
let tvFont = UIFont.boldSystemFont(ofSize: textSize)
let liveFont = UIFont.boldSystemFont(ofSize: textSize * 0.88)
var x = left + CGFloat(pad)
let baseline = top + CGFloat(pad) + CGFloat(brandH) * 0.72
("MATCH" as NSString).draw(at: CGPoint(x: x, y: baseline - textSize), withAttributes: [.font: matchFont, .foregroundColor: UIColor.white])
x += ("MATCH" as NSString).size(withAttributes: [.font: matchFont]).width + 4 * scale
let liveText = "LIVE"
let livePadH = 4 * scale
let liveW = (liveText as NSString).size(withAttributes: [.font: liveFont]).width + livePadH * 2
let liveH = textSize + 4 * scale
let liveTop = baseline - textSize - 2 * scale
let liveRect = CGRect(x: x, y: liveTop, width: liveW, height: liveH)
context.setFillColor(UIColor(red: 1, green: 0.176, blue: 0.176, alpha: 1).cgColor)
context.addPath(UIBezierPath(roundedRect: liveRect, cornerRadius: 3 * scale).cgPath)
context.fillPath()
(liveText as NSString).draw(at: CGPoint(x: x + livePadH, y: baseline - textSize), withAttributes: [.font: liveFont, .foregroundColor: UIColor.white])
x += liveW + 4 * scale
("TV" as NSString).draw(at: CGPoint(x: x, y: baseline - textSize), withAttributes: [.font: tvFont, .foregroundColor: UIColor(red: 0.612, green: 0.639, blue: 0.686, alpha: 1)])
}
private func drawTeamRow(
context: CGContext,
board: ScoreboardState,
isHome: Bool,
rowTop: CGFloat,
left: CGFloat,
pad: Int,
colorBarW: Int,
logoSize: Int,
logoGap: Int,
logoSlotW: Int,
teamW: Int,
colW: Int,
rowH: Int,
scoresLeft: CGFloat,
scale: CGFloat
) {
let accent = isHome ? board.homeAccentColor : board.awayAccentColor
let barLeft = left + CGFloat(pad)
let barTop = rowTop + 2 * scale
let barBottom = rowTop + CGFloat(rowH) - 2 * scale
context.setFillColor(accent.cgColor)
let barRect = CGRect(x: barLeft, y: barTop, width: CGFloat(colorBarW), height: barBottom - barTop)
context.addRect(barRect)
context.fillPath()
let logoUrl = isHome ? board.homeLogoUrl : board.awayLogoUrl
let logo = OverlayLogoCache.get(logoUrl)
var nameLeft = barLeft + CGFloat(colorBarW + logoGap)
if logoSlotW > 0 {
if let logo, let cg = logo.cgImage {
let logoLeft = nameLeft
let logoTop = rowTop + (CGFloat(rowH) - CGFloat(logoSize)) / 2
context.draw(cg, in: CGRect(x: logoLeft, y: logoTop, width: CGFloat(logoSize), height: CGFloat(logoSize)))
}
nameLeft += CGFloat(logoSize + logoGap)
}
let rawName = (isHome ? board.homeTeamName : board.awayTeamName).trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
let teamSize = max(9, 11 * scale)
let teamFont = UIFont.boldSystemFont(ofSize: teamSize)
let name = ellipsize(rawName, font: teamFont, maxWidth: CGFloat(teamW) - 6 * scale)
let nameBaseline = rowTop + CGFloat(rowH) * 0.72
(name as NSString).draw(at: CGPoint(x: nameLeft, y: nameBaseline - teamSize), withAttributes: [.font: teamFont, .foregroundColor: UIColor.white])
let scoreSize = max(10, 13 * scale)
let scoreFont = UIFont.boldSystemFont(ofSize: scoreSize)
let winColor = UIColor(red: 1, green: 0.176, blue: 0.176, alpha: 1)
for (index, column) in board.columns.enumerated() {
let cx = scoresLeft + CGFloat(index * colW + colW / 2)
let score = isHome ? column.homePoints : column.awayPoints
let color: UIColor
if column.isCurrent {
color = .white
} else {
let homeWon = column.homePoints > column.awayPoints
let awayWon = column.awayPoints > column.homePoints
color = (isHome && homeWon) || (!isHome && awayWon) ? winColor : .white
}
let text = "\(score)" as NSString
let size = text.size(withAttributes: [.font: scoreFont])
text.draw(at: CGPoint(x: cx - size.width / 2, y: nameBaseline - scoreSize), withAttributes: [.font: scoreFont, .foregroundColor: color])
}
}
private func ellipsize(_ text: String, font: UIFont, maxWidth: CGFloat) -> String {
let safeMax = max(48, maxWidth)
let attrs: [NSAttributedString.Key: Any] = [.font: font]
if (text as NSString).size(withAttributes: attrs).width <= safeMax { return text }
var result = text
while result.count > 1 && (result as NSString).size(withAttributes: attrs).width > safeMax {
result = String(result.dropLast())
}
return result + ""
}
private func ordinalSetLabel(_ setNumber: Int) -> String {
switch setNumber {
case 1: return "1° set"
case 2: return "2° set"
case 3: return "3° set"
case 4: return "4° set"
case 5: return "5° set"
default: return "Set \(setNumber)"
}
}
}

View File

@@ -0,0 +1,17 @@
import UIKit
struct SponsorElement: OverlayElement {
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout) {
guard let text = state.sponsorText?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return }
let fontSize = max(12, CGFloat(layout.canvasHeight) * 0.045)
let font = UIFont.boldSystemFont(ofSize: fontSize)
let attrs: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: UIColor(red: 0.961, green: 0.773, blue: 0.094, alpha: 0.86),
]
let size = (text as NSString).size(withAttributes: attrs)
let x = (CGFloat(layout.canvasWidth) - size.width) / 2
let y = CGFloat(layout.marginPx) * 2.2
(text as NSString).draw(at: CGPoint(x: x, y: y), withAttributes: attrs)
}
}

View File

@@ -0,0 +1,15 @@
import UIKit
struct WatermarkElement: OverlayElement {
private let logo = UIImage(named: "logo-white-m-256")
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout) {
guard state.overlayKind != .none, state.watermarkVisible, let logo, let cg = logo.cgImage else { return }
let targetWidth = min(72, max(28, Int((CGFloat(layout.canvasWidth) * 0.055).rounded())))
let scale = CGFloat(targetWidth) / logo.size.width
let targetHeight = Int(logo.size.height * scale)
let left = layout.canvasWidth - layout.marginPx - targetWidth
let top = layout.canvasHeight - layout.marginPx - targetHeight
context.draw(cg, in: CGRect(x: left, y: top, width: targetWidth, height: targetHeight))
}
}

View File

@@ -0,0 +1,16 @@
import HaishinKit
import SwiftUI
import UIKit
struct LivePreviewView: UIViewRepresentable {
let engine: LiveBroadcastEngine
func makeUIView(context: Context) -> MTHKView {
let view = MTHKView(frame: .zero)
view.videoGravity = .resizeAspectFill
Task { await engine.bindPreview(to: view) }
return view
}
func updateUIView(_ uiView: MTHKView, context: Context) {}
}

View File

@@ -0,0 +1,488 @@
import SwiftUI
private let sideToolbarWidth: CGFloat = 44
private let iconButtonSize: CGFloat = 36
private let scoreButtonHeight: CGFloat = 32
struct BroadcastControlsOverlay: View {
let controlsVisible: Bool
let onToggleControls: () -> Void
let statusText: String
let statusColor: Color
let cableConnected: Bool
let isPaused: Bool
let homeName: String
let awayName: String
let homeAccentColor: Color
let awayAccentColor: Color
let homeLogoUrl: String?
let awayLogoUrl: String?
let score: ScoreState
let boardType: String
let pointsTarget: Int
let onPointHome: () -> Void
let onPointAway: () -> Void
let onMinusHome: () -> Void
let onMinusAway: () -> Void
var onPoint2Home: (() -> Void)?
var onPoint3Home: (() -> Void)?
var onPoint2Away: (() -> Void)?
var onPoint3Away: (() -> Void)?
var onCloseSet: (() -> Void)?
var onAdvancePeriod: (() -> Void)?
let onPauseOrResume: () -> Void
let onTerminate: () -> Void
let onShareLive: () -> Void
let onShareRegia: () -> Void
let shareLiveEnabled: Bool
let fps: Int
let targetFps: Int
let bitrateKbps: Int
let networkType: String
let deviceHealth: DeviceHealth
@State private var showTerminateConfirm = false
var body: some View {
Color.clear
.overlay(alignment: .topLeading) {
MatchStatusBadge(
text: statusText,
backgroundColor: MatchColors.background.opacity(0.78),
textColor: statusColor
)
.padding(.leading, 8)
.padding(.top, 4)
}
.overlay(alignment: .topTrailing) {
VStack(alignment: .trailing, spacing: 6) {
SideIconButton(
systemName: controlsVisible ? "eye.slash.fill" : "eye.fill",
accessibilityLabel: controlsVisible ? "Nascondi controlli" : "Mostra controlli",
action: onToggleControls
)
BroadcastTelemetryPanel(
cableConnected: cableConnected,
fps: fps,
targetFps: targetFps,
bitrateKbps: bitrateKbps,
networkType: networkType,
deviceHealth: deviceHealth
)
}
.padding(.trailing, 8)
.padding(.top, 4)
}
.overlay(alignment: .leading) {
if controlsVisible {
leftToolbar
.padding(.leading, 6)
}
}
.overlay(alignment: .trailing) {
if controlsVisible {
rightToolbar
.padding(.trailing, 6)
}
}
.overlay(alignment: .bottom) {
if controlsVisible {
scoreControlsRow
.padding(.horizontal, sideToolbarWidth)
.padding(.bottom, 8)
}
}
.alert("Terminare la diretta?", isPresented: $showTerminateConfirm) {
Button("Annulla", role: .cancel) {}
Button("TERMINA", role: .destructive, action: onTerminate)
} message: {
Text("Lo streaming verrà chiuso per tutti gli spettatori.")
}
}
private var leftToolbar: some View {
VStack(spacing: 6) {
SideIconButton(
systemName: "square.and.arrow.up",
accessibilityLabel: "Condividi diretta",
action: onShareLive,
enabled: shareLiveEnabled
)
SideIconButton(
systemName: "video.fill",
accessibilityLabel: "Condividi link regia",
action: onShareRegia
)
if let onCloseSet {
SideIconButton(
systemName: "checkmark",
accessibilityLabel: "Chiudi set",
action: onCloseSet
)
}
if let onAdvancePeriod {
SideIconButton(
systemName: "forward.end.fill",
accessibilityLabel: "Periodo successivo",
action: onAdvancePeriod
)
}
}
}
private var rightToolbar: some View {
VStack(spacing: 6) {
SideIconButton(
systemName: isPaused ? "play.fill" : "pause.fill",
accessibilityLabel: isPaused ? "Riprendi diretta" : "Pausa diretta",
action: onPauseOrResume,
highlighted: isPaused
)
SideIconButton(
systemName: "stop.fill",
accessibilityLabel: "Termina diretta",
action: { showTerminateConfirm = true },
danger: true
)
}
}
private var scoreControlsRow: some View {
HStack(alignment: .bottom, spacing: 0) {
TeamScoreColumn(
teamLabel: "CASA",
teamName: homeName,
accentColor: homeAccentColor,
logoUrl: homeLogoUrl,
points: score.homePoints,
onPlus: onPointHome,
onMinus: onMinusHome,
onPlus2: onPoint2Home,
onPlus3: onPoint3Home,
showBasketButtons: boardType == "basket",
alignEnd: false
)
.frame(maxWidth: .infinity)
ScoreCenterPanel(score: score, boardType: boardType, pointsTarget: pointsTarget)
TeamScoreColumn(
teamLabel: "OSPITE",
teamName: awayName,
accentColor: awayAccentColor,
logoUrl: awayLogoUrl,
points: score.awayPoints,
onPlus: onPointAway,
onMinus: onMinusAway,
onPlus2: onPoint2Away,
onPlus3: onPoint3Away,
showBasketButtons: boardType == "basket",
alignEnd: true
)
.frame(maxWidth: .infinity)
}
}
}
private struct BroadcastTelemetryPanel: View {
let cableConnected: Bool
let fps: Int
let targetFps: Int
let bitrateKbps: Int
let networkType: String
let deviceHealth: DeviceHealth
var body: some View {
VStack(alignment: .trailing, spacing: 2) {
Text(cableConnected ? "Tabellone OK" : "Tabellone offline")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(cableConnected ? MatchColors.successGreen : MatchColors.textSecondary)
let fpsLabel = fps > 0 ? "\(fps) fps" : "— fps"
let targetSuffix = targetFps > 0 ? " / \(targetFps)" : ""
Text(fpsLabel + targetSuffix)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.white)
if bitrateKbps > 0 {
Text(formatBitrateKbps(bitrateKbps))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
}
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
ThermalIndicator(
tempC: deviceHealth.batteryTempC,
level: deviceHealth.thermalLevel,
label: deviceHealth.thermalLabel
)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(MatchColors.background.opacity(0.78), in: RoundedRectangle(cornerRadius: 8))
}
}
private struct ThermalIndicator: View {
let tempC: Double?
let level: Int
let label: String
private var color: Color {
switch level {
case 0: return MatchColors.successGreen
case 1: return MatchColors.accentYellow
case 2: return Color.orange
default: return MatchColors.primaryRed
}
}
var body: some View {
let tempText = tempC.map { "\(Int($0))°C" } ?? "—°C"
let warningBg = level >= 1 ? color.opacity(0.18) : Color.clear
HStack(spacing: 4) {
Text(tempText)
.font(.system(size: 11, weight: level >= 1 ? .bold : .regular))
.foregroundStyle(color)
if level >= 1 {
Text(label)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(color)
}
}
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(warningBg, in: RoundedRectangle(cornerRadius: 4))
}
}
private struct ScoreCenterPanel: View {
let score: ScoreState
let boardType: String
let pointsTarget: Int
var body: some View {
VStack(spacing: 2) {
Text("\(score.homePoints) - \(score.awayPoints)")
.font(MatchTypography.titleMedium)
.foregroundStyle(.white)
.fontWeight(.bold)
switch boardType {
case "basket", "timed":
Text(score.periodLabel ?? (boardType == "basket" ? "Q\(score.period)" : "\(score.period)° tempo"))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
case "generic":
EmptyView()
default:
Text("Set \(score.currentSet) · \(pointsTarget) pt")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
if score.homeSets > 0 || score.awaySets > 0 {
Text("Set vinti \(score.homeSets)-\(score.awaySets)")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
}
}
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
}
}
private struct TeamScoreColumn: View {
let teamLabel: String
let teamName: String
let accentColor: Color
let logoUrl: String?
let points: Int
let onPlus: () -> Void
let onMinus: () -> Void
var onPlus2: (() -> Void)?
var onPlus3: (() -> Void)?
let showBasketButtons: Bool
let alignEnd: Bool
var body: some View {
VStack(alignment: alignEnd ? .trailing : .leading, spacing: 0) {
TeamIdentityRow(
teamLabel: teamLabel,
teamName: teamName,
accentColor: accentColor,
logoUrl: logoUrl,
alignEnd: alignEnd
)
Spacer().frame(height: 6)
Text("\(points)")
.font(MatchTypography.headlineMedium)
.foregroundStyle(accentColor)
.fontWeight(.bold)
.padding(.vertical, 4)
Spacer().frame(height: 4)
let teamSide = alignEnd ? "ospite" : "casa"
HStack(spacing: 6) {
if alignEnd {
if showBasketButtons {
if let onPlus3 {
ScoreIconButton(label: "+3", tooltip: "+3 \(teamSide)", action: onPlus3, primary: true)
}
if let onPlus2 {
ScoreIconButton(label: "+2", tooltip: "+2 \(teamSide)", action: onPlus2, primary: true)
}
}
ScoreIconButton(label: "+1", tooltip: "Aggiungi punto \(teamSide)", action: onPlus, primary: !showBasketButtons)
ScoreIconButton(label: "", tooltip: "Togli punto \(teamSide)", action: onMinus)
} else {
ScoreIconButton(label: "", tooltip: "Togli punto \(teamSide)", action: onMinus)
ScoreIconButton(label: "+1", tooltip: "Aggiungi punto \(teamSide)", action: onPlus, primary: !showBasketButtons)
if showBasketButtons {
if let onPlus2 {
ScoreIconButton(label: "+2", tooltip: "+2 \(teamSide)", action: onPlus2, primary: true)
}
if let onPlus3 {
ScoreIconButton(label: "+3", tooltip: "+3 \(teamSide)", action: onPlus3, primary: true)
}
}
}
}
.frame(maxWidth: .infinity, alignment: alignEnd ? .trailing : .leading)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(MatchColors.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 10))
}
}
private struct TeamIdentityRow: View {
let teamLabel: String
let teamName: String
let accentColor: Color
let logoUrl: String?
let alignEnd: Bool
var body: some View {
let resolvedLogoUrl = MediaUrl.resolve(logoUrl)
HStack(alignment: .center, spacing: 0) {
if alignEnd {
VStack(alignment: .trailing, spacing: 0) {
Text(teamLabel)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
.kerning(1)
Text(teamName)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
.multilineTextAlignment(.trailing)
}
.frame(maxWidth: .infinity, alignment: .trailing)
if let resolvedLogoUrl, let url = URL(string: resolvedLogoUrl) {
Spacer().frame(width: 8)
TeamLogoThumbnail(url: url)
}
Spacer().frame(width: 6)
TeamColorBar(color: accentColor)
} else {
TeamColorBar(color: accentColor)
if let resolvedLogoUrl, let url = URL(string: resolvedLogoUrl) {
Spacer().frame(width: 6)
TeamLogoThumbnail(url: url)
}
Spacer().frame(width: 8)
VStack(alignment: .leading, spacing: 0) {
Text(teamLabel)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
.kerning(1)
Text(teamName)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
private struct TeamColorBar: View {
let color: Color
var body: some View {
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: 4, height: 36)
}
}
private struct TeamLogoThumbnail: View {
let url: URL
var body: some View {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
Color.clear
}
}
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(MatchColors.outline, lineWidth: 1))
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 6))
}
}
private struct SideIconButton: View {
let systemName: String
let accessibilityLabel: String
let action: () -> Void
var enabled: Bool = true
var highlighted: Bool = false
var danger: Bool = false
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(enabled ? .white : MatchColors.textSecondary)
.frame(width: iconButtonSize, height: iconButtonSize)
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(!enabled)
.accessibilityLabel(accessibilityLabel)
}
private var backgroundColor: Color {
if danger { return MatchColors.primaryRed.opacity(0.88) }
if highlighted { return MatchColors.successGreen.opacity(0.55) }
return MatchColors.background.opacity(0.78)
}
}
private struct ScoreIconButton: View {
let label: String
let tooltip: String
let action: () -> Void
var primary: Bool = false
var body: some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: label == "+1" ? 40 : scoreButtonHeight, height: scoreButtonHeight)
.background(primary ? MatchColors.primaryRed : MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
.accessibilityLabel(tooltip)
}
}
private func formatBitrateKbps(_ kbps: Int) -> String {
if kbps >= 1000 {
return String(format: "%.1f Mbps", Double(kbps) / 1000.0)
}
return "\(kbps) kbps"
}

View File

@@ -0,0 +1,448 @@
import SwiftUI
import UIKit
struct BroadcastScreen: View {
@ObservedObject var container: AppContainer
@ObservedObject private var scoreController: ScoreController
@ObservedObject private var broadcastCoordinator: LiveBroadcastCoordinator
let sessionId: String
let onFinished: () -> Void
@StateObject private var permissions = BroadcastPermissions()
@StateObject private var scoreDialogHost = LiveScoreDialogHost()
init(container: AppContainer, sessionId: String, onFinished: @escaping () -> Void) {
self.container = container
self.sessionId = sessionId
self.onFinished = onFinished
_scoreController = ObservedObject(wrappedValue: container.scoreController)
_broadcastCoordinator = ObservedObject(wrappedValue: container.broadcastCoordinator)
}
@State private var session: StreamSession?
@State private var match: Match?
@State private var error: String?
@State private var loading = true
@State private var controlsVisible = true
@State private var logoReady = 0
@State private var pauseInFlight = false
@State private var deviceHealth = DeviceTelemetry.snapshot()
@State private var snackbarMessage: String?
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if loading {
ProgressView().tint(MatchColors.primaryRed)
} else if !permissions.allGranted {
VStack(spacing: 12) {
Text("Consenti camera e microfono per andare in diretta")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.accentYellow)
.multilineTextAlignment(.center)
MatchPrimaryButton(label: "CONCEDI PERMESSI") {
Task { await permissions.requestAll() }
}
.padding(.horizontal, 40)
}
.padding(24)
} else if let session, let match {
LivePreviewView(engine: broadcastCoordinator.engine)
.ignoresSafeArea()
.allowsHitTesting(false)
broadcastOverlay(session: session, match: match)
.zIndex(1)
}
}
.statusBarHidden(true)
.lockLandscapeOrientation()
.onAppear {
UIDevice.current.isBatteryMonitoringEnabled = true
}
.onDisappear {
Task { await teardown() }
}
.task {
await permissions.refresh()
if !permissions.allGranted {
await permissions.requestAll()
}
}
.task(id: permissions.allGranted) {
guard permissions.allGranted else {
loading = false
return
}
await bootstrap()
}
.onChange(of: scoreController.score) { _ in updateOverlay() }
.onChange(of: broadcastCoordinator.metrics.phase) { _ in updateOverlay() }
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK") { onFinished() }
} message: {
Text(error ?? "")
}
.background {
if let match {
ScoreDialogRouter(
host: scoreDialogHost,
homeName: match.teamName,
awayName: match.opponentName
)
}
}
.overlay(alignment: .top) {
if let snackbarMessage {
Text(snackbarMessage)
.font(MatchTypography.bodyMedium)
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 8))
.padding(.top, 48)
.onAppear {
Task {
try? await Task.sleep(nanoseconds: 3_000_000_000)
self.snackbarMessage = nil
}
}
}
}
}
@ViewBuilder
private func broadcastOverlay(session: StreamSession, match: Match) -> some View {
let metrics = broadcastCoordinator.metrics
let score = scoreController.score
let isPaused = session.isPaused || metrics.phase == .paused
let statusText = broadcastStatusText(isPaused: isPaused, metrics: metrics)
let statusColor = broadcastStatusColor(isPaused: isPaused, metrics: metrics)
let boardType = match.boardType.isEmpty ? score.boardType : match.boardType
let usesActionScoring = boardType == "basket" || boardType == "timed"
let usesSetScoring = boardType == "volley" || boardType == "racket"
let pointsTarget = MatchScoringRules.pointsTarget(for: match, currentSet: score.currentSet)
let shareSubject = "\(match.teamName) vs \(match.opponentName)"
BroadcastControlsOverlay(
controlsVisible: controlsVisible,
onToggleControls: { controlsVisible.toggle() },
statusText: statusText,
statusColor: statusColor,
cableConnected: container.sessionCable.connected,
isPaused: isPaused,
homeName: match.teamName,
awayName: match.opponentName,
homeAccentColor: ColorHex.swiftUIColor(match.homePrimaryColor, fallback: Color(red: 1, green: 0.176, blue: 0.176)),
awayAccentColor: ColorHex.swiftUIColor(match.opponentPrimaryColor, fallback: Color(red: 0.118, green: 0.227, blue: 0.541)),
homeLogoUrl: match.homeLogoUrl,
awayLogoUrl: match.opponentLogoUrl,
score: score,
boardType: boardType,
pointsTarget: pointsTarget,
onPointHome: {
Task {
if usesActionScoring {
scoreController.applyAction("home_point")
} else if usesSetScoring {
await scoreController.applyBoardAction("home_point")
await liveScoreActions(for: match).afterPointChange(score: scoreController.score)
} else {
scoreController.incrementHome()
}
}
},
onPointAway: {
Task {
if usesActionScoring {
scoreController.applyAction("away_point")
} else if usesSetScoring {
await scoreController.applyBoardAction("away_point")
await liveScoreActions(for: match).afterPointChange(score: scoreController.score)
} else {
scoreController.incrementAway()
}
}
},
onMinusHome: {
Task {
if usesActionScoring {
scoreController.applyAction("home_undo")
} else if usesSetScoring {
await scoreController.applyBoardAction("home_undo")
} else {
scoreController.decrementHome()
}
}
},
onMinusAway: {
Task {
if usesActionScoring {
scoreController.applyAction("away_undo")
} else if usesSetScoring {
await scoreController.applyBoardAction("away_undo")
} else {
scoreController.decrementAway()
}
}
},
onPoint2Home: boardType == "basket" ? { scoreController.applyAction("home_point_2") } : nil,
onPoint3Home: boardType == "basket" ? { scoreController.applyAction("home_point_3") } : nil,
onPoint2Away: boardType == "basket" ? { scoreController.applyAction("away_point_2") } : nil,
onPoint3Away: boardType == "basket" ? { scoreController.applyAction("away_point_3") } : nil,
onCloseSet: usesSetScoring ? {
Task { await liveScoreActions(for: match).requestCloseSet() }
} : nil,
onAdvancePeriod: usesActionScoring ? { scoreController.applyAction("advance_period") } : nil,
onPauseOrResume: {
if isPaused {
Task { await resumeStream() }
} else {
pauseStream()
}
},
onTerminate: { Task { await stopStream() } },
onShareLive: { shareLiveLink(session: session, subject: shareSubject) },
onShareRegia: { shareRegiaLink(subject: shareSubject) },
shareLiveEnabled: session.watchShareUrl() != nil,
fps: metrics.fps,
targetFps: session.targetFps,
bitrateKbps: metrics.bitrateKbps,
networkType: deviceHealth.networkType,
deviceHealth: deviceHealth
)
}
private func liveScoreActions(for match: Match) -> LiveScoreActions {
LiveScoreActions(
rules: MatchScoringContext(match: match),
scoreController: scoreController,
dialogHost: scoreDialogHost,
onStopStream: { await stopStream() }
)
}
private func broadcastStatusText(isPaused: Bool, metrics: BroadcastMetrics) -> String {
if isPaused { return "PAUSA" }
switch metrics.phase {
case .live: return "IN DIRETTA"
case .connecting: return "CONNESSIONE…"
case .reconnecting: return "RICONNESSIONE…"
case .error: return metrics.lastError ?? "ERRORE"
default: return "PREVIEW"
}
}
private func broadcastStatusColor(isPaused: Bool, metrics: BroadcastMetrics) -> Color {
if isPaused { return MatchColors.accentYellow }
switch metrics.phase {
case .live: return MatchColors.successGreen
case .error: return MatchColors.primaryRed
default: return MatchColors.accentYellow
}
}
private func shareLiveLink(session: StreamSession, subject: String) {
guard let urlString = session.watchShareUrl(), let url = URL(string: urlString) else {
snackbarMessage = "Link diretta non ancora disponibile"
return
}
presentShare(items: [url], subject: subject)
}
private func shareRegiaLink(subject: String) {
Task {
do {
let urlString = try await container.sessionRepository.createRegiaLink(sessionId: sessionId)
guard let url = URL(string: urlString) else { return }
presentShare(items: [url], subject: "Link regia — \(subject)")
} catch {
snackbarMessage = UserFacingError.message(for: error) ?? "Errore link regia"
}
}
}
private func presentShare(items: [Any], subject: String) {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = scene.windows.first?.rootViewController else { return }
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
controller.setValue(subject, forKey: "subject")
root.present(controller, animated: true)
}
private func bootstrap() async {
loading = true
error = nil
await container.broadcastCoordinator.stopBroadcast()
do {
let loaded = try await container.sessionRepository.fetchSession(id: sessionId)
let loadedMatch = try await container.matchRepository.fetchMatch(matchId: loaded.matchId)
session = loaded
match = loadedMatch
container.scoreController.bind(sessionId: sessionId, initial: loaded.score)
wireCable()
if let url = loaded.rtmpIngestUrl, !url.isEmpty {
let config = broadcastConfig(for: loaded, rtmpUrl: url)
if loaded.isPaused {
try await container.broadcastCoordinator.engine.preparePreview(config: config)
} else {
try await container.broadcastCoordinator.startBroadcast(config: config)
}
}
await preloadLogos(for: loadedMatch)
startPolling()
loading = false
updateOverlay()
} catch {
if let message = UserFacingError.message(for: error) {
self.error = message
}
loading = false
}
}
private func wireCable() {
guard let token = container.tokenStore.session?.accessToken else { return }
container.sessionCable.onScoreUpdate = { [weak container] remote in
container?.scoreController.applyRemote(remote)
}
container.sessionCable.onPauseStream = { [weak container] in
Task { @MainActor in await container?.broadcastCoordinator.pauseBroadcast() }
}
container.sessionCable.onResumeStream = { [weak container] in
Task { @MainActor in
await resumeStream()
}
}
container.sessionCable.onStopStream = { [weak container] in
Task { @MainActor in await stopStream() }
}
container.sessionCable.connect(sessionId: sessionId, accessToken: token)
}
private func preloadLogos(for match: Match) async {
await OverlayLogoCache.preloadAll([match.homeLogoUrl, match.opponentLogoUrl])
logoReady += 1
}
private func updateOverlay() {
guard let match, let session else { return }
let overlayKind = OverlayKind.fromApi(match.effectiveOverlayKind)
let homeColor = ColorHex.parseColorHex(match.homePrimaryColor ?? "#FF2D2D")
let awayColor = ColorHex.parseColorHex(match.opponentPrimaryColor ?? "#1E3A8A")
let score = container.scoreController.score
let state: OverlayState
switch overlayKind {
case .none:
state = OverlayState(overlayKind: .none, watermarkVisible: false, broadcastStatus: container.broadcastCoordinator.metrics.phase.toOverlayStatus())
case .basket, .timed:
let period = score.periodLabel ?? (overlayKind == .basket ? "Q\(score.period)" : "\(score.period)° tempo")
state = OverlayState(
overlayKind: overlayKind,
compactScoreboard: CompactScoreboardState(
homeTeamName: match.teamName,
awayTeamName: match.opponentName,
homeScore: score.homePoints,
awayScore: score.awayPoints,
periodLabel: period,
homeAccentColor: homeColor,
awayAccentColor: awayColor,
homeLogoUrl: match.homeLogoUrl,
awayLogoUrl: match.opponentLogoUrl
),
broadcastStatus: container.broadcastCoordinator.metrics.phase.toOverlayStatus()
)
default:
state = OverlayState(
overlayKind: overlayKind,
scoreboard: score.toScoreboardState(
homeTeamName: match.teamName,
awayTeamName: match.opponentName,
homeAccentColor: homeColor,
awayAccentColor: awayColor,
homeLogoUrl: match.homeLogoUrl,
awayLogoUrl: match.opponentLogoUrl
),
broadcastStatus: container.broadcastCoordinator.metrics.phase.toOverlayStatus()
)
}
container.broadcastCoordinator.updateOverlay(state)
}
private func startPolling() {
Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 4_000_000_000)
if let remote = try? await container.sessionRepository.fetchSession(id: sessionId).score {
container.scoreController.applyRemote(remote)
}
}
}
Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 2_000_000_000)
deviceHealth = DeviceTelemetry.snapshot()
}
}
Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 10_000_000_000)
let health = DeviceTelemetry.snapshot()
let metrics = container.broadcastCoordinator.metrics
try? await container.sessionRepository.postTelemetry(
sessionId: sessionId,
health: health,
currentBitrate: metrics.bitrateKbps * 1000,
targetBitrate: session?.targetBitrate,
fps: metrics.fps
)
}
}
}
private func pauseStream() {
guard !pauseInFlight else { return }
pauseInFlight = true
Task {
_ = try? await container.sessionRepository.pauseSession(id: sessionId)
await container.broadcastCoordinator.pauseBroadcast()
pauseInFlight = false
}
}
private func resumeStream() async {
do {
var updated = try await container.sessionRepository.resumeSession(id: sessionId)
session = updated
guard let url = updated.rtmpIngestUrl, !url.isEmpty else { return }
let config = broadcastConfig(for: updated, rtmpUrl: url)
try await container.broadcastCoordinator.resumeBroadcast(config: config)
updated = try await container.sessionRepository.fetchSession(id: sessionId)
session = updated
} catch {
if let message = UserFacingError.message(for: error) {
self.error = message
}
}
}
private func broadcastConfig(for loaded: StreamSession, rtmpUrl: String) -> BroadcastConfig {
BroadcastConfig(
rtmpUrl: rtmpUrl,
width: 1280,
height: 720,
videoBitrate: loaded.targetBitrate,
audioBitrate: 128_000,
fps: loaded.targetFps
)
}
private func stopStream() async {
_ = try? await container.sessionRepository.stopSession(id: sessionId)
await teardown()
onFinished()
}
private func teardown() async {
container.sessionCable.disconnect()
await container.broadcastCoordinator.stopBroadcast()
}
}

View File

@@ -0,0 +1,164 @@
import Foundation
import SwiftUI
enum ScoreDialogKind {
case setWon
case closeSetAnyway
case matchWon
}
struct ScoreDialogState: Identifiable {
let id = UUID()
let kind: ScoreDialogKind
var winnerSide: ScoringSide?
var homePoints: Int = 0
var awayPoints: Int = 0
var homeSets: Int = 0
var awaySets: Int = 0
}
@MainActor
final class LiveScoreDialogHost: ObservableObject {
@Published private(set) var pending: ScoreDialogState?
private var waiter: CheckedContinuation<Bool, Never>?
func show(_ state: ScoreDialogState) async -> Bool {
guard pending == nil else { return false }
return await withCheckedContinuation { continuation in
pending = state
waiter = continuation
}
}
func resolve(_ value: Bool) {
pending = nil
waiter?.resume(returning: value)
waiter = nil
}
}
@MainActor
final class LiveScoreActions {
private let rules: MatchScoringContext
private let scoreController: ScoreController
private let dialogHost: LiveScoreDialogHost
private let onStopStream: () async -> Void
init(
rules: MatchScoringContext,
scoreController: ScoreController,
dialogHost: LiveScoreDialogHost,
onStopStream: @escaping () async -> Void
) {
self.rules = rules
self.scoreController = scoreController
self.dialogHost = dialogHost
self.onStopStream = onStopStream
}
func afterPointChange(score: ScoreState) async {
guard rules.usesSetLogic else { return }
guard let winner = rules.setWinnerFromPoints(
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet
) else { return }
let close = await dialogHost.show(
ScoreDialogState(
kind: .setWon,
winnerSide: winner,
homePoints: score.homePoints,
awayPoints: score.awayPoints
)
)
if close {
guard await scoreController.closeSetAsync() else { return }
await afterCloseSet()
}
}
func requestCloseSet() async {
guard rules.usesSetLogic else { return }
let score = scoreController.score
let winner = rules.setWinnerFromPoints(
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet
)
if winner == nil {
let ok = await dialogHost.show(ScoreDialogState(kind: .closeSetAnyway))
if !ok { return }
}
guard await scoreController.closeSetAsync() else { return }
await afterCloseSet()
}
private func afterCloseSet() async {
let score = scoreController.score
guard let winner = rules.matchWinner(homeSets: score.homeSets, awaySets: score.awaySets) else { return }
let stop = await dialogHost.show(
ScoreDialogState(
kind: .matchWon,
winnerSide: winner,
homeSets: score.homeSets,
awaySets: score.awaySets
)
)
if stop { await onStopStream() }
}
}
struct ScoreDialogRouter: View {
@ObservedObject var host: LiveScoreDialogHost
let homeName: String
let awayName: String
var body: some View {
Color.clear
.frame(width: 0, height: 0)
.alert(item: dialogBinding) { dialog in
alert(for: dialog)
}
}
private var dialogBinding: Binding<ScoreDialogState?> {
Binding(
get: { host.pending },
set: { newValue in
if newValue == nil, host.pending != nil {
host.resolve(false)
}
}
)
}
private func alert(for dialog: ScoreDialogState) -> Alert {
switch dialog.kind {
case .setWon:
let winner = dialog.winnerSide.map { scoringSideName($0, homeName: homeName, awayName: awayName) } ?? ""
return Alert(
title: Text("Set concluso"),
message: Text("\(winner) vince il set \(dialog.homePoints)-\(dialog.awayPoints).\n\nChiudere il set e passare al successivo?"),
primaryButton: .default(Text("Chiudi set")) { host.resolve(true) },
secondaryButton: .cancel(Text("Continua a segnare")) { host.resolve(false) }
)
case .closeSetAnyway:
return Alert(
title: Text("Chiudi set"),
message: Text("Il punteggio non soddisfa ancora le regole del torneo. Chiudere il set comunque?"),
primaryButton: .default(Text("Chiudi comunque")) { host.resolve(true) },
secondaryButton: .cancel(Text("Annulla")) { host.resolve(false) }
)
case .matchWon:
let winner = dialog.winnerSide.map { scoringSideName($0, homeName: homeName, awayName: awayName) } ?? ""
return Alert(
title: Text("Partita terminata"),
message: Text("\(winner) vince la partita (\(dialog.homeSets)-\(dialog.awaySets) set).\n\nChiudere definitivamente la diretta?"),
primaryButton: .default(Text("Chiudi diretta")) { host.resolve(true) },
secondaryButton: .cancel(Text("Continua in onda")) { host.resolve(false) }
)
}
}
}

View File

@@ -0,0 +1,40 @@
import SwiftUI
struct MatchLiveWordmark: View {
var compact: Bool = false
var showSlogan: Bool = false
var showLogo: Bool { !compact }
var body: some View {
VStack(spacing: 0) {
if showLogo {
Image("logo-white-m")
.resizable()
.scaledToFit()
.frame(width: compact ? 40 : 80, height: compact ? 40 : 80)
.padding(.bottom, 16)
}
HStack(spacing: 8) {
Text("MATCH")
.font(MatchTypography.displaySmall)
.foregroundStyle(.white)
Text("LIVE")
.font(MatchTypography.labelLarge)
.foregroundStyle(.white)
.padding(.horizontal, compact ? 8 : 12)
.padding(.vertical, compact ? 2 : 4)
.background(MatchColors.primaryRed, in: RoundedRectangle(cornerRadius: 6))
Text("TV")
.font(MatchTypography.displaySmall)
.foregroundStyle(MatchColors.textSecondary)
}
if showSlogan {
Text("OGNI PARTITA, OGNI EVENTO, PER I TUOI TIFOSI.")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
.multilineTextAlignment(.center)
.padding(.top, 12)
}
}
}
}

View File

@@ -0,0 +1,30 @@
import SwiftUI
struct MatchPrimaryButton: View {
let label: String
let action: () -> Void
var enabled: Bool = true
var loading: Bool = false
var body: some View {
Button(action: action) {
Group {
if loading {
ProgressView()
.tint(.white)
} else {
Text(label)
.font(MatchTypography.labelLarge)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
}
.frame(maxWidth: .infinity)
.frame(height: 52)
}
.buttonStyle(.plain)
.background(enabled && !loading ? MatchColors.primaryRed : MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 8))
.foregroundStyle(enabled && !loading ? .white : MatchColors.textSecondary)
.disabled(!enabled || loading)
}
}

View File

@@ -0,0 +1,25 @@
import SwiftUI
struct MatchScreenScaffold<Content: View, TopBar: View>: View {
@ViewBuilder var topBar: () -> TopBar
@ViewBuilder var content: () -> Content
init(
@ViewBuilder topBar: @escaping () -> TopBar = { EmptyView() },
@ViewBuilder content: @escaping () -> Content
) {
self.topBar = topBar
self.content = content
}
var body: some View {
VStack(spacing: 0) {
topBar()
content()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(MatchColors.background)
.foregroundStyle(.white)
}
}

View File

@@ -0,0 +1,25 @@
import SwiftUI
struct MatchSecondaryButton: View {
let label: String
let action: () -> Void
var enabled: Bool = true
var body: some View {
Button(action: action) {
Text(label)
.font(MatchTypography.labelLarge)
.lineLimit(1)
.minimumScaleFactor(0.7)
.frame(maxWidth: .infinity)
.frame(height: 52)
}
.buttonStyle(.plain)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(enabled ? MatchColors.outline : MatchColors.surfaceElevated, lineWidth: 1)
)
.foregroundStyle(enabled ? .white : MatchColors.textSecondary)
.disabled(!enabled)
}
}

View File

@@ -0,0 +1,16 @@
import SwiftUI
struct MatchStatusBadge: View {
let text: String
var backgroundColor: Color = MatchColors.surfaceElevated
var textColor: Color = MatchColors.accentYellow
var body: some View {
Text(text)
.font(MatchTypography.labelLarge)
.foregroundStyle(textColor)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 6))
}
}

View File

@@ -0,0 +1,125 @@
import SwiftUI
struct LoginScreen: View {
@ObservedObject var container: AppContainer
let onLoggedIn: () -> Void
@State private var email = ""
@State private var password = ""
@State private var error: String?
@State private var loading = false
@State private var passwordVisible = false
var body: some View {
MatchScreenScaffold {
ScrollView {
VStack(spacing: 0) {
MatchLiveWordmark(showSlogan: true)
.padding(.top, 32)
Text("ACCEDI")
.font(MatchTypography.headlineMedium)
.padding(.top, 48)
Text("Gestisci le dirette della tua squadra")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.multilineTextAlignment(.center)
.padding(.top, 8)
VStack(spacing: 16) {
MatchTextField(title: "Email", text: $email, placeholder: "coach@squadra.it", keyboard: .emailAddress)
MatchSecureField(title: "Password", text: $password, visible: $passwordVisible)
}
.padding(.top, 32)
if let error {
Text(error)
.foregroundStyle(MatchColors.primaryRed)
.multilineTextAlignment(.center)
.padding(.top, 12)
}
MatchPrimaryButton(
label: "ACCEDI",
action: submitLogin,
enabled: !email.isEmpty && !password.isEmpty,
loading: loading
)
.padding(.top, 32)
}
.padding(.horizontal, 24)
.padding(.bottom, 32)
}
}
}
private func submitLogin() {
guard !loading, !email.isEmpty, !password.isEmpty else { return }
loading = true
error = nil
Task {
do {
_ = try await container.authRepository.login(email: email.trimmingCharacters(in: .whitespaces), password: password)
onLoggedIn()
} catch {
let message = error.localizedDescription
if message.contains("401") {
self.error = "Email o password non corretti"
} else if message.localizedCaseInsensitiveContains("timeout") {
self.error = "Server non raggiungibile. Verifica la connessione."
} else {
self.error = message
}
}
loading = false
}
}
}
private struct MatchTextField: View {
let title: String
@Binding var text: String
var placeholder: String = ""
var keyboard: UIKeyboardType = .default
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
TextField(placeholder, text: $text)
.textInputAutocapitalization(.never)
.keyboardType(keyboard)
.autocorrectionDisabled()
.padding(12)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
}
}
}
private struct MatchSecureField: View {
let title: String
@Binding var text: String
@Binding var visible: Bool
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
HStack {
Group {
if visible {
TextField("", text: $text)
} else {
SecureField("", text: $text)
}
}
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button(action: { visible.toggle() }) {
Image(systemName: visible ? "eye.slash" : "eye")
.foregroundStyle(MatchColors.textSecondary)
}
}
.padding(12)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
}
}
}

View File

@@ -0,0 +1,533 @@
import SwiftUI
struct MatchesScreen: View {
@ObservedObject var container: AppContainer
var refreshToken: Int = 0
let onOpenSetup: (String) -> Void
let onOpenBroadcast: (String) -> Void
let onLogout: () -> Void
@State private var loading = true
@State private var refreshing = false
@State private var actionLoading = false
@State private var error: String?
@State private var teams: [Team] = []
@State private var activeTeam: Team?
@State private var matches: [Match] = []
@State private var showNewMatch = false
@State private var showSchedule = false
@State private var showTeamPicker = false
@State private var resumeMatch: Match?
@State private var deleteMatch: Match?
@State private var snackbar: String?
var body: some View {
MatchScreenScaffold(
topBar: {
HStack {
MatchLiveWordmark(compact: true)
Spacer()
Button("Esci") {
Task {
await container.authRepository.logout()
onLogout()
}
}
.foregroundStyle(MatchColors.textSecondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
},
content: {
Group {
if loading {
ProgressView().tint(MatchColors.primaryRed)
} else if teams.isEmpty {
VStack(spacing: 16) {
Text("Nessuna squadra disponibile")
.foregroundStyle(MatchColors.textSecondary)
MatchPrimaryButton(label: "RIPROVA", action: { reload(showSpinner: false) })
}
.padding(24)
} else if let error {
VStack(spacing: 16) {
Text(error).foregroundStyle(MatchColors.primaryRed).multilineTextAlignment(.center)
MatchPrimaryButton(label: "RIPROVA", action: { reload(showSpinner: false) })
}
.padding(24)
} else {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 16) {
Text("Ciao, \(container.tokenStore.session?.user.name ?? "")")
.font(MatchTypography.headlineMedium)
Text("Riprendi una diretta in corso o avvia una partita programmata.")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
HStack(spacing: 12) {
MatchSecondaryButton(label: "PARTITA PROGRAMMATA", action: { showSchedule = true })
MatchPrimaryButton(label: "NUOVA PARTITA", action: { showNewMatch = true })
}
if let activeTeam {
TeamPickerBar(
team: activeTeam,
showPicker: teams.count > 1,
onTap: { if teams.count > 1 { showTeamPicker = true } }
)
}
if let active = activeMatch {
ActiveSessionBanner(match: active) {
resumeMatch = active
}
}
}
.padding(.horizontal, 20)
.padding(.vertical, 8)
Text(calendarSectionTitle)
.font(MatchTypography.labelLarge)
.foregroundStyle(MatchColors.textSecondary)
.padding(.horizontal, 20)
.padding(.vertical, 8)
if calendarMatches.isEmpty && activeMatch == nil {
Text(emptyCalendarMessage)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.padding(.vertical, 12)
} else if calendarMatches.isEmpty {
Text("Nessuna altra partita in calendario.")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.padding(.vertical, 12)
} else {
ForEach(calendarMatches) { match in
MatchListCard(
match: match,
onTap: { openMatch(match) },
onDelete: (!match.hasActiveSession && !match.streamCompleted)
? { deleteMatch = match }
: nil
)
}
}
}
.padding(.bottom, 24)
}
.refreshable { reload(showSpinner: false) }
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
)
.task(id: refreshToken) { reload(showSpinner: refreshToken == 0) }
.alert("Riprendi diretta?", isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) {
Button("Riprendi") {
if let match = resumeMatch { resumeBroadcast(match) }
resumeMatch = nil
}
Button("Configura", role: .cancel) {
if let match = resumeMatch { onOpenSetup(match.id) }
resumeMatch = nil
}
}
.alert("Elimina partita?", isPresented: Binding(get: { deleteMatch != nil }, set: { if !$0 { deleteMatch = nil } })) {
Button("Elimina", role: .destructive) {
if let match = deleteMatch {
Task {
try? await container.matchRepository.deleteMatch(matchId: match.id)
reload(showSpinner: false)
}
}
deleteMatch = nil
}
Button("Annulla", role: .cancel) { deleteMatch = nil }
}
.sheet(isPresented: $showNewMatch) {
NewMatchSheet(
onSchedule: {
showNewMatch = false
showSchedule = true
},
onQuickStart: {
showNewMatch = false
createQuickMatch()
}
)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showSchedule) {
ScheduleMatchSheet(container: container, teamId: activeTeam?.id) { match in
showSchedule = false
onOpenSetup(match.id)
}
}
.sheet(isPresented: $showTeamPicker) {
TeamPickerSheet(teams: teams, activeTeamId: activeTeam?.id) { team in
container.matchRepository.selectTeam(teamId: team.id)
activeTeam = team
reload(showSpinner: false)
showTeamPicker = false
}
}
.overlay {
if actionLoading {
Color.black.opacity(0.35)
.ignoresSafeArea()
ProgressView().tint(MatchColors.primaryRed)
}
}
.overlay(alignment: .bottom) {
if let snackbar {
Text(snackbar)
.padding()
.background(MatchColors.surfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding()
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.snackbar = nil }
}
}
}
}
private var activeMatch: Match? {
matches.first { $0.canResumeCamera || ($0.hasActiveSession && $0.activeSessionStatus == "idle") }
}
private var scheduledMatches: [Match] {
matches.filter { !$0.hasActiveSession && MatchHubFilter.isScheduledFuture($0) }
}
private var calendarMatches: [Match] {
let drafts = matches.filter { MatchHubFilter.isDraft($0) }
return (scheduledMatches + drafts).uniqued(by: \.id)
}
private var calendarSectionTitle: String {
if calendarMatches.isEmpty && activeMatch == nil {
return "Nessuna partita in calendario"
}
if !scheduledMatches.isEmpty {
return "Partite programmate"
}
return "Pronte da avviare"
}
private var emptyCalendarMessage: String {
var message = "Programma una partita o avviane una nuova con «Nuova partita»."
if let teamName = activeTeam?.name {
message += "\n\nSquadra attiva: \(teamName)."
}
if teams.count > 1 {
message += "\nHai più squadre: verifica quella selezionata sopra."
}
return message
}
private func reload(showSpinner: Bool) {
if showSpinner { loading = true } else { refreshing = true }
error = nil
Task {
do {
let loadedTeams = try await container.matchRepository.fetchTeams()
teams = loadedTeams
activeTeam = container.matchRepository.resolveActiveTeam(teams: loadedTeams)
if let team = activeTeam {
matches = try await container.matchRepository.fetchMatchesForTeam(teamId: team.id)
}
} catch {
self.error = UserFacingError.message(for: error)
}
loading = false
refreshing = false
}
}
private func openMatch(_ match: Match) {
if match.hasActiveSession { resumeMatch = match } else { onOpenSetup(match.id) }
}
private func resumeBroadcast(_ match: Match) {
guard !actionLoading else { return }
actionLoading = true
Task {
do {
let sessionId = try await MatchSessionLauncher.resumeBroadcastSession(match: match, sessionRepository: container.sessionRepository)
onOpenBroadcast(sessionId)
} catch {
snackbar = UserFacingError.message(for: error)
}
actionLoading = false
}
}
private func createQuickMatch() {
guard let teamId = activeTeam?.id, !actionLoading else { return }
actionLoading = true
Task {
do {
let match = try await container.matchRepository.createQuickMatch(teamId: teamId)
reload(showSpinner: false)
onOpenSetup(match.id)
} catch {
snackbar = UserFacingError.message(for: error)
}
actionLoading = false
}
}
}
private struct TeamPickerBar: View {
let team: Team
var showPicker: Bool = true
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
Text(team.name).font(MatchTypography.titleMedium)
Spacer()
MatchStatusBadge(text: team.sportKey.uppercased())
if showPicker {
Image(systemName: "chevron.down")
.foregroundStyle(MatchColors.textSecondary)
}
}
.padding()
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.disabled(!showPicker)
}
}
private struct ActiveSessionBanner: View {
let match: Match
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
Image(systemName: "video.fill")
.foregroundStyle(MatchColors.primaryRed)
VStack(alignment: .leading, spacing: 4) {
Text("Riprendi diretta in corso")
.font(MatchTypography.titleMedium)
.foregroundStyle(MatchColors.primaryRed)
Text("\(match.teamName) vs \(match.opponentName)")
.font(MatchTypography.bodyMedium)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(MatchColors.primaryRed)
}
.padding(14)
.background(MatchColors.primaryRed.opacity(0.15), in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
private struct MatchListCard: View {
let match: Match
let onTap: () -> Void
let onDelete: (() -> Void)?
var body: some View {
Button(action: onTap) {
HStack(alignment: .center, spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text("\(match.teamName) vs \(match.opponentName)")
.font(MatchTypography.titleMedium)
if let date = ApiInstant.formatMatchDate(match.scheduledAt) {
Text(date)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
}
if let location = match.location?.trimmingCharacters(in: .whitespacesAndNewlines), !location.isEmpty {
Text(location)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
}
}
Spacer(minLength: 8)
Text(MatchPresentation.statusLabel(for: match))
.font(MatchTypography.labelLarge)
.foregroundStyle(match.hasActiveSession ? MatchColors.primaryRed : MatchColors.textSecondary)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
match.hasActiveSession
? MatchColors.primaryRed.opacity(0.2)
: MatchColors.surfaceElevated,
in: RoundedRectangle(cornerRadius: 8)
)
if let onDelete {
Button(action: onDelete) {
Image(systemName: "trash")
.foregroundStyle(MatchColors.textSecondary)
}
.buttonStyle(.plain)
}
}
.padding(16)
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.padding(.horizontal, 20)
.padding(.vertical, 6)
}
}
private struct NewMatchSheet: View {
let onSchedule: () -> Void
let onQuickStart: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Nuova partita")
.font(MatchTypography.headlineMedium)
Text("Programma in anticipo o avvia la configurazione diretta subito.")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 6)
VStack(spacing: 10) {
SheetOptionTile(
systemImage: "calendar.badge.clock",
title: "Programma partita",
subtitle: "Data, ora e avversario — visibile anche sul sito",
action: onSchedule
)
SheetOptionTile(
systemImage: "play.circle",
title: "Avvia subito",
subtitle: "Crea la partita e passa al wizard senza orario",
action: onQuickStart
)
}
.padding(.top, 20)
Spacer(minLength: 24)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(MatchColors.surface)
}
}
private struct SheetOptionTile: View {
let systemImage: String
let title: String
let subtitle: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 14) {
Image(systemName: systemImage)
.font(.title2)
.foregroundStyle(MatchColors.primaryRed)
.frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
Text(title).font(MatchTypography.titleMedium)
Text(subtitle)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(MatchColors.textSecondary)
}
.padding(16)
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
private struct ScheduleMatchSheet: View {
@ObservedObject var container: AppContainer
let teamId: String?
let onCreated: (Match) -> Void
@Environment(\.dismiss) private var dismiss
@State private var opponent = ""
@State private var location = ""
@State private var date = Date().addingTimeInterval(86400)
var body: some View {
NavigationStack {
Form {
TextField("Avversario", text: $opponent)
TextField("Luogo", text: $location)
DatePicker("Data", selection: $date)
}
.navigationTitle("Partita programmata")
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Chiudi") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("Crea") { create() }.disabled(opponent.isEmpty || teamId == nil)
}
}
}
}
private func create() {
guard let teamId else { return }
Task {
if let match = try? await container.matchRepository.createScheduledMatch(
teamId: teamId,
opponentName: opponent,
scheduledAt: date,
location: location.nilIfEmpty
) {
onCreated(match)
}
}
}
}
private struct TeamPickerSheet: View {
let teams: [Team]
let activeTeamId: String?
let onSelect: (Team) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(teams) { team in
Button {
onSelect(team)
dismiss()
} label: {
HStack {
Text(team.name)
Spacer()
if team.id == activeTeamId {
Image(systemName: "checkmark").foregroundStyle(MatchColors.primaryRed)
}
}
}
}
.navigationTitle("Squadra")
}
}
}
private extension Array {
func uniqued<ID: Hashable>(by keyPath: KeyPath<Element, ID>) -> [Element] {
var seen = Set<ID>()
return filter { seen.insert($0[keyPath: keyPath]).inserted }
}
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}

View File

@@ -0,0 +1,79 @@
import SwiftUI
struct AppNavHost: View {
@StateObject private var container = AppContainer()
@State private var path: [Routes] = []
@State private var wizardRoute: WizardRoute?
@State private var broadcastRoute: BroadcastRoute?
/// Incrementato al ritorno da wizard/broadcast per ricaricare l'hub partite.
@State private var matchesRefreshToken = 0
var body: some View {
NavigationStack(path: $path) {
SplashScreen(
container: container,
onAuthenticated: { path = [.matches] },
onUnauthenticated: { path = [.login] }
)
.navigationDestination(for: Routes.self) { route in
switch route {
case .splash:
EmptyView()
case .login:
LoginScreen(container: container) { path = [.matches] }
case .matches:
MatchesScreen(
container: container,
refreshToken: matchesRefreshToken,
onOpenSetup: { matchId in
wizardRoute = WizardRoute(matchId: matchId, step: 1)
},
onOpenBroadcast: { sessionId in
broadcastRoute = BroadcastRoute(sessionId: sessionId)
},
onLogout: { path = [.login] }
)
.lockPortraitOrientation()
case .setup, .broadcast:
EmptyView()
}
}
}
.lockPortraitOrientation()
.fullScreenCover(item: $wizardRoute) { route in
WizardShellScreen(
container: container,
matchId: route.matchId,
step: route.step,
onClose: {
container.wizardSession.reset()
wizardRoute = nil
matchesRefreshToken += 1
},
onStartLive: { sessionId in
wizardRoute = nil
broadcastRoute = BroadcastRoute(sessionId: sessionId)
}
)
.lockPortraitOrientation()
}
.fullScreenCover(item: $broadcastRoute, onDismiss: {
AppOrientation.lockPortrait()
matchesRefreshToken += 1
}) { route in
BroadcastScreen(container: container, sessionId: route.sessionId) {
AppOrientation.lockPortrait()
container.wizardSession.reset()
broadcastRoute = nil
path = [.matches]
}
.keepScreenOn()
}
.onChange(of: broadcastRoute?.sessionId) { sessionId in
if sessionId == nil {
AppOrientation.lockPortrait()
}
}
.environmentObject(container)
}
}

View File

@@ -0,0 +1,14 @@
import Foundation
struct WizardRoute: Identifiable, Equatable {
let matchId: String
let step: Int
var id: String { "\(matchId)-\(step)" }
}
struct BroadcastRoute: Identifiable, Equatable {
let sessionId: String
var id: String { sessionId }
}

View File

@@ -0,0 +1,9 @@
import Foundation
enum Routes: Hashable {
case splash
case login
case matches
case setup(matchId: String, step: Int)
case broadcast(sessionId: String)
}

View File

@@ -0,0 +1,24 @@
import AVFoundation
import SwiftUI
@MainActor
final class BroadcastPermissions: ObservableObject {
@Published var cameraGranted = false
@Published var microphoneGranted = false
var allGranted: Bool { cameraGranted && microphoneGranted }
func refresh() async {
cameraGranted = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
microphoneGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
}
func requestAll() async {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
let video = await AVCaptureDevice.requestAccess(for: .video)
cameraGranted = video
let audio = await AVCaptureDevice.requestAccess(for: .audio)
microphoneGranted = audio
}
}

View File

@@ -0,0 +1,27 @@
import SwiftUI
struct SplashScreen: View {
@ObservedObject var container: AppContainer
let onAuthenticated: () -> Void
let onUnauthenticated: () -> Void
var body: some View {
MatchScreenScaffold {
ZStack {
MatchLiveWordmark(showSlogan: true)
ProgressView()
.tint(MatchColors.primaryRed)
.frame(maxHeight: .infinity, alignment: .bottom)
.padding(.bottom, 48)
}
}
.task {
container.bootstrapAuth()
if await container.authRepository.validateOrRefresh() != nil {
onAuthenticated()
} else {
onUnauthenticated()
}
}
}
}

View File

@@ -0,0 +1,15 @@
import SwiftUI
import UIKit
struct KeepScreenOnModifier: ViewModifier {
func body(content: Content) -> some View {
content.onAppear { UIApplication.shared.isIdleTimerDisabled = true }
.onDisappear { UIApplication.shared.isIdleTimerDisabled = false }
}
}
extension View {
func keepScreenOn() -> some View {
modifier(KeepScreenOnModifier())
}
}

View File

@@ -0,0 +1,114 @@
import SwiftUI
import UIKit
enum AppOrientation {
enum Mode {
case portrait
case landscape
}
private(set) static var mode: Mode = .portrait
static func lockPortrait() {
apply(.portrait)
}
static func lockLandscape() {
apply(.landscape)
}
/// Torna al portrait (alias di `lockPortrait` per compatibilità).
static func unlock() {
lockPortrait()
}
static func apply(_ newMode: Mode) {
mode = newMode
refreshSupportedOrientations()
requestOrientationUpdate(for: newMode)
}
static var interfaceOrientationMask: UIInterfaceOrientationMask {
mode == .landscape ? .landscape : .portrait
}
private static func activeWindowScene() -> UIWindowScene? {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first
}
private static func refreshSupportedOrientations() {
guard let scene = activeWindowScene() else { return }
for window in scene.windows {
window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
topViewController(from: window.rootViewController)?.setNeedsUpdateOfSupportedInterfaceOrientations()
}
}
private static func requestOrientationUpdate(for mode: Mode) {
guard let scene = activeWindowScene() else { return }
let mask: UIInterfaceOrientationMask = mode == .landscape ? .landscape : .portrait
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { _ in }
if mode == .portrait, scene.interfaceOrientation.isLandscape {
UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
} else if mode == .landscape, scene.interfaceOrientation.isPortrait {
UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation")
}
}
private static func topViewController(from root: UIViewController?) -> UIViewController? {
if let presented = root?.presentedViewController {
return topViewController(from: presented)
}
if let nav = root as? UINavigationController {
return topViewController(from: nav.visibleViewController)
}
if let tab = root as? UITabBarController {
return topViewController(from: tab.selectedViewController)
}
return root
}
}
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
AppOrientation.lockPortrait()
return true
}
func application(
_ application: UIApplication,
supportedInterfaceOrientationsFor window: UIWindow?
) -> UIInterfaceOrientationMask {
AppOrientation.interfaceOrientationMask
}
}
private struct PortraitOrientationModifier: ViewModifier {
func body(content: Content) -> some View {
content
.onAppear { AppOrientation.lockPortrait() }
}
}
private struct LandscapeOrientationModifier: ViewModifier {
func body(content: Content) -> some View {
content
.onAppear { AppOrientation.lockLandscape() }
.onDisappear { AppOrientation.lockPortrait() }
}
}
extension View {
func lockPortraitOrientation() -> some View {
modifier(PortraitOrientationModifier())
}
func lockLandscapeOrientation() -> some View {
modifier(LandscapeOrientationModifier())
}
}

View File

@@ -0,0 +1,30 @@
import SwiftUI
enum MatchColors {
static let primaryRed = Color(red: 1, green: 0.176, blue: 0.176)
static let background = Color(red: 0.039, green: 0.039, blue: 0.039)
static let surface = Color(red: 0.118, green: 0.118, blue: 0.118)
static let surfaceElevated = Color(red: 0.165, green: 0.165, blue: 0.165)
static let accentYellow = Color(red: 0.961, green: 0.773, blue: 0.094)
static let successGreen = Color(red: 0.133, green: 0.773, blue: 0.369)
static let textSecondary = Color(red: 0.612, green: 0.639, blue: 0.686)
static let outline = Color(red: 0.251, green: 0.251, blue: 0.251)
}
struct MatchTypography {
static let displaySmall = Font.system(size: 28, weight: .black).leading(.loose)
static let headlineMedium = Font.system(size: 24, weight: .bold)
static let titleMedium = Font.system(size: 18, weight: .semibold)
static let bodyMedium = Font.system(size: 14, weight: .regular)
static let labelLarge = Font.system(size: 14, weight: .heavy).leading(.loose)
}
struct MatchLiveTheme<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
content()
.preferredColorScheme(.dark)
.tint(MatchColors.primaryRed)
}
}

View File

@@ -0,0 +1,438 @@
import SwiftUI
import UIKit
private enum WizardDefaults {
static let setsToWin = 3
static let pointsPerSet = 25
static let pointsDecidingSet = 15
static let minPointLead = 2
static let periods = 4
static let periodDurationSecs = 600
static let overtimeDurationSecs = 300
static let opponentColor = "#1E3A8A"
}
private func overlayLabel(_ key: String) -> String {
let name = OverlayKind.fromApi(key).rawValue
return name.prefix(1).uppercased() + name.dropFirst()
}
private func matchHasCustomOverlay(_ match: Match, sportDefaultOverlay: String?) -> Bool {
guard let defaultOverlay = sportDefaultOverlay, let current = match.overlayKind else { return false }
return current != defaultOverlay
}
private func matchHasCustomScoring(_ match: Match, boardType: String) -> Bool {
guard let rules = match.scoringRules else { return false }
switch boardType {
case "basket", "timed":
return rules.periods != WizardDefaults.periods
|| rules.periodDurationSecs != WizardDefaults.periodDurationSecs
|| rules.overtimeDurationSecs != WizardDefaults.overtimeDurationSecs
default:
if match.setsToWin != WizardDefaults.setsToWin { return true }
return rules.pointsPerSet != WizardDefaults.pointsPerSet
|| rules.pointsDecidingSet != WizardDefaults.pointsDecidingSet
|| rules.minPointLead != WizardDefaults.minPointLead
}
}
struct StepMatchScreen: View {
@ObservedObject var container: AppContainer
let match: Match
let onNext: () -> Void
let onError: (String) -> Void
@State private var opponent: String
@State private var location: String
@State private var campionato: String
@State private var homeTeam: Team?
@State private var homeLogoUrl: String?
@State private var opponentLogoUrl: String?
@State private var homePrimaryColor: String
@State private var opponentPrimaryColor: String
@State private var homeLogoImage: UIImage?
@State private var opponentLogoImage: UIImage?
@State private var saving = false
@State private var sports: [SportOption] = []
@State private var customRules = false
@State private var setsToWin: Int
@State private var pointsPerSet: Int
@State private var pointsDecidingSet: Int
@State private var pointsPerSetText: String
@State private var pointsDecidingSetText: String
@State private var periods: Int
@State private var periodDurationMins: Int
@State private var overtimeDurationMins: Int
@State private var periodDurationText: String
@State private var overtimeDurationText: String
@State private var customOverlay = false
@State private var selectedOverlay: String
private var isScheduledMatch: Bool { match.scheduledAt != nil }
init(container: AppContainer, match: Match, onNext: @escaping () -> Void, onError: @escaping (String) -> Void) {
self.container = container
self.match = match
self.onNext = onNext
self.onError = onError
let rules = match.scoringRules
_opponent = State(initialValue: match.opponentName)
_location = State(initialValue: match.location ?? "")
_campionato = State(initialValue: match.category ?? "")
_homeLogoUrl = State(initialValue: match.homeLogoUrl)
_opponentLogoUrl = State(initialValue: match.opponentLogoUrl)
_homePrimaryColor = State(initialValue: ColorHex.normalizeHexColor(match.homePrimaryColor))
_opponentPrimaryColor = State(initialValue: ColorHex.normalizeHexColor(match.opponentPrimaryColor, fallback: WizardDefaults.opponentColor))
_setsToWin = State(initialValue: min(max(match.setsToWin, 2), 3))
_pointsPerSet = State(initialValue: rules?.pointsPerSet ?? WizardDefaults.pointsPerSet)
_pointsDecidingSet = State(initialValue: rules?.pointsDecidingSet ?? WizardDefaults.pointsDecidingSet)
_pointsPerSetText = State(initialValue: "\(rules?.pointsPerSet ?? WizardDefaults.pointsPerSet)")
_pointsDecidingSetText = State(initialValue: "\(rules?.pointsDecidingSet ?? WizardDefaults.pointsDecidingSet)")
_periods = State(initialValue: rules?.periods ?? WizardDefaults.periods)
_periodDurationMins = State(initialValue: (rules?.periodDurationSecs ?? WizardDefaults.periodDurationSecs) / 60)
_overtimeDurationMins = State(initialValue: (rules?.overtimeDurationSecs ?? WizardDefaults.overtimeDurationSecs) / 60)
_periodDurationText = State(initialValue: "\((rules?.periodDurationSecs ?? WizardDefaults.periodDurationSecs) / 60)")
_overtimeDurationText = State(initialValue: "\((rules?.overtimeDurationSecs ?? WizardDefaults.overtimeDurationSecs) / 60)")
_selectedOverlay = State(initialValue: match.effectiveOverlayKind)
}
private var teamSportKey: String? {
homeTeam?.sportKey.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
}
private var effectiveSportKey: String {
teamSportKey ?? match.sportKey
}
private var currentSport: SportOption? {
sports.first { $0.key == effectiveSportKey }
}
private var allowedOverlays: [String] {
currentSport?.allowedOverlays ?? []
}
private var defaultOverlay: String {
currentSport?.overlay ?? match.effectiveOverlayKind
}
private var boardType: String {
currentSport?.board ?? homeTeam?.boardType ?? match.boardType
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Dettagli partita")
.font(MatchTypography.headlineMedium)
.padding(.bottom, 20)
TeamBrandingRow(
sectionLabel: "Squadra di casa",
teamName: .constant(match.teamName),
nameEditable: false,
remoteLogoUrl: homeLogoUrl,
localLogoImage: $homeLogoImage,
primaryColorHex: $homePrimaryColor,
placeholderColorSeed: "home-\(match.teamId)"
)
.padding(.bottom, 16)
TeamBrandingRow(
sectionLabel: "Squadra avversaria",
teamName: $opponent,
nameEditable: true,
remoteLogoUrl: opponentLogoUrl,
localLogoImage: $opponentLogoImage,
primaryColorHex: $opponentPrimaryColor,
placeholderColorSeed: "away-\(match.id)"
)
.padding(.bottom, 16)
WizardOutlinedField(label: "Luogo", text: $location)
.padding(.bottom, 12)
WizardOutlinedField(label: "Campionato (facoltativo)", text: $campionato)
Text("Es. Serie C, torneo estivo — lo useremo in descrizione e overlay.")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 4)
if isScheduledMatch {
WizardReadOnlyField(
label: "Programmata per",
value: ApiInstant.formatMatchDate(match.scheduledAt) ?? ""
)
.padding(.top, 16)
}
if !allowedOverlays.isEmpty {
Toggle("Overlay video personalizzato", isOn: $customOverlay)
.padding(.top, 20)
if customOverlay {
VStack(spacing: 6) {
ForEach(allowedOverlays, id: \.self) { overlayKey in
WizardChoiceButton(
label: overlayLabel(overlayKey),
selected: selectedOverlay == overlayKey,
action: { selectedOverlay = overlayKey }
)
}
}
.padding(.top, 12)
}
}
Toggle(isOn: $customRules) {
VStack(alignment: .leading, spacing: 4) {
Text("Regole punteggio personalizzate")
Text(customRulesDescription)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
}
}
.padding(.top, 20)
if customRules && ["basket", "timed"].contains(boardType) {
periodRulesSection
}
if customRules && ["volley", "racket"].contains(boardType) {
volleyRulesSection
}
MatchPrimaryButton(label: "AVANTI >", action: saveAndContinue, enabled: !saving, loading: saving)
.padding(.top, 32)
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 32)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.task(id: match.id) {
if let cached = container.wizardSession.team {
applyHomeTeam(cached)
} else if let team = await container.matchRepository.fetchTeam(teamId: match.teamId) {
container.wizardSession.team = team
applyHomeTeam(team)
}
if let loaded = try? await container.matchRepository.fetchSports() {
sports = loaded
}
customRules = matchHasCustomScoring(match, boardType: boardType)
customOverlay = matchHasCustomOverlay(match, sportDefaultOverlay: defaultOverlay)
if customOverlay {
selectedOverlay = match.overlayKind ?? match.effectiveOverlayKind
} else {
selectedOverlay = defaultOverlay
}
}
.onChange(of: customOverlay) { enabled in
if !enabled {
selectedOverlay = defaultOverlay
} else if !allowedOverlays.contains(selectedOverlay) {
selectedOverlay = defaultOverlay
}
}
.onChange(of: defaultOverlay) { overlay in
if !customOverlay {
selectedOverlay = overlay
}
}
}
private var customRulesDescription: String {
if !customRules && ["basket", "timed"].contains(boardType) {
return "Regole standard dello sport selezionato."
}
if customRules && ["basket", "timed"].contains(boardType) {
return "Torneo non standard: tempi e periodi personalizzati."
}
if customRules {
return "Torneo non standard: imposta set e punteggi."
}
return "Standard FIPAV: 3 set per vincere, set a 25, tie-break a 15."
}
private var periodRulesSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text(boardType == "basket" ? "Quarti" : "Tempi")
.font(MatchTypography.bodyMedium)
.padding(.top, 16)
HStack(spacing: 8) {
ForEach([2, 4], id: \.self) { value in
WizardChoiceButton(
label: "\(value)",
selected: periods == value,
action: { periods = value }
)
.frame(maxWidth: .infinity)
}
}
WizardOutlinedField(
label: boardType == "basket" ? "Minuti per quarto" : "Minuti per tempo",
text: $periodDurationText
)
.onChange(of: periodDurationText) { text in
let filtered = String(text.filter(\.isNumber).prefix(3))
if filtered != text { periodDurationText = filtered }
if let value = Int(filtered) { periodDurationMins = min(max(value, 1), 120) }
}
WizardOutlinedField(label: "Minuti supplementari", text: $overtimeDurationText)
.onChange(of: overtimeDurationText) { text in
let filtered = String(text.filter(\.isNumber).prefix(3))
if filtered != text { overtimeDurationText = filtered }
if let value = Int(filtered) { overtimeDurationMins = min(max(value, 1), 60) }
}
}
}
private var volleyRulesSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Set da vincere la partita")
.font(MatchTypography.bodyMedium)
.padding(.top, 16)
HStack(spacing: 8) {
ForEach([2, 3], id: \.self) { value in
WizardChoiceButton(
label: "\(value)",
selected: setsToWin == value,
action: { setsToWin = value }
)
.frame(maxWidth: .infinity)
}
}
WizardOutlinedField(label: "Punti per vincere un set", text: $pointsPerSetText)
.onChange(of: pointsPerSetText) { text in
let filtered = String(text.filter(\.isNumber).prefix(2))
if filtered != text { pointsPerSetText = filtered }
if let value = Int(filtered) { pointsPerSet = min(max(value, 1), 99) }
}
WizardOutlinedField(label: "Punti tie-break (ultimo set)", text: $pointsDecidingSetText)
.onChange(of: pointsDecidingSetText) { text in
let filtered = String(text.filter(\.isNumber).prefix(2))
if filtered != text { pointsDecidingSetText = filtered }
if let value = Int(filtered) { pointsDecidingSet = min(max(value, 1), 99) }
}
}
}
private func saveAndContinue() {
let trimmedOpponent = opponent.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedOpponent.isEmpty else {
onError("Inserisci il nome avversario")
return
}
if customRules && ["volley", "racket"].contains(boardType) {
guard let perSet = Int(pointsPerSetText), perSet >= 1 else {
onError("Inserisci i punti per vincere un set")
return
}
guard let deciding = Int(pointsDecidingSetText), deciding >= 1 else {
onError("Inserisci i punti del tie-break")
return
}
pointsPerSet = perSet
pointsDecidingSet = deciding
}
if customRules && ["basket", "timed"].contains(boardType) {
guard let periodMins = Int(periodDurationText), periodMins >= 1 else {
onError("Inserisci la durata del periodo in minuti")
return
}
guard let overtimeMins = Int(overtimeDurationText), overtimeMins >= 1 else {
onError("Inserisci la durata dei supplementari in minuti")
return
}
periodDurationMins = periodMins
overtimeDurationMins = overtimeMins
}
let resolvedSets: Int
if customRules && ["volley", "racket"].contains(boardType) {
resolvedSets = setsToWin
} else {
resolvedSets = WizardDefaults.setsToWin
}
let resolvedRules: ScoringRules?
if customRules && ["volley", "racket"].contains(boardType) {
resolvedRules = ScoringRules(
pointsPerSet: pointsPerSet,
pointsDecidingSet: pointsDecidingSet,
minPointLead: match.scoringRules?.minPointLead ?? WizardDefaults.minPointLead,
periods: WizardDefaults.periods,
periodDurationSecs: WizardDefaults.periodDurationSecs,
overtimeDurationSecs: WizardDefaults.overtimeDurationSecs
)
} else if customRules && ["basket", "timed"].contains(boardType) {
resolvedRules = ScoringRules(
pointsPerSet: WizardDefaults.pointsPerSet,
pointsDecidingSet: WizardDefaults.pointsDecidingSet,
minPointLead: WizardDefaults.minPointLead,
periods: periods,
periodDurationSecs: periodDurationMins * 60,
overtimeDurationSecs: overtimeDurationMins * 60
)
} else {
resolvedRules = nil
}
let initialHomeColor = ColorHex.normalizeHexColor(homeTeam?.primaryColor ?? match.homePrimaryColor)
let homeBrandingChanged =
ColorHex.normalizeHexColor(homePrimaryColor) != initialHomeColor || homeLogoImage != nil
let overlayKind: String? = {
if customOverlay && selectedOverlay != defaultOverlay { return selectedOverlay }
return ""
}()
guard !saving else { return }
saving = true
Task { @MainActor in
defer { saving = false }
do {
if homeBrandingChanged, let team = homeTeam {
_ = try await container.matchRepository.updateTeamBranding(
teamId: team.id,
primaryColor: ColorHex.normalizeHexColor(homePrimaryColor),
secondaryColor: team.secondaryColor,
logoImage: homeLogoImage
)
}
let updated = try await container.matchRepository.updateMatch(
matchId: match.id,
opponentName: trimmedOpponent,
location: location.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
scheduledAt: match.scheduledAt,
setsToWin: resolvedSets,
category: campionato.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
opponentPrimaryColor: opponentPrimaryColor,
scoringRules: resolvedRules,
sportKey: effectiveSportKey,
overlayKind: overlayKind,
opponentLogoImage: opponentLogoImage
)
container.wizardSession.match = updated
onNext()
} catch {
if let message = UserFacingError.message(for: error) {
onError(message)
}
}
}
}
private func applyHomeTeam(_ team: Team) {
homeTeam = team
homePrimaryColor = ColorHex.normalizeHexColor(team.primaryColor ?? match.homePrimaryColor)
homeLogoUrl = team.logoUrl ?? match.homeLogoUrl
}
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}

View File

@@ -0,0 +1,249 @@
import SwiftUI
import UIKit
struct StepNetworkTestScreen: View {
@ObservedObject var container: AppContainer
let match: Match
let session: StreamSession
let onBack: () -> Void
let onStartLive: (String) -> Void
let onError: (String) -> Void
@State private var testing = false
@State private var testCompleted = false
@State private var ready = false
@State private var downloadMbps = 0.0
@State private var uploadMbps = 0.0
@State private var latencyMs = 0
@State private var networkType = ""
@State private var selectedQualityLabel: String?
@State private var starting = false
@State private var currentSession: StreamSession
init(
container: AppContainer,
match: Match,
session: StreamSession,
onBack: @escaping () -> Void,
onStartLive: @escaping (String) -> Void,
onError: @escaping (String) -> Void
) {
self.container = container
self.match = match
self.session = session
self.onBack = onBack
self.onStartLive = onStartLive
self.onError = onError
_currentSession = State(initialValue: session)
}
private var shareUrl: String? {
currentSession.watchShareUrl()
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Test rete")
.font(MatchTypography.headlineMedium)
Text("Verifica che la connessione regga l'upload della diretta.")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 8)
HStack(spacing: 8) {
MetricCard(
label: "Download",
value: testing ? "..." : String(format: "%.1f Mbps", downloadMbps)
)
MetricCard(
label: "Upload",
value: testing ? "..." : String(format: "%.1f Mbps", uploadMbps),
highlight: ready
)
MetricCard(
label: "Latenza",
value: testing ? "..." : "\(latencyMs) ms"
)
}
.padding(.top, 24)
MetricCard(label: "Tipo rete", value: networkType)
.padding(.top, 12)
if testCompleted && ready {
Text("PRONTO PER ANDARE IN DIRETTA")
.font(MatchTypography.titleMedium)
.foregroundStyle(MatchColors.successGreen)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.padding(.top, 20)
if let quality = selectedQualityLabel {
WizardReadOnlyField(
label: "Qualità streaming (automatica)",
value: quality
)
.padding(.top, 12)
}
}
if testCompleted, let shareUrl {
WizardReadOnlyField(
label: currentSession.platform == "youtube" ? "Link YouTube" : "Link diretta",
value: shareUrl
)
.padding(.top, 16)
HStack(spacing: 8) {
MatchSecondaryButton(label: "COPIA", action: { copyToClipboard(shareUrl) })
if let url = URL(string: shareUrl) {
ShareLink(
item: url,
subject: Text("Diretta — \(match.teamName) vs \(match.opponentName)"),
message: Text(shareUrl)
) {
Text("CONDIVIDI")
.font(MatchTypography.labelLarge)
.frame(maxWidth: .infinity)
.frame(height: 52)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
}
.buttonStyle(.plain)
}
}
.padding(.top, 8)
MatchSecondaryButton(label: "CONDIVIDI LINK REGIA", action: shareRegiaLink)
.padding(.top, 8)
}
if !testCompleted {
MatchSecondaryButton(
label: testing ? "TEST IN CORSO..." : "AVVIA TEST RETE",
action: runTest,
enabled: !testing
)
.padding(.top, 24)
}
GeometryReader { proxy in
let spacing: CGFloat = 12
let backWidth = (proxy.size.width - spacing) / 3
let forwardWidth = (proxy.size.width - spacing) * 2 / 3
HStack(spacing: spacing) {
MatchSecondaryButton(label: "Indietro", action: onBack, enabled: !starting)
.frame(width: backWidth)
MatchPrimaryButton(
label: "INIZIA >",
action: startLive,
enabled: ready,
loading: starting
)
.frame(width: forwardWidth)
}
}
.frame(height: 52)
.padding(.top, 32)
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 32)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.task(id: session.id) {
await pollYoutubeIfNeeded()
}
}
private func runTest() {
testing = true
ready = false
Task { @MainActor in
networkType = DeviceTelemetry.snapshot().networkType
try? await Task.sleep(nanoseconds: 2_000_000_000)
downloadMbps = Double.random(in: 8...20)
uploadMbps = Double.random(in: 2...6)
latencyMs = Int.random(in: 20...99)
let result = try? await container.sessionRepository.submitNetworkTest(
sessionId: currentSession.id,
downloadMbps: downloadMbps,
uploadMbps: uploadMbps,
latencyMs: latencyMs,
networkType: networkType
)
if let result {
selectedQualityLabel = formatQualityLabel(result)
if let updated = try? await container.sessionRepository.fetchSession(id: currentSession.id) {
currentSession = updated
container.wizardSession.session = updated
}
}
ready = result?.ready ?? (uploadMbps >= 2.0)
testCompleted = true
testing = false
}
}
private func startLive() {
guard ready, !starting else { return }
starting = true
Task { @MainActor in
defer { starting = false }
do {
let started = try await container.sessionRepository.startSession(id: currentSession.id)
currentSession = started
container.wizardSession.session = started
onStartLive(started.id)
} catch {
if let message = UserFacingError.message(for: error) {
onError(message)
}
}
}
}
private func pollYoutubeIfNeeded() async {
guard currentSession.platform == "youtube", !currentSession.youtubeReady else { return }
for _ in 0..<20 {
try? await Task.sleep(nanoseconds: 3_000_000_000)
guard let updated = try? await container.sessionRepository.fetchSession(id: currentSession.id) else { continue }
currentSession = updated
container.wizardSession.session = updated
if updated.youtubeReady || !(updated.youtubeWatchUrl?.isEmpty ?? true) { return }
}
}
private func copyToClipboard(_ text: String) {
UIPasteboard.general.string = text
}
private func shareRegiaLink() {
Task { @MainActor in
do {
let url = try await container.sessionRepository.createRegiaLink(sessionId: currentSession.id)
guard let link = URL(string: url) else { return }
presentShare(items: [link])
} catch {
if let message = UserFacingError.message(for: error) {
onError(message)
}
}
}
}
private func presentShare(items: [Any]) {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = scene.windows.first?.rootViewController else { return }
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
root.present(controller, animated: true)
}
private func formatQualityLabel(_ result: NetworkTestResponse) -> String {
let bitrateMbps = Double(result.targetBitrate ?? 2_500_000) / 1_000_000.0
let fps = result.targetFps ?? 30
let resolution = result.qualityPreset?.hasPrefix("1080p") == true ? "1080p" : "720p"
return "\(resolution) · \(fps)fps · \(String(format: "%.1f", bitrateMbps)) Mbps"
}
}

View File

@@ -0,0 +1,147 @@
import SwiftUI
struct StepTransmissionScreen: View {
@ObservedObject var container: AppContainer
let match: Match
let onBack: () -> Void
let onNext: () -> Void
let onError: (String) -> Void
@State private var team: Team?
@State private var platform = "matchlivetv"
@State private var privacy = "public"
@State private var creating = false
private var youtubeReady: Bool {
team?.isYoutubeReady == true
}
private var youtubeSubtitle: String {
guard let team else { return "Canale in attivazione" }
if !team.canUseYoutube { return "Premium Light o Full" }
if !team.isYoutubeReady { return "Canale in attivazione" }
return team.youtubeDestinationLabel
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if let plan = team?.planName {
Text("Piano \(plan)")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.bottom, 12)
}
Text("Piattaforma")
.font(MatchTypography.headlineMedium)
WizardPlatformCard(
title: "Match Live TV",
subtitle: "Diretta sul nostro sito (incluso)",
selected: platform == "matchlivetv",
onClick: { platform = "matchlivetv" }
)
.padding(.top, 12)
WizardPlatformCard(
title: "YouTube Live",
subtitle: youtubeSubtitle,
selected: platform == "youtube",
enabled: youtubeReady,
badge: team?.canUseYoutube == false ? "Premium" : nil,
onClick: selectYoutube
)
.padding(.top, 8)
Text("Visibilità")
.font(MatchTypography.headlineMedium)
.padding(.top, 24)
HStack(spacing: 8) {
WizardChoiceButton(
label: "PUBBLICO",
selected: privacy == "public",
action: { privacy = "public" }
)
.frame(maxWidth: .infinity)
WizardChoiceButton(
label: "NON IN ELENCO",
selected: privacy == "unlisted",
action: { privacy = "unlisted" }
)
.frame(maxWidth: .infinity)
}
.padding(.top, 12)
Text(visibilityDescription)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 8)
Text("La partita resta sempre visibile nel backend della squadra per tutta la durata dell'abbonamento.")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 6)
WizardFooterButtons(
onBack: onBack,
forwardLabel: "AVANTI >",
forwardLoading: creating,
onForward: createSessionAndContinue
)
.padding(.top, 32)
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 32)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.task {
if let cached = container.wizardSession.team {
team = cached
} else {
team = await container.matchRepository.fetchTeam(teamId: match.teamId)
container.wizardSession.team = team
}
}
}
private var visibilityDescription: String {
if privacy == "public" {
return "Compare nell'elenco dirette su MatchLiveTV.it, nelle ricerche e sul canale YouTube (se selezionato)."
}
return "Non compare negli elenchi pubblici né nelle ricerche. Solo chi ha il link può guardare."
}
private func selectYoutube() {
if youtubeReady {
platform = "youtube"
} else {
onError("YouTube non disponibile per questa squadra")
}
}
private func createSessionAndContinue() {
creating = true
Task {
do {
let session = try await container.sessionRepository.createSession(
matchId: match.id,
platform: platform,
privacyStatus: privacy,
youtubeChannel: platform == "youtube" ? team?.effectiveYoutubeChannel : nil
)
container.wizardSession.session = session
onNext()
} catch {
if let message = UserFacingError.message(for: error) {
onError(message)
}
}
creating = false
}
}
}

View File

@@ -0,0 +1,224 @@
import PhotosUI
import SwiftUI
struct TeamBrandingRow: View {
let sectionLabel: String
@Binding var teamName: String
let nameEditable: Bool
let remoteLogoUrl: String?
@Binding var localLogoImage: UIImage?
@Binding var primaryColorHex: String
let placeholderColorSeed: String
@State private var showCustomize = false
private var hasLogo: Bool {
localLogoImage != nil || !(remoteLogoUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
}
private var isConfigured: Bool {
hasLogo && !primaryColorHex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var displayColorHex: String {
let trimmed = primaryColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? ColorHex.placeholderTeamColor(seed: placeholderColorSeed) : trimmed
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(sectionLabel)
.font(MatchTypography.labelLarge)
.foregroundStyle(MatchColors.textSecondary)
HStack(spacing: 10) {
if isConfigured {
TeamLogoImage(remoteLogoUrl: remoteLogoUrl, localLogoImage: localLogoImage, size: 44)
ColorAccentBar(color: ColorHex.swiftUIColor(displayColorHex), height: 36)
Text(teamName.isEmpty ? "Squadra" : teamName)
.font(MatchTypography.titleMedium)
.lineLimit(1)
} else {
ColorAccentBar(color: ColorHex.swiftUIColor(displayColorHex), height: 32)
if nameEditable {
TextField("Nome avversario", text: $teamName)
.font(MatchTypography.titleMedium)
.padding(8)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
} else {
Text(teamName.isEmpty ? "Squadra" : teamName)
.font(MatchTypography.titleMedium)
.lineLimit(1)
}
}
Spacer(minLength: 0)
Button { showCustomize = true } label: {
Image(systemName: "ellipsis")
.foregroundStyle(MatchColors.textSecondary)
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
.sheet(isPresented: $showCustomize) {
TeamBrandingCustomizeSheet(
title: sectionLabel,
teamName: $teamName,
nameEditable: nameEditable,
remoteLogoUrl: remoteLogoUrl,
localLogoImage: $localLogoImage,
primaryColorHex: $primaryColorHex,
fallbackColorHex: ColorHex.placeholderTeamColor(seed: placeholderColorSeed),
onDismiss: { showCustomize = false }
)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
}
}
private struct TeamBrandingCustomizeSheet: View {
let title: String
@Binding var teamName: String
let nameEditable: Bool
let remoteLogoUrl: String?
@Binding var localLogoImage: UIImage?
@Binding var primaryColorHex: String
let fallbackColorHex: String
let onDismiss: () -> Void
@State private var draftName: String = ""
@State private var draftColor: String = ""
@State private var logoPickerItem: PhotosPickerItem?
private var hasLogo: Bool {
localLogoImage != nil || !(remoteLogoUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Personalizza").font(MatchTypography.headlineMedium)
Text(title)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 4)
if nameEditable {
Text("Nome squadra").font(MatchTypography.labelLarge).padding(.top, 20)
TextField("Nome avversario", text: $draftName)
.padding(12)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
.padding(.top, 8)
} else {
Text("Nome squadra").font(MatchTypography.labelLarge).padding(.top, 20)
Text(teamName).font(MatchTypography.titleMedium).padding(.top, 4)
}
Text("Logo").font(MatchTypography.labelLarge).padding(.top, 20)
HStack(spacing: 14) {
TeamLogoImage(remoteLogoUrl: remoteLogoUrl, localLogoImage: localLogoImage, size: 72)
VStack(alignment: .leading, spacing: 8) {
PhotosPicker(selection: $logoPickerItem, matching: .images) {
Text("CARICA LOGO")
.font(MatchTypography.labelLarge)
.frame(maxWidth: .infinity)
.frame(height: 52)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
}
.buttonStyle(.plain)
if hasLogo {
MatchSecondaryButton(label: "RIMUOVI", action: {
localLogoImage = nil
logoPickerItem = nil
})
}
}
}
.padding(.top, 10)
Text("Colore squadra").font(MatchTypography.labelLarge).padding(.top, 20)
TeamColorPickerPanel(initialColorHex: draftColor.isEmpty ? fallbackColorHex : draftColor) { draftColor = $0 }
.padding(.top, 12)
MatchPrimaryButton(label: "SALVA", action: save)
.padding(.top, 24)
MatchSecondaryButton(label: "ANNULLA", action: onDismiss)
.padding(.top, 8)
}
.padding(.horizontal, 20)
.padding(.bottom, 32)
}
.background(MatchColors.surfaceElevated)
.onAppear {
draftName = teamName
draftColor = primaryColorHex.isEmpty ? fallbackColorHex : primaryColorHex
}
.onChange(of: logoPickerItem) { item in
guard let item else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
localLogoImage = image
}
}
}
}
private func save() {
if nameEditable { teamName = draftName.trimmingCharacters(in: .whitespacesAndNewlines) }
primaryColorHex = ColorHex.normalizeHexColor(draftColor, fallback: fallbackColorHex)
onDismiss()
}
}
private struct TeamLogoImage: View {
let remoteLogoUrl: String?
let localLogoImage: UIImage?
let size: CGFloat
var body: some View {
Group {
if let localLogoImage {
Image(uiImage: localLogoImage)
.resizable()
.scaledToFill()
} else if let urlString = MediaUrl.resolve(remoteLogoUrl), let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
if let image = phase.image {
image.resizable().scaledToFill()
} else {
placeholder
}
}
} else {
placeholder
}
}
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size > 50 ? 12 : 8))
.overlay(RoundedRectangle(cornerRadius: size > 50 ? 12 : 8).stroke(MatchColors.outline, lineWidth: 1))
}
private var placeholder: some View {
ZStack {
MatchColors.surface
Text("Nessun logo")
.font(.caption)
.foregroundStyle(MatchColors.textSecondary)
}
}
}
private struct ColorAccentBar: View {
let color: Color
let height: CGFloat
var body: some View {
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: 5, height: height)
}
}

View File

@@ -0,0 +1,91 @@
import SwiftUI
struct TeamColorPickerPanel: View {
let initialColorHex: String
let onColorChange: (String) -> Void
@State private var hue: Double = 0
@State private var saturation: Double = 1
@State private var brightness: Double = 1
var body: some View {
let selected = ColorHex.swiftUIColor(
ColorHex.hsvToHex(hue: hue, saturation: saturation, brightness: brightness)
)
VStack(spacing: 14) {
Circle()
.fill(selected)
.frame(width: 56, height: 56)
.overlay(Circle().stroke(MatchColors.outline, lineWidth: 2))
saturationBrightnessPicker
VStack(alignment: .leading, spacing: 4) {
Text("Tonalità")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
hueGradient
Slider(value: $hue, in: 0...360) { _ in emit() }
.tint(.white)
}
}
.onAppear {
let hsv = ColorHex.hsvComponents(from: initialColorHex)
hue = hsv.hue
saturation = hsv.saturation
brightness = hsv.brightness
}
.onChange(of: hue) { _ in emit() }
.onChange(of: saturation) { _ in emit() }
.onChange(of: brightness) { _ in emit() }
}
private var hueGradient: some View {
LinearGradient(
colors: [.red, .yellow, .green, .cyan, .blue, .purple, .red],
startPoint: .leading,
endPoint: .trailing
)
.frame(height: 16)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private var saturationBrightnessPicker: some View {
let baseHue = Color(
uiColor: UIColor(
hue: CGFloat(hue / 360),
saturation: 1,
brightness: 1,
alpha: 1
)
)
return GeometryReader { geo in
ZStack {
Rectangle().fill(.white)
Rectangle().fill(LinearGradient(colors: [.white, baseHue], startPoint: .leading, endPoint: .trailing))
Rectangle().fill(LinearGradient(colors: [.clear, .black], startPoint: .top, endPoint: .bottom))
Circle()
.strokeBorder(.white, lineWidth: 3)
.background(Circle().stroke(Color.black.opacity(0.35), lineWidth: 1.5))
.frame(width: 20, height: 20)
.position(
x: saturation * geo.size.width,
y: (1 - brightness) * geo.size.height
)
}
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(MatchColors.outline, lineWidth: 1))
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
saturation = min(max(value.location.x / geo.size.width, 0), 1)
brightness = 1 - min(max(value.location.y / geo.size.height, 0), 1)
emit()
}
)
}
.frame(height: 140)
}
private func emit() {
onColorChange(ColorHex.hsvToHex(hue: hue, saturation: saturation, brightness: brightness))
}
}

View File

@@ -0,0 +1,166 @@
import SwiftUI
private let wizardStepTitles = ["01 · Partita", "02 · Trasmissione", "03 · Test rete"]
func wizardStepTitle(_ step: Int) -> String {
wizardStepTitles[min(max(step, 1), 3) - 1]
}
struct WizardStepIndicator: View {
let currentStep: Int
var body: some View {
HStack(spacing: 6) {
ForEach(1...3, id: \.self) { step in
RoundedRectangle(cornerRadius: 2)
.fill(step <= currentStep ? MatchColors.primaryRed : MatchColors.surfaceElevated)
.frame(height: 4)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
}
struct WizardFooterButtons: View {
var showBack: Bool = true
let onBack: () -> Void
let forwardLabel: String
var forwardLoading: Bool = false
let onForward: () -> Void
var body: some View {
HStack(spacing: 12) {
if showBack {
MatchSecondaryButton(label: "Indietro", action: onBack)
.frame(maxWidth: .infinity)
}
MatchPrimaryButton(label: forwardLabel, action: onForward, loading: forwardLoading)
.frame(maxWidth: .infinity)
}
}
}
struct WizardOutlinedField: View {
let label: String
@Binding var text: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
TextField("", text: $text)
.padding(12)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(MatchColors.outline, lineWidth: 1)
)
}
}
}
struct WizardReadOnlyField: View {
let label: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(MatchTypography.bodyMedium)
Text(value).font(MatchTypography.titleMedium)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 10))
}
}
struct WizardChoiceButton: View {
let label: String
let selected: Bool
let action: () -> Void
var body: some View {
Group {
if selected {
MatchPrimaryButton(label: label, action: action)
} else {
MatchSecondaryButton(label: label, action: action)
}
}
}
}
struct MetricCard: View {
let label: String
let value: String
var highlight: Bool = false
var body: some View {
VStack(spacing: 4) {
Text(label)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
Text(value)
.font(MatchTypography.titleMedium)
.foregroundStyle(highlight ? MatchColors.successGreen : .white)
}
.frame(maxWidth: .infinity)
.padding(12)
.background(
highlight ? MatchColors.successGreen.opacity(0.12) : MatchColors.surfaceElevated,
in: RoundedRectangle(cornerRadius: 10)
)
}
}
struct WizardPlatformCard: View {
let title: String
let subtitle: String?
let selected: Bool
var enabled: Bool = true
var badge: String?
let onClick: () -> Void
var body: some View {
Button(action: onClick) {
HStack(alignment: .center, spacing: 12) {
Text(selected ? "" : "")
.foregroundStyle(selected ? MatchColors.primaryRed : MatchColors.textSecondary)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(MatchTypography.titleMedium)
.foregroundStyle(.white.opacity(enabled ? 1 : 0.55))
if let subtitle {
Text(subtitle)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary.opacity(enabled ? 1 : 0.55))
}
}
Spacer(minLength: 8)
if let badge {
Text(badge)
.font(MatchTypography.labelLarge)
.foregroundStyle(MatchColors.textSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 6))
}
}
.padding(14)
.background(
selected ? MatchColors.primaryRed.opacity(0.15) : MatchColors.surfaceElevated,
in: RoundedRectangle(cornerRadius: 10)
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(selected ? MatchColors.primaryRed : .clear, lineWidth: 1)
)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.disabled(!enabled)
.opacity(enabled ? 1 : 0.55)
.frame(maxWidth: .infinity)
}
}

View File

@@ -0,0 +1,113 @@
import SwiftUI
struct WizardShellScreen: View {
@ObservedObject var container: AppContainer
let matchId: String
let initialStep: Int
let onClose: () -> Void
let onStartLive: (String) -> Void
@State private var match: Match?
@State private var team: Team?
@State private var currentStep: Int
@State private var error: String?
init(container: AppContainer, matchId: String, step: Int, onClose: @escaping () -> Void, onStartLive: @escaping (String) -> Void) {
self.container = container
self.matchId = matchId
self.initialStep = step
self.onClose = onClose
self.onStartLive = onStartLive
_currentStep = State(initialValue: min(max(step, 1), 3))
}
var body: some View {
MatchScreenScaffold(
topBar: {
HStack(spacing: 12) {
Button(action: onClose) {
Image(systemName: "xmark").foregroundStyle(.white)
}
Text(wizardStepTitle(currentStep))
.font(MatchTypography.titleMedium)
Spacer(minLength: 0)
if currentStep == 1, let team {
MatchStatusBadge(text: team.sportKey.uppercased())
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
},
content: {
if let match {
VStack(spacing: 0) {
WizardStepIndicator(currentStep: currentStep)
Group {
switch currentStep {
case 1:
StepMatchScreen(
container: container,
match: match,
onNext: { currentStep = 2 },
onError: { presentError($0) }
)
case 2:
StepTransmissionScreen(
container: container,
match: match,
onBack: { currentStep = 1 },
onNext: { currentStep = 3 },
onError: { presentError($0) }
)
default:
if let session = container.wizardSession.session {
StepNetworkTestScreen(
container: container,
match: match,
session: session,
onBack: { currentStep = 2 },
onStartLive: onStartLive,
onError: { presentError($0) }
)
} else {
Text("Completa lo step Trasmissione")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
} else {
ProgressView().tint(MatchColors.primaryRed)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
)
.task {
do {
let loaded = try await container.matchRepository.fetchMatch(matchId: matchId)
match = loaded
container.wizardSession.match = loaded
team = await container.matchRepository.fetchTeam(teamId: loaded.teamId)
container.wizardSession.team = team
} catch {
if let message = UserFacingError.message(for: error) {
presentError(message)
}
}
}
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK", role: .cancel) {}
} message: {
Text(error ?? "")
}
}
private func presentError(_ message: String) {
guard !message.isEmpty else { return }
error = message
}
}

View File

@@ -0,0 +1,47 @@
import XCTest
/// Smoke test eseguibile senza @testable import (validazione logica pura).
final class ApiInstantTests: XCTestCase {
func testOverlayKindMapping() {
XCTAssertEqual(OverlayKindMapping.fromApi("basket"), "basket")
XCTAssertEqual(OverlayKindMapping.fromApi("none"), "none")
XCTAssertEqual(OverlayKindMapping.fromApi(nil), "volley")
}
func testRTMPUrlParser() {
let target = RTMPUrlParserLogic.parse("rtmp://stream.example.com/live/match_abc-123")
XCTAssertEqual(target?.connectURL, "rtmp://stream.example.com/live")
XCTAssertEqual(target?.streamName, "match_abc-123")
}
}
enum OverlayKindMapping {
static func fromApi(_ value: String?) -> String {
switch value?.lowercased() {
case "none", "timer": return "none"
case "basket": return "basket"
case "timed": return "timed"
case "racket": return "racket"
default: return "volley"
}
}
}
enum RTMPUrlParserLogic {
struct Target {
let connectURL: String
let streamName: String
}
static func parse(_ urlString: String) -> Target? {
guard let url = URL(string: urlString), url.scheme?.hasPrefix("rtmp") == true else { return nil }
let pathParts = url.path.split(separator: "/").map(String.init)
guard pathParts.count >= 2 else { return nil }
let app = pathParts.dropLast().joined(separator: "/")
let streamName = pathParts.last ?? ""
var connect = "rtmp://\(url.host ?? "")"
if let port = url.port { connect += ":\(port)" }
connect += "/\(app)"
return Target(connectURL: connect, streamName: streamName)
}
}

View File

@@ -0,0 +1,140 @@
import XCTest
@testable import MatchLiveTv
final class MatchScoringRulesTests: XCTestCase {
private func volleyMatch(setsToWin: Int = 3, rules: ScoringRules? = nil) -> Match {
Match(
id: "match-1",
teamId: "team-1",
teamName: "Tigers Volley",
opponentName: "Avversario",
location: nil,
scheduledAt: nil,
sportKey: "pallavolo",
sportLabel: "Pallavolo",
boardType: "volley",
overlayKind: "volley",
effectiveOverlayKind: "volley",
setsToWin: setsToWin,
category: nil,
scoringRules: rules,
activeSessionId: nil,
activeSessionStatus: nil,
streamCompleted: false,
coachHubVisible: nil,
homePrimaryColor: "#FF2D2D",
homeSecondaryColor: nil,
homeLogoUrl: nil,
opponentPrimaryColor: "#1E3A8A",
opponentLogoUrl: nil
)
}
func testSetWinnerAt26_12() {
let rules = MatchScoringContext(match: volleyMatch())
XCTAssertEqual(rules.setWinnerFromPoints(homePoints: 26, awayPoints: 12, currentSet: 1), .home)
}
func testSetWinnerRequiresMinLead() {
let rules = MatchScoringContext(match: volleyMatch())
XCTAssertNil(rules.setWinnerFromPoints(homePoints: 25, awayPoints: 24, currentSet: 1))
XCTAssertEqual(rules.setWinnerFromPoints(homePoints: 25, awayPoints: 23, currentSet: 1), .home)
}
func testDecidingSetUses15Points() {
let rules = MatchScoringContext(match: volleyMatch(setsToWin: 3))
XCTAssertEqual(rules.maxSets, 5)
XCTAssertEqual(rules.pointsTarget(currentSet: 5), 15)
XCTAssertEqual(rules.setWinnerFromPoints(homePoints: 15, awayPoints: 13, currentSet: 5), .home)
}
func testMatchWinnerAfterThreeSets() {
let rules = MatchScoringContext(match: volleyMatch(setsToWin: 3))
XCTAssertEqual(rules.matchWinner(homeSets: 3, awaySets: 1), .home)
XCTAssertNil(rules.matchWinner(homeSets: 2, awaySets: 1))
}
func testCustomScoringRulesFromMatch() {
let custom = ScoringRules(pointsPerSet: 21, pointsDecidingSet: 15, minPointLead: 2)
let rules = MatchScoringContext(match: volleyMatch(setsToWin: 2, rules: custom))
XCTAssertEqual(rules.pointsTarget(currentSet: 1), 21)
XCTAssertEqual(rules.setWinnerFromPoints(homePoints: 21, awayPoints: 19, currentSet: 1), .home)
}
func testUsesSetLogicOnlyForVolleyAndRacket() {
XCTAssertTrue(MatchScoringContext(match: volleyMatch()).usesSetLogic)
var basket = volleyMatch()
basket = Match(
id: basket.id,
teamId: basket.teamId,
teamName: basket.teamName,
opponentName: basket.opponentName,
location: basket.location,
scheduledAt: basket.scheduledAt,
sportKey: "basket",
sportLabel: "Basket",
boardType: "basket",
overlayKind: "basket",
effectiveOverlayKind: "basket",
setsToWin: 0,
category: nil,
scoringRules: nil,
activeSessionId: nil,
activeSessionStatus: nil,
streamCompleted: false,
coachHubVisible: nil,
homePrimaryColor: nil,
homeSecondaryColor: nil,
homeLogoUrl: nil,
opponentPrimaryColor: nil,
opponentLogoUrl: nil
)
XCTAssertFalse(MatchScoringContext(match: basket).usesSetLogic)
}
func testDecodesFlexibleScorePayload() throws {
let json = """
{
"board_type": "basket",
"home_points": 42,
"away_points": 38,
"current_set": 1,
"period": "Q3",
"clock_secs": 312,
"clock_running": 1,
"data": { "home_score": 42, "away_score": 38, "period": 3, "overtime": false }
}
""".data(using: .utf8)!
let dto = try JSONDecoder().decode(ScoreStateDto.self, from: json)
let score = dto.toDomain()
XCTAssertEqual(score.boardType, "basket")
XCTAssertEqual(score.homePoints, 42)
XCTAssertEqual(score.periodLabel, "Q3")
XCTAssertEqual(score.clockSecs, 312)
XCTAssertTrue(score.clockRunning)
}
}
final class ScoreboardOverlayTests: XCTestCase {
func testScoreboardColumnsAfterSetClosed() {
let score = ScoreState(
homeSets: 1,
awaySets: 0,
homePoints: 0,
awayPoints: 0,
currentSet: 2,
setPartials: [SetPartial(set: 1, home: 26, away: 12)],
boardType: "volley"
)
let board = score.toScoreboardState(homeTeamName: "Casa", awayTeamName: "Ospite")
XCTAssertEqual(board.columns.count, 2)
XCTAssertEqual(board.columns[0].setNumber, 1)
XCTAssertEqual(board.columns[0].homePoints, 26)
XCTAssertEqual(board.columns[0].awayPoints, 12)
XCTAssertFalse(board.columns[0].isCurrent)
XCTAssertEqual(board.columns[1].setNumber, 2)
XCTAssertTrue(board.columns[1].isCurrent)
XCTAssertEqual(board.columns[1].homePoints, 0)
XCTAssertEqual(board.columns[1].awayPoints, 0)
}
}

84
native/ios/README.md Normal file
View File

@@ -0,0 +1,84 @@
# Match Live TV — app iOS nativa
Porting SwiftUI dell'app Android con stessa UI, brand e contratti API backend.
## Stack
- UI: SwiftUI, NavigationStack
- Rete: URLSession + Codable
- Auth: Keychain
- Streaming: HaishinKit (RTMP) + AVFoundation
- Overlay: Core Graphics (bitmap composito su pipeline video)
## Struttura
```
MatchLiveTv/
App/ — entry point
Core/ — config, token store, utility
Data/ — API, repository, ActionCable
Domain/ — modelli
Streaming/ — motore RTMP, overlay nativi
UI/ — schermate SwiftUI
Resources/ — Assets.xcassets (brand), Info.plist
```
## Parità multi-sport (riferimento: `docs/TABELLONI_E_OVERLAY.md`)
| Board | Controlli broadcast | Overlay video | Stato iOS |
|-------|---------------------|---------------|-----------|
| volley / racket | +1, undo, chiudi set (API `close_set`) | Tabellone a colonne | Completo |
| basket | +1/+2/+3, undo, `advance_period` | Compact scoreboard | Completo |
| timed | +1, undo, `advance_period` | Compact scoreboard | Completo |
| generic | +1 / undo locale | Watermark | Completo |
| timer | — | Solo watermark | Completo |
Il cronometro basket/timed è gestito in **regia web**, non sull'overlay video (come Android).
## Asset brand
Gli asset in `brand/` sono importati in `Resources/Assets.xcassets`:
- `logo-white-m`, `logo-white-m-256` — wordmark e watermark
- `logo-dark-gradient`, `logo-dark-gradient-256` — app icon
- `CopertinaCanale*.png` — riferimenti visivi brand
## Build
```bash
python3 generate_xcodeproj.py # rigenera MatchLiveTv.xcodeproj
./scripts/build_ios_release.sh
```
Override API: `API_BASE_URL=https://staging.example.com ./scripts/build_ios_release.sh`
## Test
```bash
cd native/ios
python3 generate_xcodeproj.py
xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-derivedDataPath build/DerivedData \
ONLY_ACTIVE_ARCH=YES ARCHS=arm64 EXCLUDED_ARCHS=x86_64 \
test -only-testing:MatchLiveTvTests
```
## Requisiti
- Xcode 16+
- iOS 16+
- Mac con Apple Silicon o Intel
## Funzionalità
- Login JWT, hub partite (reload al ritorno da diretta), wizard 3 step
- Diretta RTMP 1280×720 con overlay tabellone/watermark
- ActionCable SessionChannel per punteggio e comandi regia
- Telemetria dispositivo periodica
- Orientamento: portrait ovunque, landscape solo in broadcast
## Documentazione correlata
- [`docs/TABELLONI_E_OVERLAY.md`](../../docs/TABELLONI_E_OVERLAY.md) — regolamenti, board, overlay
- [`docs/MULTI_SPORT.md`](../../docs/MULTI_SPORT.md) — catalogo sport e engine scoring
- [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) — infrastruttura e flussi live/HLS

183
native/ios/generate_xcodeproj.py Executable file
View File

@@ -0,0 +1,183 @@
#!/usr/bin/env python3
from pathlib import Path
import uuid
ROOT = Path(__file__).parent
APP_DIR = ROOT / "MatchLiveTv"
TEST_DIR = ROOT / "MatchLiveTvTests"
OUT = ROOT / "MatchLiveTv.xcodeproj" / "project.pbxproj"
def uid():
return uuid.uuid4().hex[:24].upper()
app_files = sorted(APP_DIR.rglob("*.swift"))
test_files = sorted(TEST_DIR.rglob("*.swift"))
ids = {f: uid() for f in app_files + test_files}
bf = {f: uid() for f in app_files + test_files}
assets_ref, assets_bf = uid(), uid()
info_ref = uid()
app_product, test_product = uid(), uid()
app_target, test_target = uid(), uid()
app_sources, app_frameworks, app_resources = uid(), uid(), uid()
test_sources, test_frameworks = uid(), uid()
main_group, app_group, test_group, products_group = uid(), uid(), uid(), uid()
proj = uid()
pkg = uid()
hk, rtmp = uid(), uid()
hk_bf, rtmp_bf = uid(), uid()
bcl_proj_dbg, bcl_proj_rel = uid(), uid()
bcl_app_dbg, bcl_app_rel = uid(), uid()
bcl_test_dbg, bcl_test_rel = uid(), uid()
cfg_app_dbg, cfg_app_rel = uid(), uid()
cfg_test_dbg, cfg_test_rel = uid(), uid()
cfg_proj_dbg, cfg_proj_rel = uid(), uid()
bcl_app, bcl_test, bcl_proj = uid(), uid(), uid()
test_dep, test_dep_proxy = uid(), uid()
scheme_id = uid()
lines = [
"// !$*UTF8*$!",
"{",
"\tarchiveVersion = 1;",
"\tclasses = {};",
"\tobjectVersion = 60;",
"\tobjects = {",
]
for f, fid in ids.items():
lines.append(f'\t\t{fid} /* {f.name} */ = {{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = {f.name}; path = {f.relative_to(ROOT)}; sourceTree = "<group>"; }};')
lines += [
f'\t\t{assets_ref} /* Assets.xcassets */ = {{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MatchLiveTv/Resources/Assets.xcassets; sourceTree = "<group>"; }};',
f'\t\t{info_ref} /* Info.plist */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MatchLiveTv/Resources/Info.plist; sourceTree = "<group>"; }};',
f'\t\t{app_product} /* MatchLiveTv.app */ = {{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MatchLiveTv.app; sourceTree = BUILT_PRODUCTS_DIR; }};',
f'\t\t{test_product} /* MatchLiveTvTests.xctest */ = {{isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MatchLiveTvTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }};',
f'\t\t{hk} /* HaishinKit */ = {{isa = XCSwiftPackageProductDependency; package = {pkg}; productName = HaishinKit; }};',
f'\t\t{rtmp} /* RTMPHaishinKit */ = {{isa = XCSwiftPackageProductDependency; package = {pkg}; productName = RTMPHaishinKit; }};',
]
for f, bid in bf.items():
lines.append(f'\t\t{bid} /* {f.name} in Sources */ = {{isa = PBXBuildFile; fileRef = {ids[f]} /* {f.name} */; }};')
lines += [
f'\t\t{assets_bf} /* Assets in Resources */ = {{isa = PBXBuildFile; fileRef = {assets_ref}; }};',
]
app_children = ", ".join(ids[f] for f in app_files) + f", {assets_ref}, {info_ref}"
test_children = ", ".join(ids[f] for f in test_files)
lines += [
f'\t\t{main_group} = {{isa = PBXGroup; children = ({app_group}, {test_group}, {products_group}); sourceTree = "<group>"; }};',
f'\t\t{app_group} = {{isa = PBXGroup; children = ({app_children}); name = MatchLiveTv; sourceTree = "<group>"; }};',
f'\t\t{test_group} = {{isa = PBXGroup; children = ({test_children}); name = MatchLiveTvTests; sourceTree = "<group>"; }};',
f'\t\t{products_group} = {{isa = PBXGroup; children = ({app_product}, {test_product}); name = Products; sourceTree = "<group>"; }};',
f'\t\t{app_sources} = {{isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ({", ".join(bf[f] for f in app_files)}); runOnlyForDeploymentPostprocessing = 0; }};',
f'\t\t{test_sources} = {{isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ({", ".join(bf[f] for f in test_files) if test_files else ""}); runOnlyForDeploymentPostprocessing = 0; }};',
f'\t\t{app_resources} = {{isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ({assets_bf}); runOnlyForDeploymentPostprocessing = 0; }};',
f'\t\t{app_frameworks} = {{isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }};',
f'\t\t{test_frameworks} = {{isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }};',
]
app_settings = """
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
"""
app_debug_settings = app_settings + """
ENABLE_TESTABILITY = YES;
"""
test_settings = """
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MatchLiveTv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MatchLiveTv";
"""
lines += [
f'\t\t{cfg_app_dbg} = {{isa = XCBuildConfiguration; buildSettings = {{{app_debug_settings}\t\t\t}}; name = Debug; }};',
f'\t\t{cfg_app_rel} = {{isa = XCBuildConfiguration; buildSettings = {{{app_settings}\t\t\t}}; name = Release; }};',
f'\t\t{cfg_test_dbg} = {{isa = XCBuildConfiguration; buildSettings = {{{test_settings}\t\t\t}}; name = Debug; }};',
f'\t\t{cfg_test_rel} = {{isa = XCBuildConfiguration; buildSettings = {{{test_settings}\t\t\t}}; name = Release; }};',
f'\t\t{cfg_proj_dbg} = {{isa = XCBuildConfiguration; buildSettings = {{IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; }}; name = Debug; }};',
f'\t\t{cfg_proj_rel} = {{isa = XCBuildConfiguration; buildSettings = {{IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; }}; name = Release; }};',
f'\t\t{bcl_app} = {{isa = XCConfigurationList; buildConfigurations = ({cfg_app_dbg}, {cfg_app_rel}); defaultConfigurationName = Release; }};',
f'\t\t{bcl_test} = {{isa = XCConfigurationList; buildConfigurations = ({cfg_test_dbg}, {cfg_test_rel}); defaultConfigurationName = Release; }};',
f'\t\t{bcl_proj} = {{isa = XCConfigurationList; buildConfigurations = ({cfg_proj_dbg}, {cfg_proj_rel}); defaultConfigurationName = Release; }};',
f'\t\t{app_target} = {{isa = PBXNativeTarget; buildConfigurationList = {bcl_app}; buildPhases = ({app_sources}, {app_frameworks}, {app_resources}); buildRules = (); dependencies = (); name = MatchLiveTv; packageProductDependencies = ({hk}, {rtmp}); productName = MatchLiveTv; productReference = {app_product}; productType = "com.apple.product-type.application"; }};',
f'\t\t{test_dep_proxy} = {{isa = PBXContainerItemProxy; containerPortal = {proj} /* Project object */; proxyType = 1; remoteGlobalIDString = {app_target}; remoteInfo = MatchLiveTv; }};',
f'\t\t{test_dep} = {{isa = PBXTargetDependency; target = {app_target} /* MatchLiveTv */; targetProxy = {test_dep_proxy} /* PBXContainerItemProxy */; }};',
f'\t\t{test_target} = {{isa = PBXNativeTarget; buildConfigurationList = {bcl_test}; buildPhases = ({test_sources}, {test_frameworks}); buildRules = (); dependencies = ({test_dep}); name = MatchLiveTvTests; productName = MatchLiveTvTests; productReference = {test_product}; productType = "com.apple.product-type.bundle.unit-test"; }};',
f'\t\t{pkg} = {{isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/shogo4405/HaishinKit.swift"; requirement = {{kind = upToNextMajorVersion; minimumVersion = 2.0.0;}}; }};',
f'\t\t{proj} = {{isa = PBXProject; attributes = {{BuildIndependentTargetsInParallel = 0; LastSwiftUpdateCheck = 1600;}}; buildConfigurationList = {bcl_proj}; compatibilityVersion = "Xcode 15.0"; developmentRegion = it; mainGroup = {main_group}; packageReferences = ({pkg}); productRefGroup = {products_group}; targets = ({app_target}, {test_target}); }};',
"\t};",
f"\trootObject = {proj} /* Project object */;",
"}",
]
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text("\n".join(lines) + "\n")
(OUT.parent / "project.xcworkspace").mkdir(exist_ok=True)
(OUT.parent / "project.xcworkspace" / "contents.xcworkspacedata").write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n<Workspace version="1.0">\n <FileRef location="self:"></FileRef>\n</Workspace>\n'
)
scheme_dir = OUT.parent / "xcshareddata" / "xcschemes"
scheme_dir.mkdir(parents=True, exist_ok=True)
(scheme_dir / "MatchLiveTv.xcscheme").write_text(f"""<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="1600" version="1.7">
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
<BuildActionEntries>
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{app_target}" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildActionEntry>
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{test_target}" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
<Testables>
<TestableReference skipped="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{test_target}" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{app_target}" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{app_target}" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration="Debug"/>
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
</Scheme>
""")
print(f"Wrote {OUT} ({len(app_files)} app sources, {len(test_files)} test sources)")