Skip to content
RPRichard PruittTech & Cyber
Navigation
repo://simon-says-duo/MyApp/GalleryView.swift

MyApp/GalleryView.swift

9.9 KB · 188 lines · swift · Download from the repository page

MyApp/GalleryView.swift
import SwiftUIimport AVFoundation struct GalleryView: View {    @Bindable var game: GameSessionStore    var libraryOnly = false    @Environment(\.dismiss) private var dismiss    @State private var replay: Attempt?    @State private var preferExport = false    @State private var playerID: UUID?    @State private var scope: ClipScope = .currentGame    @State private var deleteCandidate: Attempt?    @State private var confirmDelete = false    @State private var deleteAll = false    private var clips: [Attempt] {        GameQueries.clips(attempts: game.attempts, sessionID: game.sessionID, scope: scope, playerID: playerID)    }    private var filterPlayers: [Player] {        var seen: Set<UUID> = []        return game.attempts.filter { scope == .allSaved || $0.snapshot.sessionID == game.sessionID }            .map(\.snapshot.player).filter { seen.insert($0.id).inserted }    }    var body: some View {        NavigationStack {            RetroScreen {                RetroPanel {                    VStack(alignment: .leading, spacing: RetroTheme.spaceM) {                        RetroClipScopeControl(selection: $scope).disabled(libraryOnly || !game.sessionStarted)                        Picker("Player", selection: $playerID) {                            Text("All players").tag(nil as UUID?)                            ForEach(filterPlayers) { player in                                Text("\(player.name) · \(player.id.uuidString.prefix(4))").tag(Optional(player.id))                            }                        }                        Text("All Saved shows recordings retained by this app, across games.").font(.footnote).foregroundStyle(RetroTheme.ink.opacity(0.7))                    }                }                if clips.isEmpty {                    RetroEmptyState(                        title: playerID != nil ? "No matching clips" :                            scope == .currentGame ? "No current-game clips" : "No saved clips",                        systemImage: "video",                        message: "Finalized recordings appear here, including pending and incorrect attempts."                    )                } else {                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 300), spacing: RetroTheme.spaceM)], spacing: RetroTheme.spaceM) {                        ForEach(clips) { attempt in                            ClipListRow(game: game, attempt: attempt, preview: { exported in                                preferExport = exported; replay = attempt                            }, delete: { deleteCandidate = attempt; confirmDelete = true })                        }                    }                }            }            .retroTitle("Clip Collection")            .task { if libraryOnly || !game.sessionStarted { scope = .allSaved } }            .toolbar { if libraryOnly { Button("Done") { dismiss() } } }            .onChange(of: scope) { _, _ in playerID = nil }            .onChange(of: game.sessionID) { _, _ in scope = .currentGame; playerID = nil }            .sheet(item: $replay) { ClipPlayerView(game: game, attempt: $0, preferExport: preferExport).retroAppearance() }            .confirmationDialog("Delete this clip and its narration? The score stays saved.", isPresented: $confirmDelete, titleVisibility: .visible) {                Button("Delete clip", role: .destructive) {                    if let attempt = deleteCandidate { game.delete(attempt) }                    deleteCandidate = nil                }            }            .confirmationDialog("Delete all saved clips and scores?", isPresented: $deleteAll, titleVisibility: .visible) {                Button("Delete all local data", role: .destructive) { game.deleteAll() }            }            .safeAreaInset(edge: .bottom) {                RetroBottomBar {                    if game.isExporting {                        Label("Preparing clip…", systemImage: "hourglass").font(.footnote.bold())                    } else if !game.message.isEmpty {                        Text(game.message).font(.footnote)                    }                    Button("Delete all local data", role: .destructive) { deleteAll = true }                        .buttonStyle(RetroButtonStyle(color: RetroTheme.surface))                        .disabled(game.isExporting)                        .frame(maxWidth: .infinity)                }            }        }    }} struct ClipListRow: View {    let game: GameSessionStore    let attempt: Attempt    let preview: (Bool) -> Void    let delete: () -> Void    @State private var duration: Double?    private var statusColor: Color {        if attempt.voided { return RetroTheme.ink.opacity(0.5) }        switch attempt.verdict?.rawValue {        case "correct": return RetroTheme.green        case "wrong": return RetroTheme.pink        default: return RetroTheme.blue        }    }    var body: some View {        RetroPanel {            VStack(alignment: .leading, spacing: RetroTheme.spaceS) {                HStack(alignment: .top, spacing: RetroTheme.spaceM) {                    ClipThumbnail(url: attempt.rawFile.flatMap(game.mediaURL))                    VStack(alignment: .leading, spacing: RetroTheme.spaceXS) {                        Text(attempt.snapshot.player.name).font(.headline)                        if let actionTitle = attempt.snapshot.actionTitle, !actionTitle.isEmpty {                            Text(attempt.snapshot.resolvedActionSummary)                                .font(.caption.bold())                                .foregroundStyle(RetroTheme.ink.opacity(0.75))                        }                        Text(attempt.snapshot.instruction).lineLimit(3)                        Text(attempt.snapshot.created, format: .dateTime.month().day().hour().minute()).font(.caption).foregroundStyle(RetroTheme.ink.opacity(0.6))                        RetroTag(title: attempt.voided ? "VOIDED" : (attempt.verdict?.rawValue.uppercased() ?? "PENDING"), color: statusColor)                        Text(duration.map { "\(Int($0.rounded())) sec" } ?? "Duration unavailable").font(.caption).foregroundStyle(RetroTheme.ink.opacity(0.6))                        Text("Game \(attempt.snapshot.sessionID.uuidString.prefix(6))").font(.caption2.monospaced()).foregroundStyle(RetroTheme.ink.opacity(0.5))                    }                }                VStack(spacing: RetroTheme.spaceXS) {                    Button("Preview & Share Clip", systemImage: "play.circle") { preview(false) }                        .buttonStyle(RetroButtonStyle(color: RetroTheme.yellow)).frame(maxWidth: .infinity)                    if attempt.exportFile != nil {                        Button("Preview narrated MP4") { preview(true) }                            .buttonStyle(RetroButtonStyle(color: RetroTheme.surface)).frame(maxWidth: .infinity)                    } else if attempt.narrationFile != nil {                        Button("Make narrated MP4") { Task { await game.export(attempt) } }                            .buttonStyle(RetroButtonStyle(color: RetroTheme.surface)).frame(maxWidth: .infinity)                            .disabled(game.isExporting || game.isMediaInUse(attempt.id))                    }                    Button("Delete clip", role: .destructive, action: delete)                        .buttonStyle(RetroButtonStyle(color: RetroTheme.surface)).frame(maxWidth: .infinity)                        .disabled(game.isExporting || game.isMediaInUse(attempt.id))                }            }        }        .task(id: attempt.rawFile) {            duration = attempt.duration            if duration == nil, let name = attempt.rawFile, let url = game.mediaURL(name) {                duration = try? await AVURLAsset(url: url).load(.duration).seconds            }        }    }}struct DiagnosticsView: View {    @Bindable var game: GameSessionStore    @Environment(\.dismiss) private var dismiss    var body: some View {        NavigationStack {            Form {                Section("Status") {                    LabeledContent("Camera", value: game.cameraName)                    LabeledContent("Capture ready", value: String(game.cameraReady))                    LabeledContent("Challenge", value: game.phase.rawValue)                    LabeledContent("Accessory enabled", value: String(game.accessoryEnabled))                    LabeledContent("Accessory available", value: String(game.accessoryAvailable))                    LabeledContent("Accessory presented", value: String(game.accessoryPresented))                    LabeledContent("Audio route", value: game.audioRoute)                }                Section {                    Text("Manual judgments only. No recognition accuracy or physical dual-display validation is claimed.")                        .font(.footnote).foregroundStyle(RetroTheme.ink.opacity(0.7))                }                Section {                    Button("Show simulated participant view") { game.simulatedParticipant = true }                    Text("Simulation does not verify hardware camera direction or outer-display presentation.")                        .font(.footnote).foregroundStyle(RetroTheme.ink.opacity(0.7))                }            }            .scrollContentBackground(.hidden)            .background { RetroGridBackground().ignoresSafeArea() }            .retroTitle("Diagnostics")            .toolbar {                ToolbarItem(placement: .topBarTrailing) {                    Button("Done") { dismiss() }                }                .sharedBackgroundVisibility(.hidden)            }            .sheet(isPresented: $game.simulatedParticipant) {                ParticipantView(presentation: game.participant, image: game.frame, simulated: true,                                stop: { game.voidAttempt() }, directionChanged: { _, _ in })                    .retroAppearance()            }        }        .retroAppearance()    }}