diff --git a/WorterBuch/ContentView.swift b/WorterBuch/ContentView.swift index cd9a040..a02057f 100644 --- a/WorterBuch/ContentView.swift +++ b/WorterBuch/ContentView.swift @@ -20,6 +20,11 @@ struct ContentView: View { .tabItem { Label("Notes", systemImage: "note.text") } + + FlashcardHomeView() + .tabItem { + Label("Flashcards", systemImage: "rectangle.stack.fill") + } } } } diff --git a/WorterBuch/FieldEditorView.swift b/WorterBuch/FieldEditorView.swift index 4f89258..529907c 100644 --- a/WorterBuch/FieldEditorView.swift +++ b/WorterBuch/FieldEditorView.swift @@ -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 } } diff --git a/WorterBuch/FlashcardCardView.swift b/WorterBuch/FlashcardCardView.swift new file mode 100644 index 0000000..e076d2d --- /dev/null +++ b/WorterBuch/FlashcardCardView.swift @@ -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 + } + } +} diff --git a/WorterBuch/FlashcardHomeView.swift b/WorterBuch/FlashcardHomeView.swift new file mode 100644 index 0000000..4ecfaea --- /dev/null +++ b/WorterBuch/FlashcardHomeView.swift @@ -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 + + @State private var selectedTags: Set = [] + @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) + ) + } + } +} diff --git a/WorterBuch/FlashcardSession.swift b/WorterBuch/FlashcardSession.swift new file mode 100644 index 0000000..f0254fc --- /dev/null +++ b/WorterBuch/FlashcardSession.swift @@ -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() + } +} diff --git a/WorterBuch/FlashcardSessionView.swift b/WorterBuch/FlashcardSessionView.swift new file mode 100644 index 0000000..49943fd --- /dev/null +++ b/WorterBuch/FlashcardSessionView.swift @@ -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) + } + } +} diff --git a/WorterBuch/FlashcardStatsView.swift b/WorterBuch/FlashcardStatsView.swift new file mode 100644 index 0000000..b61be3c --- /dev/null +++ b/WorterBuch/FlashcardStatsView.swift @@ -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.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.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 + } + } +} diff --git a/WorterBuch/SpacedRepetitionEngine.swift b/WorterBuch/SpacedRepetitionEngine.swift new file mode 100644 index 0000000..ca9542f --- /dev/null +++ b/WorterBuch/SpacedRepetitionEngine.swift @@ -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.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.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 + ) + } + } +} diff --git a/WorterBuch/TagManagerView.swift b/WorterBuch/TagManagerView.swift index 39a4aa4..67de129 100644 --- a/WorterBuch/TagManagerView.swift +++ b/WorterBuch/TagManagerView.swift @@ -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() { diff --git a/WorterBuch/VocabularyEntry+Extensions.swift b/WorterBuch/VocabularyEntry+Extensions.swift index f1944ca..837b480 100644 --- a/WorterBuch/VocabularyEntry+Extensions.swift +++ b/WorterBuch/VocabularyEntry+Extensions.swift @@ -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" + } + } } diff --git a/WorterBuch/WorterBuch.xcdatamodeld/WorterBuch.xcdatamodel/contents b/WorterBuch/WorterBuch.xcdatamodeld/WorterBuch.xcdatamodel/contents index ff4cbd2..880289c 100644 --- a/WorterBuch/WorterBuch.xcdatamodeld/WorterBuch.xcdatamodel/contents +++ b/WorterBuch/WorterBuch.xcdatamodeld/WorterBuch.xcdatamodel/contents @@ -9,6 +9,17 @@ + + + + + + + + + + +