From 6d9a191527cc4e8fd9eb93ed16c819228fd259ad Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 14:06:11 +0200 Subject: [PATCH 01/21] =?UTF-8?q?chore:=20Archive-Polish=20=E2=80=94=20Ver?= =?UTF-8?q?sions-Sync=20+=20Orientations=20+=20Mac-Icon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CFBundleShortVersionString 0.1.0 + CFBundleVersion 1 in beiden Extensions (Widget + Share), damit sie mit dem Main-Bundle matchen (Apple-Validation-Warning bei Embedded-Binary) - UISupportedInterfaceOrientations (iPhone Portrait/Landscape + iPad alle vier), behebt Validation-Warning - AppIcon mac-Slot auf size 1024x1024 (Asset-Catalog akzeptiert) - xcodeVersion: 16.0 im XcodeGen-Manifest gegen "Update to recommended settings"-Hint - ShareViewController: DispatchQueue.main.async für State-Updates aus NSItemProvider-Callbacks (Swift-6-Concurrency-Sauberkeit) - PendingShareStore.append: guard url != nil statt unused-let Archive verifiziert via xcodebuild archive -allowProvisioningUpdates: ARCHIVE SUCCEEDED, alle drei Provisioning Profiles (cardecky, cardecky.widget, cardecky.share) automatisch geholt + signiert. Co-Authored-By: Claude Opus 4.7 (1M context) --- ShareExtension/ShareViewController.swift | 21 ++++++++++++++----- Sources/Core/Sync/PendingShareStore.swift | 2 +- .../AppIcon.appiconset/Contents.json | 2 +- project.yml | 14 +++++++++++++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index ee87506..81104f0 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -31,16 +31,27 @@ final class ShareViewController: UIViewController { group.enter() provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in if let url = item as? URL { - self.sharedURL = url.absoluteString - if self.sharedText.isEmpty { self.sharedText = url.absoluteString } + let absolute = url.absoluteString + DispatchQueue.main.async { + self.sharedURL = absolute + if self.sharedText.isEmpty { self.sharedText = absolute } + group.leave() + } + } else { + group.leave() } - group.leave() } } else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { group.enter() provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in - if let text = item as? String { self.sharedText = text } - group.leave() + if let text = item as? String { + DispatchQueue.main.async { + self.sharedText = text + group.leave() + } + } else { + group.leave() + } } } } diff --git a/Sources/Core/Sync/PendingShareStore.swift b/Sources/Core/Sync/PendingShareStore.swift index cd14d83..719f972 100644 --- a/Sources/Core/Sync/PendingShareStore.swift +++ b/Sources/Core/Sync/PendingShareStore.swift @@ -39,7 +39,7 @@ enum PendingShareStore { /// kann ein Eintrag verloren gehen — akzeptabel, weil Extension nur /// schreibt wenn User aktiv "Teilen" tippt. static func append(_ share: PendingShare) { - guard let url else { return } + guard url != nil else { return } var all = readAll() all.append(share) write(all) diff --git a/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 79657a3..dfec448 100644 --- a/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -34,7 +34,7 @@ "filename" : "AppIcon-1024.png", "idiom" : "mac", "scale" : "1x", - "size" : "512x512" + "size" : "1024x1024" } ], "info" : { diff --git a/project.yml b/project.yml index a2879d6..7753598 100644 --- a/project.yml +++ b/project.yml @@ -3,6 +3,7 @@ name: CardsNative options: bundleIdPrefix: ev.mana createIntermediateGroups: true + xcodeVersion: "16.0" deploymentTarget: iOS: "18.0" macOS: "15.0" @@ -59,6 +60,15 @@ targets: CFBundleDisplayName: Cards LSApplicationCategoryType: "public.app-category.education" UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight CFBundleURLTypes: - CFBundleURLName: ev.mana.cardecky CFBundleURLSchemes: @@ -100,6 +110,8 @@ targets: path: ShareExtension/Resources/Info.plist properties: CFBundleDisplayName: Als Karte speichern + CFBundleShortVersionString: "0.1.0" + CFBundleVersion: "1" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController @@ -131,6 +143,8 @@ targets: path: Widgets/CardsWidget/Resources/Info.plist properties: CFBundleDisplayName: Cards Widget + CFBundleShortVersionString: "0.1.0" + CFBundleVersion: "1" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension entitlements: From 6a4d66fd748eda67e49d7cc8f6d73d0fd812e7f0 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 14:12:29 +0200 Subject: [PATCH 02/21] chore: Sendable-Warning + AppIcon-Asset-Cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CardEditorView: pickerLabel als separate computed property extrahiert (PhotosPicker-Sendable-Closure-Warning auf occlusionImage). Warning bleibt cosmetisch auf der neuen Property, Swift-6-Strict-Edge-Case mit SwiftUI ViewBuilders. - AppIcon.appiconset/Contents.json: mac-Idiom-Slot entfernt (iOS-only erstmal — macOS-Support kommt mit eigenem Icon-Satz). Behebt "unassigned child"-Warnings. - RELEASE_CHECKLIST: /privacy + /help URLs als done markiert (live deployed in cards-Repo Commit c6488c0). Archive verifiziert: ARCHIVE SUCCEEDED, drei Provisioning Profiles (ev.mana.cardecky + .widget + .share) automatisch geholt und gesigned. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Features/Editor/CardEditorView.swift | 20 ++++++++++++++----- .../AppIcon.appiconset/Contents.json | 6 ------ docs/RELEASE_CHECKLIST.md | 7 ++++--- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/Sources/Features/Editor/CardEditorView.swift b/Sources/Features/Editor/CardEditorView.swift index d5bc324..3854fef 100644 --- a/Sources/Features/Editor/CardEditorView.swift +++ b/Sources/Features/Editor/CardEditorView.swift @@ -153,15 +153,25 @@ struct CardEditorView: View { } } + /// Extrahiert in eine eigene Property, weil PhotosPickers Label-Closure + /// unter Swift-6-Strict-Concurrency den direkten Zugriff auf + /// `@State`-Properties als Sendable-Verletzung markiert. Indirektion + /// über eine MainActor-isolierte computed Property löst das. + private var pickerLabel: some View { + Group { + if occlusionImage == nil { + Label("Bild auswählen", systemImage: "photo") + } else { + Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath") + } + } + } + @ViewBuilder private var imageOcclusionFields: some View { Section("Bild") { PhotosPicker(selection: $imagePickerItem, matching: .images) { - if occlusionImage == nil { - Label("Bild auswählen", systemImage: "photo") - } else { - Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath") - } + pickerLabel } .onChange(of: imagePickerItem) { _, newItem in Task { await loadPickedImage(newItem) } diff --git a/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index dfec448..59e99f9 100644 --- a/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -29,12 +29,6 @@ "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" - }, - { - "filename" : "AppIcon-1024.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "1024x1024" } ], "info" : { diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index f4f7fe4..009b9fc 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -97,9 +97,10 @@ AASA) und über Xcode (für Build + Sign). "Karteikarten,Spaced Repetition,Lernen,Vokabeln,Anki,Flashcards,FSRS,mana,Verein,Open Source" - [ ] **Screenshots** für iPhone 16 Pro Max + iPhone SE-3 + iPad Pro. 6.7", 6.5", 5.5", iPad 12.9" — siehe Apple's Specs. -- [ ] **Privacy-Policy-URL** — vermutlich `cardecky.mana.how/privacy` - oder `mana-ev.ch/privacy`. **Verifizieren.** -- [ ] **Support-URL** — `cardecky.mana.how/help` oder Verein-Kontakt. +- [x] **Privacy-Policy-URL**: `https://cardecky.mana.how/privacy` (live + seit 2026-05-13, SvelteKit-Route mit Verein-Content). +- [x] **Support-URL**: `https://cardecky.mana.how/help` (live, FAQ + + Kontakt-Email kontakt@mana-ev.ch). - [ ] **Marketing-URL** (optional) — `cardecky.mana.how`. - [ ] **Age-Rating**: vermutlich 4+ (no objectionable content). - [ ] **Pricing**: Free. From e24e0e68258e394dec40d99b86bcbc7a98bd91fb Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 14:39:09 +0200 Subject: [PATCH 03/21] fix(editor): PhotosPicker Sendable-Warning durch Sub-View struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImagePickerLabel als private View-Struct extrahiert. SwiftUIs PhotosPicker(label:)-Closure ist @Sendable, aber View-Konstruktor- Calls werden zur Build-Zeit MainActor-isoliert evaluiert — im Gegensatz zu direktem @State-Zugriff im Closure-Body. Vorher: pickerLabel als computed property → Warning blieb. Jetzt: ImagePickerLabel(hasImage: occlusionImage != nil) → Warning weg, Swift-Build clean. Archive grün, Build grün, keine Swift-Warnings mehr (nur eine AppIntents-Framework-Hinweis-Note ohne Auswirkung). Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Features/Editor/CardEditorView.swift | 33 ++++---- Sources/Resources/Localizable.xcstrings | 89 +++++++++++++++++++- 2 files changed, 105 insertions(+), 17 deletions(-) diff --git a/Sources/Features/Editor/CardEditorView.swift b/Sources/Features/Editor/CardEditorView.swift index 3854fef..2f2249b 100644 --- a/Sources/Features/Editor/CardEditorView.swift +++ b/Sources/Features/Editor/CardEditorView.swift @@ -153,25 +153,11 @@ struct CardEditorView: View { } } - /// Extrahiert in eine eigene Property, weil PhotosPickers Label-Closure - /// unter Swift-6-Strict-Concurrency den direkten Zugriff auf - /// `@State`-Properties als Sendable-Verletzung markiert. Indirektion - /// über eine MainActor-isolierte computed Property löst das. - private var pickerLabel: some View { - Group { - if occlusionImage == nil { - Label("Bild auswählen", systemImage: "photo") - } else { - Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath") - } - } - } - @ViewBuilder private var imageOcclusionFields: some View { Section("Bild") { PhotosPicker(selection: $imagePickerItem, matching: .images) { - pickerLabel + ImagePickerLabel(hasImage: occlusionImage != nil) } .onChange(of: imagePickerItem) { _, newItem in Task { await loadPickedImage(newItem) } @@ -364,3 +350,20 @@ private extension String { trimmingCharacters(in: .whitespacesAndNewlines) } } + +/// Wird als Sub-View aus dem PhotosPicker-Label-Closure aufgerufen. +/// Eigene `View`-Struct vermeidet die Swift-6-Strict-Concurrency- +/// Warning: SwiftUIs `PhotosPicker(label:)`-Closure ist `@Sendable`, +/// aber View-Konstruktor-Calls werden zur Build-Zeit MainActor-isoliert +/// evaluiert (im Gegensatz zu direktem @State-Zugriff im Closure-Body). +private struct ImagePickerLabel: View { + let hasImage: Bool + + var body: some View { + if hasImage { + Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath") + } else { + Label("Bild auswählen", systemImage: "photo") + } + } +} diff --git a/Sources/Resources/Localizable.xcstrings b/Sources/Resources/Localizable.xcstrings index 9b38231..00f53d6 100644 --- a/Sources/Resources/Localizable.xcstrings +++ b/Sources/Resources/Localizable.xcstrings @@ -1,5 +1,90 @@ { "sourceLanguage" : "de", - "strings" : { }, + "strings" : { + "%@" : { + + }, + "%@ fällige Karten aus abonnierten Decks" : { + + }, + "%@ Karten gelernt" : { + + }, + "Abmelden" : { + + }, + "Alle Karten und Reviews dieses Decks werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden." : { + + }, + "Anmelden" : { + + }, + "Antwort anzeigen" : { + + }, + "Aus Teilen-Menü" : { + + }, + "Beide Richtungen werden gelernt — front→back und back→front." : { + + }, + "Card-Type »%@« kommt in einer späteren Phase" : { + + }, + "Cards" : { + + }, + "Changelog" : { + + }, + "Distractor-Optionen werden zur Lernzeit automatisch aus anderen Karten desselben Decks gezogen." : { + + }, + "Erst ein Deck erstellen." : { + + }, + "Erstelle dein erstes Deck auf cardecky.mana.how oder ziehe nach unten zum Aktualisieren." : { + + }, + "Inbox" : { + + }, + "Karteikarten des Vereins mana e.V." : { + + }, + "Karten konnten nicht geladen werden" : { + + }, + "Keine" : { + + }, + "Lade Decks …" : { + + }, + "Mit Hint: `{{c1::Berlin::Hauptstadt von DE}}`" : { + + }, + "Noch keine Maske" : { + + }, + "Öffentlich" : { + + }, + "Privat" : { + + }, + "Space" : { + + }, + "Tippe und ziehe auf das Bild, um eine Maske zu erstellen." : { + + }, + "Versuche eine andere Suche oder Sortierung." : { + + }, + "Wählen …" : { + + } + }, "version" : "1.0" -} +} \ No newline at end of file From 2eb7f61f9cbaae818e378a882d5af8cf0d098c60 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 15:05:33 +0200 Subject: [PATCH 04/21] =?UTF-8?q?fix:=20ITMS-90129=20=E2=80=94=20DisplayNa?= =?UTF-8?q?me=20Cards=20=E2=86=92=20Cardecky=20+=20Build=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple lehnte Build 0.1.0(1) ab mit ITMS-90129: "The bundle uses a bundle name or display name that is already taken." Im App-Store gibt es schon Apps mit dem DisplayName "Cards" (u.a. Apples eigene Grußkarten-App war so benannt). App-Store-Connect-App heißt sowieso "Cardecky" — Brand-Konsistenz: DisplayName durchgehend "Cardecky". Geändert: - project.yml Main-App: CFBundleDisplayName Cards → Cardecky, CFBundleVersion 1 → 2 (Apple lehnt doppelte Build-Nummern ab) - project.yml Widget: CFBundleDisplayName Cards Widget → Cardecky Widget, INFOPLIST_KEY_CFBundleDisplayName analog - project.yml Share-Extension: CFBundleVersion 1 → 2 - LoginView Heading: "Cards" → "Cardecky" - NotificationManager.content.title: "Cards" → "Cardecky" - UITest: erwartet "Cardecky" statt "Cards" Archive verifiziert: CFBundleDisplayName=Cardecky, CFBundleVersion=2, ARCHIVE SUCCEEDED. Nach erneutem Upload via Xcode Organizer sollte TestFlight den Build akzeptieren. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Core/Notifications/NotificationManager.swift | 2 +- Sources/Features/Account/LoginView.swift | 2 +- Tests/UITests/CardsNativeUITests.swift | 6 +++--- project.yml | 12 ++++++------ 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Sources/Core/Notifications/NotificationManager.swift b/Sources/Core/Notifications/NotificationManager.swift index a48aec9..8aa98d4 100644 --- a/Sources/Core/Notifications/NotificationManager.swift +++ b/Sources/Core/Notifications/NotificationManager.swift @@ -67,7 +67,7 @@ final class NotificationManager { guard remindersEnabled, authorization == .authorized else { return } let content = UNMutableNotificationContent() - content.title = "Cards" + content.title = "Cardecky" content.body = "Ein paar Karten warten auf dich." content.sound = .default diff --git a/Sources/Features/Account/LoginView.swift b/Sources/Features/Account/LoginView.swift index 7823745..f9760e2 100644 --- a/Sources/Features/Account/LoginView.swift +++ b/Sources/Features/Account/LoginView.swift @@ -10,7 +10,7 @@ struct LoginView: View { ZStack { CardsTheme.background.ignoresSafeArea() VStack(spacing: 24) { - Text("Cards") + Text("Cardecky") .font(.system(size: 48, weight: .bold)) .foregroundStyle(CardsTheme.primary) Text("Karteikarten des Vereins mana e.V.") diff --git a/Tests/UITests/CardsNativeUITests.swift b/Tests/UITests/CardsNativeUITests.swift index 41b0ee2..f14e426 100644 --- a/Tests/UITests/CardsNativeUITests.swift +++ b/Tests/UITests/CardsNativeUITests.swift @@ -4,11 +4,11 @@ final class CardsNativeUITests: XCTestCase { func testAppLaunches() throws { let app = XCUIApplication() app.launch() - // App ist gestartet, sobald entweder das LoginView "Cards" + // App ist gestartet, sobald entweder das LoginView "Cardecky" // oder das DeckListView mit "Decks" sichtbar ist. Welcher // von beiden hängt davon ab, ob der Simulator-Keychain noch // eine Session hält. - let loginTitle = app.staticTexts["Cards"] + let loginTitle = app.staticTexts["Cardecky"] let decksTitle = app.staticTexts["Decks"] let exploreTab = app.staticTexts["Entdecken"] @@ -21,6 +21,6 @@ final class CardsNativeUITests: XCTestCase { } usleep(100_000) } - XCTAssertTrue(found, "Erwartete App-Surface (Cards | Decks | Entdecken) erschien nicht innerhalb 5 s") + XCTAssertTrue(found, "Erwartete App-Surface (Cardecky | Decks | Entdecken) erschien nicht innerhalb 5 s") } } diff --git a/project.yml b/project.yml index 7753598..c3d0ee6 100644 --- a/project.yml +++ b/project.yml @@ -55,9 +55,9 @@ targets: path: Sources/Resources/Info.plist properties: CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "1" + CFBundleVersion: "2" CFBundleDevelopmentRegion: de - CFBundleDisplayName: Cards + CFBundleDisplayName: Cardecky LSApplicationCategoryType: "public.app-category.education" UILaunchScreen: {} UISupportedInterfaceOrientations: @@ -111,7 +111,7 @@ targets: properties: CFBundleDisplayName: Als Karte speichern CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "1" + CFBundleVersion: "2" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController @@ -142,9 +142,9 @@ targets: info: path: Widgets/CardsWidget/Resources/Info.plist properties: - CFBundleDisplayName: Cards Widget + CFBundleDisplayName: Cardecky Widget CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "1" + CFBundleVersion: "2" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension entitlements: @@ -160,7 +160,7 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cardecky.widget CODE_SIGN_STYLE: Automatic SKIP_INSTALL: "YES" - INFOPLIST_KEY_CFBundleDisplayName: Cards Widget + INFOPLIST_KEY_CFBundleDisplayName: Cardecky Widget CardsNativeTests: type: bundle.unit-test From b5edf5cf2ecb62aca503ac1a25a92a2b649ee6b2 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 15:11:11 +0200 Subject: [PATCH 05/21] =?UTF-8?q?fix:=20User-facing=20Strings=20Cards=20?= =?UTF-8?q?=E2=86=92=20Cardecky=20komplett,=20Build=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing Rebrand: - LoginView Heading (war schon in v0.8.5) - NotificationManager.title (war schon in v0.8.5) - ShareEditorView Footer-Text: "...in der Cards-App" → "...in der Cardecky-App" - StudyAppIntents Description: "Öffnet Cards" → "Öffnet Cardecky" - Localizable.xcstrings: "Cards" key → "Cardecky" - NSPhotoLibraryUsageDescription: "Cards greift..." → "Cardecky greift..." - Log.app.info("Cards starting") → "Cardecky starting" - MARKETING_COPY.md: alle "Cards"-Treffer in DE + EN auf Cardecky - RELEASE_CHECKLIST: App-Name "Cards" → "Cardecky" Build-Nummer 2 → 3 (Apple lehnt doppelte Build-Nummern ab, Code- Hash hat sich geändert). Code-Identifier bleiben: CardsAPI, CardsTheme, CardsNativeApp, CardsWidgetExtension, CardsShareExtension — interne Symbol-Namen, nicht user-facing. Archive verifiziert: CFBundleDisplayName=Cardecky, Build=3. Co-Authored-By: Claude Opus 4.7 (1M context) --- ShareExtension/ShareEditorView.swift | 2 +- Sources/App/CardsNativeApp.swift | 2 +- Sources/Core/Intents/StudyAppIntents.swift | 2 +- Sources/Resources/Localizable.xcstrings | 2 +- docs/MARKETING_COPY.md | 20 ++++++++++---------- docs/RELEASE_CHECKLIST.md | 2 +- project.yml | 8 ++++---- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/ShareExtension/ShareEditorView.swift b/ShareExtension/ShareEditorView.swift index 842afa3..f7b214b 100644 --- a/ShareExtension/ShareEditorView.swift +++ b/ShareExtension/ShareEditorView.swift @@ -39,7 +39,7 @@ struct ShareEditorView: View { } } Section { - Text("Wähle das Ziel-Deck in der Cards-App.") + Text("Wähle das Ziel-Deck in der Cardecky-App.") .font(.caption) .foregroundStyle(.secondary) } diff --git a/Sources/App/CardsNativeApp.swift b/Sources/App/CardsNativeApp.swift index 7cdc542..46bcc2e 100644 --- a/Sources/App/CardsNativeApp.swift +++ b/Sources/App/CardsNativeApp.swift @@ -18,7 +18,7 @@ struct CardsNativeApp: App { auth.bootstrap() _auth = State(initialValue: auth) mediaCache = MediaCache(api: CardsAPI(auth: auth)) - Log.app.info("Cards starting — auth status: \(String(describing: auth.status), privacy: .public)") + Log.app.info("Cardecky starting — auth status: \(String(describing: auth.status), privacy: .public)") } var body: some Scene { diff --git a/Sources/Core/Intents/StudyAppIntents.swift b/Sources/Core/Intents/StudyAppIntents.swift index a98efc2..d65e541 100644 --- a/Sources/Core/Intents/StudyAppIntents.swift +++ b/Sources/Core/Intents/StudyAppIntents.swift @@ -7,7 +7,7 @@ import SwiftUI struct StudyCardsIntent: AppIntent { static let title: LocalizedStringResource = "Karten lernen" static let description = IntentDescription( - "Öffnet Cards und zeigt deine Decks mit fälligen Karten.", + "Öffnet Cardecky und zeigt deine Decks mit fälligen Karten.", categoryName: "Lernen" ) static let openAppWhenRun: Bool = true diff --git a/Sources/Resources/Localizable.xcstrings b/Sources/Resources/Localizable.xcstrings index 00f53d6..9f45a6f 100644 --- a/Sources/Resources/Localizable.xcstrings +++ b/Sources/Resources/Localizable.xcstrings @@ -31,7 +31,7 @@ "Card-Type »%@« kommt in einer späteren Phase" : { }, - "Cards" : { + "Cardecky" : { }, "Changelog" : { diff --git a/docs/MARKETING_COPY.md b/docs/MARKETING_COPY.md index 8b493ef..c327555 100644 --- a/docs/MARKETING_COPY.md +++ b/docs/MARKETING_COPY.md @@ -7,7 +7,7 @@ gegenlesen, Tonalität an Vereins-Stil schärfen (siehe `mana/docs/BRAND.md`, ## Name + Subtitle -**App-Name:** `Cards` +**App-Name:** `Cardecky` **Subtitle (max 30 Zeichen):** `Karteikarten — mana e.V.` ## Keywords (max 100 Zeichen, comma-separated) @@ -21,7 +21,7 @@ Karteikarten,Spaced Repetition,Lernen,Vokabeln,Anki,Flashcards,FSRS,mana,Verein, ## Description — DE (max 4000 Zeichen) ``` -Cards ist die Karteikarten-App des Vereins mana e.V. — Spaced +Cardecky ist die Karteikarten-App des Vereins mana e.V. — Spaced Repetition wie es sein soll: ohne Werbung, ohne Tracking, ohne Abo-Zwang. @@ -33,13 +33,13 @@ KARTEN, WIE DU SIE BRAUCHST - Audio-Karten für Sprachen und Aussprache MODERNER LERN-ALGORITHMUS -Cards nutzt FSRS (Free Spaced Repetition Scheduler), den genauesten +Cardecky nutzt FSRS (Free Spaced Repetition Scheduler), den genauesten offenen Algorithmus für Karteikarten. Karten kommen wieder, wenn du sie wirklich brauchst — nicht nach willkürlichen Intervallen. OFFLINE LERNEN Karten cachen lokal auf deinem Gerät. Im Flugzeug, in der U-Bahn, -ohne WLAN — Cards funktioniert. Deine Bewertungen werden gequeued +ohne WLAN — Cardecky funktioniert. Deine Bewertungen werden gequeued und beim nächsten Online-Moment automatisch hochgeladen. WIDGETS UND NOTIFICATIONS @@ -53,7 +53,7 @@ Sprachen, Wissenschaft. Abonnieren = Karten landen direkt in deiner Bibliothek, du kannst sie nach Belieben anpassen. VEREIN, NICHT FIRMA -Cards wird vom Verein mana e.V. (Schweiz, in Gründung) betrieben. +Cardecky wird vom Verein mana e.V. (Schweiz, in Gründung) betrieben. Kein Tracking, kein Werbe-Anbieter, kein Crash-Reporter. Wenn du dem Verein etwas zurückgeben willst: mana-ev.ch/spende. @@ -71,7 +71,7 @@ selbst. ## Description — EN (max 4000 Zeichen) ``` -Cards is the flashcard app from mana e.V. — spaced repetition the +Cardecky is the flashcard app from mana e.V. — spaced repetition the way it should be: no ads, no tracking, no subscription pressure. THE CARDS YOU NEED @@ -80,12 +80,12 @@ THE CARDS YOU NEED - Audio cards for languages and pronunciation MODERN SPACED-REPETITION -Cards uses FSRS (Free Spaced Repetition Scheduler), the most accurate -open algorithm available. Cards come back when you actually need them. +Cardecky uses FSRS (Free Spaced Repetition Scheduler), the most accurate +open algorithm available. Your cards come back when you actually need them. OFFLINE-FIRST Decks cache locally on your device. On a plane, in the subway, off -the grid — Cards keeps working. Your reviews queue up and sync on +the grid — Cardecky keeps working. Your reviews queue up and sync on the next online moment. WIDGETS AND REMINDERS @@ -97,7 +97,7 @@ Browse public decks from the Cardecky community: geography, languages, science. Subscribe = decks land in your library, ready to be edited. ASSOCIATION, NOT CORPORATION -Cards is operated by mana e.V. (Switzerland, formation in progress). +Cardecky is operated by mana e.V. (Switzerland, formation in progress). No tracking, no ad networks, no crash reporters. Want to give back? mana-ev.ch/donate. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 009b9fc..c8f965e 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -88,7 +88,7 @@ AASA) und über Xcode (für Build + Sign). - [ ] **App-Eintrag erstellen** unter https://appstoreconnect.apple.com mit Bundle-ID `ev.mana.cardecky`. - [ ] **App-Name** + **Subtitle** (max 30 Zeichen): - - Name: "Cards" + - Name: "Cardecky" - Subtitle: "Karteikarten — Verein mana" - [ ] **Description** (de + en, max 4000 Zeichen). Vorschlag in [`docs/MARKETING_COPY.md`](MARKETING_COPY.md) — vor Submission diff --git a/project.yml b/project.yml index c3d0ee6..2a39639 100644 --- a/project.yml +++ b/project.yml @@ -55,7 +55,7 @@ targets: path: Sources/Resources/Info.plist properties: CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "2" + CFBundleVersion: "3" CFBundleDevelopmentRegion: de CFBundleDisplayName: Cardecky LSApplicationCategoryType: "public.app-category.education" @@ -75,7 +75,7 @@ targets: - cards NSUserActivityTypes: - NSUserActivityTypeBrowsingWeb - NSPhotoLibraryUsageDescription: "Cards greift auf deine Fotos zu, damit du Bilder zu Image-Occlusion-Karten hinzufügen kannst." + NSPhotoLibraryUsageDescription: "Cardecky greift auf deine Fotos zu, damit du Bilder zu Image-Occlusion-Karten hinzufügen kannst." ITSAppUsesNonExemptEncryption: false entitlements: path: Sources/Resources/CardsNative.entitlements @@ -111,7 +111,7 @@ targets: properties: CFBundleDisplayName: Als Karte speichern CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "2" + CFBundleVersion: "3" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController @@ -144,7 +144,7 @@ targets: properties: CFBundleDisplayName: Cardecky Widget CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "2" + CFBundleVersion: "3" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension entitlements: From f528ea448a62b76771af2bdb6649da2c13c4038f Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 15:46:20 +0200 Subject: [PATCH 06/21] fix: PublicDeckOwner.pseudonym ist Bool, nicht String? MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beim Öffnen eines Marketplace-Decks crashed JSON-Decoder mit typeMismatch (Expected String, found Bool) auf owner.pseudonym. Ursache: Server-Schema (cards/apps/api/src/db/schema/marketplace/ authors.ts) hat pseudonym als `boolean NOT NULL DEFAULT false` — ein Flag, dass der Autor pseudonym auftritt (Anzeigename verbergen). Native hatte das fälschlich als String? (Anzeige-Pseudonym) interpretiert. Fix: - PublicDeckOwner.pseudonym: String? → Bool - decoder.decodeIfPresent(String.self) → decode(Bool.self) ?? false - Test-Fixture: "pseudonym": null → "pseudonym": false Build 3 → 4. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Core/Domain/Marketplace.swift | 8 ++++++-- Tests/UnitTests/MarketplaceDecodingTests.swift | 2 +- project.yml | 6 +++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Sources/Core/Domain/Marketplace.swift b/Sources/Core/Domain/Marketplace.swift index b4f929e..52d1b6f 100644 --- a/Sources/Core/Domain/Marketplace.swift +++ b/Sources/Core/Domain/Marketplace.swift @@ -37,7 +37,11 @@ struct PublicDeckOwner: Codable, Hashable, Sendable { let displayName: String let verifiedMana: Bool let verifiedCommunity: Bool - let pseudonym: String? + /// Flag: Autor tritt pseudonym auf (Anzeigename verbergen). + /// Server-Schema ist `boolean NOT NULL DEFAULT false`. War in v0.8.x + /// fälschlich als `String?` (Anzeige-Pseudonym) interpretiert — + /// führte zu Decoder-typeMismatch beim Öffnen eines Marketplace-Decks. + let pseudonym: Bool enum CodingKeys: String, CodingKey { case slug @@ -53,7 +57,7 @@ struct PublicDeckOwner: Codable, Hashable, Sendable { displayName = try c.decode(String.self, forKey: .displayName) verifiedMana = try c.decode(Bool.self, forKey: .verifiedMana) verifiedCommunity = try c.decode(Bool.self, forKey: .verifiedCommunity) - pseudonym = try c.decodeIfPresent(String.self, forKey: .pseudonym) + pseudonym = (try? c.decode(Bool.self, forKey: .pseudonym)) ?? false } } diff --git a/Tests/UnitTests/MarketplaceDecodingTests.swift b/Tests/UnitTests/MarketplaceDecodingTests.swift index c1d7a80..c86904c 100644 --- a/Tests/UnitTests/MarketplaceDecodingTests.swift +++ b/Tests/UnitTests/MarketplaceDecodingTests.swift @@ -31,7 +31,7 @@ struct MarketplaceDecodingTests { "display_name": "mana-Kuratoren", "verified_mana": true, "verified_community": false, - "pseudonym": null + "pseudonym": false } } """.data(using: .utf8)! diff --git a/project.yml b/project.yml index 2a39639..43a5fe4 100644 --- a/project.yml +++ b/project.yml @@ -55,7 +55,7 @@ targets: path: Sources/Resources/Info.plist properties: CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "3" + CFBundleVersion: "4" CFBundleDevelopmentRegion: de CFBundleDisplayName: Cardecky LSApplicationCategoryType: "public.app-category.education" @@ -111,7 +111,7 @@ targets: properties: CFBundleDisplayName: Als Karte speichern CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "3" + CFBundleVersion: "4" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController @@ -144,7 +144,7 @@ targets: properties: CFBundleDisplayName: Cardecky Widget CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "3" + CFBundleVersion: "4" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension entitlements: From e8b898a51ddff1e0b2c7097b7f9fb9b4850f5eae Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 16:02:59 +0200 Subject: [PATCH 07/21] feat(decks): Card-Liste im DeckDetailView + listCards-API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher zeigte DeckDetailView nur 4 Action-Buttons (Lernen, Hinzufügen, Bearbeiten, Löschen) — Karten waren nur via Study-Loop sichtbar. User-Feedback: "ich sehe keine Karten im Deck". Geändert: - CardsAPI.listCards(deckId:) → [Card] (war nur cardCount via /total) - CardListResponse: nimmt cards-Array zusätzlich zu total - DeckDetailView: ScrollView statt VStack, neue Sektion "Karten" unter den Action-Buttons mit CardPreviewRow pro Karte - CardPreviewRow: Type-Icon + Front-Preview (basic/cloze/audio/ image-occlusion adaptiv) + Type-Label - task(id:) + refreshable triggern loadCards() - Nach CardEditor-Save reloaded die Liste Build 4 → 5. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Core/API/CardsAPI.swift | 8 + Sources/Core/Domain/Deck.swift | 1 + Sources/Features/Decks/DeckDetailView.swift | 171 ++++++++++++++++++-- project.yml | 6 +- 4 files changed, 169 insertions(+), 17 deletions(-) diff --git a/Sources/Core/API/CardsAPI.swift b/Sources/Core/API/CardsAPI.swift index f9472bc..764d54c 100644 --- a/Sources/Core/API/CardsAPI.swift +++ b/Sources/Core/API/CardsAPI.swift @@ -44,6 +44,14 @@ actor CardsAPI { return try decoder.decode(CardListResponse.self, from: data).total } + /// `GET /api/v1/cards?deck_id=...` — komplette Liste der Karten + /// für den Browse-Modus im DeckDetailView. + func listCards(deckId: String) async throws -> [Card] { + let (data, http) = try await transport.request(path: "/api/v1/cards?deck_id=\(deckId)") + try ensureOK(http, data: data) + return try decoder.decode(CardListResponse.self, from: data).cards + } + /// `GET /api/v1/reviews/due?deck_id=...&limit=500` — Anzahl fälliger /// Reviews in einem Deck. func dueCount(deckId: String) async throws -> Int { diff --git a/Sources/Core/Domain/Deck.swift b/Sources/Core/Domain/Deck.swift index e4f907f..cb3d007 100644 --- a/Sources/Core/Domain/Deck.swift +++ b/Sources/Core/Domain/Deck.swift @@ -121,6 +121,7 @@ struct DeckListResponse: Decodable, Sendable { /// Server-Response von `GET /api/v1/cards?deck_id=...`. struct CardListResponse: Decodable, Sendable { + let cards: [Card] let total: Int } diff --git a/Sources/Features/Decks/DeckDetailView.swift b/Sources/Features/Decks/DeckDetailView.swift index 045f78c..d037ea1 100644 --- a/Sources/Features/Decks/DeckDetailView.swift +++ b/Sources/Features/Decks/DeckDetailView.swift @@ -2,8 +2,8 @@ import ManaCore import SwiftData import SwiftUI -/// Deck-Detail mit Aktionen: Lernen, Karte hinzufügen, Bearbeiten, Löschen. -/// Wird per Tap auf eine Deck-Row aus der DeckListView geöffnet. +/// Deck-Detail mit Aktionen + Card-Liste. Wird per Tap auf eine Deck-Row +/// aus der DeckListView geöffnet. struct DeckDetailView: View { let deckId: String @@ -18,6 +18,10 @@ struct DeckDetailView: View { @State private var navigateToStudy = false @State private var deleteError: String? + @State private var cards: [Card] = [] + @State private var isLoadingCards = false + @State private var cardsError: String? + init(deckId: String) { self.deckId = deckId _decks = Query(filter: #Predicate { $0.id == deckId }) @@ -50,7 +54,10 @@ struct DeckDetailView: View { .sheet(isPresented: $showCardEditor) { NavigationStack { CardEditorView(deckId: deckId) { _ in - Task { await refreshAfterEdit() } + Task { + await refreshAfterEdit() + await loadCards() + } } } } @@ -71,21 +78,29 @@ struct DeckDetailView: View { StudySessionView(deckId: deck.id, deckName: deck.name) } } + .task(id: deckId) { + await loadCards() + } + .refreshable { + await loadCards() + } } private func content(deck: CachedDeck) -> some View { - VStack(alignment: .leading, spacing: 16) { - header(deck: deck) - actions(deck: deck) - if let deleteError { - Text(deleteError) - .font(.footnote) - .foregroundStyle(CardsTheme.error) - .padding(.horizontal, 16) + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header(deck: deck) + actions(deck: deck) + if let deleteError { + Text(deleteError) + .font(.footnote) + .foregroundStyle(CardsTheme.error) + .padding(.horizontal, 16) + } + cardListSection } - Spacer() + .padding(.vertical, 16) } - .padding(.vertical, 16) } private func header(deck: CachedDeck) -> some View { @@ -179,17 +194,76 @@ struct DeckDetailView: View { .padding(.horizontal, 16) } + @ViewBuilder + private var cardListSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Karten") + .font(.headline) + .foregroundStyle(CardsTheme.foreground) + Spacer() + if !cards.isEmpty { + Text("\(cards.count)") + .font(.footnote) + .foregroundStyle(CardsTheme.mutedForeground) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + + if isLoadingCards, cards.isEmpty { + HStack { + Spacer() + ProgressView() + .tint(CardsTheme.primary) + Spacer() + } + .padding(.vertical, 24) + } else if let cardsError { + Text(cardsError) + .font(.caption) + .foregroundStyle(CardsTheme.error) + .padding(.horizontal, 16) + } else if cards.isEmpty { + Text("Noch keine Karten. Tippe auf »Karte hinzufügen«.") + .font(.footnote) + .foregroundStyle(CardsTheme.mutedForeground) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } else { + LazyVStack(spacing: 8) { + ForEach(cards) { card in + CardPreviewRow(card: card) + .padding(.horizontal, 16) + } + } + } + } + } + private func refreshAfterEdit() async { let store = DeckListStore(auth: auth, context: context) await store.refresh() } + private func loadCards() async { + isLoadingCards = true + cardsError = nil + defer { isLoadingCards = false } + let api = CardsAPI(auth: auth) + do { + cards = try await api.listCards(deckId: deckId) + .sorted { $0.createdAt > $1.createdAt } + } catch { + cardsError = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } + private func delete() async { deleteError = nil let api = CardsAPI(auth: auth) do { try await api.deleteDeck(id: deckId) - // Cache nachziehen if let deck = decks.first { context.delete(deck) try? context.save() @@ -200,3 +274,72 @@ struct DeckDetailView: View { } } } + +/// Kompakte Card-Row mit Front-Vorschau und Type-Badge. +private struct CardPreviewRow: View { + let card: Card + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon(for: card.type)) + .foregroundStyle(CardsTheme.primary) + .frame(width: 24) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 4) { + Text(preview(card: card)) + .font(.subheadline) + .foregroundStyle(CardsTheme.foreground) + .lineLimit(2) + Text(typeLabel(card.type)) + .font(.caption2) + .foregroundStyle(CardsTheme.mutedForeground) + } + Spacer() + } + .padding(12) + .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(CardsTheme.border, lineWidth: 1) + ) + } + + private func preview(card: Card) -> String { + switch card.type { + case .basic, .basicReverse, .typing, .multipleChoice: + return card.fields["front"] ?? "—" + case .cloze: + return card.fields["text"] ?? "—" + case .imageOcclusion: + return card.fields["note"]?.isEmpty == false + ? card.fields["note"]! + : "Bild-Verdeckung (\(MaskRegions.count(card.fields["mask_regions"] ?? "")) Masken)" + case .audioFront: + return card.fields["back"] ?? "Audio-Karte" + } + } + + private func icon(for type: CardType) -> String { + switch type { + case .basic: "rectangle.split.2x1" + case .basicReverse: "rectangle.2.swap" + case .cloze: "text.append" + case .typing: "keyboard" + case .multipleChoice: "list.bullet" + case .imageOcclusion: "photo.on.rectangle.angled" + case .audioFront: "waveform" + } + } + + private func typeLabel(_ type: CardType) -> String { + switch type { + case .basic: "Einfach" + case .basicReverse: "Beidseitig" + case .cloze: "Lückentext" + case .typing: "Eintippen" + case .multipleChoice: "Multiple Choice" + case .imageOcclusion: "Bild-Verdeckung" + case .audioFront: "Audio" + } + } +} diff --git a/project.yml b/project.yml index 43a5fe4..4f497cc 100644 --- a/project.yml +++ b/project.yml @@ -55,7 +55,7 @@ targets: path: Sources/Resources/Info.plist properties: CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "4" + CFBundleVersion: "5" CFBundleDevelopmentRegion: de CFBundleDisplayName: Cardecky LSApplicationCategoryType: "public.app-category.education" @@ -111,7 +111,7 @@ targets: properties: CFBundleDisplayName: Als Karte speichern CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "4" + CFBundleVersion: "5" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController @@ -144,7 +144,7 @@ targets: properties: CFBundleDisplayName: Cardecky Widget CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "4" + CFBundleVersion: "5" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension entitlements: From 0b0872c8c072e1edbe2b2a612eb9b2a440bb5ab6 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 17:20:03 +0200 Subject: [PATCH 08/21] =?UTF-8?q?fix:=20404=20auf=20allen=20Query-Endpoint?= =?UTF-8?q?s=20=E2=80=94=20pulls=20ManaCore=20v1.0.1=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-Cause des "0 Karten in jedem Deck" + "Server-Fehler (404)"- Bug: ManaCore.AuthenticatedTransport.request(path:) hat URL.appending(path:) verwendet, das `?` in Query-Strings als Pfad- Component encoded → `?deck_id=X` → `%3Fdeck_id=X` → Server-Route matched nicht → 404. Betroffene Endpoints (alle hatten still failed): - /api/v1/cards?deck_id=X → cardCount immer 0 - /api/v1/reviews/due?deck_id=X → dueCount immer 0, "Karten lernen" immer disabled - /api/v1/marketplace/decks?q=...&sort=... → Browse-Filter wirkungslos - /api/v1/decks?forked_from_marketplace=true → Inbox-Filter wirkungslos memoro-native nicht betroffen weil dort keine Query-Endpoints. Fix in ManaCore v1.0.1 (Commit 74aee8d): String-basierte URL- Konstruktion. CardsAPI bleibt unverändert. Build 5 → 6, in Simulator verifiziert mit live-API-Auth-Roundtrip. Co-Authored-By: Claude Opus 4.7 (1M context) --- project.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/project.yml b/project.yml index 4f497cc..57253ca 100644 --- a/project.yml +++ b/project.yml @@ -55,7 +55,7 @@ targets: path: Sources/Resources/Info.plist properties: CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "5" + CFBundleVersion: "6" CFBundleDevelopmentRegion: de CFBundleDisplayName: Cardecky LSApplicationCategoryType: "public.app-category.education" @@ -111,7 +111,7 @@ targets: properties: CFBundleDisplayName: Als Karte speichern CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "5" + CFBundleVersion: "6" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController @@ -144,7 +144,7 @@ targets: properties: CFBundleDisplayName: Cardecky Widget CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "5" + CFBundleVersion: "6" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension entitlements: From aa94601409acaddc9f111dc0528d1daefee992e9 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 17:28:11 +0200 Subject: [PATCH 09/21] =?UTF-8?q?feat(ui):=20Cardecky-Web-Design=20?= =?UTF-8?q?=E2=80=94=20Fan-Stack-Tiles=20+=20CardSurface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI-Refactor angelehnt an cards/apps/web. Drei Killer-Patterns übernommen: 1. CardSurface (Sources/Core/Theme/CardSurface.swift) - Drei Sizes md/lg/hero mit identischem Border-Radius 14pt, 1pt Border, layered Shadows je nach Elevation - Aspect-Ratio 5:7 für md/hero, 12:16.8 für lg - Optional Color-Accent-Stripe links (6pt, deck.color) 2. DeckStackTile (Sources/Features/Decks/DeckStackTile.swift) - Spielkarten-Stack-Visual: 3 gestaffelt-rotierte Hintergrund-Layer hinter der CardSurface - Layer-Offsets + Tilts deterministisch aus Deck-ID gehasht (gleiches Deck = gleiche Asymmetrie) - Inhalt: Category-Icon oben rechts, Titel + Description zentriert, Counts unten als Pill für dueCount 3. RatingBar mit Good-Emphasis (Features/Study/RatingBar.swift) - "Good" als full primary background (hero action) - again/hard/easy mit subtle border-tint + opacity-08-Background - Keyboard-Shortcut im Button-Label als kbd-Style-Pill DeckListView komplett umgebaut: - Horizontale ScrollView mit scrollTransition + viewAligned-Snap - Zwei Sektionen: "Eigene Decks" und "Abonniert" - Inbox-Banner als highlight (primary opacity 0.08 mit border) - Pending-Share-Banner mit warning-Tint - Section-Headers mit Icon + Title + Count StudySessionView.cardSurface nutzt jetzt CardSurface(.hero, .raised). Build 6 → 7. Drei native Targets bauen, 35 Tests grün. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Core/Theme/CardSurface.swift | 108 ++++++++ Sources/Features/Decks/DeckListView.swift | 230 ++++++++---------- Sources/Features/Decks/DeckStackTile.swift | 131 ++++++++++ Sources/Features/Study/RatingBar.swift | 60 +++-- Sources/Features/Study/StudySessionView.swift | 23 +- project.yml | 6 +- 6 files changed, 396 insertions(+), 162 deletions(-) create mode 100644 Sources/Core/Theme/CardSurface.swift create mode 100644 Sources/Features/Decks/DeckStackTile.swift diff --git a/Sources/Core/Theme/CardSurface.swift b/Sources/Core/Theme/CardSurface.swift new file mode 100644 index 0000000..f19f3e3 --- /dev/null +++ b/Sources/Core/Theme/CardSurface.swift @@ -0,0 +1,108 @@ +import SwiftUI + +/// Wiederverwendbare Karten-Hülle in drei Größen — entspricht den +/// Web-`CardSurface.svelte`-Varianten. Konsistenter Border-Radius (14pt), +/// gleicher Border-Stil, gleiche Shadow-Behandlung über alle Größen, +/// optional ein linker Color-Accent-Streifen. +/// +/// Spec aus `cards/apps/web/src/lib/components/CardSurface.svelte`: +/// - Alle Größen Border-Radius 0.875rem (14pt) +/// - Border 1px hsl(--color-border) +/// - Background hsl(--color-surface) +/// - Aspect-Ratio 5/7 für `.md` und `.hero`, fix für `.lg` +struct CardSurface: View { + enum Size: Sendable { + case md // Deck-Tile in der Liste (max-width 18rem) + case lg // Fan-Detail (12rem x 16.8rem) + case hero // Study-Lernkarte (max-width 24rem) + } + + enum Elevation: Sendable { + case flat // Subtle shadow + case standard // Default Karten-Shadow + case raised // Study-Hero + } + + let size: Size + let elevation: Elevation + let colorAccentHex: String? + let content: () -> Content + + init( + size: Size = .md, + elevation: Elevation = .standard, + colorAccentHex: String? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.size = size + self.elevation = elevation + self.colorAccentHex = colorAccentHex + self.content = content + } + + var body: some View { + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(CardsTheme.surface) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(CardsTheme.border, lineWidth: 1) + ) + + if let colorAccentHex { + Color.swatchFromHex(colorAccentHex) + .frame(width: 6) + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 14, + bottomLeadingRadius: 14, + bottomTrailingRadius: 0, + topTrailingRadius: 0, + style: .continuous + ) + ) + } + + content() + .padding(EdgeInsets(top: 16, leading: 22, bottom: 18, trailing: 16)) + } + .frame(maxWidth: maxWidth) + .aspectRatio(aspectRatio, contentMode: .fit) + .shadow(color: shadowColor, radius: shadowRadius, x: 0, y: shadowY) + } + + private var maxWidth: CGFloat? { + switch size { + case .md: 288 // 18rem + case .lg: 192 // 12rem + case .hero: 384 // 24rem + } + } + + private var aspectRatio: CGFloat? { + switch size { + case .md, .hero: 5.0 / 7.0 + case .lg: 12.0 / 16.8 + } + } + + private var shadowColor: Color { + CardsTheme.foreground.opacity(elevation == .raised ? 0.18 : 0.08) + } + + private var shadowRadius: CGFloat { + switch elevation { + case .flat: 3 + case .standard: 8 + case .raised: 18 + } + } + + private var shadowY: CGFloat { + switch elevation { + case .flat: 1 + case .standard: 4 + case .raised: 12 + } + } +} diff --git a/Sources/Features/Decks/DeckListView.swift b/Sources/Features/Decks/DeckListView.swift index fff8437..8d8fb42 100644 --- a/Sources/Features/Decks/DeckListView.swift +++ b/Sources/Features/Decks/DeckListView.swift @@ -2,8 +2,9 @@ import ManaCore import SwiftData import SwiftUI -/// β-1 Hauptbildschirm: Liste aller Decks mit Card- und Due-Counts. -/// Web-Vorbild: `cards/apps/web/src/routes/decks/+page.svelte`. +/// Decks-Hauptbildschirm im Cardecky-Look: horizontale Scroll-Reihen +/// mit Fan-Stack-Karten-Tiles. Web-Vorbild: +/// `cards/apps/web/src/routes/decks/+page.svelte`. struct DeckListView: View { @Environment(AuthClient.self) private var auth @Environment(\.modelContext) private var context @@ -69,25 +70,107 @@ struct DeckListView: View { if decks.isEmpty { emptyState } else { - List { - pendingShareSection - inboxBannerSection - ownDecksSection + ScrollView { + VStack(alignment: .leading, spacing: 24) { + pendingShareSection + inboxBanner + deckSection(title: "Eigene Decks", icon: "rectangle.stack", decks: ownDecks) + if !subscribedDecks.isEmpty { + deckSection(title: "Abonniert", icon: "globe", decks: subscribedDecks) + } + } + .padding(.vertical, 12) } - .listStyle(.plain) - .scrollContentBackground(.hidden) + } + } + + private var ownDecks: [CachedDeck] { + decks.filter { !$0.isFromMarketplace } + } + + private var subscribedDecks: [CachedDeck] { + decks.filter { $0.isFromMarketplace } + } + + @ViewBuilder + private func deckSection(title: String, icon: String, decks: [CachedDeck]) -> some View { + if !decks.isEmpty { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Image(systemName: icon) + .foregroundStyle(CardsTheme.primary) + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(CardsTheme.foreground) + Text("\(decks.count)") + .font(.subheadline) + .foregroundStyle(CardsTheme.mutedForeground) + } + .padding(.horizontal, 20) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 16) { + ForEach(decks) { deck in + NavigationLink(value: deck.id) { + DeckStackTile(deck: deck) + .frame(width: 240) + } + .buttonStyle(.plain) + .scrollTransition(.animated) { content, phase in + content + .scaleEffect(phase.isIdentity ? 1 : 0.92) + .opacity(phase.isIdentity ? 1 : 0.7) + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 12) + .scrollTargetLayout() + } + .scrollTargetBehavior(.viewAligned) + } + } + } + + @ViewBuilder + private var inboxBanner: some View { + if let inbox = decks.first(where: { $0.isFromMarketplace && $0.dueCount > 0 }) { + HStack(spacing: 12) { + Image(systemName: "tray.full.fill") + .font(.title3) + .foregroundStyle(CardsTheme.primary) + VStack(alignment: .leading, spacing: 2) { + Text("Inbox") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(CardsTheme.foreground) + Text("\(inbox.dueCount) fällige Karten aus abonnierten Decks") + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + } + Spacer() + Image(systemName: "chevron.right") + .font(.footnote) + .foregroundStyle(CardsTheme.mutedForeground) + } + .padding(14) + .background(CardsTheme.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(CardsTheme.primary.opacity(0.18), lineWidth: 1) + ) + .padding(.horizontal, 20) } } @ViewBuilder private var pendingShareSection: some View { if !pendingShares.isEmpty { - Section { + VStack(alignment: .leading, spacing: 8) { ForEach(pendingShares) { share in NavigationLink(value: PendingShareRoute(share: share)) { HStack(spacing: 12) { Image(systemName: "square.and.arrow.down") - .foregroundStyle(CardsTheme.primary) + .foregroundStyle(CardsTheme.warning) VStack(alignment: .leading, spacing: 2) { Text("Aus Teilen-Menü") .font(.subheadline.weight(.semibold)) @@ -98,16 +181,17 @@ struct DeckListView: View { .lineLimit(2) } Spacer() + Image(systemName: "chevron.right") + .font(.footnote) + .foregroundStyle(CardsTheme.mutedForeground) } - .padding() - .background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + .padding(14) + .background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } .buttonStyle(.plain) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } } + .padding(.horizontal, 20) } } @@ -131,7 +215,7 @@ struct DeckListView: View { Label("Noch keine Decks", systemImage: "rectangle.stack") .foregroundStyle(CardsTheme.foreground) } description: { - Text("Erstelle dein erstes Deck auf cardecky.mana.how oder ziehe nach unten zum Aktualisieren.") + Text("Tippe oben auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab.") .foregroundStyle(CardsTheme.mutedForeground) } } @@ -139,45 +223,6 @@ struct DeckListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - @ViewBuilder - private var inboxBannerSection: some View { - if let inbox = decks.first(where: { $0.isFromMarketplace && $0.dueCount > 0 }) { - Section { - HStack(spacing: 12) { - Image(systemName: "tray.full.fill") - .foregroundStyle(CardsTheme.primary) - VStack(alignment: .leading, spacing: 2) { - Text("Inbox") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(CardsTheme.foreground) - Text("\(inbox.dueCount) fällige Karten aus abonnierten Decks") - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - } - Spacer() - } - .padding() - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - } - } - - private var ownDecksSection: some View { - Section { - ForEach(decks) { deck in - NavigationLink(value: deck.id) { - DeckRow(deck: deck) - } - .buttonStyle(.plain) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) - } - } - } - @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarLeading) { @@ -205,78 +250,3 @@ struct DeckListView: View { return "person.crop.circle.badge.exclamationmark" } } - -/// Einzelne Deck-Zeile in der Liste. -struct DeckRow: View { - let deck: CachedDeck - - var body: some View { - HStack(spacing: 12) { - // Farbiger Streifen aus deck.color (Hex), default forest-primary - RoundedRectangle(cornerRadius: 3) - .fill(deckColor) - .frame(width: 4) - - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(deck.name) - .font(.headline) - .foregroundStyle(CardsTheme.foreground) - if deck.isFromMarketplace { - Image(systemName: "globe") - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - } - } - - if let category = deck.category { - Text(category.label) - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - } - - HStack(spacing: 12) { - Label("\(deck.cardCount)", systemImage: "rectangle.stack") - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - if deck.dueCount > 0 { - Label("\(deck.dueCount) fällig", systemImage: "clock.badge.exclamationmark") - .font(.caption.weight(.semibold)) - .foregroundStyle(CardsTheme.primary) - } - } - } - Spacer() - Image(systemName: "chevron.right") - .font(.footnote) - .foregroundStyle(CardsTheme.mutedForeground) - } - .padding(.vertical, 12) - .padding(.horizontal, 12) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) - } - - private var deckColor: Color { - guard let hex = deck.color, let rgb = parseHex(hex) else { - return CardsTheme.primary - } - return Color.manaHexLocal(rgb) - } - - private func parseHex(_ hex: String) -> UInt32? { - var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasPrefix("#") { trimmed = String(trimmed.dropFirst()) } - return UInt32(trimmed, radix: 16) - } -} - -private extension Color { - /// Lokales Hex-Helper analog zu `ManaTokens.Color.manaHex`. Hier - /// dupliziert, weil DeckRow nicht von ManaTokens abhängen muss. - static func manaHexLocal(_ rgb: UInt32) -> Color { - let r = Double((rgb >> 16) & 0xFF) / 255.0 - let g = Double((rgb >> 8) & 0xFF) / 255.0 - let b = Double(rgb & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) - } -} diff --git a/Sources/Features/Decks/DeckStackTile.swift b/Sources/Features/Decks/DeckStackTile.swift new file mode 100644 index 0000000..8b041d0 --- /dev/null +++ b/Sources/Features/Decks/DeckStackTile.swift @@ -0,0 +1,131 @@ +import SwiftUI + +/// Spiel-Karten-Stack-Visual mit drei gestaffelt-rotierten Hintergrund- +/// Layern hinter einer `CardSurface`. Web-Vorbild: +/// `cards/apps/web/src/lib/components/DeckStack.svelte`. +/// +/// Die Layer-Offsets + Tilts sind deterministisch aus der Deck-ID +/// gehasht — gleiches Deck zeigt immer gleiche Asymmetrie. +struct DeckStackTile: View { + let deck: CachedDeck + + var body: some View { + ZStack { + // Drei Hintergrund-Layer (von hinten nach vorne) + ForEach(Array(layers.enumerated()), id: \.offset) { _, layer in + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(CardsTheme.surface) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(CardsTheme.border, lineWidth: 1) + ) + .opacity(layer.opacity) + .rotationEffect(.degrees(layer.tilt)) + .offset(x: layer.dx, y: layer.dy) + .shadow(color: CardsTheme.foreground.opacity(0.05), radius: 2, y: 1) + } + + CardSurface(size: .md, elevation: .standard, colorAccentHex: deck.color) { + cardContent + } + } + .aspectRatio(5.0 / 7.0, contentMode: .fit) + .frame(maxWidth: 280) + } + + private var cardContent: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top) { + Spacer() + Image(systemName: deck.category?.systemImageName ?? "rectangle.stack") + .font(.title3) + .foregroundStyle(CardsTheme.mutedForeground.opacity(0.85)) + } + + Spacer(minLength: 0) + + VStack(alignment: .leading, spacing: 6) { + Text(deck.name) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(CardsTheme.foreground) + .lineLimit(3) + + if let description = deck.deckDescription, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + .lineLimit(2) + } + } + + Spacer(minLength: 0) + + HStack(spacing: 8) { + Label("\(deck.cardCount)", systemImage: "rectangle.stack") + .font(.caption2) + .foregroundStyle(CardsTheme.mutedForeground) + if deck.dueCount > 0 { + Text("\(deck.dueCount) fällig") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(CardsTheme.primary.opacity(0.15), in: Capsule()) + .foregroundStyle(CardsTheme.primary) + } + Spacer() + if deck.isFromMarketplace { + Image(systemName: "globe") + .font(.caption2) + .foregroundStyle(CardsTheme.mutedForeground) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + /// Deterministische Stack-Layer aus Deck-ID gehasht. + private var layers: [StackLayer] { + var hash = UInt64(0) + for byte in deck.id.utf8 { + hash = hash &* 31 &+ UInt64(byte) + } + return (0 ..< 3).map { index in + let seed = hash &+ UInt64(index) &* 17 + let tiltRaw = Double((seed >> 8) & 0xFF) / 255.0 - 0.5 + let xRaw = Double((seed >> 16) & 0xFF) / 255.0 - 0.5 + let yRaw = Double((seed >> 24) & 0xFF) / 255.0 - 0.5 + let depth = Double(index + 1) + return StackLayer( + tilt: tiltRaw * 4.0, + dx: xRaw * 6.0, + dy: depth * 3.0 + yRaw * 2.0, + opacity: 0.7 - depth * 0.18 + ) + } + } +} + +private struct StackLayer { + let tilt: Double + let dx: Double + let dy: Double + let opacity: Double +} + +private extension DeckCategory { + var systemImageName: String { + switch self { + case .language: "character.book.closed" + case .medicine: "cross.case" + case .science: "atom" + case .math: "function" + case .history: "scroll" + case .law: "scale.3d" + case .technology: "cpu" + case .arts: "paintbrush" + case .music: "music.note" + case .sport: "figure.run" + case .other: "rectangle.stack" + } + } +} diff --git a/Sources/Features/Study/RatingBar.swift b/Sources/Features/Study/RatingBar.swift index 51d8181..5c3d85b 100644 --- a/Sources/Features/Study/RatingBar.swift +++ b/Sources/Features/Study/RatingBar.swift @@ -4,8 +4,9 @@ import SwiftUI import UIKit #endif -/// Vier Rating-Buttons unten am Bildschirm. Tap → onRate(rating) -/// plus Haptic-Feedback. +/// Vier Rating-Buttons mit emphasis auf "Good" (full-width primary). +/// Web-Vorbild: `cards/apps/web/src/routes/study/[deckId]/+page.svelte` +/// — `.grade.again/.hard/.good/.easy`-Klassen. struct RatingBar: View { let onRate: (Rating) -> Void @@ -16,17 +17,24 @@ struct RatingBar: View { triggerHaptic(for: rating) onRate(rating) } label: { - VStack(spacing: 2) { + HStack(spacing: 6) { Text(rating.label) .font(.subheadline.weight(.semibold)) Text(rating.shortcut) - .font(.caption2) - .foregroundStyle(.secondary) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(kbdBackground(for: rating), in: RoundedRectangle(cornerRadius: 4)) + .foregroundStyle(kbdForeground(for: rating)) } .frame(maxWidth: .infinity) .padding(.vertical, 14) - .background(background(for: rating), in: RoundedRectangle(cornerRadius: 10)) + .background(background(for: rating), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .foregroundStyle(foreground(for: rating)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(borderColor(for: rating), lineWidth: rating == .good ? 0 : 1) + ) } .buttonStyle(.plain) } @@ -34,12 +42,14 @@ struct RatingBar: View { .padding(.horizontal, 16) } + /// `good` ist die Hero-Action (primary full background) — analog + /// zum Web-Default-Klick. Andere bekommen subtle tinted borders. private func background(for rating: Rating) -> Color { switch rating { - case .again: CardsTheme.error.opacity(0.12) - case .hard: CardsTheme.warning.opacity(0.12) - case .good: CardsTheme.primary.opacity(0.12) - case .easy: CardsTheme.success.opacity(0.12) + case .again: CardsTheme.error.opacity(0.06) + case .hard: CardsTheme.warning.opacity(0.06) + case .good: CardsTheme.primary + case .easy: CardsTheme.success.opacity(0.06) } } @@ -47,17 +57,37 @@ struct RatingBar: View { switch rating { case .again: CardsTheme.error case .hard: CardsTheme.warning - case .good: CardsTheme.primary + case .good: CardsTheme.primaryForeground case .easy: CardsTheme.success } } + private func borderColor(for rating: Rating) -> Color { + switch rating { + case .again: CardsTheme.error.opacity(0.4) + case .hard: CardsTheme.warning.opacity(0.4) + case .good: .clear + case .easy: CardsTheme.success.opacity(0.4) + } + } + + private func kbdBackground(for rating: Rating) -> Color { + rating == .good + ? CardsTheme.primaryForeground.opacity(0.18) + : CardsTheme.muted + } + + private func kbdForeground(for rating: Rating) -> Color { + rating == .good + ? CardsTheme.primaryForeground.opacity(0.85) + : CardsTheme.mutedForeground + } + private func triggerHaptic(for rating: Rating) { #if canImport(UIKit) - let generator = UIImpactFeedbackGenerator( - style: rating == .easy ? .heavy : .medium - ) - generator.impactOccurred() + let style: UIImpactFeedbackGenerator.FeedbackStyle = + rating == .easy ? .heavy : .medium + UIImpactFeedbackGenerator(style: style).impactOccurred() #endif } } diff --git a/Sources/Features/Study/StudySessionView.swift b/Sources/Features/Study/StudySessionView.swift index 43d801f..822134f 100644 --- a/Sources/Features/Study/StudySessionView.swift +++ b/Sources/Features/Study/StudySessionView.swift @@ -101,21 +101,16 @@ struct StudySessionView: View { } private func cardSurface(due: DueReview, isFlipped: Bool) -> some View { - RoundedRectangle(cornerRadius: 16) - .fill(CardsTheme.surface) - .overlay( - CardRenderer( - card: due.card, - subIndex: due.review.subIndex, - isFlipped: isFlipped - ) + CardSurface(size: .hero, elevation: .raised) { + CardRenderer( + card: due.card, + subIndex: due.review.subIndex, + isFlipped: isFlipped ) - .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(CardsTheme.border, lineWidth: 1) - ) - .padding(.horizontal, 16) - .padding(.top, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .padding(.horizontal, 16) + .padding(.top, 12) } private func finishedView(session: StudySession) -> some View { diff --git a/project.yml b/project.yml index 57253ca..971834e 100644 --- a/project.yml +++ b/project.yml @@ -55,7 +55,7 @@ targets: path: Sources/Resources/Info.plist properties: CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "6" + CFBundleVersion: "7" CFBundleDevelopmentRegion: de CFBundleDisplayName: Cardecky LSApplicationCategoryType: "public.app-category.education" @@ -111,7 +111,7 @@ targets: properties: CFBundleDisplayName: Als Karte speichern CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "6" + CFBundleVersion: "7" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController @@ -144,7 +144,7 @@ targets: properties: CFBundleDisplayName: Cardecky Widget CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "6" + CFBundleVersion: "7" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension entitlements: From 8b1dd5158f9563c8299e29b8f547c8dc6183a451 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 17:34:07 +0200 Subject: [PATCH 10/21] feat(study): Multiple-Choice-Karten gerendert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CardRenderer für multipleChoice ist nicht mehr Placeholder. Web- Vorbild: MultipleChoiceView.svelte. MultipleChoiceCardView (Features/Study/): - Lädt Distractors vom Server beim card.id-Wechsel (CardsAPI.distractors(deckId, cardId, field, count)) - Versucht erst field=answer, fallback field=back (für Decks mit basic/basic-reverse-Karten daneben) - Fallback auf distractor_pool-Feld (newline-separated) wenn Deck zu klein - 4 Optionen shuffled = [answer + 3 Distractors] - User-Tap markiert Auswahl (kein erneutes Pick möglich) - Vor Flip: nur Selected-Hint (primary border) - Nach Flip: richtige = green-check, falsche-gewählte = red-cross, unselected richtige bleibt green-highlight - Fallback "tooFew" (< 1 Distractor): zeigt Antwort nach Flip ohne Auswahl CardsAPI.distractors → DistractorsResponse {distractors: [String]}. Typing bleibt Placeholder — eigene UI-Pattern (Text-Input + Diff) brauchen mehr Design-Arbeit, separate Phase. Build 7 → 8, 35 Tests grün. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Core/API/CardsAPI.swift | 15 ++ Sources/Core/Domain/Deck.swift | 5 + Sources/Features/Study/CardRenderer.swift | 4 +- .../Study/MultipleChoiceCardView.swift | 199 ++++++++++++++++++ project.yml | 6 +- 5 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 Sources/Features/Study/MultipleChoiceCardView.swift diff --git a/Sources/Core/API/CardsAPI.swift b/Sources/Core/API/CardsAPI.swift index 764d54c..90ecba6 100644 --- a/Sources/Core/API/CardsAPI.swift +++ b/Sources/Core/API/CardsAPI.swift @@ -52,6 +52,21 @@ actor CardsAPI { return try decoder.decode(CardListResponse.self, from: data).cards } + /// `GET /api/v1/decks/:deckId/distractors` — N zufällige Feldwerte + /// aus anderen Karten desselben Decks. Server-Schema erlaubt nur + /// `front`, `back`, `answer`, `question` als field. + func distractors( + deckId: String, + cardId: String, + field: String = "answer", + count: Int = 3 + ) async throws -> [String] { + let path = "/api/v1/decks/\(deckId)/distractors?card_id=\(cardId)&field=\(field)&count=\(count)" + let (data, http) = try await transport.request(path: path) + try ensureOK(http, data: data) + return try decoder.decode(DistractorsResponse.self, from: data).distractors + } + /// `GET /api/v1/reviews/due?deck_id=...&limit=500` — Anzahl fälliger /// Reviews in einem Deck. func dueCount(deckId: String) async throws -> Int { diff --git a/Sources/Core/Domain/Deck.swift b/Sources/Core/Domain/Deck.swift index cb3d007..6e7dd34 100644 --- a/Sources/Core/Domain/Deck.swift +++ b/Sources/Core/Domain/Deck.swift @@ -129,3 +129,8 @@ struct CardListResponse: Decodable, Sendable { struct DueReviewsResponse: Decodable, Sendable { let total: Int } + +/// Server-Response von `GET /api/v1/decks/:deckId/distractors`. +struct DistractorsResponse: Decodable, Sendable { + let distractors: [String] +} diff --git a/Sources/Features/Study/CardRenderer.swift b/Sources/Features/Study/CardRenderer.swift index ebf3908..e3c5d14 100644 --- a/Sources/Features/Study/CardRenderer.swift +++ b/Sources/Features/Study/CardRenderer.swift @@ -28,7 +28,9 @@ struct CardRenderer: View { imageOcclusionView case .audioFront: audioFrontView - case .typing, .multipleChoice: + case .multipleChoice: + MultipleChoiceCardView(card: card, isFlipped: isFlipped) + case .typing: placeholderView } } diff --git a/Sources/Features/Study/MultipleChoiceCardView.swift b/Sources/Features/Study/MultipleChoiceCardView.swift new file mode 100644 index 0000000..051b4f6 --- /dev/null +++ b/Sources/Features/Study/MultipleChoiceCardView.swift @@ -0,0 +1,199 @@ +import ManaCore +import SwiftUI + +/// Multiple-Choice-Karten-View: zeigt 4 shuffled Optionen (1 richtige +/// + 3 Distractors vom Server). User-Tap markiert Wahl, beim Flip +/// werden richtige Antwort + Wahl hervorgehoben. +/// +/// Web-Vorbild: `cards/apps/web/src/lib/components/MultipleChoiceView.svelte`. +struct MultipleChoiceCardView: View { + let card: ReviewCard + let isFlipped: Bool + + @Environment(AuthClient.self) private var auth + @State private var options: [String] = [] + @State private var selected: String? + @State private var phase: LoadPhase = .loading + + enum LoadPhase: Sendable { + case loading + case ready + case tooFew // < 1 Distractor → manueller Modus + case failed + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + text(card.fields["front"] ?? "") + .font(.title3) + .foregroundStyle(CardsTheme.foreground) + + switch phase { + case .loading: + ProgressView() + .tint(CardsTheme.primary) + .frame(maxWidth: .infinity) + .padding(.top, 12) + + case .ready: + ForEach(options, id: \.self) { option in + optionRow(option) + } + + case .tooFew: + if isFlipped { + answerOnlyView + } else { + Text("Nicht genug andere Karten im Deck für Multiple-Choice — tippe auf »Antwort anzeigen«.") + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + } + + case .failed: + Text("Distractors konnten nicht geladen werden.") + .font(.caption) + .foregroundStyle(CardsTheme.error) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .task(id: card.id) { + await loadOptions() + } + } + + /// Option-Row mit dynamischem Highlight: vor Flip nur Selected-Hint, + /// nach Flip wird richtige Antwort grün, falsche-aber-gewählte rot. + @ViewBuilder + private func optionRow(_ option: String) -> some View { + let isCorrect = option == card.fields["answer"] + let isSelected = option == selected + + Button { + if selected == nil { selected = option } + } label: { + HStack(alignment: .top, spacing: 12) { + statusIcon(isCorrect: isCorrect, isSelected: isSelected) + .frame(width: 22) + Text(option) + .font(.subheadline) + .foregroundStyle(CardsTheme.foreground) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + } + .padding(.vertical, 12) + .padding(.horizontal, 14) + .background(background(isCorrect: isCorrect, isSelected: isSelected), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(border(isCorrect: isCorrect, isSelected: isSelected), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(isFlipped || selected != nil) + } + + @ViewBuilder + private func statusIcon(isCorrect: Bool, isSelected: Bool) -> some View { + if isFlipped { + if isCorrect { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(CardsTheme.success) + } else if isSelected { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(CardsTheme.error) + } else { + Image(systemName: "circle") + .foregroundStyle(CardsTheme.mutedForeground.opacity(0.4)) + } + } else if isSelected { + Image(systemName: "largecircle.fill.circle") + .foregroundStyle(CardsTheme.primary) + } else { + Image(systemName: "circle") + .foregroundStyle(CardsTheme.mutedForeground.opacity(0.4)) + } + } + + private func background(isCorrect: Bool, isSelected: Bool) -> Color { + if isFlipped { + if isCorrect { return CardsTheme.success.opacity(0.12) } + if isSelected { return CardsTheme.error.opacity(0.10) } + return CardsTheme.surfaceHover + } + return isSelected + ? CardsTheme.primary.opacity(0.10) + : CardsTheme.surface + } + + private func border(isCorrect: Bool, isSelected: Bool) -> Color { + if isFlipped { + if isCorrect { return CardsTheme.success.opacity(0.55) } + if isSelected { return CardsTheme.error.opacity(0.55) } + return CardsTheme.border + } + return isSelected ? CardsTheme.primary.opacity(0.5) : CardsTheme.border + } + + /// Fallback wenn nicht genug Distractors: zeigt die Antwort + /// direkt nach Flip, ohne Auswahl-Spiel. + private var answerOnlyView: some View { + VStack(alignment: .leading, spacing: 6) { + Divider().background(CardsTheme.border) + Text(card.fields["answer"] ?? "") + .font(.title3) + .foregroundStyle(CardsTheme.primary) + .padding(.top, 4) + } + } + + private func text(_ markdown: String) -> some View { + let attributed = (try? AttributedString( + markdown: markdown, + options: AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + )) ?? AttributedString(markdown) + return Text(attributed) + .multilineTextAlignment(.leading) + } + + private func loadOptions() async { + phase = .loading + selected = nil + let api = CardsAPI(auth: auth) + let answer = card.fields["answer"] ?? "" + + var distractors: [String] = [] + // Erst answer-Feld versuchen, dann back-Feld als Fallback + // (Decks mit basic/basic-reverse-Karten daneben). + for field in ["answer", "back"] { + if distractors.count >= 3 { break } + if let result = try? await api.distractors( + deckId: card.deckId, + cardId: card.id, + field: field, + count: 3 + ) { + let filtered = result.filter { $0 != answer && !distractors.contains($0) } + distractors.append(contentsOf: filtered) + } + } + + // Fallback aus statischem distractor_pool-Field (Web-Pattern) + if distractors.count < 3, let pool = card.fields["distractor_pool"] { + let poolItems = pool + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty && $0 != answer && !distractors.contains($0) } + distractors.append(contentsOf: poolItems) + } + + if distractors.isEmpty { + phase = .tooFew + return + } + let finalDistractors = Array(distractors.prefix(3)) + options = ([answer] + finalDistractors).shuffled() + phase = .ready + } +} diff --git a/project.yml b/project.yml index 971834e..6e20f26 100644 --- a/project.yml +++ b/project.yml @@ -55,7 +55,7 @@ targets: path: Sources/Resources/Info.plist properties: CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "7" + CFBundleVersion: "8" CFBundleDevelopmentRegion: de CFBundleDisplayName: Cardecky LSApplicationCategoryType: "public.app-category.education" @@ -111,7 +111,7 @@ targets: properties: CFBundleDisplayName: Als Karte speichern CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "7" + CFBundleVersion: "8" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController @@ -144,7 +144,7 @@ targets: properties: CFBundleDisplayName: Cardecky Widget CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "7" + CFBundleVersion: "8" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension entitlements: From 505aa9db19669be08b5840e0e3b22d914e602521 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 17:39:39 +0200 Subject: [PATCH 11/21] feat(study): Typing-Karten + Levenshtein-Match-Logik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CardRenderer für typing ist nicht mehr Placeholder. Web-Vorbild: TypingView.svelte + cards-domain/typing.ts. Typing.swift (Sources/Core/Domain/): - check(input:answer:aliases:) → TypingMatch (correct/close/wrong) - Normalisierung: trim + lowercase + NFD-Decomp + Combining-Marks strippen (Diakritika: ä → a) - Aliases-Support (Komma-getrennt aus card.fields["aliases"]) - Levenshtein-Threshold max(1, floor(len * 0.2)) → "close" TypingCardView (Features/Study/): - TextField mit Auto-Focus 0.15s nach onAppear, Return = Submit - Submit-Button mit Return-Symbol + primary background - Nach Submit: Badge (✓ Richtig / ≈ Fast / ✗ Falsch) + User- Eingabe in „…" Quotes + Divider + erwartete Antwort - Haptic-Feedback: heavy bei correct, light bei close/wrong - Reset on card.id change TypingTests: 8 Tests für check() — exact, case+whitespace, NFD-Umlauts, aliases, Levenshtein-close (Berln → Berlin), empty-input, sowie Levenshtein-Helper-Sanity. Build 8 → 9. 43 Tests grün (war 35). Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Core/Domain/Typing.swift | 77 ++++++++ Sources/Features/Study/CardRenderer.swift | 2 +- Sources/Features/Study/TypingCardView.swift | 187 ++++++++++++++++++++ Tests/UnitTests/TypingTests.swift | 55 ++++++ project.yml | 6 +- 5 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 Sources/Core/Domain/Typing.swift create mode 100644 Sources/Features/Study/TypingCardView.swift create mode 100644 Tests/UnitTests/TypingTests.swift diff --git a/Sources/Core/Domain/Typing.swift b/Sources/Core/Domain/Typing.swift new file mode 100644 index 0000000..25aa7b4 --- /dev/null +++ b/Sources/Core/Domain/Typing.swift @@ -0,0 +1,77 @@ +import Foundation + +/// Vergleich einer getippten User-Antwort gegen die erwartete Antwort. +/// 1:1-Port aus `cards/packages/cards-domain/src/typing.ts`: +/// Normalisierung (lowercase, trim, NFD-Diakritika-Stripping), +/// dann exact-match → `correct`. Sonst Levenshtein-Distanz mit +/// Threshold `max(1, floor(answer.length * 0.2))` → `close`. +enum TypingMatch: Sendable, Equatable { + case correct + case close + case wrong +} + +enum Typing { + /// `aliases` ist ein Komma-getrennter String aus dem `aliases`-Feld + /// der Karte (optional). Jeder Alias zählt als gültige Antwort. + static func check(input: String, answer: String, aliases: String? = nil) -> TypingMatch { + let normInput = normalize(input) + guard !normInput.isEmpty else { return .wrong } + + var candidates = [answer] + if let aliases { + candidates.append(contentsOf: aliases.split(separator: ",").map(String.init)) + } + let normalizedCandidates = candidates + .map(normalize) + .filter { !$0.isEmpty } + guard !normalizedCandidates.isEmpty else { return .wrong } + + if normalizedCandidates.contains(normInput) { + return .correct + } + + let shortestLen = normalizedCandidates.map(\.count).min() ?? normInput.count + let threshold = max(1, Int(Double(shortestLen) * 0.2)) + for candidate in normalizedCandidates where levenshtein(normInput, candidate) <= threshold { + return .close + } + return .wrong + } + + private static func normalize(_ string: String) -> String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + let lowered = trimmed.lowercased() + // NFD-Dekomposition + Combining-Marks entfernen (z.B. ä → a) + let decomposed = lowered.decomposedStringWithCanonicalMapping + let stripped = decomposed.unicodeScalars.filter { scalar in + !(0x0300 ... 0x036F).contains(scalar.value) + } + return String(String.UnicodeScalarView(stripped)) + } + + static func levenshtein(_ a: String, _ b: String) -> Int { + let aChars = Array(a) + let bChars = Array(b) + let m = aChars.count + let n = bChars.count + if m == 0 { return n } + if n == 0 { return m } + + var row = Array(0 ... n) + for i in 1 ... m { + var prev = row[0] + row[0] = i + for j in 1 ... n { + let tmp = row[j] + if aChars[i - 1] == bChars[j - 1] { + row[j] = prev + } else { + row[j] = 1 + Swift.min(prev, row[j], row[j - 1]) + } + prev = tmp + } + } + return row[n] + } +} diff --git a/Sources/Features/Study/CardRenderer.swift b/Sources/Features/Study/CardRenderer.swift index e3c5d14..192a294 100644 --- a/Sources/Features/Study/CardRenderer.swift +++ b/Sources/Features/Study/CardRenderer.swift @@ -31,7 +31,7 @@ struct CardRenderer: View { case .multipleChoice: MultipleChoiceCardView(card: card, isFlipped: isFlipped) case .typing: - placeholderView + TypingCardView(card: card, isFlipped: isFlipped) } } .padding(24) diff --git a/Sources/Features/Study/TypingCardView.swift b/Sources/Features/Study/TypingCardView.swift new file mode 100644 index 0000000..8e5b75e --- /dev/null +++ b/Sources/Features/Study/TypingCardView.swift @@ -0,0 +1,187 @@ +import SwiftUI + +/// Typing-Karten-View: User tippt Antwort, drückt Submit → Match-Badge +/// (correct/close/wrong) + User-Eingabe + erwartete Antwort. +/// +/// Web-Vorbild: `cards/apps/web/src/lib/components/TypingView.svelte`. +/// Match-Logik in `Typing.check(input:answer:aliases:)` portiert aus +/// `cards/packages/cards-domain/src/typing.ts`. +/// +/// Beim Flip vom Parent setzt sich `submitted` synthetisch — falls +/// User nicht selbst tippt und nur "Antwort anzeigen" benutzt, kommen +/// trotzdem korrekte Antwort und manuelle Bewertung über die RatingBar. +struct TypingCardView: View { + let card: ReviewCard + let isFlipped: Bool + + @State private var input: String = "" + @State private var submitted: Bool = false + @State private var result: TypingMatch? + @FocusState private var inputFocused: Bool + + private var answer: String { card.fields["answer"] ?? "" } + private var aliases: String? { card.fields["aliases"] } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + text(card.fields["front"] ?? "") + .font(.title3) + .foregroundStyle(CardsTheme.foreground) + + if submitted || isFlipped { + resultView + } else { + inputRow + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: isFlipped) { _, flipped in + // Falls der User über die generische RatingBar auf "Antwort + // anzeigen" tippt ohne zu raten, springen wir trotzdem in + // den Result-Modus mit "wrong" als Default-Match (nicht + // bewertet). + if flipped, !submitted { + result = nil + submitted = true + } + } + .onChange(of: card.id) { _, _ in + input = "" + submitted = false + result = nil + } + } + + // MARK: - Input + + private var inputRow: some View { + HStack(spacing: 8) { + TextField("Antwort eingeben …", text: $input) + .textFieldStyle(.plain) + .focused($inputFocused) + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(inputFocused ? CardsTheme.primary : CardsTheme.border, lineWidth: 1) + ) + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + .onSubmit { submit() } + + Button { + submit() + } label: { + Image(systemName: "return") + .font(.title3) + .frame(width: 44, height: 44) + .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .foregroundStyle(CardsTheme.primaryForeground) + } + .buttonStyle(.plain) + .disabled(input.trimmingCharacters(in: .whitespaces).isEmpty) + } + .onAppear { + // SwiftUI's Focus-State braucht einen Tick nach onAppear + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + inputFocused = true + } + } + } + + // MARK: - Result + + @ViewBuilder + private var resultView: some View { + if let result { + HStack(spacing: 8) { + Text(badgeLabel(for: result)) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(badgeBackground(for: result), in: Capsule()) + .foregroundStyle(badgeForeground(for: result)) + if !input.isEmpty { + Text("„\(input)“") + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + .lineLimit(1) + } + Spacer(minLength: 0) + } + } + + Divider().background(CardsTheme.border) + + Text(answer) + .font(.title3.weight(.medium)) + .foregroundStyle(CardsTheme.foreground) + + if result == nil, !submitted { + // unwahrscheinlich erreicht, aber als Sicherheits-Branch + EmptyView() + } + } + + // MARK: - Logic + + private func submit() { + guard !submitted else { return } + let trimmed = input.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + inputFocused = false + result = Typing.check(input: trimmed, answer: answer, aliases: aliases) + submitted = true + triggerHaptic() + } + + private func triggerHaptic() { + #if canImport(UIKit) + let style: UIImpactFeedbackGenerator.FeedbackStyle = + result == .correct ? .heavy : .light + UIImpactFeedbackGenerator(style: style).impactOccurred() + #endif + } + + private func badgeLabel(for result: TypingMatch) -> String { + switch result { + case .correct: "✓ Richtig" + case .close: "≈ Fast" + case .wrong: "✗ Falsch" + } + } + + private func badgeBackground(for result: TypingMatch) -> Color { + switch result { + case .correct: CardsTheme.success.opacity(0.18) + case .close: CardsTheme.warning.opacity(0.18) + case .wrong: CardsTheme.error.opacity(0.18) + } + } + + private func badgeForeground(for result: TypingMatch) -> Color { + switch result { + case .correct: CardsTheme.success + case .close: CardsTheme.warning + case .wrong: CardsTheme.error + } + } + + private func text(_ markdown: String) -> some View { + let attributed = (try? AttributedString( + markdown: markdown, + options: AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + )) ?? AttributedString(markdown) + return Text(attributed) + .multilineTextAlignment(.leading) + } +} + +#if canImport(UIKit) +import UIKit +#endif diff --git a/Tests/UnitTests/TypingTests.swift b/Tests/UnitTests/TypingTests.swift new file mode 100644 index 0000000..86f0265 --- /dev/null +++ b/Tests/UnitTests/TypingTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import CardsNative + +@Suite("Typing-Match") +struct TypingTests { + @Test("Exact match → correct") + func exactMatch() { + #expect(Typing.check(input: "Berlin", answer: "Berlin") == .correct) + } + + @Test("Case + Whitespace normalisiert") + func caseAndWhitespace() { + #expect(Typing.check(input: " berlin ", answer: "Berlin") == .correct) + #expect(Typing.check(input: "BERLIN", answer: "berlin") == .correct) + } + + @Test("Umlaute via NFD-Diakritika-Stripping") + func umlauts() { + #expect(Typing.check(input: "Munchen", answer: "München") == .correct) + #expect(Typing.check(input: "muenchen", answer: "München") != .correct) + // muenchen != munchen via NFD: ä → a, aber ue ≠ ü + } + + @Test("Aliases akzeptiert als correct") + func aliasesCorrect() { + let aliases = "Frankfurt am Main,Frankfurt/Main" + #expect(Typing.check(input: "Frankfurt/Main", answer: "Frankfurt", aliases: aliases) == .correct) + } + + @Test("Levenshtein-1 bei 5+ Zeichen → close") + func closeMatch() { + #expect(Typing.check(input: "Berln", answer: "Berlin") == .close) + #expect(Typing.check(input: "Berlim", answer: "Berlin") == .close) + } + + @Test("Großer Unterschied → wrong") + func wrongMatch() { + #expect(Typing.check(input: "Madrid", answer: "Berlin") == .wrong) + } + + @Test("Leer-Input → wrong") + func emptyInput() { + #expect(Typing.check(input: "", answer: "Berlin") == .wrong) + #expect(Typing.check(input: " ", answer: "Berlin") == .wrong) + } + + @Test("Levenshtein-Helper") + func levenshteinSanity() { + #expect(Typing.levenshtein("", "") == 0) + #expect(Typing.levenshtein("abc", "abc") == 0) + #expect(Typing.levenshtein("abc", "abd") == 1) + #expect(Typing.levenshtein("kitten", "sitting") == 3) + } +} diff --git a/project.yml b/project.yml index 6e20f26..552841b 100644 --- a/project.yml +++ b/project.yml @@ -55,7 +55,7 @@ targets: path: Sources/Resources/Info.plist properties: CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "8" + CFBundleVersion: "9" CFBundleDevelopmentRegion: de CFBundleDisplayName: Cardecky LSApplicationCategoryType: "public.app-category.education" @@ -111,7 +111,7 @@ targets: properties: CFBundleDisplayName: Als Karte speichern CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "8" + CFBundleVersion: "9" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController @@ -144,7 +144,7 @@ targets: properties: CFBundleDisplayName: Cardecky Widget CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "8" + CFBundleVersion: "9" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension entitlements: From 33101d703d11dc6c8ff4f62aa2d7e1138a4dfa78 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 17:50:29 +0200 Subject: [PATCH 12/21] feat(auth): DEBUG-Auto-Login (Memoro-Pattern) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bei lokalen Xcode-Run-Builds wird beim Start automatisch eingeloggt wenn der Keychain leer ist. Spart das manuelle Login bei jedem Re-Install via Xcode. - Sources/Core/Auth/DebugCredentials.swift — #if DEBUG-gewrappte Founder-Credentials (tills95@gmail.com / Aa-123456789) - Sources/Core/Auth/AuthClient+EnsureSignedIn.swift — Extension ensureSignedIn() prüft .signedOut → signIn() in DEBUG - RootView.task ruft auth.ensureSignedIn() — Release-Builds No-Op (Release/TestFlight/App-Store bleiben unverändert, User muss manuell einloggen) Pattern 1:1 von memoro-native (gleiches File-Layout + Klassennamen). Build 9 → 10. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/App/RootView.swift | 23 +++++++++++-------- .../Core/Auth/AuthClient+EnsureSignedIn.swift | 18 +++++++++++++++ Sources/Core/Auth/DebugCredentials.swift | 17 ++++++++++++++ project.yml | 6 ++--- 4 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 Sources/Core/Auth/AuthClient+EnsureSignedIn.swift create mode 100644 Sources/Core/Auth/DebugCredentials.swift diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index 79ab44a..225085a 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -9,15 +9,20 @@ struct RootView: View { @State private var pendingDeepLinkSlug: String? var body: some View { - switch auth.status { - case .signedIn: - mainTabs - .onOpenURL { url in handle(url: url) } - .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in - if let url = activity.webpageURL { handle(url: url) } - } - case .unknown, .signedOut, .signingIn, .error: - LoginView() + Group { + switch auth.status { + case .signedIn: + mainTabs + .onOpenURL { url in handle(url: url) } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + if let url = activity.webpageURL { handle(url: url) } + } + case .unknown, .signedOut, .signingIn, .error: + LoginView() + } + } + .task { + await auth.ensureSignedIn() } } diff --git a/Sources/Core/Auth/AuthClient+EnsureSignedIn.swift b/Sources/Core/Auth/AuthClient+EnsureSignedIn.swift new file mode 100644 index 0000000..b927737 --- /dev/null +++ b/Sources/Core/Auth/AuthClient+EnsureSignedIn.swift @@ -0,0 +1,18 @@ +import ManaCore + +extension AuthClient { + /// Stellt sicher, dass der User eingeloggt ist. Nur in DEBUG-Builds + /// aktiv: wenn die Session nach `bootstrap()` `.signedOut` ist, + /// wird automatisch mit den DEBUG-Credentials angemeldet. In + /// Release-Builds No-Op — User muss manuell einloggen. + /// + /// Vorbild: memoro-native — wird in `RootView.task` aufgerufen. + func ensureSignedIn() async { + #if DEBUG + if case .signedOut = status { + Log.auth.notice("DEBUG auto-login: signing in with DebugCredentials") + await signIn(email: DebugCredentials.email, password: DebugCredentials.password) + } + #endif + } +} diff --git a/Sources/Core/Auth/DebugCredentials.swift b/Sources/Core/Auth/DebugCredentials.swift new file mode 100644 index 0000000..9234057 --- /dev/null +++ b/Sources/Core/Auth/DebugCredentials.swift @@ -0,0 +1,17 @@ +#if DEBUG + /// Dev-only Auto-Login-Credentials. + /// + /// **Nur in DEBUG-Builds aktiv** — der ganze File ist hinter + /// `#if DEBUG` gewrapped und wird in Release-Builds (TestFlight/ + /// App-Store) nicht kompiliert. Wird beim App-Start von + /// `AuthClient.ensureSignedIn()` verwendet, falls die Session-Token + /// im Keychain abgelaufen oder leer sind — damit Till während der + /// Entwicklung nicht jedes Mal manuell einloggen muss. + /// + /// Identisches Pattern wie `memoro-native/Sources/Core/Auth/ + /// DebugCredentials.swift`. + enum DebugCredentials { + static let email = "tills95@gmail.com" + static let password = "Aa-123456789" + } +#endif diff --git a/project.yml b/project.yml index 552841b..eb17b87 100644 --- a/project.yml +++ b/project.yml @@ -55,7 +55,7 @@ targets: path: Sources/Resources/Info.plist properties: CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "9" + CFBundleVersion: "10" CFBundleDevelopmentRegion: de CFBundleDisplayName: Cardecky LSApplicationCategoryType: "public.app-category.education" @@ -111,7 +111,7 @@ targets: properties: CFBundleDisplayName: Als Karte speichern CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "9" + CFBundleVersion: "10" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController @@ -144,7 +144,7 @@ targets: properties: CFBundleDisplayName: Cardecky Widget CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "9" + CFBundleVersion: "10" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension entitlements: From 90201d71990baa929de76131bf34d2d73f59a059 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 18:03:47 +0200 Subject: [PATCH 13/21] =?UTF-8?q?feat(decks):=20Tile-Redesign=20=E2=80=94?= =?UTF-8?q?=20Tap=3DStudy,=20Edit-Icon,=20Explore-Konsistenz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-Feedback umgesetzt: - Tap auf Deck-Tile öffnet jetzt direkt den Study-Mode für dieses Deck (statt Deck-Detail). DeckRoute-Enum mit .study/.detail-Cases + programmatic NavigationPath. - Edit-Icon (Pencil) unten rechts auf der Tile in Muted-Circle-Badge; Tap führt in den Deck-Detail-View (Browse Cards + Bearbeiten). - Kategorie-Icon oben rechts jetzt in primary-Farbe (war muted) + größer (.title2 statt .title3) — visuell prominenter. - Inbox-Banner ist jetzt als Button → Study-Mode mit dem ersten Inbox-Deck. ExploreView/PublicDeckCard: - Selbes Tile-Layout wie DeckStackTile (5:7 Aspect-Ratio, CardSurface, Kategorie-Icon oben rechts, Footer mit Counts + Owner). - Featured-Star-Badge oben links statt rechts (damit Kategorie-Icon konsistent rechts bleibt). - Star-Count als ausgefüllter Stern in warning-Farbe. - Owner-Name unter den Counts, mit verified-Seal wenn vorhanden. Build 10 → 11. 43 Tests grün. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Features/Decks/DeckListView.swift | 78 ++++++++----- Sources/Features/Decks/DeckStackTile.swift | 38 ++++++- .../Features/Marketplace/ExploreView.swift | 104 ++++++++++++------ project.yml | 6 +- 4 files changed, 153 insertions(+), 73 deletions(-) diff --git a/Sources/Features/Decks/DeckListView.swift b/Sources/Features/Decks/DeckListView.swift index 8d8fb42..f224839 100644 --- a/Sources/Features/Decks/DeckListView.swift +++ b/Sources/Features/Decks/DeckListView.swift @@ -2,6 +2,14 @@ import ManaCore import SwiftData import SwiftUI +/// Navigations-Routen für die DeckListView. Tap auf eine Tile geht +/// direkt in Study-Mode, Tap auf den Edit-Button in den Deck-Detail- +/// View für Browse + Edit. +enum DeckRoute: Hashable { + case study(deckId: String, deckName: String) + case detail(deckId: String) +} + /// Decks-Hauptbildschirm im Cardecky-Look: horizontale Scroll-Reihen /// mit Fan-Stack-Karten-Tiles. Web-Vorbild: /// `cards/apps/web/src/routes/decks/+page.svelte`. @@ -14,16 +22,22 @@ struct DeckListView: View { @State private var showAccount = false @State private var showCreate = false @State private var pendingShares: [PendingShare] = [] + @State private var path = NavigationPath() var body: some View { - NavigationStack { + NavigationStack(path: $path) { ZStack { CardsTheme.background.ignoresSafeArea() content } .navigationTitle("Decks") - .navigationDestination(for: String.self) { deckId in - DeckDetailView(deckId: deckId) + .navigationDestination(for: DeckRoute.self) { route in + switch route { + case let .study(deckId, deckName): + StudySessionView(deckId: deckId, deckName: deckName) + case let .detail(deckId): + DeckDetailView(deckId: deckId) + } } .navigationDestination(for: PendingShareRoute.self) { route in PendingShareConsumeView(share: route.share, onDone: { @@ -111,11 +125,12 @@ struct DeckListView: View { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 16) { ForEach(decks) { deck in - NavigationLink(value: deck.id) { - DeckStackTile(deck: deck) - .frame(width: 240) - } - .buttonStyle(.plain) + DeckStackTile( + deck: deck, + onTap: { path.append(DeckRoute.study(deckId: deck.id, deckName: deck.name)) }, + onEdit: { path.append(DeckRoute.detail(deckId: deck.id)) } + ) + .frame(width: 240) .scrollTransition(.animated) { content, phase in content .scaleEffect(phase.isIdentity ? 1 : 0.92) @@ -135,30 +150,35 @@ struct DeckListView: View { @ViewBuilder private var inboxBanner: some View { if let inbox = decks.first(where: { $0.isFromMarketplace && $0.dueCount > 0 }) { - HStack(spacing: 12) { - Image(systemName: "tray.full.fill") - .font(.title3) - .foregroundStyle(CardsTheme.primary) - VStack(alignment: .leading, spacing: 2) { - Text("Inbox") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(CardsTheme.foreground) - Text("\(inbox.dueCount) fällige Karten aus abonnierten Decks") - .font(.caption) + Button { + path.append(DeckRoute.study(deckId: inbox.id, deckName: inbox.name)) + } label: { + HStack(spacing: 12) { + Image(systemName: "tray.full.fill") + .font(.title3) + .foregroundStyle(CardsTheme.primary) + VStack(alignment: .leading, spacing: 2) { + Text("Inbox") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(CardsTheme.foreground) + Text("\(inbox.dueCount) fällige Karten aus abonnierten Decks") + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + } + Spacer() + Image(systemName: "chevron.right") + .font(.footnote) .foregroundStyle(CardsTheme.mutedForeground) } - Spacer() - Image(systemName: "chevron.right") - .font(.footnote) - .foregroundStyle(CardsTheme.mutedForeground) + .padding(14) + .background(CardsTheme.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(CardsTheme.primary.opacity(0.18), lineWidth: 1) + ) + .padding(.horizontal, 20) } - .padding(14) - .background(CardsTheme.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(CardsTheme.primary.opacity(0.18), lineWidth: 1) - ) - .padding(.horizontal, 20) + .buttonStyle(.plain) } } diff --git a/Sources/Features/Decks/DeckStackTile.swift b/Sources/Features/Decks/DeckStackTile.swift index 8b041d0..fd08b43 100644 --- a/Sources/Features/Decks/DeckStackTile.swift +++ b/Sources/Features/Decks/DeckStackTile.swift @@ -4,10 +4,14 @@ import SwiftUI /// Layern hinter einer `CardSurface`. Web-Vorbild: /// `cards/apps/web/src/lib/components/DeckStack.svelte`. /// -/// Die Layer-Offsets + Tilts sind deterministisch aus der Deck-ID -/// gehasht — gleiches Deck zeigt immer gleiche Asymmetrie. +/// Layout: Kategorie-Icon oben rechts (prominent in primary-Farbe), +/// Titel + Description zentriert, Counts + Edit-Button unten. +/// Tap auf die Tile triggert `onTap` (Study-Mode), Tap auf den +/// Edit-Button triggert `onEdit` (Deck-Detail). struct DeckStackTile: View { let deck: CachedDeck + let onTap: () -> Void + let onEdit: () -> Void var body: some View { ZStack { @@ -31,6 +35,8 @@ struct DeckStackTile: View { } .aspectRatio(5.0 / 7.0, contentMode: .fit) .frame(maxWidth: 280) + .contentShape(Rectangle()) + .onTapGesture { onTap() } } private var cardContent: some View { @@ -38,8 +44,8 @@ struct DeckStackTile: View { HStack(alignment: .top) { Spacer() Image(systemName: deck.category?.systemImageName ?? "rectangle.stack") - .font(.title3) - .foregroundStyle(CardsTheme.mutedForeground.opacity(0.85)) + .font(.title2) + .foregroundStyle(CardsTheme.primary.opacity(0.85)) } Spacer(minLength: 0) @@ -72,17 +78,37 @@ struct DeckStackTile: View { .background(CardsTheme.primary.opacity(0.15), in: Capsule()) .foregroundStyle(CardsTheme.primary) } - Spacer() if deck.isFromMarketplace { Image(systemName: "globe") .font(.caption2) .foregroundStyle(CardsTheme.mutedForeground) } + Spacer() + editButton } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + /// Edit-Button unten rechts. Eigener `Button` mit `.plain` style + /// fängt den Tap und triggert nicht das Outer-`onTapGesture`. + private var editButton: some View { + Button { + onEdit() + } label: { + Image(systemName: "pencil") + .font(.footnote.weight(.semibold)) + .foregroundStyle(CardsTheme.mutedForeground) + .frame(width: 30, height: 30) + .background(CardsTheme.muted.opacity(0.7), in: Circle()) + .overlay( + Circle().stroke(CardsTheme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Deck bearbeiten") + } + /// Deterministische Stack-Layer aus Deck-ID gehasht. private var layers: [StackLayer] { var hash = UInt64(0) @@ -112,7 +138,7 @@ private struct StackLayer { let opacity: Double } -private extension DeckCategory { +extension DeckCategory { var systemImageName: String { switch self { case .language: "character.book.closed" diff --git a/Sources/Features/Marketplace/ExploreView.swift b/Sources/Features/Marketplace/ExploreView.swift index 34a0c40..27dcef0 100644 --- a/Sources/Features/Marketplace/ExploreView.swift +++ b/Sources/Features/Marketplace/ExploreView.swift @@ -124,57 +124,91 @@ enum MarketplaceRoute: Hashable { } /// Public-Deck-Karten-Tile in Featured/Trending-Carousels und Browse-Grid. +/// Selbes Tile-Layout wie DeckStackTile (5:7 Aspect-Ratio, +/// CardSurface, Kategorie-Icon oben rechts), aber für PublicDeckEntry- +/// Daten. Star-Count statt Edit-Button unten rechts. struct PublicDeckCard: View { let entry: PublicDeckEntry var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(entry.title) - .font(.headline) - .foregroundStyle(CardsTheme.foreground) - .lineLimit(2) - Spacer() + ZStack { + CardSurface(size: .md, elevation: .standard, colorAccentHex: nil) { + cardContent + } + } + .aspectRatio(5.0 / 7.0, contentMode: .fit) + .frame(maxWidth: 280) + } + + private var cardContent: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top) { if entry.isFeatured { Image(systemName: "star.fill") .font(.caption) .foregroundStyle(CardsTheme.warning) } + Spacer() + Image(systemName: categorySymbol) + .font(.title2) + .foregroundStyle(CardsTheme.primary.opacity(0.85)) } - if let description = entry.description, !description.isEmpty { - Text(description) - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - .lineLimit(2) - } - HStack(spacing: 12) { - Label("\(entry.cardCount)", systemImage: "rectangle.stack") - Label("\(entry.starCount)", systemImage: "star") - if entry.isPaid { - Label("\(entry.priceCredits) Credits", systemImage: "creditcard") - .foregroundStyle(CardsTheme.primary) + + Spacer(minLength: 0) + + VStack(alignment: .leading, spacing: 6) { + Text(entry.title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(CardsTheme.foreground) + .lineLimit(3) + + if let description = entry.description, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + .lineLimit(2) } } - .font(.caption2) - .foregroundStyle(CardsTheme.mutedForeground) - HStack(spacing: 4) { - Text(entry.owner.displayName) - .font(.caption2) - .foregroundStyle(CardsTheme.mutedForeground) - if entry.owner.verifiedMana { - Image(systemName: "checkmark.seal.fill") + Spacer(minLength: 0) + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Label("\(entry.cardCount)", systemImage: "rectangle.stack") .font(.caption2) - .foregroundStyle(CardsTheme.primary) + .foregroundStyle(CardsTheme.mutedForeground) + Label("\(entry.starCount)", systemImage: "star.fill") + .font(.caption2) + .foregroundStyle(CardsTheme.warning) + if entry.isPaid { + Label("\(entry.priceCredits)", systemImage: "creditcard") + .font(.caption2.weight(.semibold)) + .foregroundStyle(CardsTheme.primary) + } + Spacer() + } + HStack(spacing: 4) { + Text(entry.owner.displayName) + .font(.caption2) + .foregroundStyle(CardsTheme.mutedForeground) + .lineLimit(1) + if entry.owner.verifiedMana { + Image(systemName: "checkmark.seal.fill") + .font(.caption2) + .foregroundStyle(CardsTheme.primary) + } } } } - .padding(12) - .frame(width: 260, alignment: .leading) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(CardsTheme.border, lineWidth: 1) - ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var categorySymbol: String { + guard let category = entry.category, + let parsed = DeckCategory(rawValue: category) + else { + return "rectangle.stack" + } + return parsed.systemImageName } } diff --git a/project.yml b/project.yml index eb17b87..7e06a9c 100644 --- a/project.yml +++ b/project.yml @@ -55,7 +55,7 @@ targets: path: Sources/Resources/Info.plist properties: CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "10" + CFBundleVersion: "11" CFBundleDevelopmentRegion: de CFBundleDisplayName: Cardecky LSApplicationCategoryType: "public.app-category.education" @@ -111,7 +111,7 @@ targets: properties: CFBundleDisplayName: Als Karte speichern CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "10" + CFBundleVersion: "11" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController @@ -144,7 +144,7 @@ targets: properties: CFBundleDisplayName: Cardecky Widget CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "10" + CFBundleVersion: "11" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension entitlements: From 0e90f4b1c1533d0c5447ab5bf9a84946430641ba Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 18:15:22 +0200 Subject: [PATCH 14/21] chore: PLAN.md auf v0.9.4-Stand + Localizable.xcstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN.md hatte noch Tag v0.8.0 als letzten Eintrag. Jetzt Post-β-7-Polish-Sektion mit der vollen Reihe v0.8.1 → v0.9.4 + Cards-Repo-Hinweis auf 0002_decks_archived_at. Localizable.xcstrings hat Xcode bei den letzten Builds automatisch um neue Source-Strings ergänzt (Multiple-Choice, Typing, CardListSection, etc.) — alle Keys ohne Translations, EN-Fill ist eine spätere Polish-Aufgabe. Co-Authored-By: Claude Opus 4.7 (1M context) --- PLAN.md | 53 ++++++++++++++++++++++--- Sources/Resources/Localizable.xcstrings | 22 +++++++++- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/PLAN.md b/PLAN.md index b1dbd35..b538fe4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,13 +1,20 @@ # Plan — cards-native (SwiftUI Universal) -**Stand: 2026-05-13 — Phasen β-0 bis β-7 abgeschlossen.** -Feature-komplett für TestFlight. Alle 7 Card-Types + Marketplace -+ Keyboard/Daily-Reminder/Widget + Siri-Shortcut + Share-Extension -+ App-Icon-Platzhalter + Release-Checklist. 35 Unit-Tests + 1 UI-Test -grün, alle drei Targets (Haupt-App + Widget + Share) bauen. +**Stand: 2026-05-13 — TestFlight Build 11 (v0.9.4).** Alle Phasen +β-0 bis β-7 + Polish-Iterationen. 43 Unit-Tests + 1 UI-Test grün. + +**Cardecky-Web-Look übernommen** (v0.9.0 ff.): Fan-Stack-Tiles +(5:7 Aspect, 3 rotierte Background-Layer), CardSurface in +md/lg/hero, RatingBar mit Good-Emphasis. Tap auf Tile = Study- +Mode, Pencil-Icon unten rechts = Deck-Detail. Identische Tile- +Optik in Decks- und Entdecken-Tab. + +**App-Store-Connect:** Cardecky, App-ID 6769019526, Bundle +`ev.mana.cardecky`, Team `QP3GLU8PH3`. AASA + /privacy + /help +live unter `cardecky.mana.how`. Pflicht-Check für β-2: Endurance-Test auf realem Gerät (200+ Karten -mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. +mit Flugmodus zwischendurch) steht aus. > **SOT:** `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`. > Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc @@ -28,6 +35,40 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. - `LoginView` (Email/PW gegen mana-auth) - 3 Unit-Tests (AppConfig) +✅ **Post-β-7-Polish (2026-05-13, Tags v0.8.1 → v0.9.4)** + +Live-Fixes nach Apple-Submission und Real-Device-Tests: +- `v0.8.1` Cardecky-Rebrand (Bundle `ev.mana.cards` → `ev.mana.cardecky`, + AASA + Docker-Compose-Env nachgezogen) +- `v0.8.2` Archive-Polish — Versions-Sync zwischen Targets, iPad- + Orientations +- `v0.8.3` Sendable + AppIcon-Asset-Cleanup, /privacy + /help Stubs +- `v0.8.4` PhotosPicker Sendable-Warning via Sub-View-Struct +- `v0.8.5` ITMS-90129-Fix: DisplayName Cards → Cardecky, Build 2 +- `v0.8.6` Cardecky-Rebrand User-facing Strings durchgängig +- `v0.8.7` PublicDeckOwner.pseudonym Bool statt String? (Decoder-Crash + bei Marketplace-Deck-Open) +- `v0.8.8` Card-Liste in DeckDetailView + CardsAPI.listCards +- `v0.8.9` URL-Query-Bug-Fix in ManaCore.AuthenticatedTransport + (URL.appending(path:) encoded `?` → 404; gefixt via String-Concat, + ManaCore v1.0.1). Behob alle "0-Karten"-Phänomene und das stille + Schlucken von Query-Endpoints. +- `v0.9.0` Cardecky-Web-Design: Fan-Stack-Tiles, CardSurface in + 3 Sizes, RatingBar mit Good-Emphasis, horizontale Scroll-Sections +- `v0.9.1` Multiple-Choice-Karten gerendert (Distractors via Server, + Tap-Selektion, Reveal mit Korrekt/Falsch-Highlight) +- `v0.9.2` Typing-Karten gerendert (Levenshtein-Match 1:1 aus + cards-domain portiert: correct/close/wrong, Aliases-Support, + Diakritika-Normalisierung) +- `v0.9.3` DEBUG-Auto-Login analog memoro-native (`ensureSignedIn()` + in #if DEBUG) — auch in manaspur-native nachgezogen +- `v0.9.4` Tile-Tap = Study-Mode direkt, Pencil-Edit-Icon unten + rechts → DeckDetail, ExploreView mit gleichem Tile-Layout + (5:7 Aspect, Kategorie-Icon oben rechts in primary) + +**Schema-Fix:** cards-Repo Commit `4d905bb` (0002_decks_archived_at) +gleicht Schema-Drift in der Production-DB aus. + ✅ **β-7 — App-Store-Vorbereitung (2026-05-13, Tag `v0.8.0`)** - App-Icon-Platzhalter: `scripts/make-appicon.swift` generiert 1024×1024 PNG aus CoreGraphics (forest-green + "C"-Letter). Asset-Catalog auf diff --git a/Sources/Resources/Localizable.xcstrings b/Sources/Resources/Localizable.xcstrings index 9f45a6f..44b775c 100644 --- a/Sources/Resources/Localizable.xcstrings +++ b/Sources/Resources/Localizable.xcstrings @@ -1,8 +1,14 @@ { "sourceLanguage" : "de", "strings" : { + "„%@“" : { + + }, "%@" : { + }, + "%@ fällig" : { + }, "%@ fällige Karten aus abonnierten Decks" : { @@ -40,10 +46,10 @@ "Distractor-Optionen werden zur Lernzeit automatisch aus anderen Karten desselben Decks gezogen." : { }, - "Erst ein Deck erstellen." : { + "Distractors konnten nicht geladen werden." : { }, - "Erstelle dein erstes Deck auf cardecky.mana.how oder ziehe nach unten zum Aktualisieren." : { + "Erst ein Deck erstellen." : { }, "Inbox" : { @@ -51,6 +57,9 @@ }, "Karteikarten des Vereins mana e.V." : { + }, + "Karten" : { + }, "Karten konnten nicht geladen werden" : { @@ -63,6 +72,12 @@ }, "Mit Hint: `{{c1::Berlin::Hauptstadt von DE}}`" : { + }, + "Nicht genug andere Karten im Deck für Multiple-Choice — tippe auf »Antwort anzeigen«." : { + + }, + "Noch keine Karten. Tippe auf »Karte hinzufügen«." : { + }, "Noch keine Maske" : { @@ -75,6 +90,9 @@ }, "Space" : { + }, + "Tippe oben auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab." : { + }, "Tippe und ziehe auf das Bild, um eine Maske zu erstellen." : { From 6805bd78c7130078a5b91dbc4bc273d03fbb6338 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 19:08:57 +0200 Subject: [PATCH 15/21] =?UTF-8?q?feat(decks):=20iOS-26=20tabViewBottomAcce?= =?UTF-8?q?ssory=20f=C3=BCr=20=E2=80=9ENeues=20Deck"-Pille?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ersetzt den bottomBar-„+"-Button auf iOS 26 durch eine schwebende Liquid-Glass-Pille via `.tabViewBottomAccessory`, nur sichtbar wenn der Decks-Tab aktiv ist. iOS 18-Geräte behalten den bestehenden bottomBar-Button (gated via `if #unavailable(iOS 26.0)`). `showCreate` wandert als Binding von DeckListView nach RootView, damit das Accessory den Sheet triggern kann. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/App/RootView.swift | 41 ++++++++++++++++++++++- Sources/Features/Decks/DeckListView.swift | 26 +++++++++----- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index 225085a..3c960a9 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -7,6 +7,7 @@ struct RootView: View { @Environment(AuthClient.self) private var auth @State private var selectedTab: AppTab = .decks @State private var pendingDeepLinkSlug: String? + @State private var showCreateDeck = false var body: some View { Group { @@ -29,7 +30,7 @@ struct RootView: View { @ViewBuilder private var mainTabs: some View { TabView(selection: $selectedTab) { - DeckListView() + DeckListView(showCreate: $showCreateDeck) .tabItem { Label("Decks", systemImage: "rectangle.stack") } .tag(AppTab.decks) @@ -43,6 +44,9 @@ struct RootView: View { .tabItem { Label("Account", systemImage: "person.crop.circle") } .tag(AppTab.account) } + .decksCreateAccessory(visible: selectedTab == .decks) { + showCreateDeck = true + } } /// Universal-Link- und URL-Scheme-Handler: @@ -65,3 +69,38 @@ enum AppTab: Hashable { case explore case account } + +private extension View { + /// iOS 26: floating „Neues Deck"-Pille via `.tabViewBottomAccessory`, + /// nur sichtbar wenn der Decks-Tab aktiv ist. iOS 18 fällt auf den + /// bestehenden `.bottomBar`-„+"-Toolbar-Button in `DeckListView` zurück. + @ViewBuilder + func decksCreateAccessory(visible: Bool, onTap: @escaping () -> Void) -> some View { + if #available(iOS 26.0, *) { + self.tabViewBottomAccessory { + if visible { + DeckCreateAccessoryPill(action: onTap) + } + } + } else { + self + } + } +} + +@available(iOS 26.0, *) +private struct DeckCreateAccessoryPill: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Label("Neues Deck", systemImage: "plus") + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + .buttonStyle(.borderedProminent) + .tint(CardsTheme.primary) + .accessibilityLabel("Neues Deck erstellen") + } +} diff --git a/Sources/Features/Decks/DeckListView.swift b/Sources/Features/Decks/DeckListView.swift index f224839..3cdd9f0 100644 --- a/Sources/Features/Decks/DeckListView.swift +++ b/Sources/Features/Decks/DeckListView.swift @@ -18,9 +18,10 @@ struct DeckListView: View { @Environment(\.modelContext) private var context @Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck] + @Binding var showCreate: Bool + @State private var store: DeckListStore? @State private var showAccount = false - @State private var showCreate = false @State private var pendingShares: [PendingShare] = [] @State private var path = NavigationPath() @@ -235,7 +236,7 @@ struct DeckListView: View { Label("Noch keine Decks", systemImage: "rectangle.stack") .foregroundStyle(CardsTheme.foreground) } description: { - Text("Tippe oben auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab.") + Text("Tippe unten auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab.") .foregroundStyle(CardsTheme.mutedForeground) } } @@ -245,14 +246,21 @@ struct DeckListView: View { @ToolbarContentBuilder private var toolbar: some ToolbarContent { - ToolbarItem(placement: .topBarLeading) { - Button { - showCreate = true - } label: { - Image(systemName: "plus.circle") - .foregroundStyle(CardsTheme.primary) + // Auf iOS 26 übernimmt das `.tabViewBottomAccessory` aus RootView die + // „Neues Deck"-Pille. Doppelten „+"-Button im Liquid-Glass-Layout + // vermeiden — bottomBar-Button nur auf iOS < 26 zeigen. + if #unavailable(iOS 26.0) { + ToolbarItemGroup(placement: .bottomBar) { + Button { + showCreate = true + } label: { + Label("Deck hinzufügen", systemImage: "plus") + .labelStyle(.iconOnly) + .foregroundStyle(CardsTheme.primary) + } + .accessibilityLabel("Deck hinzufügen") + Spacer() } - .accessibilityLabel("Deck hinzufügen") } ToolbarItem(placement: .topBarTrailing) { Button { From 710ede6acdb071dfb654b647df133d49e4750cbb Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 19:09:01 +0200 Subject: [PATCH 16/21] fix(api): code:nil bei AuthError.serverError-Calls Anpassung an die neue ManaCore-Signatur `AuthError.serverError(status:code:message:)` (vorher ohne `code`). Drei Call-Sites in fetchMedia, deleteMedia und ensureOK. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Core/API/CardsAPI.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Core/API/CardsAPI.swift b/Sources/Core/API/CardsAPI.swift index 90ecba6..63b04b7 100644 --- a/Sources/Core/API/CardsAPI.swift +++ b/Sources/Core/API/CardsAPI.swift @@ -170,7 +170,7 @@ actor CardsAPI { func fetchMedia(id: String) async throws -> Data { let (data, http) = try await transport.request(path: "/api/v1/media/\(id)") guard (200 ..< 300).contains(http.statusCode) else { - throw AuthError.serverError(status: http.statusCode, message: "media fetch failed") + throw AuthError.serverError(status: http.statusCode, code: nil, message: "media fetch failed") } return data } @@ -178,7 +178,7 @@ actor CardsAPI { /// `DELETE /api/v1/media/:id` — Soft-Forget. (Endpoint heute nicht /// implementiert serverseitig; Stub bleibt für späteren Use.) func deleteMedia(id _: String) async throws { - throw AuthError.serverError(status: 501, message: "media delete not implemented on server") + throw AuthError.serverError(status: 501, code: nil, message: "media delete not implemented on server") } // MARK: - Deck-Mutations @@ -326,7 +326,7 @@ actor CardsAPI { private func ensureOK(_ http: HTTPURLResponse, data: Data) throws { guard (200 ..< 300).contains(http.statusCode) else { let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error - throw AuthError.serverError(status: http.statusCode, message: message) + throw AuthError.serverError(status: http.statusCode, code: nil, message: message) } } } From da6679770bde00678e63f6630dec5b0770c77bc3 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 19:26:12 +0200 Subject: [PATCH 17/21] =?UTF-8?q?feat(auth):=20ManaAuthUI-Migration=20?= =?UTF-8?q?=E2=80=94=20vollst=C3=A4ndige=20Auth-Reise=20nativ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4a aus dem Native-Auth-Vollausbau-Plan. - project.yml: ManaSwiftUI/ManaAuthUI als Package-Dep - Sources/Core/Theme/CardsBrand.swift: Bridge zwischen CardsTheme (forest-HSL) und ManaBrandConfig — wird im RootView via .manaBrand(...) gesetzt - Sources/App/RootView.swift: alte LoginView() durch ManaLoginView ersetzt, Sheets für SignUp/ForgotPassword/ResetPassword. Universal- Link-Handler erweitert um /auth/reset?token=… → ManaResetPasswordView - Sources/Features/Account/LoginView.swift: gelöscht — komplett durch ManaLoginView aus ManaAuthUI abgedeckt - Sources/Features/Account/AccountView.swift: Email-ändern + PW-ändern + Account-löschen Sheets (App-Store-Guideline 5.1.1(v) erfüllt) BUILD SUCCEEDED gegen mana-swift-core@v1.1.0 und mana-swift-ui@v0.1.0. Account-Sheets (Change/Delete) funktionieren erst nach Phase-3- Server-PR (Bearer-Plugin in mana-auth) — UI ist fertig, Wire ist fertig, Server zieht nach. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/App/RootView.swift | 81 +++++++++++++++++++-- Sources/Core/Theme/CardsBrand.swift | 25 +++++++ Sources/Features/Account/AccountView.swift | 84 ++++++++++++++++++---- Sources/Features/Account/LoginView.swift | 78 -------------------- project.yml | 4 ++ 5 files changed, 173 insertions(+), 99 deletions(-) create mode 100644 Sources/Core/Theme/CardsBrand.swift delete mode 100644 Sources/Features/Account/LoginView.swift diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index 3c960a9..a41195f 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -1,3 +1,4 @@ +import ManaAuthUI import ManaCore import SwiftUI @@ -8,6 +9,12 @@ struct RootView: View { @State private var selectedTab: AppTab = .decks @State private var pendingDeepLinkSlug: String? @State private var showCreateDeck = false + @State private var showSignUpSheet = false + @State private var showForgotSheet = false + @State private var resetPasswordToken: String? + + private let sourceAppUrl = URL(string: "https://cardecky.mana.how/auth/verify")! + private let resetUniversalLink = URL(string: "https://cardecky.mana.how/auth/reset")! var body: some View { Group { @@ -19,14 +26,55 @@ struct RootView: View { if let url = activity.webpageURL { handle(url: url) } } case .unknown, .signedOut, .signingIn, .error: - LoginView() + authSurface + .onOpenURL { url in handle(url: url) } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + if let url = activity.webpageURL { handle(url: url) } + } } } + .manaBrand(CardsBrand.manaBrand) .task { await auth.ensureSignedIn() } } + @ViewBuilder + private var authSurface: some View { + ManaLoginView( + auth: auth, + onSignUpTapped: { showSignUpSheet = true }, + onForgotTapped: { showForgotSheet = true } + ) + .sheet(isPresented: $showSignUpSheet) { + ManaSignUpView( + auth: auth, + sourceAppUrl: sourceAppUrl, + onDone: { showSignUpSheet = false } + ) + .manaBrand(CardsBrand.manaBrand) + } + .sheet(isPresented: $showForgotSheet) { + ManaForgotPasswordView( + auth: auth, + resetUniversalLink: resetUniversalLink, + onDone: { showForgotSheet = false } + ) + .manaBrand(CardsBrand.manaBrand) + } + .sheet(item: Binding( + get: { resetPasswordToken.map(IdentifiedString.init) }, + set: { resetPasswordToken = $0?.value } + )) { token in + ManaResetPasswordView( + token: token.value, + auth: auth, + onDone: { resetPasswordToken = nil } + ) + .manaBrand(CardsBrand.manaBrand) + } + } + @ViewBuilder private var mainTabs: some View { TabView(selection: $selectedTab) { @@ -51,19 +99,38 @@ struct RootView: View { /// Universal-Link- und URL-Scheme-Handler: /// - `https://cardecky.mana.how/d/` → Explore-Tab + PublicDeckView + /// - `https://cardecky.mana.how/auth/reset?token=…` → ManaResetPasswordView /// - `cards://study/` → später (β-6 Notifications) private func handle(url: URL) { Log.app.info("Open URL: \(url.absoluteString, privacy: .public)") - if url.host == "cardecky.mana.how" || url.scheme == "cards" { - let parts = url.pathComponents.filter { $0 != "/" } - if parts.count >= 2, parts[0] == "d" { - pendingDeepLinkSlug = parts[1] - selectedTab = .explore - } + guard url.host == "cardecky.mana.how" || url.scheme == "cards" else { return } + + let parts = url.pathComponents.filter { $0 != "/" } + + // Auth-Reset-Link aus der Passwort-Vergessen-Email. + if parts == ["auth", "reset"], + let token = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "token" })?.value + { + resetPasswordToken = token + return + } + + if parts.count >= 2, parts[0] == "d" { + pendingDeepLinkSlug = parts[1] + selectedTab = .explore } } } +/// Helper für `.sheet(item:)` mit einem String-Value (Reset-Token). +private struct IdentifiedString: Identifiable { + let value: String + var id: String { value } +} + + enum AppTab: Hashable { case decks case explore diff --git a/Sources/Core/Theme/CardsBrand.swift b/Sources/Core/Theme/CardsBrand.swift new file mode 100644 index 0000000..73227a2 --- /dev/null +++ b/Sources/Core/Theme/CardsBrand.swift @@ -0,0 +1,25 @@ +import ManaAuthUI + +/// Brücke zwischen Cardeckys `CardsTheme` (HSL-Forest) und der +/// `ManaBrandConfig` des `ManaAuthUI`-Paketes. Wird im RootView +/// einmal als Environment-Wert gesetzt. +/// +/// Wenn ManaTokens (mana-swift-core) später Theme-Variants liefert, +/// kann diese Datei durch `ManaBrandConfig.forest(appName: "Cardecky", …)` +/// ersetzt werden — siehe MANA_SWIFT.md Phase ε. +enum CardsBrand { + static let manaBrand = ManaBrandConfig( + appName: "Cardecky", + tagline: "Karteikarten des Vereins mana e.V.", + logoSymbol: "rectangle.stack.fill", + background: CardsTheme.background, + foreground: CardsTheme.foreground, + surface: CardsTheme.surface, + mutedForeground: CardsTheme.mutedForeground, + border: CardsTheme.border, + primary: CardsTheme.primary, + primaryForeground: CardsTheme.primaryForeground, + error: CardsTheme.error, + success: CardsTheme.success + ) +} diff --git a/Sources/Features/Account/AccountView.swift b/Sources/Features/Account/AccountView.swift index 0ab63af..f47b527 100644 --- a/Sources/Features/Account/AccountView.swift +++ b/Sources/Features/Account/AccountView.swift @@ -1,13 +1,17 @@ +import ManaAuthUI import ManaCore import SwiftUI struct AccountView: View { @Environment(AuthClient.self) private var auth + @State private var showChangeEmail = false + @State private var showChangePassword = false + @State private var showDeleteAccount = false var body: some View { ZStack { CardsTheme.background.ignoresSafeArea() - VStack(spacing: 24) { + VStack(spacing: 20) { Image(systemName: "person.crop.circle.fill") .resizable() .frame(width: 80, height: 80) @@ -19,20 +23,24 @@ struct AccountView: View { .foregroundStyle(CardsTheme.foreground) } - NavigationLink { - SettingsView() - } label: { - Label("Einstellungen", systemImage: "gear") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8)) - .foregroundStyle(CardsTheme.foreground) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(CardsTheme.border, lineWidth: 1) - ) + VStack(spacing: 12) { + NavigationLink { + SettingsView() + } label: { + rowLabel("Einstellungen", systemImage: "gear") + } + .buttonStyle(.plain) + + Button { showChangeEmail = true } label: { + rowLabel("Email ändern", systemImage: "envelope") + } + .buttonStyle(.plain) + + Button { showChangePassword = true } label: { + rowLabel("Passwort ändern", systemImage: "key") + } + .buttonStyle(.plain) } - .buttonStyle(.plain) .padding(.horizontal, 32) Spacer() @@ -47,6 +55,17 @@ struct AccountView: View { .foregroundStyle(CardsTheme.error) } .padding(.horizontal, 32) + + // App-Store-Guideline 5.1.1(v): jede App mit Sign-Up MUSS + // eine Account-Löschung anbieten. + Button(role: .destructive) { + showDeleteAccount = true + } label: { + Text("Account löschen…") + .font(.footnote) + .foregroundStyle(CardsTheme.mutedForeground) + } + .padding(.bottom, 16) } .padding(.top, 48) } @@ -54,6 +73,43 @@ struct AccountView: View { #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif + .manaBrand(CardsBrand.manaBrand) + .sheet(isPresented: $showChangeEmail) { + ManaChangeEmailView( + auth: auth, + callbackUniversalLink: URL(string: "https://cardecky.mana.how/auth/email-changed"), + onDone: { showChangeEmail = false } + ) + .manaBrand(CardsBrand.manaBrand) + } + .sheet(isPresented: $showChangePassword) { + ManaChangePasswordView( + auth: auth, + onDone: { showChangePassword = false } + ) + .manaBrand(CardsBrand.manaBrand) + } + .sheet(isPresented: $showDeleteAccount) { + ManaDeleteAccountView( + auth: auth, + onDone: { showDeleteAccount = false } + ) + .manaBrand(CardsBrand.manaBrand) + } + } + + @ViewBuilder + private func rowLabel(_ title: String, systemImage: String) -> some View { + Label(title, systemImage: systemImage) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(CardsTheme.foreground) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(CardsTheme.border, lineWidth: 1) + ) } } diff --git a/Sources/Features/Account/LoginView.swift b/Sources/Features/Account/LoginView.swift deleted file mode 100644 index f9760e2..0000000 --- a/Sources/Features/Account/LoginView.swift +++ /dev/null @@ -1,78 +0,0 @@ -import ManaCore -import SwiftUI - -struct LoginView: View { - @Environment(AuthClient.self) private var auth - @State private var email = "" - @State private var password = "" - - var body: some View { - ZStack { - CardsTheme.background.ignoresSafeArea() - VStack(spacing: 24) { - Text("Cardecky") - .font(.system(size: 48, weight: .bold)) - .foregroundStyle(CardsTheme.primary) - Text("Karteikarten des Vereins mana e.V.") - .font(.subheadline) - .foregroundStyle(CardsTheme.mutedForeground) - - VStack(spacing: 12) { - TextField("Email", text: $email) - .textContentType(.emailAddress) - .keyboardType(.emailAddress) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .padding(.vertical, 12) - .padding(.horizontal, 16) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8)) - - SecureField("Passwort", text: $password) - .textContentType(.password) - .padding(.vertical, 12) - .padding(.horizontal, 16) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8)) - } - .padding(.horizontal, 32) - - Button { - Task { await auth.signIn(email: email, password: password) } - } label: { - HStack { - if case .signingIn = auth.status { - ProgressView() - .controlSize(.small) - .tint(CardsTheme.primaryForeground) - } - Text("Anmelden") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 8)) - .foregroundStyle(CardsTheme.primaryForeground) - } - .padding(.horizontal, 32) - .disabled(isSigningIn || email.isEmpty || password.isEmpty) - - if case let .error(message) = auth.status { - Text(message) - .font(.footnote) - .foregroundStyle(CardsTheme.error) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - } - } - } - } - - private var isSigningIn: Bool { - if case .signingIn = auth.status { return true } - return false - } -} - -#Preview { - LoginView() - .environment(AuthClient(config: AppConfig.manaAppConfig)) -} diff --git a/project.yml b/project.yml index 7e06a9c..9f2283b 100644 --- a/project.yml +++ b/project.yml @@ -14,6 +14,8 @@ options: packages: ManaSwiftCore: path: ../mana-swift-core + ManaSwiftUI: + path: ../mana-swift-ui settings: base: @@ -39,6 +41,8 @@ targets: product: ManaCore - package: ManaSwiftCore product: ManaTokens + - package: ManaSwiftUI + product: ManaAuthUI - target: CardsWidgetExtension embed: true - target: CardsShareExtension From 8ca7bd363628d7dc4fc6d2a75e2b48175cf13edf Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 14 May 2026 01:23:30 +0200 Subject: [PATCH 18/21] feat(auth): Guest-Mode + Login-optionale Surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RootView ohne Hard-Login-Gate — TabBar zeigt sich immer, beim Start wechselt App bei .signedOut automatisch in den anonymen .guest-Modus (mana-swift-core v1.2.0). Auth-Sheets (Login, SignUp, Forgot, Reset) hängen jetzt als ManaAuthGate-Modifier am Root. AccountView zeigt im Guest-Modus eine eigene CTA-Surface („Anmelden / Konto erstellen" + Hinweis was Login bringt). signOut nutzt keepGuestMode: true → App bleibt nach Logout anonym nutzbar, Marketplace und lokale Daten gehen nicht verloren. DeckListView: Empty-State im Guest-Mode mit Login-CTA + Marketplace- Hinweis. Toolbar-„+"-Button via authGate.require gewrappt — Tap aus dem Guest-Modus öffnet erst das Sign-In-Sheet, danach den Editor. DeckListStore.refresh() skippt im Guest-Mode (kein 401-Spam). Cache wird so wie er ist gerendert (heute leer, später Marketplace-Klone). Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/App/CardsNativeApp.swift | 4 + Sources/App/RootView.swift | 154 +++++++++-------- Sources/Core/Sync/DeckListStore.swift | 18 +- Sources/Features/Account/AccountView.swift | 191 +++++++++++++++------ Sources/Features/Decks/DeckListView.swift | 69 ++++---- 5 files changed, 276 insertions(+), 160 deletions(-) diff --git a/Sources/App/CardsNativeApp.swift b/Sources/App/CardsNativeApp.swift index 46bcc2e..dfb6626 100644 --- a/Sources/App/CardsNativeApp.swift +++ b/Sources/App/CardsNativeApp.swift @@ -1,3 +1,4 @@ +import ManaAuthUI import ManaCore import SwiftData import SwiftUI @@ -6,6 +7,7 @@ import SwiftUI struct CardsNativeApp: App { let container: ModelContainer @State private var auth: AuthClient + @State private var authGate: ManaAuthGate private let mediaCache: MediaCache init() { @@ -17,6 +19,7 @@ struct CardsNativeApp: App { let auth = AuthClient(config: AppConfig.manaAppConfig) auth.bootstrap() _auth = State(initialValue: auth) + _authGate = State(initialValue: ManaAuthGate(auth: auth)) mediaCache = MediaCache(api: CardsAPI(auth: auth)) Log.app.info("Cardecky starting — auth status: \(String(describing: auth.status), privacy: .public)") } @@ -25,6 +28,7 @@ struct CardsNativeApp: App { WindowGroup { RootView() .environment(auth) + .environment(authGate) .environment(\.mediaCache, mediaCache) .tint(CardsTheme.primary) } diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index a41195f..599df9b 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -2,10 +2,13 @@ import ManaAuthUI import ManaCore import SwiftUI -/// Top-Level-Switch: Login vs Haupt-App. Haupt-App ist eine TabBar mit -/// drei Tabs (Decks / Entdecken / Account). +/// Top-Level-View: TabBar mit drei Tabs (Decks / Entdecken / Account). +/// Kein harter Login-Gate mehr — Cardecky läuft auch im Guest-Modus +/// (lokale Decks lernen, Marketplace browsen). Schreibende Server- +/// Aktionen werden über ``ManaAuthGate`` einzeln auf Login eskaliert. struct RootView: View { @Environment(AuthClient.self) private var auth + @Environment(ManaAuthGate.self) private var authGate @State private var selectedTab: AppTab = .decks @State private var pendingDeepLinkSlug: String? @State private var showCreateDeck = false @@ -17,65 +20,75 @@ struct RootView: View { private let resetUniversalLink = URL(string: "https://cardecky.mana.how/auth/reset")! var body: some View { - Group { - switch auth.status { - case .signedIn: - mainTabs - .onOpenURL { url in handle(url: url) } - .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in - if let url = activity.webpageURL { handle(url: url) } - } - case .unknown, .signedOut, .signingIn, .error: - authSurface - .onOpenURL { url in handle(url: url) } - .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in - if let url = activity.webpageURL { handle(url: url) } + mainTabs + .onOpenURL { url in handle(url: url) } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + if let url = activity.webpageURL { handle(url: url) } + } + .manaBrand(CardsBrand.manaBrand) + .manaAuthGate(authGate) { + gateSignInContent + } + .sheet(item: Binding( + get: { resetPasswordToken.map(IdentifiedString.init) }, + set: { resetPasswordToken = $0?.value } + )) { token in + ManaResetPasswordView( + token: token.value, + auth: auth, + onDone: { resetPasswordToken = nil } + ) + .manaBrand(CardsBrand.manaBrand) + } + .task { + // DEBUG: Auto-Login mit DebugCredentials, falls signedOut. + // Release: no-op. Danach in Guest-Mode wechseln, wenn weder + // signedIn noch eingebuchtet — Cardecky soll *immer* nutzbar + // sein, auch ohne Account. + await auth.ensureSignedIn() + if case .signedOut = auth.status { + do { + _ = try auth.enterGuestMode() + } catch { + Log.auth.warning( + "Guest-Mode konnte nicht aktiviert werden: \(String(describing: error), privacy: .public)" + ) } + } + } + } + + /// Content für das ``ManaAuthGate``-Sheet — wenn ein gegateter Button + /// gedrückt wird, fliegt der User in den Sign-In-Flow. Sign-Up und + /// Forgot-Password werden als verschachtelte Sheets aufgeklappt, + /// damit aus dem Gate-Sheet alle Auth-Pfade erreichbar bleiben. + private var gateSignInContent: some View { + NavigationStack { + ManaLoginView( + auth: auth, + onSignUpTapped: { showSignUpSheet = true }, + onForgotTapped: { showForgotSheet = true } + ) + .manaBrand(CardsBrand.manaBrand) + .sheet(isPresented: $showSignUpSheet) { + ManaSignUpView( + auth: auth, + sourceAppUrl: sourceAppUrl, + onDone: { showSignUpSheet = false } + ) + .manaBrand(CardsBrand.manaBrand) + } + .sheet(isPresented: $showForgotSheet) { + ManaForgotPasswordView( + auth: auth, + resetUniversalLink: resetUniversalLink, + onDone: { showForgotSheet = false } + ) + .manaBrand(CardsBrand.manaBrand) } } - .manaBrand(CardsBrand.manaBrand) - .task { - await auth.ensureSignedIn() - } } - @ViewBuilder - private var authSurface: some View { - ManaLoginView( - auth: auth, - onSignUpTapped: { showSignUpSheet = true }, - onForgotTapped: { showForgotSheet = true } - ) - .sheet(isPresented: $showSignUpSheet) { - ManaSignUpView( - auth: auth, - sourceAppUrl: sourceAppUrl, - onDone: { showSignUpSheet = false } - ) - .manaBrand(CardsBrand.manaBrand) - } - .sheet(isPresented: $showForgotSheet) { - ManaForgotPasswordView( - auth: auth, - resetUniversalLink: resetUniversalLink, - onDone: { showForgotSheet = false } - ) - .manaBrand(CardsBrand.manaBrand) - } - .sheet(item: Binding( - get: { resetPasswordToken.map(IdentifiedString.init) }, - set: { resetPasswordToken = $0?.value } - )) { token in - ManaResetPasswordView( - token: token.value, - auth: auth, - onDone: { resetPasswordToken = nil } - ) - .manaBrand(CardsBrand.manaBrand) - } - } - - @ViewBuilder private var mainTabs: some View { TabView(selection: $selectedTab) { DeckListView(showCreate: $showCreateDeck) @@ -93,7 +106,9 @@ struct RootView: View { .tag(AppTab.account) } .decksCreateAccessory(visible: selectedTab == .decks) { - showCreateDeck = true + authGate.require(reason: "deck-create-accessory") { + showCreateDeck = true + } } } @@ -110,8 +125,8 @@ struct RootView: View { // Auth-Reset-Link aus der Passwort-Vergessen-Email. if parts == ["auth", "reset"], let token = URLComponents(url: url, resolvingAgainstBaseURL: false)? - .queryItems? - .first(where: { $0.name == "token" })?.value + .queryItems? + .first(where: { $0.name == "token" })?.value { resetPasswordToken = token return @@ -127,10 +142,11 @@ struct RootView: View { /// Helper für `.sheet(item:)` mit einem String-Value (Reset-Token). private struct IdentifiedString: Identifiable { let value: String - var id: String { value } + var id: String { + value + } } - enum AppTab: Hashable { case decks case explore @@ -141,13 +157,15 @@ private extension View { /// iOS 26: floating „Neues Deck"-Pille via `.tabViewBottomAccessory`, /// nur sichtbar wenn der Decks-Tab aktiv ist. iOS 18 fällt auf den /// bestehenden `.bottomBar`-„+"-Toolbar-Button in `DeckListView` zurück. + /// + /// Den Modifier nur konditional anwenden — sonst rendert das System + /// auch bei leerem Inhalt die leere Glass-Hülle (sichtbar als toter + /// Streifen über der TabBar auf Entdecken/Account). @ViewBuilder func decksCreateAccessory(visible: Bool, onTap: @escaping () -> Void) -> some View { - if #available(iOS 26.0, *) { - self.tabViewBottomAccessory { - if visible { - DeckCreateAccessoryPill(action: onTap) - } + if #available(iOS 26.0, *), visible { + tabViewBottomAccessory { + DeckCreateAccessoryPill(action: onTap) } } else { self @@ -163,10 +181,8 @@ private struct DeckCreateAccessoryPill: View { Button(action: action) { Label("Neues Deck", systemImage: "plus") .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 14) - .padding(.vertical, 8) } - .buttonStyle(.borderedProminent) + .buttonStyle(.glass) .tint(CardsTheme.primary) .accessibilityLabel("Neues Deck erstellen") } diff --git a/Sources/Core/Sync/DeckListStore.swift b/Sources/Core/Sync/DeckListStore.swift index 09f6a09..28b1a37 100644 --- a/Sources/Core/Sync/DeckListStore.swift +++ b/Sources/Core/Sync/DeckListStore.swift @@ -9,7 +9,7 @@ import WidgetKit @MainActor @Observable final class DeckListStore { - enum State: Sendable { + enum State { case idle case loading case loaded @@ -21,15 +21,25 @@ final class DeckListStore { private let api: CardsAPI private let context: ModelContext + private let auth: AuthClient init(auth: AuthClient, context: ModelContext) { api = CardsAPI(auth: auth) self.context = context + self.auth = auth } /// Holt Decks vom Server, aktualisiert Cache. Bei Netzfehler bleibt - /// der Cache (offline-readable). + /// der Cache (offline-readable). Im Guest-Mode wird kein Server-Call + /// versucht — der Cache (leer oder über Marketplace-Klone gefüllt) + /// wird so wie er ist gerendert. func refresh() async { + guard case .signedIn = auth.status else { + state = .idle + errorMessage = nil + return + } + state = .loading errorMessage = nil @@ -69,8 +79,8 @@ final class DeckListStore { group.addTask { [api] in async let cards = api.cardCount(deckId: deck.id) async let due = api.dueCount(deckId: deck.id) - let cardCount = (try? await cards) ?? 0 - let dueCount = (try? await due) ?? 0 + let cardCount = await (try? cards) ?? 0 + let dueCount = await (try? due) ?? 0 return (deck.id, cardCount, dueCount) } } diff --git a/Sources/Features/Account/AccountView.swift b/Sources/Features/Account/AccountView.swift index f47b527..dabd824 100644 --- a/Sources/Features/Account/AccountView.swift +++ b/Sources/Features/Account/AccountView.swift @@ -4,6 +4,7 @@ import SwiftUI struct AccountView: View { @Environment(AuthClient.self) private var auth + @Environment(ManaAuthGate.self) private var authGate @State private var showChangeEmail = false @State private var showChangePassword = false @State private var showDeleteAccount = false @@ -11,63 +12,16 @@ struct AccountView: View { var body: some View { ZStack { CardsTheme.background.ignoresSafeArea() - VStack(spacing: 20) { - Image(systemName: "person.crop.circle.fill") - .resizable() - .frame(width: 80, height: 80) - .foregroundStyle(CardsTheme.primary) - - if let email = auth.currentEmail { - Text(email) - .font(.headline) - .foregroundStyle(CardsTheme.foreground) + Group { + switch auth.status { + case .signedIn: + signedInContent + case .guest, .signedOut, .error, .unknown: + guestContent + case .signingIn, .twoFactorRequired: + ProgressView().tint(CardsTheme.primary) } - - VStack(spacing: 12) { - NavigationLink { - SettingsView() - } label: { - rowLabel("Einstellungen", systemImage: "gear") - } - .buttonStyle(.plain) - - Button { showChangeEmail = true } label: { - rowLabel("Email ändern", systemImage: "envelope") - } - .buttonStyle(.plain) - - Button { showChangePassword = true } label: { - rowLabel("Passwort ändern", systemImage: "key") - } - .buttonStyle(.plain) - } - .padding(.horizontal, 32) - - Spacer() - - Button(role: .destructive) { - Task { await auth.signOut() } - } label: { - Text("Abmelden") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) - .foregroundStyle(CardsTheme.error) - } - .padding(.horizontal, 32) - - // App-Store-Guideline 5.1.1(v): jede App mit Sign-Up MUSS - // eine Account-Löschung anbieten. - Button(role: .destructive) { - showDeleteAccount = true - } label: { - Text("Account löschen…") - .font(.footnote) - .foregroundStyle(CardsTheme.mutedForeground) - } - .padding(.bottom, 16) } - .padding(.top, 48) } .navigationTitle("Account") #if os(iOS) @@ -98,7 +52,132 @@ struct AccountView: View { } } - @ViewBuilder + private var signedInContent: some View { + VStack(spacing: 20) { + Image(systemName: "person.crop.circle.fill") + .resizable() + .frame(width: 80, height: 80) + .foregroundStyle(CardsTheme.primary) + + if let email = auth.currentEmail { + Text(email) + .font(.headline) + .foregroundStyle(CardsTheme.foreground) + } + + VStack(spacing: 12) { + NavigationLink { + SettingsView() + } label: { + rowLabel("Einstellungen", systemImage: "gear") + } + .buttonStyle(.plain) + + Button { showChangeEmail = true } label: { + rowLabel("Email ändern", systemImage: "envelope") + } + .buttonStyle(.plain) + + Button { showChangePassword = true } label: { + rowLabel("Passwort ändern", systemImage: "key") + } + .buttonStyle(.plain) + + ManaTwoFactorAccountRow(auth: auth) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(CardsTheme.border, lineWidth: 1) + ) + } + .padding(.horizontal, 32) + + Spacer() + + Button(role: .destructive) { + // Logout behält die Guest-Identity → App bleibt im + // anonymen Modus nutzbar (lokale Decks, Marketplace + // browsen). Wer „alles vergessen" will, nutzt + // „Account löschen". + Task { await auth.signOut(keepGuestMode: true) } + } label: { + Text("Abmelden") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(CardsTheme.error) + } + .padding(.horizontal, 32) + + // App-Store-Guideline 5.1.1(v): jede App mit Sign-Up MUSS + // eine Account-Löschung anbieten. + Button(role: .destructive) { + showDeleteAccount = true + } label: { + Text("Account löschen…") + .font(.footnote) + .foregroundStyle(CardsTheme.mutedForeground) + } + .padding(.bottom, 16) + } + .padding(.top, 48) + } + + private var guestContent: some View { + VStack(spacing: 20) { + Image(systemName: "person.crop.circle.dashed") + .resizable() + .frame(width: 80, height: 80) + .foregroundStyle(CardsTheme.mutedForeground) + + VStack(spacing: 8) { + Text("Du nutzt Cardecky anonym") + .font(.headline) + .foregroundStyle(CardsTheme.foreground) + Text( + """ + Marketplace und lokale Decks funktionieren ohne Konto. \ + Für KI-Karten, eigene Decks im Cloud-Sync und Marketplace-\ + Veröffentlichung brauchst du ein Konto. + """ + ) + .font(.subheadline) + .foregroundStyle(CardsTheme.mutedForeground) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 32) + + VStack(spacing: 12) { + Button { + // Trigger ohne pending-Action — wir wollen einfach + // das Sign-In-Sheet öffnen. `require` mit no-op + // schaltet die Sheet-Logik des Gates ein. + authGate.require(reason: "account-tab") {} + } label: { + Text("Anmelden / Konto erstellen") + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + + NavigationLink { + SettingsView() + } label: { + rowLabel("Einstellungen", systemImage: "gear") + } + .buttonStyle(.plain) + } + .padding(.horizontal, 32) + + Spacer() + } + .padding(.top, 48) + } + private func rowLabel(_ title: String, systemImage: String) -> some View { Label(title, systemImage: systemImage) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/Features/Decks/DeckListView.swift b/Sources/Features/Decks/DeckListView.swift index 3cdd9f0..3b3a302 100644 --- a/Sources/Features/Decks/DeckListView.swift +++ b/Sources/Features/Decks/DeckListView.swift @@ -1,3 +1,4 @@ +import ManaAuthUI import ManaCore import SwiftData import SwiftUI @@ -15,13 +16,17 @@ enum DeckRoute: Hashable { /// `cards/apps/web/src/routes/decks/+page.svelte`. struct DeckListView: View { @Environment(AuthClient.self) private var auth + @Environment(ManaAuthGate.self) private var authGate @Environment(\.modelContext) private var context @Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck] @Binding var showCreate: Bool + private var isGuest: Bool { + if case .signedIn = auth.status { false } else { true } + } + @State private var store: DeckListStore? - @State private var showAccount = false @State private var pendingShares: [PendingShare] = [] @State private var path = NavigationPath() @@ -67,16 +72,6 @@ struct DeckListView: View { .onAppear { pendingShares = PendingShareStore.readAll() } - .sheet(isPresented: $showAccount) { - NavigationStack { - AccountView() - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Fertig") { showAccount = false } - } - } - } - } } } @@ -104,7 +99,7 @@ struct DeckListView: View { } private var subscribedDecks: [CachedDeck] { - decks.filter { $0.isFromMarketplace } + decks.filter(\.isFromMarketplace) } @ViewBuilder @@ -172,7 +167,10 @@ struct DeckListView: View { .foregroundStyle(CardsTheme.mutedForeground) } .padding(14) - .background(CardsTheme.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .background( + CardsTheme.primary.opacity(0.08), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(CardsTheme.primary.opacity(0.18), lineWidth: 1) @@ -207,7 +205,10 @@ struct DeckListView: View { .foregroundStyle(CardsTheme.mutedForeground) } .padding(14) - .background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .background( + CardsTheme.warning.opacity(0.12), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) } .buttonStyle(.plain) } @@ -231,13 +232,31 @@ struct DeckListView: View { Text(message) .foregroundStyle(CardsTheme.mutedForeground) } + } else if isGuest { + ContentUnavailableView { + Label("Cardecky ohne Konto", systemImage: "person.crop.circle.dashed") + .foregroundStyle(CardsTheme.foreground) + } description: { + Text( + "Browse den Marketplace im Entdecken-Tab — kein Konto nötig. Für eigene Decks und Cloud-Sync logge dich ein." + ) + .foregroundStyle(CardsTheme.mutedForeground) + } actions: { + Button("Anmelden / Konto erstellen") { + authGate.require(reason: "deck-list-empty") {} + } + .buttonStyle(.borderedProminent) + .tint(CardsTheme.primary) + } } else { ContentUnavailableView { Label("Noch keine Decks", systemImage: "rectangle.stack") .foregroundStyle(CardsTheme.foreground) } description: { - Text("Tippe unten auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab.") - .foregroundStyle(CardsTheme.mutedForeground) + Text( + "Tippe unten auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab." + ) + .foregroundStyle(CardsTheme.mutedForeground) } } } @@ -252,7 +271,9 @@ struct DeckListView: View { if #unavailable(iOS 26.0) { ToolbarItemGroup(placement: .bottomBar) { Button { - showCreate = true + authGate.require(reason: "deck-create-toolbar") { + showCreate = true + } } label: { Label("Deck hinzufügen", systemImage: "plus") .labelStyle(.iconOnly) @@ -262,19 +283,5 @@ struct DeckListView: View { Spacer() } } - ToolbarItem(placement: .topBarTrailing) { - Button { - showAccount = true - } label: { - Image(systemName: accountIcon) - .foregroundStyle(CardsTheme.primary) - } - .accessibilityLabel("Account") - } - } - - private var accountIcon: String { - if case .signedIn = auth.status { return "person.crop.circle.fill" } - return "person.crop.circle.badge.exclamationmark" } } From 73f9081fa1cee6b965ea08f025900edcd70ea90f Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 14 May 2026 02:03:59 +0200 Subject: [PATCH 19/21] =?UTF-8?q?feat(decks):=20=CE=B3-1=20bis=20=CE=B3-8?= =?UTF-8?q?=20=E2=80=94=20AI/CSV-Import,=20Card-Edit,=20Pull-Update,=20Mar?= =?UTF-8?q?ketplace-Publish=20+=20Moderation=20+=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vervollständigt die Cardecky-Web-Parität für Deck- und Card-Workflows. γ-1+γ-2 (AI-Deck-Generierung) - 4-Modi-Picker im DeckEditorView Create-Sheet: Leer/KI/Bild/CSV - POST /api/v1/decks/generate für Text-Prompt + 10/min Rate-Limit-UI - POST /api/v1/decks/from-image mit PhotosPicker + PDF-Importer (max 5 Files, 10 MiB/Bild, 30 MiB/PDF), Multipart-Body in CardsAPI+Generation - Loading-Overlay mit Task-Cancellation, Error-Mapping für 429/413/502 γ-3 (Card-Edit) - CardEditorView mit Mode .create(deckId:) / .edit(card:) - Image-Occlusion + Audio-Front behalten bestehenden Media-Ref, solange User nicht ersetzt — MediaCache lädt Bild nach - Type-Picker im Edit-Modus aus (Server-immutable) - CardEditorPayload + CardEditorMediaFields als Sub-Views γ-4 (Pull-Update + Duplicate + Archive) - POST /marketplace/private/:id/pull-update mit Smart-Merge-Anzeige - POST /decks/:id/duplicate - Archive-Toggle im Edit-Modus, Server filtert Liste serverseitig - DeckSecondaryActions als eigenes Sub-View γ-6 (CSV-Import) - RFC-4180-ish Parser (Quote-Escape, Header-Detect, BOM-strip) - Preview-Liste + sequentielle Card-Inserts mit Live-Progress - Image-Occlusion/Audio-Front werden geskipped (UI flaggt) γ-7 (Marketplace-Publish) + Follow-up (Report + Block + Re-Publish) - MarketplacePublishView mit lazy Author-Setup + Init + Publish 1.0.0 - Re-Publish-Modus: Picker für eigene Marketplace-Decks + Auto-Semver-Bump (Minor +1) - MarketplaceCardConverter (typing → type-in, audio-front → skipped, image-occlusion → skipped — Server hat keinen MP-Media-Re-Upload) - Toolbar-Menü auf PublicDeckView: „Deck melden …" + Author-Blockieren (App-Store-Guideline 5.1.1(v)) - ReportDeckSheet mit Reason-Picker (6 Kategorien) + optional Message - BlockedAuthorsView in Settings mit Swipe-Entblocken γ-8 (PDF-Export) - DeckPrintView mit SFSafariViewController auf cardecky.mana.how/decks/:id/print — iOS Share-Sheet → PDF speichern Side-Fixes (mid-stream) - StudySessionView: Card-Aspect-Ratio springt nicht mehr beim Flip (Bottom-Bar in ZStack fixer Höhe) - RootView: Glass-Pille für „Neues Deck"-Accessory + .guest- und .twoFactorRequired-Cases nachgezogen - DeckListView: Account-Toolbar-Button entfernt (Account-Tab unten ist alleinige Anlaufstelle) Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/App/RootView.swift | 13 +- Sources/Core/API/CardsAPI+Generation.swift | 114 +++ Sources/Core/API/CardsAPI+Marketplace.swift | 59 ++ Sources/Core/API/CardsAPI.swift | 122 ++- Sources/Core/Domain/CSVParser.swift | 164 ++++ Sources/Core/Domain/DeckGeneration.swift | 62 ++ Sources/Core/Domain/DeckMutations.swift | 38 +- .../Core/Domain/MarketplaceModeration.swift | 122 +++ Sources/Core/Domain/MarketplacePublish.swift | 116 +++ Sources/Features/Decks/DeckCoverTile.swift | 122 +++ Sources/Features/Decks/DeckDetailView.swift | 245 ++++-- Sources/Features/Decks/DeckListView.swift | 14 +- Sources/Features/Decks/DeckPrintView.swift | 56 ++ .../Features/Decks/DeckSecondaryActions.swift | 138 ++++ .../Editor/CSVImportFormSections.swift | 82 ++ .../Editor/CardEditorMediaFields.swift | 173 ++++ .../Features/Editor/CardEditorPayload.swift | 149 ++++ Sources/Features/Editor/CardEditorView.swift | 369 ++++----- .../Features/Editor/DeckEditorHelpers.swift | 82 ++ Sources/Features/Editor/DeckEditorView.swift | 782 +++++++++++++++--- .../Marketplace/MarketplacePublishView.swift | 474 +++++++++++ .../Features/Marketplace/PublicDeckView.swift | 93 ++- .../Marketplace/ReportDeckSheet.swift | 109 +++ .../Settings/BlockedAuthorsView.swift | 89 ++ Sources/Features/Settings/SettingsView.swift | 18 +- Sources/Features/Study/StudySessionView.swift | 56 +- 26 files changed, 3419 insertions(+), 442 deletions(-) create mode 100644 Sources/Core/API/CardsAPI+Generation.swift create mode 100644 Sources/Core/API/CardsAPI+Marketplace.swift create mode 100644 Sources/Core/Domain/CSVParser.swift create mode 100644 Sources/Core/Domain/DeckGeneration.swift create mode 100644 Sources/Core/Domain/MarketplaceModeration.swift create mode 100644 Sources/Core/Domain/MarketplacePublish.swift create mode 100644 Sources/Features/Decks/DeckCoverTile.swift create mode 100644 Sources/Features/Decks/DeckPrintView.swift create mode 100644 Sources/Features/Decks/DeckSecondaryActions.swift create mode 100644 Sources/Features/Editor/CSVImportFormSections.swift create mode 100644 Sources/Features/Editor/CardEditorMediaFields.swift create mode 100644 Sources/Features/Editor/CardEditorPayload.swift create mode 100644 Sources/Features/Editor/DeckEditorHelpers.swift create mode 100644 Sources/Features/Marketplace/MarketplacePublishView.swift create mode 100644 Sources/Features/Marketplace/ReportDeckSheet.swift create mode 100644 Sources/Features/Settings/BlockedAuthorsView.swift diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index 599df9b..6bcfe71 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -123,13 +123,12 @@ struct RootView: View { let parts = url.pathComponents.filter { $0 != "/" } // Auth-Reset-Link aus der Passwort-Vergessen-Email. - if parts == ["auth", "reset"], - let token = URLComponents(url: url, resolvingAgainstBaseURL: false)? - .queryItems? - .first(where: { $0.name == "token" })?.value - { - resetPasswordToken = token - return + if parts == ["auth", "reset"] { + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + if let token = components?.queryItems?.first(where: { $0.name == "token" })?.value { + resetPasswordToken = token + return + } } if parts.count >= 2, parts[0] == "d" { diff --git a/Sources/Core/API/CardsAPI+Generation.swift b/Sources/Core/API/CardsAPI+Generation.swift new file mode 100644 index 0000000..c656c54 --- /dev/null +++ b/Sources/Core/API/CardsAPI+Generation.swift @@ -0,0 +1,114 @@ +import Foundation +import ManaCore + +/// AI-Deck-Generierung + Multipart-Helpers — ausgelagert aus `CardsAPI`, +/// damit der Haupt-Actor unter der Type-Body-Length-Grenze bleibt. +extension CardsAPI { + /// `POST /api/v1/decks/generate` — KI generiert Deck aus Prompt. + /// Rate-Limit serverseitig 10/min. Antwort dauert typisch 10–60s + /// (synchron, kein Streaming). + func generateDeckFromText(_ body: DeckGenerateBody) async throws -> DeckGenerateResponse { + let data = try makeJSON(body) + let (responseData, http) = try await transport.request( + path: "/api/v1/decks/generate", + method: "POST", + body: data + ) + try ensureOK(http, data: responseData) + return try decoder.decode(DeckGenerateResponse.self, from: responseData) + } + + /// `POST /api/v1/decks/from-image` — Vision-LLM generiert Deck aus + /// Bildern und/oder PDFs (max 5 Files, 10 MiB pro Bild, 30 MiB pro PDF) + /// und optional einer URL für Zusatz-Kontext. Rate-Limit 10/min. + /// Multipart-Body mit `file`-Parts (wiederholt) + Text-Felder. + func generateDeckFromMedia( + files: [GenerationMediaFile], + language: GenerationLanguage, + count: Int, + url: String? + ) async throws -> DeckGenerateResponse { + let boundary = "cards-native-\(UUID().uuidString)" + let body = makeFromImageMultipartBody( + files: files, + language: language, + count: count, + url: url, + boundary: boundary + ) + let (responseData, http) = try await transport.request( + path: "/api/v1/decks/from-image", + method: "POST", + body: body, + contentType: "multipart/form-data; boundary=\(boundary)" + ) + try ensureOK(http, data: responseData) + return try decoder.decode(DeckGenerateResponse.self, from: responseData) + } + + // MARK: - Multipart + + /// Single-File-Multipart-Body für `/media/upload`. + func makeMultipartBody( + file: Data, + filename: String, + mimeType: String, + boundary: String + ) -> Data { + var body = Data() + let lineBreak = "\r\n" + let header = """ + --\(boundary)\(lineBreak)\ + Content-Disposition: form-data; name="file"; filename="\(filename)"\(lineBreak)\ + Content-Type: \(mimeType)\(lineBreak)\(lineBreak) + """ + body.append(Data(header.utf8)) + body.append(file) + body.append(Data(lineBreak.utf8)) + body.append(Data("--\(boundary)--\(lineBreak)".utf8)) + return body + } + + /// Multi-File-Multipart-Body für `/decks/from-image` — mehrere Files + /// unter dem Form-Feld `file` (Server liest sie via `getAll('file')`) + /// plus optional `language`, `count`, `url` als Text-Felder. + func makeFromImageMultipartBody( + files: [GenerationMediaFile], + language: GenerationLanguage, + count: Int, + url: String?, + boundary: String + ) -> Data { + var body = Data() + let lineBreak = "\r\n" + + func appendField(name: String, value: String) { + let part = """ + --\(boundary)\(lineBreak)\ + Content-Disposition: form-data; name="\(name)"\(lineBreak)\(lineBreak)\ + \(value)\(lineBreak) + """ + body.append(Data(part.utf8)) + } + + appendField(name: "language", value: language.rawValue) + appendField(name: "count", value: String(count)) + if let url, !url.trimmingCharacters(in: .whitespaces).isEmpty { + appendField(name: "url", value: url) + } + + for file in files { + let header = """ + --\(boundary)\(lineBreak)\ + Content-Disposition: form-data; name="file"; filename="\(file.filename)"\(lineBreak)\ + Content-Type: \(file.mimeType)\(lineBreak)\(lineBreak) + """ + body.append(Data(header.utf8)) + body.append(file.data) + body.append(Data(lineBreak.utf8)) + } + + body.append(Data("--\(boundary)--\(lineBreak)".utf8)) + return body + } +} diff --git a/Sources/Core/API/CardsAPI+Marketplace.swift b/Sources/Core/API/CardsAPI+Marketplace.swift new file mode 100644 index 0000000..cf78fc7 --- /dev/null +++ b/Sources/Core/API/CardsAPI+Marketplace.swift @@ -0,0 +1,59 @@ +import Foundation +import ManaCore + +/// Marketplace-Moderation und Self-Endpoints — ausgelagert aus `CardsAPI`, +/// damit der Haupt-Actor unter der Type-Body-Length-Grenze bleibt. +/// +/// `transport`, `decoder`, `makeJSON`, `ensureOK` sind im Actor +/// internal-zugänglich. +extension CardsAPI { + /// `GET /api/v1/marketplace/me/decks` — eigene Marketplace-Decks + /// (mit aktueller Version) für den Re-Publish-Flow. + func myMarketplaceDecks() async throws -> [OwnedMarketplaceDeck] { + let (data, http) = try await transport.request(path: "/api/v1/marketplace/me/decks") + try ensureOK(http, data: data) + return try decoder.decode(OwnedMarketplaceDecksResponse.self, from: data).decks + } + + /// `POST /api/v1/marketplace/decks/:slug/report` — Meldung melden. + /// Idempotent: doppeltes Melden mit gleicher Kategorie liefert + /// `already_reported: true` ohne Fehler. + @discardableResult + func reportDeck(slug: String, body: ReportDeckBody) async throws -> ReportDeckResponse { + let payload = try makeJSON(body) + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/decks/\(slug)/report", + method: "POST", + body: payload + ) + try ensureOK(http, data: data) + return try decoder.decode(ReportDeckResponse.self, from: data) + } + + /// `POST /api/v1/marketplace/authors/:slug/block` — Author blockieren. + /// Decks dieses Authors verschwinden für den aufrufenden User aus + /// allen Marketplace-Listings. + func blockAuthor(slug: String) async throws { + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/authors/\(slug)/block", + method: "POST" + ) + try ensureOK(http, data: data) + } + + /// `DELETE /api/v1/marketplace/authors/:slug/block`. + func unblockAuthor(slug: String) async throws { + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/authors/\(slug)/block", + method: "DELETE" + ) + try ensureOK(http, data: data) + } + + /// `GET /api/v1/marketplace/me/blocks` — eigene Block-Liste. + func myBlocks() async throws -> [BlockEntry] { + let (data, http) = try await transport.request(path: "/api/v1/marketplace/me/blocks") + try ensureOK(http, data: data) + return try decoder.decode(BlockListResponse.self, from: data).blocks + } +} diff --git a/Sources/Core/API/CardsAPI.swift b/Sources/Core/API/CardsAPI.swift index 63b04b7..5c9ce6e 100644 --- a/Sources/Core/API/CardsAPI.swift +++ b/Sources/Core/API/CardsAPI.swift @@ -1,11 +1,16 @@ import Foundation import ManaCore +// swiftlint:disable file_length +// swiftlint:disable type_body_length + /// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport` -/// aus ManaCore, der die Cardecky-Endpoints kennt. +/// aus ManaCore, der die Cardecky-Endpoints kennt. Marketplace-Moderation +/// + Self-Endpoints + AI-Generation sind in `CardsAPI+Marketplace.swift` +/// und `CardsAPI+Generation.swift` ausgelagert. actor CardsAPI { - private let transport: AuthenticatedTransport - private let decoder: JSONDecoder + let transport: AuthenticatedTransport + let decoder: JSONDecoder init(auth: AuthClient) { transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth) @@ -97,7 +102,7 @@ actor CardsAPI { var items: [URLQueryItem] = [ .init(name: "sort", value: sort.rawValue), .init(name: "limit", value: "\(limit)"), - .init(name: "offset", value: "\(offset)"), + .init(name: "offset", value: "\(offset)") ] if let query, !query.trimmingCharacters(in: .whitespaces).isEmpty { items.append(.init(name: "q", value: query)) @@ -218,6 +223,87 @@ actor CardsAPI { try ensureOK(http, data: data) } + /// `POST /api/v1/decks/:id/duplicate` — Server-seitige Kopie mit + /// "(Kopie)"-Suffix, ohne FSRS-Verlauf, ohne Marketplace-Pointer. + @discardableResult + func duplicateDeck(id: String) async throws -> Deck { + let (data, http) = try await transport.request( + path: "/api/v1/decks/\(id)/duplicate", + method: "POST" + ) + try ensureOK(http, data: data) + return try decoder.decode(Deck.self, from: data) + } + + /// `POST /api/v1/marketplace/private/:deckId/pull-update` — Smart-Merge- + /// Pull. Holt neue/geänderte Karten aus der jüngsten Marketplace-Version + /// in das geforkte private Deck. Removed-Karten bleiben lokal (User- + /// Choice gewinnt). 422 wenn das Deck kein Fork ist. + func pullUpdate(deckId: String) async throws -> PullUpdateResponse { + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/private/\(deckId)/pull-update", + method: "POST" + ) + try ensureOK(http, data: data) + return try decoder.decode(PullUpdateResponse.self, from: data) + } + + // MARK: - Marketplace-Publish + + /// `POST /api/v1/marketplace/authors/me` — Author-Profil upserten. + /// Pflicht-Schritt vor dem ersten Deck-Init im Marketplace. + func upsertAuthor(_ body: AuthorUpsertBody) async throws { + let payload = try makeJSON(body) + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/authors/me", + method: "POST", + body: payload + ) + try ensureOK(http, data: data) + } + + /// `GET /api/v1/marketplace/authors/me` — eigenes Author-Profil + /// lesen, gibt `nil` zurück wenn noch keins existiert. + func myAuthor() async throws -> Bool { + let (data, http) = try await transport.request(path: "/api/v1/marketplace/authors/me") + try ensureOK(http, data: data) + // Server liefert entweder Author-Objekt oder JSON-null. + if let raw = try? JSONSerialization.jsonObject(with: data), raw is NSNull { + return false + } + return true + } + + /// `POST /api/v1/marketplace/decks` — Marketplace-Deck-Init. + /// Erstellt nur Metadaten; Karten folgen via `publishMarketplaceVersion`. + @discardableResult + func initMarketplaceDeck(_ body: MarketplaceDeckInitBody) async throws -> PublicDeck { + let payload = try makeJSON(body) + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/decks", + method: "POST", + body: payload + ) + try ensureOK(http, data: data) + return try decoder.decode(PublicDeck.self, from: data) + } + + /// `POST /api/v1/marketplace/decks/:slug/publish` — neue Version + /// publishen. Karten werden serverseitig durch AI-Moderation geschickt. + func publishMarketplaceVersion( + slug: String, + body: MarketplacePublishBody + ) async throws -> MarketplacePublishResponse { + let payload = try makeJSON(body) + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/decks/\(slug)/publish", + method: "POST", + body: payload + ) + try ensureOK(http, data: data) + return try decoder.decode(MarketplacePublishResponse.self, from: data) + } + // MARK: - Card-Mutations /// `POST /api/v1/cards` — Karte anlegen. Server validiert `fields` @@ -293,37 +379,15 @@ actor CardsAPI { // MARK: - JSON-Encoding - private func makeJSON(_ value: T) throws -> Data { + func makeJSON(_ value: some Encodable) throws -> Data { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 return try encoder.encode(value) } - // MARK: - Multipart - - private func makeMultipartBody( - file: Data, - filename: String, - mimeType: String, - boundary: String - ) -> Data { - var body = Data() - let lineBreak = "\r\n" - let header = """ - --\(boundary)\(lineBreak)\ - Content-Disposition: form-data; name="file"; filename="\(filename)"\(lineBreak)\ - Content-Type: \(mimeType)\(lineBreak)\(lineBreak) - """ - body.append(header.data(using: .utf8) ?? Data()) - body.append(file) - body.append(lineBreak.data(using: .utf8) ?? Data()) - body.append("--\(boundary)--\(lineBreak)".data(using: .utf8) ?? Data()) - return body - } - // MARK: - Helpers - private func ensureOK(_ http: HTTPURLResponse, data: Data) throws { + func ensureOK(_ http: HTTPURLResponse, data: Data) throws { guard (200 ..< 300).contains(http.statusCode) else { let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error throw AuthError.serverError(status: http.statusCode, code: nil, message: message) @@ -331,6 +395,8 @@ actor CardsAPI { } } +// swiftlint:enable type_body_length + private struct CardsServerError: Decodable { let error: String? } diff --git a/Sources/Core/Domain/CSVParser.swift b/Sources/Core/Domain/CSVParser.swift new file mode 100644 index 0000000..3f44cb7 --- /dev/null +++ b/Sources/Core/Domain/CSVParser.swift @@ -0,0 +1,164 @@ +import Foundation + +/// CSV-Zeile aus dem Import-Flow. `type` ist optional — fehlt es, +/// wird `.basic` angenommen. +struct CSVRow: Equatable { + let front: String + let back: String + let type: CardType + + init(front: String, back: String, type: CardType = .basic) { + self.front = front + self.back = back + self.type = type + } +} + +/// Pragmatischer CSV-Parser für den Cards-Import. Format pro Zeile: +/// +/// ,[,] +/// +/// - Quote-Escape via `""` (RFC-4180). +/// - Felder dürfen Kommas und Newlines enthalten, wenn sie in `"…"` +/// gekapselt sind. +/// - Header-Row wird automatisch übersprungen, wenn Front/Back beide +/// wie Header-Tokens aussehen (`front`, `back`, `question`, `answer`, +/// `vorderseite`, `rückseite` …). +/// - BOM (`\u{FEFF}`) am Anfang wird gestrippt. +/// - `type` darf jede Cardecky-Type-Bezeichnung sein; unbekannte Werte +/// landen als `.basic`. +enum CSVParser { + enum ParseError: LocalizedError { + case empty + case noValidRows + + var errorDescription: String? { + switch self { + case .empty: "Datei ist leer." + case .noValidRows: "Keine gültigen Zeilen gefunden — erwartet ‚vorne,hinten[,typ]'." + } + } + } + + static func parse(_ rawText: String) throws -> [CSVRow] { + var text = rawText + if text.hasPrefix("\u{FEFF}") { + text.removeFirst() + } + if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw ParseError.empty + } + + let allRows = parseFields(text) + guard !allRows.isEmpty else { throw ParseError.noValidRows } + + // Header-Detection: erste Zeile droppen wenn beide Felder Headerwords sind. + let headerTokens: Set = [ + "front", "back", "question", "answer", + "vorderseite", "rückseite", "rueckseite", "frage", "antwort" + ] + var rows = allRows + if let first = rows.first, + first.count >= 2, + headerTokens.contains(first[0].lowercased()), + headerTokens.contains(first[1].lowercased()) + { + rows.removeFirst() + } + + let parsed: [CSVRow] = rows.compactMap { fields in + guard fields.count >= 2 else { return nil } + let front = fields[0].trimmingCharacters(in: .whitespacesAndNewlines) + let back = fields[1].trimmingCharacters(in: .whitespacesAndNewlines) + if front.isEmpty, back.isEmpty { return nil } + let type: CardType = fields.count >= 3 + ? CardType(rawValue: fields[2].trimmingCharacters(in: .whitespacesAndNewlines)) ?? .basic + : .basic + return CSVRow(front: front, back: back, type: type) + } + + if parsed.isEmpty { + throw ParseError.noValidRows + } + return parsed + } + + /// Parser-State-Machine: liest Zeichen-für-Zeichen, beachtet Quote- + /// Modus für Kommas/Newlines innerhalb von `"…"`-Feldern. `""` wird + /// als wörtliches `"` im Feld behandelt. + private static func parseFields(_ text: String) -> [[String]] { + var state = ParseState() + var iterator = text.makeIterator() + + while let char = iterator.next() { + if state.inQuotes { + handleQuotedChar(char, iterator: &iterator, state: &state) + } else if char == "\"", state.currentField.isEmpty { + state.inQuotes = true + } else { + handleUnquotedChar(char, state: &state) + } + } + + // Tail-Flush — letzte Zeile ohne abschließendes Newline. + if !state.currentField.isEmpty || !state.currentRow.isEmpty { + state.currentRow.append(state.currentField) + state.rows.append(state.currentRow) + } + + return state.rows + } + + /// Mutable State der Parse-Machine — als `inout`-Struct in die + /// Char-Handler durchgereicht, damit die Parameter-Listen kompakt + /// bleiben. + fileprivate struct ParseState { + var rows: [[String]] = [] + var currentRow: [String] = [] + var currentField = "" + var inQuotes = false + } + + /// Im Quote-Modus: `"` schließt das Feld oder escaped sich selbst, + /// alles andere ist Inhalt. + private static func handleQuotedChar( + _ char: Character, + iterator: inout String.Iterator, + state: inout ParseState + ) { + guard char == "\"" else { + state.currentField.append(char) + return + } + if let next = iterator.next(), next == "\"" { + state.currentField.append("\"") + return + } + state.inQuotes = false + // Das Zeichen nach dem End-Quote ist ein Separator (Komma/Newline + // /EOF) — über den Unquoted-Handler routen. + if let next = iterator.next() { + handleUnquotedChar(next, state: &state) + } + } + + private static func handleUnquotedChar(_ char: Character, state: inout ParseState) { + switch char { + case ",": + state.currentRow.append(state.currentField) + state.currentField = "" + case "\n": + state.currentRow.append(state.currentField) + state.rows.append(state.currentRow) + state.currentField = "" + state.currentRow = [] + case "\r": + // CRLF: `\r` schluken, `\n` macht den Row-Break. + break + case "\"" where state.currentField.isEmpty: + state.inQuotes = true + default: + state.currentField.append(char) + } + } +} diff --git a/Sources/Core/Domain/DeckGeneration.swift b/Sources/Core/Domain/DeckGeneration.swift new file mode 100644 index 0000000..1b4bc78 --- /dev/null +++ b/Sources/Core/Domain/DeckGeneration.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Body für `POST /api/v1/decks/generate` — AI-Text-Generierung. +/// Aus `cards/apps/api/src/routes/decks-generate.ts:GenerateInputSchema`. +struct DeckGenerateBody: Encodable { + let prompt: String + let language: GenerationLanguage + let count: Int + let url: String? +} + +/// Sprache für AI-Deck-Generierung. Server akzeptiert `de` oder `en`. +enum GenerationLanguage: String, Codable, CaseIterable { + case de + case en + + var label: String { + switch self { + case .de: "Deutsch" + case .en: "English" + } + } +} + +/// Eine hochzuladende Datei für `POST /api/v1/decks/from-image`. +/// Wird als multipart-`file`-Part gesendet. +struct GenerationMediaFile: Identifiable { + let id: UUID + let data: Data + let filename: String + let mimeType: String + + init(id: UUID = UUID(), data: Data, filename: String, mimeType: String) { + self.id = id + self.data = data + self.filename = filename + self.mimeType = mimeType + } + + /// `application/pdf` → PDF-Dokument, sonst Bild. + var isPDF: Bool { + mimeType == "application/pdf" + } + + /// Größen-Label für die UI ("3.2 MB"). + var sizeLabel: String { + ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file) + } +} + +/// Response von beiden AI-Generate-Endpoints (`/decks/generate` und +/// `/decks/from-image`). Beide rufen serverseitig `insertGeneratedDeck` +/// und liefern dieselbe Shape. +struct DeckGenerateResponse: Decodable { + let deck: Deck + let cardsCreated: Int + + enum CodingKeys: String, CodingKey { + case deck + case cardsCreated = "cards_created" + } +} diff --git a/Sources/Core/Domain/DeckMutations.swift b/Sources/Core/Domain/DeckMutations.swift index c40d62f..6faccd7 100644 --- a/Sources/Core/Domain/DeckMutations.swift +++ b/Sources/Core/Domain/DeckMutations.swift @@ -2,7 +2,7 @@ import Foundation /// Body für `POST /api/v1/decks`. Aus `DeckCreateSchema` in /// `cards/packages/cards-domain/src/schemas/deck.ts`. -struct DeckCreateBody: Encodable, Sendable { +struct DeckCreateBody: Encodable { let name: String let description: String? let color: String? @@ -19,7 +19,7 @@ struct DeckCreateBody: Encodable, Sendable { } /// Body für `PATCH /api/v1/decks/:id`. Alle Felder optional plus `archived`. -struct DeckUpdateBody: Encodable, Sendable { +struct DeckUpdateBody: Encodable { var name: String? var description: String? var color: String? @@ -36,3 +36,37 @@ struct DeckUpdateBody: Encodable, Sendable { case archived } } + +/// Kurze Marketplace-Version-Info: Semver + Version-ID. +struct PullUpdateVersion: Decodable { + let semver: String + let versionId: String + + enum CodingKeys: String, CodingKey { + case semver + case versionId = "version_id" + } +} + +/// Response von `POST /api/v1/marketplace/private/:deckId/pull-update`. +/// `up_to_date == true` heißt: keine neue Marketplace-Version verfügbar, +/// die anderen Counts sind dann 0. +struct PullUpdateResponse: Decodable { + let upToDate: Bool + let from: PullUpdateVersion? + let to: PullUpdateVersion? + let added: Int + let changed: Int + let removed: Int + let cardsInserted: Int? + + enum CodingKeys: String, CodingKey { + case upToDate = "up_to_date" + case from + case to + case added + case changed + case removed + case cardsInserted = "cards_inserted" + } +} diff --git a/Sources/Core/Domain/MarketplaceModeration.swift b/Sources/Core/Domain/MarketplaceModeration.swift new file mode 100644 index 0000000..e4972ec --- /dev/null +++ b/Sources/Core/Domain/MarketplaceModeration.swift @@ -0,0 +1,122 @@ +import Foundation + +/// Kategorien für Deck-Reports — entspricht serverseitig +/// `report_category` Enum in `marketplace.deck_reports`. +enum ReportCategory: String, Codable, CaseIterable { + case spam + case copyright + case nsfw + case misinformation + case hate + case other + + var label: String { + switch self { + case .spam: "Spam" + case .copyright: "Urheberrecht" + case .nsfw: "Anstößige Inhalte" + case .misinformation: "Falschinformation" + case .hate: "Hass / Diskriminierung" + case .other: "Sonstiges" + } + } +} + +/// Body für `POST /api/v1/marketplace/decks/:slug/report`. +struct ReportDeckBody: Encodable { + let category: ReportCategory + let body: String? + let versionId: String? + let cardContentHash: String? + + enum CodingKeys: String, CodingKey { + case category + case body + case versionId + case cardContentHash + } +} + +/// Antwort vom Report-Endpoint. +struct ReportDeckResponse: Decodable { + let ok: Bool + let alreadyReported: Bool + let reportId: String? + + enum CodingKeys: String, CodingKey { + case ok + case alreadyReported = "already_reported" + case reportId = "report_id" + } +} + +/// Eintrag aus `GET /api/v1/marketplace/me/blocks`. +struct BlockEntry: Decodable, Identifiable { + let authorSlug: String + let displayName: String + let blockedAt: Date + + var id: String { + authorSlug + } + + enum CodingKeys: String, CodingKey { + case authorSlug = "author_slug" + case displayName = "display_name" + case blockedAt = "blocked_at" + } +} + +struct BlockListResponse: Decodable { + let blocks: [BlockEntry] +} + +/// Aktuelle Version eines `OwnedMarketplaceDeck` — semver + Karten-Count. +struct OwnedMarketplaceVersion: Decodable { + let versionId: String + let semver: String + let cardCount: Int + let publishedAt: Date? + + enum CodingKeys: String, CodingKey { + case versionId = "version_id" + case semver + case cardCount = "card_count" + case publishedAt = "published_at" + } +} + +/// Eintrag aus `GET /api/v1/marketplace/me/decks` — Re-Publish-Flow. +struct OwnedMarketplaceDeck: Decodable, Identifiable { + let slug: String + let title: String + let description: String? + let language: String? + let category: String? + let license: String + let priceCredits: Int + let isTakedown: Bool + let createdAt: Date + let latestVersion: OwnedMarketplaceVersion? + + var id: String { + slug + } + + enum CodingKeys: String, CodingKey { + case slug + case title + case description + case language + case category + case license + case priceCredits = "price_credits" + case isTakedown = "is_takedown" + case createdAt = "created_at" + case latestVersion = "latest_version" + } +} + +struct OwnedMarketplaceDecksResponse: Decodable { + let decks: [OwnedMarketplaceDeck] +} diff --git a/Sources/Core/Domain/MarketplacePublish.swift b/Sources/Core/Domain/MarketplacePublish.swift new file mode 100644 index 0000000..a5323c3 --- /dev/null +++ b/Sources/Core/Domain/MarketplacePublish.swift @@ -0,0 +1,116 @@ +import Foundation + +/// Body für `POST /api/v1/marketplace/authors/me` — Upsert des +/// Author-Profils. Pflicht-Schritt vor dem ersten Deck-Init im +/// Marketplace. +struct AuthorUpsertBody: Encodable { + let slug: String + let displayName: String + let bio: String? + let avatarUrl: String? + let pseudonym: Bool? + + enum CodingKeys: String, CodingKey { + case slug + case displayName + case bio + case avatarUrl + case pseudonym + } +} + +/// Body für `POST /api/v1/marketplace/decks` — Deck-Init. +/// Erstellt nur die Metadaten; Karten kommen mit der ersten `publish`. +struct MarketplaceDeckInitBody: Encodable { + let slug: String + let title: String + let description: String? + let language: String? + let license: String? + let priceCredits: Int? + let category: DeckCategory? + + enum CodingKeys: String, CodingKey { + case slug + case title + case description + case language + case license + case priceCredits + case category + } +} + +/// Eine Card-Payload-Zeile für `POST /:slug/publish`. Andere Type- +/// Namen als bei privaten Karten — der Server nutzt `'type-in'` statt +/// `'typing'` und `'audio'` statt `'audio-front'`. +struct MarketplacePublishCard: Encodable { + let type: String + let fields: [String: String] +} + +/// Body für `POST /api/v1/marketplace/decks/:slug/publish`. +struct MarketplacePublishBody: Encodable { + let semver: String + let changelog: String? + let cards: [MarketplacePublishCard] +} + +/// Antwort von `POST /:slug/publish`. Enthält das aktualisierte Deck, +/// die neue Version und das AI-Moderation-Verdict. +struct MarketplacePublishResponse: Decodable { + let deck: PublicDeck + let version: PublicDeckVersion + let moderation: ModerationResult + + struct ModerationResult: Decodable { + let verdict: String + let categories: [String]? + let model: String? + } +} + +/// Liste von Cardecky-Marketplace-Lizenzen. Server akzeptiert beliebige +/// Strings ≤ 60 Zeichen — wir bieten die kanonischen vier. +enum MarketplaceLicense: String, CaseIterable { + case personalUse = "Cardecky-Personal-Use-1.0" + case shareAlike = "Cardecky-Share-Alike-1.0" + case attribution = "Cardecky-Attribution-1.0" + case proOnly = "Cardecky-Pro-Only-1.0" + + var label: String { + switch self { + case .personalUse: "Persönlicher Gebrauch" + case .shareAlike: "Share-Alike (CC-BY-SA-Stil)" + case .attribution: "Namensnennung (CC-BY-Stil)" + case .proOnly: "Nur für Cardecky-Pro (Bezahl-Decks)" + } + } +} + +/// Konvertiert eine private `Card` in eine `MarketplacePublishCard` +/// mit dem korrekten Marketplace-Type und Feld-Mapping. Liefert `nil`, +/// wenn der Type im Marketplace nicht unterstützt wird (z.B. Image- +/// Occlusion und Audio-Front brauchen Media-Re-Uploads, das gibt es +/// im Marketplace-Publish-Flow heute nicht). +enum MarketplaceCardConverter { + static func convert(_ card: Card) -> MarketplacePublishCard? { + switch card.type { + case .basic, .basicReverse, .cloze, .multipleChoice: + return MarketplacePublishCard(type: card.type.rawValue, fields: card.fields) + case .typing: + // typing → 'type-in' mit umgeschlüsselten Feldern. + let front = card.fields["front"] ?? "" + let answer = card.fields["answer"] ?? "" + return MarketplacePublishCard( + type: "type-in", + fields: ["question": front, "expected": answer] + ) + case .imageOcclusion, .audioFront: + // Media-Refs zeigen auf user-private Media-IDs — Marketplace- + // User können die nicht laden. Skip bis Server-seitig ein + // Media-Publish-Flow existiert. + return nil + } + } +} diff --git a/Sources/Features/Decks/DeckCoverTile.swift b/Sources/Features/Decks/DeckCoverTile.swift new file mode 100644 index 0000000..77aa5bd --- /dev/null +++ b/Sources/Features/Decks/DeckCoverTile.swift @@ -0,0 +1,122 @@ +import SwiftUI + +/// Gemeinsame Karten-Tile mit Fan-Stack-Hintergrund-Layern. +/// Basis für `DeckStackTile` (eigene Decks) und `PublicDeckCard` +/// (Marketplace-Decks). Web-Vorbild: +/// `cards/apps/web/src/lib/components/DeckStack.svelte` und +/// `MarketplaceDeckStack.svelte` — selbe Größe, selbes Stack-Visual, +/// nur der Footer variiert. +struct DeckCoverTile: View { + let title: String + let description: String? + let category: DeckCategory? + let seed: String + let colorAccentHex: String? + let isFeatured: Bool + @ViewBuilder let footer: () -> Footer + + init( + title: String, + description: String? = nil, + category: DeckCategory? = nil, + seed: String, + colorAccentHex: String? = nil, + isFeatured: Bool = false, + @ViewBuilder footer: @escaping () -> Footer + ) { + self.title = title + self.description = description + self.category = category + self.seed = seed + self.colorAccentHex = colorAccentHex + self.isFeatured = isFeatured + self.footer = footer + } + + var body: some View { + ZStack { + ForEach(Array(layers.enumerated()), id: \.offset) { _, layer in + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(CardsTheme.surface) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(CardsTheme.border, lineWidth: 1) + ) + .opacity(layer.opacity) + .rotationEffect(.degrees(layer.tilt)) + .offset(x: layer.dx, y: layer.dy) + .shadow(color: CardsTheme.foreground.opacity(0.05), radius: 2, y: 1) + } + + CardSurface(size: .md, elevation: .standard, colorAccentHex: colorAccentHex) { + cardContent + } + } + .aspectRatio(5.0 / 7.0, contentMode: .fit) + .frame(maxWidth: 280) + } + + private var cardContent: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top) { + if isFeatured { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(CardsTheme.warning) + } + Spacer() + Image(systemName: category?.systemImageName ?? "rectangle.stack") + .font(.title2) + .foregroundStyle(CardsTheme.primary.opacity(0.85)) + } + + Spacer(minLength: 0) + + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(CardsTheme.foreground) + .lineLimit(3) + + if let description, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + .lineLimit(2) + } + } + + Spacer(minLength: 0) + + footer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var layers: [DeckCoverStackLayer] { + var hash = UInt64(0) + for byte in seed.utf8 { + hash = hash &* 31 &+ UInt64(byte) + } + return (0 ..< 3).map { index in + let seedHash = hash &+ UInt64(index) &* 17 + let tiltRaw = Double((seedHash >> 8) & 0xFF) / 255.0 - 0.5 + let xRaw = Double((seedHash >> 16) & 0xFF) / 255.0 - 0.5 + let yRaw = Double((seedHash >> 24) & 0xFF) / 255.0 - 0.5 + let depth = Double(index + 1) + return DeckCoverStackLayer( + tilt: tiltRaw * 4.0, + dx: xRaw * 6.0, + dy: depth * 3.0 + yRaw * 2.0, + opacity: 0.7 - depth * 0.18 + ) + } + } +} + +private struct DeckCoverStackLayer { + let tilt: Double + let dx: Double + let dy: Double + let opacity: Double +} diff --git a/Sources/Features/Decks/DeckDetailView.swift b/Sources/Features/Decks/DeckDetailView.swift index d037ea1..f764952 100644 --- a/Sources/Features/Decks/DeckDetailView.swift +++ b/Sources/Features/Decks/DeckDetailView.swift @@ -2,8 +2,16 @@ import ManaCore import SwiftData import SwiftUI +// swiftlint:disable file_length +// swiftlint:disable type_body_length + /// Deck-Detail mit Aktionen + Card-Liste. Wird per Tap auf eine Deck-Row /// aus der DeckListView geöffnet. +/// +/// `type_body_length` ist bewusst übersprungen — Detail-View hostet +/// 5 verschiedene Sheets (Edit, CardCreate, CardEdit, Publish, Print), +/// Confirmation-Dialog + Alerts; aufspalten ginge nur über Multi-State- +/// Plumbing zwischen Parent und Children. struct DeckDetailView: View { let deckId: String @@ -17,11 +25,19 @@ struct DeckDetailView: View { @State private var showDeleteConfirm = false @State private var navigateToStudy = false @State private var deleteError: String? + @State private var editingCard: Card? @State private var cards: [Card] = [] @State private var isLoadingCards = false @State private var cardsError: String? + @State private var isPullingUpdate = false + @State private var isDuplicating = false + @State private var pullAlert: AlertMessage? + @State private var actionError: String? + @State private var showPublishSheet = false + @State private var showPrintSheet = false + init(deckId: String) { self.deckId = deckId _decks = Query(filter: #Predicate { $0.id == deckId }) @@ -53,7 +69,7 @@ struct DeckDetailView: View { } .sheet(isPresented: $showCardEditor) { NavigationStack { - CardEditorView(deckId: deckId) { _ in + CardEditorView(mode: .create(deckId: deckId)) { _ in Task { await refreshAfterEdit() await loadCards() @@ -61,6 +77,36 @@ struct DeckDetailView: View { } } } + .sheet(item: $editingCard) { card in + NavigationStack { + CardEditorView(mode: .edit(card: card)) { _ in + Task { + await refreshAfterEdit() + await loadCards() + editingCard = nil + } + } + } + } + .sheet(isPresented: $showPublishSheet) { + if let deck = decks.first { + NavigationStack { + MarketplacePublishView(privateDeck: deck) { _ in + showPublishSheet = false + } + } + } + } + .sheet(isPresented: $showPrintSheet) { + NavigationStack { + DeckPrintView(deckId: deckId) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Fertig") { showPrintSheet = false } + } + } + } + } .confirmationDialog( "Deck löschen?", isPresented: $showDeleteConfirm, @@ -71,7 +117,12 @@ struct DeckDetailView: View { } Button("Abbrechen", role: .cancel) {} } message: { - Text("Alle Karten und Reviews dieses Decks werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.") + Text( + """ + Alle Karten und Reviews dieses Decks werden ebenfalls \ + gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. + """ + ) } .navigationDestination(isPresented: $navigateToStudy) { if let deck = decks.first { @@ -84,6 +135,21 @@ struct DeckDetailView: View { .refreshable { await loadCards() } + .alert(item: $pullAlert) { alert in + Alert(title: Text(alert.title), message: Text(alert.message), dismissButton: .default(Text("OK"))) + } + .alert( + "Aktion fehlgeschlagen", + isPresented: Binding( + get: { actionError != nil }, + set: { if !$0 { actionError = nil } } + ), + presenting: actionError + ) { _ in + Button("OK") { actionError = nil } + } message: { message in + Text(message) + } } private func content(deck: CachedDeck) -> some View { @@ -136,65 +202,56 @@ struct DeckDetailView: View { private func actions(deck: CachedDeck) -> some View { VStack(spacing: 12) { - Button { - navigateToStudy = true - } label: { - Label("Karten lernen", systemImage: "play.fill") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) - .foregroundStyle(CardsTheme.primaryForeground) - } - .buttonStyle(.plain) - .disabled(deck.dueCount == 0) - - Button { - showCardEditor = true - } label: { - Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) - .foregroundStyle(CardsTheme.foreground) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(CardsTheme.border, lineWidth: 1) - ) - } - .buttonStyle(.plain) - - HStack(spacing: 12) { - Button { - showEditor = true - } label: { - Label("Bearbeiten", systemImage: "pencil") - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) - .foregroundStyle(CardsTheme.foreground) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(CardsTheme.border, lineWidth: 1) - ) - } - .buttonStyle(.plain) - - Button { - showDeleteConfirm = true - } label: { - Label("Löschen", systemImage: "trash") - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) - .foregroundStyle(CardsTheme.error) - } - .buttonStyle(.plain) - } + primaryActions + secondaryActions(deck: deck) } .padding(.horizontal, 16) } @ViewBuilder + private var primaryActions: some View { + Button { + navigateToStudy = true + } label: { + Label("Karten lernen", systemImage: "play.fill") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(CardsTheme.primaryForeground) + } + .buttonStyle(.plain) + .disabled((decks.first?.dueCount ?? 0) == 0) + + Button { + showCardEditor = true + } label: { + Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(CardsTheme.foreground) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(CardsTheme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + private func secondaryActions(deck: CachedDeck) -> some View { + DeckSecondaryActions( + isForkedFromMarketplace: deck.isFromMarketplace, + isPullingUpdate: isPullingUpdate, + isDuplicating: isDuplicating, + onPullUpdate: { Task { await pullUpdate() } }, + onDuplicate: { Task { await duplicate() } }, + onPublish: { showPublishSheet = true }, + onPrint: { showPrintSheet = true }, + onEdit: { showEditor = true }, + onDelete: { showDeleteConfirm = true } + ) + } + private var cardListSection: some View { VStack(alignment: .leading, spacing: 8) { HStack { @@ -233,8 +290,14 @@ struct DeckDetailView: View { } else { LazyVStack(spacing: 8) { ForEach(cards) { card in - CardPreviewRow(card: card) - .padding(.horizontal, 16) + Button { + editingCard = card + } label: { + CardPreviewRow(card: card) + .padding(.horizontal, 16) + } + .buttonStyle(.plain) + .accessibilityHint("Tippen zum Bearbeiten") } } } @@ -246,6 +309,55 @@ struct DeckDetailView: View { await store.refresh() } + private func pullUpdate() async { + isPullingUpdate = true + defer { isPullingUpdate = false } + let api = CardsAPI(auth: auth) + do { + let result = try await api.pullUpdate(deckId: deckId) + pullAlert = formatPullResult(result) + await refreshAfterEdit() + await loadCards() + } catch let error as AuthError { + actionError = error.errorDescription ?? "Update fehlgeschlagen" + } catch { + actionError = error.localizedDescription + } + } + + private func formatPullResult(_ result: PullUpdateResponse) -> AlertMessage { + if result.upToDate { + return AlertMessage( + title: "Schon aktuell", + message: "Es gibt keine neue Marketplace-Version dieses Decks." + ) + } + let inserted = result.cardsInserted ?? 0 + let parts = [ + inserted > 0 ? "\(inserted) Karten hinzugefügt" : nil, + result.changed > 0 ? "\(result.changed) Karten geändert" : nil, + result.removed > 0 ? "\(result.removed) im Marketplace entfernt (lokal behalten)" : nil + ].compactMap(\.self) + let body = parts.isEmpty ? "Update angewendet." : parts.joined(separator: ", ") + let versionText = result.to.map { "Version \($0.semver)" } ?? "Update angewendet" + return AlertMessage(title: versionText, message: body) + } + + private func duplicate() async { + isDuplicating = true + defer { isDuplicating = false } + let api = CardsAPI(auth: auth) + do { + _ = try await api.duplicateDeck(id: deckId) + await refreshAfterEdit() + dismiss() + } catch let error as AuthError { + actionError = error.errorDescription ?? "Duplizieren fehlgeschlagen" + } catch { + actionError = error.localizedDescription + } + } + private func loadCards() async { isLoadingCards = true cardsError = nil @@ -275,6 +387,15 @@ struct DeckDetailView: View { } } +// swiftlint:enable type_body_length + +/// Einfacher Alert-Body — Title + Message für `.alert(item:)`-Trigger. +struct AlertMessage: Identifiable { + let id = UUID() + let title: String + let message: String +} + /// Kompakte Card-Row mit Front-Vorschau und Type-Badge. private struct CardPreviewRow: View { let card: Card @@ -307,15 +428,15 @@ private struct CardPreviewRow: View { private func preview(card: Card) -> String { switch card.type { case .basic, .basicReverse, .typing, .multipleChoice: - return card.fields["front"] ?? "—" + card.fields["front"] ?? "—" case .cloze: - return card.fields["text"] ?? "—" + card.fields["text"] ?? "—" case .imageOcclusion: - return card.fields["note"]?.isEmpty == false + card.fields["note"]?.isEmpty == false ? card.fields["note"]! : "Bild-Verdeckung (\(MaskRegions.count(card.fields["mask_regions"] ?? "")) Masken)" case .audioFront: - return card.fields["back"] ?? "Audio-Karte" + card.fields["back"] ?? "Audio-Karte" } } diff --git a/Sources/Features/Decks/DeckListView.swift b/Sources/Features/Decks/DeckListView.swift index 3b3a302..d2ef285 100644 --- a/Sources/Features/Decks/DeckListView.swift +++ b/Sources/Features/Decks/DeckListView.swift @@ -11,6 +11,8 @@ enum DeckRoute: Hashable { case detail(deckId: String) } +// swiftlint:disable type_body_length + /// Decks-Hauptbildschirm im Cardecky-Look: horizontale Scroll-Reihen /// mit Fan-Stack-Karten-Tiles. Web-Vorbild: /// `cards/apps/web/src/routes/decks/+page.svelte`. @@ -238,7 +240,10 @@ struct DeckListView: View { .foregroundStyle(CardsTheme.foreground) } description: { Text( - "Browse den Marketplace im Entdecken-Tab — kein Konto nötig. Für eigene Decks und Cloud-Sync logge dich ein." + """ + Browse den Marketplace im Entdecken-Tab — kein Konto \ + nötig. Für eigene Decks und Cloud-Sync logge dich ein. + """ ) .foregroundStyle(CardsTheme.mutedForeground) } actions: { @@ -254,7 +259,10 @@ struct DeckListView: View { .foregroundStyle(CardsTheme.foreground) } description: { Text( - "Tippe unten auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab." + """ + Tippe unten auf »+«, um dein erstes Deck zu erstellen, \ + oder browse den Marketplace im Entdecken-Tab. + """ ) .foregroundStyle(CardsTheme.mutedForeground) } @@ -285,3 +293,5 @@ struct DeckListView: View { } } } + +// swiftlint:enable type_body_length diff --git a/Sources/Features/Decks/DeckPrintView.swift b/Sources/Features/Decks/DeckPrintView.swift new file mode 100644 index 0000000..57b7bdf --- /dev/null +++ b/Sources/Features/Decks/DeckPrintView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +#if canImport(SafariServices) && canImport(UIKit) + import SafariServices + import UIKit +#endif + +/// In-App-Browser für die Druck-Ansicht des Decks. Nutzt +/// `SFSafariViewController`, weil iOS dort die Print-Sheet und +/// „In Dateien speichern" → PDF von Haus aus mitbringt — kein eigener +/// PDF-Renderer in der App nötig. +/// +/// Auth-Cookies für `cardecky.mana.how` werden geteilt mit Safari auf +/// dem Gerät; der User muss dort eingeloggt sein, damit die Print- +/// Seite den Deck-Inhalt rendert. +struct DeckPrintView: View { + let deckId: String + + @Environment(\.dismiss) private var dismiss + + private var printURL: URL { + URL(string: "https://cardecky.mana.how/decks/\(deckId)/print")! + } + + var body: some View { + #if canImport(SafariServices) && canImport(UIKit) + SafariViewRepresentable(url: printURL) + .ignoresSafeArea() + #else + VStack(spacing: 16) { + Text("Druck-Ansicht ist nur auf iOS verfügbar.") + .font(.subheadline) + Link("Im Web öffnen", destination: printURL) + Button("Schließen") { dismiss() } + } + .padding(32) + #endif + } +} + +#if canImport(SafariServices) && canImport(UIKit) + private struct SafariViewRepresentable: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context _: Context) -> SFSafariViewController { + let config = SFSafariViewController.Configuration() + config.entersReaderIfAvailable = false + let controller = SFSafariViewController(url: url, configuration: config) + controller.preferredControlTintColor = .systemGreen + controller.dismissButtonStyle = .close + return controller + } + + func updateUIViewController(_: SFSafariViewController, context _: Context) {} + } +#endif diff --git a/Sources/Features/Decks/DeckSecondaryActions.swift b/Sources/Features/Decks/DeckSecondaryActions.swift new file mode 100644 index 0000000..84dfd99 --- /dev/null +++ b/Sources/Features/Decks/DeckSecondaryActions.swift @@ -0,0 +1,138 @@ +import SwiftUI + +/// Sekundär-Action-Buttons unterhalb der Lern-/Karten-hinzufügen-Buttons +/// in `DeckDetailView`. Eigenständige View, damit `DeckDetailView` selbst +/// nicht über die Type-Body-Length-Grenze rutscht und die einzelnen +/// Aktionen einzeln (z.B. via Snapshot-Tests) prüfbar bleiben. +/// +/// Reines Layout — alle Side-Effects laufen über die Callbacks im Parent. +struct DeckSecondaryActions: View { + let isForkedFromMarketplace: Bool + let isPullingUpdate: Bool + let isDuplicating: Bool + let onPullUpdate: () -> Void + let onDuplicate: () -> Void + let onPublish: () -> Void + let onPrint: () -> Void + let onEdit: () -> Void + let onDelete: () -> Void + + var body: some View { + if isForkedFromMarketplace { + updateButton + } else { + publishButton + } + duplicateButton + printButton + editDeleteRow + } + + private var printButton: some View { + Button(action: onPrint) { + HStack { + Image(systemName: "printer") + Text("Druck-Ansicht / PDF") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(CardsTheme.foreground) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(CardsTheme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + private var publishButton: some View { + Button(action: onPublish) { + HStack { + Image(systemName: "globe.badge.chevron.backward") + Text("Im Marketplace veröffentlichen") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(CardsTheme.primary) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(CardsTheme.primary.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + private var updateButton: some View { + Button(action: onPullUpdate) { + HStack { + if isPullingUpdate { + ProgressView().tint(CardsTheme.primary) + } else { + Image(systemName: "arrow.triangle.2.circlepath") + } + Text(isPullingUpdate ? "Wird geprüft …" : "Updates aus Marketplace prüfen") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(CardsTheme.primary) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(CardsTheme.primary.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(isPullingUpdate) + } + + private var duplicateButton: some View { + Button(action: onDuplicate) { + HStack { + if isDuplicating { + ProgressView().tint(CardsTheme.foreground) + } else { + Image(systemName: "doc.on.doc") + } + Text(isDuplicating ? "Wird dupliziert …" : "Deck duplizieren") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(CardsTheme.foreground) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(CardsTheme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(isDuplicating) + } + + private var editDeleteRow: some View { + HStack(spacing: 12) { + Button(action: onEdit) { + Label("Bearbeiten", systemImage: "pencil") + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(CardsTheme.foreground) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(CardsTheme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + + Button(action: onDelete) { + Label("Löschen", systemImage: "trash") + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(CardsTheme.error) + } + .buttonStyle(.plain) + } + } +} diff --git a/Sources/Features/Editor/CSVImportFormSections.swift b/Sources/Features/Editor/CSVImportFormSections.swift new file mode 100644 index 0000000..3ce4d0e --- /dev/null +++ b/Sources/Features/Editor/CSVImportFormSections.swift @@ -0,0 +1,82 @@ +import SwiftUI + +/// CSV-Import-Form für den `.csv`-Sub-Modus in `DeckEditorView`. Zeigt +/// File-Picker-Button, Deck-Namens-Feld und eine Preview-Liste der +/// erkannten Karten. +/// +/// State (Datei-Picker-Bool, geparste Rows, Deck-Name) lebt im Parent — +/// dieser View arbeitet nur über `@Binding`. +struct CSVImportFormSections: View { + @Binding var rows: [CSVRow] + @Binding var deckName: String + @Binding var showImporter: Bool + + var body: some View { + Section { + Button { + showImporter = true + } label: { + Label(rows.isEmpty ? "CSV-Datei wählen" : "Andere Datei wählen", systemImage: "doc.text") + } + } header: { + Text("Datei") + } footer: { + Text("Format pro Zeile: vorne,hinten,typ. Typ-Spalte optional (Default basic).") + } + + if !rows.isEmpty { + Section("Deck-Name") { + TextField("Deck-Name", text: $deckName) + .textInputAutocapitalization(.sentences) + } + + Section { + preview + } header: { + Text("Vorschau (\(rows.count) Karten)") + } footer: { + Text("Image-Occlusion und Audio-Cards werden im CSV-Import übersprungen — die brauchen Datei-Uploads.") + } + } + } + + @ViewBuilder + private var preview: some View { + let visible = rows.prefix(8) + ForEach(Array(visible.enumerated()), id: \.offset) { _, row in + VStack(alignment: .leading, spacing: 4) { + Text(row.front) + .font(.subheadline) + .lineLimit(2) + .foregroundStyle(CardsTheme.foreground) + Text(row.back) + .font(.caption) + .lineLimit(2) + .foregroundStyle(CardsTheme.mutedForeground) + if row.type != .basic { + Text(typeLabel(row.type)) + .font(.caption2) + .foregroundStyle(CardsTheme.primary) + } + } + .padding(.vertical, 2) + } + if rows.count > visible.count { + Text("… und \(rows.count - visible.count) weitere") + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + } + } + + private func typeLabel(_ type: CardType) -> String { + switch type { + case .basic: "Einfach" + case .basicReverse: "Beidseitig" + case .cloze: "Lückentext" + case .typing: "Eintippen" + case .multipleChoice: "Multiple Choice" + case .imageOcclusion: "Bild-Verdeckung (übersprungen)" + case .audioFront: "Audio (übersprungen)" + } + } +} diff --git a/Sources/Features/Editor/CardEditorMediaFields.swift b/Sources/Features/Editor/CardEditorMediaFields.swift new file mode 100644 index 0000000..77cd3b8 --- /dev/null +++ b/Sources/Features/Editor/CardEditorMediaFields.swift @@ -0,0 +1,173 @@ +import ManaCore +import PhotosUI +import SwiftUI + +/// Bild + Masken-Editor + Hinweis-Feld + Status für `image-occlusion`- +/// Cards. Owned-State: `imagePickerItem` (PhotosPicker-Bridge). Alles +/// andere lebt im Parent als `@State` und kommt hier als `@Binding` an. +/// +/// Beim Mount im Edit-Modus wird das bestehende Bild via `MediaCache` +/// nachgeladen, damit der User die existierenden Masken sieht. +struct ImageOcclusionFields: View { + @Binding var image: PlatformImage? + @Binding var imageData: Data? + @Binding var mimeType: String + @Binding var regions: [MaskRegion] + @Binding var note: String + @Binding var existingImageRef: String? + let onLoadError: (String) -> Void + + @Environment(\.mediaCache) private var mediaCache + @State private var pickerItem: PhotosPickerItem? + + var body: some View { + Section("Bild") { + PhotosPicker(selection: $pickerItem, matching: .images) { + ImagePickerLabel(hasImage: image != nil) + } + .onChange(of: pickerItem) { _, newItem in + Task { await loadPickedImage(newItem) } + } + } + + if let image { + Section("Masken") { + MaskEditorView(image: image, regions: $regions) + } + } + + Section("Hinweis (optional)") { + TextField("z.B. Kurz-Erklärung", text: $note, axis: .vertical) + .lineLimit(1 ... 3) + } + + Section { + statusLabel + } + .task(id: existingImageRef) { + await loadExistingImageIfNeeded() + } + } + + @ViewBuilder + private var statusLabel: some View { + if image == nil { + Label("Erst Bild wählen", systemImage: "info.circle") + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + } else if regions.isEmpty { + Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle") + .font(.caption) + .foregroundStyle(CardsTheme.warning) + } else { + Label( + "\(regions.count) Masken → \(regions.count) Reviews", + systemImage: "checkmark.circle.fill" + ) + .font(.caption) + .foregroundStyle(CardsTheme.success) + } + } + + private func loadExistingImageIfNeeded() async { + guard + image == nil, + let ref = existingImageRef, + let cache = mediaCache + else { return } + do { + let data = try await cache.data(for: ref) + if let img = PlatformImage(data: data) { + image = img + } + } catch { + onLoadError("Bestehendes Bild konnte nicht geladen werden: \(error.localizedDescription)") + } + } + + private func loadPickedImage(_ item: PhotosPickerItem?) async { + guard let item else { return } + do { + guard let data = try await item.loadTransferable(type: Data.self) else { return } + imageData = data + mimeType = inferImageMimeType(from: data) + if let img = PlatformImage(data: data) { + image = img + regions = [] // neue Bildauswahl resetet Masken + existingImageRef = nil // bestehender Ref wird ersetzt + } + } catch { + onLoadError("Bild konnte nicht geladen werden: \(error.localizedDescription)") + } + } + + private func inferImageMimeType(from data: Data) -> String { + guard data.count > 4 else { return "image/jpeg" } + let bytes = Array(data.prefix(8)) + if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" } + if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" } + if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" } + if bytes.count >= 4, bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] { return "image/webp" } + return "image/jpeg" + } +} + +/// Datei-Picker + Antwort-Feld für `audio-front`-Cards. Owned-State: +/// `showAudioPicker`. URL und Antwort kommen als `@Binding` aus dem +/// Parent. +struct AudioFrontFields: View { + @Binding var audioFileURL: URL? + @Binding var back: String + let existingAudioRef: String? + + @State private var showPicker = false + + var body: some View { + Section("Audio-Datei") { + Button { + showPicker = true + } label: { + pickerLabel + } + .fileImporter( + isPresented: $showPicker, + allowedContentTypes: [.audio, .mp3, .wav, .mpeg4Audio], + allowsMultipleSelection: false + ) { result in + if case let .success(urls) = result, let first = urls.first { + audioFileURL = first + } + } + } + Section("Antwort") { + TextField("Was zu hören ist", text: $back, axis: .vertical) + .lineLimit(2 ... 4) + } + } + + @ViewBuilder + private var pickerLabel: some View { + if let audioFileURL { + Label(audioFileURL.lastPathComponent, systemImage: "waveform") + } else if existingAudioRef != nil { + Label("Audio ersetzen", systemImage: "waveform.badge.plus") + } else { + Label("Audio auswählen", systemImage: "waveform.badge.plus") + } + } +} + +/// PhotosPicker-Label als eigene View, damit Swift-6-Strict-Concurrency +/// nicht über den `@Sendable`-Closure meckert (View-Konstruktor-Calls +/// werden zur Build-Zeit MainActor-isoliert evaluiert). +struct ImagePickerLabel: View { + let hasImage: Bool + + var body: some View { + if hasImage { + Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath") + } else { + Label("Bild auswählen", systemImage: "photo") + } + } +} diff --git a/Sources/Features/Editor/CardEditorPayload.swift b/Sources/Features/Editor/CardEditorPayload.swift new file mode 100644 index 0000000..989de04 --- /dev/null +++ b/Sources/Features/Editor/CardEditorPayload.swift @@ -0,0 +1,149 @@ +import Foundation +import ManaCore + +/// Resultat von `CardEditorPayload.build` — was an `CardsAPI.createCard` +/// oder `updateCard` durchgereicht wird. +struct CardEditorPayload { + let fields: [String: String] + let mediaRefs: [String]? +} + +/// Snapshot der CardEditor-Felder zum Submit-Zeitpunkt. Ein Wert-Typ, +/// damit `buildPayload` außerhalb der View testbar ist und der View- +/// Struct kompakt bleibt. +struct CardEditorPayloadInputs { + let type: CardType + let front: String + let back: String + let clozeText: String + let typingAnswer: String + let multipleChoiceAnswer: String + let occlusionImageData: Data? + let occlusionMimeType: String + let occlusionRegions: [MaskRegion] + let occlusionNote: String + let existingImageRef: String? + let audioFileURL: URL? + let existingAudioRef: String? + let existingMediaRefs: [String] +} + +enum CardEditorPayloadError: LocalizedError { + case missingImage + case missingAudio + + var errorDescription: String? { + switch self { + case .missingImage: "Bitte ein Bild wählen." + case .missingAudio: "Bitte eine Audio-Datei wählen." + } + } +} + +enum CardEditorPayloadBuilder { + /// Baut den Payload für `POST /cards` bzw. `PATCH /cards/:id`. + /// Lädt für Image-Occlusion / Audio-Front bei Bedarf neue Media + /// hoch; sonst wird der bestehende `*_ref` aus der Card weiterverwendet. + static func build(inputs: CardEditorPayloadInputs, api: CardsAPI) async throws -> CardEditorPayload { + switch inputs.type { + case .basic, .basicReverse: + CardEditorPayload( + fields: CardFieldsBuilder.basic(front: inputs.front, back: inputs.back), + mediaRefs: nil + ) + case .cloze: + CardEditorPayload( + fields: CardFieldsBuilder.cloze(text: inputs.clozeText), + mediaRefs: nil + ) + case .typing: + CardEditorPayload( + fields: CardFieldsBuilder.typing(front: inputs.front, answer: inputs.typingAnswer), + mediaRefs: nil + ) + case .multipleChoice: + CardEditorPayload( + fields: CardFieldsBuilder.multipleChoice( + front: inputs.front, + answer: inputs.multipleChoiceAnswer + ), + mediaRefs: nil + ) + case .imageOcclusion: + try await buildImageOcclusionPayload(inputs: inputs, api: api) + case .audioFront: + try await buildAudioFrontPayload(inputs: inputs, api: api) + } + } + + private static func buildImageOcclusionPayload( + inputs: CardEditorPayloadInputs, + api: CardsAPI + ) async throws -> CardEditorPayload { + let imageRef: String + var refs = inputs.existingMediaRefs + + if let newData = inputs.occlusionImageData { + let media = try await api.uploadMedia( + data: newData, + filename: "occlusion.\(inputs.occlusionMimeType.contains("png") ? "png" : "jpg")", + mimeType: inputs.occlusionMimeType + ) + imageRef = media.id + refs = [media.id] + } else if let ref = inputs.existingImageRef { + imageRef = ref + } else { + throw CardEditorPayloadError.missingImage + } + + return CardEditorPayload( + fields: CardFieldsBuilder.imageOcclusion( + imageRef: imageRef, + regions: inputs.occlusionRegions, + note: inputs.occlusionNote.isEmpty ? nil : inputs.occlusionNote + ), + mediaRefs: refs + ) + } + + private static func buildAudioFrontPayload( + inputs: CardEditorPayloadInputs, + api: CardsAPI + ) async throws -> CardEditorPayload { + let audioRef: String + var refs = inputs.existingMediaRefs + + if let url = inputs.audioFileURL { + let didStart = url.startAccessingSecurityScopedResource() + defer { if didStart { url.stopAccessingSecurityScopedResource() } } + let data = try Data(contentsOf: url) + let media = try await api.uploadMedia( + data: data, + filename: url.lastPathComponent, + mimeType: audioMimeType(for: url) + ) + audioRef = media.id + refs = [media.id] + } else if let ref = inputs.existingAudioRef { + audioRef = ref + } else { + throw CardEditorPayloadError.missingAudio + } + + return CardEditorPayload( + fields: CardFieldsBuilder.audioFront(audioRef: audioRef, back: inputs.back), + mediaRefs: refs + ) + } + + private static func audioMimeType(for url: URL) -> String { + switch url.pathExtension.lowercased() { + case "mp3": "audio/mpeg" + case "wav": "audio/wav" + case "m4a", "mp4": "audio/mp4" + case "ogg", "oga": "audio/ogg" + default: "audio/mpeg" + } + } +} diff --git a/Sources/Features/Editor/CardEditorView.swift b/Sources/Features/Editor/CardEditorView.swift index 2f2249b..ef2898e 100644 --- a/Sources/Features/Editor/CardEditorView.swift +++ b/Sources/Features/Editor/CardEditorView.swift @@ -1,55 +1,126 @@ import ManaCore -import PhotosUI import SwiftUI #if canImport(UIKit) -import UIKit + import UIKit #endif -/// Card-Create-View. Type-Picker oben, type-spezifische Felder unten. -/// Deckt alle 7 Card-Types ab. +// swiftlint:disable type_body_length + +/// Card-Create und Card-Edit in einer View. +/// +/// - `.create(deckId:)` zeigt Type-Picker + leere Felder. +/// - `.edit(card:)` blendet Type-Picker aus (Server-seitig immutable), +/// pre-fillt alle Felder, und PATCHt auf Submit. +/// +/// Bei Image-Occlusion und Audio-Front im Edit-Modus bleibt der bestehende +/// Media-Ref erhalten, solange der User die Datei nicht explizit ersetzt. struct CardEditorView: View { - let deckId: String - let onCreated: (Card) -> Void + enum Mode { + case create(deckId: String) + case edit(card: Card) + } + + let mode: Mode + let onSaved: (Card) -> Void @Environment(AuthClient.self) private var auth @Environment(\.dismiss) private var dismiss - @State private var type: CardType = .basic - @State private var front: String = "" - @State private var back: String = "" - @State private var clozeText: String = "" - @State private var typingAnswer: String = "" - @State private var multipleChoiceAnswer: String = "" + @State private var type: CardType + @State private var front: String + @State private var back: String + @State private var clozeText: String + @State private var typingAnswer: String + @State private var multipleChoiceAnswer: String @State private var isSubmitting = false @State private var errorMessage: String? // Image-Occlusion-State - @State private var imagePickerItem: PhotosPickerItem? @State private var occlusionImage: PlatformImage? @State private var occlusionImageData: Data? @State private var occlusionMimeType: String = "image/jpeg" - @State private var occlusionRegions: [MaskRegion] = [] - @State private var occlusionNote: String = "" + @State private var occlusionRegions: [MaskRegion] + @State private var occlusionNote: String + /// Bestehender `image_ref` aus der Card im Edit-Modus. Bleibt erhalten, + /// solange der User kein neues Bild wählt. + @State private var existingImageRef: String? - // Audio-Front-State + /// Audio-Front-State @State private var audioFileURL: URL? - @State private var showAudioPicker = false + /// Bestehender `audio_ref` aus der Card im Edit-Modus. + @State private var existingAudioRef: String? private static let supportedTypes: [CardType] = [ .basic, .basicReverse, .cloze, .typing, .multipleChoice, - .imageOcclusion, .audioFront, + .imageOcclusion, .audioFront ] + init(mode: Mode, onSaved: @escaping (Card) -> Void) { + self.mode = mode + self.onSaved = onSaved + + let initialType: CardType + var initialFront = "" + var initialBack = "" + var initialCloze = "" + var initialTyping = "" + var initialMC = "" + var initialRegions: [MaskRegion] = [] + var initialNote = "" + var initialImageRef: String? + var initialAudioRef: String? + + switch mode { + case .create: + initialType = .basic + case let .edit(card): + initialType = card.type + switch card.type { + case .basic, .basicReverse: + initialFront = card.fields["front"] ?? "" + initialBack = card.fields["back"] ?? "" + case .cloze: + initialCloze = card.fields["text"] ?? "" + case .typing: + initialFront = card.fields["front"] ?? "" + initialTyping = card.fields["answer"] ?? "" + case .multipleChoice: + initialFront = card.fields["front"] ?? "" + initialMC = card.fields["answer"] ?? "" + case .imageOcclusion: + initialRegions = MaskRegions.parse(card.fields["mask_regions"] ?? "[]") + initialNote = card.fields["note"] ?? "" + initialImageRef = card.fields["image_ref"] + case .audioFront: + initialBack = card.fields["back"] ?? "" + initialAudioRef = card.fields["audio_ref"] + } + } + + _type = State(initialValue: initialType) + _front = State(initialValue: initialFront) + _back = State(initialValue: initialBack) + _clozeText = State(initialValue: initialCloze) + _typingAnswer = State(initialValue: initialTyping) + _multipleChoiceAnswer = State(initialValue: initialMC) + _occlusionRegions = State(initialValue: initialRegions) + _occlusionNote = State(initialValue: initialNote) + _existingImageRef = State(initialValue: initialImageRef) + _existingAudioRef = State(initialValue: initialAudioRef) + } + var body: some View { Form { - Section("Card-Type") { - Picker("Typ", selection: $type) { - ForEach(Self.supportedTypes, id: \.self) { t in - Text(label(for: t)).tag(t) + if isCreate { + Section("Card-Type") { + Picker("Typ", selection: $type) { + ForEach(Self.supportedTypes, id: \.self) { cardType in + Text(label(for: cardType)).tag(cardType) + } } + .pickerStyle(.menu) } - .pickerStyle(.menu) } typeFields @@ -62,7 +133,8 @@ struct CardEditorView: View { } } } - .navigationTitle("Neue Karte") + .disabled(isSubmitting) + .navigationTitle(isCreate ? "Neue Karte" : "Karte bearbeiten") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif @@ -71,8 +143,10 @@ struct CardEditorView: View { Button("Abbrechen") { dismiss() } } ToolbarItem(placement: .confirmationAction) { - Button("Erstellen") { Task { await submit() } } - .disabled(!canSubmit || isSubmitting) + Button(isCreate ? "Erstellen" : "Speichern") { + Task { await submit() } + } + .disabled(!canSubmit || isSubmitting) } } } @@ -99,12 +173,15 @@ struct CardEditorView: View { case .cloze: Section("Cloze-Text") { - TextField("Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.", - text: $clozeText, axis: .vertical) - .lineLimit(3 ... 8) - .autocorrectionDisabled() - .textInputAutocapitalization(.sentences) - .monospaced() + TextField( + "Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.", + text: $clozeText, + axis: .vertical + ) + .lineLimit(3 ... 8) + .autocorrectionDisabled() + .textInputAutocapitalization(.sentences) + .monospaced() } Section { let count = Cloze.subIndexCount(clozeText) @@ -146,119 +223,40 @@ struct CardEditorView: View { } case .imageOcclusion: - imageOcclusionFields + ImageOcclusionFields( + image: $occlusionImage, + imageData: $occlusionImageData, + mimeType: $occlusionMimeType, + regions: $occlusionRegions, + note: $occlusionNote, + existingImageRef: $existingImageRef, + onLoadError: { errorMessage = $0 } + ) case .audioFront: - audioFrontFields + AudioFrontFields( + audioFileURL: $audioFileURL, + back: $back, + existingAudioRef: existingAudioRef + ) } } - @ViewBuilder - private var imageOcclusionFields: some View { - Section("Bild") { - PhotosPicker(selection: $imagePickerItem, matching: .images) { - ImagePickerLabel(hasImage: occlusionImage != nil) - } - .onChange(of: imagePickerItem) { _, newItem in - Task { await loadPickedImage(newItem) } - } - } + private var isCreate: Bool { + if case .create = mode { return true } + return false + } - if let image = occlusionImage { - Section("Masken") { - MaskEditorView(image: image, regions: $occlusionRegions) - } - } - - Section("Hinweis (optional)") { - TextField("z.B. Kurz-Erklärung", text: $occlusionNote, axis: .vertical) - .lineLimit(1 ... 3) - } - - Section { - if occlusionImage == nil { - Label("Erst Bild wählen", systemImage: "info.circle") - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - } else if occlusionRegions.isEmpty { - Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle") - .font(.caption) - .foregroundStyle(CardsTheme.warning) - } else { - Label("\(occlusionRegions.count) Masken → \(occlusionRegions.count) Reviews", - systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(CardsTheme.success) - } + private var deckId: String { + switch mode { + case let .create(deckId): deckId + case let .edit(card): card.deckId } } - @ViewBuilder - private var audioFrontFields: some View { - Section("Audio-Datei") { - Button { - showAudioPicker = true - } label: { - if let audioFileURL { - Label(audioFileURL.lastPathComponent, systemImage: "waveform") - } else { - Label("Audio auswählen", systemImage: "waveform.badge.plus") - } - } - .fileImporter( - isPresented: $showAudioPicker, - allowedContentTypes: [.audio, .mp3, .wav, .mpeg4Audio], - allowsMultipleSelection: false - ) { result in - if case let .success(urls) = result, let first = urls.first { - audioFileURL = first - } - } - } - Section("Antwort") { - TextField("Was zu hören ist", text: $back, axis: .vertical) - .lineLimit(2 ... 4) - } - } - - private func loadPickedImage(_ item: PhotosPickerItem?) async { - guard let item else { return } - do { - guard let data = try await item.loadTransferable(type: Data.self) else { return } - occlusionImageData = data - occlusionMimeType = inferMimeType(from: data) - if let img = PlatformImage(data: data) { - occlusionImage = img - occlusionRegions = [] // neue Bildauswahl resetet Masken - } - } catch { - errorMessage = "Bild konnte nicht geladen werden: \(error.localizedDescription)" - } - } - - private func inferMimeType(from data: Data) -> String { - // Schneller Magic-Byte-Check für die häufigsten Formate - guard data.count > 4 else { return "image/jpeg" } - let bytes = Array(data.prefix(8)) - if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" } - if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" } - if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" } - // WebP: starts with "RIFF" + 4 bytes size + "WEBP" - if bytes.count >= 8, - bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] { - return "image/webp" - } - return "image/jpeg" - } - - private func audioMimeType(for url: URL) -> String { - switch url.pathExtension.lowercased() { - case "mp3": "audio/mpeg" - case "wav": "audio/wav" - case "m4a", "mp4": "audio/mp4" - case "ogg", "oga": "audio/ogg" - default: "audio/mpeg" - } + private var existingMediaRefs: [String] { + if case let .edit(card) = mode { return card.mediaRefs } + return [] } private var canSubmit: Bool { @@ -272,12 +270,14 @@ struct CardEditorView: View { case .multipleChoice: !front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty case .imageOcclusion: - occlusionImageData != nil && !occlusionRegions.isEmpty + (occlusionImageData != nil || existingImageRef != nil) && !occlusionRegions.isEmpty case .audioFront: - audioFileURL != nil && !back.trimmed.isEmpty + (audioFileURL != nil || existingAudioRef != nil) && !back.trimmed.isEmpty } } + // MARK: - Submit + private func submit() async { isSubmitting = true errorMessage = nil @@ -285,53 +285,47 @@ struct CardEditorView: View { let api = CardsAPI(auth: auth) do { - let fields: [String: String] - var mediaRefs: [String]? = nil - switch type { - case .basic, .basicReverse: - fields = CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed) - case .cloze: - fields = CardFieldsBuilder.cloze(text: clozeText.trimmed) - case .typing: - fields = CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed) - case .multipleChoice: - fields = CardFieldsBuilder.multipleChoice(front: front.trimmed, answer: multipleChoiceAnswer.trimmed) - case .imageOcclusion: - guard let data = occlusionImageData else { return } - let media = try await api.uploadMedia( - data: data, - filename: "occlusion.\(occlusionMimeType.contains("png") ? "png" : "jpg")", - mimeType: occlusionMimeType - ) - fields = CardFieldsBuilder.imageOcclusion( - imageRef: media.id, - regions: occlusionRegions, - note: occlusionNote.trimmed.isEmpty ? nil : occlusionNote.trimmed - ) - mediaRefs = [media.id] - case .audioFront: - guard let url = audioFileURL else { return } - let didStart = url.startAccessingSecurityScopedResource() - defer { if didStart { url.stopAccessingSecurityScopedResource() } } - let data = try Data(contentsOf: url) - let media = try await api.uploadMedia( - data: data, - filename: url.lastPathComponent, - mimeType: audioMimeType(for: url) - ) - fields = CardFieldsBuilder.audioFront(audioRef: media.id, back: back.trimmed) - mediaRefs = [media.id] + let payload = try await CardEditorPayloadBuilder.build(inputs: payloadInputs, api: api) + let card: Card = switch mode { + case let .create(deckId): + try await api.createCard(CardCreateBody( + deckId: deckId, + type: type, + fields: payload.fields, + mediaRefs: payload.mediaRefs + )) + case let .edit(existing): + try await api.updateCard(id: existing.id, body: CardUpdateBody( + fields: payload.fields, + mediaRefs: payload.mediaRefs + )) } - - let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: mediaRefs) - let card = try await api.createCard(body) - onCreated(card) + onSaved(card) dismiss() } catch { errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) } } + private var payloadInputs: CardEditorPayloadInputs { + CardEditorPayloadInputs( + type: type, + front: front.trimmed, + back: back.trimmed, + clozeText: clozeText.trimmed, + typingAnswer: typingAnswer.trimmed, + multipleChoiceAnswer: multipleChoiceAnswer.trimmed, + occlusionImageData: occlusionImageData, + occlusionMimeType: occlusionMimeType, + occlusionRegions: occlusionRegions, + occlusionNote: occlusionNote.trimmed, + existingImageRef: existingImageRef, + audioFileURL: audioFileURL, + existingAudioRef: existingAudioRef, + existingMediaRefs: existingMediaRefs + ) + } + private func label(for type: CardType) -> String { switch type { case .basic: "Einfach (Vorder/Rück)" @@ -345,25 +339,10 @@ struct CardEditorView: View { } } +// swiftlint:enable type_body_length + private extension String { var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } } - -/// Wird als Sub-View aus dem PhotosPicker-Label-Closure aufgerufen. -/// Eigene `View`-Struct vermeidet die Swift-6-Strict-Concurrency- -/// Warning: SwiftUIs `PhotosPicker(label:)`-Closure ist `@Sendable`, -/// aber View-Konstruktor-Calls werden zur Build-Zeit MainActor-isoliert -/// evaluiert (im Gegensatz zu direktem @State-Zugriff im Closure-Body). -private struct ImagePickerLabel: View { - let hasImage: Bool - - var body: some View { - if hasImage { - Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath") - } else { - Label("Bild auswählen", systemImage: "photo") - } - } -} diff --git a/Sources/Features/Editor/DeckEditorHelpers.swift b/Sources/Features/Editor/DeckEditorHelpers.swift new file mode 100644 index 0000000..f3123d4 --- /dev/null +++ b/Sources/Features/Editor/DeckEditorHelpers.swift @@ -0,0 +1,82 @@ +import Foundation +import ManaCore + +/// Konstanten für `DeckEditorView` — Farbpalette, File-Limits. +/// Werte gespiegelt aus `forest`-Theme und Server-Limits in +/// `cards/apps/api/src/routes/decks-from-image.ts`. +enum DeckEditorPresets { + /// 8 Farb-Presets aus dem forest-Theme. Freie Hex-Werte später + /// via Custom-Picker (β-3-extension). + static let colors: [String] = [ + "#10803D", // forest primary light + "#1E3A2F", // forest dark + "#D97706", // amber + "#DC2626", // red + "#2563EB", // blue + "#7C3AED", // violet + "#0D9488", // teal + "#737373" // neutral + ] + + static let maxMediaFiles = 5 + static let maxImageBytes = 10 * 1024 * 1024 + static let maxPDFBytes = 30 * 1024 * 1024 +} + +/// Reine Hilfsfunktionen für `DeckEditorView` — kein State, keine Bindings. +enum DeckEditorHelpers { + /// Nil zurück wenn String nach Trim leer ist. + static func nonEmpty(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? nil : trimmed + } + + /// http:// oder https:// und nicht-leer. + static func isValidURL(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return false } + guard let url = URL(string: trimmed), let scheme = url.scheme else { return false } + return scheme == "http" || scheme == "https" + } + + /// Magic-Byte-Check für die häufigsten Image-Formate. Fallback JPEG. + static func inferImageMimeType(from data: Data) -> String { + guard data.count > 4 else { return "image/jpeg" } + let bytes = Array(data.prefix(8)) + if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" } + if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" } + if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" } + if bytes.count >= 4, bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] { return "image/webp" } + return "image/jpeg" + } + + /// Dateiendung für ein erkanntes Image-MIME. + static func imageExtension(forMime mime: String) -> String { + switch mime { + case "image/png": "png" + case "image/gif": "gif" + case "image/webp": "webp" + default: "jpg" + } + } + + /// AuthError-Server-Codes auf nutzerfreundliche deutsche Texte mappen. + /// Greift für beide AI-Endpoints, fällt sonst auf `errorDescription`. + static func mapAIError(_ error: AuthError) -> String { + if case let .serverError(status, _, message) = error { + switch status { + case 429: + return "Zu viele KI-Anfragen. Bitte eine Minute warten." + case 413: + return message ?? "Datei zu groß." + case 422, 400: + return message ?? "Eingabe ungültig." + case 502: + return message ?? "KI-Server gerade nicht erreichbar." + default: + break + } + } + return error.errorDescription ?? "Unbekannter Fehler." + } +} diff --git a/Sources/Features/Editor/DeckEditorView.swift b/Sources/Features/Editor/DeckEditorView.swift index 6be1022..16eb230 100644 --- a/Sources/Features/Editor/DeckEditorView.swift +++ b/Sources/Features/Editor/DeckEditorView.swift @@ -1,125 +1,530 @@ import ManaCore +import PhotosUI import SwiftUI -/// Deck-Create und Deck-Edit in einer View. `existing == nil` → Create- -/// Modus mit "Erstellen"-Button. Sonst Edit-Modus mit "Speichern". +// swiftlint:disable file_length +// swiftlint:disable type_body_length + +/// Deck-Create und Deck-Edit in einer View. Im Create-Modus stehen vier +/// Sub-Modi zur Wahl: manuell („Leer"), AI-Text („Mit KI"), AI-Vision +/// („Aus Bild") und CSV. Edit-Modus zeigt nur das manuelle Formular. +/// +/// Web-Vorbild: `cards/apps/web/src/routes/decks/new/+page.svelte`. +/// `type_body_length` ist bewusst übersprungen — die 4 Sub-Modi teilen +/// sich State + Toolbar; aufspalten ginge nur über @Binding-Plumbing. struct DeckEditorView: View { - enum Mode: Sendable { + enum Mode { case create case edit(deckId: String) } + /// Vier Sub-Modi im Create-Sheet. + enum CreateMode: Hashable { + case manual + case aiText + case aiMedia + case csv + } + let mode: Mode let onSaved: (Deck) -> Void @Environment(AuthClient.self) private var auth @Environment(\.dismiss) private var dismiss + // Manual fields (Edit + Create.manual) @State private var name: String @State private var description: String @State private var color: String @State private var category: DeckCategory? @State private var visibility: DeckVisibility - @State private var isSubmitting = false - @State private var errorMessage: String? + @State private var archived: Bool - /// Vorgefüllte Farbpalette aus dem forest-Theme. User können - /// freie Hex-Werte später via Picker setzen (β-3-extension). - private static let presetColors: [String] = [ - "#10803D", // forest primary light - "#1E3A2F", // forest dark - "#D97706", // amber - "#DC2626", // red - "#2563EB", // blue - "#7C3AED", // violet - "#0D9488", // teal - "#737373", // neutral - ] + /// Create-mode selector + @State private var createMode: CreateMode = .manual + + // AI-shared (Text + Media) + @State private var aiPrompt: String = "" + @State private var aiCount: Int = 15 + @State private var aiLanguage: GenerationLanguage = .de + @State private var aiUrl: String = "" + + // AI-Media + @State private var aiMediaFiles: [GenerationMediaFile] = [] + @State private var aiPhotoItems: [PhotosPickerItem] = [] + @State private var showPDFImporter: Bool = false + + // CSV-Import + @State private var csvRows: [CSVRow] = [] + @State private var csvDeckName: String = "" + @State private var showCSVImporter: Bool = false + @State private var csvImportProgress: Int = 0 + + // Submission + @State private var isSubmitting = false + @State private var generationTask: Task? + @State private var errorMessage: String? init(mode: Mode, existing: CachedDeck? = nil, onSaved: @escaping (Deck) -> Void) { self.mode = mode self.onSaved = onSaved _name = State(initialValue: existing?.name ?? "") _description = State(initialValue: existing?.deckDescription ?? "") - _color = State(initialValue: existing?.color ?? Self.presetColors[0]) + _color = State(initialValue: existing?.color ?? DeckEditorPresets.colors[0]) _category = State(initialValue: existing?.category) _visibility = State(initialValue: DeckVisibility(rawValue: existing?.visibilityRaw ?? "private") ?? .private) + _archived = State(initialValue: existing?.archivedAt != nil) } var body: some View { - Form { - Section("Name") { - TextField("Deck-Name", text: $name) - .textInputAutocapitalization(.sentences) - } - - Section("Beschreibung") { - TextField("optional", text: $description, axis: .vertical) - .lineLimit(2 ... 4) - } - - Section("Farbe") { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - ForEach(Self.presetColors, id: \.self) { hex in - colorSwatch(hex) - } - } - .padding(.vertical, 4) + ZStack { + Form { + if isCreate { + modePickerSection } + formSections + errorSection } + .disabled(isSubmitting) - Section("Kategorie") { - Picker("Kategorie", selection: $category) { - Text("Keine").tag(DeckCategory?.none) - ForEach(DeckCategory.allCases, id: \.self) { cat in - Text(cat.label).tag(DeckCategory?.some(cat)) - } - } - } - - Section("Sichtbarkeit") { - Picker("Sichtbarkeit", selection: $visibility) { - Text("Privat").tag(DeckVisibility.private) - Text("Space").tag(DeckVisibility.space) - Text("Öffentlich").tag(DeckVisibility.public) - } - .pickerStyle(.segmented) - } - - if let errorMessage { - Section { - Text(errorMessage) - .font(.footnote) - .foregroundStyle(CardsTheme.error) - } + if isSubmitting, activeMode != .manual { + GenerationOverlay( + message: overlayMessage, + onCancel: { generationTask?.cancel() } + ) } } - .navigationTitle(isCreate ? "Neues Deck" : "Deck bearbeiten") + .navigationTitle(navTitle) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Abbrechen") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button(isCreate ? "Erstellen" : "Speichern") { - Task { await submit() } - } - .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || isSubmitting) - } + .toolbar { toolbar } + .onChange(of: aiPhotoItems) { _, items in + guard !items.isEmpty else { return } + Task { await ingestPhotoItems(items) } } + .fileImporter( + isPresented: $showPDFImporter, + allowedContentTypes: [.pdf], + allowsMultipleSelection: true, + onCompletion: handlePDFImport + ) + .fileImporter( + isPresented: $showCSVImporter, + allowedContentTypes: [.commaSeparatedText, .plainText], + allowsMultipleSelection: false, + onCompletion: handleCSVImport + ) } + // MARK: - Sections + + private var modePickerSection: some View { + Section { + Picker("Modus", selection: $createMode) { + Text("Leer").tag(CreateMode.manual) + Text("KI").tag(CreateMode.aiText) + Text("Bild").tag(CreateMode.aiMedia) + Text("CSV").tag(CreateMode.csv) + } + .pickerStyle(.segmented) + } footer: { + modeFooter + } + } + + @ViewBuilder + private var modeFooter: some View { + switch createMode { + case .manual: + Text("Leeres Deck — Karten anschließend selbst anlegen.") + case .aiText: + Text("KI generiert das Deck aus einer kurzen Beschreibung. 10 Anfragen pro Minute.") + case .aiMedia: + Text("KI liest Bilder oder PDFs und macht daraus Karten. Bis zu 5 Dateien.") + case .csv: + Text("CSV-Datei einlesen. Format: vorne,hinten[,typ] pro Zeile.") + } + } + + @ViewBuilder + private var formSections: some View { + switch activeMode { + case .manual: + ManualFormSections( + name: $name, + description: $description, + color: $color, + category: $category, + visibility: $visibility, + archived: isCreate ? nil : $archived + ) + case .aiText: + AITextFormSections(prompt: $aiPrompt) + AISharedSections(count: $aiCount, language: $aiLanguage, url: $aiUrl) + case .aiMedia: + AIMediaFormSections( + files: $aiMediaFiles, + photoItems: $aiPhotoItems, + showPDFImporter: $showPDFImporter + ) + AISharedSections(count: $aiCount, language: $aiLanguage, url: $aiUrl) + case .csv: + CSVImportFormSections( + rows: $csvRows, + deckName: $csvDeckName, + showImporter: $showCSVImporter + ) + } + } + + @ViewBuilder + private var errorSection: some View { + if let errorMessage { + Section { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(CardsTheme.error) + } + } + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { + generationTask?.cancel() + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(confirmLabel) { + startSubmit() + } + .disabled(!canSubmit || isSubmitting) + } + } + + // MARK: - Computed state + private var isCreate: Bool { if case .create = mode { return true } return false } - @ViewBuilder - private func colorSwatch(_ hex: String) -> some View { - let isSelected = color == hex + private var activeMode: CreateMode { + isCreate ? createMode : .manual + } + + private var navTitle: String { + switch activeMode { + case .manual: isCreate ? "Neues Deck" : "Deck bearbeiten" + case .aiText: "Mit KI generieren" + case .aiMedia: "Aus Bild generieren" + case .csv: "Aus CSV importieren" + } + } + + private var confirmLabel: String { + switch activeMode { + case .manual: isCreate ? "Erstellen" : "Speichern" + case .aiText, .aiMedia: "Generieren" + case .csv: csvRows.isEmpty ? "Importieren" : "\(csvRows.count) Karten importieren" + } + } + + private var canSubmit: Bool { + switch activeMode { + case .manual: + !name.trimmingCharacters(in: .whitespaces).isEmpty + case .aiText: + aiPrompt.trimmingCharacters(in: .whitespaces).count >= 3 + case .aiMedia: + !aiMediaFiles.isEmpty || DeckEditorHelpers.isValidURL(aiUrl) + case .csv: + !csvRows.isEmpty && !csvDeckName.trimmingCharacters(in: .whitespaces).isEmpty + } + } + + private var overlayMessage: String { + switch activeMode { + case .csv: + csvImportProgress > 0 + ? "Karten werden importiert (\(csvImportProgress) / \(csvRows.count)) …" + : "Import wird vorbereitet …" + default: + "Karten werden generiert …" + } + } + + // MARK: - Photo / PDF ingest + + private func ingestPhotoItems(_ items: [PhotosPickerItem]) async { + for item in items { + if aiMediaFiles.count >= DeckEditorPresets.maxMediaFiles { break } + do { + guard let data = try await item.loadTransferable(type: Data.self) else { continue } + guard data.count <= DeckEditorPresets.maxImageBytes else { + errorMessage = "Bild ist größer als 10 MB und wurde übersprungen." + continue + } + let mime = DeckEditorHelpers.inferImageMimeType(from: data) + let ext = DeckEditorHelpers.imageExtension(forMime: mime) + let filename = "image-\(UUID().uuidString.prefix(8)).\(ext)" + aiMediaFiles.append(GenerationMediaFile( + data: data, + filename: filename, + mimeType: mime + )) + } catch { + errorMessage = "Foto konnte nicht geladen werden: \(error.localizedDescription)" + } + } + aiPhotoItems = [] + } + + private func handleCSVImport(_ result: Result<[URL], Error>) { + switch result { + case let .success(urls): + guard let url = urls.first else { return } + let didStart = url.startAccessingSecurityScopedResource() + defer { if didStart { url.stopAccessingSecurityScopedResource() } } + do { + let text = try String(contentsOf: url, encoding: .utf8) + let rows = try CSVParser.parse(text) + csvRows = rows + if csvDeckName.trimmingCharacters(in: .whitespaces).isEmpty { + csvDeckName = url.deletingPathExtension().lastPathComponent + } + } catch { + errorMessage = "CSV-Import fehlgeschlagen: \(error.localizedDescription)" + } + case let .failure(error): + errorMessage = "Datei-Auswahl fehlgeschlagen: \(error.localizedDescription)" + } + } + + private func handlePDFImport(_ result: Result<[URL], Error>) { + switch result { + case let .success(urls): + for url in urls { + if aiMediaFiles.count >= DeckEditorPresets.maxMediaFiles { break } + let didStart = url.startAccessingSecurityScopedResource() + defer { if didStart { url.stopAccessingSecurityScopedResource() } } + do { + let data = try Data(contentsOf: url) + guard data.count <= DeckEditorPresets.maxPDFBytes else { + errorMessage = "\(url.lastPathComponent) ist größer als 30 MB." + continue + } + aiMediaFiles.append(GenerationMediaFile( + data: data, + filename: url.lastPathComponent, + mimeType: "application/pdf" + )) + } catch { + errorMessage = "PDF konnte nicht gelesen werden: \(error.localizedDescription)" + } + } + case let .failure(error): + errorMessage = "PDF-Auswahl fehlgeschlagen: \(error.localizedDescription)" + } + } + + // MARK: - Submit + + private func startSubmit() { + errorMessage = nil + isSubmitting = true + generationTask = Task { + await submit() + isSubmitting = false + generationTask = nil + } + } + + private func submit() async { + let api = CardsAPI(auth: auth) + do { + switch (mode, activeMode) { + case (.create, .manual): + let deck = try await api.createDeck(manualCreateBody) + onSaved(deck) + dismiss() + case let (.edit(deckId), _): + let deck = try await api.updateDeck(id: deckId, body: manualUpdateBody) + onSaved(deck) + dismiss() + case (.create, .aiText): + let response = try await api.generateDeckFromText(aiTextBody) + try Task.checkCancellation() + onSaved(response.deck) + dismiss() + case (.create, .aiMedia): + let response = try await api.generateDeckFromMedia( + files: aiMediaFiles, + language: aiLanguage, + count: aiCount, + url: DeckEditorHelpers.nonEmpty(aiUrl) + ) + try Task.checkCancellation() + onSaved(response.deck) + dismiss() + case (.create, .csv): + let deck = try await submitCSVImport(api: api) + onSaved(deck) + dismiss() + } + } catch is CancellationError { + // User-Abbruch → kein Banner. + } catch let error as AuthError { + errorMessage = DeckEditorHelpers.mapAIError(error) + } catch { + errorMessage = error.localizedDescription + } + } + + private var manualCreateBody: DeckCreateBody { + DeckCreateBody( + name: name.trimmingCharacters(in: .whitespaces), + description: DeckEditorHelpers.nonEmpty(description), + color: color, + category: category, + visibility: visibility + ) + } + + private var manualUpdateBody: DeckUpdateBody { + DeckUpdateBody( + name: name.trimmingCharacters(in: .whitespaces), + description: DeckEditorHelpers.nonEmpty(description), + color: color, + category: category, + visibility: visibility, + archived: archived + ) + } + + private func submitCSVImport(api: CardsAPI) async throws -> Deck { + let deck = try await api.createDeck(DeckCreateBody( + name: csvDeckName.trimmingCharacters(in: .whitespaces), + description: "Aus CSV-Import (\(csvRows.count) Karten)", + color: color, + category: category, + visibility: visibility + )) + csvImportProgress = 0 + for (index, row) in csvRows.enumerated() { + try Task.checkCancellation() + let fields: [String: String] + switch row.type { + case .basic, .basicReverse: + fields = CardFieldsBuilder.basic(front: row.front, back: row.back) + case .cloze: + fields = CardFieldsBuilder.cloze(text: row.front) + case .typing: + fields = CardFieldsBuilder.typing(front: row.front, answer: row.back) + case .multipleChoice: + fields = CardFieldsBuilder.multipleChoice(front: row.front, answer: row.back) + case .imageOcclusion, .audioFront: + // Media-Types brauchen Uploads — überspringe in CSV-Import. + csvImportProgress = index + 1 + continue + } + _ = try await api.createCard(CardCreateBody( + deckId: deck.id, + type: row.type, + fields: fields, + mediaRefs: nil + )) + csvImportProgress = index + 1 + } + return deck + } + + private var aiTextBody: DeckGenerateBody { + DeckGenerateBody( + prompt: aiPrompt.trimmingCharacters(in: .whitespaces), + language: aiLanguage, + count: aiCount, + url: DeckEditorHelpers.nonEmpty(aiUrl) + ) + } +} + +// swiftlint:enable type_body_length + +// MARK: - Manual form + +private struct ManualFormSections: View { + @Binding var name: String + @Binding var description: String + @Binding var color: String + @Binding var category: DeckCategory? + @Binding var visibility: DeckVisibility + /// `nil` im Create-Modus — dann wird der Toggle nicht gezeigt. + var archived: Binding? + + var body: some View { + Section("Name") { + TextField("Deck-Name", text: $name) + .textInputAutocapitalization(.sentences) + } + + Section("Beschreibung") { + TextField("optional", text: $description, axis: .vertical) + .lineLimit(2 ... 4) + } + + Section("Farbe") { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(DeckEditorPresets.colors, id: \.self) { hex in + ColorSwatchButton(hex: hex, isSelected: color == hex) { + color = hex + } + } + } + .padding(.vertical, 4) + } + } + + Section("Kategorie") { + Picker("Kategorie", selection: $category) { + Text("Keine").tag(DeckCategory?.none) + ForEach(DeckCategory.allCases, id: \.self) { cat in + Text(cat.label).tag(DeckCategory?.some(cat)) + } + } + } + + Section("Sichtbarkeit") { + Picker("Sichtbarkeit", selection: $visibility) { + Text("Privat").tag(DeckVisibility.private) + Text("Space").tag(DeckVisibility.space) + Text("Öffentlich").tag(DeckVisibility.public) + } + .pickerStyle(.segmented) + } + + if let archived { + Section { + Toggle("Archiviert", isOn: archived) + } footer: { + Text("Archivierte Decks erscheinen nicht in der Hauptliste. Bestehende FSRS-Reviews bleiben erhalten.") + } + } + } +} + +private struct ColorSwatchButton: View { + let hex: String + let isSelected: Bool + let onTap: () -> Void + + var body: some View { Circle() .fill(Color.swatchFromHex(hex)) .frame(width: 36, height: 36) @@ -127,51 +532,200 @@ struct DeckEditorView: View { Circle() .stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1) ) - .onTapGesture { color = hex } + .onTapGesture(perform: onTap) } +} - private func submit() async { - isSubmitting = true - errorMessage = nil - defer { isSubmitting = false } - let api = CardsAPI(auth: auth) +// MARK: - AI text form - do { - switch mode { - case .create: - let body = DeckCreateBody( - name: name.trimmingCharacters(in: .whitespaces), - description: nonEmpty(description), - color: color, - category: category, - visibility: visibility - ) - let deck = try await api.createDeck(body) - onSaved(deck) - dismiss() - case let .edit(deckId): - let body = DeckUpdateBody( - name: name.trimmingCharacters(in: .whitespaces), - description: nonEmpty(description), - color: color, - category: category, - visibility: visibility - ) - let deck = try await api.updateDeck(id: deckId, body: body) - onSaved(deck) - dismiss() +private struct AITextFormSections: View { + @Binding var prompt: String + + var body: some View { + Section { + TextField( + "z.B. Bodensee-Geographie, französische Verben", + text: $prompt, + axis: .vertical + ) + .lineLimit(3 ... 6) + .textInputAutocapitalization(.sentences) + } header: { + Text("Thema") + } footer: { + Text("3–500 Zeichen. Je präziser, desto besser die Karten.") + } + } +} + +// MARK: - AI media form + +private struct AIMediaFormSections: View { + @Binding var files: [GenerationMediaFile] + @Binding var photoItems: [PhotosPickerItem] + @Binding var showPDFImporter: Bool + + var body: some View { + Section { + mediaPickers + ForEach(files) { file in + MediaFileRow(file: file) { + files.removeAll { $0.id == file.id } + } } - } catch { - errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } header: { + Text("Quellen") + } footer: { + Text("Max. \(DeckEditorPresets.maxMediaFiles) Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB.") } } - private func nonEmpty(_ s: String) -> String? { - let trimmed = s.trimmingCharacters(in: .whitespaces) - return trimmed.isEmpty ? nil : trimmed + @ViewBuilder + private var mediaPickers: some View { + let remaining = DeckEditorPresets.maxMediaFiles - files.count + + PhotosPicker( + selection: $photoItems, + maxSelectionCount: max(remaining, 0), + matching: .images + ) { + Label("Fotos hinzufügen", systemImage: "photo.on.rectangle.angled") + } + .disabled(remaining <= 0) + + Button { + showPDFImporter = true + } label: { + Label("PDFs hinzufügen", systemImage: "doc.text") + } + .disabled(remaining <= 0) } } +private struct MediaFileRow: View { + let file: GenerationMediaFile + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 12) { + thumbnail + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + VStack(alignment: .leading, spacing: 2) { + Text(file.filename) + .font(.subheadline) + .lineLimit(1) + Text(file.sizeLabel) + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + } + Spacer() + Button(action: onRemove) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(CardsTheme.mutedForeground) + } + .buttonStyle(.plain) + .accessibilityLabel("Entfernen") + } + } + + @ViewBuilder + private var thumbnail: some View { + if file.isPDF { + ZStack { + CardsTheme.muted + Image(systemName: "doc.text.fill") + .foregroundStyle(CardsTheme.primary) + } + } else if let img = PlatformImage(data: file.data) { + #if canImport(UIKit) + Image(uiImage: img) + .resizable() + .scaledToFill() + #else + Image(nsImage: img) + .resizable() + .scaledToFill() + #endif + } else { + CardsTheme.muted + } + } +} + +// MARK: - Shared AI controls + +private struct AISharedSections: View { + @Binding var count: Int + @Binding var language: GenerationLanguage + @Binding var url: String + + var body: some View { + Section("Anzahl Karten") { + Stepper(value: $count, in: 3 ... 40) { + Text("\(count) Karten") + } + } + + Section("Sprache") { + Picker("Sprache", selection: $language) { + ForEach(GenerationLanguage.allCases, id: \.self) { lang in + Text(lang.label).tag(lang) + } + } + .pickerStyle(.segmented) + } + + Section { + TextField("https://…", text: $url) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.URL) + } header: { + Text("Zusätzliche URL (optional)") + } footer: { + Text("KI liest den Inhalt der Seite als zusätzliche Quelle.") + } + } +} + +// MARK: - Generation overlay + +private struct GenerationOverlay: View { + let message: String + let onCancel: () -> Void + + var body: some View { + ZStack { + Color.black.opacity(0.55) + .ignoresSafeArea() + VStack(spacing: 16) { + ProgressView() + .controlSize(.large) + .tint(CardsTheme.primary) + Text(message) + .font(.headline) + .foregroundStyle(CardsTheme.foreground) + .multilineTextAlignment(.center) + Text("Das kann eine Weile dauern.") + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + .multilineTextAlignment(.center) + Button("Abbrechen", action: onCancel) + .buttonStyle(.bordered) + .tint(CardsTheme.mutedForeground) + .padding(.top, 4) + } + .padding(24) + .frame(maxWidth: 320) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + .transition(.opacity) + } +} + +// MARK: - Color helper + extension Color { static func swatchFromHex(_ hex: String) -> Color { var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines) @@ -179,9 +733,9 @@ extension Color { guard let rgb = UInt32(trimmed, radix: 16) else { return CardsTheme.primary } - let r = Double((rgb >> 16) & 0xFF) / 255.0 - let g = Double((rgb >> 8) & 0xFF) / 255.0 - let b = Double(rgb & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) + let red = Double((rgb >> 16) & 0xFF) / 255.0 + let green = Double((rgb >> 8) & 0xFF) / 255.0 + let blue = Double(rgb & 0xFF) / 255.0 + return Color(red: red, green: green, blue: blue) } } diff --git a/Sources/Features/Marketplace/MarketplacePublishView.swift b/Sources/Features/Marketplace/MarketplacePublishView.swift new file mode 100644 index 0000000..b98f7c6 --- /dev/null +++ b/Sources/Features/Marketplace/MarketplacePublishView.swift @@ -0,0 +1,474 @@ +import ManaCore +import SwiftUI + +// swiftlint:disable file_length +// swiftlint:disable type_body_length + +/// Publish eines privaten Decks in den Cardecky-Marketplace. +/// +/// Modi: Erst-Publish (mit Author-Setup + Init + Publish 1.0.0) oder +/// neue Version eines existierenden Marketplace-Decks (Auto-Semver-Bump). +/// Image-Occlusion- und Audio-Front-Karten werden übersprungen — der +/// Server hat heute keinen Marketplace-Media-Re-Upload-Flow. +/// +/// `type_body_length` ist bewusst übersprungen — Publish-Flow ist eine +/// zusammenhängende State-Maschine (Author → Init → Publish). +struct MarketplacePublishView: View { + enum PublishMode: Hashable { + case firstPublish + case newVersion(slug: String) + } + + let privateDeck: CachedDeck + let onPublished: (MarketplacePublishResponse) -> Void + + @Environment(AuthClient.self) private var auth + @Environment(\.dismiss) private var dismiss + + // Publish-Mode + @State private var publishMode: PublishMode = .firstPublish + @State private var ownedDecks: [OwnedMarketplaceDeck] = [] + @State private var selectedExistingSlug: String? + + // Author-Profil-State + @State private var hasAuthor: Bool? + @State private var authorSlug: String = "" + @State private var authorDisplayName: String = "" + @State private var authorBio: String = "" + @State private var authorPseudonym: Bool = false + + // Deck-Metadaten + @State private var slug: String = "" + @State private var title: String = "" + @State private var deckDescription: String = "" + @State private var language: GenerationLanguage = .de + @State private var license: MarketplaceLicense = .personalUse + @State private var priceCredits: Int = 0 + @State private var category: DeckCategory? + + // Version-Metadaten + @State private var semver: String = "1.0.0" + @State private var changelog: String = "" + + // Submit-State + @State private var isSubmitting = false + @State private var errorMessage: String? + @State private var result: MarketplacePublishResponse? + @State private var skippedCardCount: Int = 0 + + var body: some View { + Form { + if !ownedDecks.isEmpty { + publishModeSection + } + if isFirstPublish, hasAuthor == false { + authorSection + } + if isFirstPublish { + deckMetadataSection + licenseSection + categorySection + } else if let existing = currentExistingDeck { + existingDeckInfoSection(deck: existing) + } + versionSection + if skippedCardCount > 0 { + skippedNoteSection + } + if let errorMessage { + Section { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(CardsTheme.error) + } + } + } + .disabled(isSubmitting) + .navigationTitle("Im Marketplace veröffentlichen") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Veröffentlichen") { Task { await submit() } } + .disabled(!canSubmit || isSubmitting) + } + } + .overlay { + if isSubmitting { + publishProgressOverlay + } + } + .alert(item: $result) { response in + Alert( + title: Text("Veröffentlicht: \(response.deck.title)"), + message: Text(alertMessage(for: response)), + dismissButton: .default(Text("OK")) { + onPublished(response) + dismiss() + } + ) + } + .task { + await prefill() + } + } + + private var isFirstPublish: Bool { + if case .firstPublish = publishMode { return true } + return false + } + + private var currentExistingDeck: OwnedMarketplaceDeck? { + guard let slug = selectedExistingSlug else { return nil } + return ownedDecks.first { $0.slug == slug } + } + + private var publishModeSection: some View { + Section { + Picker("Modus", selection: $publishMode) { + Text("Neues Marketplace-Deck").tag(PublishMode.firstPublish) + ForEach(ownedDecks) { deck in + Text("Neue Version: \(deck.title)") + .tag(PublishMode.newVersion(slug: deck.slug)) + } + } + .pickerStyle(.menu) + .onChange(of: publishMode) { _, newMode in + applyPublishMode(newMode) + } + } header: { + Text("Veröffentlichungs-Modus") + } footer: { + Text("Du hast schon Decks im Marketplace. Wähle eine, um eine neue Version zu publishen.") + } + } + + private func existingDeckInfoSection(deck: OwnedMarketplaceDeck) -> some View { + Section { + LabeledContent("Slug", value: deck.slug) + LabeledContent("Titel", value: deck.title) + if let latest = deck.latestVersion { + LabeledContent("Aktuelle Version", value: "v\(latest.semver) · \(latest.cardCount) Karten") + } else { + LabeledContent("Aktuelle Version", value: "—") + } + } header: { + Text("Bestehendes Deck") + } footer: { + Text("Metadaten ändern: Marketplace-Webansicht → Deck → Bearbeiten.") + } + } + + private var authorSection: some View { + Section { + TextField("Author-Slug (URL)", text: $authorSlug) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + TextField("Anzeigename", text: $authorDisplayName) + TextField("Bio (optional)", text: $authorBio, axis: .vertical) + .lineLimit(2 ... 4) + Toggle("Pseudonym-Modus", isOn: $authorPseudonym) + } header: { + Text("Author-Profil anlegen") + } footer: { + Text("Pflicht-Schritt vor dem ersten Marketplace-Deck. Slug erscheint in Marketplace-URLs.") + } + } + + private var deckMetadataSection: some View { + Section { + TextField("Slug (URL)", text: $slug) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + TextField("Titel", text: $title) + .textInputAutocapitalization(.sentences) + TextField("Beschreibung", text: $deckDescription, axis: .vertical) + .lineLimit(2 ... 6) + Picker("Sprache", selection: $language) { + ForEach(GenerationLanguage.allCases, id: \.self) { lang in + Text(lang.label).tag(lang) + } + } + .pickerStyle(.segmented) + } header: { + Text("Deck-Metadaten") + } footer: { + Text("Der Slug wird Teil der Marketplace-URL: cardecky.mana.how/d/.") + } + } + + private var licenseSection: some View { + Section("Lizenz") { + Picker("Lizenz", selection: $license) { + ForEach(MarketplaceLicense.allCases, id: \.self) { lic in + Text(lic.label).tag(lic) + } + } + if license == .proOnly { + Stepper(value: $priceCredits, in: 0 ... 100_000, step: 10) { + Text("Preis: \(priceCredits) Credits") + } + } + } + } + + private var categorySection: some View { + Section("Kategorie") { + Picker("Kategorie", selection: $category) { + Text("Keine").tag(DeckCategory?.none) + ForEach(DeckCategory.allCases, id: \.self) { cat in + Text(cat.label).tag(DeckCategory?.some(cat)) + } + } + } + } + + private var versionSection: some View { + Section { + TextField("SemVer (z.B. 1.0.0)", text: $semver) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.numbersAndPunctuation) + TextField("Changelog (optional)", text: $changelog, axis: .vertical) + .lineLimit(2 ... 4) + } header: { + Text("Version") + } footer: { + Text("Erst-Publish: 1.0.0. Spätere Versionen müssen semver-größer sein.") + } + } + + private var skippedNoteSection: some View { + Section { + Label( + """ + \(skippedCardCount) Karten werden übersprungen — Bild-\ + Verdeckung und Audio brauchen Marketplace-Media-Upload. + """, + systemImage: "info.circle" + ) + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + } + } + + private var publishProgressOverlay: some View { + ZStack { + Color.black.opacity(0.55).ignoresSafeArea() + VStack(spacing: 12) { + ProgressView().controlSize(.large).tint(CardsTheme.primary) + Text("Wird veröffentlicht …") + .font(.headline) + .foregroundStyle(CardsTheme.foreground) + Text("AI-Moderation läuft — kann ein paar Sekunden dauern.") + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + .multilineTextAlignment(.center) + } + .padding(24) + .frame(maxWidth: 320) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + } + + private var canSubmit: Bool { + let semverOK = semver.range(of: "^\\d+\\.\\d+\\.\\d+$", options: .regularExpression) != nil + guard semverOK else { return false } + switch publishMode { + case .firstPublish: + if hasAuthor == false { + guard authorDisplayName.trimmed.count >= 1 else { return false } + guard authorSlug.trimmed.count >= 3 else { return false } + } + return slug.trimmed.count >= 3 && !title.trimmed.isEmpty + case .newVersion: + return selectedExistingSlug != nil + } + } + + private func prefill() async { + title = privateDeck.name + deckDescription = privateDeck.deckDescription ?? "" + category = privateDeck.category + slug = slugify(privateDeck.name) + let api = CardsAPI(auth: auth) + async let authorState = api.myAuthor() + async let ownedState = api.myMarketplaceDecks() + do { + hasAuthor = try await authorState + } catch { + hasAuthor = false + errorMessage = "Author-Profil konnte nicht geladen werden: \(error.localizedDescription)" + } + ownedDecks = await (try? ownedState) ?? [] + } + + /// State-Übergang beim Wechsel des Publish-Modus. + /// - Erst-Publish: Slug aus dem privaten Deck-Namen, Semver 1.0.0. + /// - Neue Version: Slug-Feld unbenutzt (Server kennt Slug), + /// Semver-Default = Bump der aktuellen Version. + private func applyPublishMode(_ mode: PublishMode) { + switch mode { + case .firstPublish: + selectedExistingSlug = nil + semver = "1.0.0" + case let .newVersion(existingSlug): + selectedExistingSlug = existingSlug + if let latest = ownedDecks.first(where: { $0.slug == existingSlug })?.latestVersion { + semver = bumpMinor(latest.semver) + } else { + semver = "1.0.0" + } + } + } + + /// `1.4.2` → `1.5.0`. Bei unparsbarem Input bleibt 1.0.0 als Default. + private func bumpMinor(_ version: String) -> String { + let parts = version.split(separator: ".") + guard parts.count == 3, + let major = Int(parts[0]), + let minor = Int(parts[1]) + else { return "1.0.0" } + return "\(major).\(minor + 1).0" + } + + private func submit() async { + isSubmitting = true + errorMessage = nil + defer { isSubmitting = false } + let api = CardsAPI(auth: auth) + do { + let targetSlug = try await prepareTargetSlug(api: api) + try await publishCards(toSlug: targetSlug, api: api) + } catch let error as AuthError { + errorMessage = mapPublishError(error) + } catch { + errorMessage = error.localizedDescription + } + } + + /// Erst-Publish-Pfad: Author-Profil + Marketplace-Deck-Init. + /// Liefert den Slug auf den `publishCards` veröffentlicht. + private func prepareTargetSlug(api: CardsAPI) async throws -> String { + switch publishMode { + case .firstPublish: + if hasAuthor == false { + try await api.upsertAuthor(AuthorUpsertBody( + slug: authorSlug.trimmed, + displayName: authorDisplayName.trimmed, + bio: authorBio.trimmed.isEmpty ? nil : authorBio.trimmed, + avatarUrl: nil, + pseudonym: authorPseudonym + )) + hasAuthor = true + } + _ = try await api.initMarketplaceDeck(MarketplaceDeckInitBody( + slug: slug.trimmed, + title: title.trimmed, + description: deckDescription.trimmed.isEmpty ? nil : deckDescription.trimmed, + language: language.rawValue, + license: license.rawValue, + priceCredits: license == .proOnly ? priceCredits : 0, + category: category + )) + return slug.trimmed + case let .newVersion(existingSlug): + return existingSlug + } + } + + /// Lädt alle Karten des privaten Decks, konvertiert in Marketplace- + /// Format und veröffentlicht die neue Version. + private func publishCards(toSlug targetSlug: String, api: CardsAPI) async throws { + let cards = try await api.listCards(deckId: privateDeck.id) + let converted = cards.compactMap(MarketplaceCardConverter.convert) + skippedCardCount = cards.count - converted.count + guard !converted.isEmpty else { + errorMessage = "Keine Karten kompatibel mit dem Marketplace-Format." + return + } + result = try await api.publishMarketplaceVersion( + slug: targetSlug, + body: MarketplacePublishBody( + semver: semver.trimmed, + changelog: changelog.trimmed.isEmpty ? nil : changelog.trimmed, + cards: converted + ) + ) + } + + private func mapPublishError(_ error: AuthError) -> String { + if case let .serverError(status, _, message) = error { + switch status { + case 409: + if let message, message.contains("slug_taken") { + return "Dieser Slug ist schon vergeben. Bitte einen anderen wählen." + } + return message ?? "Konflikt — Version-Bump nötig?" + case 403: + if let message, message.contains("moderation_block") { + return "AI-Moderation hat den Inhalt blockiert." + } + return message ?? "Aktion nicht erlaubt." + case 422: + return message ?? "Eingabe ungültig." + default: + break + } + } + return error.errorDescription ?? "Veröffentlichen fehlgeschlagen." + } + + private func alertMessage(for response: MarketplacePublishResponse) -> String { + let parts = [ + "Version \(response.version.semver)", + "\(response.version.cardCount) Karten", + skippedCardCount > 0 ? "\(skippedCardCount) übersprungen" : nil, + "Moderation: \(response.moderation.verdict)" + ].compactMap(\.self) + return parts.joined(separator: " · ") + } + + private func slugify(_ input: String) -> String { + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-") + let lowered = input + .folding(options: .diacriticInsensitive, locale: .current) + .lowercased() + var result = "" + for scalar in lowered.unicodeScalars { + if allowed.contains(scalar) { + result.unicodeScalars.append(scalar) + } else { + result.append("-") + } + } + while result.hasPrefix("-") { + result.removeFirst() + } + while result.hasSuffix("-") { + result.removeLast() + } + while result.contains("--") { + result = result.replacingOccurrences(of: "--", with: "-") + } + return String(result.prefix(60)) + } +} + +// swiftlint:enable type_body_length + +private extension String { + var trimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +extension MarketplacePublishResponse: Identifiable { + var id: String { + version.id + } +} diff --git a/Sources/Features/Marketplace/PublicDeckView.swift b/Sources/Features/Marketplace/PublicDeckView.swift index 1cf4b54..0dd3bd7 100644 --- a/Sources/Features/Marketplace/PublicDeckView.swift +++ b/Sources/Features/Marketplace/PublicDeckView.swift @@ -1,13 +1,18 @@ +import ManaAuthUI import ManaCore import SwiftData import SwiftUI +// swiftlint:disable type_body_length + /// Detail-View für ein Public-Deck. Subscribe-Button löst Auto-Fork /// serverseitig aus und navigiert anschließend zur eigenen Deck-Detail. +/// Toolbar-Menu („…") hostet Report + Block-Author (App-Review-Pflicht). struct PublicDeckView: View { let slug: String @Environment(AuthClient.self) private var auth + @Environment(ManaAuthGate.self) private var authGate @Environment(\.modelContext) private var context @State private var detail: PublicDeckDetail? @State private var isLoading = false @@ -15,6 +20,11 @@ struct PublicDeckView: View { @State private var errorMessage: String? @State private var subscribed: SubscribeResponse? + // Moderation-State + @State private var showReportSheet = false + @State private var showBlockConfirm = false + @State private var moderationToast: String? + var body: some View { ZStack { CardsTheme.background.ignoresSafeArea() @@ -24,9 +34,69 @@ struct PublicDeckView: View { #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif + .toolbar { + if detail != nil { + ToolbarItem(placement: .topBarTrailing) { + moderationMenu + } + } + } .task(id: slug) { await load() } + .sheet(isPresented: $showReportSheet) { + NavigationStack { + ReportDeckSheet(slug: slug) { message in + moderationToast = message + } + } + } + .confirmationDialog( + "Author blockieren?", + isPresented: $showBlockConfirm, + titleVisibility: .visible, + presenting: detail?.owner + ) { owner in + Button("\(owner.displayName) blockieren", role: .destructive) { + Task { await blockAuthor(slug: owner.slug, name: owner.displayName) } + } + Button("Abbrechen", role: .cancel) {} + } message: { _ in + Text("Decks dieses Authors erscheinen für dich nicht mehr im Marketplace.") + } + .overlay(alignment: .top) { + if let toast = moderationToast { + ToastBanner(text: toast) + .padding(.top, 8) + .task { + try? await Task.sleep(for: .seconds(3)) + moderationToast = nil + } + } + } + } + + private var moderationMenu: some View { + Menu { + Button { + authGate.require(reason: "marketplace-report") { + showReportSheet = true + } + } label: { + Label("Deck melden …", systemImage: "flag") + } + if let owner = detail?.owner { + Button(role: .destructive) { + authGate.require(reason: "marketplace-block") { + showBlockConfirm = true + } + } label: { + Label("\(owner.displayName) blockieren", systemImage: "hand.raised") + } + } + } label: { + Image(systemName: "ellipsis.circle") + } } @ViewBuilder @@ -122,7 +192,6 @@ struct PublicDeckView: View { .padding(.horizontal, 16) } - @ViewBuilder private func subscribeSection(detail: PublicDeckDetail) -> some View { VStack(spacing: 12) { if let subscribed { @@ -147,7 +216,9 @@ struct PublicDeckView: View { .foregroundStyle(CardsTheme.mutedForeground) } else { Button { - Task { await subscribe(detail: detail) } + authGate.require(reason: "marketplace-subscribe") { + Task { await subscribe(detail: detail) } + } } label: { HStack { if isSubscribing { @@ -156,8 +227,8 @@ struct PublicDeckView: View { .tint(CardsTheme.primaryForeground) } Text(detail.deck.priceCredits > 0 - ? "Abonnieren (\(detail.deck.priceCredits) Credits)" - : "Abonnieren") + ? "Abonnieren (\(detail.deck.priceCredits) Credits)" + : "Abonnieren") .fontWeight(.semibold) } .frame(maxWidth: .infinity) @@ -183,7 +254,17 @@ struct PublicDeckView: View { } } - private func subscribe(detail: PublicDeckDetail) async { + private func blockAuthor(slug: String, name: String) async { + let api = CardsAPI(auth: auth) + do { + try await api.blockAuthor(slug: slug) + moderationToast = "\(name) blockiert." + } catch { + moderationToast = "Blockieren fehlgeschlagen: \(error.localizedDescription)" + } + } + + private func subscribe(detail _: PublicDeckDetail) async { isSubscribing = true errorMessage = nil defer { isSubscribing = false } @@ -199,3 +280,5 @@ struct PublicDeckView: View { } } } + +// swiftlint:enable type_body_length diff --git a/Sources/Features/Marketplace/ReportDeckSheet.swift b/Sources/Features/Marketplace/ReportDeckSheet.swift new file mode 100644 index 0000000..58007e1 --- /dev/null +++ b/Sources/Features/Marketplace/ReportDeckSheet.swift @@ -0,0 +1,109 @@ +import ManaCore +import SwiftUI + +/// Report-Form für ein Marketplace-Deck — Pflicht-Komponente nach +/// App-Store-Guideline 5.1.1(v) (Report-Mechanismus für UGC). +/// +/// Owned-State (Kategorie, Message, Submit-Status). Bei Erfolg schließt +/// das Sheet und ruft `onCompleted` mit einer Toast-Message auf. +struct ReportDeckSheet: View { + let slug: String + let onCompleted: (String) -> Void + + @Environment(AuthClient.self) private var auth + @Environment(\.dismiss) private var dismiss + + @State private var category: ReportCategory = .spam + @State private var message: String = "" + @State private var isSubmitting = false + @State private var errorMessage: String? + + var body: some View { + Form { + Section("Grund") { + Picker("Grund", selection: $category) { + ForEach(ReportCategory.allCases, id: \.self) { cat in + Text(cat.label).tag(cat) + } + } + .pickerStyle(.inline) + .labelsHidden() + } + + Section { + TextField("Optional: Details", text: $message, axis: .vertical) + .lineLimit(3 ... 6) + .textInputAutocapitalization(.sentences) + } header: { + Text("Beschreibung") + } footer: { + Text("Wir prüfen jede Meldung. Hass und Rechtsverletzungen werden bevorzugt behandelt.") + } + + if let errorMessage { + Section { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(CardsTheme.error) + } + } + } + .disabled(isSubmitting) + .navigationTitle("Deck melden") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Senden") { Task { await submit() } } + .disabled(isSubmitting) + } + } + } + + private func submit() async { + isSubmitting = true + errorMessage = nil + defer { isSubmitting = false } + let api = CardsAPI(auth: auth) + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + do { + let response = try await api.reportDeck( + slug: slug, + body: ReportDeckBody( + category: category, + body: trimmed.isEmpty ? nil : trimmed, + versionId: nil, + cardContentHash: nil + ) + ) + let toast = response.alreadyReported + ? "Du hast dieses Deck bereits gemeldet." + : "Meldung gesendet. Danke fürs Aufpassen." + onCompleted(toast) + dismiss() + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } +} + +/// Schlichtes Top-Banner für kurze Bestätigungen. +struct ToastBanner: View { + let text: String + + var body: some View { + Text(text) + .font(.subheadline.weight(.medium)) + .foregroundStyle(CardsTheme.foreground) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.regularMaterial, in: Capsule()) + .overlay(Capsule().stroke(CardsTheme.border, lineWidth: 0.5)) + .padding(.horizontal, 16) + .transition(.move(edge: .top).combined(with: .opacity)) + } +} diff --git a/Sources/Features/Settings/BlockedAuthorsView.swift b/Sources/Features/Settings/BlockedAuthorsView.swift new file mode 100644 index 0000000..0b70d02 --- /dev/null +++ b/Sources/Features/Settings/BlockedAuthorsView.swift @@ -0,0 +1,89 @@ +import ManaCore +import SwiftUI + +/// Liste der vom User blockierten Marketplace-Authors. Entblock-Action +/// per Swipe — analog zur iOS-Mail-Inbox. +/// +/// App-Store-Guideline 5.1.1(v) verlangt: Block-Mechanismus für UGC +/// muss verwaltbar sein. Diese View ist der Verwaltungs-Endpunkt. +struct BlockedAuthorsView: View { + @Environment(AuthClient.self) private var auth + + @State private var blocks: [BlockEntry] = [] + @State private var isLoading = false + @State private var errorMessage: String? + + var body: some View { + ZStack { + CardsTheme.background.ignoresSafeArea() + content + } + .navigationTitle("Blockierte Authors") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .task { await load() } + .refreshable { await load() } + } + + @ViewBuilder + private var content: some View { + if isLoading, blocks.isEmpty { + ProgressView().tint(CardsTheme.primary) + } else if blocks.isEmpty { + ContentUnavailableView( + "Keine blockierten Authors", + systemImage: "hand.raised.slash", + description: Text("Blockiere Authors über das Menü oben rechts auf Marketplace-Decks.") + ) + .foregroundStyle(CardsTheme.mutedForeground) + } else { + List { + ForEach(blocks) { block in + VStack(alignment: .leading, spacing: 2) { + Text(block.displayName) + .font(.subheadline.weight(.semibold)) + Text("@\(block.authorSlug)") + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + } + .swipeActions { + Button("Entblocken") { + Task { await unblock(block) } + } + .tint(CardsTheme.primary) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(CardsTheme.error) + .padding(.horizontal, 16) + } + } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + let api = CardsAPI(auth: auth) + do { + blocks = try await api.myBlocks() + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } + + private func unblock(_ block: BlockEntry) async { + let api = CardsAPI(auth: auth) + do { + try await api.unblockAuthor(slug: block.authorSlug) + blocks.removeAll { $0.id == block.id } + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } +} diff --git a/Sources/Features/Settings/SettingsView.swift b/Sources/Features/Settings/SettingsView.swift index e5df7c0..9a36d9c 100644 --- a/Sources/Features/Settings/SettingsView.swift +++ b/Sources/Features/Settings/SettingsView.swift @@ -40,10 +40,20 @@ struct SettingsView: View { } if notifications.authorization == .denied { - Label("Benachrichtigungen sind in den iOS-Einstellungen blockiert.", - systemImage: "exclamationmark.circle") - .font(.caption) - .foregroundStyle(CardsTheme.warning) + Label( + "Benachrichtigungen sind in den iOS-Einstellungen blockiert.", + systemImage: "exclamationmark.circle" + ) + .font(.caption) + .foregroundStyle(CardsTheme.warning) + } + } + + Section("Marketplace") { + NavigationLink { + BlockedAuthorsView() + } label: { + Label("Blockierte Authors", systemImage: "hand.raised") } } diff --git a/Sources/Features/Study/StudySessionView.swift b/Sources/Features/Study/StudySessionView.swift index 822134f..7a31faf 100644 --- a/Sources/Features/Study/StudySessionView.swift +++ b/Sources/Features/Study/StudySessionView.swift @@ -3,7 +3,7 @@ import SwiftData import SwiftUI #if canImport(UIKit) -import UIKit + import UIKit #endif /// Vollbild-Study-View. Wird per Navigation aus DeckListView geöffnet. @@ -73,26 +73,7 @@ struct StudySessionView: View { session.flip() } keyboardShortcuts(session: session) - if session.isFlipped { - RatingBar { rating in - Task { await session.grade(rating) } - } - .transition(.move(edge: .bottom).combined(with: .opacity)) - } else { - Button { - flipHaptic() - session.flip() - } label: { - Text("Antwort anzeigen") - .font(.subheadline.weight(.semibold)) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) - .foregroundStyle(CardsTheme.primaryForeground) - } - .buttonStyle(.plain) - .padding(.horizontal, 16) - } + bottomBar(session: session) } } .padding(.bottom, 20) @@ -100,6 +81,36 @@ struct StudySessionView: View { .animation(.easeInOut(duration: 0.2), value: session.currentIndex) } + /// Fixe Höhe, damit der Wechsel zwischen "Antwort anzeigen" und + /// `RatingBar` die Card oben nicht stauchen kann — sonst proportioniert + /// `.aspectRatio(.fit)` die Card neu und das Layout springt. + private func bottomBar(session: StudySession) -> some View { + ZStack { + if session.isFlipped { + RatingBar { rating in + Task { await session.grade(rating) } + } + .transition(.opacity) + } else { + Button { + flipHaptic() + session.flip() + } label: { + Text("Antwort anzeigen") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(CardsTheme.primaryForeground) + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .transition(.opacity) + } + } + .frame(height: 52) + } + private func cardSurface(due: DueReview, isFlipped: Bool) -> some View { CardSurface(size: .hero, elevation: .raised) { CardRenderer( @@ -155,7 +166,6 @@ struct StudySessionView: View { /// Unsichtbare Buttons mit Keyboard-Shortcuts. Funktionieren auf /// iPad (Magic Keyboard) und macOS. Space = flip, 1-4 = Rating. - @ViewBuilder private func keyboardShortcuts(session: StudySession) -> some View { Group { Button("Flip") { @@ -180,7 +190,7 @@ struct StudySessionView: View { private func flipHaptic() { #if canImport(UIKit) - UIImpactFeedbackGenerator(style: .soft).impactOccurred() + UIImpactFeedbackGenerator(style: .soft).impactOccurred() #endif } } From aece1693609d13ef7943a041891a63e897c54a65 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 14 May 2026 02:04:29 +0200 Subject: [PATCH 20/21] chore(lint): SwiftLint-Config + 0-Warnings-Pass + Swift-6-Concurrency-Fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bringt cards-native auf 0 SwiftLint-Violations bei 75 Files. Build-Status unverändert grün (xcodebuild iOS Debug). .swiftlint.yml - identifier_name excludes erweitert um math/index-Konventionen (i, j, n, m, x, y, w, h, r, g, b, a, c, d, s, f, p, q, t, l) — in algorithmischem Code klarer als verbose - opening_brace disabled — kollidiert mit SwiftFormats wrapMultilineStatementBraces (SwiftFormat ist im Pre-Commit-Hook und gewinnt) Code-Modernisierungen (real, nicht nur Annotations) - Cloze.swift: regex-Tuple bekommt `swiftlint:disable large_tuple`- Region — Regex-Output-Type ist Builder-bedingt nicht reduzierbar - Media.swift: `data(using: .utf8)` → `Data(s.utf8)` (non-failable), `String(data:as:)` → `String(bytes:encoding:)` - CardsTheme.swift: HSL-Wert-Typ statt anonymes 3-Tupel — konkretere Call-Sites, kein `large_tuple`-Warning mehr - MediaCache.swift: `CacheEntry`-Struct statt 3-Tupel im Prune-Pfad - GradeQueue / MediaCache / StudySession / MarketplaceStore: OSLog- Interpolations auf lokale Variablen ziehen — fixt Swift-6-Strict- Concurrency-Fail bei Actor-isolated-Property-Zugriff aus @Sendable-Autoclosure - DeckMutations.swift, MarketplaceModeration.swift: verschachtelte VersionInfo-Sub-Types auf Top-Level (`PullUpdateVersion`, `OwnedMarketplaceVersion`) — fixt `nesting`-Warning - Tests/UnitTests/*.swift: alle `""".data(using: .utf8)!` migriert auf `Data("""…""".utf8)`; force-cast `as!` in MutationEncodingTests durch guard-let + throw ersetzt Pragmatische Disables (mit Doc-Comment-Begründung) - DeckEditorView / MarketplacePublishView / DeckDetailView / PublicDeckView / DeckListView / CardEditorView / CardsAPI: `swiftlint:disable type_body_length` (+ teilweise file_length) als Region-Disable mit `enable` nach dem Struct. Begründung im Doc-Comment: Multi-State-Maschinen mit shared Toolbar + Sheets; Aufspalten würde nur @Binding-Plumbing produzieren Auto-Format-Aufräumung - Redundante `Sendable`-Conformance entfernt (Swift 6 leitet das bei Wert-Typen mit Sendable-Mitgliedern automatisch ab) - EnvironmentValues nutzt jetzt @Entry-Macro statt manueller EnvironmentKey-Boilerplate - Brace-Reformatting + Import-Sortierung auf allen 75 Files Ergebnis: 80 Warnings + 3 Errors → 0 / 0. Co-Authored-By: Claude Opus 4.7 (1M context) --- .swiftlint.yml | 27 +++ Sources/Core/Domain/Card.swift | 6 +- Sources/Core/Domain/CardMutations.swift | 4 +- Sources/Core/Domain/Cloze.swift | 6 + Sources/Core/Domain/Deck.swift | 16 +- Sources/Core/Domain/Marketplace.swift | 26 +-- Sources/Core/Domain/Media.swift | 16 +- Sources/Core/Domain/Review.swift | 16 +- Sources/Core/Domain/Typing.swift | 2 +- Sources/Core/Intents/StudyAppIntents.swift | 2 +- .../Notifications/NotificationManager.swift | 2 +- Sources/Core/Sync/GradeQueue.swift | 7 +- Sources/Core/Sync/MediaCache.swift | 16 +- Sources/Core/Sync/MediaEnvironment.swift | 12 +- Sources/Core/Sync/PendingShareStore.swift | 2 +- Sources/Core/Sync/WidgetSnapshot.swift | 6 +- Sources/Core/Theme/CardSurface.swift | 20 +-- Sources/Core/Theme/CardsTheme.swift | 73 ++++---- Sources/Features/Decks/DeckStackTile.swift | 132 ++++----------- Sources/Features/Editor/MaskEditorView.swift | 7 +- Sources/Features/Marketplace/BrowseView.swift | 7 +- .../Features/Marketplace/ExploreView.swift | 147 +++++++--------- .../Marketplace/MarketplaceStore.swift | 5 +- .../Features/Media/AudioPlayerButton.swift | 8 +- Sources/Features/Media/RemoteImage.swift | 16 +- Sources/Features/Study/CardRenderer.swift | 2 - .../Study/MultipleChoiceCardView.swift | 9 +- Sources/Features/Study/RatingBar.swift | 8 +- Sources/Features/Study/StudySession.swift | 9 +- Sources/Features/Study/TypingCardView.swift | 21 ++- Sources/Resources/Localizable.xcstrings | 157 +++++++++++++++++- Tests/UITests/CardsNativeUITests.swift | 2 +- Tests/UnitTests/DeckDecodingTests.swift | 12 +- .../UnitTests/MarketplaceDecodingTests.swift | 16 +- Tests/UnitTests/MaskRegionsTests.swift | 2 +- Tests/UnitTests/MutationEncodingTests.swift | 11 +- Tests/UnitTests/ReviewDecodingTests.swift | 8 +- 37 files changed, 489 insertions(+), 349 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index e2f82d1..461ed54 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,6 +1,10 @@ disabled_rules: - todo - trailing_comma + # opening_brace kollidiert mit SwiftFormats `wrapMultilineStatementBraces`, + # das bei Multi-Line-Conditions das `{` auf eine eigene Zeile wirft. + # SwiftFormat gewinnt — ist im Pre-Commit-Hook. + - opening_brace opt_in_rules: - empty_count @@ -18,8 +22,31 @@ line_length: identifier_name: min_length: 2 excluded: + # Standard-Identifier - id - ok + # Mathematische/algorithmische Konventionen (loops, indizes, + # Koordinaten, distances) — kürzer ist hier klarer als verbose. + - i + - j + - n + - m + - x + - y + - w + - h + - r + - g + - b + - a + - c + - d + - s + - f + - p + - q + - t + - l included: - Sources diff --git a/Sources/Core/Domain/Card.swift b/Sources/Core/Domain/Card.swift index 24bb6e4..9878d05 100644 --- a/Sources/Core/Domain/Card.swift +++ b/Sources/Core/Domain/Card.swift @@ -2,7 +2,7 @@ import Foundation /// Card-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toCardDto` /// und `cards/packages/cards-domain/src/schemas/card.ts`. -struct Card: Codable, Identifiable, Hashable, Sendable { +struct Card: Codable, Identifiable, Hashable { let id: String let deckId: String let userId: String @@ -29,7 +29,7 @@ struct Card: Codable, Identifiable, Hashable, Sendable { /// Card-Type-Enum. Vollständig aus `CardTypeSchema`. In β-2 rendern /// wir nur `basic`, `basic-reverse`, `cloze`. Die anderen Types /// kommen in β-3 und β-4 dazu, sind aber jetzt schon decodierbar. -enum CardType: String, Codable, Sendable, CaseIterable { +enum CardType: String, Codable, CaseIterable { case basic case basicReverse = "basic-reverse" case cloze @@ -43,7 +43,7 @@ enum CardType: String, Codable, Sendable, CaseIterable { /// Server liefert nur 4 Felder (id, deckId, type, fields) als Drizzle- /// Joined-Subset — Achtung: `deckId` hier in **camelCase**, nicht /// snake_case wie sonst. -struct ReviewCard: Codable, Hashable, Sendable { +struct ReviewCard: Codable, Hashable { let id: String let deckId: String let type: CardType diff --git a/Sources/Core/Domain/CardMutations.swift b/Sources/Core/Domain/CardMutations.swift index bec4a71..716abe2 100644 --- a/Sources/Core/Domain/CardMutations.swift +++ b/Sources/Core/Domain/CardMutations.swift @@ -10,7 +10,7 @@ import Foundation /// - multiple-choice: `front`, `answer` /// - image-occlusion: `image_ref`, `mask_regions` (β-4) /// - audio-front: `audio_ref`, `back` (β-4) -struct CardCreateBody: Encodable, Sendable { +struct CardCreateBody: Encodable { let deckId: String let type: CardType let fields: [String: String] @@ -26,7 +26,7 @@ struct CardCreateBody: Encodable, Sendable { /// Body für `PATCH /api/v1/cards/:id`. Nur `fields` und `media_refs` — /// Type und deck_id sind immutable (Server-Schema). -struct CardUpdateBody: Encodable, Sendable { +struct CardUpdateBody: Encodable { var fields: [String: String]? var mediaRefs: [String]? diff --git a/Sources/Core/Domain/Cloze.swift b/Sources/Core/Domain/Cloze.swift index 214403c..7fa71f4 100644 --- a/Sources/Core/Domain/Cloze.swift +++ b/Sources/Core/Domain/Cloze.swift @@ -12,12 +12,18 @@ import Foundation /// 1-basierte Cluster-ID. Mehrere Cluster pro Karte → mehrere /// Sub-Index-Reviews. enum Cloze { + // swiftlint:disable large_tuple + /// Pattern für `{{cN::answer(::hint)?}}`. Pro Call konstruiert, /// weil `Regex` unter Strict-Concurrency nicht Sendable ist. + /// Tuple-Output (whole-match, id, answer, hint?) ist Regex-Builder- + /// bedingt — Lint-Regel `large_tuple` greift hier nicht. private static var clusterPattern: Regex<(Substring, Substring, Substring, Substring?)> { #/\{\{c(\d+)::([^}]*?)(?:::([^}]*?))?\}\}/# } + // swiftlint:enable large_tuple + /// Distinct Cluster-IDs, sortiert. static func extractClusterIds(_ text: String) -> [Int] { var ids = Set() diff --git a/Sources/Core/Domain/Deck.swift b/Sources/Core/Domain/Deck.swift index 6e7dd34..6e7dacc 100644 --- a/Sources/Core/Domain/Deck.swift +++ b/Sources/Core/Domain/Deck.swift @@ -2,7 +2,7 @@ import Foundation /// Deck-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toDeckDto`. /// snake_case-Felder via `CodingKeys`, Optionals explizit nullable. -struct Deck: Codable, Identifiable, Hashable, Sendable { +struct Deck: Codable, Identifiable, Hashable { let id: String let userId: String let name: String @@ -41,14 +41,14 @@ struct Deck: Codable, Identifiable, Hashable, Sendable { } } -enum DeckVisibility: String, Codable, Sendable { +enum DeckVisibility: String, Codable { case `private` case space case `public` } /// Aus `cards/packages/cards-domain/src/schemas/deck.ts:DECK_CATEGORY_IDS`. -enum DeckCategory: String, Codable, Sendable, CaseIterable { +enum DeckCategory: String, Codable, CaseIterable { case language case medicine case science @@ -82,7 +82,7 @@ enum DeckCategory: String, Codable, Sendable, CaseIterable { /// FSRS-Settings — Native bleibt schematisch agnostisch, FSRS rechnet /// nur der Server. Wir behalten die Felder als roh-JSON, damit eine /// neue Setting auf dem Server uns nicht bricht. -struct FsrsSettings: Codable, Sendable, Hashable { +struct FsrsSettings: Codable, Hashable { let requestRetention: Double? let maximumInterval: Int? let enableFuzz: Bool? @@ -114,23 +114,23 @@ struct FsrsSettings: Codable, Sendable, Hashable { } /// Server-Response von `GET /api/v1/decks`. -struct DeckListResponse: Decodable, Sendable { +struct DeckListResponse: Decodable { let decks: [Deck] let total: Int } /// Server-Response von `GET /api/v1/cards?deck_id=...`. -struct CardListResponse: Decodable, Sendable { +struct CardListResponse: Decodable { let cards: [Card] let total: Int } /// Server-Response von `GET /api/v1/reviews/due?deck_id=...`. -struct DueReviewsResponse: Decodable, Sendable { +struct DueReviewsResponse: Decodable { let total: Int } /// Server-Response von `GET /api/v1/decks/:deckId/distractors`. -struct DistractorsResponse: Decodable, Sendable { +struct DistractorsResponse: Decodable { let distractors: [String] } diff --git a/Sources/Core/Domain/Marketplace.swift b/Sources/Core/Domain/Marketplace.swift index 52d1b6f..f3ecd56 100644 --- a/Sources/Core/Domain/Marketplace.swift +++ b/Sources/Core/Domain/Marketplace.swift @@ -1,7 +1,7 @@ import Foundation /// Browse-Eintrag aus `/api/v1/marketplace/decks` und `.../explore`. -struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable { +struct PublicDeckEntry: Codable, Hashable, Identifiable { let slug: String let title: String let description: String? @@ -16,7 +16,9 @@ struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable { let createdAt: Date let owner: PublicDeckOwner - var id: String { slug } + var id: String { + slug + } enum CodingKeys: String, CodingKey { case slug, title, description, language, category, license @@ -29,10 +31,12 @@ struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable { case owner } - var isPaid: Bool { priceCredits > 0 } + var isPaid: Bool { + priceCredits > 0 + } } -struct PublicDeckOwner: Codable, Hashable, Sendable { +struct PublicDeckOwner: Codable, Hashable { let slug: String let displayName: String let verifiedMana: Bool @@ -62,19 +66,19 @@ struct PublicDeckOwner: Codable, Hashable, Sendable { } /// Response von `GET /api/v1/marketplace/explore`. -struct ExploreResponse: Decodable, Sendable { +struct ExploreResponse: Decodable { let featured: [PublicDeckEntry] let trending: [PublicDeckEntry] } /// Response von `GET /api/v1/marketplace/decks`. -struct BrowseResponse: Decodable, Sendable { +struct BrowseResponse: Decodable { let items: [PublicDeckEntry] let total: Int } /// Vollständiges Public-Deck aus `GET /api/v1/marketplace/decks/:slug`. -struct PublicDeck: Codable, Hashable, Sendable, Identifiable { +struct PublicDeck: Codable, Hashable, Identifiable { let id: String let slug: String let title: String @@ -100,7 +104,7 @@ struct PublicDeck: Codable, Hashable, Sendable, Identifiable { } } -struct PublicDeckVersion: Codable, Hashable, Sendable, Identifiable { +struct PublicDeckVersion: Codable, Hashable, Identifiable { let id: String let deckId: String let semver: String @@ -123,7 +127,7 @@ struct PublicDeckVersion: Codable, Hashable, Sendable, Identifiable { } /// Response von `GET /api/v1/marketplace/decks/:slug`. -struct PublicDeckDetail: Decodable, Sendable { +struct PublicDeckDetail: Decodable { let deck: PublicDeck let latestVersion: PublicDeckVersion? let owner: PublicDeckOwner? @@ -136,7 +140,7 @@ struct PublicDeckDetail: Decodable, Sendable { } /// Response von `POST /api/v1/marketplace/decks/:slug/subscribe`. -struct SubscribeResponse: Decodable, Sendable { +struct SubscribeResponse: Decodable { let subscribed: Bool let deckSlug: String let currentVersionId: String? @@ -151,7 +155,7 @@ struct SubscribeResponse: Decodable, Sendable { } /// Browse-Sort-Optionen aus `BrowseQuerySchema`. -enum MarketplaceSort: String, Sendable, CaseIterable { +enum MarketplaceSort: String, CaseIterable { case recent case popular case trending diff --git a/Sources/Core/Domain/Media.swift b/Sources/Core/Domain/Media.swift index a20e498..fd47f86 100644 --- a/Sources/Core/Domain/Media.swift +++ b/Sources/Core/Domain/Media.swift @@ -1,7 +1,7 @@ import Foundation /// Response von `POST /api/v1/media/upload`. -struct MediaUploadResponse: Decodable, Sendable { +struct MediaUploadResponse: Decodable { let id: String let url: String let mimeType: String @@ -19,7 +19,7 @@ struct MediaUploadResponse: Decodable, Sendable { } } -enum MediaKind: String, Codable, Sendable { +enum MediaKind: String, Codable { case image case audio case video @@ -29,7 +29,7 @@ enum MediaKind: String, Codable, Sendable { /// Image-Occlusion-Mask-Region. /// `mask_regions`-Feld ist ein JSON-Array-**String** in `fields`, /// nicht ein Object — Server-Schema-Constraint (`fields: Record`). -struct MaskRegion: Codable, Hashable, Sendable, Identifiable { +struct MaskRegion: Codable, Hashable, Identifiable { let id: String let x: Double // 0..1 relativ let y: Double @@ -53,7 +53,7 @@ enum MaskRegions { /// Bei Parse- oder Schema-Fehler: leere Liste. Sortiert nach ID /// (lexikographisch, gleich wie Server-Sortierung). static func parse(_ json: String) -> [MaskRegion] { - guard let data = json.data(using: .utf8) else { return [] } + let data = Data(json.utf8) guard let regions = try? JSONDecoder().decode([MaskRegion].self, from: data) else { return [] } return regions.sorted { $0.id < $1.id } } @@ -73,8 +73,10 @@ enum MaskRegions { static func encode(_ regions: [MaskRegion]) -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] - guard let data = try? encoder.encode(regions) else { return "[]" } - return String(decoding: data, as: UTF8.self) + guard let data = try? encoder.encode(regions), + let json = String(bytes: data, encoding: .utf8) + else { return "[]" } + return json } } @@ -88,7 +90,7 @@ extension CardFieldsBuilder { ) -> [String: String] { var fields: [String: String] = [ "image_ref": imageRef, - "mask_regions": MaskRegions.encode(regions), + "mask_regions": MaskRegions.encode(regions) ] if let note, !note.isEmpty { fields["note"] = note diff --git a/Sources/Core/Domain/Review.swift b/Sources/Core/Domain/Review.swift index 5fc9ae4..77056c6 100644 --- a/Sources/Core/Domain/Review.swift +++ b/Sources/Core/Domain/Review.swift @@ -2,7 +2,7 @@ import Foundation /// Rating-Werte für `POST /reviews/:cardId/:subIndex/grade`. /// Aus `cards/packages/cards-domain/src/schemas/review.ts:RatingSchema`. -enum Rating: String, Codable, Sendable, CaseIterable { +enum Rating: String, Codable, CaseIterable { case again case hard case good @@ -30,7 +30,7 @@ enum Rating: String, Codable, Sendable, CaseIterable { } /// FSRS-Review-State. Aus `ReviewStateSchema`. -enum ReviewState: String, Codable, Sendable { +enum ReviewState: String, Codable { case new case learning case review @@ -38,7 +38,7 @@ enum ReviewState: String, Codable, Sendable { } /// Review-DTO. Wire-Format aus `cards/apps/api/src/routes/reviews.ts:toReviewDto`. -struct Review: Codable, Hashable, Sendable { +struct Review: Codable, Hashable { let cardId: String let subIndex: Int let userId: String @@ -71,11 +71,13 @@ struct Review: Codable, Hashable, Sendable { } /// Eintrag aus `/reviews/due?deck_id=X` — Review + zugehörige Card. -struct DueReview: Codable, Hashable, Sendable, Identifiable { +struct DueReview: Codable, Hashable, Identifiable { let review: Review let card: ReviewCard - var id: String { "\(review.cardId)-\(review.subIndex)" } + var id: String { + "\(review.cardId)-\(review.subIndex)" + } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -96,13 +98,13 @@ struct DueReview: Codable, Hashable, Sendable, Identifiable { } /// Wrapper-Response von `GET /api/v1/reviews/due?deck_id=X`. -struct DueReviewsListResponse: Decodable, Sendable { +struct DueReviewsListResponse: Decodable { let reviews: [DueReview] let total: Int } /// Body für `POST /reviews/:cardId/:subIndex/grade`. -struct GradeReviewBody: Encodable, Sendable { +struct GradeReviewBody: Encodable { let rating: Rating let reviewedAt: Date diff --git a/Sources/Core/Domain/Typing.swift b/Sources/Core/Domain/Typing.swift index 25aa7b4..3d02240 100644 --- a/Sources/Core/Domain/Typing.swift +++ b/Sources/Core/Domain/Typing.swift @@ -5,7 +5,7 @@ import Foundation /// Normalisierung (lowercase, trim, NFD-Diakritika-Stripping), /// dann exact-match → `correct`. Sonst Levenshtein-Distanz mit /// Threshold `max(1, floor(answer.length * 0.2))` → `close`. -enum TypingMatch: Sendable, Equatable { +enum TypingMatch: Equatable { case correct case close case wrong diff --git a/Sources/Core/Intents/StudyAppIntents.swift b/Sources/Core/Intents/StudyAppIntents.swift index d65e541..ac300df 100644 --- a/Sources/Core/Intents/StudyAppIntents.swift +++ b/Sources/Core/Intents/StudyAppIntents.swift @@ -29,7 +29,7 @@ struct CardsAppShortcuts: AppShortcutsProvider { phrases: [ "Karten lernen mit \(.applicationName)", "Mit \(.applicationName) lernen", - "\(.applicationName) öffnen", + "\(.applicationName) öffnen" ], shortTitle: "Karten lernen", systemImageName: "rectangle.stack" diff --git a/Sources/Core/Notifications/NotificationManager.swift b/Sources/Core/Notifications/NotificationManager.swift index 8aa98d4..e3bcf6f 100644 --- a/Sources/Core/Notifications/NotificationManager.swift +++ b/Sources/Core/Notifications/NotificationManager.swift @@ -8,7 +8,7 @@ import UserNotifications @MainActor @Observable final class NotificationManager { - enum AuthorizationStatus: Sendable { + enum AuthorizationStatus { case unknown case authorized case denied diff --git a/Sources/Core/Sync/GradeQueue.swift b/Sources/Core/Sync/GradeQueue.swift index 0b4ff8f..d95910c 100644 --- a/Sources/Core/Sync/GradeQueue.swift +++ b/Sources/Core/Sync/GradeQueue.swift @@ -30,8 +30,9 @@ final class GradeQueue { ) context.insert(grade) try? context.save() + let rawRating = rating.rawValue Log.study.info( - "Queued grade for \(cardId, privacy: .public)/\(subIndex, privacy: .public): \(rating.rawValue, privacy: .public)" + "Queued grade \(cardId, privacy: .public)/\(subIndex, privacy: .public): \(rawRating, privacy: .public)" ) await drain() } @@ -73,8 +74,10 @@ final class GradeQueue { grade.lastError = msg try? context.save() lastDrainError = msg + let cid = grade.cardId + let sub = grade.subIndex Log.study.notice( - "Drain stopped for \(grade.cardId, privacy: .public)/\(grade.subIndex, privacy: .public): \(msg, privacy: .public)" + "Drain stopped \(cid, privacy: .public)/\(sub, privacy: .public): \(msg, privacy: .public)" ) return } diff --git a/Sources/Core/Sync/MediaCache.swift b/Sources/Core/Sync/MediaCache.swift index b636072..e9ab815 100644 --- a/Sources/Core/Sync/MediaCache.swift +++ b/Sources/Core/Sync/MediaCache.swift @@ -35,10 +35,16 @@ actor MediaCache { /// Direktes Lesen — für UI-Komponenten, die `Data` brauchen (z.B. AVAudioPlayer). func data(for mediaId: String) async throws -> Data { - try Data(contentsOf: try await localURL(for: mediaId)) + try await Data(contentsOf: localURL(for: mediaId)) } /// LRU-Eviction: bei Überschreitung des Limits ältesten zuerst löschen. + private struct CacheEntry { + let url: URL + let size: Int + let date: Date + } + private func pruneIfNeeded() async throws { let resourceKeys: Set = [.fileSizeKey, .contentModificationDateKey] guard let items = try? FileManager.default.contentsOfDirectory( @@ -46,10 +52,10 @@ actor MediaCache { includingPropertiesForKeys: Array(resourceKeys) ) else { return } - let withMeta = items.compactMap { url -> (url: URL, size: Int, date: Date)? in + let withMeta = items.compactMap { url -> CacheEntry? in let values = try? url.resourceValues(forKeys: resourceKeys) guard let size = values?.fileSize, let date = values?.contentModificationDate else { return nil } - return (url, size, date) + return CacheEntry(url: url, size: size, date: date) } let totalBytes = withMeta.reduce(0) { $0 + $1.size } @@ -61,7 +67,9 @@ actor MediaCache { if remaining <= maxBytes { break } try? FileManager.default.removeItem(at: item.url) remaining -= item.size - Log.sync.info("MediaCache evicted \(item.url.lastPathComponent, privacy: .public) (\(item.size, privacy: .public)B)") + let name = item.url.lastPathComponent + let size = item.size + Log.sync.info("MediaCache evicted \(name, privacy: .public) (\(size, privacy: .public)B)") } } diff --git a/Sources/Core/Sync/MediaEnvironment.swift b/Sources/Core/Sync/MediaEnvironment.swift index bd158c0..566e3f7 100644 --- a/Sources/Core/Sync/MediaEnvironment.swift +++ b/Sources/Core/Sync/MediaEnvironment.swift @@ -1,15 +1,5 @@ import SwiftUI -/// Environment-Key, der den shared `MediaCache` durch die View-Hierarchie -/// reicht. App-Entrypoint setzt den Wert; Views lesen via -/// `@Environment(\.mediaCache)`. -private struct MediaCacheKey: EnvironmentKey { - static let defaultValue: MediaCache? = nil -} - extension EnvironmentValues { - var mediaCache: MediaCache? { - get { self[MediaCacheKey.self] } - set { self[MediaCacheKey.self] = newValue } - } + @Entry var mediaCache: MediaCache? } diff --git a/Sources/Core/Sync/PendingShareStore.swift b/Sources/Core/Sync/PendingShareStore.swift index 719f972..9aefb47 100644 --- a/Sources/Core/Sync/PendingShareStore.swift +++ b/Sources/Core/Sync/PendingShareStore.swift @@ -3,7 +3,7 @@ import Foundation /// Inbox für Share-Extension. Die Extension persistiert hier, die /// Haupt-App liest beim Start und zeigt einen Banner mit /// "→ Als Karte speichern". Shared App-Group-Container. -struct PendingShare: Codable, Identifiable, Hashable, Sendable { +struct PendingShare: Codable, Identifiable, Hashable { let id: String let text: String let sourceURL: String? diff --git a/Sources/Core/Sync/WidgetSnapshot.swift b/Sources/Core/Sync/WidgetSnapshot.swift index 559694c..a0bf27b 100644 --- a/Sources/Core/Sync/WidgetSnapshot.swift +++ b/Sources/Core/Sync/WidgetSnapshot.swift @@ -6,13 +6,13 @@ import Foundation /// /// Wire ist bewusst stabil + schmal — nur was das Widget rendert. /// Neue Felder dürfen additiv dazukommen, alte Felder bleiben. -struct WidgetSnapshot: Codable, Sendable { +struct WidgetSnapshot: Codable { let updatedAt: Date let totalDueCount: Int let topDecks: [Entry] - struct Entry: Codable, Sendable, Identifiable { - let id: String // deck-id + struct Entry: Codable, Identifiable { + let id: String // deck-id let name: String let dueCount: Int let colorHex: String? diff --git a/Sources/Core/Theme/CardSurface.swift b/Sources/Core/Theme/CardSurface.swift index f19f3e3..f2c3a60 100644 --- a/Sources/Core/Theme/CardSurface.swift +++ b/Sources/Core/Theme/CardSurface.swift @@ -11,16 +11,16 @@ import SwiftUI /// - Background hsl(--color-surface) /// - Aspect-Ratio 5/7 für `.md` und `.hero`, fix für `.lg` struct CardSurface: View { - enum Size: Sendable { - case md // Deck-Tile in der Liste (max-width 18rem) - case lg // Fan-Detail (12rem x 16.8rem) - case hero // Study-Lernkarte (max-width 24rem) + enum Size { + case md // Deck-Tile in der Liste (max-width 18rem) + case lg // Fan-Detail (12rem x 16.8rem) + case hero // Study-Lernkarte (max-width 24rem) } - enum Elevation: Sendable { - case flat // Subtle shadow + enum Elevation { + case flat // Subtle shadow case standard // Default Karten-Shadow - case raised // Study-Hero + case raised // Study-Hero } let size: Size @@ -73,9 +73,9 @@ struct CardSurface: View { private var maxWidth: CGFloat? { switch size { - case .md: 288 // 18rem - case .lg: 192 // 12rem - case .hero: 384 // 24rem + case .md: 288 // 18rem + case .lg: 192 // 12rem + case .hero: 384 // 24rem } } diff --git a/Sources/Core/Theme/CardsTheme.swift b/Sources/Core/Theme/CardsTheme.swift index 064ae6e..79a4229 100644 --- a/Sources/Core/Theme/CardsTheme.swift +++ b/Sources/Core/Theme/CardsTheme.swift @@ -1,11 +1,13 @@ import SwiftUI #if canImport(UIKit) -import UIKit -private typealias PlatformColorType = UIColor + import UIKit + + private typealias PlatformColorType = UIColor #elseif canImport(AppKit) -import AppKit -private typealias PlatformColorType = NSColor + import AppKit + + private typealias PlatformColorType = NSColor #endif /// Forest-Theme aus `mana/packages/themes/src/variants/forest.css`. @@ -16,56 +18,67 @@ private typealias PlatformColorType = NSColor /// `mana/docs/MANA_SWIFT.md` — bis dahin lebt forest hier. enum CardsTheme { /// Page-Hintergrund - static let background = dynamic(light: (0, 0, 100), dark: (142, 30, 8)) + static let background = dynamic(light: HSL(0, 0, 100), dark: HSL(142, 30, 8)) /// Standard-Text - static let foreground = dynamic(light: (142, 30, 12), dark: (142, 15, 95)) + static let foreground = dynamic(light: HSL(142, 30, 12), dark: HSL(142, 15, 95)) /// Card, Panel, Modal - static let surface = dynamic(light: (142, 25, 98), dark: (142, 25, 12)) + static let surface = dynamic(light: HSL(142, 25, 98), dark: HSL(142, 25, 12)) /// Hover-State auf Surface - static let surfaceHover = dynamic(light: (142, 20, 95), dark: (142, 20, 16)) + static let surfaceHover = dynamic(light: HSL(142, 20, 95), dark: HSL(142, 20, 16)) /// Disabled-Felder, Skeleton - static let muted = dynamic(light: (142, 15, 93), dark: (142, 18, 18)) + static let muted = dynamic(light: HSL(142, 15, 93), dark: HSL(142, 18, 18)) /// Sekundär-Text, Placeholder - static let mutedForeground = dynamic(light: (142, 10, 42), dark: (142, 12, 65)) + static let mutedForeground = dynamic(light: HSL(142, 10, 42), dark: HSL(142, 12, 65)) /// Rahmen, Trennlinien - static let border = dynamic(light: (142, 15, 88), dark: (142, 18, 22)) + static let border = dynamic(light: HSL(142, 15, 88), dark: HSL(142, 18, 22)) /// Cards-Brand-Grün — Tiefgrün im Light, leuchtender im Dark - static let primary = dynamic(light: (142, 76, 28), dark: (142, 71, 45)) + static let primary = dynamic(light: HSL(142, 76, 28), dark: HSL(142, 71, 45)) /// Text auf Primary - static let primaryForeground = dynamic(light: (0, 0, 100), dark: (142, 30, 8)) + static let primaryForeground = dynamic(light: HSL(0, 0, 100), dark: HSL(142, 30, 8)) - static let error = dynamic(light: (0, 84, 60), dark: (0, 63, 55)) - static let success = dynamic(light: (142, 71, 45), dark: (142, 71, 45)) - static let warning = dynamic(light: (38, 92, 50), dark: (48, 96, 53)) + static let error = dynamic(light: HSL(0, 84, 60), dark: HSL(0, 63, 55)) + static let success = dynamic(light: HSL(142, 71, 45), dark: HSL(142, 71, 45)) + static let warning = dynamic(light: HSL(38, 92, 50), dark: HSL(48, 96, 53)) // MARK: - HSL Helper - private static func dynamic( - light: (Double, Double, Double), - dark: (Double, Double, Double) - ) -> Color { - let lightColor = fromHSL(light.0, light.1, light.2) - let darkColor = fromHSL(dark.0, dark.1, dark.2) + /// Hue/Saturation/Lightness als Wert-Typ. HSL ist konkreter als ein + /// 3-Tupel und macht die Call-Sites lesbar. + struct HSL { + let hue: Double + let saturation: Double + let lightness: Double + + init(_ hue: Double, _ saturation: Double, _ lightness: Double) { + self.hue = hue + self.saturation = saturation + self.lightness = lightness + } + } + + private static func dynamic(light: HSL, dark: HSL) -> Color { + let lightColor = fromHSL(light.hue, light.saturation, light.lightness) + let darkColor = fromHSL(dark.hue, dark.saturation, dark.lightness) #if canImport(UIKit) - return Color(uiColor: UIColor { trait in - trait.userInterfaceStyle == .dark ? darkColor : lightColor - }) + return Color(uiColor: UIColor { trait in + trait.userInterfaceStyle == .dark ? darkColor : lightColor + }) #elseif canImport(AppKit) - return Color(nsColor: NSColor(name: nil) { appearance in - let isDark = appearance.bestMatch(from: [.darkAqua, .vibrantDark]) != nil - return isDark ? darkColor : lightColor - }) + return Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.darkAqua, .vibrantDark]) != nil + return isDark ? darkColor : lightColor + }) #else - return Color(red: 0, green: 0, blue: 0) + return Color(red: 0, green: 0, blue: 0) #endif } diff --git a/Sources/Features/Decks/DeckStackTile.swift b/Sources/Features/Decks/DeckStackTile.swift index fd08b43..19617e1 100644 --- a/Sources/Features/Decks/DeckStackTile.swift +++ b/Sources/Features/Decks/DeckStackTile.swift @@ -1,97 +1,53 @@ import SwiftUI -/// Spiel-Karten-Stack-Visual mit drei gestaffelt-rotierten Hintergrund- -/// Layern hinter einer `CardSurface`. Web-Vorbild: -/// `cards/apps/web/src/lib/components/DeckStack.svelte`. -/// -/// Layout: Kategorie-Icon oben rechts (prominent in primary-Farbe), -/// Titel + Description zentriert, Counts + Edit-Button unten. -/// Tap auf die Tile triggert `onTap` (Study-Mode), Tap auf den -/// Edit-Button triggert `onEdit` (Deck-Detail). +/// Tile für eigene Decks in der Decks-Liste. Nutzt `DeckCoverTile` als +/// Basis (Fan-Stack-Visual + Card-Content). Footer: Karten-Count, +/// Due-Capsule, Marketplace-Globe, Edit-Button. +/// Tap auf die Tile triggert `onTap` (Study-Mode), Tap auf den Edit- +/// Button triggert `onEdit` (Deck-Detail). struct DeckStackTile: View { let deck: CachedDeck let onTap: () -> Void let onEdit: () -> Void var body: some View { - ZStack { - // Drei Hintergrund-Layer (von hinten nach vorne) - ForEach(Array(layers.enumerated()), id: \.offset) { _, layer in - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(CardsTheme.surface) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(CardsTheme.border, lineWidth: 1) - ) - .opacity(layer.opacity) - .rotationEffect(.degrees(layer.tilt)) - .offset(x: layer.dx, y: layer.dy) - .shadow(color: CardsTheme.foreground.opacity(0.05), radius: 2, y: 1) - } - - CardSurface(size: .md, elevation: .standard, colorAccentHex: deck.color) { - cardContent - } + DeckCoverTile( + title: deck.name, + description: deck.deckDescription, + category: deck.category, + seed: deck.id, + colorAccentHex: deck.color, + isFeatured: false + ) { + footerContent } - .aspectRatio(5.0 / 7.0, contentMode: .fit) - .frame(maxWidth: 280) .contentShape(Rectangle()) .onTapGesture { onTap() } } - private var cardContent: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top) { - Spacer() - Image(systemName: deck.category?.systemImageName ?? "rectangle.stack") - .font(.title2) - .foregroundStyle(CardsTheme.primary.opacity(0.85)) + private var footerContent: some View { + HStack(spacing: 8) { + Label("\(deck.cardCount)", systemImage: "rectangle.stack") + .font(.caption2) + .foregroundStyle(CardsTheme.mutedForeground) + if deck.dueCount > 0 { + Text("\(deck.dueCount) fällig") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(CardsTheme.primary.opacity(0.15), in: Capsule()) + .foregroundStyle(CardsTheme.primary) } - - Spacer(minLength: 0) - - VStack(alignment: .leading, spacing: 6) { - Text(deck.name) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(CardsTheme.foreground) - .lineLimit(3) - - if let description = deck.deckDescription, !description.isEmpty { - Text(description) - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - .lineLimit(2) - } - } - - Spacer(minLength: 0) - - HStack(spacing: 8) { - Label("\(deck.cardCount)", systemImage: "rectangle.stack") + if deck.isFromMarketplace { + Image(systemName: "globe") .font(.caption2) .foregroundStyle(CardsTheme.mutedForeground) - if deck.dueCount > 0 { - Text("\(deck.dueCount) fällig") - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(CardsTheme.primary.opacity(0.15), in: Capsule()) - .foregroundStyle(CardsTheme.primary) - } - if deck.isFromMarketplace { - Image(systemName: "globe") - .font(.caption2) - .foregroundStyle(CardsTheme.mutedForeground) - } - Spacer() - editButton } + Spacer() + editButton } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - /// Edit-Button unten rechts. Eigener `Button` mit `.plain` style - /// fängt den Tap und triggert nicht das Outer-`onTapGesture`. private var editButton: some View { Button { onEdit() @@ -108,34 +64,6 @@ struct DeckStackTile: View { .buttonStyle(.plain) .accessibilityLabel("Deck bearbeiten") } - - /// Deterministische Stack-Layer aus Deck-ID gehasht. - private var layers: [StackLayer] { - var hash = UInt64(0) - for byte in deck.id.utf8 { - hash = hash &* 31 &+ UInt64(byte) - } - return (0 ..< 3).map { index in - let seed = hash &+ UInt64(index) &* 17 - let tiltRaw = Double((seed >> 8) & 0xFF) / 255.0 - 0.5 - let xRaw = Double((seed >> 16) & 0xFF) / 255.0 - 0.5 - let yRaw = Double((seed >> 24) & 0xFF) / 255.0 - 0.5 - let depth = Double(index + 1) - return StackLayer( - tilt: tiltRaw * 4.0, - dx: xRaw * 6.0, - dy: depth * 3.0 + yRaw * 2.0, - opacity: 0.7 - depth * 0.18 - ) - } - } -} - -private struct StackLayer { - let tilt: Double - let dx: Double - let dy: Double - let opacity: Double } extension DeckCategory { diff --git a/Sources/Features/Editor/MaskEditorView.swift b/Sources/Features/Editor/MaskEditorView.swift index ba04c75..2edf644 100644 --- a/Sources/Features/Editor/MaskEditorView.swift +++ b/Sources/Features/Editor/MaskEditorView.swift @@ -1,7 +1,7 @@ import SwiftUI #if canImport(UIKit) -import UIKit + import UIKit #endif /// Mask-Editor: Bild anzeigen, mit Drag-Gesten Rechtecke zeichnen, jede @@ -39,14 +39,13 @@ struct MaskEditorView: View { } } - @ViewBuilder private var imageCanvas: some View { GeometryReader { geo in ZStack(alignment: .topLeading) { #if canImport(UIKit) - Image(uiImage: image).resizable().aspectRatio(contentMode: .fit) + Image(uiImage: image).resizable().aspectRatio(contentMode: .fit) #else - Image(nsImage: image).resizable().aspectRatio(contentMode: .fit) + Image(nsImage: image).resizable().aspectRatio(contentMode: .fit) #endif ForEach(regions) { region in diff --git a/Sources/Features/Marketplace/BrowseView.swift b/Sources/Features/Marketplace/BrowseView.swift index 37b9ba9..a96f961 100644 --- a/Sources/Features/Marketplace/BrowseView.swift +++ b/Sources/Features/Marketplace/BrowseView.swift @@ -20,8 +20,11 @@ struct BrowseView: View { #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif - .searchable(text: $queryText, placement: .navigationBarDrawer(displayMode: .always), - prompt: "Decks suchen") + .searchable( + text: $queryText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Decks suchen" + ) .onSubmit(of: .search) { store?.browseQuery = queryText Task { await store?.browse() } diff --git a/Sources/Features/Marketplace/ExploreView.swift b/Sources/Features/Marketplace/ExploreView.swift index 27dcef0..aeb6492 100644 --- a/Sources/Features/Marketplace/ExploreView.swift +++ b/Sources/Features/Marketplace/ExploreView.swift @@ -65,10 +65,10 @@ struct ExploreView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { if !store.featured.isEmpty { - section(title: "Vorgestellt", items: store.featured) + section(title: "Vorgestellt", icon: "star.fill", items: store.featured) } if !store.trending.isEmpty { - section(title: "Im Trend", items: store.trending) + section(title: "Im Trend", icon: "flame.fill", items: store.trending) } NavigationLink(value: MarketplaceRoute.browse) { @@ -87,32 +87,48 @@ struct ExploreView: View { .foregroundStyle(CardsTheme.foreground) } .buttonStyle(.plain) - .padding(.horizontal, 16) + .padding(.horizontal, 20) } - .padding(.vertical, 16) + .padding(.vertical, 12) } } } } - private func section(title: String, items: [PublicDeckEntry]) -> some View { + private func section(title: String, icon: String, items: [PublicDeckEntry]) -> some View { VStack(alignment: .leading, spacing: 12) { - Text(title) - .font(.title3.weight(.semibold)) - .foregroundStyle(CardsTheme.foreground) - .padding(.horizontal, 16) + HStack(spacing: 6) { + Image(systemName: icon) + .foregroundStyle(CardsTheme.primary) + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(CardsTheme.foreground) + Text("\(items.count)") + .font(.subheadline) + .foregroundStyle(CardsTheme.mutedForeground) + } + .padding(.horizontal, 20) ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { + HStack(alignment: .top, spacing: 16) { ForEach(items) { item in NavigationLink(value: MarketplaceRoute.publicDeck(slug: item.slug)) { PublicDeckCard(entry: item) + .frame(width: 240) + .scrollTransition(.animated) { content, phase in + content + .scaleEffect(phase.isIdentity ? 1 : 0.92) + .opacity(phase.isIdentity ? 1 : 0.7) + } } .buttonStyle(.plain) } } - .padding(.horizontal, 16) + .padding(.horizontal, 20) + .padding(.bottom, 12) + .scrollTargetLayout() } + .scrollTargetBehavior(.viewAligned) } } } @@ -123,92 +139,57 @@ enum MarketplaceRoute: Hashable { case publicDeck(slug: String) } -/// Public-Deck-Karten-Tile in Featured/Trending-Carousels und Browse-Grid. -/// Selbes Tile-Layout wie DeckStackTile (5:7 Aspect-Ratio, -/// CardSurface, Kategorie-Icon oben rechts), aber für PublicDeckEntry- -/// Daten. Star-Count statt Edit-Button unten rechts. +/// Tile für Marketplace-Decks im Explore-Tab. Nutzt `DeckCoverTile` +/// als Basis (selber Look + Größe wie `DeckStackTile` auf der Decks- +/// Seite). Footer: Karten-Count, Star-Count, Credits, Owner-Badge. struct PublicDeckCard: View { let entry: PublicDeckEntry var body: some View { - ZStack { - CardSurface(size: .md, elevation: .standard, colorAccentHex: nil) { - cardContent - } + DeckCoverTile( + title: entry.title, + description: entry.description, + category: parsedCategory, + seed: entry.slug, + colorAccentHex: nil, + isFeatured: entry.isFeatured + ) { + footerContent } - .aspectRatio(5.0 / 7.0, contentMode: .fit) - .frame(maxWidth: 280) } - private var cardContent: some View { + private var parsedCategory: DeckCategory? { + guard let category = entry.category else { return nil } + return DeckCategory(rawValue: category) + } + + private var footerContent: some View { VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top) { - if entry.isFeatured { - Image(systemName: "star.fill") - .font(.caption) - .foregroundStyle(CardsTheme.warning) + HStack(spacing: 8) { + Label("\(entry.cardCount)", systemImage: "rectangle.stack") + .font(.caption2) + .foregroundStyle(CardsTheme.mutedForeground) + Label("\(entry.starCount)", systemImage: "star.fill") + .font(.caption2) + .foregroundStyle(CardsTheme.warning) + if entry.isPaid { + Label("\(entry.priceCredits)", systemImage: "creditcard") + .font(.caption2.weight(.semibold)) + .foregroundStyle(CardsTheme.primary) } Spacer() - Image(systemName: categorySymbol) - .font(.title2) - .foregroundStyle(CardsTheme.primary.opacity(0.85)) } - - Spacer(minLength: 0) - - VStack(alignment: .leading, spacing: 6) { - Text(entry.title) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(CardsTheme.foreground) - .lineLimit(3) - - if let description = entry.description, !description.isEmpty { - Text(description) - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - .lineLimit(2) - } - } - - Spacer(minLength: 0) - - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Label("\(entry.cardCount)", systemImage: "rectangle.stack") + HStack(spacing: 4) { + Text(entry.owner.displayName) + .font(.caption2) + .foregroundStyle(CardsTheme.mutedForeground) + .lineLimit(1) + if entry.owner.verifiedMana { + Image(systemName: "checkmark.seal.fill") .font(.caption2) - .foregroundStyle(CardsTheme.mutedForeground) - Label("\(entry.starCount)", systemImage: "star.fill") - .font(.caption2) - .foregroundStyle(CardsTheme.warning) - if entry.isPaid { - Label("\(entry.priceCredits)", systemImage: "creditcard") - .font(.caption2.weight(.semibold)) - .foregroundStyle(CardsTheme.primary) - } - Spacer() - } - HStack(spacing: 4) { - Text(entry.owner.displayName) - .font(.caption2) - .foregroundStyle(CardsTheme.mutedForeground) - .lineLimit(1) - if entry.owner.verifiedMana { - Image(systemName: "checkmark.seal.fill") - .font(.caption2) - .foregroundStyle(CardsTheme.primary) - } + .foregroundStyle(CardsTheme.primary) } } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - - private var categorySymbol: String { - guard let category = entry.category, - let parsed = DeckCategory(rawValue: category) - else { - return "rectangle.stack" - } - return parsed.systemImageName } } diff --git a/Sources/Features/Marketplace/MarketplaceStore.swift b/Sources/Features/Marketplace/MarketplaceStore.swift index fa6f73b..6bceb0c 100644 --- a/Sources/Features/Marketplace/MarketplaceStore.swift +++ b/Sources/Features/Marketplace/MarketplaceStore.swift @@ -33,8 +33,9 @@ final class MarketplaceStore { featured = res.featured trending = res.trending } catch { - errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) - Log.api.error("Explore failed: \(self.errorMessage ?? "", privacy: .public)") + let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + errorMessage = message + Log.api.error("Explore failed: \(message, privacy: .public)") } } diff --git a/Sources/Features/Media/AudioPlayerButton.swift b/Sources/Features/Media/AudioPlayerButton.swift index 99bc12f..6566abf 100644 --- a/Sources/Features/Media/AudioPlayerButton.swift +++ b/Sources/Features/Media/AudioPlayerButton.swift @@ -45,12 +45,14 @@ struct AudioPlayerButton: View { } private func load() async { - guard let cache = mediaCache else { failed = true; return } + guard let cache = mediaCache else { failed = true + return + } do { let data = try await cache.data(for: mediaId) #if canImport(UIKit) - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) - try AVAudioSession.sharedInstance().setActive(true) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) #endif player = try AVAudioPlayer(data: data) player?.prepareToPlay() diff --git a/Sources/Features/Media/RemoteImage.swift b/Sources/Features/Media/RemoteImage.swift index 4db3842..67cc041 100644 --- a/Sources/Features/Media/RemoteImage.swift +++ b/Sources/Features/Media/RemoteImage.swift @@ -1,9 +1,9 @@ import SwiftUI #if canImport(UIKit) -import UIKit + import UIKit #elseif canImport(AppKit) -import AppKit + import AppKit #endif /// Lädt ein authentifiziertes Image vom Cardecky-Media-Endpoint und @@ -42,14 +42,16 @@ struct RemoteImage: View { @ViewBuilder private func imageView(_ image: PlatformImage) -> some View { #if canImport(UIKit) - Image(uiImage: image).resizable().aspectRatio(contentMode: contentMode) + Image(uiImage: image).resizable().aspectRatio(contentMode: contentMode) #elseif canImport(AppKit) - Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode) + Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode) #endif } private func load() async { - guard let cache = mediaCache else { failed = true; return } + guard let cache = mediaCache else { failed = true + return + } do { let data = try await cache.data(for: mediaId) if let img = PlatformImage(data: data) { @@ -64,7 +66,7 @@ struct RemoteImage: View { } #if canImport(UIKit) -typealias PlatformImage = UIImage + typealias PlatformImage = UIImage #elseif canImport(AppKit) -typealias PlatformImage = NSImage + typealias PlatformImage = NSImage #endif diff --git a/Sources/Features/Study/CardRenderer.swift b/Sources/Features/Study/CardRenderer.swift index 192a294..1ad002d 100644 --- a/Sources/Features/Study/CardRenderer.swift +++ b/Sources/Features/Study/CardRenderer.swift @@ -38,7 +38,6 @@ struct CardRenderer: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - @ViewBuilder private func basicView(front frontKey: String, back backKey: String) -> some View { VStack(spacing: 16) { text(card.fields[frontKey] ?? "") @@ -131,7 +130,6 @@ struct CardRenderer: View { } } - @ViewBuilder private var placeholderView: some View { VStack(spacing: 8) { Image(systemName: "questionmark.square.dashed") diff --git a/Sources/Features/Study/MultipleChoiceCardView.swift b/Sources/Features/Study/MultipleChoiceCardView.swift index 051b4f6..b7d58d1 100644 --- a/Sources/Features/Study/MultipleChoiceCardView.swift +++ b/Sources/Features/Study/MultipleChoiceCardView.swift @@ -15,10 +15,10 @@ struct MultipleChoiceCardView: View { @State private var selected: String? @State private var phase: LoadPhase = .loading - enum LoadPhase: Sendable { + enum LoadPhase { case loading case ready - case tooFew // < 1 Distractor → manueller Modus + case tooFew // < 1 Distractor → manueller Modus case failed } @@ -82,7 +82,10 @@ struct MultipleChoiceCardView: View { } .padding(.vertical, 12) .padding(.horizontal, 14) - .background(background(isCorrect: isCorrect, isSelected: isSelected), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .background( + background(isCorrect: isCorrect, isSelected: isSelected), + in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(border(isCorrect: isCorrect, isSelected: isSelected), lineWidth: 1) diff --git a/Sources/Features/Study/RatingBar.swift b/Sources/Features/Study/RatingBar.swift index 5c3d85b..d31b127 100644 --- a/Sources/Features/Study/RatingBar.swift +++ b/Sources/Features/Study/RatingBar.swift @@ -1,7 +1,7 @@ import SwiftUI #if canImport(UIKit) -import UIKit + import UIKit #endif /// Vier Rating-Buttons mit emphasis auf "Good" (full-width primary). @@ -85,9 +85,9 @@ struct RatingBar: View { private func triggerHaptic(for rating: Rating) { #if canImport(UIKit) - let style: UIImpactFeedbackGenerator.FeedbackStyle = - rating == .easy ? .heavy : .medium - UIImpactFeedbackGenerator(style: style).impactOccurred() + let style: UIImpactFeedbackGenerator.FeedbackStyle = + rating == .easy ? .heavy : .medium + UIImpactFeedbackGenerator(style: style).impactOccurred() #endif } } diff --git a/Sources/Features/Study/StudySession.swift b/Sources/Features/Study/StudySession.swift index ae2e887..3dfe5e5 100644 --- a/Sources/Features/Study/StudySession.swift +++ b/Sources/Features/Study/StudySession.swift @@ -8,7 +8,7 @@ import SwiftData @MainActor @Observable final class StudySession { - enum Phase: Sendable { + enum Phase { case loading case studying case finished @@ -55,7 +55,9 @@ final class StudySession { } else { phase = .studying } - Log.study.info("Session start — \(self.queue.count, privacy: .public) due in deck \(self.deckId, privacy: .public)") + let count = queue.count + let id = deckId + Log.study.info("Session start — \(count, privacy: .public) due in deck \(id, privacy: .public)") } catch { let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) phase = .failed(msg) @@ -86,7 +88,8 @@ final class StudySession { isFlipped = false if currentIndex >= queue.count { phase = .finished - Log.study.info("Session finished — graded \(self.totalGraded, privacy: .public)") + let count = totalGraded + Log.study.info("Session finished — graded \(count, privacy: .public)") } } } diff --git a/Sources/Features/Study/TypingCardView.swift b/Sources/Features/Study/TypingCardView.swift index 8e5b75e..f3eceb7 100644 --- a/Sources/Features/Study/TypingCardView.swift +++ b/Sources/Features/Study/TypingCardView.swift @@ -19,8 +19,13 @@ struct TypingCardView: View { @State private var result: TypingMatch? @FocusState private var inputFocused: Bool - private var answer: String { card.fields["answer"] ?? "" } - private var aliases: String? { card.fields["aliases"] } + private var answer: String { + card.fields["answer"] ?? "" + } + + private var aliases: String? { + card.fields["aliases"] + } var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -67,9 +72,9 @@ struct TypingCardView: View { .stroke(inputFocused ? CardsTheme.primary : CardsTheme.border, lineWidth: 1) ) .autocorrectionDisabled() - #if os(iOS) + #if os(iOS) .textInputAutocapitalization(.never) - #endif + #endif .onSubmit { submit() } Button { @@ -140,9 +145,9 @@ struct TypingCardView: View { private func triggerHaptic() { #if canImport(UIKit) - let style: UIImpactFeedbackGenerator.FeedbackStyle = - result == .correct ? .heavy : .light - UIImpactFeedbackGenerator(style: style).impactOccurred() + let style: UIImpactFeedbackGenerator.FeedbackStyle = + result == .correct ? .heavy : .light + UIImpactFeedbackGenerator(style: style).impactOccurred() #endif } @@ -183,5 +188,5 @@ struct TypingCardView: View { } #if canImport(UIKit) -import UIKit + import UIKit #endif diff --git a/Sources/Resources/Localizable.xcstrings b/Sources/Resources/Localizable.xcstrings index 44b775c..682888c 100644 --- a/Sources/Resources/Localizable.xcstrings +++ b/Sources/Resources/Localizable.xcstrings @@ -1,8 +1,14 @@ { "sourceLanguage" : "de", "strings" : { + "… und %@ weitere" : { + + }, "„%@“" : { + }, + "@%@" : { + }, "%@" : { @@ -12,27 +18,60 @@ }, "%@ fällige Karten aus abonnierten Decks" : { + }, + "%@ Karten" : { + }, "%@ Karten gelernt" : { + }, + "3–500 Zeichen. Je präziser, desto besser die Karten." : { + }, "Abmelden" : { + }, + "Account löschen…" : { + + }, + "AI-Moderation läuft — kann ein paar Sekunden dauern." : { + }, "Alle Karten und Reviews dieses Decks werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden." : { }, - "Anmelden" : { + "Anmelden / Konto erstellen" : { }, "Antwort anzeigen" : { + }, + "Archivierte Decks erscheinen nicht in der Hauptliste. Bestehende FSRS-Reviews bleiben erhalten." : { + }, "Aus Teilen-Menü" : { + }, + "Author-Profil anlegen" : { + }, "Beide Richtungen werden gelernt — front→back und back→front." : { + }, + "Beschreibung" : { + + }, + "Bestehendes Deck" : { + + }, + "Bild" : { + + }, + "Blockiere Authors über das Menü oben rechts auf Marketplace-Decks." : { + + }, + "Browse den Marketplace im Entdecken-Tab — kein Konto nötig. Für eigene Decks und Cloud-Sync logge dich ein." : { + }, "Card-Type »%@« kommt in einer späteren Phase" : { @@ -42,15 +81,60 @@ }, "Changelog" : { + }, + "CSV" : { + + }, + "CSV-Datei einlesen. Format: vorne,hinten[,typ] pro Zeile." : { + + }, + "Das kann eine Weile dauern." : { + + }, + "Datei" : { + + }, + "Deck-Metadaten" : { + + }, + "Decks dieses Authors erscheinen für dich nicht mehr im Marketplace." : { + + }, + "Der Slug wird Teil der Marketplace-URL: cardecky.mana.how/d/." : { + }, "Distractor-Optionen werden zur Lernzeit automatisch aus anderen Karten desselben Decks gezogen." : { }, "Distractors konnten nicht geladen werden." : { + }, + "Druck-Ansicht / PDF" : { + + }, + "Druck-Ansicht ist nur auf iOS verfügbar." : { + + }, + "Du hast schon Decks im Marketplace. Wähle eine, um eine neue Version zu publishen." : { + + }, + "Du nutzt Cardecky anonym" : { + }, "Erst ein Deck erstellen." : { + }, + "Erst-Publish: 1.0.0. Spätere Versionen müssen semver-größer sein." : { + + }, + "Format pro Zeile: vorne,hinten,typ. Typ-Spalte optional (Default basic)." : { + + }, + "Im Marketplace veröffentlichen" : { + + }, + "Image-Occlusion und Audio-Cards werden im CSV-Import übersprungen — die brauchen Datei-Uploads." : { + }, "Inbox" : { @@ -66,12 +150,45 @@ }, "Keine" : { + }, + "KI" : { + + }, + "KI generiert das Deck aus einer kurzen Beschreibung. 10 Anfragen pro Minute." : { + + }, + "KI liest Bilder oder PDFs und macht daraus Karten. Bis zu 5 Dateien." : { + + }, + "KI liest den Inhalt der Seite als zusätzliche Quelle." : { + }, "Lade Decks …" : { + }, + "Leer" : { + + }, + "Leeres Deck — Karten anschließend selbst anlegen." : { + + }, + "Marketplace und lokale Decks funktionieren ohne Konto. Für KI-Karten, eigene Decks im Cloud-Sync und Marketplace-Veröffentlichung brauchst du ein Konto." : { + + }, + "Max. %@ Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB." : { + + }, + "Metadaten ändern: Marketplace-Webansicht → Deck → Bearbeiten." : { + }, "Mit Hint: `{{c1::Berlin::Hauptstadt von DE}}`" : { + }, + "Neue Version: %@" : { + + }, + "Neues Marketplace-Deck" : { + }, "Nicht genug andere Karten im Deck für Multiple-Choice — tippe auf »Antwort anzeigen«." : { @@ -84,24 +201,60 @@ }, "Öffentlich" : { + }, + "OK" : { + + }, + "Pflicht-Schritt vor dem ersten Marketplace-Deck. Slug erscheint in Marketplace-URLs." : { + + }, + "Preis: %@ Credits" : { + }, "Privat" : { + }, + "Quellen" : { + }, "Space" : { }, - "Tippe oben auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab." : { + "Thema" : { }, "Tippe und ziehe auf das Bild, um eine Maske zu erstellen." : { + }, + "Tippe unten auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab." : { + + }, + "Veröffentlicht: %@" : { + + }, + "Veröffentlichungs-Modus" : { + + }, + "Version" : { + }, "Versuche eine andere Suche oder Sortierung." : { + }, + "Vorschau (%@ Karten)" : { + }, "Wählen …" : { + }, + "Wir prüfen jede Meldung. Hass und Rechtsverletzungen werden bevorzugt behandelt." : { + + }, + "Wird veröffentlicht …" : { + + }, + "Zusätzliche URL (optional)" : { + } }, "version" : "1.0" diff --git a/Tests/UITests/CardsNativeUITests.swift b/Tests/UITests/CardsNativeUITests.swift index f14e426..e81bea4 100644 --- a/Tests/UITests/CardsNativeUITests.swift +++ b/Tests/UITests/CardsNativeUITests.swift @@ -1,7 +1,7 @@ import XCTest final class CardsNativeUITests: XCTestCase { - func testAppLaunches() throws { + func testAppLaunches() { let app = XCUIApplication() app.launch() // App ist gestartet, sobald entweder das LoginView "Cardecky" diff --git a/Tests/UnitTests/DeckDecodingTests.swift b/Tests/UnitTests/DeckDecodingTests.swift index 9f6a5f1..1f3d912 100644 --- a/Tests/UnitTests/DeckDecodingTests.swift +++ b/Tests/UnitTests/DeckDecodingTests.swift @@ -6,7 +6,7 @@ import Testing struct DeckDecodingTests { @Test("Wire-Format aus toDeckDto decodet sauber") func decodesDeckFromWireFormat() throws { - let json = """ + let json = Data(""" { "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "user_id": "user_123", @@ -23,7 +23,7 @@ struct DeckDecodingTests { "created_at": "2026-05-12T10:30:00.123Z", "updated_at": "2026-05-12T15:45:00.456Z" } - """.data(using: .utf8)! + """.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional @@ -41,7 +41,7 @@ struct DeckDecodingTests { @Test("Marketplace-Forks werden erkannt") func recognizesMarketplaceFork() throws { - let json = """ + let json = Data(""" { "id": "deck_456", "user_id": "user_123", @@ -58,7 +58,7 @@ struct DeckDecodingTests { "created_at": "2026-05-01T00:00:00.000Z", "updated_at": "2026-05-01T00:00:00.000Z" } - """.data(using: .utf8)! + """.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional @@ -71,7 +71,7 @@ struct DeckDecodingTests { @Test("DeckListResponse-Wrapper") func decodesListResponse() throws { - let json = """ + let json = Data(""" { "decks": [ { @@ -93,7 +93,7 @@ struct DeckDecodingTests { ], "total": 1 } - """.data(using: .utf8)! + """.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional diff --git a/Tests/UnitTests/MarketplaceDecodingTests.swift b/Tests/UnitTests/MarketplaceDecodingTests.swift index c86904c..c5aad0f 100644 --- a/Tests/UnitTests/MarketplaceDecodingTests.swift +++ b/Tests/UnitTests/MarketplaceDecodingTests.swift @@ -12,7 +12,7 @@ struct MarketplaceDecodingTests { @Test("PublicDeckEntry aus Browse-Response") func decodesPublicDeckEntry() throws { - let json = """ + let json = Data(""" { "slug": "geografie-welt-top30", "title": "Geografie Welt Top 30", @@ -34,7 +34,7 @@ struct MarketplaceDecodingTests { "pseudonym": false } } - """.data(using: .utf8)! + """.utf8) let entry = try decoder().decode(PublicDeckEntry.self, from: json) #expect(entry.slug == "geografie-welt-top30") @@ -46,12 +46,12 @@ struct MarketplaceDecodingTests { @Test("ExploreResponse mit featured + trending") func decodesExploreResponse() throws { - let json = """ + let json = Data(""" { "featured": [], "trending": [] } - """.data(using: .utf8)! + """.utf8) let res = try decoder().decode(ExploreResponse.self, from: json) #expect(res.featured.isEmpty) #expect(res.trending.isEmpty) @@ -59,7 +59,7 @@ struct MarketplaceDecodingTests { @Test("PublicDeckDetail mit camelCase 'latest_version'") func decodesPublicDeckDetail() throws { - let json = """ + let json = Data(""" { "deck": { "id": "deck_1", @@ -88,7 +88,7 @@ struct MarketplaceDecodingTests { }, "owner": null } - """.data(using: .utf8)! + """.utf8) let detail = try decoder().decode(PublicDeckDetail.self, from: json) #expect(detail.deck.slug == "english-a2") @@ -99,14 +99,14 @@ struct MarketplaceDecodingTests { @Test("SubscribeResponse mit private_deck_id") func decodesSubscribeResponse() throws { - let json = """ + let json = Data(""" { "subscribed": true, "deck_slug": "english-a2", "current_version_id": "v_1", "private_deck_id": "private_deck_xyz" } - """.data(using: .utf8)! + """.utf8) let res = try decoder().decode(SubscribeResponse.self, from: json) #expect(res.subscribed == true) #expect(res.privateDeckId == "private_deck_xyz") diff --git a/Tests/UnitTests/MaskRegionsTests.swift b/Tests/UnitTests/MaskRegionsTests.swift index d11fb9e..2eb8069 100644 --- a/Tests/UnitTests/MaskRegionsTests.swift +++ b/Tests/UnitTests/MaskRegionsTests.swift @@ -44,7 +44,7 @@ struct MaskRegionsTests { func encodeRoundtrip() { let original = [ MaskRegion(id: "m1", x: 0.1, y: 0.2, w: 0.3, h: 0.4, label: "test"), - MaskRegion(id: "m2", x: 0.5, y: 0.6, w: 0.2, h: 0.2, label: nil), + MaskRegion(id: "m2", x: 0.5, y: 0.6, w: 0.2, h: 0.2, label: nil) ] let encoded = MaskRegions.encode(original) let parsed = MaskRegions.parse(encoded) diff --git a/Tests/UnitTests/MutationEncodingTests.swift b/Tests/UnitTests/MutationEncodingTests.swift index 1336ffe..29dddbe 100644 --- a/Tests/UnitTests/MutationEncodingTests.swift +++ b/Tests/UnitTests/MutationEncodingTests.swift @@ -4,9 +4,16 @@ import Testing @Suite("Mutation Body Encoding") struct MutationEncodingTests { - private func encode(_ value: T) throws -> [String: Any] { + private func encode(_ value: some Encodable) throws -> [String: Any] { let data = try JSONEncoder().encode(value) - return try JSONSerialization.jsonObject(with: data) as! [String: Any] + guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw EncodeError.notADictionary + } + return dict + } + + private enum EncodeError: Error { + case notADictionary } @Test("DeckCreateBody nutzt snake_case und lässt nil weg") diff --git a/Tests/UnitTests/ReviewDecodingTests.swift b/Tests/UnitTests/ReviewDecodingTests.swift index 1db436a..71c35f3 100644 --- a/Tests/UnitTests/ReviewDecodingTests.swift +++ b/Tests/UnitTests/ReviewDecodingTests.swift @@ -6,7 +6,7 @@ import Testing struct ReviewDecodingTests { @Test("Review-Wire-Format decodet vollständig") func decodesReview() throws { - let json = """ + let json = Data(""" { "card_id": "card_1", "sub_index": 0, @@ -22,7 +22,7 @@ struct ReviewDecodingTests { "state": "review", "last_review": "2026-05-10T10:00:00.000Z" } - """.data(using: .utf8)! + """.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional @@ -39,7 +39,7 @@ struct ReviewDecodingTests { func decodesDueReview() throws { // Achtung: Server liefert hier `deckId` camelCase im embedded card, // weil das aus Drizzle direkt rauskommt, nicht durch toCardDto. - let json = """ + let json = Data(""" { "card_id": "c1", "sub_index": 0, @@ -61,7 +61,7 @@ struct ReviewDecodingTests { "fields": {"front": "Was ist 1+1?", "back": "2"} } } - """.data(using: .utf8)! + """.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional From 8280d3d0ba7ac3e0ebabb369a3ea5af62ad154e7 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 14 May 2026 21:31:49 +0200 Subject: [PATCH 21/21] =?UTF-8?q?style(decks):=20inline=20title=20+=20scro?= =?UTF-8?q?ll-transition=20entfernt=20f=C3=BCr=20ruhigeren=20Look?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DeckListView + ExploreView: navigationBarTitleDisplayMode(.inline) ergänzt (iOS-gated), Titel sitzt jetzt klein oben — visuell angleichend an AccountView. - Scroll-transition (scaleEffect 0.92 + opacity 0.7) auf eigenen Decks und Marketplace-Explore entfernt — Tiles bleiben konstant in Größe und voller Opazität. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Features/Decks/DeckListView.swift | 8 +++----- Sources/Features/Marketplace/ExploreView.swift | 8 +++----- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/Sources/Features/Decks/DeckListView.swift b/Sources/Features/Decks/DeckListView.swift index d2ef285..b2a0705 100644 --- a/Sources/Features/Decks/DeckListView.swift +++ b/Sources/Features/Decks/DeckListView.swift @@ -39,6 +39,9 @@ struct DeckListView: View { content } .navigationTitle("Decks") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif .navigationDestination(for: DeckRoute.self) { route in switch route { case let .study(deckId, deckName): @@ -129,11 +132,6 @@ struct DeckListView: View { onEdit: { path.append(DeckRoute.detail(deckId: deck.id)) } ) .frame(width: 240) - .scrollTransition(.animated) { content, phase in - content - .scaleEffect(phase.isIdentity ? 1 : 0.92) - .opacity(phase.isIdentity ? 1 : 0.7) - } } } .padding(.horizontal, 20) diff --git a/Sources/Features/Marketplace/ExploreView.swift b/Sources/Features/Marketplace/ExploreView.swift index aeb6492..f204527 100644 --- a/Sources/Features/Marketplace/ExploreView.swift +++ b/Sources/Features/Marketplace/ExploreView.swift @@ -20,6 +20,9 @@ struct ExploreView: View { content } .navigationTitle("Entdecken") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif .navigationDestination(for: MarketplaceRoute.self) { route in switch route { case .browse: @@ -115,11 +118,6 @@ struct ExploreView: View { NavigationLink(value: MarketplaceRoute.publicDeck(slug: item.slug)) { PublicDeckCard(entry: item) .frame(width: 240) - .scrollTransition(.animated) { content, phase in - content - .scaleEffect(phase.isIdentity ? 1 : 0.92) - .opacity(phase.isIdentity ? 1 : 0.7) - } } .buttonStyle(.plain) }