Files
Spanish/Conjuga/Conjuga/Views/Guide/VideoActionsView.swift
T
Trey t dce2cc1f51 Make Full Table level-agnostic, fix the streak system end-to-end
Full Table (issue from chat): drop the level filter — Full Table tests
regular conjugation patterns, not vocabulary recognition, so restricting
to Basic-level verbs collapsed the eligible pool to two combos
(vivir present, ir future). Pool now draws from all 1,750 verbs. Random
sampling first; if 40 attempts fail we fall through to a deterministic
shuffled scan that guarantees finding any eligible (verb, tense) combo
when one exists. Returning nil now happens only when the user's filters
genuinely produce zero eligible prompts. The view replaces its silent
blank screen with a ContentUnavailableView pointing at the settings
that need adjusting. FeatureReferenceView documents the level exception.

Streak (issue #31 follow-up): activity recording was scoped to flashcard
and Full Table reviews only, so spending an hour on textbook work,
guides, videos, or AI chat could break a "streak" that the dashboard
kept displaying as if it were intact. Three fixes:

  1. Extract ReviewStore.recordActivity(context:) — a streak-only entry
     point that any user-initiated learning action can call.
  2. Add UserProgress.validateStreakIfStale(today:context:) — resets a
     broken currentStreak to 0 immediately, called from app launch and
     dashboard appear so the displayed number is never a lie.
  3. DailyLog formatter pins POSIX locale + current timezone so the
     yyyy-MM-dd strings can't drift across locales.

Wired recordActivity into every previously-silent learning action: chat
send, story-quiz completion, textbook exercise submit, grammar exercise
completion, course-deck study finish, week test / checkpoint save,
listening + pronunciation check, cloze quiz completion, lyrics word
lookup, video stream / play / download success, sentence-builder check,
and course-vocab SRS rate (which was bypassing ReviewStore entirely).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:24:27 -05:00

246 lines
8.1 KiB
Swift

import SwiftUI
import SwiftData
import AVKit
import SharedModels
/// Three-button row for a curated YouTube video (Issue #21):
/// - **Stream** opens in the YouTube app (falls back to Safari).
/// - **Download** pulls the MP4 via YouTubeKit, shows progress, then enables Play.
/// - **Play** enabled only when the video exists on disk; plays via AVPlayer.
///
/// Used by both `GuideDetailView` and `GrammarNoteDetailView` to keep the
/// video affordances consistent.
struct VideoActionsButtonRow: View {
let video: YouTubeVideoStore.VideoEntry
@Environment(\.openURL) private var openURL
@Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var downloadService = VideoDownloadService.shared
@State private var isDownloaded: Bool
@State private var playerVideoId: String?
@State private var downloadError: String?
@State private var confirmDelete: Bool = false
init(video: YouTubeVideoStore.VideoEntry) {
self.video = video
self._isDownloaded = State(initialValue: VideoDownloadService.isDownloaded(videoId: video.videoId))
}
private var activeStatus: VideoDownloadService.DownloadStatus? {
downloadService.activeDownloads[video.videoId]
}
private var isDownloading: Bool {
activeStatus != nil
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(video.title)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
HStack(spacing: 10) {
streamButton
downloadButton
playButton
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
.fullScreenCover(item: Binding(
get: { playerVideoId.map { LocalVideoID(videoId: $0) } },
set: { playerVideoId = $0?.videoId }
)) { id in
LocalVideoPlayerSheet(videoId: id.videoId, title: video.title)
}
.alert("Download failed", isPresented: .init(
get: { downloadError != nil },
set: { if !$0 { downloadError = nil } }
)) {
Button("OK") { downloadError = nil }
} message: {
Text(downloadError ?? "")
}
.confirmationDialog(
"Delete this downloaded video?",
isPresented: $confirmDelete,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
downloadService.delete(videoId: video.videoId, modelContext: modelContext)
isDownloaded = false
}
Button("Cancel", role: .cancel) {}
} message: {
Text("You can re-download it at any time.")
}
.onAppear {
// Refresh on appear in case the user deleted the file via Settings.
isDownloaded = VideoDownloadService.isDownloaded(videoId: video.videoId)
}
}
// MARK: - Buttons
private var streamButton: some View {
Button {
if let url = URL(string: "https://www.youtube.com/watch?v=\(video.videoId)") {
ReviewStore.recordActivity(context: cloudModelContext)
openURL(url)
}
} label: {
Label("Stream", systemImage: "play.rectangle.fill")
.labelStyle(VideoActionLabelStyle())
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.large)
}
@ViewBuilder
private var downloadButton: some View {
// Single slot whose role flips through the download lifecycle:
// Download progress/label (disabled) Delete.
if let status = activeStatus {
Button {} label: {
VStack(spacing: 4) {
if let progress = status.progress {
ProgressView(value: progress).frame(width: 56)
} else {
ProgressView().controlSize(.small)
}
Text(status.label)
.font(.caption2.monospacedDigit().weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.blue)
.controlSize(.large)
.disabled(true)
} else if isDownloaded {
Button(role: .destructive) {
confirmDelete = true
} label: {
Label("Delete", systemImage: "trash")
.labelStyle(VideoActionLabelStyle())
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.red)
.controlSize(.large)
} else {
Button {
Task { await startDownload() }
} label: {
Label("Download", systemImage: "arrow.down.to.line")
.labelStyle(VideoActionLabelStyle())
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.blue)
.controlSize(.large)
}
}
private var playButton: some View {
Button {
ReviewStore.recordActivity(context: cloudModelContext)
playerVideoId = video.videoId
} label: {
Label("Play", systemImage: "play.fill")
.labelStyle(VideoActionLabelStyle())
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.green)
.controlSize(.large)
.disabled(!isDownloaded)
}
// MARK: - Actions
private func startDownload() async {
do {
try await downloadService.download(
videoId: video.videoId,
title: video.title,
into: modelContext
)
isDownloaded = true
ReviewStore.recordActivity(context: cloudModelContext)
} catch {
downloadError = error.localizedDescription
}
}
}
// MARK: - Helper identifiable wrapper so .sheet(item:) can use a plain String
private struct LocalVideoID: Identifiable {
let videoId: String
var id: String { videoId }
}
// Stacks the icon above the title so three equal-width buttons fit an iPhone
// row without wrapping mid-word.
private struct VideoActionLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(spacing: 4) {
configuration.icon
.imageScale(.large)
configuration.title
.font(.caption2.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
}
}
// MARK: - Local playback sheet
struct LocalVideoPlayerSheet: View {
let videoId: String
let title: String
@Environment(\.dismiss) private var dismiss
@State private var player: AVPlayer
init(videoId: String, title: String) {
self.videoId = videoId
self.title = title
self._player = State(initialValue: AVPlayer(url: VideoDownloadService.fileURL(for: videoId)))
}
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
VideoPlayer(player: player)
.ignoresSafeArea()
.onAppear { player.play() }
.onDisappear { player.pause() }
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.black, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
}
}