Compare commits
8 Commits
642a113a97
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 17f4329ff2 | |||
| b7624eae6c | |||
| 57e130c4e3 | |||
| c64dc59897 | |||
| d0c8f222ae | |||
| 61e5a13388 | |||
| 5994f8021f | |||
| 6ef789bf31 |
@@ -20,6 +20,11 @@ struct ContentView: View {
|
||||
.tabItem {
|
||||
Label("Notes", systemImage: "note.text")
|
||||
}
|
||||
|
||||
FlashcardHomeView()
|
||||
.tabItem {
|
||||
Label("Flashcards", systemImage: "rectangle.stack.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ struct FieldEditorView: View {
|
||||
@State private var viewAppeared = false
|
||||
@State private var showingTagPicker = false
|
||||
@State private var newTagName = ""
|
||||
@State private var recognitionWorkItem: DispatchWorkItem?
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
@@ -174,31 +175,31 @@ struct FieldEditorView: View {
|
||||
}
|
||||
|
||||
private func recognizeHandwriting(_ drawing: PKDrawing) {
|
||||
print("=== recognizeHandwriting called ===")
|
||||
print("Drawing bounds: \(drawing.bounds)")
|
||||
print("Drawing is empty: \(drawing.bounds.isEmpty)")
|
||||
|
||||
guard !drawing.bounds.isEmpty else {
|
||||
print("Drawing bounds are empty, skipping recognition")
|
||||
return
|
||||
}
|
||||
|
||||
print("Starting recognition...")
|
||||
// Cancel any pending recognition
|
||||
recognitionWorkItem?.cancel()
|
||||
|
||||
// Create new work item with debounce
|
||||
let workItem = DispatchWorkItem { [drawing] in
|
||||
Task { @MainActor in
|
||||
isRecognizing = true
|
||||
Task {
|
||||
|
||||
if let recognizedText = await HandwritingRecognizer.recognizeTextAsync(from: drawing) {
|
||||
print("Recognition successful: \(recognizedText)")
|
||||
await MainActor.run {
|
||||
text = recognizedText
|
||||
isRecognizing = false
|
||||
}
|
||||
} else {
|
||||
print("Recognition returned nil")
|
||||
await MainActor.run {
|
||||
isRecognizing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recognitionWorkItem = workItem
|
||||
|
||||
// Execute after 3 seconds delay (same as NotesListView)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: workItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +223,7 @@ struct TagChip: View {
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(tag.isCustom ? Color.purple : Color.blue)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
@@ -295,29 +296,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
|
||||
}
|
||||
}
|
||||
|
||||
114
WorterBuch/FlashcardCardView.swift
Normal file
114
WorterBuch/FlashcardCardView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
364
WorterBuch/FlashcardHomeView.swift
Normal file
364
WorterBuch/FlashcardHomeView.swift
Normal file
@@ -0,0 +1,364 @@
|
||||
//
|
||||
// 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?
|
||||
@State private var currentTime = Date()
|
||||
@State private var timer: Timer?
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private var nextCardDate: Date? {
|
||||
let tags = showAllTags ? nil : Array(selectedTagObjects)
|
||||
return SpacedRepetitionEngine.getNextCardDate(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)
|
||||
|
||||
if let nextDate = nextCardDate, nextDate > currentTime {
|
||||
Text("Next card available in")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(timeUntilNextCard(nextDate: nextDate))
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.blue)
|
||||
.monospacedDigit()
|
||||
} else if statistics.totalCards == 0 {
|
||||
Text("No cards due for review right now")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("💡 Tip: Cards need both German word AND English translation to appear in flashcards")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 4)
|
||||
} else {
|
||||
Text("No cards due for review right now")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.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")
|
||||
.onAppear {
|
||||
startTimer()
|
||||
}
|
||||
.onDisappear {
|
||||
stopTimer()
|
||||
}
|
||||
.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
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||
currentTime = Date()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func timeUntilNextCard(nextDate: Date) -> String {
|
||||
let timeInterval = nextDate.timeIntervalSince(currentTime)
|
||||
|
||||
guard timeInterval > 0 else {
|
||||
return "Available now!"
|
||||
}
|
||||
|
||||
let hours = Int(timeInterval) / 3600
|
||||
let minutes = (Int(timeInterval) % 3600) / 60
|
||||
let seconds = Int(timeInterval) % 60
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
|
||||
} else {
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
101
WorterBuch/FlashcardSession.swift
Normal file
101
WorterBuch/FlashcardSession.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
217
WorterBuch/FlashcardSessionView.swift
Normal file
217
WorterBuch/FlashcardSessionView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
237
WorterBuch/FlashcardStatsView.swift
Normal file
237
WorterBuch/FlashcardStatsView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ struct HandwritingCanvasView: UIViewRepresentable {
|
||||
// Store canvas in coordinator for later access
|
||||
context.coordinator.canvasView = canvasView
|
||||
|
||||
// Restore saved tool if available
|
||||
if let savedTool = Coordinator.savedTool {
|
||||
canvasView.tool = savedTool
|
||||
}
|
||||
|
||||
return canvasView
|
||||
}
|
||||
|
||||
@@ -45,6 +50,8 @@ struct HandwritingCanvasView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, PKCanvasViewDelegate {
|
||||
static var savedTool: PKTool?
|
||||
|
||||
var drawing: Binding<PKDrawing>
|
||||
var onDrawingChanged: ((PKDrawing) -> Void)?
|
||||
var canvasView: PKCanvasView?
|
||||
@@ -78,6 +85,9 @@ struct HandwritingCanvasView: UIViewRepresentable {
|
||||
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
|
||||
drawing.wrappedValue = canvasView.drawing
|
||||
onDrawingChanged?(canvasView.drawing)
|
||||
|
||||
// Save the current tool for persistence
|
||||
Coordinator.savedTool = canvasView.tool
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,13 @@ class HandwritingRecognizer {
|
||||
context.setFillColor(UIColor.white.cgColor)
|
||||
context.fill(CGRect(origin: .zero, size: bounds.size))
|
||||
|
||||
// Draw the PKDrawing
|
||||
let drawingImage = drawing.image(from: bounds, scale: scale)
|
||||
drawingImage.draw(at: .zero)
|
||||
// Force light mode appearance to ensure dark ink on white background
|
||||
let lightTraits = UITraitCollection(userInterfaceStyle: .light)
|
||||
var drawingImage: UIImage!
|
||||
lightTraits.performAsCurrent {
|
||||
drawingImage = drawing.image(from: bounds, scale: scale)
|
||||
}
|
||||
drawingImage.draw(at: CGPoint.zero)
|
||||
|
||||
guard let image = UIGraphicsGetImageFromCurrentImageContext() else {
|
||||
UIGraphicsEndImageContext()
|
||||
@@ -83,7 +87,7 @@ class HandwritingRecognizer {
|
||||
|
||||
print("Recognized strings: \(recognizedStrings)")
|
||||
|
||||
let recognizedText = recognizedStrings.joined(separator: " ")
|
||||
let recognizedText = recognizedStrings.joined(separator: "\n")
|
||||
completion(recognizedText.isEmpty ? nil : recognizedText)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,25 @@ struct NotesListView: View {
|
||||
private var notes: FetchedResults<Note>
|
||||
|
||||
@State private var selectedNote: Note?
|
||||
@State private var searchText = ""
|
||||
|
||||
var filteredNotes: [Note] {
|
||||
if searchText.isEmpty {
|
||||
return Array(notes)
|
||||
} else {
|
||||
return notes.filter { note in
|
||||
let titleMatch = note.title.localizedCaseInsensitiveContains(searchText)
|
||||
let textMatch = (note.text ?? "").localizedCaseInsensitiveContains(searchText)
|
||||
return titleMatch || textMatch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
// Left sidebar - list of notes
|
||||
List(selection: $selectedNote) {
|
||||
ForEach(notes, id: \.id) { note in
|
||||
ForEach(filteredNotes, id: \.id) { note in
|
||||
NoteRowView(note: note)
|
||||
.tag(note)
|
||||
.onTapGesture {
|
||||
@@ -47,6 +60,7 @@ struct NotesListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search notes")
|
||||
|
||||
// Right panel - note editor or placeholder
|
||||
if let note = selectedNote {
|
||||
@@ -108,7 +122,6 @@ struct NotesListView: View {
|
||||
}
|
||||
|
||||
private func deleteNotes(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
let notesToDelete = offsets.map { notes[$0] }
|
||||
|
||||
// Clear selection if we're deleting the selected note
|
||||
@@ -116,6 +129,9 @@ struct NotesListView: View {
|
||||
selectedNote = nil
|
||||
}
|
||||
|
||||
// Small delay to ensure UI updates before deletion
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
withAnimation {
|
||||
// Delete the notes
|
||||
notesToDelete.forEach { note in
|
||||
viewContext.delete(note)
|
||||
@@ -134,6 +150,7 @@ struct NotesListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveContext() {
|
||||
do {
|
||||
@@ -159,7 +176,7 @@ struct NoteRowView: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(note.title)
|
||||
.font(.body)
|
||||
.lineLimit(2)
|
||||
.lineLimit(1)
|
||||
|
||||
if note.timestamp != nil {
|
||||
Text(timeAgo)
|
||||
@@ -175,6 +192,8 @@ struct NoteEditorContentView: View {
|
||||
@ObservedObject var note: Note
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
let onAddNote: () -> Void
|
||||
@State private var showTranscription = false
|
||||
@State private var forceTranscriptionTrigger = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -194,6 +213,22 @@ struct NoteEditorContentView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
let wasHidden = !showTranscription
|
||||
showTranscription.toggle()
|
||||
if wasHidden {
|
||||
// Trigger immediate transcription when showing
|
||||
forceTranscriptionTrigger += 1
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Image(systemName: showTranscription ? "text.bubble.fill" : "text.bubble")
|
||||
.font(.body)
|
||||
.foregroundColor(showTranscription ? .blue : .primary)
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
|
||||
Button(action: onAddNote) {
|
||||
Image(systemName: "plus")
|
||||
.font(.body)
|
||||
@@ -219,7 +254,9 @@ struct NoteEditorContentView: View {
|
||||
// Note editor content
|
||||
NoteEditorContentOnly(
|
||||
drawing: bindingForDrawing(),
|
||||
text: bindingForText()
|
||||
text: bindingForText(),
|
||||
showTranscription: $showTranscription,
|
||||
forceTranscriptionTrigger: forceTranscriptionTrigger
|
||||
)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
@@ -229,6 +266,8 @@ struct NoteEditorContentView: View {
|
||||
Binding(
|
||||
get: { note.pkDrawing },
|
||||
set: { newValue in
|
||||
// Guard against modifying deleted objects
|
||||
guard note.managedObjectContext != nil else { return }
|
||||
note.pkDrawing = newValue
|
||||
saveContext()
|
||||
}
|
||||
@@ -239,6 +278,8 @@ struct NoteEditorContentView: View {
|
||||
Binding(
|
||||
get: { note.text ?? "" },
|
||||
set: { newValue in
|
||||
// Guard against modifying deleted objects
|
||||
guard note.managedObjectContext != nil else { return }
|
||||
note.text = newValue
|
||||
saveContext()
|
||||
}
|
||||
@@ -258,9 +299,10 @@ struct NoteEditorContentView: View {
|
||||
struct NoteEditorContentOnly: View {
|
||||
@Binding var drawing: PKDrawing
|
||||
@Binding var text: String
|
||||
@Binding var showTranscription: Bool
|
||||
let forceTranscriptionTrigger: Int
|
||||
|
||||
@State private var isRecognizing = false
|
||||
@State private var showTranscription = false
|
||||
@State private var viewAppeared = false
|
||||
@State private var recognitionWorkItem: DispatchWorkItem?
|
||||
|
||||
@@ -334,23 +376,30 @@ struct NoteEditorContentOnly: View {
|
||||
.padding()
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.onChange(of: forceTranscriptionTrigger) { _ in
|
||||
forceRecognizeHandwriting()
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle transcription button
|
||||
if !showTranscription {
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
showTranscription = true
|
||||
private func forceRecognizeHandwriting() {
|
||||
guard !drawing.bounds.isEmpty else {
|
||||
return
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "text.bubble")
|
||||
Text("Show Transcription")
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.blue)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.padding(.bottom)
|
||||
|
||||
// Cancel any pending recognition
|
||||
recognitionWorkItem?.cancel()
|
||||
|
||||
// Perform recognition immediately without debounce
|
||||
Task { @MainActor in
|
||||
isRecognizing = true
|
||||
showTranscription = true // Auto-show transcription when forcing
|
||||
|
||||
if let recognizedText = await HandwritingRecognizer.recognizeTextAsync(from: drawing) {
|
||||
text = recognizedText
|
||||
isRecognizing = false
|
||||
} else {
|
||||
isRecognizing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,7 +428,7 @@ struct NoteEditorContentOnly: View {
|
||||
|
||||
recognitionWorkItem = workItem
|
||||
|
||||
// Execute after 800ms delay (adjust as needed)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8, execute: workItem)
|
||||
// Execute after 3 seconds delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: workItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,21 +30,47 @@ struct ScrollableCanvasView: UIViewRepresentable {
|
||||
// Allow finger touches to pass through for scrolling
|
||||
canvasView.allowsFingerDrawing = false
|
||||
|
||||
// Set initial canvas size - will be updated in updateUIView with proper bounds
|
||||
let canvasWidth: CGFloat = 800 // Placeholder width
|
||||
let canvasHeight: CGFloat = 3000 // Start with a large canvas
|
||||
// Set initial canvas size based on drawing bounds
|
||||
let drawingBounds = drawing.bounds
|
||||
let canvasWidth: CGFloat
|
||||
let canvasHeight: CGFloat
|
||||
|
||||
if drawingBounds.isEmpty {
|
||||
canvasWidth = 2000
|
||||
canvasHeight = 3000
|
||||
} else {
|
||||
canvasWidth = max(drawingBounds.maxX + 500, 2000)
|
||||
canvasHeight = max(drawingBounds.maxY + 500, 3000)
|
||||
}
|
||||
|
||||
canvasView.frame = CGRect(x: 0, y: 0, width: canvasWidth, height: canvasHeight)
|
||||
|
||||
scrollView.addSubview(canvasView)
|
||||
scrollView.contentSize = canvasView.frame.size
|
||||
|
||||
// Allow scrolling beyond content
|
||||
// Allow scrolling beyond content in both directions
|
||||
scrollView.alwaysBounceVertical = true
|
||||
scrollView.alwaysBounceHorizontal = true
|
||||
|
||||
// Enable zooming
|
||||
scrollView.minimumZoomScale = 0.25 // Zoom out to 25% (see full canvas)
|
||||
scrollView.maximumZoomScale = 2.0 // Zoom in to 200%
|
||||
scrollView.delegate = context.coordinator
|
||||
|
||||
// Store references in coordinator
|
||||
context.coordinator.canvasView = canvasView
|
||||
context.coordinator.scrollView = scrollView
|
||||
|
||||
// Add double-tap gesture for zoom toggle
|
||||
let doubleTapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleDoubleTap(_:)))
|
||||
doubleTapGesture.numberOfTapsRequired = 2
|
||||
scrollView.addGestureRecognizer(doubleTapGesture)
|
||||
|
||||
// Restore saved tool if available
|
||||
if let savedTool = Coordinator.savedTool {
|
||||
canvasView.tool = savedTool
|
||||
}
|
||||
|
||||
return scrollView
|
||||
}
|
||||
|
||||
@@ -56,23 +82,24 @@ struct ScrollableCanvasView: UIViewRepresentable {
|
||||
}
|
||||
canvasView.isUserInteractionEnabled = isEditable
|
||||
|
||||
// Update canvas width to match scroll view bounds (once they're valid)
|
||||
let canvasWidth = scrollView.bounds.width > 0 ? scrollView.bounds.width : 800
|
||||
|
||||
// Expand canvas if drawing extends beyond current bounds
|
||||
let drawingBounds = drawing.bounds
|
||||
let currentWidth = canvasView.frame.width
|
||||
let currentHeight = canvasView.frame.height
|
||||
let requiredWidth: CGFloat
|
||||
let requiredHeight: CGFloat
|
||||
|
||||
if drawingBounds.isEmpty {
|
||||
requiredWidth = 2000
|
||||
requiredHeight = 3000
|
||||
} else {
|
||||
requiredWidth = max(drawingBounds.maxX + 500, 2000) // Add padding to the right
|
||||
requiredHeight = max(drawingBounds.maxY + 500, 3000) // Add padding below
|
||||
}
|
||||
|
||||
// Update canvas size if needed
|
||||
if requiredHeight > currentHeight || abs(canvasView.frame.width - canvasWidth) > 1 {
|
||||
canvasView.frame = CGRect(x: 0, y: 0, width: canvasWidth, height: requiredHeight)
|
||||
if requiredWidth > currentWidth || requiredHeight > currentHeight {
|
||||
canvasView.frame = CGRect(x: 0, y: 0, width: requiredWidth, height: requiredHeight)
|
||||
scrollView.contentSize = canvasView.frame.size
|
||||
}
|
||||
|
||||
@@ -86,7 +113,9 @@ struct ScrollableCanvasView: UIViewRepresentable {
|
||||
Coordinator(drawing: $drawing, onDrawingChanged: onDrawingChanged)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, PKCanvasViewDelegate {
|
||||
class Coordinator: NSObject, PKCanvasViewDelegate, UIScrollViewDelegate {
|
||||
static var savedTool: PKTool?
|
||||
|
||||
var drawing: Binding<PKDrawing>
|
||||
var onDrawingChanged: ((PKDrawing) -> Void)?
|
||||
var canvasView: PKCanvasView?
|
||||
@@ -121,6 +150,54 @@ struct ScrollableCanvasView: UIViewRepresentable {
|
||||
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
|
||||
drawing.wrappedValue = canvasView.drawing
|
||||
onDrawingChanged?(canvasView.drawing)
|
||||
|
||||
// Save the current tool for persistence
|
||||
Coordinator.savedTool = canvasView.tool
|
||||
}
|
||||
|
||||
// MARK: - UIScrollViewDelegate for zooming
|
||||
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
return canvasView
|
||||
}
|
||||
|
||||
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
|
||||
// Snap back to normal zoom (1.0) if close enough
|
||||
if scale > 0.9 && scale < 1.1 {
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
scrollView.setZoomScale(1.0, animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
|
||||
guard let scrollView = scrollView else { return }
|
||||
|
||||
let currentScale = scrollView.zoomScale
|
||||
|
||||
if abs(currentScale - 1.0) < 0.01 {
|
||||
// Currently at 100%, zoom in to 1.5x
|
||||
let location = gesture.location(in: canvasView)
|
||||
let zoomRect = zoomRect(for: 1.5, center: location)
|
||||
scrollView.zoom(to: zoomRect, animated: true)
|
||||
} else {
|
||||
// Not at 100%, zoom back to 100%
|
||||
scrollView.setZoomScale(1.0, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func zoomRect(for scale: CGFloat, center: CGPoint) -> CGRect {
|
||||
guard let scrollView = scrollView else { return .zero }
|
||||
|
||||
var zoomRect = CGRect.zero
|
||||
zoomRect.size.width = scrollView.frame.size.width / scale
|
||||
zoomRect.size.height = scrollView.frame.size.height / scale
|
||||
|
||||
// Center on the tap location
|
||||
zoomRect.origin.x = center.x - (zoomRect.size.width / 2.0)
|
||||
zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0)
|
||||
|
||||
return zoomRect
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
255
WorterBuch/SpacedRepetitionEngine.swift
Normal file
255
WorterBuch/SpacedRepetitionEngine.swift
Normal file
@@ -0,0 +1,255 @@
|
||||
//
|
||||
// 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 the date when the next card will become available
|
||||
static func getNextCardDate(
|
||||
in context: NSManagedObjectContext,
|
||||
tags: [Tag]? = nil
|
||||
) -> Date? {
|
||||
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)
|
||||
|
||||
// Get all flashcard-ready entries that are NOT yet due
|
||||
let futureCards = allEntries.filter { entry in
|
||||
entry.isFlashcardReady && !entry.isDueForReview && entry.nextReviewDate != nil
|
||||
}
|
||||
|
||||
// Find the earliest next review date
|
||||
return futureCards.compactMap { $0.nextReviewDate }.min()
|
||||
} catch {
|
||||
print("Error fetching next card date: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
34
WorterBuch/TranslationService.swift
Normal file
34
WorterBuch/TranslationService.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// TranslationService.swift
|
||||
// WorterBuch
|
||||
//
|
||||
// Created by Claude on 07.12.2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Translation
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
class TranslationService {
|
||||
static func translate(text: String, from sourceLanguage: String = "de", to targetLanguage: String = "en") async -> String? {
|
||||
do {
|
||||
let sourceLocale = Locale.Language(identifier: sourceLanguage)
|
||||
let targetLocale = Locale.Language(identifier: targetLanguage)
|
||||
|
||||
print("Attempting translation from \(sourceLanguage) to \(targetLanguage)")
|
||||
|
||||
// Create session - requires language pack to be installed
|
||||
let session = TranslationSession(installedSource: sourceLocale, target: targetLocale)
|
||||
|
||||
let response = try await session.translate(text)
|
||||
return response.targetText
|
||||
} catch {
|
||||
print("Translation error: \(error)")
|
||||
print("To enable translation:")
|
||||
print("1. Open the Translate app on your iPad")
|
||||
print("2. Download German language pack")
|
||||
print("3. Try the translate button again")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ struct VocabularyFieldCell: View {
|
||||
let drawing: PKDrawing
|
||||
let text: String
|
||||
let onTap: () -> Void
|
||||
var showTranslateButton: Bool = false
|
||||
var onTranslate: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -41,6 +43,7 @@ struct VocabularyFieldCell: View {
|
||||
}
|
||||
|
||||
// Transcribed text - selectable but not editable
|
||||
HStack(spacing: 8) {
|
||||
if !text.isEmpty {
|
||||
SelectableTextView(
|
||||
text: text.lowercased(),
|
||||
@@ -55,6 +58,16 @@ struct VocabularyFieldCell: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: 40)
|
||||
}
|
||||
|
||||
if showTranslateButton, let onTranslate = onTranslate {
|
||||
Button(action: onTranslate) {
|
||||
Image(systemName: "arrow.right.arrow.left.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
.font(.system(size: 20))
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color(.systemBackground))
|
||||
|
||||
@@ -86,7 +86,8 @@ struct VocabularyGridView: View {
|
||||
entry: entry,
|
||||
onSelectField: { fieldType in
|
||||
openFieldEditor(for: entry, fieldType: fieldType)
|
||||
}
|
||||
},
|
||||
onTranslate: translateEntry
|
||||
)
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
|
||||
.listRowSeparator(.hidden)
|
||||
@@ -211,11 +212,35 @@ struct VocabularyGridView: View {
|
||||
print("Error saving context: \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
}
|
||||
|
||||
private func translateEntry(_ entry: VocabularyEntry) {
|
||||
guard let germanText = entry.germanWordText, !germanText.isEmpty else { return }
|
||||
guard entry.englishTranslationText?.isEmpty ?? true else { return }
|
||||
|
||||
Task {
|
||||
if #available(iOS 18.0, *) {
|
||||
if let translation = await TranslationService.translate(text: germanText) {
|
||||
await MainActor.run {
|
||||
entry.englishTranslationText = translation
|
||||
saveContext()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print("Translation requires iOS 18.0 or later")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VocabularyEntryRow: View {
|
||||
@ObservedObject var entry: VocabularyEntry
|
||||
let onSelectField: (FieldType) -> Void
|
||||
let onTranslate: (VocabularyEntry) -> Void
|
||||
|
||||
var shouldShowTranslateButton: Bool {
|
||||
!(entry.germanWordText?.isEmpty ?? true) &&
|
||||
(entry.englishTranslationText?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
@@ -239,7 +264,9 @@ struct VocabularyEntryRow: View {
|
||||
VocabularyFieldCell(
|
||||
drawing: entry.englishTranslationPKDrawing,
|
||||
text: entry.englishTranslationText ?? "",
|
||||
onTap: { onSelectField(.englishTranslation) }
|
||||
onTap: { onSelectField(.englishTranslation) },
|
||||
showTranslateButton: shouldShowTranslateButton,
|
||||
onTranslate: { onTranslate(entry) }
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user