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:
- todo
- trailing_comma
# opening_brace kollidiert mit SwiftFormats `wrapMultilineStatementBraces`,
# das bei Multi-Line-Conditions das `{` auf eine eigene Zeile wirft.
# SwiftFormat gewinnt — ist im Pre-Commit-Hook.
- opening_brace
opt_in_rules:
- empty_count
@ -18,8 +22,31 @@ line_length:
identifier_name:
min_length: 2
excluded:
# Standard-Identifier
- id
- ok
# Mathematische/algorithmische Konventionen (loops, indizes,
# Koordinaten, distances) — kürzer ist hier klarer als verbose.
- i
- j
- n
- m
- x
- y
- w
- h
- r
- g
- b
- a
- c
- d
- s
- f
- p
- q
- t
- l
included:
- Sources

53
PLAN.md
View file

@ -1,13 +1,20 @@
# Plan — cards-native (SwiftUI Universal)
**Stand: 2026-05-13 — Phasen β-0 bis β-7 abgeschlossen.**
Feature-komplett für TestFlight. Alle 7 Card-Types + Marketplace
+ Keyboard/Daily-Reminder/Widget + Siri-Shortcut + Share-Extension
+ App-Icon-Platzhalter + Release-Checklist. 35 Unit-Tests + 1 UI-Test
grün, alle drei Targets (Haupt-App + Widget + Share) bauen.
**Stand: 2026-05-13 — TestFlight Build 11 (v0.9.4).** Alle Phasen
β-0 bis β-7 + Polish-Iterationen. 43 Unit-Tests + 1 UI-Test grün.
**Cardecky-Web-Look übernommen** (v0.9.0 ff.): Fan-Stack-Tiles
(5:7 Aspect, 3 rotierte Background-Layer), CardSurface in
md/lg/hero, RatingBar mit Good-Emphasis. Tap auf Tile = Study-
Mode, Pencil-Icon unten rechts = Deck-Detail. Identische Tile-
Optik in Decks- und Entdecken-Tab.
**App-Store-Connect:** Cardecky, App-ID 6769019526, Bundle
`ev.mana.cardecky`, Team `QP3GLU8PH3`. AASA + /privacy + /help
live unter `cardecky.mana.how`.
Pflicht-Check für β-2: Endurance-Test auf realem Gerät (200+ Karten
mit Flugmodus zwischendurch) steht aus — Aufgabe für Till.
mit Flugmodus zwischendurch) steht aus.
> **SOT:** `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`.
> Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc
@ -28,6 +35,40 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till.
- `LoginView` (Email/PW gegen mana-auth)
- 3 Unit-Tests (AppConfig)
✅ **Post-β-7-Polish (2026-05-13, Tags v0.8.1 → v0.9.4)**
Live-Fixes nach Apple-Submission und Real-Device-Tests:
- `v0.8.1` Cardecky-Rebrand (Bundle `ev.mana.cards``ev.mana.cardecky`,
AASA + Docker-Compose-Env nachgezogen)
- `v0.8.2` Archive-Polish — Versions-Sync zwischen Targets, iPad-
Orientations
- `v0.8.3` Sendable + AppIcon-Asset-Cleanup, /privacy + /help Stubs
- `v0.8.4` PhotosPicker Sendable-Warning via Sub-View-Struct
- `v0.8.5` ITMS-90129-Fix: DisplayName Cards → Cardecky, Build 2
- `v0.8.6` Cardecky-Rebrand User-facing Strings durchgängig
- `v0.8.7` PublicDeckOwner.pseudonym Bool statt String? (Decoder-Crash
bei Marketplace-Deck-Open)
- `v0.8.8` Card-Liste in DeckDetailView + CardsAPI.listCards
- `v0.8.9` URL-Query-Bug-Fix in ManaCore.AuthenticatedTransport
(URL.appending(path:) encoded `?` → 404; gefixt via String-Concat,
ManaCore v1.0.1). Behob alle "0-Karten"-Phänomene und das stille
Schlucken von Query-Endpoints.
- `v0.9.0` Cardecky-Web-Design: Fan-Stack-Tiles, CardSurface in
3 Sizes, RatingBar mit Good-Emphasis, horizontale Scroll-Sections
- `v0.9.1` Multiple-Choice-Karten gerendert (Distractors via Server,
Tap-Selektion, Reveal mit Korrekt/Falsch-Highlight)
- `v0.9.2` Typing-Karten gerendert (Levenshtein-Match 1:1 aus
cards-domain portiert: correct/close/wrong, Aliases-Support,
Diakritika-Normalisierung)
- `v0.9.3` DEBUG-Auto-Login analog memoro-native (`ensureSignedIn()`
in #if DEBUG) — auch in manaspur-native nachgezogen
- `v0.9.4` Tile-Tap = Study-Mode direkt, Pencil-Edit-Icon unten
rechts → DeckDetail, ExploreView mit gleichem Tile-Layout
(5:7 Aspect, Kategorie-Icon oben rechts in primary)
**Schema-Fix:** cards-Repo Commit `4d905bb` (0002_decks_archived_at)
gleicht Schema-Drift in der Production-DB aus.
✅ **β-7 — App-Store-Vorbereitung (2026-05-13, Tag `v0.8.0`)**
- App-Icon-Platzhalter: `scripts/make-appicon.swift` generiert 1024×1024
PNG aus CoreGraphics (forest-green + "C"-Letter). Asset-Catalog auf

View file

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

View file

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

View file

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

View file

@ -1,30 +1,97 @@
import ManaAuthUI
import ManaCore
import SwiftUI
/// Top-Level-Switch: Login vs Haupt-App. Haupt-App ist eine TabBar mit
/// drei Tabs (Decks / Entdecken / Account).
/// Top-Level-View: TabBar mit drei Tabs (Decks / Entdecken / Account).
/// Kein harter Login-Gate mehr Cardecky läuft auch im Guest-Modus
/// (lokale Decks lernen, Marketplace browsen). Schreibende Server-
/// Aktionen werden über ``ManaAuthGate`` einzeln auf Login eskaliert.
struct RootView: View {
@Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@State private var selectedTab: AppTab = .decks
@State private var pendingDeepLinkSlug: String?
@State private var showCreateDeck = false
@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 {
switch auth.status {
case .signedIn:
mainTabs
.onOpenURL { url in handle(url: url) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { handle(url: url) }
mainTabs
.onOpenURL { url in handle(url: url) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { handle(url: url) }
}
.manaBrand(CardsBrand.manaBrand)
.manaAuthGate(authGate) {
gateSignInContent
}
.sheet(item: Binding(
get: { resetPasswordToken.map(IdentifiedString.init) },
set: { resetPasswordToken = $0?.value }
)) { token in
ManaResetPasswordView(
token: token.value,
auth: auth,
onDone: { resetPasswordToken = nil }
)
.manaBrand(CardsBrand.manaBrand)
}
.task {
// DEBUG: Auto-Login mit DebugCredentials, falls signedOut.
// Release: no-op. Danach in Guest-Mode wechseln, wenn weder
// signedIn noch eingebuchtet Cardecky soll *immer* nutzbar
// sein, auch ohne Account.
await auth.ensureSignedIn()
if case .signedOut = auth.status {
do {
_ = try auth.enterGuestMode()
} catch {
Log.auth.warning(
"Guest-Mode konnte nicht aktiviert werden: \(String(describing: error), privacy: .public)"
)
}
}
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 {
TabView(selection: $selectedTab) {
DeckListView()
DeckListView(showCreate: $showCreateDeck)
.tabItem { Label("Decks", systemImage: "rectangle.stack") }
.tag(AppTab.decks)
@ -38,20 +105,44 @@ struct RootView: View {
.tabItem { Label("Account", systemImage: "person.crop.circle") }
.tag(AppTab.account)
}
.decksCreateAccessory(visible: selectedTab == .decks) {
authGate.require(reason: "deck-create-accessory") {
showCreateDeck = true
}
}
}
/// Universal-Link- und URL-Scheme-Handler:
/// - `https://cardecky.mana.how/d/<slug>` Explore-Tab + PublicDeckView
/// - `https://cardecky.mana.how/auth/reset?token=` ManaResetPasswordView
/// - `cards://study/<deckId>` später (β-6 Notifications)
private func handle(url: URL) {
Log.app.info("Open URL: \(url.absoluteString, privacy: .public)")
if url.host == "cardecky.mana.how" || url.scheme == "cards" {
let parts = url.pathComponents.filter { $0 != "/" }
if parts.count >= 2, parts[0] == "d" {
pendingDeepLinkSlug = parts[1]
selectedTab = .explore
guard url.host == "cardecky.mana.how" || url.scheme == "cards" else { return }
let parts = url.pathComponents.filter { $0 != "/" }
// Auth-Reset-Link aus der Passwort-Vergessen-Email.
if parts == ["auth", "reset"] {
let 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 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 ManaCore
// swiftlint:disable file_length
// swiftlint:disable type_body_length
/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
/// aus ManaCore, der die Cardecky-Endpoints kennt.
/// aus ManaCore, der die Cardecky-Endpoints kennt. Marketplace-Moderation
/// + Self-Endpoints + AI-Generation sind in `CardsAPI+Marketplace.swift`
/// und `CardsAPI+Generation.swift` ausgelagert.
actor CardsAPI {
private let transport: AuthenticatedTransport
private let decoder: JSONDecoder
let transport: AuthenticatedTransport
let decoder: JSONDecoder
init(auth: AuthClient) {
transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth)
@ -44,6 +49,29 @@ actor CardsAPI {
return try decoder.decode(CardListResponse.self, from: data).total
}
/// `GET /api/v1/cards?deck_id=...` komplette Liste der Karten
/// für den Browse-Modus im DeckDetailView.
func listCards(deckId: String) async throws -> [Card] {
let (data, http) = try await transport.request(path: "/api/v1/cards?deck_id=\(deckId)")
try ensureOK(http, data: data)
return try decoder.decode(CardListResponse.self, from: data).cards
}
/// `GET /api/v1/decks/:deckId/distractors` N zufällige Feldwerte
/// aus anderen Karten desselben Decks. Server-Schema erlaubt nur
/// `front`, `back`, `answer`, `question` als field.
func distractors(
deckId: String,
cardId: String,
field: String = "answer",
count: Int = 3
) async throws -> [String] {
let path = "/api/v1/decks/\(deckId)/distractors?card_id=\(cardId)&field=\(field)&count=\(count)"
let (data, http) = try await transport.request(path: path)
try ensureOK(http, data: data)
return try decoder.decode(DistractorsResponse.self, from: data).distractors
}
/// `GET /api/v1/reviews/due?deck_id=...&limit=500` Anzahl fälliger
/// Reviews in einem Deck.
func dueCount(deckId: String) async throws -> Int {
@ -74,7 +102,7 @@ actor CardsAPI {
var items: [URLQueryItem] = [
.init(name: "sort", value: sort.rawValue),
.init(name: "limit", value: "\(limit)"),
.init(name: "offset", value: "\(offset)"),
.init(name: "offset", value: "\(offset)")
]
if let query, !query.trimmingCharacters(in: .whitespaces).isEmpty {
items.append(.init(name: "q", value: query))
@ -147,7 +175,7 @@ actor CardsAPI {
func fetchMedia(id: String) async throws -> Data {
let (data, http) = try await transport.request(path: "/api/v1/media/\(id)")
guard (200 ..< 300).contains(http.statusCode) else {
throw AuthError.serverError(status: http.statusCode, message: "media fetch failed")
throw AuthError.serverError(status: http.statusCode, code: nil, message: "media fetch failed")
}
return data
}
@ -155,7 +183,7 @@ actor CardsAPI {
/// `DELETE /api/v1/media/:id` Soft-Forget. (Endpoint heute nicht
/// implementiert serverseitig; Stub bleibt für späteren Use.)
func deleteMedia(id _: String) async throws {
throw AuthError.serverError(status: 501, message: "media delete not implemented on server")
throw AuthError.serverError(status: 501, code: nil, message: "media delete not implemented on server")
}
// MARK: - Deck-Mutations
@ -195,6 +223,87 @@ actor CardsAPI {
try ensureOK(http, data: data)
}
/// `POST /api/v1/decks/:id/duplicate` Server-seitige Kopie mit
/// "(Kopie)"-Suffix, ohne FSRS-Verlauf, ohne Marketplace-Pointer.
@discardableResult
func duplicateDeck(id: String) async throws -> Deck {
let (data, http) = try await transport.request(
path: "/api/v1/decks/\(id)/duplicate",
method: "POST"
)
try ensureOK(http, data: data)
return try decoder.decode(Deck.self, from: data)
}
/// `POST /api/v1/marketplace/private/:deckId/pull-update` Smart-Merge-
/// Pull. Holt neue/geänderte Karten aus der jüngsten Marketplace-Version
/// in das geforkte private Deck. Removed-Karten bleiben lokal (User-
/// Choice gewinnt). 422 wenn das Deck kein Fork ist.
func pullUpdate(deckId: String) async throws -> PullUpdateResponse {
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/private/\(deckId)/pull-update",
method: "POST"
)
try ensureOK(http, data: data)
return try decoder.decode(PullUpdateResponse.self, from: data)
}
// MARK: - Marketplace-Publish
/// `POST /api/v1/marketplace/authors/me` Author-Profil upserten.
/// Pflicht-Schritt vor dem ersten Deck-Init im Marketplace.
func upsertAuthor(_ body: AuthorUpsertBody) async throws {
let payload = try makeJSON(body)
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/authors/me",
method: "POST",
body: payload
)
try ensureOK(http, data: data)
}
/// `GET /api/v1/marketplace/authors/me` eigenes Author-Profil
/// lesen, gibt `nil` zurück wenn noch keins existiert.
func myAuthor() async throws -> Bool {
let (data, http) = try await transport.request(path: "/api/v1/marketplace/authors/me")
try ensureOK(http, data: data)
// Server liefert entweder Author-Objekt oder JSON-null.
if let raw = try? JSONSerialization.jsonObject(with: data), raw is NSNull {
return false
}
return true
}
/// `POST /api/v1/marketplace/decks` Marketplace-Deck-Init.
/// Erstellt nur Metadaten; Karten folgen via `publishMarketplaceVersion`.
@discardableResult
func initMarketplaceDeck(_ body: MarketplaceDeckInitBody) async throws -> PublicDeck {
let payload = try makeJSON(body)
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/decks",
method: "POST",
body: payload
)
try ensureOK(http, data: data)
return try decoder.decode(PublicDeck.self, from: data)
}
/// `POST /api/v1/marketplace/decks/:slug/publish` neue Version
/// publishen. Karten werden serverseitig durch AI-Moderation geschickt.
func publishMarketplaceVersion(
slug: String,
body: MarketplacePublishBody
) async throws -> MarketplacePublishResponse {
let payload = try makeJSON(body)
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/decks/\(slug)/publish",
method: "POST",
body: payload
)
try ensureOK(http, data: data)
return try decoder.decode(MarketplacePublishResponse.self, from: data)
}
// MARK: - Card-Mutations
/// `POST /api/v1/cards` Karte anlegen. Server validiert `fields`
@ -270,44 +379,24 @@ actor CardsAPI {
// MARK: - JSON-Encoding
private func makeJSON<T: Encodable>(_ value: T) throws -> Data {
func makeJSON(_ value: some Encodable) throws -> Data {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return try encoder.encode(value)
}
// MARK: - Multipart
private func makeMultipartBody(
file: Data,
filename: String,
mimeType: String,
boundary: String
) -> Data {
var body = Data()
let lineBreak = "\r\n"
let header = """
--\(boundary)\(lineBreak)\
Content-Disposition: form-data; name="file"; filename="\(filename)"\(lineBreak)\
Content-Type: \(mimeType)\(lineBreak)\(lineBreak)
"""
body.append(header.data(using: .utf8) ?? Data())
body.append(file)
body.append(lineBreak.data(using: .utf8) ?? Data())
body.append("--\(boundary)--\(lineBreak)".data(using: .utf8) ?? Data())
return body
}
// MARK: - Helpers
private func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
guard (200 ..< 300).contains(http.statusCode) else {
let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error
throw AuthError.serverError(status: http.statusCode, message: message)
throw AuthError.serverError(status: http.statusCode, code: nil, message: message)
}
}
}
// swiftlint:enable type_body_length
private struct CardsServerError: Decodable {
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`
/// und `cards/packages/cards-domain/src/schemas/card.ts`.
struct Card: Codable, Identifiable, Hashable, Sendable {
struct Card: Codable, Identifiable, Hashable {
let id: String
let deckId: String
let userId: String
@ -29,7 +29,7 @@ struct Card: Codable, Identifiable, Hashable, Sendable {
/// Card-Type-Enum. Vollständig aus `CardTypeSchema`. In β-2 rendern
/// wir nur `basic`, `basic-reverse`, `cloze`. Die anderen Types
/// kommen in β-3 und β-4 dazu, sind aber jetzt schon decodierbar.
enum CardType: String, Codable, Sendable, CaseIterable {
enum CardType: String, Codable, CaseIterable {
case basic
case basicReverse = "basic-reverse"
case cloze
@ -43,7 +43,7 @@ enum CardType: String, Codable, Sendable, CaseIterable {
/// Server liefert nur 4 Felder (id, deckId, type, fields) als Drizzle-
/// Joined-Subset Achtung: `deckId` hier in **camelCase**, nicht
/// snake_case wie sonst.
struct ReviewCard: Codable, Hashable, Sendable {
struct ReviewCard: Codable, Hashable {
let id: String
let deckId: String
let type: CardType

View file

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

View file

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

View file

@ -2,7 +2,7 @@ import Foundation
/// Deck-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toDeckDto`.
/// snake_case-Felder via `CodingKeys`, Optionals explizit nullable.
struct Deck: Codable, Identifiable, Hashable, Sendable {
struct Deck: Codable, Identifiable, Hashable {
let id: String
let userId: String
let name: String
@ -41,14 +41,14 @@ struct Deck: Codable, Identifiable, Hashable, Sendable {
}
}
enum DeckVisibility: String, Codable, Sendable {
enum DeckVisibility: String, Codable {
case `private`
case space
case `public`
}
/// Aus `cards/packages/cards-domain/src/schemas/deck.ts:DECK_CATEGORY_IDS`.
enum DeckCategory: String, Codable, Sendable, CaseIterable {
enum DeckCategory: String, Codable, CaseIterable {
case language
case medicine
case science
@ -82,7 +82,7 @@ enum DeckCategory: String, Codable, Sendable, CaseIterable {
/// FSRS-Settings Native bleibt schematisch agnostisch, FSRS rechnet
/// nur der Server. Wir behalten die Felder als roh-JSON, damit eine
/// neue Setting auf dem Server uns nicht bricht.
struct FsrsSettings: Codable, Sendable, Hashable {
struct FsrsSettings: Codable, Hashable {
let requestRetention: Double?
let maximumInterval: Int?
let enableFuzz: Bool?
@ -114,17 +114,23 @@ struct FsrsSettings: Codable, Sendable, Hashable {
}
/// Server-Response von `GET /api/v1/decks`.
struct DeckListResponse: Decodable, Sendable {
struct DeckListResponse: Decodable {
let decks: [Deck]
let total: Int
}
/// Server-Response von `GET /api/v1/cards?deck_id=...`.
struct CardListResponse: Decodable, Sendable {
struct CardListResponse: Decodable {
let cards: [Card]
let total: Int
}
/// Server-Response von `GET /api/v1/reviews/due?deck_id=...`.
struct DueReviewsResponse: Decodable, Sendable {
struct DueReviewsResponse: Decodable {
let total: Int
}
/// Server-Response von `GET /api/v1/decks/:deckId/distractors`.
struct DistractorsResponse: Decodable {
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
/// `cards/packages/cards-domain/src/schemas/deck.ts`.
struct DeckCreateBody: Encodable, Sendable {
struct DeckCreateBody: Encodable {
let name: String
let description: String?
let color: String?
@ -19,7 +19,7 @@ struct DeckCreateBody: Encodable, Sendable {
}
/// Body für `PATCH /api/v1/decks/:id`. Alle Felder optional plus `archived`.
struct DeckUpdateBody: Encodable, Sendable {
struct DeckUpdateBody: Encodable {
var name: String?
var description: String?
var color: String?
@ -36,3 +36,37 @@ struct DeckUpdateBody: Encodable, Sendable {
case archived
}
}
/// Kurze Marketplace-Version-Info: Semver + Version-ID.
struct PullUpdateVersion: Decodable {
let semver: String
let versionId: String
enum CodingKeys: String, CodingKey {
case semver
case versionId = "version_id"
}
}
/// Response von `POST /api/v1/marketplace/private/:deckId/pull-update`.
/// `up_to_date == true` heißt: keine neue Marketplace-Version verfügbar,
/// die anderen Counts sind dann 0.
struct PullUpdateResponse: Decodable {
let upToDate: Bool
let from: PullUpdateVersion?
let to: PullUpdateVersion?
let added: Int
let changed: Int
let removed: Int
let cardsInserted: Int?
enum CodingKeys: String, CodingKey {
case upToDate = "up_to_date"
case from
case to
case added
case changed
case removed
case cardsInserted = "cards_inserted"
}
}

View file

@ -1,7 +1,7 @@
import Foundation
/// Browse-Eintrag aus `/api/v1/marketplace/decks` und `.../explore`.
struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable {
struct PublicDeckEntry: Codable, Hashable, Identifiable {
let slug: String
let title: String
let description: String?
@ -16,7 +16,9 @@ struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable {
let createdAt: Date
let owner: PublicDeckOwner
var id: String { slug }
var id: String {
slug
}
enum CodingKeys: String, CodingKey {
case slug, title, description, language, category, license
@ -29,15 +31,21 @@ struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable {
case owner
}
var isPaid: Bool { priceCredits > 0 }
var isPaid: Bool {
priceCredits > 0
}
}
struct PublicDeckOwner: Codable, Hashable, Sendable {
struct PublicDeckOwner: Codable, Hashable {
let slug: String
let displayName: String
let verifiedMana: Bool
let verifiedCommunity: Bool
let pseudonym: String?
/// Flag: Autor tritt pseudonym auf (Anzeigename verbergen).
/// Server-Schema ist `boolean NOT NULL DEFAULT false`. War in v0.8.x
/// fälschlich als `String?` (Anzeige-Pseudonym) interpretiert
/// führte zu Decoder-typeMismatch beim Öffnen eines Marketplace-Decks.
let pseudonym: Bool
enum CodingKeys: String, CodingKey {
case slug
@ -53,24 +61,24 @@ struct PublicDeckOwner: Codable, Hashable, Sendable {
displayName = try c.decode(String.self, forKey: .displayName)
verifiedMana = try c.decode(Bool.self, forKey: .verifiedMana)
verifiedCommunity = try c.decode(Bool.self, forKey: .verifiedCommunity)
pseudonym = try c.decodeIfPresent(String.self, forKey: .pseudonym)
pseudonym = (try? c.decode(Bool.self, forKey: .pseudonym)) ?? false
}
}
/// Response von `GET /api/v1/marketplace/explore`.
struct ExploreResponse: Decodable, Sendable {
struct ExploreResponse: Decodable {
let featured: [PublicDeckEntry]
let trending: [PublicDeckEntry]
}
/// Response von `GET /api/v1/marketplace/decks`.
struct BrowseResponse: Decodable, Sendable {
struct BrowseResponse: Decodable {
let items: [PublicDeckEntry]
let total: Int
}
/// Vollständiges Public-Deck aus `GET /api/v1/marketplace/decks/:slug`.
struct PublicDeck: Codable, Hashable, Sendable, Identifiable {
struct PublicDeck: Codable, Hashable, Identifiable {
let id: String
let slug: String
let title: String
@ -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 deckId: String
let semver: String
@ -119,7 +127,7 @@ struct PublicDeckVersion: Codable, Hashable, Sendable, Identifiable {
}
/// Response von `GET /api/v1/marketplace/decks/:slug`.
struct PublicDeckDetail: Decodable, Sendable {
struct PublicDeckDetail: Decodable {
let deck: PublicDeck
let latestVersion: PublicDeckVersion?
let owner: PublicDeckOwner?
@ -132,7 +140,7 @@ struct PublicDeckDetail: Decodable, Sendable {
}
/// Response von `POST /api/v1/marketplace/decks/:slug/subscribe`.
struct SubscribeResponse: Decodable, Sendable {
struct SubscribeResponse: Decodable {
let subscribed: Bool
let deckSlug: String
let currentVersionId: String?
@ -147,7 +155,7 @@ struct SubscribeResponse: Decodable, Sendable {
}
/// Browse-Sort-Optionen aus `BrowseQuerySchema`.
enum MarketplaceSort: String, Sendable, CaseIterable {
enum MarketplaceSort: String, CaseIterable {
case recent
case popular
case trending

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

View file

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

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 {
static let title: LocalizedStringResource = "Karten lernen"
static let description = IntentDescription(
"Öffnet Cards und zeigt deine Decks mit fälligen Karten.",
"Öffnet Cardecky und zeigt deine Decks mit fälligen Karten.",
categoryName: "Lernen"
)
static let openAppWhenRun: Bool = true
@ -29,7 +29,7 @@ struct CardsAppShortcuts: AppShortcutsProvider {
phrases: [
"Karten lernen mit \(.applicationName)",
"Mit \(.applicationName) lernen",
"\(.applicationName) öffnen",
"\(.applicationName) öffnen"
],
shortTitle: "Karten lernen",
systemImageName: "rectangle.stack"

View file

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

View file

@ -9,7 +9,7 @@ import WidgetKit
@MainActor
@Observable
final class DeckListStore {
enum State: Sendable {
enum State {
case idle
case loading
case loaded
@ -21,15 +21,25 @@ final class DeckListStore {
private let api: CardsAPI
private let context: ModelContext
private let auth: AuthClient
init(auth: AuthClient, context: ModelContext) {
api = CardsAPI(auth: auth)
self.context = context
self.auth = auth
}
/// Holt Decks vom Server, aktualisiert Cache. Bei Netzfehler bleibt
/// der Cache (offline-readable).
/// der Cache (offline-readable). Im Guest-Mode wird kein Server-Call
/// versucht der Cache (leer oder über Marketplace-Klone gefüllt)
/// wird so wie er ist gerendert.
func refresh() async {
guard case .signedIn = auth.status else {
state = .idle
errorMessage = nil
return
}
state = .loading
errorMessage = nil
@ -69,8 +79,8 @@ final class DeckListStore {
group.addTask { [api] in
async let cards = api.cardCount(deckId: deck.id)
async let due = api.dueCount(deckId: deck.id)
let cardCount = (try? await cards) ?? 0
let dueCount = (try? await due) ?? 0
let cardCount = await (try? cards) ?? 0
let dueCount = await (try? due) ?? 0
return (deck.id, cardCount, dueCount)
}
}

View file

@ -30,8 +30,9 @@ final class GradeQueue {
)
context.insert(grade)
try? context.save()
let rawRating = rating.rawValue
Log.study.info(
"Queued grade for \(cardId, privacy: .public)/\(subIndex, privacy: .public): \(rating.rawValue, privacy: .public)"
"Queued grade \(cardId, privacy: .public)/\(subIndex, privacy: .public): \(rawRating, privacy: .public)"
)
await drain()
}
@ -73,8 +74,10 @@ final class GradeQueue {
grade.lastError = msg
try? context.save()
lastDrainError = msg
let cid = grade.cardId
let sub = grade.subIndex
Log.study.notice(
"Drain stopped for \(grade.cardId, privacy: .public)/\(grade.subIndex, privacy: .public): \(msg, privacy: .public)"
"Drain stopped \(cid, privacy: .public)/\(sub, privacy: .public): \(msg, privacy: .public)"
)
return
}

View file

@ -35,10 +35,16 @@ actor MediaCache {
/// Direktes Lesen für UI-Komponenten, die `Data` brauchen (z.B. AVAudioPlayer).
func data(for mediaId: String) async throws -> Data {
try Data(contentsOf: try await localURL(for: mediaId))
try await Data(contentsOf: localURL(for: mediaId))
}
/// LRU-Eviction: bei Überschreitung des Limits ältesten zuerst löschen.
private struct CacheEntry {
let url: URL
let size: Int
let date: Date
}
private func pruneIfNeeded() async throws {
let resourceKeys: Set<URLResourceKey> = [.fileSizeKey, .contentModificationDateKey]
guard let items = try? FileManager.default.contentsOfDirectory(
@ -46,10 +52,10 @@ actor MediaCache {
includingPropertiesForKeys: Array(resourceKeys)
) else { return }
let withMeta = items.compactMap { url -> (url: URL, size: Int, date: Date)? in
let withMeta = items.compactMap { url -> CacheEntry? in
let values = try? url.resourceValues(forKeys: resourceKeys)
guard let size = values?.fileSize, let date = values?.contentModificationDate else { return nil }
return (url, size, date)
return CacheEntry(url: url, size: size, date: date)
}
let totalBytes = withMeta.reduce(0) { $0 + $1.size }
@ -61,7 +67,9 @@ actor MediaCache {
if remaining <= maxBytes { break }
try? FileManager.default.removeItem(at: item.url)
remaining -= item.size
Log.sync.info("MediaCache evicted \(item.url.lastPathComponent, privacy: .public) (\(item.size, privacy: .public)B)")
let name = item.url.lastPathComponent
let size = item.size
Log.sync.info("MediaCache evicted \(name, privacy: .public) (\(size, privacy: .public)B)")
}
}

View file

@ -1,15 +1,5 @@
import SwiftUI
/// Environment-Key, der den shared `MediaCache` durch die View-Hierarchie
/// reicht. App-Entrypoint setzt den Wert; Views lesen via
/// `@Environment(\.mediaCache)`.
private struct MediaCacheKey: EnvironmentKey {
static let defaultValue: MediaCache? = nil
}
extension EnvironmentValues {
var mediaCache: MediaCache? {
get { self[MediaCacheKey.self] }
set { self[MediaCacheKey.self] = newValue }
}
@Entry var mediaCache: MediaCache?
}

View file

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

View file

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

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
#if canImport(UIKit)
import UIKit
private typealias PlatformColorType = UIColor
import UIKit
private typealias PlatformColorType = UIColor
#elseif canImport(AppKit)
import AppKit
private typealias PlatformColorType = NSColor
import AppKit
private typealias PlatformColorType = NSColor
#endif
/// Forest-Theme aus `mana/packages/themes/src/variants/forest.css`.
@ -16,56 +18,67 @@ private typealias PlatformColorType = NSColor
/// `mana/docs/MANA_SWIFT.md` bis dahin lebt forest hier.
enum CardsTheme {
/// Page-Hintergrund
static let background = dynamic(light: (0, 0, 100), dark: (142, 30, 8))
static let background = dynamic(light: HSL(0, 0, 100), dark: HSL(142, 30, 8))
/// Standard-Text
static let foreground = dynamic(light: (142, 30, 12), dark: (142, 15, 95))
static let foreground = dynamic(light: HSL(142, 30, 12), dark: HSL(142, 15, 95))
/// Card, Panel, Modal
static let surface = dynamic(light: (142, 25, 98), dark: (142, 25, 12))
static let surface = dynamic(light: HSL(142, 25, 98), dark: HSL(142, 25, 12))
/// Hover-State auf Surface
static let surfaceHover = dynamic(light: (142, 20, 95), dark: (142, 20, 16))
static let surfaceHover = dynamic(light: HSL(142, 20, 95), dark: HSL(142, 20, 16))
/// Disabled-Felder, Skeleton
static let muted = dynamic(light: (142, 15, 93), dark: (142, 18, 18))
static let muted = dynamic(light: HSL(142, 15, 93), dark: HSL(142, 18, 18))
/// Sekundär-Text, Placeholder
static let mutedForeground = dynamic(light: (142, 10, 42), dark: (142, 12, 65))
static let mutedForeground = dynamic(light: HSL(142, 10, 42), dark: HSL(142, 12, 65))
/// Rahmen, Trennlinien
static let border = dynamic(light: (142, 15, 88), dark: (142, 18, 22))
static let border = dynamic(light: HSL(142, 15, 88), dark: HSL(142, 18, 22))
/// Cards-Brand-Grün Tiefgrün im Light, leuchtender im Dark
static let primary = dynamic(light: (142, 76, 28), dark: (142, 71, 45))
static let primary = dynamic(light: HSL(142, 76, 28), dark: HSL(142, 71, 45))
/// Text auf Primary
static let primaryForeground = dynamic(light: (0, 0, 100), dark: (142, 30, 8))
static let primaryForeground = dynamic(light: HSL(0, 0, 100), dark: HSL(142, 30, 8))
static let error = dynamic(light: (0, 84, 60), dark: (0, 63, 55))
static let success = dynamic(light: (142, 71, 45), dark: (142, 71, 45))
static let warning = dynamic(light: (38, 92, 50), dark: (48, 96, 53))
static let error = dynamic(light: HSL(0, 84, 60), dark: HSL(0, 63, 55))
static let success = dynamic(light: HSL(142, 71, 45), dark: HSL(142, 71, 45))
static let warning = dynamic(light: HSL(38, 92, 50), dark: HSL(48, 96, 53))
// MARK: - HSL Helper
private static func dynamic(
light: (Double, Double, Double),
dark: (Double, Double, Double)
) -> Color {
let lightColor = fromHSL(light.0, light.1, light.2)
let darkColor = fromHSL(dark.0, dark.1, dark.2)
/// Hue/Saturation/Lightness als Wert-Typ. HSL ist konkreter als ein
/// 3-Tupel und macht die Call-Sites lesbar.
struct HSL {
let hue: Double
let saturation: Double
let lightness: Double
init(_ hue: Double, _ saturation: Double, _ lightness: Double) {
self.hue = hue
self.saturation = saturation
self.lightness = lightness
}
}
private static func dynamic(light: HSL, dark: HSL) -> Color {
let lightColor = fromHSL(light.hue, light.saturation, light.lightness)
let darkColor = fromHSL(dark.hue, dark.saturation, dark.lightness)
#if canImport(UIKit)
return Color(uiColor: UIColor { trait in
trait.userInterfaceStyle == .dark ? darkColor : lightColor
})
return Color(uiColor: UIColor { trait in
trait.userInterfaceStyle == .dark ? darkColor : lightColor
})
#elseif canImport(AppKit)
return Color(nsColor: NSColor(name: nil) { appearance in
let isDark = appearance.bestMatch(from: [.darkAqua, .vibrantDark]) != nil
return isDark ? darkColor : lightColor
})
return Color(nsColor: NSColor(name: nil) { appearance in
let isDark = appearance.bestMatch(from: [.darkAqua, .vibrantDark]) != nil
return isDark ? darkColor : lightColor
})
#else
return Color(red: 0, green: 0, blue: 0)
return Color(red: 0, green: 0, blue: 0)
#endif
}

View file

@ -1,59 +1,194 @@
import ManaAuthUI
import ManaCore
import SwiftUI
struct AccountView: View {
@Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@State private var showChangeEmail = false
@State private var showChangePassword = false
@State private var showDeleteAccount = false
var body: some View {
ZStack {
CardsTheme.background.ignoresSafeArea()
VStack(spacing: 24) {
Image(systemName: "person.crop.circle.fill")
.resizable()
.frame(width: 80, height: 80)
.foregroundStyle(CardsTheme.primary)
if let email = auth.currentEmail {
Text(email)
.font(.headline)
.foregroundStyle(CardsTheme.foreground)
Group {
switch auth.status {
case .signedIn:
signedInContent
case .guest, .signedOut, .error, .unknown:
guestContent
case .signingIn, .twoFactorRequired:
ProgressView().tint(CardsTheme.primary)
}
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")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.manaBrand(CardsBrand.manaBrand)
.sheet(isPresented: $showChangeEmail) {
ManaChangeEmailView(
auth: auth,
callbackUniversalLink: URL(string: "https://cardecky.mana.how/auth/email-changed"),
onDone: { showChangeEmail = false }
)
.manaBrand(CardsBrand.manaBrand)
}
.sheet(isPresented: $showChangePassword) {
ManaChangePasswordView(
auth: auth,
onDone: { showChangePassword = false }
)
.manaBrand(CardsBrand.manaBrand)
}
.sheet(isPresented: $showDeleteAccount) {
ManaDeleteAccountView(
auth: auth,
onDone: { showDeleteAccount = false }
)
.manaBrand(CardsBrand.manaBrand)
}
}
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 SwiftUI
/// Deck-Detail mit Aktionen: Lernen, Karte hinzufügen, Bearbeiten, Löschen.
/// Wird per Tap auf eine Deck-Row aus der DeckListView geöffnet.
// swiftlint:disable file_length
// swiftlint:disable type_body_length
/// Deck-Detail mit Aktionen + Card-Liste. Wird per Tap auf eine Deck-Row
/// aus der DeckListView geöffnet.
///
/// `type_body_length` ist bewusst übersprungen Detail-View hostet
/// 5 verschiedene Sheets (Edit, CardCreate, CardEdit, Publish, Print),
/// Confirmation-Dialog + Alerts; aufspalten ginge nur über Multi-State-
/// Plumbing zwischen Parent und Children.
struct DeckDetailView: View {
let deckId: String
@ -17,6 +25,18 @@ struct DeckDetailView: View {
@State private var showDeleteConfirm = false
@State private var navigateToStudy = false
@State private var deleteError: String?
@State private var editingCard: Card?
@State private var cards: [Card] = []
@State private var isLoadingCards = false
@State private var cardsError: String?
@State private var isPullingUpdate = false
@State private var isDuplicating = false
@State private var pullAlert: AlertMessage?
@State private var actionError: String?
@State private var showPublishSheet = false
@State private var showPrintSheet = false
init(deckId: String) {
self.deckId = deckId
@ -49,11 +69,44 @@ struct DeckDetailView: View {
}
.sheet(isPresented: $showCardEditor) {
NavigationStack {
CardEditorView(deckId: deckId) { _ in
Task { await refreshAfterEdit() }
CardEditorView(mode: .create(deckId: deckId)) { _ in
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(
"Deck löschen?",
isPresented: $showDeleteConfirm,
@ -64,28 +117,56 @@ struct DeckDetailView: View {
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Alle Karten und Reviews dieses Decks werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.")
Text(
"""
Alle Karten und Reviews dieses Decks werden ebenfalls \
gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
"""
)
}
.navigationDestination(isPresented: $navigateToStudy) {
if let deck = decks.first {
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 {
VStack(alignment: .leading, spacing: 16) {
header(deck: deck)
actions(deck: deck)
if let deleteError {
Text(deleteError)
.font(.footnote)
.foregroundStyle(CardsTheme.error)
.padding(.horizontal, 16)
ScrollView {
VStack(alignment: .leading, spacing: 16) {
header(deck: deck)
actions(deck: deck)
if let deleteError {
Text(deleteError)
.font(.footnote)
.foregroundStyle(CardsTheme.error)
.padding(.horizontal, 16)
}
cardListSection
}
Spacer()
.padding(.vertical, 16)
}
.padding(.vertical, 16)
}
private func header(deck: CachedDeck) -> some View {
@ -121,75 +202,180 @@ struct DeckDetailView: View {
private func actions(deck: CachedDeck) -> some View {
VStack(spacing: 12) {
Button {
navigateToStudy = true
} label: {
Label("Karten lernen", systemImage: "play.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.primaryForeground)
}
.buttonStyle(.plain)
.disabled(deck.dueCount == 0)
Button {
showCardEditor = true
} label: {
Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.foreground)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(CardsTheme.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
HStack(spacing: 12) {
Button {
showEditor = true
} label: {
Label("Bearbeiten", systemImage: "pencil")
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.foreground)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(CardsTheme.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
Button {
showDeleteConfirm = true
} label: {
Label("Löschen", systemImage: "trash")
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.error)
}
.buttonStyle(.plain)
}
primaryActions
secondaryActions(deck: deck)
}
.padding(.horizontal, 16)
}
@ViewBuilder
private var primaryActions: some View {
Button {
navigateToStudy = true
} label: {
Label("Karten lernen", systemImage: "play.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.primaryForeground)
}
.buttonStyle(.plain)
.disabled((decks.first?.dueCount ?? 0) == 0)
Button {
showCardEditor = true
} label: {
Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.foreground)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(CardsTheme.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
private func secondaryActions(deck: CachedDeck) -> some View {
DeckSecondaryActions(
isForkedFromMarketplace: deck.isFromMarketplace,
isPullingUpdate: isPullingUpdate,
isDuplicating: isDuplicating,
onPullUpdate: { Task { await pullUpdate() } },
onDuplicate: { Task { await duplicate() } },
onPublish: { showPublishSheet = true },
onPrint: { showPrintSheet = true },
onEdit: { showEditor = true },
onDelete: { showDeleteConfirm = true }
)
}
private var cardListSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
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 {
let store = DeckListStore(auth: auth, context: context)
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 {
deleteError = nil
let api = CardsAPI(auth: auth)
do {
try await api.deleteDeck(id: deckId)
// Cache nachziehen
if let deck = decks.first {
context.delete(deck)
try? context.save()
@ -200,3 +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 SwiftData
import SwiftUI
/// β-1 Hauptbildschirm: Liste aller Decks mit Card- und Due-Counts.
/// Web-Vorbild: `cards/apps/web/src/routes/decks/+page.svelte`.
/// Navigations-Routen für die DeckListView. Tap auf eine Tile geht
/// direkt in Study-Mode, Tap auf den Edit-Button in den Deck-Detail-
/// View für Browse + Edit.
enum DeckRoute: Hashable {
case study(deckId: String, deckName: String)
case detail(deckId: String)
}
// 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 {
@Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@Environment(\.modelContext) private var context
@Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck]
@Binding var showCreate: Bool
private var isGuest: Bool {
if case .signedIn = auth.status { false } else { true }
}
@State private var store: DeckListStore?
@State private var showAccount = false
@State private var showCreate = false
@State private var pendingShares: [PendingShare] = []
@State private var path = NavigationPath()
var body: some View {
NavigationStack {
NavigationStack(path: $path) {
ZStack {
CardsTheme.background.ignoresSafeArea()
content
}
.navigationTitle("Decks")
.navigationDestination(for: String.self) { deckId in
DeckDetailView(deckId: deckId)
#if os(iOS)
.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
PendingShareConsumeView(share: route.share, onDone: {
@ -51,16 +77,6 @@ struct DeckListView: View {
.onAppear {
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 {
emptyState
} else {
List {
pendingShareSection
inboxBannerSection
ownDecksSection
ScrollView {
VStack(alignment: .leading, spacing: 24) {
pendingShareSection
inboxBanner
deckSection(title: "Eigene Decks", icon: "rectangle.stack", decks: ownDecks)
if !subscribedDecks.isEmpty {
deckSection(title: "Abonniert", icon: "globe", decks: subscribedDecks)
}
}
.padding(.vertical, 12)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
private var ownDecks: [CachedDeck] {
decks.filter { !$0.isFromMarketplace }
}
private var subscribedDecks: [CachedDeck] {
decks.filter(\.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
private var pendingShareSection: some View {
if !pendingShares.isEmpty {
Section {
VStack(alignment: .leading, spacing: 8) {
ForEach(pendingShares) { share in
NavigationLink(value: PendingShareRoute(share: share)) {
HStack(spacing: 12) {
Image(systemName: "square.and.arrow.down")
.foregroundStyle(CardsTheme.primary)
.foregroundStyle(CardsTheme.warning)
VStack(alignment: .leading, spacing: 2) {
Text("Aus Teilen-Menü")
.font(.subheadline.weight(.semibold))
@ -98,16 +200,20 @@ struct DeckListView: View {
.lineLimit(2)
}
Spacer()
Image(systemName: "chevron.right")
.font(.footnote)
.foregroundStyle(CardsTheme.mutedForeground)
}
.padding()
.background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
.padding(14)
.background(
CardsTheme.warning.opacity(0.12),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
}
.buttonStyle(.plain)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
}
}
.padding(.horizontal, 20)
}
}
@ -126,157 +232,64 @@ struct DeckListView: View {
Text(message)
.foregroundStyle(CardsTheme.mutedForeground)
}
} else if isGuest {
ContentUnavailableView {
Label("Cardecky ohne Konto", systemImage: "person.crop.circle.dashed")
.foregroundStyle(CardsTheme.foreground)
} description: {
Text(
"""
Browse den Marketplace im Entdecken-Tab kein Konto \
nötig. Für eigene Decks und Cloud-Sync logge dich ein.
"""
)
.foregroundStyle(CardsTheme.mutedForeground)
} actions: {
Button("Anmelden / Konto erstellen") {
authGate.require(reason: "deck-list-empty") {}
}
.buttonStyle(.borderedProminent)
.tint(CardsTheme.primary)
}
} else {
ContentUnavailableView {
Label("Noch keine Decks", systemImage: "rectangle.stack")
.foregroundStyle(CardsTheme.foreground)
} description: {
Text("Erstelle dein erstes Deck auf cardecky.mana.how oder ziehe nach unten zum Aktualisieren.")
.foregroundStyle(CardsTheme.mutedForeground)
Text(
"""
Tippe unten auf »+«, um dein erstes Deck zu erstellen, \
oder browse den Marketplace im Entdecken-Tab.
"""
)
.foregroundStyle(CardsTheme.mutedForeground)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var inboxBannerSection: some View {
if let inbox = decks.first(where: { $0.isFromMarketplace && $0.dueCount > 0 }) {
Section {
HStack(spacing: 12) {
Image(systemName: "tray.full.fill")
.foregroundStyle(CardsTheme.primary)
VStack(alignment: .leading, spacing: 2) {
Text("Inbox")
.font(.subheadline.weight(.semibold))
.foregroundStyle(CardsTheme.foreground)
Text("\(inbox.dueCount) fällige Karten aus abonnierten Decks")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
}
Spacer()
}
.padding()
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
}
private var ownDecksSection: some View {
Section {
ForEach(decks) { deck in
NavigationLink(value: deck.id) {
DeckRow(deck: deck)
}
.buttonStyle(.plain)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
}
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) {
Button {
showCreate = true
} label: {
Image(systemName: "plus.circle")
.foregroundStyle(CardsTheme.primary)
}
.accessibilityLabel("Deck hinzufügen")
}
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)
// Auf iOS 26 übernimmt das `.tabViewBottomAccessory` aus RootView die
// Neues Deck"-Pille. Doppelten +"-Button im Liquid-Glass-Layout
// vermeiden bottomBar-Button nur auf iOS < 26 zeigen.
if #unavailable(iOS 26.0) {
ToolbarItemGroup(placement: .bottomBar) {
Button {
authGate.require(reason: "deck-create-toolbar") {
showCreate = true
}
} 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 {
/// 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)
}
}
// swiftlint:enable type_body_length

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 PhotosUI
import SwiftUI
#if canImport(UIKit)
import UIKit
import UIKit
#endif
/// Card-Create-View. Type-Picker oben, type-spezifische Felder unten.
/// Deckt alle 7 Card-Types ab.
// swiftlint:disable type_body_length
/// Card-Create und Card-Edit in einer View.
///
/// - `.create(deckId:)` zeigt Type-Picker + leere Felder.
/// - `.edit(card:)` blendet Type-Picker aus (Server-seitig immutable),
/// pre-fillt alle Felder, und PATCHt auf Submit.
///
/// Bei Image-Occlusion und Audio-Front im Edit-Modus bleibt der bestehende
/// Media-Ref erhalten, solange der User die Datei nicht explizit ersetzt.
struct CardEditorView: View {
let deckId: String
let onCreated: (Card) -> Void
enum Mode {
case create(deckId: String)
case edit(card: Card)
}
let mode: Mode
let onSaved: (Card) -> Void
@Environment(AuthClient.self) private var auth
@Environment(\.dismiss) private var dismiss
@State private var type: CardType = .basic
@State private var front: String = ""
@State private var back: String = ""
@State private var clozeText: String = ""
@State private var typingAnswer: String = ""
@State private var multipleChoiceAnswer: String = ""
@State private var type: CardType
@State private var front: String
@State private var back: String
@State private var clozeText: String
@State private var typingAnswer: String
@State private var multipleChoiceAnswer: String
@State private var isSubmitting = false
@State private var errorMessage: String?
// Image-Occlusion-State
@State private var imagePickerItem: PhotosPickerItem?
@State private var occlusionImage: PlatformImage?
@State private var occlusionImageData: Data?
@State private var occlusionMimeType: String = "image/jpeg"
@State private var occlusionRegions: [MaskRegion] = []
@State private var occlusionNote: String = ""
@State private var occlusionRegions: [MaskRegion]
@State private var occlusionNote: String
/// Bestehender `image_ref` aus der Card im Edit-Modus. Bleibt erhalten,
/// solange der User kein neues Bild wählt.
@State private var existingImageRef: String?
// Audio-Front-State
/// Audio-Front-State
@State private var audioFileURL: URL?
@State private var showAudioPicker = false
/// Bestehender `audio_ref` aus der Card im Edit-Modus.
@State private var existingAudioRef: String?
private static let supportedTypes: [CardType] = [
.basic, .basicReverse, .cloze, .typing, .multipleChoice,
.imageOcclusion, .audioFront,
.imageOcclusion, .audioFront
]
init(mode: Mode, onSaved: @escaping (Card) -> Void) {
self.mode = mode
self.onSaved = onSaved
let initialType: CardType
var initialFront = ""
var initialBack = ""
var initialCloze = ""
var initialTyping = ""
var initialMC = ""
var initialRegions: [MaskRegion] = []
var initialNote = ""
var initialImageRef: String?
var initialAudioRef: String?
switch mode {
case .create:
initialType = .basic
case let .edit(card):
initialType = card.type
switch card.type {
case .basic, .basicReverse:
initialFront = card.fields["front"] ?? ""
initialBack = card.fields["back"] ?? ""
case .cloze:
initialCloze = card.fields["text"] ?? ""
case .typing:
initialFront = card.fields["front"] ?? ""
initialTyping = card.fields["answer"] ?? ""
case .multipleChoice:
initialFront = card.fields["front"] ?? ""
initialMC = card.fields["answer"] ?? ""
case .imageOcclusion:
initialRegions = MaskRegions.parse(card.fields["mask_regions"] ?? "[]")
initialNote = card.fields["note"] ?? ""
initialImageRef = card.fields["image_ref"]
case .audioFront:
initialBack = card.fields["back"] ?? ""
initialAudioRef = card.fields["audio_ref"]
}
}
_type = State(initialValue: initialType)
_front = State(initialValue: initialFront)
_back = State(initialValue: initialBack)
_clozeText = State(initialValue: initialCloze)
_typingAnswer = State(initialValue: initialTyping)
_multipleChoiceAnswer = State(initialValue: initialMC)
_occlusionRegions = State(initialValue: initialRegions)
_occlusionNote = State(initialValue: initialNote)
_existingImageRef = State(initialValue: initialImageRef)
_existingAudioRef = State(initialValue: initialAudioRef)
}
var body: some View {
Form {
Section("Card-Type") {
Picker("Typ", selection: $type) {
ForEach(Self.supportedTypes, id: \.self) { t in
Text(label(for: t)).tag(t)
if isCreate {
Section("Card-Type") {
Picker("Typ", selection: $type) {
ForEach(Self.supportedTypes, id: \.self) { cardType in
Text(label(for: cardType)).tag(cardType)
}
}
.pickerStyle(.menu)
}
.pickerStyle(.menu)
}
typeFields
@ -62,7 +133,8 @@ struct CardEditorView: View {
}
}
}
.navigationTitle("Neue Karte")
.disabled(isSubmitting)
.navigationTitle(isCreate ? "Neue Karte" : "Karte bearbeiten")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
@ -71,8 +143,10 @@ struct CardEditorView: View {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Erstellen") { Task { await submit() } }
.disabled(!canSubmit || isSubmitting)
Button(isCreate ? "Erstellen" : "Speichern") {
Task { await submit() }
}
.disabled(!canSubmit || isSubmitting)
}
}
}
@ -99,12 +173,15 @@ struct CardEditorView: View {
case .cloze:
Section("Cloze-Text") {
TextField("Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.",
text: $clozeText, axis: .vertical)
.lineLimit(3 ... 8)
.autocorrectionDisabled()
.textInputAutocapitalization(.sentences)
.monospaced()
TextField(
"Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.",
text: $clozeText,
axis: .vertical
)
.lineLimit(3 ... 8)
.autocorrectionDisabled()
.textInputAutocapitalization(.sentences)
.monospaced()
}
Section {
let count = Cloze.subIndexCount(clozeText)
@ -146,123 +223,40 @@ struct CardEditorView: View {
}
case .imageOcclusion:
imageOcclusionFields
ImageOcclusionFields(
image: $occlusionImage,
imageData: $occlusionImageData,
mimeType: $occlusionMimeType,
regions: $occlusionRegions,
note: $occlusionNote,
existingImageRef: $existingImageRef,
onLoadError: { errorMessage = $0 }
)
case .audioFront:
audioFrontFields
AudioFrontFields(
audioFileURL: $audioFileURL,
back: $back,
existingAudioRef: existingAudioRef
)
}
}
@ViewBuilder
private var imageOcclusionFields: some View {
Section("Bild") {
PhotosPicker(selection: $imagePickerItem, matching: .images) {
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) }
}
}
private var isCreate: Bool {
if case .create = mode { return true }
return false
}
if let image = occlusionImage {
Section("Masken") {
MaskEditorView(image: image, regions: $occlusionRegions)
}
}
Section("Hinweis (optional)") {
TextField("z.B. Kurz-Erklärung", text: $occlusionNote, axis: .vertical)
.lineLimit(1 ... 3)
}
Section {
if occlusionImage == nil {
Label("Erst Bild wählen", systemImage: "info.circle")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
} else if occlusionRegions.isEmpty {
Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundStyle(CardsTheme.warning)
} else {
Label("\(occlusionRegions.count) Masken → \(occlusionRegions.count) Reviews",
systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(CardsTheme.success)
}
private var deckId: String {
switch mode {
case let .create(deckId): deckId
case let .edit(card): card.deckId
}
}
@ViewBuilder
private var audioFrontFields: some View {
Section("Audio-Datei") {
Button {
showAudioPicker = true
} label: {
if let audioFileURL {
Label(audioFileURL.lastPathComponent, systemImage: "waveform")
} else {
Label("Audio auswählen", systemImage: "waveform.badge.plus")
}
}
.fileImporter(
isPresented: $showAudioPicker,
allowedContentTypes: [.audio, .mp3, .wav, .mpeg4Audio],
allowsMultipleSelection: false
) { result in
if case let .success(urls) = result, let first = urls.first {
audioFileURL = first
}
}
}
Section("Antwort") {
TextField("Was zu hören ist", text: $back, axis: .vertical)
.lineLimit(2 ... 4)
}
}
private func loadPickedImage(_ item: PhotosPickerItem?) async {
guard let item else { return }
do {
guard let data = try await item.loadTransferable(type: Data.self) else { return }
occlusionImageData = data
occlusionMimeType = inferMimeType(from: data)
if let img = PlatformImage(data: data) {
occlusionImage = img
occlusionRegions = [] // neue Bildauswahl resetet Masken
}
} catch {
errorMessage = "Bild konnte nicht geladen werden: \(error.localizedDescription)"
}
}
private func inferMimeType(from data: Data) -> String {
// Schneller Magic-Byte-Check für die häufigsten Formate
guard data.count > 4 else { return "image/jpeg" }
let bytes = Array(data.prefix(8))
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" }
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" }
if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" }
// WebP: starts with "RIFF" + 4 bytes size + "WEBP"
if bytes.count >= 8,
bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] {
return "image/webp"
}
return "image/jpeg"
}
private func audioMimeType(for url: URL) -> String {
switch url.pathExtension.lowercased() {
case "mp3": "audio/mpeg"
case "wav": "audio/wav"
case "m4a", "mp4": "audio/mp4"
case "ogg", "oga": "audio/ogg"
default: "audio/mpeg"
}
private var existingMediaRefs: [String] {
if case let .edit(card) = mode { return card.mediaRefs }
return []
}
private var canSubmit: Bool {
@ -276,12 +270,14 @@ struct CardEditorView: View {
case .multipleChoice:
!front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty
case .imageOcclusion:
occlusionImageData != nil && !occlusionRegions.isEmpty
(occlusionImageData != nil || existingImageRef != nil) && !occlusionRegions.isEmpty
case .audioFront:
audioFileURL != nil && !back.trimmed.isEmpty
(audioFileURL != nil || existingAudioRef != nil) && !back.trimmed.isEmpty
}
}
// MARK: - Submit
private func submit() async {
isSubmitting = true
errorMessage = nil
@ -289,53 +285,47 @@ struct CardEditorView: View {
let api = CardsAPI(auth: auth)
do {
let fields: [String: String]
var mediaRefs: [String]? = nil
switch type {
case .basic, .basicReverse:
fields = CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed)
case .cloze:
fields = CardFieldsBuilder.cloze(text: clozeText.trimmed)
case .typing:
fields = CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed)
case .multipleChoice:
fields = CardFieldsBuilder.multipleChoice(front: front.trimmed, answer: multipleChoiceAnswer.trimmed)
case .imageOcclusion:
guard let data = occlusionImageData else { return }
let media = try await api.uploadMedia(
data: data,
filename: "occlusion.\(occlusionMimeType.contains("png") ? "png" : "jpg")",
mimeType: occlusionMimeType
)
fields = CardFieldsBuilder.imageOcclusion(
imageRef: media.id,
regions: occlusionRegions,
note: occlusionNote.trimmed.isEmpty ? nil : occlusionNote.trimmed
)
mediaRefs = [media.id]
case .audioFront:
guard let url = audioFileURL else { return }
let didStart = url.startAccessingSecurityScopedResource()
defer { if didStart { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
let media = try await api.uploadMedia(
data: data,
filename: url.lastPathComponent,
mimeType: audioMimeType(for: url)
)
fields = CardFieldsBuilder.audioFront(audioRef: media.id, back: back.trimmed)
mediaRefs = [media.id]
let payload = try await CardEditorPayloadBuilder.build(inputs: payloadInputs, api: api)
let card: Card = switch mode {
case let .create(deckId):
try await api.createCard(CardCreateBody(
deckId: deckId,
type: type,
fields: payload.fields,
mediaRefs: payload.mediaRefs
))
case let .edit(existing):
try await api.updateCard(id: existing.id, body: CardUpdateBody(
fields: payload.fields,
mediaRefs: payload.mediaRefs
))
}
let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: mediaRefs)
let card = try await api.createCard(body)
onCreated(card)
onSaved(card)
dismiss()
} catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
}
}
private var payloadInputs: CardEditorPayloadInputs {
CardEditorPayloadInputs(
type: type,
front: front.trimmed,
back: back.trimmed,
clozeText: clozeText.trimmed,
typingAnswer: typingAnswer.trimmed,
multipleChoiceAnswer: multipleChoiceAnswer.trimmed,
occlusionImageData: occlusionImageData,
occlusionMimeType: occlusionMimeType,
occlusionRegions: occlusionRegions,
occlusionNote: occlusionNote.trimmed,
existingImageRef: existingImageRef,
audioFileURL: audioFileURL,
existingAudioRef: existingAudioRef,
existingMediaRefs: existingMediaRefs
)
}
private func label(for type: CardType) -> String {
switch type {
case .basic: "Einfach (Vorder/Rück)"
@ -349,6 +339,8 @@ struct CardEditorView: View {
}
}
// swiftlint:enable type_body_length
private extension String {
var trimmed: String {
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 PhotosUI
import SwiftUI
/// Deck-Create und Deck-Edit in einer View. `existing == nil` Create-
/// Modus mit "Erstellen"-Button. Sonst Edit-Modus mit "Speichern".
// swiftlint:disable file_length
// swiftlint:disable type_body_length
/// Deck-Create und Deck-Edit in einer View. Im Create-Modus stehen vier
/// Sub-Modi zur Wahl: manuell (Leer"), AI-Text (Mit KI"), AI-Vision
/// (Aus Bild") und CSV. Edit-Modus zeigt nur das manuelle Formular.
///
/// Web-Vorbild: `cards/apps/web/src/routes/decks/new/+page.svelte`.
/// `type_body_length` ist bewusst übersprungen die 4 Sub-Modi teilen
/// sich State + Toolbar; aufspalten ginge nur über @Binding-Plumbing.
struct DeckEditorView: View {
enum Mode: Sendable {
enum Mode {
case create
case edit(deckId: String)
}
/// Vier Sub-Modi im Create-Sheet.
enum CreateMode: Hashable {
case manual
case aiText
case aiMedia
case csv
}
let mode: Mode
let onSaved: (Deck) -> Void
@Environment(AuthClient.self) private var auth
@Environment(\.dismiss) private var dismiss
// Manual fields (Edit + Create.manual)
@State private var name: String
@State private var description: String
@State private var color: String
@State private var category: DeckCategory?
@State private var visibility: DeckVisibility
@State private var isSubmitting = false
@State private var errorMessage: String?
@State private var archived: Bool
/// Vorgefüllte Farbpalette aus dem forest-Theme. User können
/// freie Hex-Werte später via Picker setzen (β-3-extension).
private static let presetColors: [String] = [
"#10803D", // forest primary light
"#1E3A2F", // forest dark
"#D97706", // amber
"#DC2626", // red
"#2563EB", // blue
"#7C3AED", // violet
"#0D9488", // teal
"#737373", // neutral
]
/// Create-mode selector
@State private var createMode: CreateMode = .manual
// AI-shared (Text + Media)
@State private var aiPrompt: String = ""
@State private var aiCount: Int = 15
@State private var aiLanguage: GenerationLanguage = .de
@State private var aiUrl: String = ""
// AI-Media
@State private var aiMediaFiles: [GenerationMediaFile] = []
@State private var aiPhotoItems: [PhotosPickerItem] = []
@State private var showPDFImporter: Bool = false
// CSV-Import
@State private var csvRows: [CSVRow] = []
@State private var csvDeckName: String = ""
@State private var showCSVImporter: Bool = false
@State private var csvImportProgress: Int = 0
// Submission
@State private var isSubmitting = false
@State private var generationTask: Task<Void, Never>?
@State private var errorMessage: String?
init(mode: Mode, existing: CachedDeck? = nil, onSaved: @escaping (Deck) -> Void) {
self.mode = mode
self.onSaved = onSaved
_name = State(initialValue: existing?.name ?? "")
_description = State(initialValue: existing?.deckDescription ?? "")
_color = State(initialValue: existing?.color ?? Self.presetColors[0])
_color = State(initialValue: existing?.color ?? DeckEditorPresets.colors[0])
_category = State(initialValue: existing?.category)
_visibility = State(initialValue: DeckVisibility(rawValue: existing?.visibilityRaw ?? "private") ?? .private)
_archived = State(initialValue: existing?.archivedAt != nil)
}
var body: some View {
Form {
Section("Name") {
TextField("Deck-Name", text: $name)
.textInputAutocapitalization(.sentences)
}
Section("Beschreibung") {
TextField("optional", text: $description, axis: .vertical)
.lineLimit(2 ... 4)
}
Section("Farbe") {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Self.presetColors, id: \.self) { hex in
colorSwatch(hex)
}
}
.padding(.vertical, 4)
ZStack {
Form {
if isCreate {
modePickerSection
}
formSections
errorSection
}
.disabled(isSubmitting)
Section("Kategorie") {
Picker("Kategorie", selection: $category) {
Text("Keine").tag(DeckCategory?.none)
ForEach(DeckCategory.allCases, id: \.self) { cat in
Text(cat.label).tag(DeckCategory?.some(cat))
}
}
}
Section("Sichtbarkeit") {
Picker("Sichtbarkeit", selection: $visibility) {
Text("Privat").tag(DeckVisibility.private)
Text("Space").tag(DeckVisibility.space)
Text("Öffentlich").tag(DeckVisibility.public)
}
.pickerStyle(.segmented)
}
if let errorMessage {
Section {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(CardsTheme.error)
}
if isSubmitting, activeMode != .manual {
GenerationOverlay(
message: overlayMessage,
onCancel: { generationTask?.cancel() }
)
}
}
.navigationTitle(isCreate ? "Neues Deck" : "Deck bearbeiten")
.navigationTitle(navTitle)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(isCreate ? "Erstellen" : "Speichern") {
Task { await submit() }
}
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || isSubmitting)
}
.toolbar { toolbar }
.onChange(of: aiPhotoItems) { _, items in
guard !items.isEmpty else { return }
Task { await ingestPhotoItems(items) }
}
.fileImporter(
isPresented: $showPDFImporter,
allowedContentTypes: [.pdf],
allowsMultipleSelection: true,
onCompletion: handlePDFImport
)
.fileImporter(
isPresented: $showCSVImporter,
allowedContentTypes: [.commaSeparatedText, .plainText],
allowsMultipleSelection: false,
onCompletion: handleCSVImport
)
}
// MARK: - Sections
private var modePickerSection: some View {
Section {
Picker("Modus", selection: $createMode) {
Text("Leer").tag(CreateMode.manual)
Text("KI").tag(CreateMode.aiText)
Text("Bild").tag(CreateMode.aiMedia)
Text("CSV").tag(CreateMode.csv)
}
.pickerStyle(.segmented)
} footer: {
modeFooter
}
}
@ViewBuilder
private var modeFooter: some View {
switch createMode {
case .manual:
Text("Leeres Deck — Karten anschließend selbst anlegen.")
case .aiText:
Text("KI generiert das Deck aus einer kurzen Beschreibung. 10 Anfragen pro Minute.")
case .aiMedia:
Text("KI liest Bilder oder PDFs und macht daraus Karten. Bis zu 5 Dateien.")
case .csv:
Text("CSV-Datei einlesen. Format: vorne,hinten[,typ] pro Zeile.")
}
}
@ViewBuilder
private var formSections: some View {
switch activeMode {
case .manual:
ManualFormSections(
name: $name,
description: $description,
color: $color,
category: $category,
visibility: $visibility,
archived: isCreate ? nil : $archived
)
case .aiText:
AITextFormSections(prompt: $aiPrompt)
AISharedSections(count: $aiCount, language: $aiLanguage, url: $aiUrl)
case .aiMedia:
AIMediaFormSections(
files: $aiMediaFiles,
photoItems: $aiPhotoItems,
showPDFImporter: $showPDFImporter
)
AISharedSections(count: $aiCount, language: $aiLanguage, url: $aiUrl)
case .csv:
CSVImportFormSections(
rows: $csvRows,
deckName: $csvDeckName,
showImporter: $showCSVImporter
)
}
}
@ViewBuilder
private var errorSection: some View {
if let errorMessage {
Section {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(CardsTheme.error)
}
}
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") {
generationTask?.cancel()
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(confirmLabel) {
startSubmit()
}
.disabled(!canSubmit || isSubmitting)
}
}
// MARK: - Computed state
private var isCreate: Bool {
if case .create = mode { return true }
return false
}
@ViewBuilder
private func colorSwatch(_ hex: String) -> some View {
let isSelected = color == hex
private var activeMode: CreateMode {
isCreate ? createMode : .manual
}
private var navTitle: String {
switch activeMode {
case .manual: isCreate ? "Neues Deck" : "Deck bearbeiten"
case .aiText: "Mit KI generieren"
case .aiMedia: "Aus Bild generieren"
case .csv: "Aus CSV importieren"
}
}
private var confirmLabel: String {
switch activeMode {
case .manual: isCreate ? "Erstellen" : "Speichern"
case .aiText, .aiMedia: "Generieren"
case .csv: csvRows.isEmpty ? "Importieren" : "\(csvRows.count) Karten importieren"
}
}
private var canSubmit: Bool {
switch activeMode {
case .manual:
!name.trimmingCharacters(in: .whitespaces).isEmpty
case .aiText:
aiPrompt.trimmingCharacters(in: .whitespaces).count >= 3
case .aiMedia:
!aiMediaFiles.isEmpty || DeckEditorHelpers.isValidURL(aiUrl)
case .csv:
!csvRows.isEmpty && !csvDeckName.trimmingCharacters(in: .whitespaces).isEmpty
}
}
private var overlayMessage: String {
switch activeMode {
case .csv:
csvImportProgress > 0
? "Karten werden importiert (\(csvImportProgress) / \(csvRows.count)) …"
: "Import wird vorbereitet …"
default:
"Karten werden generiert …"
}
}
// MARK: - Photo / PDF ingest
private func ingestPhotoItems(_ items: [PhotosPickerItem]) async {
for item in items {
if aiMediaFiles.count >= DeckEditorPresets.maxMediaFiles { break }
do {
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
guard data.count <= DeckEditorPresets.maxImageBytes else {
errorMessage = "Bild ist größer als 10 MB und wurde übersprungen."
continue
}
let mime = DeckEditorHelpers.inferImageMimeType(from: data)
let ext = DeckEditorHelpers.imageExtension(forMime: mime)
let filename = "image-\(UUID().uuidString.prefix(8)).\(ext)"
aiMediaFiles.append(GenerationMediaFile(
data: data,
filename: filename,
mimeType: mime
))
} catch {
errorMessage = "Foto konnte nicht geladen werden: \(error.localizedDescription)"
}
}
aiPhotoItems = []
}
private func handleCSVImport(_ result: Result<[URL], Error>) {
switch result {
case let .success(urls):
guard let url = urls.first else { return }
let didStart = url.startAccessingSecurityScopedResource()
defer { if didStart { url.stopAccessingSecurityScopedResource() } }
do {
let text = try String(contentsOf: url, encoding: .utf8)
let rows = try CSVParser.parse(text)
csvRows = rows
if csvDeckName.trimmingCharacters(in: .whitespaces).isEmpty {
csvDeckName = url.deletingPathExtension().lastPathComponent
}
} catch {
errorMessage = "CSV-Import fehlgeschlagen: \(error.localizedDescription)"
}
case let .failure(error):
errorMessage = "Datei-Auswahl fehlgeschlagen: \(error.localizedDescription)"
}
}
private func handlePDFImport(_ result: Result<[URL], Error>) {
switch result {
case let .success(urls):
for url in urls {
if aiMediaFiles.count >= DeckEditorPresets.maxMediaFiles { break }
let didStart = url.startAccessingSecurityScopedResource()
defer { if didStart { url.stopAccessingSecurityScopedResource() } }
do {
let data = try Data(contentsOf: url)
guard data.count <= DeckEditorPresets.maxPDFBytes else {
errorMessage = "\(url.lastPathComponent) ist größer als 30 MB."
continue
}
aiMediaFiles.append(GenerationMediaFile(
data: data,
filename: url.lastPathComponent,
mimeType: "application/pdf"
))
} catch {
errorMessage = "PDF konnte nicht gelesen werden: \(error.localizedDescription)"
}
}
case let .failure(error):
errorMessage = "PDF-Auswahl fehlgeschlagen: \(error.localizedDescription)"
}
}
// MARK: - Submit
private func startSubmit() {
errorMessage = nil
isSubmitting = true
generationTask = Task {
await submit()
isSubmitting = false
generationTask = nil
}
}
private func submit() async {
let api = CardsAPI(auth: auth)
do {
switch (mode, activeMode) {
case (.create, .manual):
let deck = try await api.createDeck(manualCreateBody)
onSaved(deck)
dismiss()
case let (.edit(deckId), _):
let deck = try await api.updateDeck(id: deckId, body: manualUpdateBody)
onSaved(deck)
dismiss()
case (.create, .aiText):
let response = try await api.generateDeckFromText(aiTextBody)
try Task.checkCancellation()
onSaved(response.deck)
dismiss()
case (.create, .aiMedia):
let response = try await api.generateDeckFromMedia(
files: aiMediaFiles,
language: aiLanguage,
count: aiCount,
url: DeckEditorHelpers.nonEmpty(aiUrl)
)
try Task.checkCancellation()
onSaved(response.deck)
dismiss()
case (.create, .csv):
let deck = try await submitCSVImport(api: api)
onSaved(deck)
dismiss()
}
} catch is CancellationError {
// User-Abbruch kein Banner.
} catch let error as AuthError {
errorMessage = DeckEditorHelpers.mapAIError(error)
} catch {
errorMessage = error.localizedDescription
}
}
private var manualCreateBody: DeckCreateBody {
DeckCreateBody(
name: name.trimmingCharacters(in: .whitespaces),
description: DeckEditorHelpers.nonEmpty(description),
color: color,
category: category,
visibility: visibility
)
}
private var manualUpdateBody: DeckUpdateBody {
DeckUpdateBody(
name: name.trimmingCharacters(in: .whitespaces),
description: DeckEditorHelpers.nonEmpty(description),
color: color,
category: category,
visibility: visibility,
archived: archived
)
}
private func submitCSVImport(api: CardsAPI) async throws -> Deck {
let deck = try await api.createDeck(DeckCreateBody(
name: csvDeckName.trimmingCharacters(in: .whitespaces),
description: "Aus CSV-Import (\(csvRows.count) Karten)",
color: color,
category: category,
visibility: visibility
))
csvImportProgress = 0
for (index, row) in csvRows.enumerated() {
try Task.checkCancellation()
let fields: [String: String]
switch row.type {
case .basic, .basicReverse:
fields = CardFieldsBuilder.basic(front: row.front, back: row.back)
case .cloze:
fields = CardFieldsBuilder.cloze(text: row.front)
case .typing:
fields = CardFieldsBuilder.typing(front: row.front, answer: row.back)
case .multipleChoice:
fields = CardFieldsBuilder.multipleChoice(front: row.front, answer: row.back)
case .imageOcclusion, .audioFront:
// Media-Types brauchen Uploads überspringe in CSV-Import.
csvImportProgress = index + 1
continue
}
_ = try await api.createCard(CardCreateBody(
deckId: deck.id,
type: row.type,
fields: fields,
mediaRefs: nil
))
csvImportProgress = index + 1
}
return deck
}
private var aiTextBody: DeckGenerateBody {
DeckGenerateBody(
prompt: aiPrompt.trimmingCharacters(in: .whitespaces),
language: aiLanguage,
count: aiCount,
url: DeckEditorHelpers.nonEmpty(aiUrl)
)
}
}
// swiftlint:enable type_body_length
// MARK: - Manual form
private struct ManualFormSections: View {
@Binding var name: String
@Binding var description: String
@Binding var color: String
@Binding var category: DeckCategory?
@Binding var visibility: DeckVisibility
/// `nil` im Create-Modus dann wird der Toggle nicht gezeigt.
var archived: Binding<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()
.fill(Color.swatchFromHex(hex))
.frame(width: 36, height: 36)
@ -127,51 +532,200 @@ struct DeckEditorView: View {
Circle()
.stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1)
)
.onTapGesture { color = hex }
.onTapGesture(perform: onTap)
}
}
private func submit() async {
isSubmitting = true
errorMessage = nil
defer { isSubmitting = false }
let api = CardsAPI(auth: auth)
// MARK: - AI text form
do {
switch mode {
case .create:
let body = DeckCreateBody(
name: name.trimmingCharacters(in: .whitespaces),
description: nonEmpty(description),
color: color,
category: category,
visibility: visibility
)
let deck = try await api.createDeck(body)
onSaved(deck)
dismiss()
case let .edit(deckId):
let body = DeckUpdateBody(
name: name.trimmingCharacters(in: .whitespaces),
description: nonEmpty(description),
color: color,
category: category,
visibility: visibility
)
let deck = try await api.updateDeck(id: deckId, body: body)
onSaved(deck)
dismiss()
private struct AITextFormSections: View {
@Binding var prompt: String
var body: some View {
Section {
TextField(
"z.B. Bodensee-Geographie, französische Verben",
text: $prompt,
axis: .vertical
)
.lineLimit(3 ... 6)
.textInputAutocapitalization(.sentences)
} header: {
Text("Thema")
} footer: {
Text("3500 Zeichen. Je präziser, desto besser die Karten.")
}
}
}
// MARK: - AI media form
private struct AIMediaFormSections: View {
@Binding var files: [GenerationMediaFile]
@Binding var photoItems: [PhotosPickerItem]
@Binding var showPDFImporter: Bool
var body: some View {
Section {
mediaPickers
ForEach(files) { file in
MediaFileRow(file: file) {
files.removeAll { $0.id == file.id }
}
}
} catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
} header: {
Text("Quellen")
} footer: {
Text("Max. \(DeckEditorPresets.maxMediaFiles) Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB.")
}
}
private func nonEmpty(_ s: String) -> String? {
let trimmed = s.trimmingCharacters(in: .whitespaces)
return trimmed.isEmpty ? nil : trimmed
@ViewBuilder
private var mediaPickers: some View {
let remaining = DeckEditorPresets.maxMediaFiles - files.count
PhotosPicker(
selection: $photoItems,
maxSelectionCount: max(remaining, 0),
matching: .images
) {
Label("Fotos hinzufügen", systemImage: "photo.on.rectangle.angled")
}
.disabled(remaining <= 0)
Button {
showPDFImporter = true
} label: {
Label("PDFs hinzufügen", systemImage: "doc.text")
}
.disabled(remaining <= 0)
}
}
private struct MediaFileRow: View {
let file: GenerationMediaFile
let onRemove: () -> Void
var body: some View {
HStack(spacing: 12) {
thumbnail
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text(file.filename)
.font(.subheadline)
.lineLimit(1)
Text(file.sizeLabel)
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
}
Spacer()
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(CardsTheme.mutedForeground)
}
.buttonStyle(.plain)
.accessibilityLabel("Entfernen")
}
}
@ViewBuilder
private var thumbnail: some View {
if file.isPDF {
ZStack {
CardsTheme.muted
Image(systemName: "doc.text.fill")
.foregroundStyle(CardsTheme.primary)
}
} else if let img = PlatformImage(data: file.data) {
#if canImport(UIKit)
Image(uiImage: img)
.resizable()
.scaledToFill()
#else
Image(nsImage: img)
.resizable()
.scaledToFill()
#endif
} else {
CardsTheme.muted
}
}
}
// MARK: - Shared AI controls
private struct AISharedSections: View {
@Binding var count: Int
@Binding var language: GenerationLanguage
@Binding var url: String
var body: some View {
Section("Anzahl Karten") {
Stepper(value: $count, in: 3 ... 40) {
Text("\(count) Karten")
}
}
Section("Sprache") {
Picker("Sprache", selection: $language) {
ForEach(GenerationLanguage.allCases, id: \.self) { lang in
Text(lang.label).tag(lang)
}
}
.pickerStyle(.segmented)
}
Section {
TextField("https://…", text: $url)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.keyboardType(.URL)
} header: {
Text("Zusätzliche URL (optional)")
} footer: {
Text("KI liest den Inhalt der Seite als zusätzliche Quelle.")
}
}
}
// MARK: - Generation overlay
private struct GenerationOverlay: View {
let message: String
let onCancel: () -> Void
var body: some View {
ZStack {
Color.black.opacity(0.55)
.ignoresSafeArea()
VStack(spacing: 16) {
ProgressView()
.controlSize(.large)
.tint(CardsTheme.primary)
Text(message)
.font(.headline)
.foregroundStyle(CardsTheme.foreground)
.multilineTextAlignment(.center)
Text("Das kann eine Weile dauern.")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
.multilineTextAlignment(.center)
Button("Abbrechen", action: onCancel)
.buttonStyle(.bordered)
.tint(CardsTheme.mutedForeground)
.padding(.top, 4)
}
.padding(24)
.frame(maxWidth: 320)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.transition(.opacity)
}
}
// MARK: - Color helper
extension Color {
static func swatchFromHex(_ hex: String) -> Color {
var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines)
@ -179,9 +733,9 @@ extension Color {
guard let rgb = UInt32(trimmed, radix: 16) else {
return CardsTheme.primary
}
let r = Double((rgb >> 16) & 0xFF) / 255.0
let g = Double((rgb >> 8) & 0xFF) / 255.0
let b = Double(rgb & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
let red = Double((rgb >> 16) & 0xFF) / 255.0
let green = Double((rgb >> 8) & 0xFF) / 255.0
let blue = Double(rgb & 0xFF) / 255.0
return Color(red: red, green: green, blue: blue)
}
}

View file

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

View file

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

View file

@ -20,6 +20,9 @@ struct ExploreView: View {
content
}
.navigationTitle("Entdecken")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.navigationDestination(for: MarketplaceRoute.self) { route in
switch route {
case .browse:
@ -65,10 +68,10 @@ struct ExploreView: View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
if !store.featured.isEmpty {
section(title: "Vorgestellt", items: store.featured)
section(title: "Vorgestellt", icon: "star.fill", items: store.featured)
}
if !store.trending.isEmpty {
section(title: "Im Trend", items: store.trending)
section(title: "Im Trend", icon: "flame.fill", items: store.trending)
}
NavigationLink(value: MarketplaceRoute.browse) {
@ -87,32 +90,43 @@ struct ExploreView: View {
.foregroundStyle(CardsTheme.foreground)
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
.padding(.horizontal, 20)
}
.padding(.vertical, 16)
.padding(.vertical, 12)
}
}
}
}
private func section(title: String, items: [PublicDeckEntry]) -> some View {
private func section(title: String, icon: String, items: [PublicDeckEntry]) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.title3.weight(.semibold))
.foregroundStyle(CardsTheme.foreground)
.padding(.horizontal, 16)
HStack(spacing: 6) {
Image(systemName: icon)
.foregroundStyle(CardsTheme.primary)
Text(title)
.font(.title3.weight(.semibold))
.foregroundStyle(CardsTheme.foreground)
Text("\(items.count)")
.font(.subheadline)
.foregroundStyle(CardsTheme.mutedForeground)
}
.padding(.horizontal, 20)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
HStack(alignment: .top, spacing: 16) {
ForEach(items) { item in
NavigationLink(value: MarketplaceRoute.publicDeck(slug: item.slug)) {
PublicDeckCard(entry: item)
.frame(width: 240)
}
.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)
}
/// 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 {
let entry: PublicDeckEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(entry.title)
.font(.headline)
.foregroundStyle(CardsTheme.foreground)
.lineLimit(2)
Spacer()
if entry.isFeatured {
Image(systemName: "star.fill")
.font(.caption)
.foregroundStyle(CardsTheme.warning)
}
}
if let description = entry.description, !description.isEmpty {
Text(description)
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
.lineLimit(2)
}
HStack(spacing: 12) {
DeckCoverTile(
title: entry.title,
description: entry.description,
category: parsedCategory,
seed: entry.slug,
colorAccentHex: nil,
isFeatured: entry.isFeatured
) {
footerContent
}
}
private var parsedCategory: DeckCategory? {
guard let category = entry.category else { return nil }
return DeckCategory(rawValue: category)
}
private var footerContent: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
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 {
Label("\(entry.priceCredits) Credits", systemImage: "creditcard")
Label("\(entry.priceCredits)", systemImage: "creditcard")
.font(.caption2.weight(.semibold))
.foregroundStyle(CardsTheme.primary)
}
Spacer()
}
.font(.caption2)
.foregroundStyle(CardsTheme.mutedForeground)
HStack(spacing: 4) {
Text(entry.owner.displayName)
.font(.caption2)
.foregroundStyle(CardsTheme.mutedForeground)
.lineLimit(1)
if entry.owner.verifiedMana {
Image(systemName: "checkmark.seal.fill")
.font(.caption2)
@ -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
trending = res.trending
} catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
Log.api.error("Explore failed: \(self.errorMessage ?? "", privacy: .public)")
let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
errorMessage = message
Log.api.error("Explore failed: \(message, privacy: .public)")
}
}

View file

@ -1,13 +1,18 @@
import ManaAuthUI
import ManaCore
import SwiftData
import SwiftUI
// swiftlint:disable type_body_length
/// Detail-View für ein Public-Deck. Subscribe-Button löst Auto-Fork
/// serverseitig aus und navigiert anschließend zur eigenen Deck-Detail.
/// Toolbar-Menu (") hostet Report + Block-Author (App-Review-Pflicht).
struct PublicDeckView: View {
let slug: String
@Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@Environment(\.modelContext) private var context
@State private var detail: PublicDeckDetail?
@State private var isLoading = false
@ -15,6 +20,11 @@ struct PublicDeckView: View {
@State private var errorMessage: String?
@State private var subscribed: SubscribeResponse?
// Moderation-State
@State private var showReportSheet = false
@State private var showBlockConfirm = false
@State private var moderationToast: String?
var body: some View {
ZStack {
CardsTheme.background.ignoresSafeArea()
@ -24,9 +34,69 @@ struct PublicDeckView: View {
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
if detail != nil {
ToolbarItem(placement: .topBarTrailing) {
moderationMenu
}
}
}
.task(id: slug) {
await load()
}
.sheet(isPresented: $showReportSheet) {
NavigationStack {
ReportDeckSheet(slug: slug) { message in
moderationToast = message
}
}
}
.confirmationDialog(
"Author blockieren?",
isPresented: $showBlockConfirm,
titleVisibility: .visible,
presenting: detail?.owner
) { owner in
Button("\(owner.displayName) blockieren", role: .destructive) {
Task { await blockAuthor(slug: owner.slug, name: owner.displayName) }
}
Button("Abbrechen", role: .cancel) {}
} message: { _ in
Text("Decks dieses Authors erscheinen für dich nicht mehr im Marketplace.")
}
.overlay(alignment: .top) {
if let toast = moderationToast {
ToastBanner(text: toast)
.padding(.top, 8)
.task {
try? await Task.sleep(for: .seconds(3))
moderationToast = nil
}
}
}
}
private var moderationMenu: some View {
Menu {
Button {
authGate.require(reason: "marketplace-report") {
showReportSheet = true
}
} label: {
Label("Deck melden …", systemImage: "flag")
}
if let owner = detail?.owner {
Button(role: .destructive) {
authGate.require(reason: "marketplace-block") {
showBlockConfirm = true
}
} label: {
Label("\(owner.displayName) blockieren", systemImage: "hand.raised")
}
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
@ViewBuilder
@ -122,7 +192,6 @@ struct PublicDeckView: View {
.padding(.horizontal, 16)
}
@ViewBuilder
private func subscribeSection(detail: PublicDeckDetail) -> some View {
VStack(spacing: 12) {
if let subscribed {
@ -147,7 +216,9 @@ struct PublicDeckView: View {
.foregroundStyle(CardsTheme.mutedForeground)
} else {
Button {
Task { await subscribe(detail: detail) }
authGate.require(reason: "marketplace-subscribe") {
Task { await subscribe(detail: detail) }
}
} label: {
HStack {
if isSubscribing {
@ -156,8 +227,8 @@ struct PublicDeckView: View {
.tint(CardsTheme.primaryForeground)
}
Text(detail.deck.priceCredits > 0
? "Abonnieren (\(detail.deck.priceCredits) Credits)"
: "Abonnieren")
? "Abonnieren (\(detail.deck.priceCredits) Credits)"
: "Abonnieren")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
@ -183,7 +254,17 @@ struct PublicDeckView: View {
}
}
private func subscribe(detail: PublicDeckDetail) async {
private func blockAuthor(slug: String, name: String) async {
let api = CardsAPI(auth: auth)
do {
try await api.blockAuthor(slug: slug)
moderationToast = "\(name) blockiert."
} catch {
moderationToast = "Blockieren fehlgeschlagen: \(error.localizedDescription)"
}
}
private func subscribe(detail _: PublicDeckDetail) async {
isSubscribing = true
errorMessage = nil
defer { isSubscribing = false }
@ -199,3 +280,5 @@ struct PublicDeckView: View {
}
}
}
// swiftlint:enable type_body_length

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 {
guard let cache = mediaCache else { failed = true; return }
guard let cache = mediaCache else { failed = true
return
}
do {
let data = try await cache.data(for: mediaId)
#if canImport(UIKit)
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try AVAudioSession.sharedInstance().setActive(true)
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try AVAudioSession.sharedInstance().setActive(true)
#endif
player = try AVAudioPlayer(data: data)
player?.prepareToPlay()

View file

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

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 {
Label("Benachrichtigungen sind in den iOS-Einstellungen blockiert.",
systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundStyle(CardsTheme.warning)
Label(
"Benachrichtigungen sind in den iOS-Einstellungen blockiert.",
systemImage: "exclamationmark.circle"
)
.font(.caption)
.foregroundStyle(CardsTheme.warning)
}
}
Section("Marketplace") {
NavigationLink {
BlockedAuthorsView()
} label: {
Label("Blockierte Authors", systemImage: "hand.raised")
}
}

View file

@ -28,15 +28,16 @@ struct CardRenderer: View {
imageOcclusionView
case .audioFront:
audioFrontView
case .typing, .multipleChoice:
placeholderView
case .multipleChoice:
MultipleChoiceCardView(card: card, isFlipped: isFlipped)
case .typing:
TypingCardView(card: card, isFlipped: isFlipped)
}
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private func basicView(front frontKey: String, back backKey: String) -> some View {
VStack(spacing: 16) {
text(card.fields[frontKey] ?? "")
@ -129,7 +130,6 @@ struct CardRenderer: View {
}
}
@ViewBuilder
private var placeholderView: some View {
VStack(spacing: 8) {
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
#if canImport(UIKit)
import UIKit
import UIKit
#endif
/// Vier Rating-Buttons unten am Bildschirm. Tap onRate(rating)
/// plus Haptic-Feedback.
/// Vier Rating-Buttons mit emphasis auf "Good" (full-width primary).
/// Web-Vorbild: `cards/apps/web/src/routes/study/[deckId]/+page.svelte`
/// `.grade.again/.hard/.good/.easy`-Klassen.
struct RatingBar: View {
let onRate: (Rating) -> Void
@ -16,17 +17,24 @@ struct RatingBar: View {
triggerHaptic(for: rating)
onRate(rating)
} label: {
VStack(spacing: 2) {
HStack(spacing: 6) {
Text(rating.label)
.font(.subheadline.weight(.semibold))
Text(rating.shortcut)
.font(.caption2)
.foregroundStyle(.secondary)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(kbdBackground(for: rating), in: RoundedRectangle(cornerRadius: 4))
.foregroundStyle(kbdForeground(for: rating))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(background(for: rating), in: RoundedRectangle(cornerRadius: 10))
.background(background(for: rating), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.foregroundStyle(foreground(for: rating))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(borderColor(for: rating), lineWidth: rating == .good ? 0 : 1)
)
}
.buttonStyle(.plain)
}
@ -34,12 +42,14 @@ struct RatingBar: View {
.padding(.horizontal, 16)
}
/// `good` ist die Hero-Action (primary full background) analog
/// zum Web-Default-Klick. Andere bekommen subtle tinted borders.
private func background(for rating: Rating) -> Color {
switch rating {
case .again: CardsTheme.error.opacity(0.12)
case .hard: CardsTheme.warning.opacity(0.12)
case .good: CardsTheme.primary.opacity(0.12)
case .easy: CardsTheme.success.opacity(0.12)
case .again: CardsTheme.error.opacity(0.06)
case .hard: CardsTheme.warning.opacity(0.06)
case .good: CardsTheme.primary
case .easy: CardsTheme.success.opacity(0.06)
}
}
@ -47,17 +57,37 @@ struct RatingBar: View {
switch rating {
case .again: CardsTheme.error
case .hard: CardsTheme.warning
case .good: CardsTheme.primary
case .good: CardsTheme.primaryForeground
case .easy: CardsTheme.success
}
}
private func borderColor(for rating: Rating) -> Color {
switch rating {
case .again: CardsTheme.error.opacity(0.4)
case .hard: CardsTheme.warning.opacity(0.4)
case .good: .clear
case .easy: CardsTheme.success.opacity(0.4)
}
}
private func kbdBackground(for rating: Rating) -> Color {
rating == .good
? CardsTheme.primaryForeground.opacity(0.18)
: CardsTheme.muted
}
private func kbdForeground(for rating: Rating) -> Color {
rating == .good
? CardsTheme.primaryForeground.opacity(0.85)
: CardsTheme.mutedForeground
}
private func triggerHaptic(for rating: Rating) {
#if canImport(UIKit)
let generator = UIImpactFeedbackGenerator(
style: rating == .easy ? .heavy : .medium
)
generator.impactOccurred()
let style: UIImpactFeedbackGenerator.FeedbackStyle =
rating == .easy ? .heavy : .medium
UIImpactFeedbackGenerator(style: style).impactOccurred()
#endif
}
}

View file

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

View file

@ -3,7 +3,7 @@ import SwiftData
import SwiftUI
#if canImport(UIKit)
import UIKit
import UIKit
#endif
/// Vollbild-Study-View. Wird per Navigation aus DeckListView geöffnet.
@ -73,26 +73,7 @@ struct StudySessionView: View {
session.flip()
}
keyboardShortcuts(session: session)
if session.isFlipped {
RatingBar { rating in
Task { await session.grade(rating) }
}
.transition(.move(edge: .bottom).combined(with: .opacity))
} else {
Button {
flipHaptic()
session.flip()
} label: {
Text("Antwort anzeigen")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.primaryForeground)
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
}
bottomBar(session: session)
}
}
.padding(.bottom, 20)
@ -100,22 +81,47 @@ struct StudySessionView: View {
.animation(.easeInOut(duration: 0.2), value: session.currentIndex)
}
/// Fixe Höhe, damit der Wechsel zwischen "Antwort anzeigen" und
/// `RatingBar` die Card oben nicht stauchen kann sonst proportioniert
/// `.aspectRatio(.fit)` die Card neu und das Layout springt.
private func bottomBar(session: StudySession) -> some View {
ZStack {
if session.isFlipped {
RatingBar { rating in
Task { await session.grade(rating) }
}
.transition(.opacity)
} else {
Button {
flipHaptic()
session.flip()
} label: {
Text("Antwort anzeigen")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.primaryForeground)
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
.transition(.opacity)
}
}
.frame(height: 52)
}
private func cardSurface(due: DueReview, isFlipped: Bool) -> some View {
RoundedRectangle(cornerRadius: 16)
.fill(CardsTheme.surface)
.overlay(
CardRenderer(
card: due.card,
subIndex: due.review.subIndex,
isFlipped: isFlipped
)
CardSurface(size: .hero, elevation: .raised) {
CardRenderer(
card: due.card,
subIndex: due.review.subIndex,
isFlipped: isFlipped
)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(CardsTheme.border, lineWidth: 1)
)
.padding(.horizontal, 16)
.padding(.top, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(.horizontal, 16)
.padding(.top, 12)
}
private func finishedView(session: StudySession) -> some View {
@ -160,7 +166,6 @@ struct StudySessionView: View {
/// Unsichtbare Buttons mit Keyboard-Shortcuts. Funktionieren auf
/// iPad (Magic Keyboard) und macOS. Space = flip, 1-4 = Rating.
@ViewBuilder
private func keyboardShortcuts(session: StudySession) -> some View {
Group {
Button("Flip") {
@ -185,7 +190,7 @@ struct StudySessionView: View {
private func flipHaptic() {
#if canImport(UIKit)
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
#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",
"platform" : "ios",
"size" : "1024x1024"
},
{
"filename" : "AppIcon-1024.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
}
],
"info" : {

View file

@ -1,5 +1,261 @@
{
"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"
}
}

View file

@ -1,14 +1,14 @@
import XCTest
final class CardsNativeUITests: XCTestCase {
func testAppLaunches() throws {
func testAppLaunches() {
let app = XCUIApplication()
app.launch()
// App ist gestartet, sobald entweder das LoginView "Cards"
// App ist gestartet, sobald entweder das LoginView "Cardecky"
// oder das DeckListView mit "Decks" sichtbar ist. Welcher
// von beiden hängt davon ab, ob der Simulator-Keychain noch
// eine Session hält.
let loginTitle = app.staticTexts["Cards"]
let loginTitle = app.staticTexts["Cardecky"]
let decksTitle = app.staticTexts["Decks"]
let exploreTab = app.staticTexts["Entdecken"]
@ -21,6 +21,6 @@ final class CardsNativeUITests: XCTestCase {
}
usleep(100_000)
}
XCTAssertTrue(found, "Erwartete App-Surface (Cards | Decks | Entdecken) erschien nicht innerhalb 5 s")
XCTAssertTrue(found, "Erwartete App-Surface (Cardecky | Decks | Entdecken) erschien nicht innerhalb 5 s")
}
}

View file

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

View file

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

View file

@ -44,7 +44,7 @@ struct MaskRegionsTests {
func encodeRoundtrip() {
let original = [
MaskRegion(id: "m1", x: 0.1, y: 0.2, w: 0.3, h: 0.4, label: "test"),
MaskRegion(id: "m2", x: 0.5, y: 0.6, w: 0.2, h: 0.2, label: nil),
MaskRegion(id: "m2", x: 0.5, y: 0.6, w: 0.2, h: 0.2, label: nil)
]
let encoded = MaskRegions.encode(original)
let parsed = MaskRegions.parse(encoded)

View file

@ -4,9 +4,16 @@ import Testing
@Suite("Mutation Body Encoding")
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)
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")

View file

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

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
**App-Name:** `Cards`
**App-Name:** `Cardecky`
**Subtitle (max 30 Zeichen):** `Karteikarten — mana e.V.`
## Keywords (max 100 Zeichen, comma-separated)
@ -21,7 +21,7 @@ Karteikarten,Spaced Repetition,Lernen,Vokabeln,Anki,Flashcards,FSRS,mana,Verein,
## Description — DE (max 4000 Zeichen)
```
Cards ist die Karteikarten-App des Vereins mana e.V. — Spaced
Cardecky ist die Karteikarten-App des Vereins mana e.V. — Spaced
Repetition wie es sein soll: ohne Werbung, ohne Tracking, ohne
Abo-Zwang.
@ -33,13 +33,13 @@ KARTEN, WIE DU SIE BRAUCHST
- Audio-Karten für Sprachen und Aussprache
MODERNER LERN-ALGORITHMUS
Cards nutzt FSRS (Free Spaced Repetition Scheduler), den genauesten
Cardecky nutzt FSRS (Free Spaced Repetition Scheduler), den genauesten
offenen Algorithmus für Karteikarten. Karten kommen wieder, wenn du
sie wirklich brauchst — nicht nach willkürlichen Intervallen.
OFFLINE LERNEN
Karten cachen lokal auf deinem Gerät. Im Flugzeug, in der U-Bahn,
ohne WLAN — Cards funktioniert. Deine Bewertungen werden gequeued
ohne WLAN — Cardecky funktioniert. Deine Bewertungen werden gequeued
und beim nächsten Online-Moment automatisch hochgeladen.
WIDGETS UND NOTIFICATIONS
@ -53,7 +53,7 @@ Sprachen, Wissenschaft. Abonnieren = Karten landen direkt in deiner
Bibliothek, du kannst sie nach Belieben anpassen.
VEREIN, NICHT FIRMA
Cards wird vom Verein mana e.V. (Schweiz, in Gründung) betrieben.
Cardecky wird vom Verein mana e.V. (Schweiz, in Gründung) betrieben.
Kein Tracking, kein Werbe-Anbieter, kein Crash-Reporter. Wenn du dem
Verein etwas zurückgeben willst: mana-ev.ch/spende.
@ -71,7 +71,7 @@ selbst.
## Description — EN (max 4000 Zeichen)
```
Cards is the flashcard app from mana e.V. — spaced repetition the
Cardecky is the flashcard app from mana e.V. — spaced repetition the
way it should be: no ads, no tracking, no subscription pressure.
THE CARDS YOU NEED
@ -80,12 +80,12 @@ THE CARDS YOU NEED
- Audio cards for languages and pronunciation
MODERN SPACED-REPETITION
Cards uses FSRS (Free Spaced Repetition Scheduler), the most accurate
open algorithm available. Cards come back when you actually need them.
Cardecky uses FSRS (Free Spaced Repetition Scheduler), the most accurate
open algorithm available. Your cards come back when you actually need them.
OFFLINE-FIRST
Decks cache locally on your device. On a plane, in the subway, off
the grid — Cards keeps working. Your reviews queue up and sync on
the grid — Cardecky keeps working. Your reviews queue up and sync on
the next online moment.
WIDGETS AND REMINDERS
@ -97,7 +97,7 @@ Browse public decks from the Cardecky community: geography, languages,
science. Subscribe = decks land in your library, ready to be edited.
ASSOCIATION, NOT CORPORATION
Cards is operated by mana e.V. (Switzerland, formation in progress).
Cardecky is operated by mana e.V. (Switzerland, formation in progress).
No tracking, no ad networks, no crash reporters. Want to give back?
mana-ev.ch/donate.

View file

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

View file

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