feat(anki): basic implementation of anki flashcards

This commit is contained in:
2025-12-06 16:55:51 +01:00
parent 642a113a97
commit 6ef789bf31
11 changed files with 1321 additions and 65 deletions

View File

@@ -20,6 +20,11 @@ struct ContentView: View {
.tabItem {
Label("Notes", systemImage: "note.text")
}
FlashcardHomeView()
.tabItem {
Label("Flashcards", systemImage: "rectangle.stack.fill")
}
}
}
}

View File

@@ -222,7 +222,7 @@ struct TagChip: View {
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(tag.isCustom ? Color.purple : Color.blue)
.background(Color.blue)
.cornerRadius(16)
}
}
@@ -295,29 +295,19 @@ struct TagPickerView: View {
var body: some View {
NavigationView {
List {
Section(header: Text("Predefined Tags")) {
ForEach(allTags.filter { !$0.isCustom }, id: \.id) { tag in
Section(header: Text("All Tags")) {
ForEach(allTags, id: \.id) { tag in
TagRow(tag: tag, isSelected: selectedTags.contains(tag.id ?? UUID())) {
toggleTagSelection(tag)
}
}
}
if !allTags.filter({ $0.isCustom }).isEmpty {
Section(header: Text("Custom Tags")) {
ForEach(allTags.filter { $0.isCustom }, id: \.id) { tag in
TagRow(tag: tag, isSelected: selectedTags.contains(tag.id ?? UUID())) {
toggleTagSelection(tag)
}
}
}
}
}
.navigationTitle("Select Tags")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Create Custom") {
Button("Create New Tag") {
showingCustomTagAlert = true
}
}

View File

@@ -0,0 +1,114 @@
//
// FlashcardCardView.swift
// WorterBuch
//
import SwiftUI
import PencilKit
struct FlashcardCardView: View {
let entry: VocabularyEntry
let showAnswer: Bool
@State private var explanationRevealed: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 20) {
// English section (always visible)
VStack(alignment: .leading, spacing: 8) {
Text("English")
.font(.caption)
.foregroundColor(.secondary)
.textCase(.uppercase)
Text(entry.englishTranslationText ?? "")
.font(.title)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity, alignment: .leading)
Divider()
// Explanation section (always visible but with spoiler effect)
VStack(alignment: .leading, spacing: 8) {
Text("Explanation")
.font(.caption)
.foregroundColor(.secondary)
.textCase(.uppercase)
if let explanation = entry.germanExplanationText, !explanation.isEmpty {
Button(action: {
explanationRevealed = true
}) {
Text(explanation)
.font(.body)
.foregroundColor(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
.blur(radius: explanationRevealed ? 0 : 8)
.disabled(explanationRevealed)
}
.buttonStyle(.plain)
} else {
Text("No explanation provided")
.font(.body)
.foregroundColor(.secondary)
.italic()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if showAnswer {
Divider()
// German section with handwriting and text
VStack(alignment: .leading, spacing: 12) {
Text("German Word")
.font(.caption)
.foregroundColor(.secondary)
.textCase(.uppercase)
// Handwriting drawing (prominent)
if !entry.germanWordPKDrawing.bounds.isEmpty {
Image(uiImage: entry.germanWordPKDrawing.image(
from: entry.germanWordPKDrawing.bounds,
scale: 2.0
))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 120)
.frame(maxWidth: .infinity)
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
// Transcribed text
if let germanText = entry.germanWordText, !germanText.isEmpty {
Text(germanText)
.font(.title2)
.fontWeight(.medium)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.transition(.asymmetric(
insertion: .scale(scale: 0.8).combined(with: .opacity),
removal: .opacity
))
}
}
.padding(24)
.frame(maxWidth: 600)
.background(Color(.systemBackground))
.cornerRadius(16)
.shadow(color: .black.opacity(0.1), radius: 10, x: 0, y: 4)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.blue.opacity(0.2), lineWidth: 2)
)
.onChange(of: showAnswer) { _ in
// Reset explanation reveal when moving to next card
explanationRevealed = false
}
}
}

View File

@@ -0,0 +1,308 @@
//
// FlashcardHomeView.swift
// WorterBuch
//
// Created by implementation plan.
//
import SwiftUI
import CoreData
struct FlashcardHomeView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)],
animation: .default
)
private var allTags: FetchedResults<Tag>
@State private var selectedTags: Set<UUID> = []
@State private var showAllTags = true
@State private var showingSession = false
@State private var showingStats = false
@State private var session: FlashcardSession?
private var statistics: FlashcardStatistics {
let tags = showAllTags ? nil : Array(selectedTagObjects)
return SpacedRepetitionEngine.getStatistics(in: viewContext, tags: tags)
}
private var selectedTagObjects: [Tag] {
allTags.filter { tag in
guard let id = tag.id else { return false }
return selectedTags.contains(id)
}
}
private var dueCards: [VocabularyEntry] {
let tags = showAllTags ? nil : Array(selectedTagObjects)
return SpacedRepetitionEngine.getDueCards(in: viewContext, tags: tags)
}
var body: some View {
NavigationView {
ScrollView {
VStack(spacing: 24) {
// Statistics summary
VStack(spacing: 16) {
Text("Your Progress")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 12) {
StatCard(
title: "Total Cards",
value: "\(statistics.totalCards)",
icon: "rectangle.stack",
color: .blue
)
StatCard(
title: "Due Today",
value: "\(statistics.dueToday)",
icon: "clock",
color: .orange
)
StatCard(
title: "Mastered",
value: "\(statistics.masteredCards)",
icon: "checkmark.seal",
color: .green
)
}
HStack(spacing: 12) {
StatCard(
title: "Reviewed Today",
value: "\(statistics.reviewedToday)",
icon: "checkmark.circle",
color: .purple
)
StatCard(
title: "Success Rate",
value: "\(Int(statistics.overallSuccessRate))%",
icon: "chart.line.uptrend.xyaxis",
color: .cyan
)
}
}
.padding()
Divider()
// Tag filter section
VStack(alignment: .leading, spacing: 12) {
Text("Filter by Tags")
.font(.headline)
HStack(spacing: 12) {
Button(action: {
showAllTags = true
selectedTags.removeAll()
}) {
Text("All Tags")
.font(.subheadline)
.fontWeight(showAllTags ? .semibold : .regular)
.foregroundColor(showAllTags ? .white : .blue)
.frame(maxWidth: .infinity)
.padding()
.background(showAllTags ? Color.blue : Color.blue.opacity(0.1))
.cornerRadius(12)
}
Button(action: {
showAllTags = false
}) {
Text("Selected Tags")
.font(.subheadline)
.fontWeight(!showAllTags ? .semibold : .regular)
.foregroundColor(!showAllTags ? .white : .blue)
.frame(maxWidth: .infinity)
.padding()
.background(!showAllTags ? Color.blue : Color.blue.opacity(0.1))
.cornerRadius(12)
}
}
if !showAllTags {
if allTags.isEmpty {
Text("No tags available")
.font(.caption)
.foregroundColor(.secondary)
.padding(.vertical, 4)
} else {
FlowLayout(spacing: 8) {
ForEach(Array(allTags), id: \.id) { tag in
FilterTagChip(
tag: tag,
isSelected: selectedTags.contains(tag.id ?? UUID()),
onToggle: {
guard let id = tag.id else { return }
if selectedTags.contains(id) {
selectedTags.remove(id)
} else {
selectedTags.insert(id)
}
}
)
}
}
}
}
}
.padding()
Divider()
// Action buttons
VStack(spacing: 12) {
if statistics.dueToday > 0 {
Button(action: startSession) {
HStack {
Image(systemName: "play.fill")
Text("Start Session (\(statistics.dueToday) cards)")
.fontWeight(.semibold)
}
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.cornerRadius(12)
}
} else {
VStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 48))
.foregroundColor(.green)
Text("All Caught Up!")
.font(.title3)
.fontWeight(.semibold)
Text("No cards due for review right now")
.font(.caption)
.foregroundColor(.secondary)
if statistics.totalCards == 0 {
Text("💡 Tip: Cards need both German word AND English translation to appear in flashcards")
.font(.caption2)
.foregroundColor(.orange)
.multilineTextAlignment(.center)
.padding(.top, 4)
}
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
Button(action: { showingStats = true }) {
HStack {
Image(systemName: "chart.bar")
Text("View Detailed Statistics")
}
.font(.headline)
.foregroundColor(.blue)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue.opacity(0.1))
.cornerRadius(12)
}
}
.padding()
}
}
.navigationTitle("Flashcards")
.sheet(isPresented: $showingSession) {
if let session = session {
FlashcardSessionView(session: session)
}
}
.sheet(isPresented: $showingStats) {
FlashcardStatsView(
selectedTags: showAllTags ? [] : Array(selectedTagObjects)
)
}
}
.navigationViewStyle(.stack)
}
private func startSession() {
let cards = dueCards
guard !cards.isEmpty else { return }
// Initialize flashcard fields for any new cards
for card in cards where card.isNew {
card.initializeForFlashcards()
}
session = FlashcardSession(cards: cards, context: viewContext)
showingSession = true
}
}
// MARK: - Stat Card
struct StatCard: View {
let title: String
let value: String
let icon: String
let color: Color
var body: some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.title2)
.foregroundColor(color)
Text(value)
.font(.title2)
.fontWeight(.bold)
Text(title)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
}
// MARK: - Filter Tag Chip
struct FilterTagChip: View {
let tag: Tag
let isSelected: Bool
let onToggle: () -> Void
var body: some View {
Button(action: onToggle) {
HStack(spacing: 4) {
Text(tag.name ?? "")
.font(.caption)
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.caption)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.foregroundColor(isSelected ? .white : .primary)
.background(isSelected ? Color.blue : Color(.systemGray5))
.cornerRadius(16)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? Color.clear : Color.gray.opacity(0.3), lineWidth: 1)
)
}
}
}

View File

@@ -0,0 +1,101 @@
//
// FlashcardSession.swift
// WorterBuch
//
// Created by implementation plan.
//
import Foundation
import CoreData
import Combine
import SwiftUI
class FlashcardSession: ObservableObject {
@Published var cards: [VocabularyEntry]
@Published var currentIndex: Int = 0
@Published var showAnswer: Bool = false
@Published var sessionComplete: Bool = false
@Published var wrongCount: Int = 0
@Published var kindaCount: Int = 0
@Published var rightCount: Int = 0
private let context: NSManagedObjectContext
init(cards: [VocabularyEntry], context: NSManagedObjectContext) {
// Shuffle cards for variety
self.cards = cards.shuffled()
self.context = context
}
var currentCard: VocabularyEntry? {
guard currentIndex < cards.count else { return nil }
return cards[currentIndex]
}
var progress: Double {
guard !cards.isEmpty else { return 0 }
return Double(currentIndex + 1) / Double(cards.count)
}
var progressText: String {
return "\(currentIndex + 1) / \(cards.count)"
}
var canShowAnswer: Bool {
return !showAnswer
}
var canRate: Bool {
return showAnswer
}
func revealAnswer() {
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
showAnswer = true
}
}
func rateCard(_ rating: FlashcardRating) {
guard let card = currentCard else { return }
// Update statistics
switch rating {
case .wrong:
wrongCount += 1
case .kinda:
kindaCount += 1
case .right:
rightCount += 1
}
// Record review in Core Data
SpacedRepetitionEngine.recordReview(for: card, rating: rating, in: context)
// Move to next card
advanceToNextCard()
}
private func advanceToNextCard() {
if currentIndex + 1 < cards.count {
withAnimation {
currentIndex += 1
showAnswer = false
}
} else {
withAnimation {
sessionComplete = true
}
}
}
func reset() {
currentIndex = 0
showAnswer = false
sessionComplete = false
wrongCount = 0
kindaCount = 0
rightCount = 0
cards.shuffle()
}
}

View File

@@ -0,0 +1,217 @@
//
// FlashcardSessionView.swift
// WorterBuch
//
// Created by implementation plan.
//
import SwiftUI
struct FlashcardSessionView: View {
@Environment(\.dismiss) private var dismiss
@StateObject var session: FlashcardSession
var body: some View {
NavigationView {
ZStack {
if session.sessionComplete {
// Session complete view
VStack(spacing: 24) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 80))
.foregroundColor(.green)
Text("Session Complete!")
.font(.largeTitle)
.fontWeight(.bold)
VStack(spacing: 12) {
StatRow(
label: "Total Cards",
value: "\(session.cards.count)",
color: .blue
)
StatRow(
label: "Right",
value: "\(session.rightCount)",
color: .green
)
StatRow(
label: "Kinda",
value: "\(session.kindaCount)",
color: .orange
)
StatRow(
label: "Wrong",
value: "\(session.wrongCount)",
color: .red
)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
Button(action: {
dismiss()
}) {
Text("Done")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.cornerRadius(12)
}
.padding(.horizontal)
}
.padding()
} else {
// Active session view
VStack(spacing: 20) {
// Progress bar
VStack(spacing: 8) {
HStack {
Text(session.progressText)
.font(.headline)
Spacer()
Text("\(Int(session.progress * 100))%")
.font(.subheadline)
.foregroundColor(.secondary)
}
ProgressView(value: session.progress)
.progressViewStyle(.linear)
}
.padding(.horizontal)
ScrollView {
VStack(spacing: 20) {
// Flashcard
if let card = session.currentCard {
FlashcardCardView(
entry: card,
showAnswer: session.showAnswer
)
.padding(.horizontal)
}
// Control buttons
VStack(spacing: 12) {
if session.canShowAnswer {
Button(action: {
session.revealAnswer()
}) {
Label("Show Answer", systemImage: "eye.fill")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.cornerRadius(12)
}
}
if session.canRate {
// Rating buttons
HStack(spacing: 12) {
RatingButton(
rating: .wrong,
action: { session.rateCard(.wrong) }
)
RatingButton(
rating: .kinda,
action: { session.rateCard(.kinda) }
)
RatingButton(
rating: .right,
action: { session.rateCard(.right) }
)
}
}
}
.padding(.horizontal)
}
}
}
}
}
.navigationTitle("Flashcards")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Exit") {
dismiss()
}
}
}
}
.navigationViewStyle(.stack)
}
}
// MARK: - Rating Button
struct RatingButton: View {
let rating: FlashcardRating
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
Image(systemName: rating.icon)
.font(.title2)
Text(rating.rawValue)
.font(.headline)
Text(intervalText)
.font(.caption2)
.foregroundColor(colorForRating.opacity(0.8))
}
.foregroundColor(colorForRating)
.frame(maxWidth: .infinity)
.padding()
.background(colorForRating.opacity(0.1))
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(colorForRating.opacity(0.3), lineWidth: 2)
)
}
}
private var colorForRating: Color {
switch rating {
case .wrong: return .red
case .kinda: return .orange
case .right: return .green
}
}
private var intervalText: String {
switch rating {
case .wrong: return "10 min"
case .kinda: return "1 day"
case .right: return "6 days"
}
}
}
// MARK: - Stat Row
struct StatRow: View {
let label: String
let value: String
let color: Color
var body: some View {
HStack {
Text(label)
.font(.body)
Spacer()
Text(value)
.font(.headline)
.foregroundColor(color)
}
}
}

View File

@@ -0,0 +1,237 @@
//
// FlashcardStatsView.swift
// WorterBuch
//
// Created by implementation plan.
//
import SwiftUI
import CoreData
struct FlashcardStatsView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(\.dismiss) private var dismiss
let selectedTags: [Tag]
private var statistics: FlashcardStatistics {
let tags = selectedTags.isEmpty ? nil : selectedTags
return SpacedRepetitionEngine.getStatistics(in: viewContext, tags: tags)
}
private var allEntries: [VocabularyEntry] {
let fetchRequest: NSFetchRequest<VocabularyEntry> = VocabularyEntry.fetchRequest()
if !selectedTags.isEmpty {
fetchRequest.predicate = NSPredicate(format: "ANY tags IN %@", selectedTags)
}
do {
return try viewContext.fetch(fetchRequest).filter { $0.isFlashcardReady }
} catch {
print("Error fetching entries: \(error)")
return []
}
}
private var tagPerformance: [(tag: Tag, stats: TagStats)] {
let fetchRequest: NSFetchRequest<Tag> = Tag.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
do {
let tags = try viewContext.fetch(fetchRequest)
return tags.compactMap { tag in
let entries = tag.sortedEntries.filter { $0.isFlashcardReady }
guard !entries.isEmpty else { return nil }
let totalReviews = entries.reduce(0) { $0 + Int($1.totalReviews) }
let successfulReviews = entries.reduce(0.0) { result, entry in
result + Double(entry.rightCount) + (Double(entry.kindaCount) * 0.5)
}
let successRate = totalReviews > 0 ? (successfulReviews / Double(totalReviews)) * 100 : 0
let stats = TagStats(
totalCards: entries.count,
totalReviews: totalReviews,
successRate: successRate,
masteredCards: entries.filter { $0.masteryLevel == .mastered }.count
)
return (tag, stats)
}
.sorted { $0.stats.successRate > $1.stats.successRate }
} catch {
print("Error fetching tags: \(error)")
return []
}
}
var body: some View {
NavigationView {
List {
// Overall statistics
Section(header: Text("Overall Progress")) {
StatisticsRow(label: "Total Cards", value: "\(statistics.totalCards)")
StatisticsRow(label: "Due Today", value: "\(statistics.dueToday)")
StatisticsRow(label: "Reviewed Today", value: "\(statistics.reviewedToday)")
StatisticsRow(label: "Success Rate", value: "\(Int(statistics.overallSuccessRate))%")
}
// Mastery breakdown
Section(header: Text("Mastery Levels")) {
MasteryRow(
level: .new,
count: statistics.newCards,
total: statistics.totalCards
)
MasteryRow(
level: .learning,
count: statistics.learningCards,
total: statistics.totalCards
)
MasteryRow(
level: .familiar,
count: statistics.familiarCards,
total: statistics.totalCards
)
MasteryRow(
level: .mastered,
count: statistics.masteredCards,
total: statistics.totalCards
)
}
// Tag performance
if !tagPerformance.isEmpty {
Section(header: Text("Performance by Tag")) {
ForEach(tagPerformance, id: \.tag.id) { item in
TagPerformanceRow(tag: item.tag, stats: item.stats)
}
}
}
}
.navigationTitle("Statistics")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
.fontWeight(.semibold)
}
}
}
}
}
// MARK: - Supporting Types
struct TagStats {
let totalCards: Int
let totalReviews: Int
let successRate: Double
let masteredCards: Int
}
struct StatisticsRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
Spacer()
Text(value)
.fontWeight(.semibold)
.foregroundColor(.blue)
}
}
}
struct MasteryRow: View {
let level: MasteryLevel
let count: Int
let total: Int
private var percentage: Double {
guard total > 0 else { return 0 }
return Double(count) / Double(total) * 100
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: level.icon)
.foregroundColor(level.color)
Text(level.rawValue)
Spacer()
Text("\(count)")
.fontWeight(.semibold)
.foregroundColor(level.color)
}
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle()
.fill(Color(.systemGray5))
.frame(height: 4)
.cornerRadius(2)
Rectangle()
.fill(level.color)
.frame(width: geometry.size.width * (percentage / 100), height: 4)
.cornerRadius(2)
}
}
.frame(height: 4)
Text("\(Int(percentage))% of total")
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
}
struct TagPerformanceRow: View {
let tag: Tag
let stats: TagStats
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(tag.name ?? "")
.fontWeight(.medium)
Spacer()
Text("\(Int(stats.successRate))%")
.fontWeight(.semibold)
.foregroundColor(colorForSuccessRate(stats.successRate))
}
HStack(spacing: 16) {
Label("\(stats.totalCards)", systemImage: "rectangle.stack")
.font(.caption)
.foregroundColor(.secondary)
Label("\(stats.totalReviews)", systemImage: "repeat")
.font(.caption)
.foregroundColor(.secondary)
Label("\(stats.masteredCards)", systemImage: "checkmark.seal")
.font(.caption)
.foregroundColor(.green)
}
}
.padding(.vertical, 4)
}
private func colorForSuccessRate(_ rate: Double) -> Color {
if rate >= 80 {
return .green
} else if rate >= 60 {
return .orange
} else {
return .red
}
}
}

View File

@@ -0,0 +1,227 @@
//
// SpacedRepetitionEngine.swift
// WorterBuch
//
// Created by implementation plan.
//
import Foundation
import CoreData
/// Rating for a flashcard review
enum FlashcardRating: String, CaseIterable {
case wrong = "Wrong"
case kinda = "Kinda"
case right = "Right"
var color: String {
switch self {
case .wrong: return "red"
case .kinda: return "orange"
case .right: return "green"
}
}
var icon: String {
switch self {
case .wrong: return "xmark.circle.fill"
case .kinda: return "minus.circle.fill"
case .right: return "checkmark.circle.fill"
}
}
}
/// Statistics for flashcard progress
struct FlashcardStatistics {
let totalCards: Int
let dueToday: Int
let newCards: Int
let learningCards: Int
let familiarCards: Int
let masteredCards: Int
let reviewedToday: Int
let overallSuccessRate: Double
}
/// Spaced Repetition Engine using simplified SM-2 algorithm
class SpacedRepetitionEngine {
/// Calculate next review date, ease factor, and interval based on rating
static func calculateNextReview(
for entry: VocabularyEntry,
rating: FlashcardRating
) -> (nextReviewDate: Date, easeFactor: Float, interval: Int32, consecutiveCorrect: Int32) {
let currentEase = entry.easeFactor > 0 ? entry.easeFactor : 2.5
let currentInterval = Int(entry.currentInterval)
let currentConsecutive = Int(entry.consecutiveCorrect)
var newEase = currentEase
var newInterval: Int
var newConsecutive: Int32
switch rating {
case .wrong:
// Reset progress
newInterval = 10 // 10 minutes
newConsecutive = 0
newEase = max(1.3, currentEase - 0.2) // Decrease ease, but keep minimum
case .kinda:
// Modest progress
newConsecutive = Int32(max(0, currentConsecutive - 1))
newEase = max(1.3, currentEase - 0.15)
if currentInterval == 0 {
newInterval = 1440 // 1 day in minutes
} else {
newInterval = Int(Double(currentInterval) * 1.2)
}
case .right:
// Good progress
newConsecutive = Int32(currentConsecutive + 1)
newEase = currentEase + 0.1
if currentInterval == 0 {
newInterval = 1440 // 1 day in minutes
} else if currentInterval < 10000 { // Less than ~7 days
newInterval = 1440 * 6 // 6 days in minutes
} else {
newInterval = Int(Double(currentInterval) * Double(newEase))
}
}
// Calculate next review date
let nextReview = Date().addingTimeInterval(TimeInterval(newInterval * 60))
return (nextReview, newEase, Int32(newInterval), newConsecutive)
}
/// Update entry after review with rating
static func recordReview(
for entry: VocabularyEntry,
rating: FlashcardRating,
in context: NSManagedObjectContext
) {
let result = calculateNextReview(for: entry, rating: rating)
entry.lastReviewedDate = Date()
entry.nextReviewDate = result.nextReviewDate
entry.easeFactor = result.easeFactor
entry.currentInterval = result.interval
entry.consecutiveCorrect = result.consecutiveCorrect
entry.totalReviews += 1
// Update rating counts
switch rating {
case .wrong:
entry.wrongCount += 1
case .kinda:
entry.kindaCount += 1
case .right:
entry.rightCount += 1
}
do {
try context.save()
} catch {
print("Error saving review: \(error)")
}
}
/// Get cards due for review, optionally filtered by tags
static func getDueCards(
in context: NSManagedObjectContext,
tags: [Tag]? = nil
) -> [VocabularyEntry] {
let fetchRequest: NSFetchRequest<VocabularyEntry> = VocabularyEntry.fetchRequest()
var predicates: [NSPredicate] = []
// Filter by tags if provided
if let tags = tags, !tags.isEmpty {
let tagPredicate = NSPredicate(format: "ANY tags IN %@", tags)
predicates.append(tagPredicate)
}
// Combine predicates
if !predicates.isEmpty {
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
do {
let allEntries = try context.fetch(fetchRequest)
// Filter for flashcard-ready entries that are due
return allEntries.filter { entry in
entry.isFlashcardReady && entry.isDueForReview
}
} catch {
print("Error fetching due cards: \(error)")
return []
}
}
/// Get overall statistics for flashcards
static func getStatistics(
in context: NSManagedObjectContext,
tags: [Tag]? = nil
) -> FlashcardStatistics {
let fetchRequest: NSFetchRequest<VocabularyEntry> = VocabularyEntry.fetchRequest()
// Filter by tags if provided
if let tags = tags, !tags.isEmpty {
fetchRequest.predicate = NSPredicate(format: "ANY tags IN %@", tags)
}
do {
let allEntries = try context.fetch(fetchRequest)
let readyEntries = allEntries.filter { $0.isFlashcardReady }
let total = readyEntries.count
let due = readyEntries.filter { $0.isDueForReview }.count
let new = readyEntries.filter { $0.masteryLevel == .new }.count
let learning = readyEntries.filter { $0.masteryLevel == .learning }.count
let familiar = readyEntries.filter { $0.masteryLevel == .familiar }.count
let mastered = readyEntries.filter { $0.masteryLevel == .mastered }.count
// Count reviewed today
let today = Calendar.current.startOfDay(for: Date())
let reviewedToday = readyEntries.filter { entry in
guard let lastReviewed = entry.lastReviewedDate else { return false }
return lastReviewed >= today
}.count
// Calculate overall success rate
let totalReviews = readyEntries.reduce(0) { $0 + Int($1.totalReviews) }
let successfulReviews = readyEntries.reduce(0.0) { result, entry in
result + Double(entry.rightCount) + (Double(entry.kindaCount) * 0.5)
}
let successRate = totalReviews > 0 ? (successfulReviews / Double(totalReviews)) * 100 : 0
return FlashcardStatistics(
totalCards: total,
dueToday: due,
newCards: new,
learningCards: learning,
familiarCards: familiar,
masteredCards: mastered,
reviewedToday: reviewedToday,
overallSuccessRate: successRate
)
} catch {
print("Error fetching statistics: \(error)")
return FlashcardStatistics(
totalCards: 0,
dueToday: 0,
newCards: 0,
learningCards: 0,
familiarCards: 0,
masteredCards: 0,
reviewedToday: 0,
overallSuccessRate: 0
)
}
}
}

View File

@@ -258,7 +258,7 @@ struct TagChipDisplay: View {
}
private var tagColor: Color {
tag.isCustom ? .purple : .blue
.blue
}
}
@@ -283,23 +283,13 @@ struct BulkTagPickerView: View {
.padding()
List {
Section(header: Text("Predefined Tags")) {
ForEach(allTags.filter { !$0.isCustom }, id: \.id) { tag in
Section(header: Text("All Tags")) {
ForEach(allTags, id: \.id) { tag in
TagRow(tag: tag, isSelected: tagsToAdd.contains(tag.id ?? UUID())) {
toggleTag(tag)
}
}
}
if !allTags.filter({ $0.isCustom }).isEmpty {
Section(header: Text("Custom Tags")) {
ForEach(allTags.filter { $0.isCustom }, id: \.id) { tag in
TagRow(tag: tag, isSelected: tagsToAdd.contains(tag.id ?? UUID())) {
toggleTag(tag)
}
}
}
}
}
.listStyle(.insetGrouped)
}
@@ -398,14 +388,12 @@ struct TagSettingsView: View {
@State private var allTags: [Tag] = []
@State private var showingCreateAlert = false
@State private var newTagName = ""
@State private var tagToDelete: Tag?
@State private var showingDeleteAlert = false
var body: some View {
NavigationView {
List {
Section(header: Text("Predefined Tags")) {
ForEach(allTags.filter { !$0.isCustom }, id: \.id) { tag in
Section(header: Text("All Tags")) {
ForEach(allTags, id: \.id) { tag in
HStack {
Text(tag.name ?? "")
Spacer()
@@ -414,26 +402,9 @@ struct TagSettingsView: View {
.font(.caption)
}
}
}
if !allTags.filter({ $0.isCustom }).isEmpty {
Section(header: Text("Custom Tags")) {
ForEach(allTags.filter { $0.isCustom }, id: \.id) { tag in
HStack {
Text(tag.name ?? "")
Spacer()
Text("\(tag.sortedEntries.count)")
.foregroundColor(.secondary)
.font(.caption)
Button(action: {
tagToDelete = tag
showingDeleteAlert = true
}) {
Image(systemName: "trash")
.foregroundColor(.red)
}
.buttonStyle(.borderless)
}
.onDelete { indexSet in
indexSet.forEach { index in
deleteTag(allTags[index])
}
}
}
@@ -442,7 +413,7 @@ struct TagSettingsView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Create Tag") {
Button("Create New Tag") {
showingCreateAlert = true
}
}
@@ -462,18 +433,6 @@ struct TagSettingsView: View {
createTag()
}
}
.alert("Delete Tag", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
if let tag = tagToDelete {
deleteTag(tag)
}
}
} message: {
if let tag = tagToDelete {
Text("Are you sure you want to delete '\(tag.name ?? "")'? This will remove it from \(tag.sortedEntries.count) entries.")
}
}
}
.onAppear {
loadTags()
@@ -499,7 +458,6 @@ struct TagSettingsView: View {
viewContext.delete(tag)
saveContext()
loadTags()
tagToDelete = nil
}
private func saveContext() {

View File

@@ -8,6 +8,7 @@
import Foundation
import PencilKit
import CoreData
import SwiftUI
extension VocabularyEntry {
@@ -99,4 +100,91 @@ extension VocabularyEntry {
addTag(tag)
}
}
// MARK: - Flashcard Support
/// Check if card is ready for flashcards (has both German and English content)
var isFlashcardReady: Bool {
let hasGerman = !(germanWordText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
let hasEnglish = !(englishTranslationText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
return hasGerman && hasEnglish
}
/// Check if card is due for review
var isDueForReview: Bool {
guard let nextReview = nextReviewDate else { return true }
return nextReview <= Date()
}
/// Check if card has never been reviewed
var isNew: Bool {
return totalReviews == 0
}
/// Calculate mastery level based on statistics
var masteryLevel: MasteryLevel {
let reviews = Int(totalReviews)
let consecutive = Int(consecutiveCorrect)
if reviews == 0 {
return .new
} else if consecutive >= 5 {
return .mastered
} else if consecutive >= 2 {
return .familiar
} else {
return .learning
}
}
/// Calculate success rate percentage
var successRate: Double {
let total = Int(wrongCount + kindaCount + rightCount)
guard total > 0 else { return 0 }
let successfulReviews = Double(rightCount) + (Double(kindaCount) * 0.5)
return (successfulReviews / Double(total)) * 100
}
/// Initialize flashcard-specific fields for a new card
func initializeForFlashcards() {
if totalReviews == 0 {
lastReviewedDate = nil
nextReviewDate = Date()
totalReviews = 0
easeFactor = 2.5
consecutiveCorrect = 0
currentInterval = 0
wrongCount = 0
kindaCount = 0
rightCount = 0
}
}
}
// MARK: - Mastery Level
enum MasteryLevel: String, CaseIterable {
case new = "New"
case learning = "Learning"
case familiar = "Familiar"
case mastered = "Mastered"
var color: Color {
switch self {
case .new: return .gray
case .learning: return .orange
case .familiar: return .blue
case .mastered: return .green
}
}
var icon: String {
switch self {
case .new: return "sparkles"
case .learning: return "book"
case .familiar: return "star.fill"
case .mastered: return "checkmark.seal.fill"
}
}
}

View File

@@ -9,6 +9,17 @@
<attribute name="germanExplanationText" optional="YES" attributeType="String"/>
<attribute name="englishTranslationDrawing" optional="YES" attributeType="Binary"/>
<attribute name="englishTranslationText" optional="YES" attributeType="String"/>
<!-- Spaced Repetition Fields -->
<attribute name="lastReviewedDate" optional="YES" attributeType="Date"/>
<attribute name="nextReviewDate" optional="YES" attributeType="Date"/>
<attribute name="totalReviews" optional="YES" attributeType="Integer 32" defaultValueString="0"/>
<attribute name="easeFactor" optional="YES" attributeType="Float" defaultValueString="2.5"/>
<attribute name="consecutiveCorrect" optional="YES" attributeType="Integer 32" defaultValueString="0"/>
<attribute name="currentInterval" optional="YES" attributeType="Integer 32" defaultValueString="0"/>
<!-- Rating Statistics -->
<attribute name="wrongCount" optional="YES" attributeType="Integer 32" defaultValueString="0"/>
<attribute name="kindaCount" optional="YES" attributeType="Integer 32" defaultValueString="0"/>
<attribute name="rightCount" optional="YES" attributeType="Integer 32" defaultValueString="0"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="entries" inverseEntity="Tag"/>
</entity>
<entity name="Note" representedClassName="Note" syncable="YES" codeGenerationType="class">