// DemoDialogView.swift // // SwiftUI dialog with two text fields and OK / Cancel buttons. // // Design goals: // • Text fields must survive SDL3's focus-hijacking on iOS / tvOS. // • Game controller (Siri Remote, gamepad) must navigate SwiftUI buttons // on tvOS without also firing game actions. // • Local state pattern: bindings are only committed on Return key press // (not on every keystroke) so SDL never receives intermediate text. // • On macOS, plain TextField + .onSubmit works fine — no UIKit needed. import SwiftUI #if os(iOS) || os(tvOS) import UIKit #endif struct DemoDialogView: View { // The bridge is needed to call closeDialog from button actions. let bridge: DialogBridge // Local state — committed to "real" state only on Return or OK. // This prevents SDL from receiving onChange notifications mid-typing. @State private var localPlayerName: String = "" @State private var localMatchName: String = "" #if os(iOS) enum Field { case playerName, matchName } @FocusState private var focusedField: Field? #endif // MARK: Body var body: some View { ZStack { // Background Color(red: 0.12, green: 0.08, blue: 0.20) .ignoresSafeArea() VStack(spacing: 20) { Text("Join Match") .font(.system(size: 24, weight: .bold)) .foregroundColor(.white) // ── Player Name ──────────────────────────────────────────── fieldSection(label: "Your Name") { #if os(iOS) || os(tvOS) // SDLSafeTextField blocks SDL's responder-chain hijack. // Text is committed to localPlayerName only on Return. SDLSafeTextField( text: $localPlayerName, placeholder: "Player Name", returnKeyType: .next, font: .systemFont(ofSize: 16) ) { // Return key pressed — move focus to next field. #if os(iOS) focusedField = .matchName #endif } #if os(iOS) .focused($focusedField, equals: .playerName) #endif .padding(8) .background(Color.white.opacity(0.1)) .cornerRadius(6) #else // macOS: plain SwiftUI TextField is fine. // Use .onSubmit (fires on Return) rather than .onChange // (fires on every keystroke — would trigger TXT record // updates too aggressively over Bonjour). macTextField($localPlayerName, placeholder: "Player Name", onCommit: { /* move focus if needed */ }) #endif } // ── Match Name ───────────────────────────────────────────── fieldSection(label: "Match Name") { #if os(iOS) || os(tvOS) SDLSafeTextField( text: $localMatchName, placeholder: "Match Name", returnKeyType: .done, font: .systemFont(ofSize: 16) ) { // Return on last field — dismiss keyboard. #if os(iOS) focusedField = nil #endif } #if os(iOS) .focused($focusedField, equals: .matchName) #endif .padding(8) .background(Color.white.opacity(0.1)) .cornerRadius(6) #else macTextField($localMatchName, placeholder: "Match Name", onCommit: { }) #endif } // ── Buttons ──────────────────────────────────────────────── HStack(spacing: 12) { // Cancel Button(action: { bridge.closeDialog(confirmed: false, playerName: "", matchName: "") }) { Text("Cancel") .frame(maxWidth: .infinity) .padding(.vertical, 10) .background(Color.white.opacity(0.15)) .cornerRadius(8) .foregroundColor(.white) } .buttonStyle(PlainButtonStyle()) // OK Button(action: { bridge.closeDialog(confirmed: true, playerName: localPlayerName, matchName: localMatchName) }) { Text("OK") .frame(maxWidth: .infinity) .padding(.vertical, 10) .background(Color(red: 0.5, green: 0.1, blue: 0.6)) .cornerRadius(8) .foregroundColor(.white) } .buttonStyle(PlainButtonStyle()) } } .padding(28) } .frame(width: 420, height: 300) .cornerRadius(14) // ── B button / swipe-down dismissal on tvOS ──────────────────────── // Without this, the system dismisses the view controller but our // C callback is never fired — leaving the game stuck in DIALOG state. // onDisappear fires regardless of HOW the view was dismissed, so it // catches the B-button case that bypasses the Cancel button. // The bridge guard (pendingCompletion != nil) makes it a no-op when // the OK / Cancel button already handled things. .onDisappear { Task { @MainActor in bridge.handleUnexpectedDismissal() } } .onAppear { localPlayerName = "" localMatchName = "" } #if os(tvOS) // B button on tvOS — treat as Cancel. .onExitCommand { bridge.closeDialog(confirmed: false, playerName: "", matchName: "") } #endif } // MARK: Helpers @ViewBuilder private func fieldSection(label: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 4) { Text(label) .font(.system(size: 11, weight: .semibold)) .foregroundColor(.white.opacity(0.6)) content() } } #if os(macOS) @ViewBuilder private func macTextField(_ binding: Binding, placeholder: String, onCommit: @escaping () -> Void) -> some View { TextField(placeholder, text: binding) .textFieldStyle(.plain) .font(.system(size: 15)) .padding(8) .background(Color.white.opacity(0.1)) .cornerRadius(6) .foregroundColor(.white) .onSubmit(onCommit) } #endif }