dce2cc1f51
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>
246 lines
8.1 KiB
Swift
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() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|