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 @State private var shareItem: ShareItem? 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(L10n.t("wizard.network.test.title")) .font(MatchTypography.headlineMedium) Text(L10n.t("wizard.network.test.subtitle")) .font(MatchTypography.bodyMedium) .foregroundStyle(MatchColors.textSecondary) .padding(.top, 8) HStack(spacing: 8) { MetricCard( label: L10n.t("wizard.network.download.label"), value: testing ? "..." : String(format: "%.1f Mbps", downloadMbps) ) MetricCard( label: L10n.t("wizard.network.upload.label"), value: testing ? "..." : String(format: "%.1f Mbps", uploadMbps), highlight: ready ) MetricCard( label: L10n.t("wizard.network.latency.label"), value: testing ? "..." : "\(latencyMs) ms" ) } .padding(.top, 24) MetricCard(label: L10n.t("wizard.network.type.label"), value: networkType) .padding(.top, 12) if testCompleted && ready { Text(L10n.t("wizard.network.ready.label")) .font(MatchTypography.titleMedium) .foregroundStyle(MatchColors.successGreen) .frame(maxWidth: .infinity) .multilineTextAlignment(.center) .padding(.top, 20) if let quality = selectedQualityLabel { WizardReadOnlyField( label: L10n.t("wizard.network.quality.label"), value: quality ) .padding(.top, 12) } } if testCompleted, let shareUrl { WizardReadOnlyField( label: currentSession.platform == "youtube" ? L10n.t("wizard.network.link.youtube.label") : L10n.t("wizard.network.link.live.label"), value: shareUrl ) .padding(.top, 16) HStack(spacing: 8) { MatchSecondaryButton(label: L10n.t("wizard.action.copy"), action: { copyToClipboard(shareUrl) }) if let url = URL(string: shareUrl) { ShareLink( item: url, subject: Text(L10n.t("wizard.network.share.subject", match.teamName, match.opponentName)), message: Text(shareUrl) ) { Text(L10n.t("wizard.action.share")) .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: L10n.t("wizard.action.share.regia.link"), action: shareRegiaLink) .padding(.top, 8) } if !testCompleted { MatchSecondaryButton( label: testing ? L10n.t("wizard.network.test.running.label") : L10n.t("wizard.network.test.start.label"), 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: L10n.t("wizard.action.back"), action: onBack, enabled: !starting) .frame(width: backWidth) MatchPrimaryButton( label: L10n.t("wizard.action.start"), 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() } .sheet(item: $shareItem) { item in ShareSheet(items: item.items, subject: item.subject) { shareItem = nil } } } 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 { onError(L10n.t("wizard.error.regia.link")) return } shareItem = ShareItem( items: [link], subject: L10n.t("broadcast.share.regia.subject", "\(match.teamName) vs \(match.opponentName)") ) } catch { if let message = UserFacingError.message(for: error) { onError(message) } } } } 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" } }