Compare commits

...

21 commits
v0.8.1 ... main

Author SHA1 Message Date
Till JS
8280d3d0ba style(decks): inline title + scroll-transition entfernt für ruhigeren Look
- 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) <noreply@anthropic.com>
2026-05-14 21:31:49 +02:00
Till JS
aece169360 chore(lint): SwiftLint-Config + 0-Warnings-Pass + Swift-6-Concurrency-Fixes
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) <noreply@anthropic.com>
2026-05-14 02:04:29 +02:00
Till JS
73f9081fa1 feat(decks): γ-1 bis γ-8 — AI/CSV-Import, Card-Edit, Pull-Update, Marketplace-Publish + Moderation + PDF
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) <noreply@anthropic.com>
2026-05-14 02:03:59 +02:00
Till JS
8ca7bd3636 feat(auth): Guest-Mode + Login-optionale Surface
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) <noreply@anthropic.com>
2026-05-14 01:23:30 +02:00
Till JS
da6679770b feat(auth): ManaAuthUI-Migration — vollständige Auth-Reise nativ
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) <noreply@anthropic.com>
2026-05-13 19:26:12 +02:00
Till JS
710ede6acd 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) <noreply@anthropic.com>
2026-05-13 19:09:01 +02:00
Till JS
6805bd78c7 feat(decks): iOS-26 tabViewBottomAccessory für „Neues Deck"-Pille
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) <noreply@anthropic.com>
2026-05-13 19:08:57 +02:00
Till JS
0e90f4b1c1 chore: PLAN.md auf v0.9.4-Stand + Localizable.xcstrings
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) <noreply@anthropic.com>
2026-05-13 18:15:22 +02:00
Till JS
90201d7199 feat(decks): Tile-Redesign — Tap=Study, Edit-Icon, Explore-Konsistenz
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) <noreply@anthropic.com>
2026-05-13 18:03:47 +02:00
Till JS
33101d703d feat(auth): DEBUG-Auto-Login (Memoro-Pattern)
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) <noreply@anthropic.com>
2026-05-13 17:50:29 +02:00
Till JS
505aa9db19 feat(study): Typing-Karten + Levenshtein-Match-Logik
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) <noreply@anthropic.com>
2026-05-13 17:39:39 +02:00
Till JS
8b1dd5158f feat(study): Multiple-Choice-Karten gerendert
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) <noreply@anthropic.com>
2026-05-13 17:34:07 +02:00
Till JS
aa94601409 feat(ui): Cardecky-Web-Design — Fan-Stack-Tiles + CardSurface
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) <noreply@anthropic.com>
2026-05-13 17:28:11 +02:00
Till JS
0b0872c8c0 fix: 404 auf allen Query-Endpoints — pulls ManaCore v1.0.1 fix
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) <noreply@anthropic.com>
2026-05-13 17:20:03 +02:00
Till JS
e8b898a51d feat(decks): Card-Liste im DeckDetailView + listCards-API
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) <noreply@anthropic.com>
2026-05-13 16:02:59 +02:00
Till JS
f528ea448a fix: PublicDeckOwner.pseudonym ist Bool, nicht String?
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) <noreply@anthropic.com>
2026-05-13 15:46:20 +02:00
Till JS
b5edf5cf2e fix: User-facing Strings Cards → Cardecky komplett, Build 3
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) <noreply@anthropic.com>
2026-05-13 15:11:11 +02:00
Till JS
2eb7f61f9c fix: ITMS-90129 — DisplayName Cards → Cardecky + Build 2
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) <noreply@anthropic.com>
2026-05-13 15:05:33 +02:00
Till JS
e24e0e6825 fix(editor): PhotosPicker Sendable-Warning durch Sub-View struct
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) <noreply@anthropic.com>
2026-05-13 14:39:09 +02:00
Till JS
6a4d66fd74 chore: Sendable-Warning + AppIcon-Asset-Cleanup
- 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) <noreply@anthropic.com>
2026-05-13 14:12:29 +02:00
Till JS
6d9a191527 chore: Archive-Polish — Versions-Sync + Orientations + Mac-Icon
- 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) <noreply@anthropic.com>
2026-05-13 14:06:11 +02:00
78 changed files with 5571 additions and 994 deletions

View file

@ -1,6 +1,10 @@
disabled_rules: disabled_rules:
- todo - todo
- trailing_comma - 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: opt_in_rules:
- empty_count - empty_count
@ -18,8 +22,31 @@ line_length:
identifier_name: identifier_name:
min_length: 2 min_length: 2
excluded: excluded:
# Standard-Identifier
- id - id
- ok - 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: included:
- Sources - Sources

53
PLAN.md
View file

@ -1,13 +1,20 @@
# Plan — cards-native (SwiftUI Universal) # Plan — cards-native (SwiftUI Universal)
**Stand: 2026-05-13 — Phasen β-0 bis β-7 abgeschlossen.** **Stand: 2026-05-13 — TestFlight Build 11 (v0.9.4).** Alle Phasen
Feature-komplett für TestFlight. Alle 7 Card-Types + Marketplace β-0 bis β-7 + Polish-Iterationen. 43 Unit-Tests + 1 UI-Test grün.
+ Keyboard/Daily-Reminder/Widget + Siri-Shortcut + Share-Extension
+ App-Icon-Platzhalter + Release-Checklist. 35 Unit-Tests + 1 UI-Test **Cardecky-Web-Look übernommen** (v0.9.0 ff.): Fan-Stack-Tiles
grün, alle drei Targets (Haupt-App + Widget + Share) bauen. (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 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`. > **SOT:** `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`.
> Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc > 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) - `LoginView` (Email/PW gegen mana-auth)
- 3 Unit-Tests (AppConfig) - 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`)** ✅ **β-7 — App-Store-Vorbereitung (2026-05-13, Tag `v0.8.0`)**
- App-Icon-Platzhalter: `scripts/make-appicon.swift` generiert 1024×1024 - App-Icon-Platzhalter: `scripts/make-appicon.swift` generiert 1024×1024
PNG aus CoreGraphics (forest-green + "C"-Letter). Asset-Catalog auf PNG aus CoreGraphics (forest-green + "C"-Letter). Asset-Catalog auf

View file

@ -39,7 +39,7 @@ struct ShareEditorView: View {
} }
} }
Section { Section {
Text("Wähle das Ziel-Deck in der Cards-App.") Text("Wähle das Ziel-Deck in der Cardecky-App.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }

View file

@ -31,16 +31,27 @@ final class ShareViewController: UIViewController {
group.enter() group.enter()
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in
if let url = item as? URL { if let url = item as? URL {
self.sharedURL = url.absoluteString let absolute = url.absoluteString
if self.sharedText.isEmpty { self.sharedText = 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) { } else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
group.enter() group.enter()
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
if let text = item as? String { self.sharedText = text } if let text = item as? String {
group.leave() DispatchQueue.main.async {
self.sharedText = text
group.leave()
}
} else {
group.leave()
}
} }
} }
} }

View file

@ -1,3 +1,4 @@
import ManaAuthUI
import ManaCore import ManaCore
import SwiftData import SwiftData
import SwiftUI import SwiftUI
@ -6,6 +7,7 @@ import SwiftUI
struct CardsNativeApp: App { struct CardsNativeApp: App {
let container: ModelContainer let container: ModelContainer
@State private var auth: AuthClient @State private var auth: AuthClient
@State private var authGate: ManaAuthGate
private let mediaCache: MediaCache private let mediaCache: MediaCache
init() { init() {
@ -17,14 +19,16 @@ struct CardsNativeApp: App {
let auth = AuthClient(config: AppConfig.manaAppConfig) let auth = AuthClient(config: AppConfig.manaAppConfig)
auth.bootstrap() auth.bootstrap()
_auth = State(initialValue: auth) _auth = State(initialValue: auth)
_authGate = State(initialValue: ManaAuthGate(auth: auth))
mediaCache = MediaCache(api: CardsAPI(auth: 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 { var body: some Scene {
WindowGroup { WindowGroup {
RootView() RootView()
.environment(auth) .environment(auth)
.environment(authGate)
.environment(\.mediaCache, mediaCache) .environment(\.mediaCache, mediaCache)
.tint(CardsTheme.primary) .tint(CardsTheme.primary)
} }

View file

@ -1,30 +1,97 @@
import ManaAuthUI
import ManaCore import ManaCore
import SwiftUI import SwiftUI
/// Top-Level-Switch: Login vs Haupt-App. Haupt-App ist eine TabBar mit /// Top-Level-View: TabBar mit drei Tabs (Decks / Entdecken / Account).
/// 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 { struct RootView: View {
@Environment(AuthClient.self) private var auth @Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@State private var selectedTab: AppTab = .decks @State private var selectedTab: AppTab = .decks
@State private var pendingDeepLinkSlug: String? @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 { var body: some View {
switch auth.status { mainTabs
case .signedIn: .onOpenURL { url in handle(url: url) }
mainTabs .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
.onOpenURL { url in handle(url: url) } if let url = activity.webpageURL { 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)"
)
}
} }
case .unknown, .signedOut, .signingIn, .error: }
LoginView() }
/// 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)
}
} }
} }
@ViewBuilder
private var mainTabs: some View { private var mainTabs: some View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
DeckListView() DeckListView(showCreate: $showCreateDeck)
.tabItem { Label("Decks", systemImage: "rectangle.stack") } .tabItem { Label("Decks", systemImage: "rectangle.stack") }
.tag(AppTab.decks) .tag(AppTab.decks)
@ -38,20 +105,44 @@ struct RootView: View {
.tabItem { Label("Account", systemImage: "person.crop.circle") } .tabItem { Label("Account", systemImage: "person.crop.circle") }
.tag(AppTab.account) .tag(AppTab.account)
} }
.decksCreateAccessory(visible: selectedTab == .decks) {
authGate.require(reason: "deck-create-accessory") {
showCreateDeck = true
}
}
} }
/// Universal-Link- und URL-Scheme-Handler: /// Universal-Link- und URL-Scheme-Handler:
/// - `https://cardecky.mana.how/d/<slug>` Explore-Tab + PublicDeckView /// - `https://cardecky.mana.how/d/<slug>` Explore-Tab + PublicDeckView
/// - `https://cardecky.mana.how/auth/reset?token=` ManaResetPasswordView
/// - `cards://study/<deckId>` später (β-6 Notifications) /// - `cards://study/<deckId>` später (β-6 Notifications)
private func handle(url: URL) { private func handle(url: URL) {
Log.app.info("Open URL: \(url.absoluteString, privacy: .public)") Log.app.info("Open URL: \(url.absoluteString, privacy: .public)")
if url.host == "cardecky.mana.how" || url.scheme == "cards" { guard url.host == "cardecky.mana.how" || url.scheme == "cards" else { return }
let parts = url.pathComponents.filter { $0 != "/" }
if parts.count >= 2, parts[0] == "d" { let parts = url.pathComponents.filter { $0 != "/" }
pendingDeepLinkSlug = parts[1]
selectedTab = .explore // Auth-Reset-Link aus der Passwort-Vergessen-Email.
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" {
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
} }
} }
@ -60,3 +151,38 @@ enum AppTab: Hashable {
case explore case explore
case account 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.
///
/// 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, *), visible {
tabViewBottomAccessory {
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))
}
.buttonStyle(.glass)
.tint(CardsTheme.primary)
.accessibilityLabel("Neues Deck erstellen")
}
}

View file

@ -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 1060s
/// (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
}
}

View file

@ -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
}
}

View file

@ -1,11 +1,16 @@
import Foundation import Foundation
import ManaCore import ManaCore
// swiftlint:disable file_length
// swiftlint:disable type_body_length
/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport` /// 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 { actor CardsAPI {
private let transport: AuthenticatedTransport let transport: AuthenticatedTransport
private let decoder: JSONDecoder let decoder: JSONDecoder
init(auth: AuthClient) { init(auth: AuthClient) {
transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth) transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth)
@ -44,6 +49,29 @@ actor CardsAPI {
return try decoder.decode(CardListResponse.self, from: data).total 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/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 /// `GET /api/v1/reviews/due?deck_id=...&limit=500` Anzahl fälliger
/// Reviews in einem Deck. /// Reviews in einem Deck.
func dueCount(deckId: String) async throws -> Int { func dueCount(deckId: String) async throws -> Int {
@ -74,7 +102,7 @@ actor CardsAPI {
var items: [URLQueryItem] = [ var items: [URLQueryItem] = [
.init(name: "sort", value: sort.rawValue), .init(name: "sort", value: sort.rawValue),
.init(name: "limit", value: "\(limit)"), .init(name: "limit", value: "\(limit)"),
.init(name: "offset", value: "\(offset)"), .init(name: "offset", value: "\(offset)")
] ]
if let query, !query.trimmingCharacters(in: .whitespaces).isEmpty { if let query, !query.trimmingCharacters(in: .whitespaces).isEmpty {
items.append(.init(name: "q", value: query)) items.append(.init(name: "q", value: query))
@ -147,7 +175,7 @@ actor CardsAPI {
func fetchMedia(id: String) async throws -> Data { func fetchMedia(id: String) async throws -> Data {
let (data, http) = try await transport.request(path: "/api/v1/media/\(id)") let (data, http) = try await transport.request(path: "/api/v1/media/\(id)")
guard (200 ..< 300).contains(http.statusCode) else { 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 return data
} }
@ -155,7 +183,7 @@ actor CardsAPI {
/// `DELETE /api/v1/media/:id` Soft-Forget. (Endpoint heute nicht /// `DELETE /api/v1/media/:id` Soft-Forget. (Endpoint heute nicht
/// implementiert serverseitig; Stub bleibt für späteren Use.) /// implementiert serverseitig; Stub bleibt für späteren Use.)
func deleteMedia(id _: String) async throws { 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 // MARK: - Deck-Mutations
@ -195,6 +223,87 @@ actor CardsAPI {
try ensureOK(http, data: data) 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 // MARK: - Card-Mutations
/// `POST /api/v1/cards` Karte anlegen. Server validiert `fields` /// `POST /api/v1/cards` Karte anlegen. Server validiert `fields`
@ -270,44 +379,24 @@ actor CardsAPI {
// MARK: - JSON-Encoding // MARK: - JSON-Encoding
private func makeJSON<T: Encodable>(_ value: T) throws -> Data { func makeJSON(_ value: some Encodable) throws -> Data {
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601 encoder.dateEncodingStrategy = .iso8601
return try encoder.encode(value) 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 // MARK: - Helpers
private func ensureOK(_ http: HTTPURLResponse, data: Data) throws { func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
guard (200 ..< 300).contains(http.statusCode) else { guard (200 ..< 300).contains(http.statusCode) else {
let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error 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)
} }
} }
} }
// swiftlint:enable type_body_length
private struct CardsServerError: Decodable { private struct CardsServerError: Decodable {
let error: String? let error: String?
} }

View file

@ -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
}
}

View file

@ -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

View file

@ -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:
///
/// <question>,<answer>[,<type>]
///
/// - 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)
}
}
}

View file

@ -2,7 +2,7 @@ import Foundation
/// Card-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toCardDto` /// Card-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toCardDto`
/// und `cards/packages/cards-domain/src/schemas/card.ts`. /// und `cards/packages/cards-domain/src/schemas/card.ts`.
struct Card: Codable, Identifiable, Hashable, Sendable { struct Card: Codable, Identifiable, Hashable {
let id: String let id: String
let deckId: String let deckId: String
let userId: String let userId: String
@ -29,7 +29,7 @@ struct Card: Codable, Identifiable, Hashable, Sendable {
/// Card-Type-Enum. Vollständig aus `CardTypeSchema`. In β-2 rendern /// Card-Type-Enum. Vollständig aus `CardTypeSchema`. In β-2 rendern
/// wir nur `basic`, `basic-reverse`, `cloze`. Die anderen Types /// wir nur `basic`, `basic-reverse`, `cloze`. Die anderen Types
/// kommen in β-3 und β-4 dazu, sind aber jetzt schon decodierbar. /// 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 basic
case basicReverse = "basic-reverse" case basicReverse = "basic-reverse"
case cloze case cloze
@ -43,7 +43,7 @@ enum CardType: String, Codable, Sendable, CaseIterable {
/// Server liefert nur 4 Felder (id, deckId, type, fields) als Drizzle- /// Server liefert nur 4 Felder (id, deckId, type, fields) als Drizzle-
/// Joined-Subset Achtung: `deckId` hier in **camelCase**, nicht /// Joined-Subset Achtung: `deckId` hier in **camelCase**, nicht
/// snake_case wie sonst. /// snake_case wie sonst.
struct ReviewCard: Codable, Hashable, Sendable { struct ReviewCard: Codable, Hashable {
let id: String let id: String
let deckId: String let deckId: String
let type: CardType let type: CardType

View file

@ -10,7 +10,7 @@ import Foundation
/// - multiple-choice: `front`, `answer` /// - multiple-choice: `front`, `answer`
/// - image-occlusion: `image_ref`, `mask_regions` (β-4) /// - image-occlusion: `image_ref`, `mask_regions` (β-4)
/// - audio-front: `audio_ref`, `back` (β-4) /// - audio-front: `audio_ref`, `back` (β-4)
struct CardCreateBody: Encodable, Sendable { struct CardCreateBody: Encodable {
let deckId: String let deckId: String
let type: CardType let type: CardType
let fields: [String: String] 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` /// Body für `PATCH /api/v1/cards/:id`. Nur `fields` und `media_refs`
/// Type und deck_id sind immutable (Server-Schema). /// Type und deck_id sind immutable (Server-Schema).
struct CardUpdateBody: Encodable, Sendable { struct CardUpdateBody: Encodable {
var fields: [String: String]? var fields: [String: String]?
var mediaRefs: [String]? var mediaRefs: [String]?

View file

@ -12,12 +12,18 @@ import Foundation
/// 1-basierte Cluster-ID. Mehrere Cluster pro Karte mehrere /// 1-basierte Cluster-ID. Mehrere Cluster pro Karte mehrere
/// Sub-Index-Reviews. /// Sub-Index-Reviews.
enum Cloze { enum Cloze {
// swiftlint:disable large_tuple
/// Pattern für `{{cN::answer(::hint)?}}`. Pro Call konstruiert, /// Pattern für `{{cN::answer(::hint)?}}`. Pro Call konstruiert,
/// weil `Regex` unter Strict-Concurrency nicht Sendable ist. /// 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?)> { private static var clusterPattern: Regex<(Substring, Substring, Substring, Substring?)> {
#/\{\{c(\d+)::([^}]*?)(?:::([^}]*?))?\}\}/# #/\{\{c(\d+)::([^}]*?)(?:::([^}]*?))?\}\}/#
} }
// swiftlint:enable large_tuple
/// Distinct Cluster-IDs, sortiert. /// Distinct Cluster-IDs, sortiert.
static func extractClusterIds(_ text: String) -> [Int] { static func extractClusterIds(_ text: String) -> [Int] {
var ids = Set<Int>() var ids = Set<Int>()

View file

@ -2,7 +2,7 @@ import Foundation
/// Deck-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toDeckDto`. /// Deck-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toDeckDto`.
/// snake_case-Felder via `CodingKeys`, Optionals explizit nullable. /// snake_case-Felder via `CodingKeys`, Optionals explizit nullable.
struct Deck: Codable, Identifiable, Hashable, Sendable { struct Deck: Codable, Identifiable, Hashable {
let id: String let id: String
let userId: String let userId: String
let name: 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 `private`
case space case space
case `public` case `public`
} }
/// Aus `cards/packages/cards-domain/src/schemas/deck.ts:DECK_CATEGORY_IDS`. /// 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 language
case medicine case medicine
case science case science
@ -82,7 +82,7 @@ enum DeckCategory: String, Codable, Sendable, CaseIterable {
/// FSRS-Settings Native bleibt schematisch agnostisch, FSRS rechnet /// FSRS-Settings Native bleibt schematisch agnostisch, FSRS rechnet
/// nur der Server. Wir behalten die Felder als roh-JSON, damit eine /// nur der Server. Wir behalten die Felder als roh-JSON, damit eine
/// neue Setting auf dem Server uns nicht bricht. /// neue Setting auf dem Server uns nicht bricht.
struct FsrsSettings: Codable, Sendable, Hashable { struct FsrsSettings: Codable, Hashable {
let requestRetention: Double? let requestRetention: Double?
let maximumInterval: Int? let maximumInterval: Int?
let enableFuzz: Bool? let enableFuzz: Bool?
@ -114,17 +114,23 @@ struct FsrsSettings: Codable, Sendable, Hashable {
} }
/// Server-Response von `GET /api/v1/decks`. /// Server-Response von `GET /api/v1/decks`.
struct DeckListResponse: Decodable, Sendable { struct DeckListResponse: Decodable {
let decks: [Deck] let decks: [Deck]
let total: Int let total: Int
} }
/// Server-Response von `GET /api/v1/cards?deck_id=...`. /// Server-Response von `GET /api/v1/cards?deck_id=...`.
struct CardListResponse: Decodable, Sendable { struct CardListResponse: Decodable {
let cards: [Card]
let total: Int let total: Int
} }
/// Server-Response von `GET /api/v1/reviews/due?deck_id=...`. /// Server-Response von `GET /api/v1/reviews/due?deck_id=...`.
struct DueReviewsResponse: Decodable, Sendable { struct DueReviewsResponse: Decodable {
let total: Int let total: Int
} }
/// Server-Response von `GET /api/v1/decks/:deckId/distractors`.
struct DistractorsResponse: Decodable {
let distractors: [String]
}

View file

@ -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"
}
}

View file

@ -2,7 +2,7 @@ import Foundation
/// Body für `POST /api/v1/decks`. Aus `DeckCreateSchema` in /// Body für `POST /api/v1/decks`. Aus `DeckCreateSchema` in
/// `cards/packages/cards-domain/src/schemas/deck.ts`. /// `cards/packages/cards-domain/src/schemas/deck.ts`.
struct DeckCreateBody: Encodable, Sendable { struct DeckCreateBody: Encodable {
let name: String let name: String
let description: String? let description: String?
let color: 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`. /// Body für `PATCH /api/v1/decks/:id`. Alle Felder optional plus `archived`.
struct DeckUpdateBody: Encodable, Sendable { struct DeckUpdateBody: Encodable {
var name: String? var name: String?
var description: String? var description: String?
var color: String? var color: String?
@ -36,3 +36,37 @@ struct DeckUpdateBody: Encodable, Sendable {
case archived 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"
}
}

View file

@ -1,7 +1,7 @@
import Foundation import Foundation
/// Browse-Eintrag aus `/api/v1/marketplace/decks` und `.../explore`. /// Browse-Eintrag aus `/api/v1/marketplace/decks` und `.../explore`.
struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable { struct PublicDeckEntry: Codable, Hashable, Identifiable {
let slug: String let slug: String
let title: String let title: String
let description: String? let description: String?
@ -16,7 +16,9 @@ struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable {
let createdAt: Date let createdAt: Date
let owner: PublicDeckOwner let owner: PublicDeckOwner
var id: String { slug } var id: String {
slug
}
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case slug, title, description, language, category, license case slug, title, description, language, category, license
@ -29,15 +31,21 @@ struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable {
case owner 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 slug: String
let displayName: String let displayName: String
let verifiedMana: Bool let verifiedMana: Bool
let verifiedCommunity: 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 { enum CodingKeys: String, CodingKey {
case slug case slug
@ -53,24 +61,24 @@ struct PublicDeckOwner: Codable, Hashable, Sendable {
displayName = try c.decode(String.self, forKey: .displayName) displayName = try c.decode(String.self, forKey: .displayName)
verifiedMana = try c.decode(Bool.self, forKey: .verifiedMana) verifiedMana = try c.decode(Bool.self, forKey: .verifiedMana)
verifiedCommunity = try c.decode(Bool.self, forKey: .verifiedCommunity) 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
} }
} }
/// Response von `GET /api/v1/marketplace/explore`. /// Response von `GET /api/v1/marketplace/explore`.
struct ExploreResponse: Decodable, Sendable { struct ExploreResponse: Decodable {
let featured: [PublicDeckEntry] let featured: [PublicDeckEntry]
let trending: [PublicDeckEntry] let trending: [PublicDeckEntry]
} }
/// Response von `GET /api/v1/marketplace/decks`. /// Response von `GET /api/v1/marketplace/decks`.
struct BrowseResponse: Decodable, Sendable { struct BrowseResponse: Decodable {
let items: [PublicDeckEntry] let items: [PublicDeckEntry]
let total: Int let total: Int
} }
/// Vollständiges Public-Deck aus `GET /api/v1/marketplace/decks/:slug`. /// 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 id: String
let slug: String let slug: String
let title: String let title: String
@ -96,7 +104,7 @@ struct PublicDeck: Codable, Hashable, Sendable, Identifiable {
} }
} }
struct PublicDeckVersion: Codable, Hashable, Sendable, Identifiable { struct PublicDeckVersion: Codable, Hashable, Identifiable {
let id: String let id: String
let deckId: String let deckId: String
let semver: String let semver: String
@ -119,7 +127,7 @@ struct PublicDeckVersion: Codable, Hashable, Sendable, Identifiable {
} }
/// Response von `GET /api/v1/marketplace/decks/:slug`. /// Response von `GET /api/v1/marketplace/decks/:slug`.
struct PublicDeckDetail: Decodable, Sendable { struct PublicDeckDetail: Decodable {
let deck: PublicDeck let deck: PublicDeck
let latestVersion: PublicDeckVersion? let latestVersion: PublicDeckVersion?
let owner: PublicDeckOwner? let owner: PublicDeckOwner?
@ -132,7 +140,7 @@ struct PublicDeckDetail: Decodable, Sendable {
} }
/// Response von `POST /api/v1/marketplace/decks/:slug/subscribe`. /// Response von `POST /api/v1/marketplace/decks/:slug/subscribe`.
struct SubscribeResponse: Decodable, Sendable { struct SubscribeResponse: Decodable {
let subscribed: Bool let subscribed: Bool
let deckSlug: String let deckSlug: String
let currentVersionId: String? let currentVersionId: String?
@ -147,7 +155,7 @@ struct SubscribeResponse: Decodable, Sendable {
} }
/// Browse-Sort-Optionen aus `BrowseQuerySchema`. /// Browse-Sort-Optionen aus `BrowseQuerySchema`.
enum MarketplaceSort: String, Sendable, CaseIterable { enum MarketplaceSort: String, CaseIterable {
case recent case recent
case popular case popular
case trending case trending

View file

@ -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]
}

View file

@ -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
}
}
}

View file

@ -1,7 +1,7 @@
import Foundation import Foundation
/// Response von `POST /api/v1/media/upload`. /// Response von `POST /api/v1/media/upload`.
struct MediaUploadResponse: Decodable, Sendable { struct MediaUploadResponse: Decodable {
let id: String let id: String
let url: String let url: String
let mimeType: String let mimeType: String
@ -19,7 +19,7 @@ struct MediaUploadResponse: Decodable, Sendable {
} }
} }
enum MediaKind: String, Codable, Sendable { enum MediaKind: String, Codable {
case image case image
case audio case audio
case video case video
@ -29,7 +29,7 @@ enum MediaKind: String, Codable, Sendable {
/// Image-Occlusion-Mask-Region. /// Image-Occlusion-Mask-Region.
/// `mask_regions`-Feld ist ein JSON-Array-**String** in `fields`, /// `mask_regions`-Feld ist ein JSON-Array-**String** in `fields`,
/// nicht ein Object Server-Schema-Constraint (`fields: Record<string,string>`). /// nicht ein Object Server-Schema-Constraint (`fields: Record<string,string>`).
struct MaskRegion: Codable, Hashable, Sendable, Identifiable { struct MaskRegion: Codable, Hashable, Identifiable {
let id: String let id: String
let x: Double // 0..1 relativ let x: Double // 0..1 relativ
let y: Double let y: Double
@ -53,7 +53,7 @@ enum MaskRegions {
/// Bei Parse- oder Schema-Fehler: leere Liste. Sortiert nach ID /// Bei Parse- oder Schema-Fehler: leere Liste. Sortiert nach ID
/// (lexikographisch, gleich wie Server-Sortierung). /// (lexikographisch, gleich wie Server-Sortierung).
static func parse(_ json: String) -> [MaskRegion] { 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 [] } guard let regions = try? JSONDecoder().decode([MaskRegion].self, from: data) else { return [] }
return regions.sorted { $0.id < $1.id } return regions.sorted { $0.id < $1.id }
} }
@ -73,8 +73,10 @@ enum MaskRegions {
static func encode(_ regions: [MaskRegion]) -> String { static func encode(_ regions: [MaskRegion]) -> String {
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys] encoder.outputFormatting = [.sortedKeys]
guard let data = try? encoder.encode(regions) else { return "[]" } guard let data = try? encoder.encode(regions),
return String(decoding: data, as: UTF8.self) let json = String(bytes: data, encoding: .utf8)
else { return "[]" }
return json
} }
} }
@ -88,7 +90,7 @@ extension CardFieldsBuilder {
) -> [String: String] { ) -> [String: String] {
var fields: [String: String] = [ var fields: [String: String] = [
"image_ref": imageRef, "image_ref": imageRef,
"mask_regions": MaskRegions.encode(regions), "mask_regions": MaskRegions.encode(regions)
] ]
if let note, !note.isEmpty { if let note, !note.isEmpty {
fields["note"] = note fields["note"] = note

View file

@ -2,7 +2,7 @@ import Foundation
/// Rating-Werte für `POST /reviews/:cardId/:subIndex/grade`. /// Rating-Werte für `POST /reviews/:cardId/:subIndex/grade`.
/// Aus `cards/packages/cards-domain/src/schemas/review.ts:RatingSchema`. /// Aus `cards/packages/cards-domain/src/schemas/review.ts:RatingSchema`.
enum Rating: String, Codable, Sendable, CaseIterable { enum Rating: String, Codable, CaseIterable {
case again case again
case hard case hard
case good case good
@ -30,7 +30,7 @@ enum Rating: String, Codable, Sendable, CaseIterable {
} }
/// FSRS-Review-State. Aus `ReviewStateSchema`. /// FSRS-Review-State. Aus `ReviewStateSchema`.
enum ReviewState: String, Codable, Sendable { enum ReviewState: String, Codable {
case new case new
case learning case learning
case review case review
@ -38,7 +38,7 @@ enum ReviewState: String, Codable, Sendable {
} }
/// Review-DTO. Wire-Format aus `cards/apps/api/src/routes/reviews.ts:toReviewDto`. /// 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 cardId: String
let subIndex: Int let subIndex: Int
let userId: String let userId: String
@ -71,11 +71,13 @@ struct Review: Codable, Hashable, Sendable {
} }
/// Eintrag aus `/reviews/due?deck_id=X` Review + zugehörige Card. /// 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 review: Review
let card: ReviewCard let card: ReviewCard
var id: String { "\(review.cardId)-\(review.subIndex)" } var id: String {
"\(review.cardId)-\(review.subIndex)"
}
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) 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`. /// Wrapper-Response von `GET /api/v1/reviews/due?deck_id=X`.
struct DueReviewsListResponse: Decodable, Sendable { struct DueReviewsListResponse: Decodable {
let reviews: [DueReview] let reviews: [DueReview]
let total: Int let total: Int
} }
/// Body für `POST /reviews/:cardId/:subIndex/grade`. /// Body für `POST /reviews/:cardId/:subIndex/grade`.
struct GradeReviewBody: Encodable, Sendable { struct GradeReviewBody: Encodable {
let rating: Rating let rating: Rating
let reviewedAt: Date let reviewedAt: Date

View file

@ -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: 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]
}
}

View file

@ -7,7 +7,7 @@ import SwiftUI
struct StudyCardsIntent: AppIntent { struct StudyCardsIntent: AppIntent {
static let title: LocalizedStringResource = "Karten lernen" static let title: LocalizedStringResource = "Karten lernen"
static let description = IntentDescription( 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" categoryName: "Lernen"
) )
static let openAppWhenRun: Bool = true static let openAppWhenRun: Bool = true
@ -29,7 +29,7 @@ struct CardsAppShortcuts: AppShortcutsProvider {
phrases: [ phrases: [
"Karten lernen mit \(.applicationName)", "Karten lernen mit \(.applicationName)",
"Mit \(.applicationName) lernen", "Mit \(.applicationName) lernen",
"\(.applicationName) öffnen", "\(.applicationName) öffnen"
], ],
shortTitle: "Karten lernen", shortTitle: "Karten lernen",
systemImageName: "rectangle.stack" systemImageName: "rectangle.stack"

View file

@ -8,7 +8,7 @@ import UserNotifications
@MainActor @MainActor
@Observable @Observable
final class NotificationManager { final class NotificationManager {
enum AuthorizationStatus: Sendable { enum AuthorizationStatus {
case unknown case unknown
case authorized case authorized
case denied case denied
@ -67,7 +67,7 @@ final class NotificationManager {
guard remindersEnabled, authorization == .authorized else { return } guard remindersEnabled, authorization == .authorized else { return }
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = "Cards" content.title = "Cardecky"
content.body = "Ein paar Karten warten auf dich." content.body = "Ein paar Karten warten auf dich."
content.sound = .default content.sound = .default

View file

@ -9,7 +9,7 @@ import WidgetKit
@MainActor @MainActor
@Observable @Observable
final class DeckListStore { final class DeckListStore {
enum State: Sendable { enum State {
case idle case idle
case loading case loading
case loaded case loaded
@ -21,15 +21,25 @@ final class DeckListStore {
private let api: CardsAPI private let api: CardsAPI
private let context: ModelContext private let context: ModelContext
private let auth: AuthClient
init(auth: AuthClient, context: ModelContext) { init(auth: AuthClient, context: ModelContext) {
api = CardsAPI(auth: auth) api = CardsAPI(auth: auth)
self.context = context self.context = context
self.auth = auth
} }
/// Holt Decks vom Server, aktualisiert Cache. Bei Netzfehler bleibt /// 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 { func refresh() async {
guard case .signedIn = auth.status else {
state = .idle
errorMessage = nil
return
}
state = .loading state = .loading
errorMessage = nil errorMessage = nil
@ -69,8 +79,8 @@ final class DeckListStore {
group.addTask { [api] in group.addTask { [api] in
async let cards = api.cardCount(deckId: deck.id) async let cards = api.cardCount(deckId: deck.id)
async let due = api.dueCount(deckId: deck.id) async let due = api.dueCount(deckId: deck.id)
let cardCount = (try? await cards) ?? 0 let cardCount = await (try? cards) ?? 0
let dueCount = (try? await due) ?? 0 let dueCount = await (try? due) ?? 0
return (deck.id, cardCount, dueCount) return (deck.id, cardCount, dueCount)
} }
} }

View file

@ -30,8 +30,9 @@ final class GradeQueue {
) )
context.insert(grade) context.insert(grade)
try? context.save() try? context.save()
let rawRating = rating.rawValue
Log.study.info( 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() await drain()
} }
@ -73,8 +74,10 @@ final class GradeQueue {
grade.lastError = msg grade.lastError = msg
try? context.save() try? context.save()
lastDrainError = msg lastDrainError = msg
let cid = grade.cardId
let sub = grade.subIndex
Log.study.notice( 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 return
} }

View file

@ -35,10 +35,16 @@ actor MediaCache {
/// Direktes Lesen für UI-Komponenten, die `Data` brauchen (z.B. AVAudioPlayer). /// Direktes Lesen für UI-Komponenten, die `Data` brauchen (z.B. AVAudioPlayer).
func data(for mediaId: String) async throws -> Data { 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. /// 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 { private func pruneIfNeeded() async throws {
let resourceKeys: Set<URLResourceKey> = [.fileSizeKey, .contentModificationDateKey] let resourceKeys: Set<URLResourceKey> = [.fileSizeKey, .contentModificationDateKey]
guard let items = try? FileManager.default.contentsOfDirectory( guard let items = try? FileManager.default.contentsOfDirectory(
@ -46,10 +52,10 @@ actor MediaCache {
includingPropertiesForKeys: Array(resourceKeys) includingPropertiesForKeys: Array(resourceKeys)
) else { return } ) 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) let values = try? url.resourceValues(forKeys: resourceKeys)
guard let size = values?.fileSize, let date = values?.contentModificationDate else { return nil } 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 } let totalBytes = withMeta.reduce(0) { $0 + $1.size }
@ -61,7 +67,9 @@ actor MediaCache {
if remaining <= maxBytes { break } if remaining <= maxBytes { break }
try? FileManager.default.removeItem(at: item.url) try? FileManager.default.removeItem(at: item.url)
remaining -= item.size 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)")
} }
} }

View file

@ -1,15 +1,5 @@
import SwiftUI 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 { extension EnvironmentValues {
var mediaCache: MediaCache? { @Entry var mediaCache: MediaCache?
get { self[MediaCacheKey.self] }
set { self[MediaCacheKey.self] = newValue }
}
} }

View file

@ -3,7 +3,7 @@ import Foundation
/// Inbox für Share-Extension. Die Extension persistiert hier, die /// Inbox für Share-Extension. Die Extension persistiert hier, die
/// Haupt-App liest beim Start und zeigt einen Banner mit /// Haupt-App liest beim Start und zeigt einen Banner mit
/// " Als Karte speichern". Shared App-Group-Container. /// " Als Karte speichern". Shared App-Group-Container.
struct PendingShare: Codable, Identifiable, Hashable, Sendable { struct PendingShare: Codable, Identifiable, Hashable {
let id: String let id: String
let text: String let text: String
let sourceURL: String? let sourceURL: String?
@ -39,7 +39,7 @@ enum PendingShareStore {
/// kann ein Eintrag verloren gehen akzeptabel, weil Extension nur /// kann ein Eintrag verloren gehen akzeptabel, weil Extension nur
/// schreibt wenn User aktiv "Teilen" tippt. /// schreibt wenn User aktiv "Teilen" tippt.
static func append(_ share: PendingShare) { static func append(_ share: PendingShare) {
guard let url else { return } guard url != nil else { return }
var all = readAll() var all = readAll()
all.append(share) all.append(share)
write(all) write(all)

View file

@ -6,13 +6,13 @@ import Foundation
/// ///
/// Wire ist bewusst stabil + schmal nur was das Widget rendert. /// Wire ist bewusst stabil + schmal nur was das Widget rendert.
/// Neue Felder dürfen additiv dazukommen, alte Felder bleiben. /// Neue Felder dürfen additiv dazukommen, alte Felder bleiben.
struct WidgetSnapshot: Codable, Sendable { struct WidgetSnapshot: Codable {
let updatedAt: Date let updatedAt: Date
let totalDueCount: Int let totalDueCount: Int
let topDecks: [Entry] let topDecks: [Entry]
struct Entry: Codable, Sendable, Identifiable { struct Entry: Codable, Identifiable {
let id: String // deck-id let id: String // deck-id
let name: String let name: String
let dueCount: Int let dueCount: Int
let colorHex: String? let colorHex: String?

View file

@ -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<Content: View>: View {
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 {
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
}
}
}

View file

@ -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
)
}

View file

@ -1,11 +1,13 @@
import SwiftUI import SwiftUI
#if canImport(UIKit) #if canImport(UIKit)
import UIKit import UIKit
private typealias PlatformColorType = UIColor
private typealias PlatformColorType = UIColor
#elseif canImport(AppKit) #elseif canImport(AppKit)
import AppKit import AppKit
private typealias PlatformColorType = NSColor
private typealias PlatformColorType = NSColor
#endif #endif
/// Forest-Theme aus `mana/packages/themes/src/variants/forest.css`. /// 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. /// `mana/docs/MANA_SWIFT.md` bis dahin lebt forest hier.
enum CardsTheme { enum CardsTheme {
/// Page-Hintergrund /// 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 /// 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 /// 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 /// 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 /// 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 /// 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 /// 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 /// 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 /// 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 error = dynamic(light: HSL(0, 84, 60), dark: HSL(0, 63, 55))
static let success = dynamic(light: (142, 71, 45), dark: (142, 71, 45)) static let success = dynamic(light: HSL(142, 71, 45), dark: HSL(142, 71, 45))
static let warning = dynamic(light: (38, 92, 50), dark: (48, 96, 53)) static let warning = dynamic(light: HSL(38, 92, 50), dark: HSL(48, 96, 53))
// MARK: - HSL Helper // MARK: - HSL Helper
private static func dynamic( /// Hue/Saturation/Lightness als Wert-Typ. HSL ist konkreter als ein
light: (Double, Double, Double), /// 3-Tupel und macht die Call-Sites lesbar.
dark: (Double, Double, Double) struct HSL {
) -> Color { let hue: Double
let lightColor = fromHSL(light.0, light.1, light.2) let saturation: Double
let darkColor = fromHSL(dark.0, dark.1, dark.2) 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) #if canImport(UIKit)
return Color(uiColor: UIColor { trait in return Color(uiColor: UIColor { trait in
trait.userInterfaceStyle == .dark ? darkColor : lightColor trait.userInterfaceStyle == .dark ? darkColor : lightColor
}) })
#elseif canImport(AppKit) #elseif canImport(AppKit)
return Color(nsColor: NSColor(name: nil) { appearance in return Color(nsColor: NSColor(name: nil) { appearance in
let isDark = appearance.bestMatch(from: [.darkAqua, .vibrantDark]) != nil let isDark = appearance.bestMatch(from: [.darkAqua, .vibrantDark]) != nil
return isDark ? darkColor : lightColor return isDark ? darkColor : lightColor
}) })
#else #else
return Color(red: 0, green: 0, blue: 0) return Color(red: 0, green: 0, blue: 0)
#endif #endif
} }

View file

@ -1,59 +1,194 @@
import ManaAuthUI
import ManaCore import ManaCore
import SwiftUI import SwiftUI
struct AccountView: View { struct AccountView: View {
@Environment(AuthClient.self) private var auth @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
var body: some View { var body: some View {
ZStack { ZStack {
CardsTheme.background.ignoresSafeArea() CardsTheme.background.ignoresSafeArea()
VStack(spacing: 24) { Group {
Image(systemName: "person.crop.circle.fill") switch auth.status {
.resizable() case .signedIn:
.frame(width: 80, height: 80) signedInContent
.foregroundStyle(CardsTheme.primary) case .guest, .signedOut, .error, .unknown:
guestContent
if let email = auth.currentEmail { case .signingIn, .twoFactorRequired:
Text(email) ProgressView().tint(CardsTheme.primary)
.font(.headline)
.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)
)
}
.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)
} }
.padding(.top, 48)
} }
.navigationTitle("Account") .navigationTitle("Account")
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #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)
}
}
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)
.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)
)
} }
} }

View file

@ -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("Cards")
.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))
}

View file

@ -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<Footer: View>: 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
}

View file

@ -2,8 +2,16 @@ import ManaCore
import SwiftData import SwiftData
import SwiftUI import SwiftUI
/// Deck-Detail mit Aktionen: Lernen, Karte hinzufügen, Bearbeiten, Löschen. // swiftlint:disable file_length
/// Wird per Tap auf eine Deck-Row aus der DeckListView geöffnet. // 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 { struct DeckDetailView: View {
let deckId: String let deckId: String
@ -17,6 +25,18 @@ struct DeckDetailView: View {
@State private var showDeleteConfirm = false @State private var showDeleteConfirm = false
@State private var navigateToStudy = false @State private var navigateToStudy = false
@State private var deleteError: String? @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) { init(deckId: String) {
self.deckId = deckId self.deckId = deckId
@ -49,11 +69,44 @@ struct DeckDetailView: View {
} }
.sheet(isPresented: $showCardEditor) { .sheet(isPresented: $showCardEditor) {
NavigationStack { NavigationStack {
CardEditorView(deckId: deckId) { _ in CardEditorView(mode: .create(deckId: deckId)) { _ in
Task { await refreshAfterEdit() } Task {
await refreshAfterEdit()
await loadCards()
}
} }
} }
} }
.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( .confirmationDialog(
"Deck löschen?", "Deck löschen?",
isPresented: $showDeleteConfirm, isPresented: $showDeleteConfirm,
@ -64,28 +117,56 @@ struct DeckDetailView: View {
} }
Button("Abbrechen", role: .cancel) {} Button("Abbrechen", role: .cancel) {}
} message: { } 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) { .navigationDestination(isPresented: $navigateToStudy) {
if let deck = decks.first { if let deck = decks.first {
StudySessionView(deckId: deck.id, deckName: deck.name) StudySessionView(deckId: deck.id, deckName: deck.name)
} }
} }
.task(id: deckId) {
await loadCards()
}
.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 { private func content(deck: CachedDeck) -> some View {
VStack(alignment: .leading, spacing: 16) { ScrollView {
header(deck: deck) VStack(alignment: .leading, spacing: 16) {
actions(deck: deck) header(deck: deck)
if let deleteError { actions(deck: deck)
Text(deleteError) if let deleteError {
.font(.footnote) Text(deleteError)
.foregroundStyle(CardsTheme.error) .font(.footnote)
.padding(.horizontal, 16) .foregroundStyle(CardsTheme.error)
.padding(.horizontal, 16)
}
cardListSection
} }
Spacer() .padding(.vertical, 16)
} }
.padding(.vertical, 16)
} }
private func header(deck: CachedDeck) -> some View { private func header(deck: CachedDeck) -> some View {
@ -121,75 +202,180 @@ struct DeckDetailView: View {
private func actions(deck: CachedDeck) -> some View { private func actions(deck: CachedDeck) -> some View {
VStack(spacing: 12) { VStack(spacing: 12) {
Button { primaryActions
navigateToStudy = true secondaryActions(deck: deck)
} 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)
}
} }
.padding(.horizontal, 16) .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 {
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
Button {
editingCard = card
} label: {
CardPreviewRow(card: card)
.padding(.horizontal, 16)
}
.buttonStyle(.plain)
.accessibilityHint("Tippen zum Bearbeiten")
}
}
}
}
}
private func refreshAfterEdit() async { private func refreshAfterEdit() async {
let store = DeckListStore(auth: auth, context: context) let store = DeckListStore(auth: auth, context: context)
await store.refresh() 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
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 { private func delete() async {
deleteError = nil deleteError = nil
let api = CardsAPI(auth: auth) let api = CardsAPI(auth: auth)
do { do {
try await api.deleteDeck(id: deckId) try await api.deleteDeck(id: deckId)
// Cache nachziehen
if let deck = decks.first { if let deck = decks.first {
context.delete(deck) context.delete(deck)
try? context.save() try? context.save()
@ -200,3 +386,81 @@ 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
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:
card.fields["front"] ?? ""
case .cloze:
card.fields["text"] ?? ""
case .imageOcclusion:
card.fields["note"]?.isEmpty == false
? card.fields["note"]!
: "Bild-Verdeckung (\(MaskRegions.count(card.fields["mask_regions"] ?? "")) Masken)"
case .audioFront:
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"
}
}
}

View file

@ -1,28 +1,54 @@
import ManaAuthUI
import ManaCore import ManaCore
import SwiftData import SwiftData
import SwiftUI import SwiftUI
/// β-1 Hauptbildschirm: Liste aller Decks mit Card- und Due-Counts. /// Navigations-Routen für die DeckListView. Tap auf eine Tile geht
/// Web-Vorbild: `cards/apps/web/src/routes/decks/+page.svelte`. /// 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)
}
// 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`.
struct DeckListView: View { struct DeckListView: View {
@Environment(AuthClient.self) private var auth @Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@Environment(\.modelContext) private var context @Environment(\.modelContext) private var context
@Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck] @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 store: DeckListStore?
@State private var showAccount = false
@State private var showCreate = false
@State private var pendingShares: [PendingShare] = [] @State private var pendingShares: [PendingShare] = []
@State private var path = NavigationPath()
var body: some View { var body: some View {
NavigationStack { NavigationStack(path: $path) {
ZStack { ZStack {
CardsTheme.background.ignoresSafeArea() CardsTheme.background.ignoresSafeArea()
content content
} }
.navigationTitle("Decks") .navigationTitle("Decks")
.navigationDestination(for: String.self) { deckId in #if os(iOS)
DeckDetailView(deckId: deckId) .navigationBarTitleDisplayMode(.inline)
#endif
.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 .navigationDestination(for: PendingShareRoute.self) { route in
PendingShareConsumeView(share: route.share, onDone: { PendingShareConsumeView(share: route.share, onDone: {
@ -51,16 +77,6 @@ struct DeckListView: View {
.onAppear { .onAppear {
pendingShares = PendingShareStore.readAll() pendingShares = PendingShareStore.readAll()
} }
.sheet(isPresented: $showAccount) {
NavigationStack {
AccountView()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") { showAccount = false }
}
}
}
}
} }
} }
@ -69,25 +85,111 @@ struct DeckListView: View {
if decks.isEmpty { if decks.isEmpty {
emptyState emptyState
} else { } else {
List { ScrollView {
pendingShareSection VStack(alignment: .leading, spacing: 24) {
inboxBannerSection pendingShareSection
ownDecksSection 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(\.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
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)
}
}
.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 }) {
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)
}
.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)
} }
} }
@ViewBuilder @ViewBuilder
private var pendingShareSection: some View { private var pendingShareSection: some View {
if !pendingShares.isEmpty { if !pendingShares.isEmpty {
Section { VStack(alignment: .leading, spacing: 8) {
ForEach(pendingShares) { share in ForEach(pendingShares) { share in
NavigationLink(value: PendingShareRoute(share: share)) { NavigationLink(value: PendingShareRoute(share: share)) {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "square.and.arrow.down") Image(systemName: "square.and.arrow.down")
.foregroundStyle(CardsTheme.primary) .foregroundStyle(CardsTheme.warning)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Aus Teilen-Menü") Text("Aus Teilen-Menü")
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
@ -98,16 +200,20 @@ struct DeckListView: View {
.lineLimit(2) .lineLimit(2)
} }
Spacer() Spacer()
Image(systemName: "chevron.right")
.font(.footnote)
.foregroundStyle(CardsTheme.mutedForeground)
} }
.padding() .padding(14)
.background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) .background(
CardsTheme.warning.opacity(0.12),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
} }
} }
.padding(.horizontal, 20)
} }
} }
@ -126,157 +232,64 @@ struct DeckListView: View {
Text(message) Text(message)
.foregroundStyle(CardsTheme.mutedForeground) .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 { } else {
ContentUnavailableView { ContentUnavailableView {
Label("Noch keine Decks", systemImage: "rectangle.stack") Label("Noch keine Decks", systemImage: "rectangle.stack")
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(CardsTheme.foreground)
} description: { } description: {
Text("Erstelle dein erstes Deck auf cardecky.mana.how oder ziehe nach unten zum Aktualisieren.") Text(
.foregroundStyle(CardsTheme.mutedForeground) """
Tippe unten auf »+«, um dein erstes Deck zu erstellen, \
oder browse den Marketplace im Entdecken-Tab.
"""
)
.foregroundStyle(CardsTheme.mutedForeground)
} }
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .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 @ToolbarContentBuilder
private var toolbar: some ToolbarContent { private var toolbar: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) { // Auf iOS 26 übernimmt das `.tabViewBottomAccessory` aus RootView die
Button { // Neues Deck"-Pille. Doppelten +"-Button im Liquid-Glass-Layout
showCreate = true // vermeiden bottomBar-Button nur auf iOS < 26 zeigen.
} label: { if #unavailable(iOS 26.0) {
Image(systemName: "plus.circle") ToolbarItemGroup(placement: .bottomBar) {
.foregroundStyle(CardsTheme.primary) Button {
} authGate.require(reason: "deck-create-toolbar") {
.accessibilityLabel("Deck hinzufügen") showCreate = true
}
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"
}
}
/// 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)
} }
} label: {
Label("Deck hinzufügen", systemImage: "plus")
.labelStyle(.iconOnly)
.foregroundStyle(CardsTheme.primary)
} }
.accessibilityLabel("Deck hinzufügen")
Spacer()
} }
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 { // swiftlint:enable type_body_length
/// 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)
}
}

View file

@ -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

View file

@ -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)
}
}
}

View file

@ -0,0 +1,85 @@
import SwiftUI
/// 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 {
DeckCoverTile(
title: deck.name,
description: deck.deckDescription,
category: deck.category,
seed: deck.id,
colorAccentHex: deck.color,
isFeatured: false
) {
footerContent
}
.contentShape(Rectangle())
.onTapGesture { onTap() }
}
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)
}
if deck.isFromMarketplace {
Image(systemName: "globe")
.font(.caption2)
.foregroundStyle(CardsTheme.mutedForeground)
}
Spacer()
editButton
}
}
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")
}
}
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"
}
}
}

View file

@ -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)"
}
}
}

View file

@ -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")
}
}
}

View file

@ -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"
}
}
}

View file

@ -1,55 +1,126 @@
import ManaCore import ManaCore
import PhotosUI
import SwiftUI import SwiftUI
#if canImport(UIKit) #if canImport(UIKit)
import UIKit import UIKit
#endif #endif
/// Card-Create-View. Type-Picker oben, type-spezifische Felder unten. // swiftlint:disable type_body_length
/// Deckt alle 7 Card-Types ab.
/// 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 { struct CardEditorView: View {
let deckId: String enum Mode {
let onCreated: (Card) -> Void case create(deckId: String)
case edit(card: Card)
}
let mode: Mode
let onSaved: (Card) -> Void
@Environment(AuthClient.self) private var auth @Environment(AuthClient.self) private var auth
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var type: CardType = .basic @State private var type: CardType
@State private var front: String = "" @State private var front: String
@State private var back: String = "" @State private var back: String
@State private var clozeText: String = "" @State private var clozeText: String
@State private var typingAnswer: String = "" @State private var typingAnswer: String
@State private var multipleChoiceAnswer: String = "" @State private var multipleChoiceAnswer: String
@State private var isSubmitting = false @State private var isSubmitting = false
@State private var errorMessage: String? @State private var errorMessage: String?
// Image-Occlusion-State // Image-Occlusion-State
@State private var imagePickerItem: PhotosPickerItem?
@State private var occlusionImage: PlatformImage? @State private var occlusionImage: PlatformImage?
@State private var occlusionImageData: Data? @State private var occlusionImageData: Data?
@State private var occlusionMimeType: String = "image/jpeg" @State private var occlusionMimeType: String = "image/jpeg"
@State private var occlusionRegions: [MaskRegion] = [] @State private var occlusionRegions: [MaskRegion]
@State private var occlusionNote: String = "" @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 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] = [ private static let supportedTypes: [CardType] = [
.basic, .basicReverse, .cloze, .typing, .multipleChoice, .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 { var body: some View {
Form { Form {
Section("Card-Type") { if isCreate {
Picker("Typ", selection: $type) { Section("Card-Type") {
ForEach(Self.supportedTypes, id: \.self) { t in Picker("Typ", selection: $type) {
Text(label(for: t)).tag(t) ForEach(Self.supportedTypes, id: \.self) { cardType in
Text(label(for: cardType)).tag(cardType)
}
} }
.pickerStyle(.menu)
} }
.pickerStyle(.menu)
} }
typeFields typeFields
@ -62,7 +133,8 @@ struct CardEditorView: View {
} }
} }
} }
.navigationTitle("Neue Karte") .disabled(isSubmitting)
.navigationTitle(isCreate ? "Neue Karte" : "Karte bearbeiten")
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
@ -71,8 +143,10 @@ struct CardEditorView: View {
Button("Abbrechen") { dismiss() } Button("Abbrechen") { dismiss() }
} }
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("Erstellen") { Task { await submit() } } Button(isCreate ? "Erstellen" : "Speichern") {
.disabled(!canSubmit || isSubmitting) Task { await submit() }
}
.disabled(!canSubmit || isSubmitting)
} }
} }
} }
@ -99,12 +173,15 @@ struct CardEditorView: View {
case .cloze: case .cloze:
Section("Cloze-Text") { Section("Cloze-Text") {
TextField("Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.", TextField(
text: $clozeText, axis: .vertical) "Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.",
.lineLimit(3 ... 8) text: $clozeText,
.autocorrectionDisabled() axis: .vertical
.textInputAutocapitalization(.sentences) )
.monospaced() .lineLimit(3 ... 8)
.autocorrectionDisabled()
.textInputAutocapitalization(.sentences)
.monospaced()
} }
Section { Section {
let count = Cloze.subIndexCount(clozeText) let count = Cloze.subIndexCount(clozeText)
@ -146,123 +223,40 @@ struct CardEditorView: View {
} }
case .imageOcclusion: case .imageOcclusion:
imageOcclusionFields ImageOcclusionFields(
image: $occlusionImage,
imageData: $occlusionImageData,
mimeType: $occlusionMimeType,
regions: $occlusionRegions,
note: $occlusionNote,
existingImageRef: $existingImageRef,
onLoadError: { errorMessage = $0 }
)
case .audioFront: case .audioFront:
audioFrontFields AudioFrontFields(
audioFileURL: $audioFileURL,
back: $back,
existingAudioRef: existingAudioRef
)
} }
} }
@ViewBuilder private var isCreate: Bool {
private var imageOcclusionFields: some View { if case .create = mode { return true }
Section("Bild") { return false
PhotosPicker(selection: $imagePickerItem, matching: .images) { }
if occlusionImage == nil {
Label("Bild auswählen", systemImage: "photo")
} else {
Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath")
}
}
.onChange(of: imagePickerItem) { _, newItem in
Task { await loadPickedImage(newItem) }
}
}
if let image = occlusionImage { private var deckId: String {
Section("Masken") { switch mode {
MaskEditorView(image: image, regions: $occlusionRegions) case let .create(deckId): deckId
} case let .edit(card): card.deckId
}
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)
}
} }
} }
@ViewBuilder private var existingMediaRefs: [String] {
private var audioFrontFields: some View { if case let .edit(card) = mode { return card.mediaRefs }
Section("Audio-Datei") { return []
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 canSubmit: Bool { private var canSubmit: Bool {
@ -276,12 +270,14 @@ struct CardEditorView: View {
case .multipleChoice: case .multipleChoice:
!front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty !front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty
case .imageOcclusion: case .imageOcclusion:
occlusionImageData != nil && !occlusionRegions.isEmpty (occlusionImageData != nil || existingImageRef != nil) && !occlusionRegions.isEmpty
case .audioFront: case .audioFront:
audioFileURL != nil && !back.trimmed.isEmpty (audioFileURL != nil || existingAudioRef != nil) && !back.trimmed.isEmpty
} }
} }
// MARK: - Submit
private func submit() async { private func submit() async {
isSubmitting = true isSubmitting = true
errorMessage = nil errorMessage = nil
@ -289,53 +285,47 @@ struct CardEditorView: View {
let api = CardsAPI(auth: auth) let api = CardsAPI(auth: auth)
do { do {
let fields: [String: String] let payload = try await CardEditorPayloadBuilder.build(inputs: payloadInputs, api: api)
var mediaRefs: [String]? = nil let card: Card = switch mode {
switch type { case let .create(deckId):
case .basic, .basicReverse: try await api.createCard(CardCreateBody(
fields = CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed) deckId: deckId,
case .cloze: type: type,
fields = CardFieldsBuilder.cloze(text: clozeText.trimmed) fields: payload.fields,
case .typing: mediaRefs: payload.mediaRefs
fields = CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed) ))
case .multipleChoice: case let .edit(existing):
fields = CardFieldsBuilder.multipleChoice(front: front.trimmed, answer: multipleChoiceAnswer.trimmed) try await api.updateCard(id: existing.id, body: CardUpdateBody(
case .imageOcclusion: fields: payload.fields,
guard let data = occlusionImageData else { return } mediaRefs: payload.mediaRefs
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]
} }
onSaved(card)
let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: mediaRefs)
let card = try await api.createCard(body)
onCreated(card)
dismiss() dismiss()
} catch { } catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) 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 { private func label(for type: CardType) -> String {
switch type { switch type {
case .basic: "Einfach (Vorder/Rück)" case .basic: "Einfach (Vorder/Rück)"
@ -349,6 +339,8 @@ struct CardEditorView: View {
} }
} }
// swiftlint:enable type_body_length
private extension String { private extension String {
var trimmed: String { var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines) trimmingCharacters(in: .whitespacesAndNewlines)

View file

@ -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."
}
}

View file

@ -1,125 +1,530 @@
import ManaCore import ManaCore
import PhotosUI
import SwiftUI import SwiftUI
/// Deck-Create und Deck-Edit in einer View. `existing == nil` Create- // swiftlint:disable file_length
/// Modus mit "Erstellen"-Button. Sonst Edit-Modus mit "Speichern". // 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 { struct DeckEditorView: View {
enum Mode: Sendable { enum Mode {
case create case create
case edit(deckId: String) 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 mode: Mode
let onSaved: (Deck) -> Void let onSaved: (Deck) -> Void
@Environment(AuthClient.self) private var auth @Environment(AuthClient.self) private var auth
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
// Manual fields (Edit + Create.manual)
@State private var name: String @State private var name: String
@State private var description: String @State private var description: String
@State private var color: String @State private var color: String
@State private var category: DeckCategory? @State private var category: DeckCategory?
@State private var visibility: DeckVisibility @State private var visibility: DeckVisibility
@State private var isSubmitting = false @State private var archived: Bool
@State private var errorMessage: String?
/// Vorgefüllte Farbpalette aus dem forest-Theme. User können /// Create-mode selector
/// freie Hex-Werte später via Picker setzen (β-3-extension). @State private var createMode: CreateMode = .manual
private static let presetColors: [String] = [
"#10803D", // forest primary light // AI-shared (Text + Media)
"#1E3A2F", // forest dark @State private var aiPrompt: String = ""
"#D97706", // amber @State private var aiCount: Int = 15
"#DC2626", // red @State private var aiLanguage: GenerationLanguage = .de
"#2563EB", // blue @State private var aiUrl: String = ""
"#7C3AED", // violet
"#0D9488", // teal // AI-Media
"#737373", // neutral @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<Void, Never>?
@State private var errorMessage: String?
init(mode: Mode, existing: CachedDeck? = nil, onSaved: @escaping (Deck) -> Void) { init(mode: Mode, existing: CachedDeck? = nil, onSaved: @escaping (Deck) -> Void) {
self.mode = mode self.mode = mode
self.onSaved = onSaved self.onSaved = onSaved
_name = State(initialValue: existing?.name ?? "") _name = State(initialValue: existing?.name ?? "")
_description = State(initialValue: existing?.deckDescription ?? "") _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) _category = State(initialValue: existing?.category)
_visibility = State(initialValue: DeckVisibility(rawValue: existing?.visibilityRaw ?? "private") ?? .private) _visibility = State(initialValue: DeckVisibility(rawValue: existing?.visibilityRaw ?? "private") ?? .private)
_archived = State(initialValue: existing?.archivedAt != nil)
} }
var body: some View { var body: some View {
Form { ZStack {
Section("Name") { Form {
TextField("Deck-Name", text: $name) if isCreate {
.textInputAutocapitalization(.sentences) modePickerSection
}
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)
} }
formSections
errorSection
} }
.disabled(isSubmitting)
Section("Kategorie") { if isSubmitting, activeMode != .manual {
Picker("Kategorie", selection: $category) { GenerationOverlay(
Text("Keine").tag(DeckCategory?.none) message: overlayMessage,
ForEach(DeckCategory.allCases, id: \.self) { cat in onCancel: { generationTask?.cancel() }
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)
}
} }
} }
.navigationTitle(isCreate ? "Neues Deck" : "Deck bearbeiten") .navigationTitle(navTitle)
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
.toolbar { .toolbar { toolbar }
ToolbarItem(placement: .cancellationAction) { .onChange(of: aiPhotoItems) { _, items in
Button("Abbrechen") { dismiss() } guard !items.isEmpty else { return }
} Task { await ingestPhotoItems(items) }
ToolbarItem(placement: .confirmationAction) {
Button(isCreate ? "Erstellen" : "Speichern") {
Task { await submit() }
}
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || isSubmitting)
}
} }
.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 { private var isCreate: Bool {
if case .create = mode { return true } if case .create = mode { return true }
return false return false
} }
@ViewBuilder private var activeMode: CreateMode {
private func colorSwatch(_ hex: String) -> some View { isCreate ? createMode : .manual
let isSelected = color == hex }
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<Bool>?
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() Circle()
.fill(Color.swatchFromHex(hex)) .fill(Color.swatchFromHex(hex))
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
@ -127,51 +532,200 @@ struct DeckEditorView: View {
Circle() Circle()
.stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1) .stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1)
) )
.onTapGesture { color = hex } .onTapGesture(perform: onTap)
} }
}
private func submit() async { // MARK: - AI text form
isSubmitting = true
errorMessage = nil
defer { isSubmitting = false }
let api = CardsAPI(auth: auth)
do { private struct AITextFormSections: View {
switch mode { @Binding var prompt: String
case .create:
let body = DeckCreateBody( var body: some View {
name: name.trimmingCharacters(in: .whitespaces), Section {
description: nonEmpty(description), TextField(
color: color, "z.B. Bodensee-Geographie, französische Verben",
category: category, text: $prompt,
visibility: visibility axis: .vertical
) )
let deck = try await api.createDeck(body) .lineLimit(3 ... 6)
onSaved(deck) .textInputAutocapitalization(.sentences)
dismiss() } header: {
case let .edit(deckId): Text("Thema")
let body = DeckUpdateBody( } footer: {
name: name.trimmingCharacters(in: .whitespaces), Text("3500 Zeichen. Je präziser, desto besser die Karten.")
description: nonEmpty(description), }
color: color, }
category: category, }
visibility: visibility
) // MARK: - AI media form
let deck = try await api.updateDeck(id: deckId, body: body)
onSaved(deck) private struct AIMediaFormSections: View {
dismiss() @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 { } header: {
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) Text("Quellen")
} footer: {
Text("Max. \(DeckEditorPresets.maxMediaFiles) Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB.")
} }
} }
private func nonEmpty(_ s: String) -> String? { @ViewBuilder
let trimmed = s.trimmingCharacters(in: .whitespaces) private var mediaPickers: some View {
return trimmed.isEmpty ? nil : trimmed 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 { extension Color {
static func swatchFromHex(_ hex: String) -> Color { static func swatchFromHex(_ hex: String) -> Color {
var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines) var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines)
@ -179,9 +733,9 @@ extension Color {
guard let rgb = UInt32(trimmed, radix: 16) else { guard let rgb = UInt32(trimmed, radix: 16) else {
return CardsTheme.primary return CardsTheme.primary
} }
let r = Double((rgb >> 16) & 0xFF) / 255.0 let red = Double((rgb >> 16) & 0xFF) / 255.0
let g = Double((rgb >> 8) & 0xFF) / 255.0 let green = Double((rgb >> 8) & 0xFF) / 255.0
let b = Double(rgb & 0xFF) / 255.0 let blue = Double(rgb & 0xFF) / 255.0
return Color(red: r, green: g, blue: b) return Color(red: red, green: green, blue: blue)
} }
} }

View file

@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
#if canImport(UIKit) #if canImport(UIKit)
import UIKit import UIKit
#endif #endif
/// Mask-Editor: Bild anzeigen, mit Drag-Gesten Rechtecke zeichnen, jede /// Mask-Editor: Bild anzeigen, mit Drag-Gesten Rechtecke zeichnen, jede
@ -39,14 +39,13 @@ struct MaskEditorView: View {
} }
} }
@ViewBuilder
private var imageCanvas: some View { private var imageCanvas: some View {
GeometryReader { geo in GeometryReader { geo in
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
#if canImport(UIKit) #if canImport(UIKit)
Image(uiImage: image).resizable().aspectRatio(contentMode: .fit) Image(uiImage: image).resizable().aspectRatio(contentMode: .fit)
#else #else
Image(nsImage: image).resizable().aspectRatio(contentMode: .fit) Image(nsImage: image).resizable().aspectRatio(contentMode: .fit)
#endif #endif
ForEach(regions) { region in ForEach(regions) { region in

View file

@ -20,8 +20,11 @@ struct BrowseView: View {
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
.searchable(text: $queryText, placement: .navigationBarDrawer(displayMode: .always), .searchable(
prompt: "Decks suchen") text: $queryText,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Decks suchen"
)
.onSubmit(of: .search) { .onSubmit(of: .search) {
store?.browseQuery = queryText store?.browseQuery = queryText
Task { await store?.browse() } Task { await store?.browse() }

View file

@ -20,6 +20,9 @@ struct ExploreView: View {
content content
} }
.navigationTitle("Entdecken") .navigationTitle("Entdecken")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.navigationDestination(for: MarketplaceRoute.self) { route in .navigationDestination(for: MarketplaceRoute.self) { route in
switch route { switch route {
case .browse: case .browse:
@ -65,10 +68,10 @@ struct ExploreView: View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
if !store.featured.isEmpty { if !store.featured.isEmpty {
section(title: "Vorgestellt", items: store.featured) section(title: "Vorgestellt", icon: "star.fill", items: store.featured)
} }
if !store.trending.isEmpty { 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) { NavigationLink(value: MarketplaceRoute.browse) {
@ -87,32 +90,43 @@ struct ExploreView: View {
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(CardsTheme.foreground)
} }
.buttonStyle(.plain) .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) { VStack(alignment: .leading, spacing: 12) {
Text(title) HStack(spacing: 6) {
.font(.title3.weight(.semibold)) Image(systemName: icon)
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(CardsTheme.primary)
.padding(.horizontal, 16) Text(title)
.font(.title3.weight(.semibold))
.foregroundStyle(CardsTheme.foreground)
Text("\(items.count)")
.font(.subheadline)
.foregroundStyle(CardsTheme.mutedForeground)
}
.padding(.horizontal, 20)
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) { HStack(alignment: .top, spacing: 16) {
ForEach(items) { item in ForEach(items) { item in
NavigationLink(value: MarketplaceRoute.publicDeck(slug: item.slug)) { NavigationLink(value: MarketplaceRoute.publicDeck(slug: item.slug)) {
PublicDeckCard(entry: item) PublicDeckCard(entry: item)
.frame(width: 240)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 20)
.padding(.bottom, 12)
.scrollTargetLayout()
} }
.scrollTargetBehavior(.viewAligned)
} }
} }
} }
@ -123,45 +137,51 @@ enum MarketplaceRoute: Hashable {
case publicDeck(slug: String) case publicDeck(slug: String)
} }
/// Public-Deck-Karten-Tile in Featured/Trending-Carousels und Browse-Grid. /// 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 { struct PublicDeckCard: View {
let entry: PublicDeckEntry let entry: PublicDeckEntry
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { DeckCoverTile(
HStack { title: entry.title,
Text(entry.title) description: entry.description,
.font(.headline) category: parsedCategory,
.foregroundStyle(CardsTheme.foreground) seed: entry.slug,
.lineLimit(2) colorAccentHex: nil,
Spacer() isFeatured: entry.isFeatured
if entry.isFeatured { ) {
Image(systemName: "star.fill") footerContent
.font(.caption) }
.foregroundStyle(CardsTheme.warning) }
}
} private var parsedCategory: DeckCategory? {
if let description = entry.description, !description.isEmpty { guard let category = entry.category else { return nil }
Text(description) return DeckCategory(rawValue: category)
.font(.caption) }
.foregroundStyle(CardsTheme.mutedForeground)
.lineLimit(2) private var footerContent: some View {
} VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) { HStack(spacing: 8) {
Label("\(entry.cardCount)", systemImage: "rectangle.stack") Label("\(entry.cardCount)", systemImage: "rectangle.stack")
Label("\(entry.starCount)", systemImage: "star") .font(.caption2)
.foregroundStyle(CardsTheme.mutedForeground)
Label("\(entry.starCount)", systemImage: "star.fill")
.font(.caption2)
.foregroundStyle(CardsTheme.warning)
if entry.isPaid { if entry.isPaid {
Label("\(entry.priceCredits) Credits", systemImage: "creditcard") Label("\(entry.priceCredits)", systemImage: "creditcard")
.font(.caption2.weight(.semibold))
.foregroundStyle(CardsTheme.primary) .foregroundStyle(CardsTheme.primary)
} }
Spacer()
} }
.font(.caption2)
.foregroundStyle(CardsTheme.mutedForeground)
HStack(spacing: 4) { HStack(spacing: 4) {
Text(entry.owner.displayName) Text(entry.owner.displayName)
.font(.caption2) .font(.caption2)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(CardsTheme.mutedForeground)
.lineLimit(1)
if entry.owner.verifiedMana { if entry.owner.verifiedMana {
Image(systemName: "checkmark.seal.fill") Image(systemName: "checkmark.seal.fill")
.font(.caption2) .font(.caption2)
@ -169,12 +189,5 @@ struct PublicDeckCard: View {
} }
} }
} }
.padding(12)
.frame(width: 260, alignment: .leading)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(CardsTheme.border, lineWidth: 1)
)
} }
} }

View file

@ -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/<slug>.")
}
}
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
}
}

View file

@ -33,8 +33,9 @@ final class MarketplaceStore {
featured = res.featured featured = res.featured
trending = res.trending trending = res.trending
} catch { } catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
Log.api.error("Explore failed: \(self.errorMessage ?? "", privacy: .public)") errorMessage = message
Log.api.error("Explore failed: \(message, privacy: .public)")
} }
} }

View file

@ -1,13 +1,18 @@
import ManaAuthUI
import ManaCore import ManaCore
import SwiftData import SwiftData
import SwiftUI import SwiftUI
// swiftlint:disable type_body_length
/// Detail-View für ein Public-Deck. Subscribe-Button löst Auto-Fork /// Detail-View für ein Public-Deck. Subscribe-Button löst Auto-Fork
/// serverseitig aus und navigiert anschließend zur eigenen Deck-Detail. /// serverseitig aus und navigiert anschließend zur eigenen Deck-Detail.
/// Toolbar-Menu (") hostet Report + Block-Author (App-Review-Pflicht).
struct PublicDeckView: View { struct PublicDeckView: View {
let slug: String let slug: String
@Environment(AuthClient.self) private var auth @Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@Environment(\.modelContext) private var context @Environment(\.modelContext) private var context
@State private var detail: PublicDeckDetail? @State private var detail: PublicDeckDetail?
@State private var isLoading = false @State private var isLoading = false
@ -15,6 +20,11 @@ struct PublicDeckView: View {
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var subscribed: SubscribeResponse? @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 { var body: some View {
ZStack { ZStack {
CardsTheme.background.ignoresSafeArea() CardsTheme.background.ignoresSafeArea()
@ -24,9 +34,69 @@ struct PublicDeckView: View {
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
.toolbar {
if detail != nil {
ToolbarItem(placement: .topBarTrailing) {
moderationMenu
}
}
}
.task(id: slug) { .task(id: slug) {
await load() 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 @ViewBuilder
@ -122,7 +192,6 @@ struct PublicDeckView: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
@ViewBuilder
private func subscribeSection(detail: PublicDeckDetail) -> some View { private func subscribeSection(detail: PublicDeckDetail) -> some View {
VStack(spacing: 12) { VStack(spacing: 12) {
if let subscribed { if let subscribed {
@ -147,7 +216,9 @@ struct PublicDeckView: View {
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(CardsTheme.mutedForeground)
} else { } else {
Button { Button {
Task { await subscribe(detail: detail) } authGate.require(reason: "marketplace-subscribe") {
Task { await subscribe(detail: detail) }
}
} label: { } label: {
HStack { HStack {
if isSubscribing { if isSubscribing {
@ -156,8 +227,8 @@ struct PublicDeckView: View {
.tint(CardsTheme.primaryForeground) .tint(CardsTheme.primaryForeground)
} }
Text(detail.deck.priceCredits > 0 Text(detail.deck.priceCredits > 0
? "Abonnieren (\(detail.deck.priceCredits) Credits)" ? "Abonnieren (\(detail.deck.priceCredits) Credits)"
: "Abonnieren") : "Abonnieren")
.fontWeight(.semibold) .fontWeight(.semibold)
} }
.frame(maxWidth: .infinity) .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 isSubscribing = true
errorMessage = nil errorMessage = nil
defer { isSubscribing = false } defer { isSubscribing = false }
@ -199,3 +280,5 @@ struct PublicDeckView: View {
} }
} }
} }
// swiftlint:enable type_body_length

View file

@ -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))
}
}

View file

@ -45,12 +45,14 @@ struct AudioPlayerButton: View {
} }
private func load() async { private func load() async {
guard let cache = mediaCache else { failed = true; return } guard let cache = mediaCache else { failed = true
return
}
do { do {
let data = try await cache.data(for: mediaId) let data = try await cache.data(for: mediaId)
#if canImport(UIKit) #if canImport(UIKit)
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try AVAudioSession.sharedInstance().setActive(true) try AVAudioSession.sharedInstance().setActive(true)
#endif #endif
player = try AVAudioPlayer(data: data) player = try AVAudioPlayer(data: data)
player?.prepareToPlay() player?.prepareToPlay()

View file

@ -1,9 +1,9 @@
import SwiftUI import SwiftUI
#if canImport(UIKit) #if canImport(UIKit)
import UIKit import UIKit
#elseif canImport(AppKit) #elseif canImport(AppKit)
import AppKit import AppKit
#endif #endif
/// Lädt ein authentifiziertes Image vom Cardecky-Media-Endpoint und /// Lädt ein authentifiziertes Image vom Cardecky-Media-Endpoint und
@ -42,14 +42,16 @@ struct RemoteImage: View {
@ViewBuilder @ViewBuilder
private func imageView(_ image: PlatformImage) -> some View { private func imageView(_ image: PlatformImage) -> some View {
#if canImport(UIKit) #if canImport(UIKit)
Image(uiImage: image).resizable().aspectRatio(contentMode: contentMode) Image(uiImage: image).resizable().aspectRatio(contentMode: contentMode)
#elseif canImport(AppKit) #elseif canImport(AppKit)
Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode) Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode)
#endif #endif
} }
private func load() async { private func load() async {
guard let cache = mediaCache else { failed = true; return } guard let cache = mediaCache else { failed = true
return
}
do { do {
let data = try await cache.data(for: mediaId) let data = try await cache.data(for: mediaId)
if let img = PlatformImage(data: data) { if let img = PlatformImage(data: data) {
@ -64,7 +66,7 @@ struct RemoteImage: View {
} }
#if canImport(UIKit) #if canImport(UIKit)
typealias PlatformImage = UIImage typealias PlatformImage = UIImage
#elseif canImport(AppKit) #elseif canImport(AppKit)
typealias PlatformImage = NSImage typealias PlatformImage = NSImage
#endif #endif

View file

@ -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)
}
}
}

View file

@ -40,10 +40,20 @@ struct SettingsView: View {
} }
if notifications.authorization == .denied { if notifications.authorization == .denied {
Label("Benachrichtigungen sind in den iOS-Einstellungen blockiert.", Label(
systemImage: "exclamationmark.circle") "Benachrichtigungen sind in den iOS-Einstellungen blockiert.",
.font(.caption) systemImage: "exclamationmark.circle"
.foregroundStyle(CardsTheme.warning) )
.font(.caption)
.foregroundStyle(CardsTheme.warning)
}
}
Section("Marketplace") {
NavigationLink {
BlockedAuthorsView()
} label: {
Label("Blockierte Authors", systemImage: "hand.raised")
} }
} }

View file

@ -28,15 +28,16 @@ struct CardRenderer: View {
imageOcclusionView imageOcclusionView
case .audioFront: case .audioFront:
audioFrontView audioFrontView
case .typing, .multipleChoice: case .multipleChoice:
placeholderView MultipleChoiceCardView(card: card, isFlipped: isFlipped)
case .typing:
TypingCardView(card: card, isFlipped: isFlipped)
} }
} }
.padding(24) .padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
@ViewBuilder
private func basicView(front frontKey: String, back backKey: String) -> some View { private func basicView(front frontKey: String, back backKey: String) -> some View {
VStack(spacing: 16) { VStack(spacing: 16) {
text(card.fields[frontKey] ?? "") text(card.fields[frontKey] ?? "")
@ -129,7 +130,6 @@ struct CardRenderer: View {
} }
} }
@ViewBuilder
private var placeholderView: some View { private var placeholderView: some View {
VStack(spacing: 8) { VStack(spacing: 8) {
Image(systemName: "questionmark.square.dashed") Image(systemName: "questionmark.square.dashed")

View file

@ -0,0 +1,202 @@
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 {
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
}
}

View file

@ -1,11 +1,12 @@
import SwiftUI import SwiftUI
#if canImport(UIKit) #if canImport(UIKit)
import UIKit import UIKit
#endif #endif
/// Vier Rating-Buttons unten am Bildschirm. Tap onRate(rating) /// Vier Rating-Buttons mit emphasis auf "Good" (full-width primary).
/// plus Haptic-Feedback. /// Web-Vorbild: `cards/apps/web/src/routes/study/[deckId]/+page.svelte`
/// `.grade.again/.hard/.good/.easy`-Klassen.
struct RatingBar: View { struct RatingBar: View {
let onRate: (Rating) -> Void let onRate: (Rating) -> Void
@ -16,17 +17,24 @@ struct RatingBar: View {
triggerHaptic(for: rating) triggerHaptic(for: rating)
onRate(rating) onRate(rating)
} label: { } label: {
VStack(spacing: 2) { HStack(spacing: 6) {
Text(rating.label) Text(rating.label)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
Text(rating.shortcut) Text(rating.shortcut)
.font(.caption2) .font(.caption2.weight(.semibold))
.foregroundStyle(.secondary) .padding(.horizontal, 5)
.padding(.vertical, 1)
.background(kbdBackground(for: rating), in: RoundedRectangle(cornerRadius: 4))
.foregroundStyle(kbdForeground(for: rating))
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 14) .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)) .foregroundStyle(foreground(for: rating))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(borderColor(for: rating), lineWidth: rating == .good ? 0 : 1)
)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@ -34,12 +42,14 @@ struct RatingBar: View {
.padding(.horizontal, 16) .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 { private func background(for rating: Rating) -> Color {
switch rating { switch rating {
case .again: CardsTheme.error.opacity(0.12) case .again: CardsTheme.error.opacity(0.06)
case .hard: CardsTheme.warning.opacity(0.12) case .hard: CardsTheme.warning.opacity(0.06)
case .good: CardsTheme.primary.opacity(0.12) case .good: CardsTheme.primary
case .easy: CardsTheme.success.opacity(0.12) case .easy: CardsTheme.success.opacity(0.06)
} }
} }
@ -47,17 +57,37 @@ struct RatingBar: View {
switch rating { switch rating {
case .again: CardsTheme.error case .again: CardsTheme.error
case .hard: CardsTheme.warning case .hard: CardsTheme.warning
case .good: CardsTheme.primary case .good: CardsTheme.primaryForeground
case .easy: CardsTheme.success 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) { private func triggerHaptic(for rating: Rating) {
#if canImport(UIKit) #if canImport(UIKit)
let generator = UIImpactFeedbackGenerator( let style: UIImpactFeedbackGenerator.FeedbackStyle =
style: rating == .easy ? .heavy : .medium rating == .easy ? .heavy : .medium
) UIImpactFeedbackGenerator(style: style).impactOccurred()
generator.impactOccurred()
#endif #endif
} }
} }

View file

@ -8,7 +8,7 @@ import SwiftData
@MainActor @MainActor
@Observable @Observable
final class StudySession { final class StudySession {
enum Phase: Sendable { enum Phase {
case loading case loading
case studying case studying
case finished case finished
@ -55,7 +55,9 @@ final class StudySession {
} else { } else {
phase = .studying 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 { } catch {
let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
phase = .failed(msg) phase = .failed(msg)
@ -86,7 +88,8 @@ final class StudySession {
isFlipped = false isFlipped = false
if currentIndex >= queue.count { if currentIndex >= queue.count {
phase = .finished phase = .finished
Log.study.info("Session finished — graded \(self.totalGraded, privacy: .public)") let count = totalGraded
Log.study.info("Session finished — graded \(count, privacy: .public)")
} }
} }
} }

View file

@ -3,7 +3,7 @@ import SwiftData
import SwiftUI import SwiftUI
#if canImport(UIKit) #if canImport(UIKit)
import UIKit import UIKit
#endif #endif
/// Vollbild-Study-View. Wird per Navigation aus DeckListView geöffnet. /// Vollbild-Study-View. Wird per Navigation aus DeckListView geöffnet.
@ -73,26 +73,7 @@ struct StudySessionView: View {
session.flip() session.flip()
} }
keyboardShortcuts(session: session) keyboardShortcuts(session: session)
if session.isFlipped { bottomBar(session: session)
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)
}
} }
} }
.padding(.bottom, 20) .padding(.bottom, 20)
@ -100,22 +81,47 @@ struct StudySessionView: View {
.animation(.easeInOut(duration: 0.2), value: session.currentIndex) .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 { private func cardSurface(due: DueReview, isFlipped: Bool) -> some View {
RoundedRectangle(cornerRadius: 16) CardSurface(size: .hero, elevation: .raised) {
.fill(CardsTheme.surface) CardRenderer(
.overlay( card: due.card,
CardRenderer( subIndex: due.review.subIndex,
card: due.card, isFlipped: isFlipped
subIndex: due.review.subIndex,
isFlipped: isFlipped
)
) )
.overlay( .frame(maxWidth: .infinity, maxHeight: .infinity)
RoundedRectangle(cornerRadius: 16) }
.stroke(CardsTheme.border, lineWidth: 1) .padding(.horizontal, 16)
) .padding(.top, 12)
.padding(.horizontal, 16)
.padding(.top, 12)
} }
private func finishedView(session: StudySession) -> some View { private func finishedView(session: StudySession) -> some View {
@ -160,7 +166,6 @@ struct StudySessionView: View {
/// Unsichtbare Buttons mit Keyboard-Shortcuts. Funktionieren auf /// Unsichtbare Buttons mit Keyboard-Shortcuts. Funktionieren auf
/// iPad (Magic Keyboard) und macOS. Space = flip, 1-4 = Rating. /// iPad (Magic Keyboard) und macOS. Space = flip, 1-4 = Rating.
@ViewBuilder
private func keyboardShortcuts(session: StudySession) -> some View { private func keyboardShortcuts(session: StudySession) -> some View {
Group { Group {
Button("Flip") { Button("Flip") {
@ -185,7 +190,7 @@ struct StudySessionView: View {
private func flipHaptic() { private func flipHaptic() {
#if canImport(UIKit) #if canImport(UIKit)
UIImpactFeedbackGenerator(style: .soft).impactOccurred() UIImpactFeedbackGenerator(style: .soft).impactOccurred()
#endif #endif
} }
} }

View file

@ -0,0 +1,192 @@
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

View file

@ -29,12 +29,6 @@
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
},
{
"filename" : "AppIcon-1024.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
} }
], ],
"info" : { "info" : {

View file

@ -1,5 +1,261 @@
{ {
"sourceLanguage" : "de", "sourceLanguage" : "de",
"strings" : { }, "strings" : {
"… und %@ weitere" : {
},
"„%@“" : {
},
"@%@" : {
},
"%@" : {
},
"%@ fällig" : {
},
"%@ fällige Karten aus abonnierten Decks" : {
},
"%@ Karten" : {
},
"%@ Karten gelernt" : {
},
"3500 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 / 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" : {
},
"Cardecky" : {
},
"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/<slug>." : {
},
"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" : {
},
"Karteikarten des Vereins mana e.V." : {
},
"Karten" : {
},
"Karten konnten nicht geladen werden" : {
},
"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«." : {
},
"Noch keine Karten. Tippe auf »Karte hinzufügen«." : {
},
"Noch keine Maske" : {
},
"Öffentlich" : {
},
"OK" : {
},
"Pflicht-Schritt vor dem ersten Marketplace-Deck. Slug erscheint in Marketplace-URLs." : {
},
"Preis: %@ Credits" : {
},
"Privat" : {
},
"Quellen" : {
},
"Space" : {
},
"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" "version" : "1.0"
} }

View file

@ -1,14 +1,14 @@
import XCTest import XCTest
final class CardsNativeUITests: XCTestCase { final class CardsNativeUITests: XCTestCase {
func testAppLaunches() throws { func testAppLaunches() {
let app = XCUIApplication() let app = XCUIApplication()
app.launch() 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 // oder das DeckListView mit "Decks" sichtbar ist. Welcher
// von beiden hängt davon ab, ob der Simulator-Keychain noch // von beiden hängt davon ab, ob der Simulator-Keychain noch
// eine Session hält. // eine Session hält.
let loginTitle = app.staticTexts["Cards"] let loginTitle = app.staticTexts["Cardecky"]
let decksTitle = app.staticTexts["Decks"] let decksTitle = app.staticTexts["Decks"]
let exploreTab = app.staticTexts["Entdecken"] let exploreTab = app.staticTexts["Entdecken"]
@ -21,6 +21,6 @@ final class CardsNativeUITests: XCTestCase {
} }
usleep(100_000) 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")
} }
} }

View file

@ -6,7 +6,7 @@ import Testing
struct DeckDecodingTests { struct DeckDecodingTests {
@Test("Wire-Format aus toDeckDto decodet sauber") @Test("Wire-Format aus toDeckDto decodet sauber")
func decodesDeckFromWireFormat() throws { func decodesDeckFromWireFormat() throws {
let json = """ let json = Data("""
{ {
"id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"user_id": "user_123", "user_id": "user_123",
@ -23,7 +23,7 @@ struct DeckDecodingTests {
"created_at": "2026-05-12T10:30:00.123Z", "created_at": "2026-05-12T10:30:00.123Z",
"updated_at": "2026-05-12T15:45:00.456Z" "updated_at": "2026-05-12T15:45:00.456Z"
} }
""".data(using: .utf8)! """.utf8)
let decoder = JSONDecoder() let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601withFractional decoder.dateDecodingStrategy = .iso8601withFractional
@ -41,7 +41,7 @@ struct DeckDecodingTests {
@Test("Marketplace-Forks werden erkannt") @Test("Marketplace-Forks werden erkannt")
func recognizesMarketplaceFork() throws { func recognizesMarketplaceFork() throws {
let json = """ let json = Data("""
{ {
"id": "deck_456", "id": "deck_456",
"user_id": "user_123", "user_id": "user_123",
@ -58,7 +58,7 @@ struct DeckDecodingTests {
"created_at": "2026-05-01T00:00:00.000Z", "created_at": "2026-05-01T00:00:00.000Z",
"updated_at": "2026-05-01T00:00:00.000Z" "updated_at": "2026-05-01T00:00:00.000Z"
} }
""".data(using: .utf8)! """.utf8)
let decoder = JSONDecoder() let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601withFractional decoder.dateDecodingStrategy = .iso8601withFractional
@ -71,7 +71,7 @@ struct DeckDecodingTests {
@Test("DeckListResponse-Wrapper") @Test("DeckListResponse-Wrapper")
func decodesListResponse() throws { func decodesListResponse() throws {
let json = """ let json = Data("""
{ {
"decks": [ "decks": [
{ {
@ -93,7 +93,7 @@ struct DeckDecodingTests {
], ],
"total": 1 "total": 1
} }
""".data(using: .utf8)! """.utf8)
let decoder = JSONDecoder() let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601withFractional decoder.dateDecodingStrategy = .iso8601withFractional

View file

@ -12,7 +12,7 @@ struct MarketplaceDecodingTests {
@Test("PublicDeckEntry aus Browse-Response") @Test("PublicDeckEntry aus Browse-Response")
func decodesPublicDeckEntry() throws { func decodesPublicDeckEntry() throws {
let json = """ let json = Data("""
{ {
"slug": "geografie-welt-top30", "slug": "geografie-welt-top30",
"title": "Geografie Welt Top 30", "title": "Geografie Welt Top 30",
@ -31,10 +31,10 @@ struct MarketplaceDecodingTests {
"display_name": "mana-Kuratoren", "display_name": "mana-Kuratoren",
"verified_mana": true, "verified_mana": true,
"verified_community": false, "verified_community": false,
"pseudonym": null "pseudonym": false
} }
} }
""".data(using: .utf8)! """.utf8)
let entry = try decoder().decode(PublicDeckEntry.self, from: json) let entry = try decoder().decode(PublicDeckEntry.self, from: json)
#expect(entry.slug == "geografie-welt-top30") #expect(entry.slug == "geografie-welt-top30")
@ -46,12 +46,12 @@ struct MarketplaceDecodingTests {
@Test("ExploreResponse mit featured + trending") @Test("ExploreResponse mit featured + trending")
func decodesExploreResponse() throws { func decodesExploreResponse() throws {
let json = """ let json = Data("""
{ {
"featured": [], "featured": [],
"trending": [] "trending": []
} }
""".data(using: .utf8)! """.utf8)
let res = try decoder().decode(ExploreResponse.self, from: json) let res = try decoder().decode(ExploreResponse.self, from: json)
#expect(res.featured.isEmpty) #expect(res.featured.isEmpty)
#expect(res.trending.isEmpty) #expect(res.trending.isEmpty)
@ -59,7 +59,7 @@ struct MarketplaceDecodingTests {
@Test("PublicDeckDetail mit camelCase 'latest_version'") @Test("PublicDeckDetail mit camelCase 'latest_version'")
func decodesPublicDeckDetail() throws { func decodesPublicDeckDetail() throws {
let json = """ let json = Data("""
{ {
"deck": { "deck": {
"id": "deck_1", "id": "deck_1",
@ -88,7 +88,7 @@ struct MarketplaceDecodingTests {
}, },
"owner": null "owner": null
} }
""".data(using: .utf8)! """.utf8)
let detail = try decoder().decode(PublicDeckDetail.self, from: json) let detail = try decoder().decode(PublicDeckDetail.self, from: json)
#expect(detail.deck.slug == "english-a2") #expect(detail.deck.slug == "english-a2")
@ -99,14 +99,14 @@ struct MarketplaceDecodingTests {
@Test("SubscribeResponse mit private_deck_id") @Test("SubscribeResponse mit private_deck_id")
func decodesSubscribeResponse() throws { func decodesSubscribeResponse() throws {
let json = """ let json = Data("""
{ {
"subscribed": true, "subscribed": true,
"deck_slug": "english-a2", "deck_slug": "english-a2",
"current_version_id": "v_1", "current_version_id": "v_1",
"private_deck_id": "private_deck_xyz" "private_deck_id": "private_deck_xyz"
} }
""".data(using: .utf8)! """.utf8)
let res = try decoder().decode(SubscribeResponse.self, from: json) let res = try decoder().decode(SubscribeResponse.self, from: json)
#expect(res.subscribed == true) #expect(res.subscribed == true)
#expect(res.privateDeckId == "private_deck_xyz") #expect(res.privateDeckId == "private_deck_xyz")

View file

@ -44,7 +44,7 @@ struct MaskRegionsTests {
func encodeRoundtrip() { func encodeRoundtrip() {
let original = [ let original = [
MaskRegion(id: "m1", x: 0.1, y: 0.2, w: 0.3, h: 0.4, label: "test"), 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 encoded = MaskRegions.encode(original)
let parsed = MaskRegions.parse(encoded) let parsed = MaskRegions.parse(encoded)

View file

@ -4,9 +4,16 @@ import Testing
@Suite("Mutation Body Encoding") @Suite("Mutation Body Encoding")
struct MutationEncodingTests { struct MutationEncodingTests {
private func encode<T: Encodable>(_ value: T) throws -> [String: Any] { private func encode(_ value: some Encodable) throws -> [String: Any] {
let data = try JSONEncoder().encode(value) 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") @Test("DeckCreateBody nutzt snake_case und lässt nil weg")

View file

@ -6,7 +6,7 @@ import Testing
struct ReviewDecodingTests { struct ReviewDecodingTests {
@Test("Review-Wire-Format decodet vollständig") @Test("Review-Wire-Format decodet vollständig")
func decodesReview() throws { func decodesReview() throws {
let json = """ let json = Data("""
{ {
"card_id": "card_1", "card_id": "card_1",
"sub_index": 0, "sub_index": 0,
@ -22,7 +22,7 @@ struct ReviewDecodingTests {
"state": "review", "state": "review",
"last_review": "2026-05-10T10:00:00.000Z" "last_review": "2026-05-10T10:00:00.000Z"
} }
""".data(using: .utf8)! """.utf8)
let decoder = JSONDecoder() let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601withFractional decoder.dateDecodingStrategy = .iso8601withFractional
@ -39,7 +39,7 @@ struct ReviewDecodingTests {
func decodesDueReview() throws { func decodesDueReview() throws {
// Achtung: Server liefert hier `deckId` camelCase im embedded card, // Achtung: Server liefert hier `deckId` camelCase im embedded card,
// weil das aus Drizzle direkt rauskommt, nicht durch toCardDto. // weil das aus Drizzle direkt rauskommt, nicht durch toCardDto.
let json = """ let json = Data("""
{ {
"card_id": "c1", "card_id": "c1",
"sub_index": 0, "sub_index": 0,
@ -61,7 +61,7 @@ struct ReviewDecodingTests {
"fields": {"front": "Was ist 1+1?", "back": "2"} "fields": {"front": "Was ist 1+1?", "back": "2"}
} }
} }
""".data(using: .utf8)! """.utf8)
let decoder = JSONDecoder() let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601withFractional decoder.dateDecodingStrategy = .iso8601withFractional

View file

@ -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)
}
}

View file

@ -7,7 +7,7 @@ gegenlesen, Tonalität an Vereins-Stil schärfen (siehe `mana/docs/BRAND.md`,
## Name + Subtitle ## Name + Subtitle
**App-Name:** `Cards` **App-Name:** `Cardecky`
**Subtitle (max 30 Zeichen):** `Karteikarten — mana e.V.` **Subtitle (max 30 Zeichen):** `Karteikarten — mana e.V.`
## Keywords (max 100 Zeichen, comma-separated) ## 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) ## 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 Repetition wie es sein soll: ohne Werbung, ohne Tracking, ohne
Abo-Zwang. Abo-Zwang.
@ -33,13 +33,13 @@ KARTEN, WIE DU SIE BRAUCHST
- Audio-Karten für Sprachen und Aussprache - Audio-Karten für Sprachen und Aussprache
MODERNER LERN-ALGORITHMUS 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 offenen Algorithmus für Karteikarten. Karten kommen wieder, wenn du
sie wirklich brauchst — nicht nach willkürlichen Intervallen. sie wirklich brauchst — nicht nach willkürlichen Intervallen.
OFFLINE LERNEN OFFLINE LERNEN
Karten cachen lokal auf deinem Gerät. Im Flugzeug, in der U-Bahn, 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. und beim nächsten Online-Moment automatisch hochgeladen.
WIDGETS UND NOTIFICATIONS WIDGETS UND NOTIFICATIONS
@ -53,7 +53,7 @@ Sprachen, Wissenschaft. Abonnieren = Karten landen direkt in deiner
Bibliothek, du kannst sie nach Belieben anpassen. Bibliothek, du kannst sie nach Belieben anpassen.
VEREIN, NICHT FIRMA 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 Kein Tracking, kein Werbe-Anbieter, kein Crash-Reporter. Wenn du dem
Verein etwas zurückgeben willst: mana-ev.ch/spende. Verein etwas zurückgeben willst: mana-ev.ch/spende.
@ -71,7 +71,7 @@ selbst.
## Description — EN (max 4000 Zeichen) ## 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. way it should be: no ads, no tracking, no subscription pressure.
THE CARDS YOU NEED THE CARDS YOU NEED
@ -80,12 +80,12 @@ THE CARDS YOU NEED
- Audio cards for languages and pronunciation - Audio cards for languages and pronunciation
MODERN SPACED-REPETITION MODERN SPACED-REPETITION
Cards uses FSRS (Free Spaced Repetition Scheduler), the most accurate Cardecky uses FSRS (Free Spaced Repetition Scheduler), the most accurate
open algorithm available. Cards come back when you actually need them. open algorithm available. Your cards come back when you actually need them.
OFFLINE-FIRST OFFLINE-FIRST
Decks cache locally on your device. On a plane, in the subway, off 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. the next online moment.
WIDGETS AND REMINDERS 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. science. Subscribe = decks land in your library, ready to be edited.
ASSOCIATION, NOT CORPORATION 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? No tracking, no ad networks, no crash reporters. Want to give back?
mana-ev.ch/donate. mana-ev.ch/donate.

View file

@ -88,7 +88,7 @@ AASA) und über Xcode (für Build + Sign).
- [ ] **App-Eintrag erstellen** unter https://appstoreconnect.apple.com - [ ] **App-Eintrag erstellen** unter https://appstoreconnect.apple.com
mit Bundle-ID `ev.mana.cardecky`. mit Bundle-ID `ev.mana.cardecky`.
- [ ] **App-Name** + **Subtitle** (max 30 Zeichen): - [ ] **App-Name** + **Subtitle** (max 30 Zeichen):
- Name: "Cards" - Name: "Cardecky"
- Subtitle: "Karteikarten — Verein mana" - Subtitle: "Karteikarten — Verein mana"
- [ ] **Description** (de + en, max 4000 Zeichen). Vorschlag in - [ ] **Description** (de + en, max 4000 Zeichen). Vorschlag in
[`docs/MARKETING_COPY.md`](MARKETING_COPY.md) — vor Submission [`docs/MARKETING_COPY.md`](MARKETING_COPY.md) — vor Submission
@ -97,9 +97,10 @@ AASA) und über Xcode (für Build + Sign).
"Karteikarten,Spaced Repetition,Lernen,Vokabeln,Anki,Flashcards,FSRS,mana,Verein,Open Source" "Karteikarten,Spaced Repetition,Lernen,Vokabeln,Anki,Flashcards,FSRS,mana,Verein,Open Source"
- [ ] **Screenshots** für iPhone 16 Pro Max + iPhone SE-3 + iPad Pro. - [ ] **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. 6.7", 6.5", 5.5", iPad 12.9" — siehe Apple's Specs.
- [ ] **Privacy-Policy-URL** — vermutlich `cardecky.mana.how/privacy` - [x] **Privacy-Policy-URL**: `https://cardecky.mana.how/privacy` (live
oder `mana-ev.ch/privacy`. **Verifizieren.** seit 2026-05-13, SvelteKit-Route mit Verein-Content).
- [ ] **Support-URL**`cardecky.mana.how/help` oder Verein-Kontakt. - [x] **Support-URL**: `https://cardecky.mana.how/help` (live, FAQ +
Kontakt-Email kontakt@mana-ev.ch).
- [ ] **Marketing-URL** (optional) — `cardecky.mana.how`. - [ ] **Marketing-URL** (optional) — `cardecky.mana.how`.
- [ ] **Age-Rating**: vermutlich 4+ (no objectionable content). - [ ] **Age-Rating**: vermutlich 4+ (no objectionable content).
- [ ] **Pricing**: Free. - [ ] **Pricing**: Free.

View file

@ -3,6 +3,7 @@ name: CardsNative
options: options:
bundleIdPrefix: ev.mana bundleIdPrefix: ev.mana
createIntermediateGroups: true createIntermediateGroups: true
xcodeVersion: "16.0"
deploymentTarget: deploymentTarget:
iOS: "18.0" iOS: "18.0"
macOS: "15.0" macOS: "15.0"
@ -13,6 +14,8 @@ options:
packages: packages:
ManaSwiftCore: ManaSwiftCore:
path: ../mana-swift-core path: ../mana-swift-core
ManaSwiftUI:
path: ../mana-swift-ui
settings: settings:
base: base:
@ -38,6 +41,8 @@ targets:
product: ManaCore product: ManaCore
- package: ManaSwiftCore - package: ManaSwiftCore
product: ManaTokens product: ManaTokens
- package: ManaSwiftUI
product: ManaAuthUI
- target: CardsWidgetExtension - target: CardsWidgetExtension
embed: true embed: true
- target: CardsShareExtension - target: CardsShareExtension
@ -54,18 +59,27 @@ targets:
path: Sources/Resources/Info.plist path: Sources/Resources/Info.plist
properties: properties:
CFBundleShortVersionString: "0.1.0" CFBundleShortVersionString: "0.1.0"
CFBundleVersion: "1" CFBundleVersion: "11"
CFBundleDevelopmentRegion: de CFBundleDevelopmentRegion: de
CFBundleDisplayName: Cards CFBundleDisplayName: Cardecky
LSApplicationCategoryType: "public.app-category.education" LSApplicationCategoryType: "public.app-category.education"
UILaunchScreen: {} UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
CFBundleURLTypes: CFBundleURLTypes:
- CFBundleURLName: ev.mana.cardecky - CFBundleURLName: ev.mana.cardecky
CFBundleURLSchemes: CFBundleURLSchemes:
- cards - cards
NSUserActivityTypes: NSUserActivityTypes:
- NSUserActivityTypeBrowsingWeb - 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 ITSAppUsesNonExemptEncryption: false
entitlements: entitlements:
path: Sources/Resources/CardsNative.entitlements path: Sources/Resources/CardsNative.entitlements
@ -100,6 +114,8 @@ targets:
path: ShareExtension/Resources/Info.plist path: ShareExtension/Resources/Info.plist
properties: properties:
CFBundleDisplayName: Als Karte speichern CFBundleDisplayName: Als Karte speichern
CFBundleShortVersionString: "0.1.0"
CFBundleVersion: "11"
NSExtension: NSExtension:
NSExtensionPointIdentifier: com.apple.share-services NSExtensionPointIdentifier: com.apple.share-services
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
@ -130,7 +146,9 @@ targets:
info: info:
path: Widgets/CardsWidget/Resources/Info.plist path: Widgets/CardsWidget/Resources/Info.plist
properties: properties:
CFBundleDisplayName: Cards Widget CFBundleDisplayName: Cardecky Widget
CFBundleShortVersionString: "0.1.0"
CFBundleVersion: "11"
NSExtension: NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension NSExtensionPointIdentifier: com.apple.widgetkit-extension
entitlements: entitlements:
@ -146,7 +164,7 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cardecky.widget PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cardecky.widget
CODE_SIGN_STYLE: Automatic CODE_SIGN_STYLE: Automatic
SKIP_INSTALL: "YES" SKIP_INSTALL: "YES"
INFOPLIST_KEY_CFBundleDisplayName: Cards Widget INFOPLIST_KEY_CFBundleDisplayName: Cardecky Widget
CardsNativeTests: CardsNativeTests:
type: bundle.unit-test type: bundle.unit-test