Compare commits

...

31 commits
v0.8.1 ... main

Author SHA1 Message Date
Till JS
a59863487e chore: build/-Verzeichnis aus Repo entfernen + gitignore
xcarchive landete versehentlich im letzten Commit. build/ ist
jetzt in .gitignore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:44:55 +02:00
Till JS
73d95aeecc feat(widget): #Preview-Macros fuer Small/Medium/Large
Sample-Daten mit 4 Decks und totalDueCount=23. Small-Preview zeigt
zusätzlich den Empty-State als zweiten Timeline-Entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:44:06 +02:00
Till JS
ba856bbea8 feat(version): v1.0.0 Build 1 — Wordeck-Native frischer Bundle-Start
Apple sieht ev.mana.wordeck als neue App (vs. alter ev.mana.cardecky
Build 11). Daher Marketing-Version 1.0.0 + Build-Number 1 für alle
3 Targets (App, Widget-Extension, Share-Extension).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:33:35 +02:00
Till JS
2f7f3d8c0b feat(widget): widgetURL fuer Tap → Study-View
Tap auf das Widget oeffnet jetzt direkt die Study-Session
(wordeck://study), nicht nur die App-Root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:27:01 +02:00
Till JS
f6b7c05296 feat(widget): widgetURL für Wordeck-Deep-Link
Tap auf das Widget öffnet wordeck://study (statt nur die App
home view). Vorbereitet fürs neue Bundle ev.mana.wordeck —
deep-link-scheme ist im app-manifest auf wordeck:// gestellt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:25:40 +02:00
Till JS
542082772a refactor(big-bang): cards-native → wordeck-native
Code + Identity-Rename zur Vorbereitung auf Apple-Dev-Portal-Aktion
(Bundle ev.mana.wordeck, App-Group group.ev.mana.wordeck, AASA
applinks:wordeck.com). Build bleibt funktional, aber gegen die
neue text-only-API können image-occlusion-Creates 422 zurückgeben —
das wird mit der Wordeck-Native v1.0-Welle (parallele Apple-Aktion)
sauber gemacht.

Umbenennung:
- 41 Files: cardecky/Cardecky → wordeck/Wordeck (Display, Strings,
  Kommentare)
- 57 Files: CardsNative → WordeckNative, CardsAPI → WordeckAPI,
  CardsTheme → WordeckTheme, CardsBrand → WordeckBrand, CardsWidget →
  WordeckWidget, CardsDueWidget → WordeckDueWidget
- Bundle-ID ev.mana.cardecky → ev.mana.wordeck (project.yml,
  Info.plist, entitlements, Keychain-Service, App-Group)
- AASA applinks:cardecky.mana.how → applinks:wordeck.com
- API-Base cardecky-api.mana.how → api.wordeck.com
- 10 Files renamed (App-Entry, API-Extensions, Theme, Widget,
  Entitlements, Tests)
- xcodeproj regenerated via xcodegen → WordeckNative.xcodeproj
- MaskRegionsTests.swift gelöscht (image-occlusion entfällt mit
  Wordeck text-only)

Forgejo-Repo git.mana.how/till/cards-native → wordeck-native umbenannt
(Auto-Redirect aktiv). Lokales Verzeichnis Code/cards-native/ bleibt
vorerst — wird beim nächsten Apple-Setup mit Bundle-Test umbenannt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:10:42 +02:00
Till JS
4f154bfb39 refactor(log): Log.swift auf ManaAppLog (mana-swift-core v1.7.0) + appGroup in AppConfig
Audit V4. Spart Subsystem-String-Duplikation; AppGroup ist jetzt
single-source in AppConfig.manaAppConfig.appGroup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:38:36 +02:00
Till JS
57e472ff34 refactor(theme): CardsTheme forwarded auf ManaTheme.forest (v1.6.0)
`mana-swift-core` v1.6.0 liefert alle acht Web-Theme-Variants nativ.
CardsTheme bleibt als duenner Alias bestehen — alle ~290 Call-Sites
muessen nicht in einem Sprint umziehen, neue Call-Sites koennen
direkt `ManaTheme.forest.<token>` oder `@Environment(\.manaTheme)`
nutzen.

Spart ~100 LOC lokales HSL-Apparat. forest-Werte sind jetzt
single-source in `mana/packages/themes/src/variants/forest.css`.

43/43 Tests gruen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:12:26 +02:00
Till JS
2194da5b2c fix(auth): keychainAccessGroup explizit auf TeamID.BundleID
Symptom: User wurden nach App-Update / längerer App-Pause aus-
geloggt, obwohl Refresh-Token theoretisch noch gültig war. Ursache:
mit `keychainAccessGroup: nil` landet das Token im impliziten
default-bucket; bei TestFlight-Cert-Drift oder Provisioning-
Profile-Wechsel wurde es nach Update für die neue App-Instanz
unzugänglich.

Bestehende Tokens werden via ManaCore v1.5.1 KeychainStore-
Migration-Fallback automatisch in den expliziten Bucket gespiegelt
— kein erzwungener Logout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:20:49 +02:00
Till JS
c0b0c902bc devlog: 3 Tage geschrieben (v0.1.0 → v0.9.4)
Tag 1: β-0 bis β-7 Marathon (Skelett → App-Store-Vorbereitung).
Tag 2: Cardecky-Web-Look + alle 7 Card-Types + ManaAuthUI +
Guest-Mode + γ-1 bis γ-8 (AI/CSV-Import, Marketplace, PDF).
Tag 3: kleiner Style-Polish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:22:29 +02:00
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
107 changed files with 6968 additions and 1436 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
.DS_Store .DS_Store
.build/ .build/
build/
.swiftpm/ .swiftpm/
DerivedData/ DerivedData/
Package.resolved Package.resolved

View file

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

View file

@ -1,4 +1,4 @@
# CLAUDE.md — cards-native repo # CLAUDE.md — wordeck-native repo
Guidance für Claude Code in diesem Repository. Guidance für Claude Code in diesem Repository.
@ -10,27 +10,27 @@ Guidance für Claude Code in diesem Repository.
## Was dieses Repo ist ## Was dieses Repo ist
**Cards Native** — native SwiftUI-Universal-App (iOS / iPadOS / macOS) **Cards Native** — native SwiftUI-Universal-App (iOS / iPadOS / macOS)
für **Cardecky**, die Spaced-Repetition-Karten-App des Vereins für **Wordeck**, die Spaced-Repetition-Karten-App des Vereins
**mana e.V.** Web-Parität zu `cardecky.mana.how`, plus native iOS- **mana e.V.** Web-Parität zu `wordeck.com`, plus native iOS-
Affordances (Widgets, Notifications, Universal-Links, Pencil). Affordances (Widgets, Notifications, Universal-Links, Pencil).
``` ```
HTTPS/JWT ┌──────────────────┐ HTTPS/JWT ┌──────────────────┐
cards-api ◄───────────── │ cards-native │ SwiftUI cards-api ◄───────────── │ wordeck-native │ SwiftUI
cardecky-api.mana.how │ (this repo) │ SwiftData (Cache) api.wordeck.com │ (this repo) │ SwiftData (Cache)
│ ev.mana.cardecky │ WidgetKit (β-6) │ ev.mana.wordeck │ WidgetKit (β-6)
└──────────────────┘ └──────────────────┘
``` ```
## Status ## Status
**Phase β-0 — Setup (2026-05-12).** Repo-Skelett, ManaCore + ManaTokens **Phase β-0 — Setup (2026-05-12).** Repo-Skelett, ManaCore + ManaTokens
als Package-Dependency, Login + Cardecky-API-Reachability-Probe. als Package-Dependency, Login + Wordeck-API-Reachability-Probe.
Phasen β-1 bis β-7 in `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`. Phasen β-1 bis β-7 in `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`.
## Leitprinzip: Web-Parität ## Leitprinzip: Web-Parität
Die Web-App auf `cardecky.mana.how` ist Funktions-Referenz. Bei Die Web-App auf `wordeck.com` ist Funktions-Referenz. Bei
Konflikt zwischen Native und Web → **Web gewinnt**. Native ist Konflikt zwischen Native und Web → **Web gewinnt**. Native ist
Re-Implementation, kein neues Produkt. Re-Implementation, kein neues Produkt.
@ -53,12 +53,12 @@ Beschlossen. Nicht ohne explizite Diskussion antasten.
Auth-Implementierung ist verboten. Auth-Implementierung ist verboten.
4. **Pure SwiftUI.** Keine externen UI-Libraries. AppKit/UIKit nur 4. **Pure SwiftUI.** Keine externen UI-Libraries. AppKit/UIKit nur
als Bridge wenn zwingend (z.B. `PencilKit` für Image-Occlusion). als Bridge wenn zwingend (z.B. `PencilKit` für Image-Occlusion).
5. **Bundle-ID `ev.mana.cardecky`.** Reverse-Domain mana-ev.ch. 5. **Bundle-ID `ev.mana.wordeck`.** Reverse-Domain mana-ev.ch.
Universal-Link-Domain: `cardecky.mana.how`. Universal-Link-Domain: `wordeck.com`.
6. **Cards-Domain-Logik bleibt am Server.** SubIndex-Berechnung für 6. **Cards-Domain-Logik bleibt am Server.** SubIndex-Berechnung für
Cloze, Image-Occlusion-Mask-Validation, Content-Hash — alles Cloze, Image-Occlusion-Mask-Validation, Content-Hash — alles
Server. Native zeigt nur, was vom Server kommt. Server. Native zeigt nur, was vom Server kommt.
7. **`forest`-Theme.** Heute lokal in `CardsTheme.swift` nachgebaut 7. **`forest`-Theme.** Heute lokal in `WordeckTheme.swift` nachgebaut
(Werte gespiegelt aus `mana/packages/themes/src/variants/forest.css`). (Werte gespiegelt aus `mana/packages/themes/src/variants/forest.css`).
Migration auf ManaTokens-Theme-Switch ist Phase ε. Migration auf ManaTokens-Theme-Switch ist Phase ε.
8. **Web gewinnt bei Konflikt.** Eleganteres Native-Verhalten geht 8. **Web gewinnt bei Konflikt.** Eleganteres Native-Verhalten geht
@ -74,16 +74,16 @@ Beschlossen. Nicht ohne explizite Diskussion antasten.
sind **nicht** im Git sind **nicht** im Git
- **SwiftFormat** mit `.swiftformat` (4-space, 120-col, sorted imports) - **SwiftFormat** mit `.swiftformat` (4-space, 120-col, sorted imports)
- **SwiftLint** mit `.swiftlint.yml` - **SwiftLint** mit `.swiftlint.yml`
- **Logging:** App-Subsystem `ev.mana.cardecky` via - **Logging:** App-Subsystem `ev.mana.wordeck` via
`Sources/Core/Telemetry/Log.swift`. ManaCore loggt parallel unter `Sources/Core/Telemetry/Log.swift`. ManaCore loggt parallel unter
`ev.mana.core` `ev.mana.core`
- **Persistenz:** SwiftData für Deck/Card-Cache (ab β-1), JWT im - **Persistenz:** SwiftData für Deck/Card-Cache (ab β-1), JWT im
Keychain (über ManaCore) Keychain (über ManaCore)
- **Lokalisierung:** DE primary, EN fallback via `Localizable.xcstrings` - **Lokalisierung:** DE primary, EN fallback via `Localizable.xcstrings`
## Cardecky-API-Wire-Format ## Wordeck-API-Wire-Format
Wire-Format gegen `https://cardecky-api.mana.how/api/v1/*`. Quelle der Wire-Format gegen `https://api.wordeck.com/api/v1/*`. Quelle der
Wahrheit: `../cards/apps/api/src/routes/*.ts`. Bei neuem DTO Wahrheit: `../cards/apps/api/src/routes/*.ts`. Bei neuem DTO
verifizieren: verifizieren:
@ -95,14 +95,14 @@ verifizieren:
## Repo-Layout ## Repo-Layout
``` ```
cards-native/ wordeck-native/
├── project.yml XcodeGen-Manifest (SOT) ├── project.yml XcodeGen-Manifest (SOT)
├── PLAN.md Phase-Tracking (gekürzt aus Greenfield-Plan) ├── PLAN.md Phase-Tracking (gekürzt aus Greenfield-Plan)
├── CLAUDE.md dieses File ├── CLAUDE.md dieses File
├── README.md ├── README.md
├── .swiftformat, .swiftlint.yml ├── .swiftformat, .swiftlint.yml
├── Sources/ ├── Sources/
│ ├── App/ CardsNativeApp (@main), RootView │ ├── App/ WordeckNativeApp (@main), RootView
│ ├── Features/ │ ├── Features/
│ │ ├── Account/ LoginView, AccountView (ab β-1) │ │ ├── Account/ LoginView, AccountView (ab β-1)
│ │ ├── Decks/ DashboardView (Placeholder), DeckList (β-1) │ │ ├── Decks/ DashboardView (Placeholder), DeckList (β-1)
@ -113,19 +113,19 @@ cards-native/
│ │ └── Imports/ (β-3) │ │ └── Imports/ (β-3)
│ ├── Core/ │ ├── Core/
│ │ ├── Auth/ AppConfig (ManaAppConfig-Provider) │ │ ├── Auth/ AppConfig (ManaAppConfig-Provider)
│ │ ├── API/ CardsAPI (AuthenticatedTransport-Wrapper) │ │ ├── API/ WordeckAPI (AuthenticatedTransport-Wrapper)
│ │ ├── Domain/ (Card-Type-Enums, Rating-Enum — ab β-2) │ │ ├── Domain/ (Card-Type-Enums, Rating-Enum — ab β-2)
│ │ ├── Storage/ (SwiftData-Models — ab β-1) │ │ ├── Storage/ (SwiftData-Models — ab β-1)
│ │ ├── Sync/ (ReviewQueue, MediaCache — ab β-2/β-4) │ │ ├── Sync/ (ReviewQueue, MediaCache — ab β-2/β-4)
│ │ ├── Telemetry/ OSLog (Subsystem ev.mana.cardecky) │ │ ├── Telemetry/ OSLog (Subsystem ev.mana.wordeck)
│ │ └── Theme/ CardsTheme (forest-Werte) │ │ └── Theme/ WordeckTheme (forest-Werte)
│ ├── Widgets/ (WidgetKit-Extension — ab β-6) │ ├── Widgets/ (WidgetKit-Extension — ab β-6)
│ ├── ShareExtension/ (Save-as-Card — ab β-6) │ ├── ShareExtension/ (Save-as-Card — ab β-6)
│ └── Resources/ │ └── Resources/
│ ├── Assets.xcassets │ ├── Assets.xcassets
│ ├── Localizable.xcstrings │ ├── Localizable.xcstrings
│ ├── Info.plist (generiert, gitignored) │ ├── Info.plist (generiert, gitignored)
│ └── CardsNative.entitlements (generiert, gitignored) │ └── WordeckNative.entitlements (generiert, gitignored)
├── Tests/ ├── Tests/
│ ├── UnitTests/ │ ├── UnitTests/
│ └── UITests/ │ └── UITests/
@ -156,7 +156,7 @@ cards-native/
**Workflow:** **Workflow:**
```bash ```bash
xcodegen generate xcodegen generate
open CardsNative.xcodeproj open WordeckNative.xcodeproj
``` ```
**Vor jedem Commit:** **Vor jedem Commit:**

91
PLAN.md
View file

@ -1,13 +1,20 @@
# Plan — cards-native (SwiftUI Universal) # Plan — wordeck-native (SwiftUI Universal)
**Stand: 2026-05-13 — Phasen β-0 bis β-7 abgeschlossen.** **Stand: 2026-05-13 — TestFlight Build 11 (v0.9.4).** Alle Phasen
Feature-komplett für TestFlight. Alle 7 Card-Types + Marketplace β-0 bis β-7 + Polish-Iterationen. 43 Unit-Tests + 1 UI-Test grün.
+ Keyboard/Daily-Reminder/Widget + Siri-Shortcut + Share-Extension
+ App-Icon-Platzhalter + Release-Checklist. 35 Unit-Tests + 1 UI-Test **Wordeck-Web-Look übernommen** (v0.9.0 ff.): Fan-Stack-Tiles
grün, alle drei Targets (Haupt-App + Widget + Share) bauen. (5:7 Aspect, 3 rotierte Background-Layer), CardSurface in
md/lg/hero, RatingBar mit Good-Emphasis. Tap auf Tile = Study-
Mode, Pencil-Icon unten rechts = Deck-Detail. Identische Tile-
Optik in Decks- und Entdecken-Tab.
**App-Store-Connect:** Wordeck, App-ID 6769019526, Bundle
`ev.mana.wordeck`, Team `QP3GLU8PH3`. AASA + /privacy + /help
live unter `wordeck.com`.
Pflicht-Check für β-2: Endurance-Test auf realem Gerät (200+ Karten Pflicht-Check für β-2: Endurance-Test auf realem Gerät (200+ Karten
mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. mit Flugmodus zwischendurch) steht aus.
> **SOT:** `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`. > **SOT:** `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`.
> Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc > Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc
@ -16,18 +23,52 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till.
## Aktueller Stand ## Aktueller Stand
✅ **β-0 — Setup (2026-05-12, Tag `v0.1.0`)** ✅ **β-0 — Setup (2026-05-12, Tag `v0.1.0`)**
- Repo-Skelett unter `git.mana.how/till/cards-native` - Repo-Skelett unter `git.mana.how/till/wordeck-native`
- `project.yml` mit Bundle-ID `ev.mana.cardecky`, ManaSwiftCore via - `project.yml` mit Bundle-ID `ev.mana.wordeck`, ManaSwiftCore via
`path: ../mana-swift-core` `path: ../mana-swift-core`
- `AppConfig` als `ManaAppConfig`-Provider: - `AppConfig` als `ManaAppConfig`-Provider:
- Auth: `https://auth.mana.how` - Auth: `https://auth.mana.how`
- API: `https://cardecky-api.mana.how` - API: `https://api.wordeck.com`
- Keychain-Service: `ev.mana.cardecky` - Keychain-Service: `ev.mana.wordeck`
- `CardsTheme.swift` mit forest-Werten (lokal nachgebaut aus - `WordeckTheme.swift` mit forest-Werten (lokal nachgebaut aus
`mana/packages/themes/src/variants/forest.css`) `mana/packages/themes/src/variants/forest.css`)
- `LoginView` (Email/PW gegen mana-auth) - `LoginView` (Email/PW gegen mana-auth)
- 3 Unit-Tests (AppConfig) - 3 Unit-Tests (AppConfig)
✅ **Post-β-7-Polish (2026-05-13, Tags v0.8.1 → v0.9.4)**
Live-Fixes nach Apple-Submission und Real-Device-Tests:
- `v0.8.1` Wordeck-Rebrand (Bundle `ev.mana.cards``ev.mana.wordeck`,
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 → Wordeck, Build 2
- `v0.8.6` Wordeck-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 + WordeckAPI.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` Wordeck-Web-Design: Fan-Stack-Tiles, CardSurface in
3 Sizes, RatingBar mit Good-Emphasis, horizontale Scroll-Sections
- `v0.9.1` Multiple-Choice-Karten gerendert (Distractors via Server,
Tap-Selektion, Reveal mit Korrekt/Falsch-Highlight)
- `v0.9.2` Typing-Karten gerendert (Levenshtein-Match 1:1 aus
cards-domain portiert: correct/close/wrong, Aliases-Support,
Diakritika-Normalisierung)
- `v0.9.3` DEBUG-Auto-Login analog memoro-native (`ensureSignedIn()`
in #if DEBUG) — auch in manaspur-native nachgezogen
- `v0.9.4` Tile-Tap = Study-Mode direkt, Pencil-Edit-Icon unten
rechts → DeckDetail, ExploreView mit gleichem Tile-Layout
(5:7 Aspect, Kategorie-Icon oben rechts in primary)
**Schema-Fix:** cards-Repo Commit `4d905bb` (0002_decks_archived_at)
gleicht Schema-Drift in der Production-DB aus.
✅ **β-7 — App-Store-Vorbereitung (2026-05-13, Tag `v0.8.0`)** ✅ **β-7 — App-Store-Vorbereitung (2026-05-13, Tag `v0.8.0`)**
- App-Icon-Platzhalter: `scripts/make-appicon.swift` generiert 1024×1024 - App-Icon-Platzhalter: `scripts/make-appicon.swift` generiert 1024×1024
PNG aus CoreGraphics (forest-green + "C"-Letter). Asset-Catalog auf PNG aus CoreGraphics (forest-green + "C"-Letter). Asset-Catalog auf
@ -63,17 +104,17 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till.
- `WidgetSnapshot` Codable mit `topDecks` (Top-3 nach dueCount) - `WidgetSnapshot` Codable mit `topDecks` (Top-3 nach dueCount)
und `totalDueCount` und `totalDueCount`
- `WidgetSnapshotStore` schreibt in App-Group-Container - `WidgetSnapshotStore` schreibt in App-Group-Container
`group.ev.mana.cardecky` `group.ev.mana.wordeck`
- `DeckListStore.refresh` ruft `updateWidgetSnapshot()` und - `DeckListStore.refresh` ruft `updateWidgetSnapshot()` und
`WidgetCenter.shared.reloadAllTimelines()` nach jedem Pull `WidgetCenter.shared.reloadAllTimelines()` nach jedem Pull
- `CardsWidgetExtension`-Target (eigenes app-extension-Bundle): - `WordeckWidgetExtension`-Target (eigenes app-extension-Bundle):
`CardsWidgetBundle` + `CardsDueWidget` mit `StaticConfiguration`, `WordeckWidgetBundle` + `CardsDueWidget` mit `StaticConfiguration`,
Support für systemSmall, systemMedium, accessoryCircular, Support für systemSmall, systemMedium, accessoryCircular,
accessoryInline, accessoryRectangular accessoryInline, accessoryRectangular
- `DueProvider` als `TimelineProvider`: liest Snapshot, plant - `DueProvider` als `TimelineProvider`: liest Snapshot, plant
Refresh alle 30 min (plus instant-Refresh via Haupt-App) Refresh alle 30 min (plus instant-Refresh via Haupt-App)
- `DueWidgetView` mit Family-Switch, alle 5 Family-Layouts - `DueWidgetView` mit Family-Switch, alle 5 Family-Layouts
- `com.apple.security.application-groups: group.ev.mana.cardecky` - `com.apple.security.application-groups: group.ev.mana.wordeck`
im Haupt- und Widget-Entitlement im Haupt- und Widget-Entitlement
- `WidgetSnapshot.swift` in beiden Targets via XcodeGen-source-array - `WidgetSnapshot.swift` in beiden Targets via XcodeGen-source-array
(single-source-of-truth) (single-source-of-truth)
@ -87,7 +128,7 @@ Widget decken 90% des Native-Polish ab.
`PublicDeckDetail`, `ExploreResponse`, `BrowseResponse`, `PublicDeckDetail`, `ExploreResponse`, `BrowseResponse`,
`SubscribeResponse` Codable-DTOs mit snake_case `SubscribeResponse` Codable-DTOs mit snake_case
- `MarketplaceSort` Enum (recent/popular/trending) mit deutschen Labels - `MarketplaceSort` Enum (recent/popular/trending) mit deutschen Labels
- `CardsAPI`: explore(), browseMarketplace(query:sort:language:), - `WordeckAPI`: explore(), browseMarketplace(query:sort:language:),
publicDeck(slug:), subscribe(slug:), unsubscribe(slug:) publicDeck(slug:), subscribe(slug:), unsubscribe(slug:)
- `MarketplaceStore` @Observable mit Explore-State + Browse-State - `MarketplaceStore` @Observable mit Explore-State + Browse-State
- `ExploreView` mit Featured + Trending Carousels, Browse-Link - `ExploreView` mit Featured + Trending Carousels, Browse-Link
@ -97,13 +138,13 @@ Widget decken 90% des Native-Polish ab.
- `PublicDeckCard` + `BrowseRow` Komponenten mit forest-Theme - `PublicDeckCard` + `BrowseRow` Komponenten mit forest-Theme
- `RootView` → TabBar (Decks / Entdecken / Account) statt Single-View - `RootView` → TabBar (Decks / Entdecken / Account) statt Single-View
- Universal-Link-Handler in `RootView` (onOpenURL + onContinueUserActivity): - Universal-Link-Handler in `RootView` (onOpenURL + onContinueUserActivity):
`https://cardecky.mana.how/d/<slug>` und `cards://d/<slug>` → Explore-Tab `https://wordeck.com/d/<slug>` und `cards://d/<slug>` → Explore-Tab
öffnet `PublicDeckView` öffnet `PublicDeckView`
- `associated-domains: applinks:cardecky.mana.how` im entitlement - `associated-domains: applinks:wordeck.com` im entitlement
- 5 neue Marketplace-Decoding-Tests (35 Total grün) - 5 neue Marketplace-Decoding-Tests (35 Total grün)
**Wichtig:** Universal-Links funktionieren erst, wenn AASA-Endpoint **Wichtig:** Universal-Links funktionieren erst, wenn AASA-Endpoint
unter `cardecky.mana.how/.well-known/apple-app-site-association` unter `wordeck.com/.well-known/apple-app-site-association`
ausgeliefert wird — heute 404. Web-seitige Aufgabe. ausgeliefert wird — heute 404. Web-seitige Aufgabe.
✅ **β-4 — Media + Advanced Card-Types (2026-05-13, Tag `v0.5.0`)** ✅ **β-4 — Media + Advanced Card-Types (2026-05-13, Tag `v0.5.0`)**
@ -113,7 +154,7 @@ ausgeliefert wird — heute 404. Web-seitige Aufgabe.
nach ID lexikographisch) nach ID lexikographisch)
- `CardFieldsBuilder.imageOcclusion`, `.audioFront` mit korrekter - `CardFieldsBuilder.imageOcclusion`, `.audioFront` mit korrekter
`mask_regions`-Serialisierung als stringified JSON-Array `mask_regions`-Serialisierung als stringified JSON-Array
- `CardsAPI.uploadMedia(data, filename, mimeType)` mit Multipart - `WordeckAPI.uploadMedia(data, filename, mimeType)` mit Multipart
(25 MiB max), `.fetchMedia(id)` für streamed bytes (25 MiB max), `.fetchMedia(id)` für streamed bytes
- `MediaCache` actor mit LRU 200 MB (sortiert nach `contentModificationDate`) - `MediaCache` actor mit LRU 200 MB (sortiert nach `contentModificationDate`)
- `mediaCache`-Environment-Key, im App-Entrypoint instantiiert - `mediaCache`-Environment-Key, im App-Entrypoint instantiiert
@ -135,7 +176,7 @@ ausgeliefert wird — heute 404. Web-seitige Aufgabe.
Encodable-Structs (snake_case via `CodingKeys`, nil-Felder werden Encodable-Structs (snake_case via `CodingKeys`, nil-Felder werden
weggelassen) weggelassen)
- `CardFieldsBuilder` mit Type-spezifischen Pflicht-Feld-Konstruktoren - `CardFieldsBuilder` mit Type-spezifischen Pflicht-Feld-Konstruktoren
- `CardsAPI`: createDeck/updateDeck/deleteDeck + createCard/updateCard/deleteCard - `WordeckAPI`: createDeck/updateDeck/deleteDeck + createCard/updateCard/deleteCard
- `DeckEditorView` für Create + Edit in einer View (mode-switch), - `DeckEditorView` für Create + Edit in einer View (mode-switch),
Color-Picker mit 8-Preset-Palette aus forest-Theme, Category-Picker Color-Picker mit 8-Preset-Palette aus forest-Theme, Category-Picker
(11 Kategorien mit deutschen Labels), Visibility-Segmented-Control (11 Kategorien mit deutschen Labels), Visibility-Segmented-Control
@ -154,7 +195,7 @@ ausgeliefert wird — heute 404. Web-seitige Aufgabe.
- `Cloze`-Helpers (extractClusterIds, subIndexCount, clusterId, - `Cloze`-Helpers (extractClusterIds, subIndexCount, clusterId,
renderPrompt, renderAnswer, hint) — 1:1-Port aus renderPrompt, renderAnswer, hint) — 1:1-Port aus
`cards/packages/cards-domain/src/cloze.ts` `cards/packages/cards-domain/src/cloze.ts`
- `CardsAPI.dueReviews(deckId:)`, `CardsAPI.gradeReview(...)` mit - `WordeckAPI.dueReviews(deckId:)`, `WordeckAPI.gradeReview(...)` mit
ISO8601-Encoder ISO8601-Encoder
- `PendingGrade` SwiftData-Model + `GradeQueue` für Offline-Submit - `PendingGrade` SwiftData-Model + `GradeQueue` für Offline-Submit
(FIFO-Drain, originaler reviewedAt-Timestamp bleibt erhalten) (FIFO-Drain, originaler reviewedAt-Timestamp bleibt erhalten)
@ -172,7 +213,7 @@ ausgeliefert wird — heute 404. Web-seitige Aufgabe.
- `Deck`-Codable-DTO mit snake_case-CodingKeys, plus - `Deck`-Codable-DTO mit snake_case-CodingKeys, plus
`DeckCategory`, `DeckVisibility`, `FsrsSettings` `DeckCategory`, `DeckVisibility`, `FsrsSettings`
- ISO8601-Date-Decoder mit Fractional-Seconds-Toleranz - ISO8601-Date-Decoder mit Fractional-Seconds-Toleranz
- `CardsAPI.listDecks()`, `cardCount(deckId:)`, `dueCount(deckId:)` - `WordeckAPI.listDecks()`, `cardCount(deckId:)`, `dueCount(deckId:)`
- `CachedDeck` als SwiftData-Model mit `lastFetchedAt` (Offline-Read) - `CachedDeck` als SwiftData-Model mit `lastFetchedAt` (Offline-Read)
- `DeckListStore` orchestriert API + Cache, paralleles Counts-Fetching - `DeckListStore` orchestriert API + Cache, paralleles Counts-Fetching
via TaskGroup via TaskGroup
@ -203,7 +244,7 @@ Cards-Web-Repo (AASA). Strukturierte Liste in
1. Apple-Developer-Konfiguration (Team-ID, App-IDs, App-Group, Profiles) 1. Apple-Developer-Konfiguration (Team-ID, App-IDs, App-Group, Profiles)
2. App-Icon-Platzhalter durch Designer-Icon ersetzen 2. App-Icon-Platzhalter durch Designer-Icon ersetzen
3. AASA-Endpoint auf `cardecky.mana.how` (Cards-Web-Repo) 3. AASA-Endpoint auf `wordeck.com` (Cards-Web-Repo)
4. Xcode-Archive + TestFlight-Upload 4. Xcode-Archive + TestFlight-Upload
5. Endurance- und Cross-Device-Tests im TestFlight-Beta 5. Endurance- und Cross-Device-Tests im TestFlight-Beta
6. App-Store-Connect-Listing (Description, Screenshots, Privacy) 6. App-Store-Connect-Listing (Description, Screenshots, Privacy)

View file

@ -1,23 +1,23 @@
# cards-native # wordeck-native
Native SwiftUI-Universal-App (iOS / iPadOS / macOS) für Cardecky Native SwiftUI-Universal-App (iOS / iPadOS / macOS) für Wordeck
die Spaced-Repetition-Karten-App des Vereins **mana e.V.** die Spaced-Repetition-Karten-App des Vereins **mana e.V.**
> **Web-App-Parität.** Die existierende Web-App auf > **Web-App-Parität.** Die existierende Web-App auf
> `cardecky.mana.how` ist Funktions- und Verhaltens-Referenz. > `wordeck.com` ist Funktions- und Verhaltens-Referenz.
> Native bringt kein neues Produkt, sondern die App in einer Form, > Native bringt kein neues Produkt, sondern die App in einer Form,
> die iOS-Hardware besser nutzt. > die iOS-Hardware besser nutzt.
## Status ## Status
**Phase β-0 — Setup.** Leerer Build, Login funktioniert, Cardecky-API- **Phase β-0 — Setup.** Leerer Build, Login funktioniert, Wordeck-API-
Reachability-Check. Vollständiger Phasen-Plan in Reachability-Check. Vollständiger Phasen-Plan in
`../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`. `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`.
``` ```
HTTPS/JWT ┌──────────────────┐ HTTPS/JWT ┌──────────────────┐
cards-api ◄───────────── │ cards-native │ SwiftUI cards-api ◄───────────── │ wordeck-native │ SwiftUI
cardecky-api.mana.how │ ev.mana.cardecky │ WidgetKit (β-6) api.wordeck.com │ ev.mana.wordeck │ WidgetKit (β-6)
└──────────────────┘ └──────────────────┘
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
@ -30,7 +30,7 @@ Reachability-Check. Vollständiger Phasen-Plan in
```bash ```bash
xcodegen generate xcodegen generate
open CardsNative.xcodeproj # iPhone-17-Simulator open WordeckNative.xcodeproj # iPhone-17-Simulator
``` ```
Konventionen, Invarianten, Phasen-Disziplin: [`CLAUDE.md`](CLAUDE.md). Konventionen, Invarianten, Phasen-Disziplin: [`CLAUDE.md`](CLAUDE.md).

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.ev.mana.wordeck</string>
</array>
</dict>
</plist>

View file

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

View file

@ -31,17 +31,28 @@ final class ShareViewController: UIViewController {
group.enter() group.enter()
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in
if let url = item as? URL { if let url = item as? URL {
self.sharedURL = url.absoluteString let absolute = url.absoluteString
if self.sharedText.isEmpty { self.sharedText = url.absoluteString } DispatchQueue.main.async {
} self.sharedURL = absolute
if self.sharedText.isEmpty { self.sharedText = absolute }
group.leave() group.leave()
} }
} else {
group.leave()
}
}
} else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { } else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
group.enter() group.enter()
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
if let text = item as? String { self.sharedText = text } if let text = item as? String {
DispatchQueue.main.async {
self.sharedText = text
group.leave() group.leave()
} }
} else {
group.leave()
}
}
} }
} }
group.notify(queue: .main, execute: completion) group.notify(queue: .main, execute: completion)
@ -72,7 +83,7 @@ final class ShareViewController: UIViewController {
private func cancel() { private func cancel() {
dismiss(animated: true) { [weak self] in dismiss(animated: true) { [weak self] in
self?.extensionContext?.cancelRequest(withError: NSError(domain: "ev.mana.cardecky.share", code: 0)) self?.extensionContext?.cancelRequest(withError: NSError(domain: "ev.mana.wordeck.share", code: 0))
} }
} }
} }

View file

@ -1,30 +1,97 @@
import ManaAuthUI
import ManaCore import ManaCore
import SwiftUI import SwiftUI
/// Top-Level-Switch: Login vs Haupt-App. Haupt-App ist eine TabBar mit /// Top-Level-View: TabBar mit drei Tabs (Decks / Entdecken / Account).
/// drei Tabs (Decks / Entdecken / Account). /// Kein harter Login-Gate mehr Wordeck läuft auch im Guest-Modus
/// (lokale Decks lernen, Marketplace browsen). Schreibende Server-
/// Aktionen werden über ``ManaAuthGate`` einzeln auf Login eskaliert.
struct RootView: View { struct RootView: View {
@Environment(AuthClient.self) private var auth @Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@State private var selectedTab: AppTab = .decks @State private var selectedTab: AppTab = .decks
@State private var pendingDeepLinkSlug: String? @State private var pendingDeepLinkSlug: String?
@State private var showCreateDeck = false
@State private var showSignUpSheet = false
@State private var showForgotSheet = false
@State private var resetPasswordToken: String?
private let sourceAppUrl = URL(string: "https://wordeck.com/auth/verify")!
private let resetUniversalLink = URL(string: "https://wordeck.com/auth/reset")!
var body: some View { var body: some View {
switch auth.status {
case .signedIn:
mainTabs mainTabs
.onOpenURL { url in handle(url: url) } .onOpenURL { url in handle(url: url) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { handle(url: url) } if let url = activity.webpageURL { handle(url: url) }
} }
case .unknown, .signedOut, .signingIn, .error: .manaBrand(WordeckBrand.manaBrand)
LoginView() .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(WordeckBrand.manaBrand)
}
.task {
// DEBUG: Auto-Login mit DebugCredentials, falls signedOut.
// Release: no-op. Danach in Guest-Mode wechseln, wenn weder
// signedIn noch eingebuchtet Wordeck soll *immer* nutzbar
// sein, auch ohne Account.
await auth.ensureSignedIn()
if case .signedOut = auth.status {
do {
_ = try auth.enterGuestMode()
} catch {
Log.auth.warning(
"Guest-Mode konnte nicht aktiviert werden: \(String(describing: error), privacy: .public)"
)
}
}
}
}
/// Content für das ``ManaAuthGate``-Sheet wenn ein gegateter Button
/// gedrückt wird, fliegt der User in den Sign-In-Flow. Sign-Up und
/// Forgot-Password werden als verschachtelte Sheets aufgeklappt,
/// damit aus dem Gate-Sheet alle Auth-Pfade erreichbar bleiben.
private var gateSignInContent: some View {
NavigationStack {
ManaLoginView(
auth: auth,
onSignUpTapped: { showSignUpSheet = true },
onForgotTapped: { showForgotSheet = true }
)
.manaBrand(WordeckBrand.manaBrand)
.sheet(isPresented: $showSignUpSheet) {
ManaSignUpView(
auth: auth,
sourceAppUrl: sourceAppUrl,
onDone: { showSignUpSheet = false }
)
.manaBrand(WordeckBrand.manaBrand)
}
.sheet(isPresented: $showForgotSheet) {
ManaForgotPasswordView(
auth: auth,
resetUniversalLink: resetUniversalLink,
onDone: { showForgotSheet = false }
)
.manaBrand(WordeckBrand.manaBrand)
}
} }
} }
@ViewBuilder
private var mainTabs: some View { private var mainTabs: some View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
DeckListView() DeckListView(showCreate: $showCreateDeck)
.tabItem { Label("Decks", systemImage: "rectangle.stack") } .tabItem { Label("Decks", systemImage: "rectangle.stack") }
.tag(AppTab.decks) .tag(AppTab.decks)
@ -38,20 +105,44 @@ struct RootView: View {
.tabItem { Label("Account", systemImage: "person.crop.circle") } .tabItem { Label("Account", systemImage: "person.crop.circle") }
.tag(AppTab.account) .tag(AppTab.account)
} }
.decksCreateAccessory(visible: selectedTab == .decks) {
authGate.require(reason: "deck-create-accessory") {
showCreateDeck = true
}
}
} }
/// Universal-Link- und URL-Scheme-Handler: /// Universal-Link- und URL-Scheme-Handler:
/// - `https://cardecky.mana.how/d/<slug>` Explore-Tab + PublicDeckView /// - `https://wordeck.com/d/<slug>` Explore-Tab + PublicDeckView
/// - `https://wordeck.com/auth/reset?token=` ManaResetPasswordView
/// - `cards://study/<deckId>` später (β-6 Notifications) /// - `cards://study/<deckId>` später (β-6 Notifications)
private func handle(url: URL) { private func handle(url: URL) {
Log.app.info("Open URL: \(url.absoluteString, privacy: .public)") Log.app.info("Open URL: \(url.absoluteString, privacy: .public)")
if url.host == "cardecky.mana.how" || url.scheme == "cards" { guard url.host == "wordeck.com" || url.scheme == "cards" else { return }
let parts = url.pathComponents.filter { $0 != "/" } 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" { if parts.count >= 2, parts[0] == "d" {
pendingDeepLinkSlug = parts[1] pendingDeepLinkSlug = parts[1]
selectedTab = .explore selectedTab = .explore
} }
} }
}
/// Helper für `.sheet(item:)` mit einem String-Value (Reset-Token).
private struct IdentifiedString: Identifiable {
let value: String
var id: String {
value
} }
} }
@ -60,3 +151,38 @@ enum AppTab: Hashable {
case explore case explore
case account case account
} }
private extension View {
/// iOS 26: floating Neues Deck"-Pille via `.tabViewBottomAccessory`,
/// nur sichtbar wenn der Decks-Tab aktiv ist. iOS 18 fällt auf den
/// bestehenden `.bottomBar`-+"-Toolbar-Button in `DeckListView` zurück.
///
/// Den Modifier nur konditional anwenden sonst rendert das System
/// auch bei leerem Inhalt die leere Glass-Hülle (sichtbar als toter
/// Streifen über der TabBar auf Entdecken/Account).
@ViewBuilder
func decksCreateAccessory(visible: Bool, onTap: @escaping () -> Void) -> some View {
if #available(iOS 26.0, *), visible {
tabViewBottomAccessory {
DeckCreateAccessoryPill(action: onTap)
}
} else {
self
}
}
}
@available(iOS 26.0, *)
private struct DeckCreateAccessoryPill: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Label("Neues Deck", systemImage: "plus")
.font(.subheadline.weight(.semibold))
}
.buttonStyle(.glass)
.tint(WordeckTheme.primary)
.accessibilityLabel("Neues Deck erstellen")
}
}

View file

@ -1,11 +1,13 @@
import ManaAuthUI
import ManaCore import ManaCore
import SwiftData import SwiftData
import SwiftUI import SwiftUI
@main @main
struct CardsNativeApp: App { struct WordeckNativeApp: App {
let container: ModelContainer let container: ModelContainer
@State private var auth: AuthClient @State private var auth: AuthClient
@State private var authGate: ManaAuthGate
private let mediaCache: MediaCache private let mediaCache: MediaCache
init() { init() {
@ -17,16 +19,18 @@ struct CardsNativeApp: App {
let auth = AuthClient(config: AppConfig.manaAppConfig) let auth = AuthClient(config: AppConfig.manaAppConfig)
auth.bootstrap() auth.bootstrap()
_auth = State(initialValue: auth) _auth = State(initialValue: auth)
mediaCache = MediaCache(api: CardsAPI(auth: auth)) _authGate = State(initialValue: ManaAuthGate(auth: auth))
Log.app.info("Cards starting — auth status: \(String(describing: auth.status), privacy: .public)") mediaCache = MediaCache(api: WordeckAPI(auth: auth))
Log.app.info("Wordeck starting — auth status: \(String(describing: auth.status), privacy: .public)")
} }
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
RootView() RootView()
.environment(auth) .environment(auth)
.environment(authGate)
.environment(\.mediaCache, mediaCache) .environment(\.mediaCache, mediaCache)
.tint(CardsTheme.primary) .tint(WordeckTheme.primary)
} }
.modelContainer(container) .modelContainer(container)
} }

View file

@ -0,0 +1,114 @@
import Foundation
import ManaCore
/// AI-Deck-Generierung + Multipart-Helpers ausgelagert aus `WordeckAPI`,
/// damit der Haupt-Actor unter der Type-Body-Length-Grenze bleibt.
extension WordeckAPI {
/// `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 = "wordeck-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 `WordeckAPI`,
/// damit der Haupt-Actor unter der Type-Body-Length-Grenze bleibt.
///
/// `transport`, `decoder`, `makeJSON`, `ensureOK` sind im Actor
/// internal-zugänglich.
extension WordeckAPI {
/// `GET /api/v1/marketplace/me/decks` eigene Marketplace-Decks
/// (mit aktueller Version) für den Re-Publish-Flow.
func myMarketplaceDecks() async throws -> [OwnedMarketplaceDeck] {
let (data, http) = try await transport.request(path: "/api/v1/marketplace/me/decks")
try ensureOK(http, data: data)
return try decoder.decode(OwnedMarketplaceDecksResponse.self, from: data).decks
}
/// `POST /api/v1/marketplace/decks/:slug/report` Meldung melden.
/// Idempotent: doppeltes Melden mit gleicher Kategorie liefert
/// `already_reported: true` ohne Fehler.
@discardableResult
func reportDeck(slug: String, body: ReportDeckBody) async throws -> ReportDeckResponse {
let payload = try makeJSON(body)
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/decks/\(slug)/report",
method: "POST",
body: payload
)
try ensureOK(http, data: data)
return try decoder.decode(ReportDeckResponse.self, from: data)
}
/// `POST /api/v1/marketplace/authors/:slug/block` Author blockieren.
/// Decks dieses Authors verschwinden für den aufrufenden User aus
/// allen Marketplace-Listings.
func blockAuthor(slug: String) async throws {
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/authors/\(slug)/block",
method: "POST"
)
try ensureOK(http, data: data)
}
/// `DELETE /api/v1/marketplace/authors/:slug/block`.
func unblockAuthor(slug: String) async throws {
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/authors/\(slug)/block",
method: "DELETE"
)
try ensureOK(http, data: data)
}
/// `GET /api/v1/marketplace/me/blocks` eigene Block-Liste.
func myBlocks() async throws -> [BlockEntry] {
let (data, http) = try await transport.request(path: "/api/v1/marketplace/me/blocks")
try ensureOK(http, data: data)
return try decoder.decode(BlockListResponse.self, from: data).blocks
}
}

View file

@ -1,11 +1,16 @@
import Foundation import Foundation
import ManaCore import ManaCore
// swiftlint:disable file_length
// swiftlint:disable type_body_length
/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport` /// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
/// aus ManaCore, der die Cardecky-Endpoints kennt. /// aus ManaCore, der die Wordeck-Endpoints kennt. Marketplace-Moderation
actor CardsAPI { /// + Self-Endpoints + AI-Generation sind in `WordeckAPI+Marketplace.swift`
private let transport: AuthenticatedTransport /// und `WordeckAPI+Generation.swift` ausgelagert.
private let decoder: JSONDecoder actor WordeckAPI {
let transport: AuthenticatedTransport
let decoder: JSONDecoder
init(auth: AuthClient) { init(auth: AuthClient) {
transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth) transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth)
@ -13,7 +18,7 @@ actor CardsAPI {
decoder.dateDecodingStrategy = .iso8601withFractional decoder.dateDecodingStrategy = .iso8601withFractional
} }
/// Health-Probe verifiziert dass cardecky-api erreichbar ist /// Health-Probe verifiziert dass wordeck-api erreichbar ist
/// und der eigene JWT akzeptiert wird. /// und der eigene JWT akzeptiert wird.
func healthCheck() async throws -> Bool { func healthCheck() async throws -> Bool {
let (_, http) = try await transport.request(path: "/healthz") let (_, http) = try await transport.request(path: "/healthz")
@ -44,6 +49,29 @@ actor CardsAPI {
return try decoder.decode(CardListResponse.self, from: data).total return try decoder.decode(CardListResponse.self, from: data).total
} }
/// `GET /api/v1/cards?deck_id=...` komplette Liste der Karten
/// für den Browse-Modus im DeckDetailView.
func listCards(deckId: String) async throws -> [Card] {
let (data, http) = try await transport.request(path: "/api/v1/cards?deck_id=\(deckId)")
try ensureOK(http, data: data)
return try decoder.decode(CardListResponse.self, from: data).cards
}
/// `GET /api/v1/decks/:deckId/distractors` N zufällige Feldwerte
/// aus anderen Karten desselben Decks. Server-Schema erlaubt nur
/// `front`, `back`, `answer`, `question` als field.
func distractors(
deckId: String,
cardId: String,
field: String = "answer",
count: Int = 3
) async throws -> [String] {
let path = "/api/v1/decks/\(deckId)/distractors?card_id=\(cardId)&field=\(field)&count=\(count)"
let (data, http) = try await transport.request(path: path)
try ensureOK(http, data: data)
return try decoder.decode(DistractorsResponse.self, from: data).distractors
}
/// `GET /api/v1/reviews/due?deck_id=...&limit=500` Anzahl fälliger /// `GET /api/v1/reviews/due?deck_id=...&limit=500` Anzahl fälliger
/// Reviews in einem Deck. /// Reviews in einem Deck.
func dueCount(deckId: String) async throws -> Int { func dueCount(deckId: String) async throws -> Int {
@ -74,7 +102,7 @@ actor CardsAPI {
var items: [URLQueryItem] = [ var items: [URLQueryItem] = [
.init(name: "sort", value: sort.rawValue), .init(name: "sort", value: sort.rawValue),
.init(name: "limit", value: "\(limit)"), .init(name: "limit", value: "\(limit)"),
.init(name: "offset", value: "\(offset)"), .init(name: "offset", value: "\(offset)")
] ]
if let query, !query.trimmingCharacters(in: .whitespaces).isEmpty { if let query, !query.trimmingCharacters(in: .whitespaces).isEmpty {
items.append(.init(name: "q", value: query)) items.append(.init(name: "q", value: query))
@ -125,7 +153,7 @@ actor CardsAPI {
/// `POST /api/v1/media/upload` Multipart-Upload. Max 25 MiB. /// `POST /api/v1/media/upload` Multipart-Upload. Max 25 MiB.
/// Erlaubte MIMEs: image/*, audio/*, video/*. /// Erlaubte MIMEs: image/*, audio/*, video/*.
func uploadMedia(data: Data, filename: String, mimeType: String) async throws -> MediaUploadResponse { func uploadMedia(data: Data, filename: String, mimeType: String) async throws -> MediaUploadResponse {
let boundary = "cards-native-\(UUID().uuidString)" let boundary = "wordeck-native-\(UUID().uuidString)"
let body = makeMultipartBody( let body = makeMultipartBody(
file: data, file: data,
filename: filename, filename: filename,
@ -147,7 +175,7 @@ actor CardsAPI {
func fetchMedia(id: String) async throws -> Data { func fetchMedia(id: String) async throws -> Data {
let (data, http) = try await transport.request(path: "/api/v1/media/\(id)") let (data, http) = try await transport.request(path: "/api/v1/media/\(id)")
guard (200 ..< 300).contains(http.statusCode) else { guard (200 ..< 300).contains(http.statusCode) else {
throw AuthError.serverError(status: http.statusCode, message: "media fetch failed") throw AuthError.serverError(status: http.statusCode, code: nil, message: "media fetch failed")
} }
return data return data
} }
@ -155,7 +183,7 @@ actor CardsAPI {
/// `DELETE /api/v1/media/:id` Soft-Forget. (Endpoint heute nicht /// `DELETE /api/v1/media/:id` Soft-Forget. (Endpoint heute nicht
/// implementiert serverseitig; Stub bleibt für späteren Use.) /// implementiert serverseitig; Stub bleibt für späteren Use.)
func deleteMedia(id _: String) async throws { func deleteMedia(id _: String) async throws {
throw AuthError.serverError(status: 501, message: "media delete not implemented on server") throw AuthError.serverError(status: 501, code: nil, message: "media delete not implemented on server")
} }
// MARK: - Deck-Mutations // MARK: - Deck-Mutations
@ -195,6 +223,87 @@ actor CardsAPI {
try ensureOK(http, data: data) try ensureOK(http, data: data)
} }
/// `POST /api/v1/decks/:id/duplicate` Server-seitige Kopie mit
/// "(Kopie)"-Suffix, ohne FSRS-Verlauf, ohne Marketplace-Pointer.
@discardableResult
func duplicateDeck(id: String) async throws -> Deck {
let (data, http) = try await transport.request(
path: "/api/v1/decks/\(id)/duplicate",
method: "POST"
)
try ensureOK(http, data: data)
return try decoder.decode(Deck.self, from: data)
}
/// `POST /api/v1/marketplace/private/:deckId/pull-update` Smart-Merge-
/// Pull. Holt neue/geänderte Karten aus der jüngsten Marketplace-Version
/// in das geforkte private Deck. Removed-Karten bleiben lokal (User-
/// Choice gewinnt). 422 wenn das Deck kein Fork ist.
func pullUpdate(deckId: String) async throws -> PullUpdateResponse {
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/private/\(deckId)/pull-update",
method: "POST"
)
try ensureOK(http, data: data)
return try decoder.decode(PullUpdateResponse.self, from: data)
}
// MARK: - Marketplace-Publish
/// `POST /api/v1/marketplace/authors/me` Author-Profil upserten.
/// Pflicht-Schritt vor dem ersten Deck-Init im Marketplace.
func upsertAuthor(_ body: AuthorUpsertBody) async throws {
let payload = try makeJSON(body)
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/authors/me",
method: "POST",
body: payload
)
try ensureOK(http, data: data)
}
/// `GET /api/v1/marketplace/authors/me` eigenes Author-Profil
/// lesen, gibt `nil` zurück wenn noch keins existiert.
func myAuthor() async throws -> Bool {
let (data, http) = try await transport.request(path: "/api/v1/marketplace/authors/me")
try ensureOK(http, data: data)
// Server liefert entweder Author-Objekt oder JSON-null.
if let raw = try? JSONSerialization.jsonObject(with: data), raw is NSNull {
return false
}
return true
}
/// `POST /api/v1/marketplace/decks` Marketplace-Deck-Init.
/// Erstellt nur Metadaten; Karten folgen via `publishMarketplaceVersion`.
@discardableResult
func initMarketplaceDeck(_ body: MarketplaceDeckInitBody) async throws -> PublicDeck {
let payload = try makeJSON(body)
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/decks",
method: "POST",
body: payload
)
try ensureOK(http, data: data)
return try decoder.decode(PublicDeck.self, from: data)
}
/// `POST /api/v1/marketplace/decks/:slug/publish` neue Version
/// publishen. Karten werden serverseitig durch AI-Moderation geschickt.
func publishMarketplaceVersion(
slug: String,
body: MarketplacePublishBody
) async throws -> MarketplacePublishResponse {
let payload = try makeJSON(body)
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/decks/\(slug)/publish",
method: "POST",
body: payload
)
try ensureOK(http, data: data)
return try decoder.decode(MarketplacePublishResponse.self, from: data)
}
// MARK: - Card-Mutations // MARK: - Card-Mutations
/// `POST /api/v1/cards` Karte anlegen. Server validiert `fields` /// `POST /api/v1/cards` Karte anlegen. Server validiert `fields`
@ -270,44 +379,24 @@ actor CardsAPI {
// MARK: - JSON-Encoding // MARK: - JSON-Encoding
private func makeJSON<T: Encodable>(_ value: T) throws -> Data { func makeJSON(_ value: some Encodable) throws -> Data {
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601 encoder.dateEncodingStrategy = .iso8601
return try encoder.encode(value) return try encoder.encode(value)
} }
// MARK: - Multipart
private func makeMultipartBody(
file: Data,
filename: String,
mimeType: String,
boundary: String
) -> Data {
var body = Data()
let lineBreak = "\r\n"
let header = """
--\(boundary)\(lineBreak)\
Content-Disposition: form-data; name="file"; filename="\(filename)"\(lineBreak)\
Content-Type: \(mimeType)\(lineBreak)\(lineBreak)
"""
body.append(header.data(using: .utf8) ?? Data())
body.append(file)
body.append(lineBreak.data(using: .utf8) ?? Data())
body.append("--\(boundary)--\(lineBreak)".data(using: .utf8) ?? Data())
return body
}
// MARK: - Helpers // MARK: - Helpers
private func ensureOK(_ http: HTTPURLResponse, data: Data) throws { func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
guard (200 ..< 300).contains(http.statusCode) else { guard (200 ..< 300).contains(http.statusCode) else {
let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error
throw AuthError.serverError(status: http.statusCode, message: message) throw AuthError.serverError(status: http.statusCode, code: nil, message: message)
} }
} }
} }
// swiftlint:enable type_body_length
private struct CardsServerError: Decodable { private struct CardsServerError: Decodable {
let error: String? let error: String?
} }

View file

@ -2,14 +2,19 @@ import Foundation
import ManaCore import ManaCore
/// App-spezifische Konfiguration für Cards. Implementiert `ManaAppConfig` /// App-spezifische Konfiguration für Cards. Implementiert `ManaAppConfig`
/// aus ManaCore und ergänzt die Cards-eigene `apiBaseURL` (cardecky-api, /// aus ManaCore und ergänzt die Cards-eigene `apiBaseURL` (wordeck-api,
/// getrennt von mana-auth). /// getrennt von mana-auth).
enum AppConfig { enum AppConfig {
static let manaAppConfig: ManaAppConfig = DefaultManaAppConfig( static let manaAppConfig: ManaAppConfig = DefaultManaAppConfig(
authBaseURL: URL(string: "https://auth.mana.how")!, authBaseURL: URL(string: "https://auth.mana.how")!,
keychainService: "ev.mana.cardecky", keychainService: "ev.mana.wordeck",
keychainAccessGroup: nil // Explizit auf TeamID.BundleID, statt nil. Vermeidet Logout
// bei TestFlight-Cert-Drift (siehe mana-swift-core v1.5.1).
keychainAccessGroup: "QP3GLU8PH3.ev.mana.wordeck",
// Single-Source fuer den App-Group-String (App-Hülle + Widget
// + ShareExt-Entitlement spiegeln das hardcoded).
appGroup: "group.ev.mana.wordeck"
) )
static let apiBaseURL = URL(string: "https://cardecky-api.mana.how")! static let apiBaseURL = URL(string: "https://api.wordeck.com")!
} }

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 Wordeck-Type-Bezeichnung sein; unbekannte Werte
/// landen als `.basic`.
enum CSVParser {
enum ParseError: LocalizedError {
case empty
case noValidRows
var errorDescription: String? {
switch self {
case .empty: "Datei ist leer."
case .noValidRows: "Keine gültigen Zeilen gefunden — erwartet vorne,hinten[,typ]'."
}
}
}
static func parse(_ rawText: String) throws -> [CSVRow] {
var text = rawText
if text.hasPrefix("\u{FEFF}") {
text.removeFirst()
}
if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
throw ParseError.empty
}
let allRows = parseFields(text)
guard !allRows.isEmpty else { throw ParseError.noValidRows }
// Header-Detection: erste Zeile droppen wenn beide Felder Headerwords sind.
let headerTokens: Set = [
"front", "back", "question", "answer",
"vorderseite", "rückseite", "rueckseite", "frage", "antwort"
]
var rows = allRows
if let first = rows.first,
first.count >= 2,
headerTokens.contains(first[0].lowercased()),
headerTokens.contains(first[1].lowercased())
{
rows.removeFirst()
}
let parsed: [CSVRow] = rows.compactMap { fields in
guard fields.count >= 2 else { return nil }
let front = fields[0].trimmingCharacters(in: .whitespacesAndNewlines)
let back = fields[1].trimmingCharacters(in: .whitespacesAndNewlines)
if front.isEmpty, back.isEmpty { return nil }
let type: CardType = fields.count >= 3
? CardType(rawValue: fields[2].trimmingCharacters(in: .whitespacesAndNewlines)) ?? .basic
: .basic
return CSVRow(front: front, back: back, type: type)
}
if parsed.isEmpty {
throw ParseError.noValidRows
}
return parsed
}
/// Parser-State-Machine: liest Zeichen-für-Zeichen, beachtet Quote-
/// Modus für Kommas/Newlines innerhalb von `""`-Feldern. `""` wird
/// als wörtliches `"` im Feld behandelt.
private static func parseFields(_ text: String) -> [[String]] {
var state = ParseState()
var iterator = text.makeIterator()
while let char = iterator.next() {
if state.inQuotes {
handleQuotedChar(char, iterator: &iterator, state: &state)
} else if char == "\"", state.currentField.isEmpty {
state.inQuotes = true
} else {
handleUnquotedChar(char, state: &state)
}
}
// Tail-Flush letzte Zeile ohne abschließendes Newline.
if !state.currentField.isEmpty || !state.currentRow.isEmpty {
state.currentRow.append(state.currentField)
state.rows.append(state.currentRow)
}
return state.rows
}
/// Mutable State der Parse-Machine als `inout`-Struct in die
/// Char-Handler durchgereicht, damit die Parameter-Listen kompakt
/// bleiben.
fileprivate struct ParseState {
var rows: [[String]] = []
var currentRow: [String] = []
var currentField = ""
var inQuotes = false
}
/// Im Quote-Modus: `"` schließt das Feld oder escaped sich selbst,
/// alles andere ist Inhalt.
private static func handleQuotedChar(
_ char: Character,
iterator: inout String.Iterator,
state: inout ParseState
) {
guard char == "\"" else {
state.currentField.append(char)
return
}
if let next = iterator.next(), next == "\"" {
state.currentField.append("\"")
return
}
state.inQuotes = false
// Das Zeichen nach dem End-Quote ist ein Separator (Komma/Newline
// /EOF) über den Unquoted-Handler routen.
if let next = iterator.next() {
handleUnquotedChar(next, state: &state)
}
}
private static func handleUnquotedChar(_ char: Character, state: inout ParseState) {
switch char {
case ",":
state.currentRow.append(state.currentField)
state.currentField = ""
case "\n":
state.currentRow.append(state.currentField)
state.rows.append(state.currentRow)
state.currentField = ""
state.currentRow = []
case "\r":
// CRLF: `\r` schluken, `\n` macht den Row-Break.
break
case "\"" where state.currentField.isEmpty:
state.inQuotes = true
default:
state.currentField.append(char)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,62 @@
import Foundation
/// Body für `POST /api/v1/decks/generate` AI-Text-Generierung.
/// Aus `cards/apps/api/src/routes/decks-generate.ts:GenerateInputSchema`.
struct DeckGenerateBody: Encodable {
let prompt: String
let language: GenerationLanguage
let count: Int
let url: String?
}
/// Sprache für AI-Deck-Generierung. Server akzeptiert `de` oder `en`.
enum GenerationLanguage: String, Codable, CaseIterable {
case de
case en
var label: String {
switch self {
case .de: "Deutsch"
case .en: "English"
}
}
}
/// Eine hochzuladende Datei für `POST /api/v1/decks/from-image`.
/// Wird als multipart-`file`-Part gesendet.
struct GenerationMediaFile: Identifiable {
let id: UUID
let data: Data
let filename: String
let mimeType: String
init(id: UUID = UUID(), data: Data, filename: String, mimeType: String) {
self.id = id
self.data = data
self.filename = filename
self.mimeType = mimeType
}
/// `application/pdf` PDF-Dokument, sonst Bild.
var isPDF: Bool {
mimeType == "application/pdf"
}
/// Größen-Label für die UI ("3.2 MB").
var sizeLabel: String {
ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)
}
}
/// Response von beiden AI-Generate-Endpoints (`/decks/generate` und
/// `/decks/from-image`). Beide rufen serverseitig `insertGeneratedDeck`
/// und liefern dieselbe Shape.
struct DeckGenerateResponse: Decodable {
let deck: Deck
let cardsCreated: Int
enum CodingKeys: String, CodingKey {
case deck
case cardsCreated = "cards_created"
}
}

View file

@ -2,7 +2,7 @@ import Foundation
/// Body für `POST /api/v1/decks`. Aus `DeckCreateSchema` in /// Body für `POST /api/v1/decks`. Aus `DeckCreateSchema` in
/// `cards/packages/cards-domain/src/schemas/deck.ts`. /// `cards/packages/cards-domain/src/schemas/deck.ts`.
struct DeckCreateBody: Encodable, Sendable { struct DeckCreateBody: Encodable {
let name: String let name: String
let description: String? let description: String?
let color: String? let color: String?
@ -19,7 +19,7 @@ struct DeckCreateBody: Encodable, Sendable {
} }
/// Body für `PATCH /api/v1/decks/:id`. Alle Felder optional plus `archived`. /// Body für `PATCH /api/v1/decks/:id`. Alle Felder optional plus `archived`.
struct DeckUpdateBody: Encodable, Sendable { struct DeckUpdateBody: Encodable {
var name: String? var name: String?
var description: String? var description: String?
var color: String? var color: String?
@ -36,3 +36,37 @@ struct DeckUpdateBody: Encodable, Sendable {
case archived case archived
} }
} }
/// Kurze Marketplace-Version-Info: Semver + Version-ID.
struct PullUpdateVersion: Decodable {
let semver: String
let versionId: String
enum CodingKeys: String, CodingKey {
case semver
case versionId = "version_id"
}
}
/// Response von `POST /api/v1/marketplace/private/:deckId/pull-update`.
/// `up_to_date == true` heißt: keine neue Marketplace-Version verfügbar,
/// die anderen Counts sind dann 0.
struct PullUpdateResponse: Decodable {
let upToDate: Bool
let from: PullUpdateVersion?
let to: PullUpdateVersion?
let added: Int
let changed: Int
let removed: Int
let cardsInserted: Int?
enum CodingKeys: String, CodingKey {
case upToDate = "up_to_date"
case from
case to
case added
case changed
case removed
case cardsInserted = "cards_inserted"
}
}

View file

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

View file

@ -0,0 +1,122 @@
import Foundation
/// Kategorien für Deck-Reports entspricht serverseitig
/// `report_category` Enum in `marketplace.deck_reports`.
enum ReportCategory: String, Codable, CaseIterable {
case spam
case copyright
case nsfw
case misinformation
case hate
case other
var label: String {
switch self {
case .spam: "Spam"
case .copyright: "Urheberrecht"
case .nsfw: "Anstößige Inhalte"
case .misinformation: "Falschinformation"
case .hate: "Hass / Diskriminierung"
case .other: "Sonstiges"
}
}
}
/// Body für `POST /api/v1/marketplace/decks/:slug/report`.
struct ReportDeckBody: Encodable {
let category: ReportCategory
let body: String?
let versionId: String?
let cardContentHash: String?
enum CodingKeys: String, CodingKey {
case category
case body
case versionId
case cardContentHash
}
}
/// Antwort vom Report-Endpoint.
struct ReportDeckResponse: Decodable {
let ok: Bool
let alreadyReported: Bool
let reportId: String?
enum CodingKeys: String, CodingKey {
case ok
case alreadyReported = "already_reported"
case reportId = "report_id"
}
}
/// Eintrag aus `GET /api/v1/marketplace/me/blocks`.
struct BlockEntry: Decodable, Identifiable {
let authorSlug: String
let displayName: String
let blockedAt: Date
var id: String {
authorSlug
}
enum CodingKeys: String, CodingKey {
case authorSlug = "author_slug"
case displayName = "display_name"
case blockedAt = "blocked_at"
}
}
struct BlockListResponse: Decodable {
let blocks: [BlockEntry]
}
/// Aktuelle Version eines `OwnedMarketplaceDeck` semver + Karten-Count.
struct OwnedMarketplaceVersion: Decodable {
let versionId: String
let semver: String
let cardCount: Int
let publishedAt: Date?
enum CodingKeys: String, CodingKey {
case versionId = "version_id"
case semver
case cardCount = "card_count"
case publishedAt = "published_at"
}
}
/// Eintrag aus `GET /api/v1/marketplace/me/decks` Re-Publish-Flow.
struct OwnedMarketplaceDeck: Decodable, Identifiable {
let slug: String
let title: String
let description: String?
let language: String?
let category: String?
let license: String
let priceCredits: Int
let isTakedown: Bool
let createdAt: Date
let latestVersion: OwnedMarketplaceVersion?
var id: String {
slug
}
enum CodingKeys: String, CodingKey {
case slug
case title
case description
case language
case category
case license
case priceCredits = "price_credits"
case isTakedown = "is_takedown"
case createdAt = "created_at"
case latestVersion = "latest_version"
}
}
struct OwnedMarketplaceDecksResponse: Decodable {
let decks: [OwnedMarketplaceDeck]
}

View file

@ -0,0 +1,116 @@
import Foundation
/// Body für `POST /api/v1/marketplace/authors/me` Upsert des
/// Author-Profils. Pflicht-Schritt vor dem ersten Deck-Init im
/// Marketplace.
struct AuthorUpsertBody: Encodable {
let slug: String
let displayName: String
let bio: String?
let avatarUrl: String?
let pseudonym: Bool?
enum CodingKeys: String, CodingKey {
case slug
case displayName
case bio
case avatarUrl
case pseudonym
}
}
/// Body für `POST /api/v1/marketplace/decks` Deck-Init.
/// Erstellt nur die Metadaten; Karten kommen mit der ersten `publish`.
struct MarketplaceDeckInitBody: Encodable {
let slug: String
let title: String
let description: String?
let language: String?
let license: String?
let priceCredits: Int?
let category: DeckCategory?
enum CodingKeys: String, CodingKey {
case slug
case title
case description
case language
case license
case priceCredits
case category
}
}
/// Eine Card-Payload-Zeile für `POST /:slug/publish`. Andere Type-
/// Namen als bei privaten Karten der Server nutzt `'type-in'` statt
/// `'typing'` und `'audio'` statt `'audio-front'`.
struct MarketplacePublishCard: Encodable {
let type: String
let fields: [String: String]
}
/// Body für `POST /api/v1/marketplace/decks/:slug/publish`.
struct MarketplacePublishBody: Encodable {
let semver: String
let changelog: String?
let cards: [MarketplacePublishCard]
}
/// Antwort von `POST /:slug/publish`. Enthält das aktualisierte Deck,
/// die neue Version und das AI-Moderation-Verdict.
struct MarketplacePublishResponse: Decodable {
let deck: PublicDeck
let version: PublicDeckVersion
let moderation: ModerationResult
struct ModerationResult: Decodable {
let verdict: String
let categories: [String]?
let model: String?
}
}
/// Liste von Wordeck-Marketplace-Lizenzen. Server akzeptiert beliebige
/// Strings 60 Zeichen wir bieten die kanonischen vier.
enum MarketplaceLicense: String, CaseIterable {
case personalUse = "Wordeck-Personal-Use-1.0"
case shareAlike = "Wordeck-Share-Alike-1.0"
case attribution = "Wordeck-Attribution-1.0"
case proOnly = "Wordeck-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 Wordeck-Pro (Bezahl-Decks)"
}
}
}
/// Konvertiert eine private `Card` in eine `MarketplacePublishCard`
/// mit dem korrekten Marketplace-Type und Feld-Mapping. Liefert `nil`,
/// wenn der Type im Marketplace nicht unterstützt wird (z.B. Image-
/// Occlusion und Audio-Front brauchen Media-Re-Uploads, das gibt es
/// im Marketplace-Publish-Flow heute nicht).
enum MarketplaceCardConverter {
static func convert(_ card: Card) -> MarketplacePublishCard? {
switch card.type {
case .basic, .basicReverse, .cloze, .multipleChoice:
return MarketplacePublishCard(type: card.type.rawValue, fields: card.fields)
case .typing:
// typing 'type-in' mit umgeschlüsselten Feldern.
let front = card.fields["front"] ?? ""
let answer = card.fields["answer"] ?? ""
return MarketplacePublishCard(
type: "type-in",
fields: ["question": front, "expected": answer]
)
case .imageOcclusion, .audioFront:
// Media-Refs zeigen auf user-private Media-IDs Marketplace-
// User können die nicht laden. Skip bis Server-seitig ein
// Media-Publish-Flow existiert.
return nil
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,77 @@
import Foundation
/// Vergleich einer getippten User-Antwort gegen die erwartete Antwort.
/// 1:1-Port aus `cards/packages/cards-domain/src/typing.ts`:
/// Normalisierung (lowercase, trim, NFD-Diakritika-Stripping),
/// dann exact-match `correct`. Sonst Levenshtein-Distanz mit
/// Threshold `max(1, floor(answer.length * 0.2))` `close`.
enum TypingMatch: Equatable {
case correct
case close
case wrong
}
enum Typing {
/// `aliases` ist ein Komma-getrennter String aus dem `aliases`-Feld
/// der Karte (optional). Jeder Alias zählt als gültige Antwort.
static func check(input: String, answer: String, aliases: String? = nil) -> TypingMatch {
let normInput = normalize(input)
guard !normInput.isEmpty else { return .wrong }
var candidates = [answer]
if let aliases {
candidates.append(contentsOf: aliases.split(separator: ",").map(String.init))
}
let normalizedCandidates = candidates
.map(normalize)
.filter { !$0.isEmpty }
guard !normalizedCandidates.isEmpty else { return .wrong }
if normalizedCandidates.contains(normInput) {
return .correct
}
let shortestLen = normalizedCandidates.map(\.count).min() ?? normInput.count
let threshold = max(1, Int(Double(shortestLen) * 0.2))
for candidate in normalizedCandidates where levenshtein(normInput, candidate) <= threshold {
return .close
}
return .wrong
}
private static func normalize(_ string: String) -> String {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
let lowered = trimmed.lowercased()
// NFD-Dekomposition + Combining-Marks entfernen (z.B. ä a)
let decomposed = lowered.decomposedStringWithCanonicalMapping
let stripped = decomposed.unicodeScalars.filter { scalar in
!(0x0300 ... 0x036F).contains(scalar.value)
}
return String(String.UnicodeScalarView(stripped))
}
static func levenshtein(_ a: String, _ b: String) -> Int {
let aChars = Array(a)
let bChars = Array(b)
let m = aChars.count
let n = bChars.count
if m == 0 { return n }
if n == 0 { return m }
var row = Array(0 ... n)
for i in 1 ... m {
var prev = row[0]
row[0] = i
for j in 1 ... n {
let tmp = row[j]
if aChars[i - 1] == bChars[j - 1] {
row[j] = prev
} else {
row[j] = 1 + Swift.min(prev, row[j], row[j - 1])
}
prev = tmp
}
}
return row[n]
}
}

View file

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

View file

@ -8,14 +8,14 @@ import UserNotifications
@MainActor @MainActor
@Observable @Observable
final class NotificationManager { final class NotificationManager {
enum AuthorizationStatus: Sendable { enum AuthorizationStatus {
case unknown case unknown
case authorized case authorized
case denied case denied
} }
private(set) var authorization: AuthorizationStatus = .unknown private(set) var authorization: AuthorizationStatus = .unknown
private let identifier = "ev.mana.cardecky.dailyReminder" private let identifier = "ev.mana.wordeck.dailyReminder"
private let store = UserDefaults.standard private let store = UserDefaults.standard
/// Persistiert User-Pref. Format: ISO-Stunde:Minute (default 18:00). /// Persistiert User-Pref. Format: ISO-Stunde:Minute (default 18:00).
@ -67,7 +67,7 @@ final class NotificationManager {
guard remindersEnabled, authorization == .authorized else { return } guard remindersEnabled, authorization == .authorized else { return }
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = "Cards" content.title = "Wordeck"
content.body = "Ein paar Karten warten auf dich." content.body = "Ein paar Karten warten auf dich."
content.sound = .default content.sound = .default

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,12 +6,12 @@ import Foundation
/// ///
/// Wire ist bewusst stabil + schmal nur was das Widget rendert. /// Wire ist bewusst stabil + schmal nur was das Widget rendert.
/// Neue Felder dürfen additiv dazukommen, alte Felder bleiben. /// Neue Felder dürfen additiv dazukommen, alte Felder bleiben.
struct WidgetSnapshot: Codable, Sendable { struct WidgetSnapshot: Codable {
let updatedAt: Date let updatedAt: Date
let totalDueCount: Int let totalDueCount: Int
let topDecks: [Entry] let topDecks: [Entry]
struct Entry: Codable, Sendable, Identifiable { struct Entry: Codable, Identifiable {
let id: String // deck-id let id: String // deck-id
let name: String let name: String
let dueCount: Int let dueCount: Int
@ -22,7 +22,7 @@ struct WidgetSnapshot: Codable, Sendable {
/// Liest und schreibt WidgetSnapshot in den shared App-Group-Container. /// Liest und schreibt WidgetSnapshot in den shared App-Group-Container.
enum WidgetSnapshotStore { enum WidgetSnapshotStore {
/// App-Group-ID muss exakt mit dem Entitlement-Eintrag matchen. /// App-Group-ID muss exakt mit dem Entitlement-Eintrag matchen.
static let appGroupID = "group.ev.mana.cardecky" static let appGroupID = "group.ev.mana.wordeck"
static let snapshotFilename = "widget-snapshot.json" static let snapshotFilename = "widget-snapshot.json"
static var snapshotURL: URL? { static var snapshotURL: URL? {

View file

@ -1,13 +1,19 @@
import Foundation import ManaCore
import OSLog import OSLog
/// App-eigene OSLog-Logger unter Subsystem `ev.mana.cardecky`. /// App-eigene OSLog-Logger gegen ``AppConfig/manaAppConfig``. Standard-
/// ManaCore loggt unter `ev.mana.core` parallel siehe /// Kategorien (`app`/`auth`/`api`) kommen aus ``ManaAppLog`` (mana-swift-
/// `mana-swift-core/Sources/ManaCore/Telemetry/CoreLog.swift`. /// core v1.7.0+), app-spezifische über `mana.category()`.
///
/// Subsystem leitet sich aus `AppConfig.manaAppConfig.logSubsystem` ab
/// heute `ev.mana.wordeck`. ManaCore loggt parallel unter
/// `ev.mana.core`.
enum Log { enum Log {
static let app = Logger(subsystem: "ev.mana.cardecky", category: "app") private static let mana = ManaAppLog(AppConfig.manaAppConfig)
static let auth = Logger(subsystem: "ev.mana.cardecky", category: "auth")
static let api = Logger(subsystem: "ev.mana.cardecky", category: "api") static let app = mana.app
static let study = Logger(subsystem: "ev.mana.cardecky", category: "study") static let auth = mana.auth
static let sync = Logger(subsystem: "ev.mana.cardecky", category: "sync") static let api = mana.api
static let study = mana.category("study")
static let sync = mana.category("sync")
} }

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(WordeckTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(WordeckTheme.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 {
WordeckTheme.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

@ -1,99 +0,0 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
private typealias PlatformColorType = UIColor
#elseif canImport(AppKit)
import AppKit
private typealias PlatformColorType = NSColor
#endif
/// Forest-Theme aus `mana/packages/themes/src/variants/forest.css`.
/// Lokal in cards-native nachgebaut, weil ManaTokens v1.0.0 nur den
/// Default-Theme (mana-Variant) liefert.
///
/// Migration auf einen Theme-Switch in ManaTokens ist Phase ε aus
/// `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))
/// Standard-Text
static let foreground = dynamic(light: (142, 30, 12), dark: (142, 15, 95))
/// Card, Panel, Modal
static let surface = dynamic(light: (142, 25, 98), dark: (142, 25, 12))
/// Hover-State auf Surface
static let surfaceHover = dynamic(light: (142, 20, 95), dark: (142, 20, 16))
/// Disabled-Felder, Skeleton
static let muted = dynamic(light: (142, 15, 93), dark: (142, 18, 18))
/// Sekundär-Text, Placeholder
static let mutedForeground = dynamic(light: (142, 10, 42), dark: (142, 12, 65))
/// Rahmen, Trennlinien
static let border = dynamic(light: (142, 15, 88), dark: (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))
/// Text auf Primary
static let primaryForeground = dynamic(light: (0, 0, 100), dark: (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))
// 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)
#if canImport(UIKit)
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
})
#else
return Color(red: 0, green: 0, blue: 0)
#endif
}
private static func fromHSL(_ hue: Double, _ saturation: Double, _ lightness: Double) -> PlatformColorType {
let h = hue / 360
let s = saturation / 100
let l = lightness / 100
if s == 0 {
return PlatformColorType(red: l, green: l, blue: l, alpha: 1)
}
let q = l < 0.5 ? l * (1 + s) : l + s - l * s
let p = 2 * l - q
let r = hueToRGB(p, q, h + 1.0 / 3.0)
let g = hueToRGB(p, q, h)
let b = hueToRGB(p, q, h - 1.0 / 3.0)
return PlatformColorType(red: r, green: g, blue: b, alpha: 1)
}
private static func hueToRGB(_ p: Double, _ q: Double, _ rawT: Double) -> Double {
var t = rawT
if t < 0 { t += 1 }
if t > 1 { t -= 1 }
if t < 1.0 / 6.0 { return p + (q - p) * 6 * t }
if t < 1.0 / 2.0 { return q }
if t < 2.0 / 3.0 { return p + (q - p) * (2.0 / 3.0 - t) * 6 }
return p
}
}

View file

@ -0,0 +1,25 @@
import ManaAuthUI
/// Brücke zwischen Wordecks `WordeckTheme` (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: "Wordeck", )`
/// ersetzt werden siehe MANA_SWIFT.md Phase ε.
enum WordeckBrand {
static let manaBrand = ManaBrandConfig(
appName: "Wordeck",
tagline: "Karteikarten des Vereins mana e.V.",
logoSymbol: "rectangle.stack.fill",
background: WordeckTheme.background,
foreground: WordeckTheme.foreground,
surface: WordeckTheme.surface,
mutedForeground: WordeckTheme.mutedForeground,
border: WordeckTheme.border,
primary: WordeckTheme.primary,
primaryForeground: WordeckTheme.primaryForeground,
error: WordeckTheme.error,
success: WordeckTheme.success
)
}

View file

@ -0,0 +1,29 @@
import ManaTokens
import SwiftUI
/// Cards-Theme forwarded auf ``ManaTheme/forest`` aus
/// `mana-swift-core` v1.6.0.
///
/// Bis v1.5.x lebte hier ein 120-LOC-HSL-Apparat als lokaler Nachbau
/// der `forest.css`-Variant. Mit v1.6.0 liefert ManaTokens alle acht
/// Web-Theme-Variants nativ `forest` ist eine davon.
///
/// `WordeckTheme` bleibt als dünner Alias bestehen, damit die ~290
/// Call-Sites in dieser App nicht in einem einzigen Sprint umziehen
/// müssen. Neue Call-Sites bevorzugen direkt `ManaTheme.forest.<token>`
/// (oder `@Environment(\.manaTheme)` falls die App irgendwann
/// Theme-Switching bekommt).
enum WordeckTheme {
static let background = ManaTheme.forest.background
static let foreground = ManaTheme.forest.foreground
static let surface = ManaTheme.forest.surface
static let surfaceHover = ManaTheme.forest.surfaceHover
static let muted = ManaTheme.forest.muted
static let mutedForeground = ManaTheme.forest.mutedForeground
static let border = ManaTheme.forest.border
static let primary = ManaTheme.forest.primary
static let primaryForeground = ManaTheme.forest.primaryForeground
static let error = ManaTheme.forest.error
static let success = ManaTheme.forest.success
static let warning = ManaTheme.forest.warning
}

View file

@ -1,59 +1,194 @@
import ManaAuthUI
import ManaCore import ManaCore
import SwiftUI import SwiftUI
struct AccountView: View { struct AccountView: View {
@Environment(AuthClient.self) private var auth @Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@State private var showChangeEmail = false
@State private var showChangePassword = false
@State private var showDeleteAccount = false
var body: some View { var body: some View {
ZStack { ZStack {
CardsTheme.background.ignoresSafeArea() WordeckTheme.background.ignoresSafeArea()
VStack(spacing: 24) { Group {
Image(systemName: "person.crop.circle.fill") switch auth.status {
.resizable() case .signedIn:
.frame(width: 80, height: 80) signedInContent
.foregroundStyle(CardsTheme.primary) case .guest, .signedOut, .error, .unknown:
guestContent
if let email = auth.currentEmail { case .signingIn, .twoFactorRequired:
Text(email) ProgressView().tint(WordeckTheme.primary)
.font(.headline)
.foregroundStyle(CardsTheme.foreground)
} }
NavigationLink {
SettingsView()
} label: {
Label("Einstellungen", systemImage: "gear")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8))
.foregroundStyle(CardsTheme.foreground)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(CardsTheme.border, lineWidth: 1)
)
} }
.buttonStyle(.plain)
.padding(.horizontal, 32)
Spacer()
Button(role: .destructive) {
Task { await auth.signOut() }
} label: {
Text("Abmelden")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
.foregroundStyle(CardsTheme.error)
}
.padding(.horizontal, 32)
}
.padding(.top, 48)
} }
.navigationTitle("Account") .navigationTitle("Account")
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
.manaBrand(WordeckBrand.manaBrand)
.sheet(isPresented: $showChangeEmail) {
ManaChangeEmailView(
auth: auth,
callbackUniversalLink: URL(string: "https://wordeck.com/auth/email-changed"),
onDone: { showChangeEmail = false }
)
.manaBrand(WordeckBrand.manaBrand)
}
.sheet(isPresented: $showChangePassword) {
ManaChangePasswordView(
auth: auth,
onDone: { showChangePassword = false }
)
.manaBrand(WordeckBrand.manaBrand)
}
.sheet(isPresented: $showDeleteAccount) {
ManaDeleteAccountView(
auth: auth,
onDone: { showDeleteAccount = false }
)
.manaBrand(WordeckBrand.manaBrand)
}
}
private var signedInContent: some View {
VStack(spacing: 20) {
Image(systemName: "person.crop.circle.fill")
.resizable()
.frame(width: 80, height: 80)
.foregroundStyle(WordeckTheme.primary)
if let email = auth.currentEmail {
Text(email)
.font(.headline)
.foregroundStyle(WordeckTheme.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(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(WordeckTheme.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(WordeckTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
.foregroundStyle(WordeckTheme.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(WordeckTheme.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(WordeckTheme.mutedForeground)
VStack(spacing: 8) {
Text("Du nutzt Wordeck anonym")
.font(.headline)
.foregroundStyle(WordeckTheme.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(WordeckTheme.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(WordeckTheme.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(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 8))
.foregroundStyle(WordeckTheme.foreground)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(WordeckTheme.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(WordeckTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(WordeckTheme.border, lineWidth: 1)
)
.opacity(layer.opacity)
.rotationEffect(.degrees(layer.tilt))
.offset(x: layer.dx, y: layer.dy)
.shadow(color: WordeckTheme.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(WordeckTheme.warning)
}
Spacer()
Image(systemName: category?.systemImageName ?? "rectangle.stack")
.font(.title2)
.foregroundStyle(WordeckTheme.primary.opacity(0.85))
}
Spacer(minLength: 0)
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(WordeckTheme.foreground)
.lineLimit(3)
if let description, !description.isEmpty {
Text(description)
.font(.caption)
.foregroundStyle(WordeckTheme.mutedForeground)
.lineLimit(2)
}
}
Spacer(minLength: 0)
footer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var layers: [DeckCoverStackLayer] {
var hash = UInt64(0)
for byte in seed.utf8 {
hash = hash &* 31 &+ UInt64(byte)
}
return (0 ..< 3).map { index in
let seedHash = hash &+ UInt64(index) &* 17
let tiltRaw = Double((seedHash >> 8) & 0xFF) / 255.0 - 0.5
let xRaw = Double((seedHash >> 16) & 0xFF) / 255.0 - 0.5
let yRaw = Double((seedHash >> 24) & 0xFF) / 255.0 - 0.5
let depth = Double(index + 1)
return DeckCoverStackLayer(
tilt: tiltRaw * 4.0,
dx: xRaw * 6.0,
dy: depth * 3.0 + yRaw * 2.0,
opacity: 0.7 - depth * 0.18
)
}
}
}
private struct DeckCoverStackLayer {
let tilt: Double
let dx: Double
let dy: Double
let opacity: Double
}

View file

@ -2,8 +2,16 @@ import ManaCore
import SwiftData import SwiftData
import SwiftUI import SwiftUI
/// Deck-Detail mit Aktionen: Lernen, Karte hinzufügen, Bearbeiten, Löschen. // swiftlint:disable file_length
/// Wird per Tap auf eine Deck-Row aus der DeckListView geöffnet. // swiftlint:disable type_body_length
/// Deck-Detail mit Aktionen + Card-Liste. Wird per Tap auf eine Deck-Row
/// aus der DeckListView geöffnet.
///
/// `type_body_length` ist bewusst übersprungen Detail-View hostet
/// 5 verschiedene Sheets (Edit, CardCreate, CardEdit, Publish, Print),
/// Confirmation-Dialog + Alerts; aufspalten ginge nur über Multi-State-
/// Plumbing zwischen Parent und Children.
struct DeckDetailView: View { struct DeckDetailView: View {
let deckId: String let deckId: String
@ -17,6 +25,18 @@ struct DeckDetailView: View {
@State private var showDeleteConfirm = false @State private var showDeleteConfirm = false
@State private var navigateToStudy = false @State private var navigateToStudy = false
@State private var deleteError: String? @State private var deleteError: String?
@State private var editingCard: Card?
@State private var cards: [Card] = []
@State private var isLoadingCards = false
@State private var cardsError: String?
@State private var isPullingUpdate = false
@State private var isDuplicating = false
@State private var pullAlert: AlertMessage?
@State private var actionError: String?
@State private var showPublishSheet = false
@State private var showPrintSheet = false
init(deckId: String) { init(deckId: String) {
self.deckId = deckId self.deckId = deckId
@ -25,12 +45,12 @@ struct DeckDetailView: View {
var body: some View { var body: some View {
ZStack { ZStack {
CardsTheme.background.ignoresSafeArea() WordeckTheme.background.ignoresSafeArea()
if let deck = decks.first { if let deck = decks.first {
content(deck: deck) content(deck: deck)
} else { } else {
ContentUnavailableView("Deck nicht gefunden", systemImage: "questionmark.folder") ContentUnavailableView("Deck nicht gefunden", systemImage: "questionmark.folder")
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} }
} }
.navigationTitle(decks.first?.name ?? "") .navigationTitle(decks.first?.name ?? "")
@ -49,8 +69,41 @@ struct DeckDetailView: View {
} }
.sheet(isPresented: $showCardEditor) { .sheet(isPresented: $showCardEditor) {
NavigationStack { NavigationStack {
CardEditorView(deckId: deckId) { _ in CardEditorView(mode: .create(deckId: deckId)) { _ in
Task { await refreshAfterEdit() } Task {
await refreshAfterEdit()
await loadCards()
}
}
}
}
.sheet(item: $editingCard) { card in
NavigationStack {
CardEditorView(mode: .edit(card: card)) { _ in
Task {
await refreshAfterEdit()
await loadCards()
editingCard = nil
}
}
}
}
.sheet(isPresented: $showPublishSheet) {
if let deck = decks.first {
NavigationStack {
MarketplacePublishView(privateDeck: deck) { _ in
showPublishSheet = false
}
}
}
}
.sheet(isPresented: $showPrintSheet) {
NavigationStack {
DeckPrintView(deckId: deckId)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Fertig") { showPrintSheet = false }
}
} }
} }
} }
@ -64,54 +117,82 @@ struct DeckDetailView: View {
} }
Button("Abbrechen", role: .cancel) {} Button("Abbrechen", role: .cancel) {}
} message: { } message: {
Text("Alle Karten und Reviews dieses Decks werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.") Text(
"""
Alle Karten und Reviews dieses Decks werden ebenfalls \
gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
"""
)
} }
.navigationDestination(isPresented: $navigateToStudy) { .navigationDestination(isPresented: $navigateToStudy) {
if let deck = decks.first { if let deck = decks.first {
StudySessionView(deckId: deck.id, deckName: deck.name) StudySessionView(deckId: deck.id, deckName: deck.name)
} }
} }
.task(id: deckId) {
await loadCards()
}
.refreshable {
await loadCards()
}
.alert(item: $pullAlert) { alert in
Alert(title: Text(alert.title), message: Text(alert.message), dismissButton: .default(Text("OK")))
}
.alert(
"Aktion fehlgeschlagen",
isPresented: Binding(
get: { actionError != nil },
set: { if !$0 { actionError = nil } }
),
presenting: actionError
) { _ in
Button("OK") { actionError = nil }
} message: { message in
Text(message)
}
} }
private func content(deck: CachedDeck) -> some View { private func content(deck: CachedDeck) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
header(deck: deck) header(deck: deck)
actions(deck: deck) actions(deck: deck)
if let deleteError { if let deleteError {
Text(deleteError) Text(deleteError)
.font(.footnote) .font(.footnote)
.foregroundStyle(CardsTheme.error) .foregroundStyle(WordeckTheme.error)
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
Spacer() cardListSection
} }
.padding(.vertical, 16) .padding(.vertical, 16)
} }
}
private func header(deck: CachedDeck) -> some View { private func header(deck: CachedDeck) -> some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
Text(deck.name) Text(deck.name)
.font(.title.bold()) .font(.title.bold())
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
if deck.isFromMarketplace { if deck.isFromMarketplace {
Image(systemName: "globe") Image(systemName: "globe")
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} }
} }
if let description = deck.deckDescription, !description.isEmpty { if let description = deck.deckDescription, !description.isEmpty {
Text(description) Text(description)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} }
HStack(spacing: 16) { HStack(spacing: 16) {
Label("\(deck.cardCount) Karten", systemImage: "rectangle.stack") Label("\(deck.cardCount) Karten", systemImage: "rectangle.stack")
if deck.dueCount > 0 { if deck.dueCount > 0 {
Label("\(deck.dueCount) fällig", systemImage: "clock.badge.exclamationmark") Label("\(deck.dueCount) fällig", systemImage: "clock.badge.exclamationmark")
.foregroundStyle(CardsTheme.primary) .foregroundStyle(WordeckTheme.primary)
} }
if let category = deck.category { if let category = deck.category {
Text(category.label) Text(category.label)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} }
} }
.font(.footnote) .font(.footnote)
@ -121,17 +202,25 @@ struct DeckDetailView: View {
private func actions(deck: CachedDeck) -> some View { private func actions(deck: CachedDeck) -> some View {
VStack(spacing: 12) { VStack(spacing: 12) {
primaryActions
secondaryActions(deck: deck)
}
.padding(.horizontal, 16)
}
@ViewBuilder
private var primaryActions: some View {
Button { Button {
navigateToStudy = true navigateToStudy = true
} label: { } label: {
Label("Karten lernen", systemImage: "play.fill") Label("Karten lernen", systemImage: "play.fill")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) .background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.primaryForeground) .foregroundStyle(WordeckTheme.primaryForeground)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(deck.dueCount == 0) .disabled((decks.first?.dueCount ?? 0) == 0)
Button { Button {
showCardEditor = true showCardEditor = true
@ -139,44 +228,80 @@ struct DeckDetailView: View {
Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle") Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) .background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.stroke(CardsTheme.border, lineWidth: 1) .stroke(WordeckTheme.border, lineWidth: 1)
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
}
HStack(spacing: 12) { private func secondaryActions(deck: CachedDeck) -> some View {
Button { DeckSecondaryActions(
showEditor = true isForkedFromMarketplace: deck.isFromMarketplace,
} label: { isPullingUpdate: isPullingUpdate,
Label("Bearbeiten", systemImage: "pencil") isDuplicating: isDuplicating,
.frame(maxWidth: .infinity) onPullUpdate: { Task { await pullUpdate() } },
.padding(.vertical, 10) onDuplicate: { Task { await duplicate() } },
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) onPublish: { showPublishSheet = true },
.foregroundStyle(CardsTheme.foreground) onPrint: { showPrintSheet = true },
.overlay( onEdit: { showEditor = true },
RoundedRectangle(cornerRadius: 10) onDelete: { showDeleteConfirm = true }
.stroke(CardsTheme.border, lineWidth: 1)
) )
} }
.buttonStyle(.plain)
Button { private var cardListSection: some View {
showDeleteConfirm = true VStack(alignment: .leading, spacing: 8) {
} label: { HStack {
Label("Löschen", systemImage: "trash") Text("Karten")
.frame(maxWidth: .infinity) .font(.headline)
.padding(.vertical, 10) .foregroundStyle(WordeckTheme.foreground)
.background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) Spacer()
.foregroundStyle(CardsTheme.error) if !cards.isEmpty {
} Text("\(cards.count)")
.buttonStyle(.plain) .font(.footnote)
.foregroundStyle(WordeckTheme.mutedForeground)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 8)
if isLoadingCards, cards.isEmpty {
HStack {
Spacer()
ProgressView()
.tint(WordeckTheme.primary)
Spacer()
}
.padding(.vertical, 24)
} else if let cardsError {
Text(cardsError)
.font(.caption)
.foregroundStyle(WordeckTheme.error)
.padding(.horizontal, 16)
} else if cards.isEmpty {
Text("Noch keine Karten. Tippe auf »Karte hinzufügen«.")
.font(.footnote)
.foregroundStyle(WordeckTheme.mutedForeground)
.padding(.horizontal, 16)
.padding(.vertical, 12)
} else {
LazyVStack(spacing: 8) {
ForEach(cards) { card in
Button {
editingCard = card
} label: {
CardPreviewRow(card: card)
.padding(.horizontal, 16)
}
.buttonStyle(.plain)
.accessibilityHint("Tippen zum Bearbeiten")
}
}
}
}
} }
private func refreshAfterEdit() async { private func refreshAfterEdit() async {
@ -184,12 +309,73 @@ struct DeckDetailView: View {
await store.refresh() await store.refresh()
} }
private func pullUpdate() async {
isPullingUpdate = true
defer { isPullingUpdate = false }
let api = WordeckAPI(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 = WordeckAPI(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 = WordeckAPI(auth: auth)
do {
cards = try await api.listCards(deckId: deckId)
.sorted { $0.createdAt > $1.createdAt }
} catch {
cardsError = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
}
}
private func delete() async { private func delete() async {
deleteError = nil deleteError = nil
let api = CardsAPI(auth: auth) let api = WordeckAPI(auth: auth)
do { do {
try await api.deleteDeck(id: deckId) try await api.deleteDeck(id: deckId)
// Cache nachziehen
if let deck = decks.first { if let deck = decks.first {
context.delete(deck) context.delete(deck)
try? context.save() try? context.save()
@ -200,3 +386,81 @@ struct DeckDetailView: View {
} }
} }
} }
// swiftlint:enable type_body_length
/// Einfacher Alert-Body Title + Message für `.alert(item:)`-Trigger.
struct AlertMessage: Identifiable {
let id = UUID()
let title: String
let message: String
}
/// Kompakte Card-Row mit Front-Vorschau und Type-Badge.
private struct CardPreviewRow: View {
let card: Card
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon(for: card.type))
.foregroundStyle(WordeckTheme.primary)
.frame(width: 24)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 4) {
Text(preview(card: card))
.font(.subheadline)
.foregroundStyle(WordeckTheme.foreground)
.lineLimit(2)
Text(typeLabel(card.type))
.font(.caption2)
.foregroundStyle(WordeckTheme.mutedForeground)
}
Spacer()
}
.padding(12)
.background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(WordeckTheme.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,29 +1,55 @@
import ManaAuthUI
import ManaCore import ManaCore
import SwiftData import SwiftData
import SwiftUI import SwiftUI
/// β-1 Hauptbildschirm: Liste aller Decks mit Card- und Due-Counts. /// Navigations-Routen für die DeckListView. Tap auf eine Tile geht
/// Web-Vorbild: `cards/apps/web/src/routes/decks/+page.svelte`. /// direkt in Study-Mode, Tap auf den Edit-Button in den Deck-Detail-
/// View für Browse + Edit.
enum DeckRoute: Hashable {
case study(deckId: String, deckName: String)
case detail(deckId: String)
}
// swiftlint:disable type_body_length
/// Decks-Hauptbildschirm im Wordeck-Look: horizontale Scroll-Reihen
/// mit Fan-Stack-Karten-Tiles. Web-Vorbild:
/// `cards/apps/web/src/routes/decks/+page.svelte`.
struct DeckListView: View { struct DeckListView: View {
@Environment(AuthClient.self) private var auth @Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@Environment(\.modelContext) private var context @Environment(\.modelContext) private var context
@Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck] @Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck]
@Binding var showCreate: Bool
private var isGuest: Bool {
if case .signedIn = auth.status { false } else { true }
}
@State private var store: DeckListStore? @State private var store: DeckListStore?
@State private var showAccount = false
@State private var showCreate = false
@State private var pendingShares: [PendingShare] = [] @State private var pendingShares: [PendingShare] = []
@State private var path = NavigationPath()
var body: some View { var body: some View {
NavigationStack { NavigationStack(path: $path) {
ZStack { ZStack {
CardsTheme.background.ignoresSafeArea() WordeckTheme.background.ignoresSafeArea()
content content
} }
.navigationTitle("Decks") .navigationTitle("Decks")
.navigationDestination(for: String.self) { deckId in #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) DeckDetailView(deckId: deckId)
} }
}
.navigationDestination(for: PendingShareRoute.self) { route in .navigationDestination(for: PendingShareRoute.self) { route in
PendingShareConsumeView(share: route.share, onDone: { PendingShareConsumeView(share: route.share, onDone: {
PendingShareStore.remove(id: route.share.id) PendingShareStore.remove(id: route.share.id)
@ -51,16 +77,6 @@ struct DeckListView: View {
.onAppear { .onAppear {
pendingShares = PendingShareStore.readAll() pendingShares = PendingShareStore.readAll()
} }
.sheet(isPresented: $showAccount) {
NavigationStack {
AccountView()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") { showAccount = false }
}
}
}
}
} }
} }
@ -69,45 +85,135 @@ struct DeckListView: View {
if decks.isEmpty { if decks.isEmpty {
emptyState emptyState
} else { } else {
List { ScrollView {
VStack(alignment: .leading, spacing: 24) {
pendingShareSection pendingShareSection
inboxBannerSection inboxBanner
ownDecksSection deckSection(title: "Eigene Decks", icon: "rectangle.stack", decks: ownDecks)
if !subscribedDecks.isEmpty {
deckSection(title: "Abonniert", icon: "globe", decks: subscribedDecks)
} }
.listStyle(.plain) }
.scrollContentBackground(.hidden) .padding(.vertical, 12)
}
}
}
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(WordeckTheme.primary)
Text(title)
.font(.title3.weight(.semibold))
.foregroundStyle(WordeckTheme.foreground)
Text("\(decks.count)")
.font(.subheadline)
.foregroundStyle(WordeckTheme.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(WordeckTheme.primary)
VStack(alignment: .leading, spacing: 2) {
Text("Inbox")
.font(.subheadline.weight(.semibold))
.foregroundStyle(WordeckTheme.foreground)
Text("\(inbox.dueCount) fällige Karten aus abonnierten Decks")
.font(.caption)
.foregroundStyle(WordeckTheme.mutedForeground)
}
Spacer()
Image(systemName: "chevron.right")
.font(.footnote)
.foregroundStyle(WordeckTheme.mutedForeground)
}
.padding(14)
.background(
WordeckTheme.primary.opacity(0.08),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(WordeckTheme.primary.opacity(0.18), lineWidth: 1)
)
.padding(.horizontal, 20)
}
.buttonStyle(.plain)
} }
} }
@ViewBuilder @ViewBuilder
private var pendingShareSection: some View { private var pendingShareSection: some View {
if !pendingShares.isEmpty { if !pendingShares.isEmpty {
Section { VStack(alignment: .leading, spacing: 8) {
ForEach(pendingShares) { share in ForEach(pendingShares) { share in
NavigationLink(value: PendingShareRoute(share: share)) { NavigationLink(value: PendingShareRoute(share: share)) {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "square.and.arrow.down") Image(systemName: "square.and.arrow.down")
.foregroundStyle(CardsTheme.primary) .foregroundStyle(WordeckTheme.warning)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Aus Teilen-Menü") Text("Aus Teilen-Menü")
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
Text(share.text) Text(share.text)
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
.lineLimit(2) .lineLimit(2)
} }
Spacer() Spacer()
Image(systemName: "chevron.right")
.font(.footnote)
.foregroundStyle(WordeckTheme.mutedForeground)
} }
.padding() .padding(14)
.background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) .background(
WordeckTheme.warning.opacity(0.12),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
} }
} }
.padding(.horizontal, 20)
} }
} }
@ -115,168 +221,75 @@ struct DeckListView: View {
VStack(spacing: 16) { VStack(spacing: 16) {
if store?.state == .loading { if store?.state == .loading {
ProgressView() ProgressView()
.tint(CardsTheme.primary) .tint(WordeckTheme.primary)
Text("Lade Decks …") Text("Lade Decks …")
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} else if let message = store?.errorMessage { } else if let message = store?.errorMessage {
ContentUnavailableView { ContentUnavailableView {
Label("Decks konnten nicht geladen werden", systemImage: "wifi.exclamationmark") Label("Decks konnten nicht geladen werden", systemImage: "wifi.exclamationmark")
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
} description: { } description: {
Text(message) Text(message)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
}
} else if isGuest {
ContentUnavailableView {
Label("Wordeck ohne Konto", systemImage: "person.crop.circle.dashed")
.foregroundStyle(WordeckTheme.foreground)
} description: {
Text(
"""
Browse den Marketplace im Entdecken-Tab kein Konto \
nötig. Für eigene Decks und Cloud-Sync logge dich ein.
"""
)
.foregroundStyle(WordeckTheme.mutedForeground)
} actions: {
Button("Anmelden / Konto erstellen") {
authGate.require(reason: "deck-list-empty") {}
}
.buttonStyle(.borderedProminent)
.tint(WordeckTheme.primary)
} }
} else { } else {
ContentUnavailableView { ContentUnavailableView {
Label("Noch keine Decks", systemImage: "rectangle.stack") Label("Noch keine Decks", systemImage: "rectangle.stack")
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
} description: { } description: {
Text("Erstelle dein erstes Deck auf cardecky.mana.how oder ziehe nach unten zum Aktualisieren.") Text(
.foregroundStyle(CardsTheme.mutedForeground) """
Tippe unten auf »+«, um dein erstes Deck zu erstellen, \
oder browse den Marketplace im Entdecken-Tab.
"""
)
.foregroundStyle(WordeckTheme.mutedForeground)
} }
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
@ViewBuilder
private var inboxBannerSection: some View {
if let inbox = decks.first(where: { $0.isFromMarketplace && $0.dueCount > 0 }) {
Section {
HStack(spacing: 12) {
Image(systemName: "tray.full.fill")
.foregroundStyle(CardsTheme.primary)
VStack(alignment: .leading, spacing: 2) {
Text("Inbox")
.font(.subheadline.weight(.semibold))
.foregroundStyle(CardsTheme.foreground)
Text("\(inbox.dueCount) fällige Karten aus abonnierten Decks")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
}
Spacer()
}
.padding()
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
}
private var ownDecksSection: some View {
Section {
ForEach(decks) { deck in
NavigationLink(value: deck.id) {
DeckRow(deck: deck)
}
.buttonStyle(.plain)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
}
}
}
@ToolbarContentBuilder @ToolbarContentBuilder
private var toolbar: some ToolbarContent { private var toolbar: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) { // Auf iOS 26 übernimmt das `.tabViewBottomAccessory` aus RootView die
// 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 { Button {
authGate.require(reason: "deck-create-toolbar") {
showCreate = true showCreate = true
}
} label: { } label: {
Image(systemName: "plus.circle") Label("Deck hinzufügen", systemImage: "plus")
.foregroundStyle(CardsTheme.primary) .labelStyle(.iconOnly)
.foregroundStyle(WordeckTheme.primary)
} }
.accessibilityLabel("Deck hinzufügen") .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)
}
}
}
Spacer() Spacer()
Image(systemName: "chevron.right")
.font(.footnote)
.foregroundStyle(CardsTheme.mutedForeground)
} }
.padding(.vertical, 12)
.padding(.horizontal, 12)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
} }
private var deckColor: Color {
guard let hex = deck.color, let rgb = parseHex(hex) else {
return CardsTheme.primary
}
return Color.manaHexLocal(rgb)
}
private func parseHex(_ hex: String) -> UInt32? {
var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("#") { trimmed = String(trimmed.dropFirst()) }
return UInt32(trimmed, radix: 16)
} }
} }
private extension Color { // swiftlint:enable type_body_length
/// Lokales Hex-Helper analog zu `ManaTokens.Color.manaHex`. Hier
/// dupliziert, weil DeckRow nicht von ManaTokens abhängen muss.
static func manaHexLocal(_ rgb: UInt32) -> Color {
let r = Double((rgb >> 16) & 0xFF) / 255.0
let g = Double((rgb >> 8) & 0xFF) / 255.0
let b = Double(rgb & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
}

View file

@ -0,0 +1,56 @@
import SwiftUI
#if canImport(SafariServices) && canImport(UIKit)
import SafariServices
import UIKit
#endif
/// In-App-Browser für die Druck-Ansicht des Decks. Nutzt
/// `SFSafariViewController`, weil iOS dort die Print-Sheet und
/// In Dateien speichern" PDF von Haus aus mitbringt kein eigener
/// PDF-Renderer in der App nötig.
///
/// Auth-Cookies für `wordeck.com` 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://wordeck.com/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(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(WordeckTheme.foreground)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(WordeckTheme.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(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(WordeckTheme.primary)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(WordeckTheme.primary.opacity(0.3), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
private var updateButton: some View {
Button(action: onPullUpdate) {
HStack {
if isPullingUpdate {
ProgressView().tint(WordeckTheme.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(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(WordeckTheme.primary)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(WordeckTheme.primary.opacity(0.3), lineWidth: 1)
)
}
.buttonStyle(.plain)
.disabled(isPullingUpdate)
}
private var duplicateButton: some View {
Button(action: onDuplicate) {
HStack {
if isDuplicating {
ProgressView().tint(WordeckTheme.foreground)
} else {
Image(systemName: "doc.on.doc")
}
Text(isDuplicating ? "Wird dupliziert …" : "Deck duplizieren")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(WordeckTheme.foreground)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(WordeckTheme.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(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(WordeckTheme.foreground)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(WordeckTheme.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
Button(action: onDelete) {
Label("Löschen", systemImage: "trash")
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(WordeckTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(WordeckTheme.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(WordeckTheme.mutedForeground)
if deck.dueCount > 0 {
Text("\(deck.dueCount) fällig")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(WordeckTheme.primary.opacity(0.15), in: Capsule())
.foregroundStyle(WordeckTheme.primary)
}
if deck.isFromMarketplace {
Image(systemName: "globe")
.font(.caption2)
.foregroundStyle(WordeckTheme.mutedForeground)
}
Spacer()
editButton
}
}
private var editButton: some View {
Button {
onEdit()
} label: {
Image(systemName: "pencil")
.font(.footnote.weight(.semibold))
.foregroundStyle(WordeckTheme.mutedForeground)
.frame(width: 30, height: 30)
.background(WordeckTheme.muted.opacity(0.7), in: Circle())
.overlay(
Circle().stroke(WordeckTheme.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

@ -33,7 +33,7 @@ struct PendingShareConsumeView: View {
Section("Ziel-Deck") { Section("Ziel-Deck") {
if decks.isEmpty { if decks.isEmpty {
Text("Erst ein Deck erstellen.") Text("Erst ein Deck erstellen.")
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} else { } else {
Picker("Deck", selection: $selectedDeckId) { Picker("Deck", selection: $selectedDeckId) {
Text("Wählen …").tag(String?.none) Text("Wählen …").tag(String?.none)
@ -55,14 +55,14 @@ struct PendingShareConsumeView: View {
Section("Quelle") { Section("Quelle") {
Text(sourceURL) Text(sourceURL)
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} }
} }
if let errorMessage { if let errorMessage {
Section { Section {
Text(errorMessage) Text(errorMessage)
.font(.footnote) .font(.footnote)
.foregroundStyle(CardsTheme.error) .foregroundStyle(WordeckTheme.error)
} }
} }
} }
@ -92,7 +92,7 @@ struct PendingShareConsumeView: View {
defer { isSubmitting = false } defer { isSubmitting = false }
let backText = back.trimmed.isEmpty ? (share.sourceURL ?? "") : back.trimmed let backText = back.trimmed.isEmpty ? (share.sourceURL ?? "") : back.trimmed
let api = CardsAPI(auth: auth) let api = WordeckAPI(auth: auth)
let body = CardCreateBody( let body = CardCreateBody(
deckId: deckId, deckId: deckId,
type: .basic, type: .basic,

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(WordeckTheme.foreground)
Text(row.back)
.font(.caption)
.lineLimit(2)
.foregroundStyle(WordeckTheme.mutedForeground)
if row.type != .basic {
Text(typeLabel(row.type))
.font(.caption2)
.foregroundStyle(WordeckTheme.primary)
}
}
.padding(.vertical, 2)
}
if rows.count > visible.count {
Text("… und \(rows.count - visible.count) weitere")
.font(.caption)
.foregroundStyle(WordeckTheme.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(WordeckTheme.mutedForeground)
} else if regions.isEmpty {
Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundStyle(WordeckTheme.warning)
} else {
Label(
"\(regions.count) Masken → \(regions.count) Reviews",
systemImage: "checkmark.circle.fill"
)
.font(.caption)
.foregroundStyle(WordeckTheme.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 `WordeckAPI.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: WordeckAPI) 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: WordeckAPI
) 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: WordeckAPI
) 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,56 +1,127 @@
import ManaCore import ManaCore
import PhotosUI
import SwiftUI import SwiftUI
#if canImport(UIKit) #if canImport(UIKit)
import UIKit import UIKit
#endif #endif
/// Card-Create-View. Type-Picker oben, type-spezifische Felder unten. // swiftlint:disable type_body_length
/// Deckt alle 7 Card-Types ab.
/// Card-Create und Card-Edit in einer View.
///
/// - `.create(deckId:)` zeigt Type-Picker + leere Felder.
/// - `.edit(card:)` blendet Type-Picker aus (Server-seitig immutable),
/// pre-fillt alle Felder, und PATCHt auf Submit.
///
/// Bei Image-Occlusion und Audio-Front im Edit-Modus bleibt der bestehende
/// Media-Ref erhalten, solange der User die Datei nicht explizit ersetzt.
struct CardEditorView: View { struct CardEditorView: View {
let deckId: String enum Mode {
let onCreated: (Card) -> Void case create(deckId: String)
case edit(card: Card)
}
let mode: Mode
let onSaved: (Card) -> Void
@Environment(AuthClient.self) private var auth @Environment(AuthClient.self) private var auth
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var type: CardType = .basic @State private var type: CardType
@State private var front: String = "" @State private var front: String
@State private var back: String = "" @State private var back: String
@State private var clozeText: String = "" @State private var clozeText: String
@State private var typingAnswer: String = "" @State private var typingAnswer: String
@State private var multipleChoiceAnswer: String = "" @State private var multipleChoiceAnswer: String
@State private var isSubmitting = false @State private var isSubmitting = false
@State private var errorMessage: String? @State private var errorMessage: String?
// Image-Occlusion-State // Image-Occlusion-State
@State private var imagePickerItem: PhotosPickerItem?
@State private var occlusionImage: PlatformImage? @State private var occlusionImage: PlatformImage?
@State private var occlusionImageData: Data? @State private var occlusionImageData: Data?
@State private var occlusionMimeType: String = "image/jpeg" @State private var occlusionMimeType: String = "image/jpeg"
@State private var occlusionRegions: [MaskRegion] = [] @State private var occlusionRegions: [MaskRegion]
@State private var occlusionNote: String = "" @State private var occlusionNote: String
/// Bestehender `image_ref` aus der Card im Edit-Modus. Bleibt erhalten,
/// solange der User kein neues Bild wählt.
@State private var existingImageRef: String?
// Audio-Front-State /// Audio-Front-State
@State private var audioFileURL: URL? @State private var audioFileURL: URL?
@State private var showAudioPicker = false /// Bestehender `audio_ref` aus der Card im Edit-Modus.
@State private var existingAudioRef: String?
private static let supportedTypes: [CardType] = [ private static let supportedTypes: [CardType] = [
.basic, .basicReverse, .cloze, .typing, .multipleChoice, .basic, .basicReverse, .cloze, .typing, .multipleChoice,
.imageOcclusion, .audioFront, .imageOcclusion, .audioFront
] ]
init(mode: Mode, onSaved: @escaping (Card) -> Void) {
self.mode = mode
self.onSaved = onSaved
let initialType: CardType
var initialFront = ""
var initialBack = ""
var initialCloze = ""
var initialTyping = ""
var initialMC = ""
var initialRegions: [MaskRegion] = []
var initialNote = ""
var initialImageRef: String?
var initialAudioRef: String?
switch mode {
case .create:
initialType = .basic
case let .edit(card):
initialType = card.type
switch card.type {
case .basic, .basicReverse:
initialFront = card.fields["front"] ?? ""
initialBack = card.fields["back"] ?? ""
case .cloze:
initialCloze = card.fields["text"] ?? ""
case .typing:
initialFront = card.fields["front"] ?? ""
initialTyping = card.fields["answer"] ?? ""
case .multipleChoice:
initialFront = card.fields["front"] ?? ""
initialMC = card.fields["answer"] ?? ""
case .imageOcclusion:
initialRegions = MaskRegions.parse(card.fields["mask_regions"] ?? "[]")
initialNote = card.fields["note"] ?? ""
initialImageRef = card.fields["image_ref"]
case .audioFront:
initialBack = card.fields["back"] ?? ""
initialAudioRef = card.fields["audio_ref"]
}
}
_type = State(initialValue: initialType)
_front = State(initialValue: initialFront)
_back = State(initialValue: initialBack)
_clozeText = State(initialValue: initialCloze)
_typingAnswer = State(initialValue: initialTyping)
_multipleChoiceAnswer = State(initialValue: initialMC)
_occlusionRegions = State(initialValue: initialRegions)
_occlusionNote = State(initialValue: initialNote)
_existingImageRef = State(initialValue: initialImageRef)
_existingAudioRef = State(initialValue: initialAudioRef)
}
var body: some View { var body: some View {
Form { Form {
if isCreate {
Section("Card-Type") { Section("Card-Type") {
Picker("Typ", selection: $type) { Picker("Typ", selection: $type) {
ForEach(Self.supportedTypes, id: \.self) { t in ForEach(Self.supportedTypes, id: \.self) { cardType in
Text(label(for: t)).tag(t) Text(label(for: cardType)).tag(cardType)
} }
} }
.pickerStyle(.menu) .pickerStyle(.menu)
} }
}
typeFields typeFields
@ -58,11 +129,12 @@ struct CardEditorView: View {
Section { Section {
Text(errorMessage) Text(errorMessage)
.font(.footnote) .font(.footnote)
.foregroundStyle(CardsTheme.error) .foregroundStyle(WordeckTheme.error)
} }
} }
} }
.navigationTitle("Neue Karte") .disabled(isSubmitting)
.navigationTitle(isCreate ? "Neue Karte" : "Karte bearbeiten")
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
@ -71,7 +143,9 @@ struct CardEditorView: View {
Button("Abbrechen") { dismiss() } Button("Abbrechen") { dismiss() }
} }
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("Erstellen") { Task { await submit() } } Button(isCreate ? "Erstellen" : "Speichern") {
Task { await submit() }
}
.disabled(!canSubmit || isSubmitting) .disabled(!canSubmit || isSubmitting)
} }
} }
@ -93,14 +167,17 @@ struct CardEditorView: View {
Section { Section {
Text("Beide Richtungen werden gelernt — front→back und back→front.") Text("Beide Richtungen werden gelernt — front→back und back→front.")
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} }
} }
case .cloze: case .cloze:
Section("Cloze-Text") { Section("Cloze-Text") {
TextField("Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.", TextField(
text: $clozeText, axis: .vertical) "Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.",
text: $clozeText,
axis: .vertical
)
.lineLimit(3 ... 8) .lineLimit(3 ... 8)
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.sentences) .textInputAutocapitalization(.sentences)
@ -111,15 +188,15 @@ struct CardEditorView: View {
if count > 0 { if count > 0 {
Label("\(count) Lücken erkannt → \(count) Reviews", systemImage: "checkmark.circle.fill") Label("\(count) Lücken erkannt → \(count) Reviews", systemImage: "checkmark.circle.fill")
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.success) .foregroundStyle(WordeckTheme.success)
} else { } else {
Label("Mindestens ein Cluster `{{c1::...}}` erforderlich", systemImage: "exclamationmark.circle") Label("Mindestens ein Cluster `{{c1::...}}` erforderlich", systemImage: "exclamationmark.circle")
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.warning) .foregroundStyle(WordeckTheme.warning)
} }
Text("Mit Hint: `{{c1::Berlin::Hauptstadt von DE}}`") Text("Mit Hint: `{{c1::Berlin::Hauptstadt von DE}}`")
.font(.caption2) .font(.caption2)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} }
case .typing: case .typing:
@ -142,127 +219,44 @@ struct CardEditorView: View {
Section { Section {
Text("Distractor-Optionen werden zur Lernzeit automatisch aus anderen Karten desselben Decks gezogen.") Text("Distractor-Optionen werden zur Lernzeit automatisch aus anderen Karten desselben Decks gezogen.")
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} }
case .imageOcclusion: case .imageOcclusion:
imageOcclusionFields ImageOcclusionFields(
image: $occlusionImage,
imageData: $occlusionImageData,
mimeType: $occlusionMimeType,
regions: $occlusionRegions,
note: $occlusionNote,
existingImageRef: $existingImageRef,
onLoadError: { errorMessage = $0 }
)
case .audioFront: case .audioFront:
audioFrontFields AudioFrontFields(
audioFileURL: $audioFileURL,
back: $back,
existingAudioRef: existingAudioRef
)
} }
} }
@ViewBuilder private var isCreate: Bool {
private var imageOcclusionFields: some View { if case .create = mode { return true }
Section("Bild") { return false
PhotosPicker(selection: $imagePickerItem, matching: .images) {
if occlusionImage == nil {
Label("Bild auswählen", systemImage: "photo")
} else {
Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath")
} }
}
.onChange(of: imagePickerItem) { _, newItem in private var deckId: String {
Task { await loadPickedImage(newItem) } switch mode {
case let .create(deckId): deckId
case let .edit(card): card.deckId
} }
} }
if let image = occlusionImage { private var existingMediaRefs: [String] {
Section("Masken") { if case let .edit(card) = mode { return card.mediaRefs }
MaskEditorView(image: image, regions: $occlusionRegions) return []
}
}
Section("Hinweis (optional)") {
TextField("z.B. Kurz-Erklärung", text: $occlusionNote, axis: .vertical)
.lineLimit(1 ... 3)
}
Section {
if occlusionImage == nil {
Label("Erst Bild wählen", systemImage: "info.circle")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
} else if occlusionRegions.isEmpty {
Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundStyle(CardsTheme.warning)
} else {
Label("\(occlusionRegions.count) Masken → \(occlusionRegions.count) Reviews",
systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(CardsTheme.success)
}
}
}
@ViewBuilder
private var 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 canSubmit: Bool { private var canSubmit: Bool {
@ -276,66 +270,62 @@ struct CardEditorView: View {
case .multipleChoice: case .multipleChoice:
!front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty !front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty
case .imageOcclusion: case .imageOcclusion:
occlusionImageData != nil && !occlusionRegions.isEmpty (occlusionImageData != nil || existingImageRef != nil) && !occlusionRegions.isEmpty
case .audioFront: case .audioFront:
audioFileURL != nil && !back.trimmed.isEmpty (audioFileURL != nil || existingAudioRef != nil) && !back.trimmed.isEmpty
} }
} }
// MARK: - Submit
private func submit() async { private func submit() async {
isSubmitting = true isSubmitting = true
errorMessage = nil errorMessage = nil
defer { isSubmitting = false } defer { isSubmitting = false }
let api = CardsAPI(auth: auth) let api = WordeckAPI(auth: auth)
do { do {
let fields: [String: String] let payload = try await CardEditorPayloadBuilder.build(inputs: payloadInputs, api: api)
var mediaRefs: [String]? = nil let card: Card = switch mode {
switch type { case let .create(deckId):
case .basic, .basicReverse: try await api.createCard(CardCreateBody(
fields = CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed) deckId: deckId,
case .cloze: type: type,
fields = CardFieldsBuilder.cloze(text: clozeText.trimmed) fields: payload.fields,
case .typing: mediaRefs: payload.mediaRefs
fields = CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed) ))
case .multipleChoice: case let .edit(existing):
fields = CardFieldsBuilder.multipleChoice(front: front.trimmed, answer: multipleChoiceAnswer.trimmed) try await api.updateCard(id: existing.id, body: CardUpdateBody(
case .imageOcclusion: fields: payload.fields,
guard let data = occlusionImageData else { return } mediaRefs: payload.mediaRefs
let media = try await api.uploadMedia( ))
data: data,
filename: "occlusion.\(occlusionMimeType.contains("png") ? "png" : "jpg")",
mimeType: occlusionMimeType
)
fields = CardFieldsBuilder.imageOcclusion(
imageRef: media.id,
regions: occlusionRegions,
note: occlusionNote.trimmed.isEmpty ? nil : occlusionNote.trimmed
)
mediaRefs = [media.id]
case .audioFront:
guard let url = audioFileURL else { return }
let didStart = url.startAccessingSecurityScopedResource()
defer { if didStart { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
let media = try await api.uploadMedia(
data: data,
filename: url.lastPathComponent,
mimeType: audioMimeType(for: url)
)
fields = CardFieldsBuilder.audioFront(audioRef: media.id, back: back.trimmed)
mediaRefs = [media.id]
} }
onSaved(card)
let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: mediaRefs)
let card = try await api.createCard(body)
onCreated(card)
dismiss() dismiss()
} catch { } catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
} }
} }
private var payloadInputs: CardEditorPayloadInputs {
CardEditorPayloadInputs(
type: type,
front: front.trimmed,
back: back.trimmed,
clozeText: clozeText.trimmed,
typingAnswer: typingAnswer.trimmed,
multipleChoiceAnswer: multipleChoiceAnswer.trimmed,
occlusionImageData: occlusionImageData,
occlusionMimeType: occlusionMimeType,
occlusionRegions: occlusionRegions,
occlusionNote: occlusionNote.trimmed,
existingImageRef: existingImageRef,
audioFileURL: audioFileURL,
existingAudioRef: existingAudioRef,
existingMediaRefs: existingMediaRefs
)
}
private func label(for type: CardType) -> String { private func label(for type: CardType) -> String {
switch type { switch type {
case .basic: "Einfach (Vorder/Rück)" case .basic: "Einfach (Vorder/Rück)"
@ -349,6 +339,8 @@ struct CardEditorView: View {
} }
} }
// swiftlint:enable type_body_length
private extension String { private extension String {
var trimmed: String { var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines) trimmingCharacters(in: .whitespacesAndNewlines)

View file

@ -0,0 +1,82 @@
import Foundation
import ManaCore
/// Konstanten für `DeckEditorView` Farbpalette, File-Limits.
/// Werte gespiegelt aus `forest`-Theme und Server-Limits in
/// `cards/apps/api/src/routes/decks-from-image.ts`.
enum DeckEditorPresets {
/// 8 Farb-Presets aus dem forest-Theme. Freie Hex-Werte später
/// via Custom-Picker (β-3-extension).
static let colors: [String] = [
"#10803D", // forest primary light
"#1E3A2F", // forest dark
"#D97706", // amber
"#DC2626", // red
"#2563EB", // blue
"#7C3AED", // violet
"#0D9488", // teal
"#737373" // neutral
]
static let maxMediaFiles = 5
static let maxImageBytes = 10 * 1024 * 1024
static let maxPDFBytes = 30 * 1024 * 1024
}
/// Reine Hilfsfunktionen für `DeckEditorView` kein State, keine Bindings.
enum DeckEditorHelpers {
/// Nil zurück wenn String nach Trim leer ist.
static func nonEmpty(_ value: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespaces)
return trimmed.isEmpty ? nil : trimmed
}
/// http:// oder https:// und nicht-leer.
static func isValidURL(_ value: String) -> Bool {
let trimmed = value.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return false }
guard let url = URL(string: trimmed), let scheme = url.scheme else { return false }
return scheme == "http" || scheme == "https"
}
/// Magic-Byte-Check für die häufigsten Image-Formate. Fallback JPEG.
static func inferImageMimeType(from data: Data) -> String {
guard data.count > 4 else { return "image/jpeg" }
let bytes = Array(data.prefix(8))
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" }
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" }
if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" }
if bytes.count >= 4, bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] { return "image/webp" }
return "image/jpeg"
}
/// Dateiendung für ein erkanntes Image-MIME.
static func imageExtension(forMime mime: String) -> String {
switch mime {
case "image/png": "png"
case "image/gif": "gif"
case "image/webp": "webp"
default: "jpg"
}
}
/// AuthError-Server-Codes auf nutzerfreundliche deutsche Texte mappen.
/// Greift für beide AI-Endpoints, fällt sonst auf `errorDescription`.
static func mapAIError(_ error: AuthError) -> String {
if case let .serverError(status, _, message) = error {
switch status {
case 429:
return "Zu viele KI-Anfragen. Bitte eine Minute warten."
case 413:
return message ?? "Datei zu groß."
case 422, 400:
return message ?? "Eingabe ungültig."
case 502:
return message ?? "KI-Server gerade nicht erreichbar."
default:
break
}
}
return error.errorDescription ?? "Unbekannter Fehler."
}
}

View file

@ -1,53 +1,473 @@
import ManaCore import ManaCore
import PhotosUI
import SwiftUI import SwiftUI
/// Deck-Create und Deck-Edit in einer View. `existing == nil` Create- // swiftlint:disable file_length
/// Modus mit "Erstellen"-Button. Sonst Edit-Modus mit "Speichern". // swiftlint:disable type_body_length
/// Deck-Create und Deck-Edit in einer View. Im Create-Modus stehen vier
/// Sub-Modi zur Wahl: manuell (Leer"), AI-Text (Mit KI"), AI-Vision
/// (Aus Bild") und CSV. Edit-Modus zeigt nur das manuelle Formular.
///
/// Web-Vorbild: `cards/apps/web/src/routes/decks/new/+page.svelte`.
/// `type_body_length` ist bewusst übersprungen die 4 Sub-Modi teilen
/// sich State + Toolbar; aufspalten ginge nur über @Binding-Plumbing.
struct DeckEditorView: View { struct DeckEditorView: View {
enum Mode: Sendable { enum Mode {
case create case create
case edit(deckId: String) case edit(deckId: String)
} }
/// Vier Sub-Modi im Create-Sheet.
enum CreateMode: Hashable {
case manual
case aiText
case aiMedia
case csv
}
let mode: Mode let mode: Mode
let onSaved: (Deck) -> Void let onSaved: (Deck) -> Void
@Environment(AuthClient.self) private var auth @Environment(AuthClient.self) private var auth
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
// Manual fields (Edit + Create.manual)
@State private var name: String @State private var name: String
@State private var description: String @State private var description: String
@State private var color: String @State private var color: String
@State private var category: DeckCategory? @State private var category: DeckCategory?
@State private var visibility: DeckVisibility @State private var visibility: DeckVisibility
@State private var isSubmitting = false @State private var archived: Bool
@State private var errorMessage: String?
/// Vorgefüllte Farbpalette aus dem forest-Theme. User können /// Create-mode selector
/// freie Hex-Werte später via Picker setzen (β-3-extension). @State private var createMode: CreateMode = .manual
private static let presetColors: [String] = [
"#10803D", // forest primary light // AI-shared (Text + Media)
"#1E3A2F", // forest dark @State private var aiPrompt: String = ""
"#D97706", // amber @State private var aiCount: Int = 15
"#DC2626", // red @State private var aiLanguage: GenerationLanguage = .de
"#2563EB", // blue @State private var aiUrl: String = ""
"#7C3AED", // violet
"#0D9488", // teal // AI-Media
"#737373", // neutral @State private var aiMediaFiles: [GenerationMediaFile] = []
] @State private var aiPhotoItems: [PhotosPickerItem] = []
@State private var showPDFImporter: Bool = false
// CSV-Import
@State private var csvRows: [CSVRow] = []
@State private var csvDeckName: String = ""
@State private var showCSVImporter: Bool = false
@State private var csvImportProgress: Int = 0
// Submission
@State private var isSubmitting = false
@State private var generationTask: Task<Void, Never>?
@State private var errorMessage: String?
init(mode: Mode, existing: CachedDeck? = nil, onSaved: @escaping (Deck) -> Void) { init(mode: Mode, existing: CachedDeck? = nil, onSaved: @escaping (Deck) -> Void) {
self.mode = mode self.mode = mode
self.onSaved = onSaved self.onSaved = onSaved
_name = State(initialValue: existing?.name ?? "") _name = State(initialValue: existing?.name ?? "")
_description = State(initialValue: existing?.deckDescription ?? "") _description = State(initialValue: existing?.deckDescription ?? "")
_color = State(initialValue: existing?.color ?? Self.presetColors[0]) _color = State(initialValue: existing?.color ?? DeckEditorPresets.colors[0])
_category = State(initialValue: existing?.category) _category = State(initialValue: existing?.category)
_visibility = State(initialValue: DeckVisibility(rawValue: existing?.visibilityRaw ?? "private") ?? .private) _visibility = State(initialValue: DeckVisibility(rawValue: existing?.visibilityRaw ?? "private") ?? .private)
_archived = State(initialValue: existing?.archivedAt != nil)
} }
var body: some View { var body: some View {
ZStack {
Form { Form {
if isCreate {
modePickerSection
}
formSections
errorSection
}
.disabled(isSubmitting)
if isSubmitting, activeMode != .manual {
GenerationOverlay(
message: overlayMessage,
onCancel: { generationTask?.cancel() }
)
}
}
.navigationTitle(navTitle)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.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(WordeckTheme.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
}
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 = WordeckAPI(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: WordeckAPI) 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") { Section("Name") {
TextField("Deck-Name", text: $name) TextField("Deck-Name", text: $name)
.textInputAutocapitalization(.sentences) .textInputAutocapitalization(.sentences)
@ -61,8 +481,10 @@ struct DeckEditorView: View {
Section("Farbe") { Section("Farbe") {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) { HStack(spacing: 10) {
ForEach(Self.presetColors, id: \.self) { hex in ForEach(DeckEditorPresets.colors, id: \.self) { hex in
colorSwatch(hex) ColorSwatchButton(hex: hex, isSelected: color == hex) {
color = hex
}
} }
} }
.padding(.vertical, 4) .padding(.vertical, 4)
@ -87,101 +509,233 @@ struct DeckEditorView: View {
.pickerStyle(.segmented) .pickerStyle(.segmented)
} }
if let errorMessage { if let archived {
Section { Section {
Text(errorMessage) Toggle("Archiviert", isOn: archived)
.font(.footnote) } footer: {
.foregroundStyle(CardsTheme.error) Text("Archivierte Decks erscheinen nicht in der Hauptliste. Bestehende FSRS-Reviews bleiben erhalten.")
}
}
}
.navigationTitle(isCreate ? "Neues Deck" : "Deck bearbeiten")
#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)
} }
} }
} }
}
private var isCreate: Bool { private struct ColorSwatchButton: View {
if case .create = mode { return true } let hex: String
return false let isSelected: Bool
} let onTap: () -> Void
@ViewBuilder var body: some View {
private func colorSwatch(_ hex: String) -> some View {
let isSelected = color == hex
Circle() Circle()
.fill(Color.swatchFromHex(hex)) .fill(Color.swatchFromHex(hex))
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
.overlay( .overlay(
Circle() Circle()
.stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1) .stroke(isSelected ? WordeckTheme.foreground : WordeckTheme.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)
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()
}
} catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
}
}
private func nonEmpty(_ s: String) -> String? {
let trimmed = s.trimmingCharacters(in: .whitespaces)
return trimmed.isEmpty ? nil : trimmed
} }
} }
// MARK: - AI text form
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 }
}
}
} header: {
Text("Quellen")
} footer: {
Text("Max. \(DeckEditorPresets.maxMediaFiles) Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB.")
}
}
@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(WordeckTheme.mutedForeground)
}
Spacer()
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(WordeckTheme.mutedForeground)
}
.buttonStyle(.plain)
.accessibilityLabel("Entfernen")
}
}
@ViewBuilder
private var thumbnail: some View {
if file.isPDF {
ZStack {
WordeckTheme.muted
Image(systemName: "doc.text.fill")
.foregroundStyle(WordeckTheme.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 {
WordeckTheme.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(WordeckTheme.primary)
Text(message)
.font(.headline)
.foregroundStyle(WordeckTheme.foreground)
.multilineTextAlignment(.center)
Text("Das kann eine Weile dauern.")
.font(.caption)
.foregroundStyle(WordeckTheme.mutedForeground)
.multilineTextAlignment(.center)
Button("Abbrechen", action: onCancel)
.buttonStyle(.bordered)
.tint(WordeckTheme.mutedForeground)
.padding(.top, 4)
}
.padding(24)
.frame(maxWidth: 320)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.transition(.opacity)
}
}
// MARK: - Color helper
extension Color { extension Color {
static func swatchFromHex(_ hex: String) -> Color { static func swatchFromHex(_ hex: String) -> Color {
var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines) var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("#") { trimmed = String(trimmed.dropFirst()) } if trimmed.hasPrefix("#") { trimmed = String(trimmed.dropFirst()) }
guard let rgb = UInt32(trimmed, radix: 16) else { guard let rgb = UInt32(trimmed, radix: 16) else {
return CardsTheme.primary return WordeckTheme.primary
} }
let r = Double((rgb >> 16) & 0xFF) / 255.0 let red = Double((rgb >> 16) & 0xFF) / 255.0
let g = Double((rgb >> 8) & 0xFF) / 255.0 let green = Double((rgb >> 8) & 0xFF) / 255.0
let b = Double(rgb & 0xFF) / 255.0 let blue = Double(rgb & 0xFF) / 255.0
return Color(red: r, green: g, blue: b) return Color(red: red, green: green, blue: blue)
} }
} }

View file

@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
#if canImport(UIKit) #if canImport(UIKit)
import UIKit import UIKit
#endif #endif
/// Mask-Editor: Bild anzeigen, mit Drag-Gesten Rechtecke zeichnen, jede /// Mask-Editor: Bild anzeigen, mit Drag-Gesten Rechtecke zeichnen, jede
@ -20,7 +20,7 @@ struct MaskEditorView: View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Tippe und ziehe auf das Bild, um eine Maske zu erstellen.") Text("Tippe und ziehe auf das Bild, um eine Maske zu erstellen.")
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
imageCanvas imageCanvas
.aspectRatio(image.size.width / max(image.size.height, 1), contentMode: .fit) .aspectRatio(image.size.width / max(image.size.height, 1), contentMode: .fit)
@ -30,7 +30,7 @@ struct MaskEditorView: View {
if regions.isEmpty { if regions.isEmpty {
Text("Noch keine Maske") Text("Noch keine Maske")
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} else { } else {
ForEach(regions) { region in ForEach(regions) { region in
maskRow(region: region) maskRow(region: region)
@ -39,7 +39,6 @@ struct MaskEditorView: View {
} }
} }
@ViewBuilder
private var imageCanvas: some View { private var imageCanvas: some View {
GeometryReader { geo in GeometryReader { geo in
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
@ -56,8 +55,8 @@ struct MaskEditorView: View {
if let dragStart, let dragEnd { if let dragStart, let dragEnd {
let rect = normalizedRect(from: dragStart, to: dragEnd) let rect = normalizedRect(from: dragStart, to: dragEnd)
Rectangle() Rectangle()
.stroke(CardsTheme.warning, lineWidth: 2) .stroke(WordeckTheme.warning, lineWidth: 2)
.background(Rectangle().fill(CardsTheme.warning.opacity(0.2))) .background(Rectangle().fill(WordeckTheme.warning.opacity(0.2)))
.frame(width: rect.width, height: rect.height) .frame(width: rect.width, height: rect.height)
.offset(x: rect.minX, y: rect.minY) .offset(x: rect.minX, y: rect.minY)
} }
@ -78,13 +77,13 @@ struct MaskEditorView: View {
private func overlayRect(for region: MaskRegion, in size: CGSize) -> some View { private func overlayRect(for region: MaskRegion, in size: CGSize) -> some View {
Rectangle() Rectangle()
.fill(CardsTheme.primary.opacity(0.6)) .fill(WordeckTheme.primary.opacity(0.6))
.frame(width: region.w * size.width, height: region.h * size.height) .frame(width: region.w * size.width, height: region.h * size.height)
.offset(x: region.x * size.width, y: region.y * size.height) .offset(x: region.x * size.width, y: region.y * size.height)
.overlay( .overlay(
Text(region.label?.isEmpty == false ? region.label! : region.id) Text(region.label?.isEmpty == false ? region.label! : region.id)
.font(.caption2.weight(.bold)) .font(.caption2.weight(.bold))
.foregroundStyle(CardsTheme.primaryForeground) .foregroundStyle(WordeckTheme.primaryForeground)
.padding(2) .padding(2)
.offset(x: region.x * size.width + 2, y: region.y * size.height + 2), .offset(x: region.x * size.width + 2, y: region.y * size.height + 2),
alignment: .topLeading alignment: .topLeading
@ -94,7 +93,7 @@ struct MaskEditorView: View {
private func maskRow(region: MaskRegion) -> some View { private func maskRow(region: MaskRegion) -> some View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "square.dashed") Image(systemName: "square.dashed")
.foregroundStyle(CardsTheme.primary) .foregroundStyle(WordeckTheme.primary)
TextField("Label (optional)", text: Binding( TextField("Label (optional)", text: Binding(
get: { region.label ?? "" }, get: { region.label ?? "" },
set: { newValue in updateLabel(for: region.id, to: newValue) } set: { newValue in updateLabel(for: region.id, to: newValue) }
@ -104,7 +103,7 @@ struct MaskEditorView: View {
regions.removeAll { $0.id == region.id } regions.removeAll { $0.id == region.id }
} label: { } label: {
Image(systemName: "trash") Image(systemName: "trash")
.foregroundStyle(CardsTheme.error) .foregroundStyle(WordeckTheme.error)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }

View file

@ -9,10 +9,10 @@ struct BrowseView: View {
var body: some View { var body: some View {
ZStack { ZStack {
CardsTheme.background.ignoresSafeArea() WordeckTheme.background.ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
filters filters
Divider().background(CardsTheme.border) Divider().background(WordeckTheme.border)
resultsList resultsList
} }
} }
@ -20,8 +20,11 @@ struct BrowseView: View {
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
.searchable(text: $queryText, placement: .navigationBarDrawer(displayMode: .always), .searchable(
prompt: "Decks suchen") text: $queryText,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Decks suchen"
)
.onSubmit(of: .search) { .onSubmit(of: .search) {
store?.browseQuery = queryText store?.browseQuery = queryText
Task { await store?.browse() } Task { await store?.browse() }
@ -62,7 +65,7 @@ struct BrowseView: View {
if store.isLoadingBrowse, store.browseResults.isEmpty { if store.isLoadingBrowse, store.browseResults.isEmpty {
Spacer() Spacer()
ProgressView() ProgressView()
.tint(CardsTheme.primary) .tint(WordeckTheme.primary)
Spacer() Spacer()
} else if store.browseResults.isEmpty { } else if store.browseResults.isEmpty {
ContentUnavailableView( ContentUnavailableView(
@ -70,7 +73,7 @@ struct BrowseView: View {
systemImage: "magnifyingglass", systemImage: "magnifyingglass",
description: Text("Versuche eine andere Suche oder Sortierung.") description: Text("Versuche eine andere Suche oder Sortierung.")
) )
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
} else { } else {
List { List {
ForEach(store.browseResults) { entry in ForEach(store.browseResults) { entry in
@ -101,17 +104,17 @@ struct BrowseRow: View {
HStack { HStack {
Text(entry.title) Text(entry.title)
.font(.headline) .font(.headline)
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
if entry.isFeatured { if entry.isFeatured {
Image(systemName: "star.fill") Image(systemName: "star.fill")
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.warning) .foregroundStyle(WordeckTheme.warning)
} }
} }
if let description = entry.description, !description.isEmpty { if let description = entry.description, !description.isEmpty {
Text(description) Text(description)
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
.lineLimit(2) .lineLimit(2)
} }
HStack(spacing: 12) { HStack(spacing: 12) {
@ -119,23 +122,23 @@ struct BrowseRow: View {
Label("\(entry.starCount)", systemImage: "star") Label("\(entry.starCount)", systemImage: "star")
if entry.isPaid { if entry.isPaid {
Label("\(entry.priceCredits)", systemImage: "creditcard") Label("\(entry.priceCredits)", systemImage: "creditcard")
.foregroundStyle(CardsTheme.primary) .foregroundStyle(WordeckTheme.primary)
} }
if let language = entry.language { if let language = entry.language {
Text(language.uppercased()) Text(language.uppercased())
.font(.caption2.weight(.semibold)) .font(.caption2.weight(.semibold))
.padding(.horizontal, 4) .padding(.horizontal, 4)
.padding(.vertical, 1) .padding(.vertical, 1)
.background(CardsTheme.muted, in: Capsule()) .background(WordeckTheme.muted, in: Capsule())
} }
} }
.font(.caption2) .font(.caption2)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} }
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.footnote) .font(.footnote)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} }
.padding(.vertical, 8) .padding(.vertical, 8)
} }

View file

@ -16,10 +16,13 @@ struct ExploreView: View {
var body: some View { var body: some View {
NavigationStack(path: $path) { NavigationStack(path: $path) {
ZStack { ZStack {
CardsTheme.background.ignoresSafeArea() WordeckTheme.background.ignoresSafeArea()
content content
} }
.navigationTitle("Entdecken") .navigationTitle("Entdecken")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.navigationDestination(for: MarketplaceRoute.self) { route in .navigationDestination(for: MarketplaceRoute.self) { route in
switch route { switch route {
case .browse: case .browse:
@ -53,22 +56,22 @@ struct ExploreView: View {
if let store { if let store {
if store.isLoadingExplore, store.featured.isEmpty, store.trending.isEmpty { if store.isLoadingExplore, store.featured.isEmpty, store.trending.isEmpty {
ProgressView() ProgressView()
.tint(CardsTheme.primary) .tint(WordeckTheme.primary)
} else if let message = store.errorMessage, store.featured.isEmpty { } else if let message = store.errorMessage, store.featured.isEmpty {
ContentUnavailableView( ContentUnavailableView(
"Marketplace nicht erreichbar", "Marketplace nicht erreichbar",
systemImage: "wifi.exclamationmark", systemImage: "wifi.exclamationmark",
description: Text(message) description: Text(message)
) )
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
} else { } else {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
if !store.featured.isEmpty { if !store.featured.isEmpty {
section(title: "Vorgestellt", items: store.featured) section(title: "Vorgestellt", icon: "star.fill", items: store.featured)
} }
if !store.trending.isEmpty { if !store.trending.isEmpty {
section(title: "Im Trend", items: store.trending) section(title: "Im Trend", icon: "flame.fill", items: store.trending)
} }
NavigationLink(value: MarketplaceRoute.browse) { NavigationLink(value: MarketplaceRoute.browse) {
@ -79,40 +82,51 @@ struct ExploreView: View {
.font(.footnote) .font(.footnote)
} }
.padding() .padding()
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) .background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.stroke(CardsTheme.border, lineWidth: 1) .stroke(WordeckTheme.border, lineWidth: 1)
) )
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.horizontal, 16) .padding(.horizontal, 20)
} }
.padding(.vertical, 16) .padding(.vertical, 12)
} }
} }
} }
} }
private func section(title: String, items: [PublicDeckEntry]) -> some View { private func section(title: String, icon: String, items: [PublicDeckEntry]) -> some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Image(systemName: icon)
.foregroundStyle(WordeckTheme.primary)
Text(title) Text(title)
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
.padding(.horizontal, 16) Text("\(items.count)")
.font(.subheadline)
.foregroundStyle(WordeckTheme.mutedForeground)
}
.padding(.horizontal, 20)
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) { HStack(alignment: .top, spacing: 16) {
ForEach(items) { item in ForEach(items) { item in
NavigationLink(value: MarketplaceRoute.publicDeck(slug: item.slug)) { NavigationLink(value: MarketplaceRoute.publicDeck(slug: item.slug)) {
PublicDeckCard(entry: item) PublicDeckCard(entry: item)
.frame(width: 240)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 20)
.padding(.bottom, 12)
.scrollTargetLayout()
} }
.scrollTargetBehavior(.viewAligned)
} }
} }
} }
@ -123,58 +137,57 @@ enum MarketplaceRoute: Hashable {
case publicDeck(slug: String) case publicDeck(slug: String)
} }
/// Public-Deck-Karten-Tile in Featured/Trending-Carousels und Browse-Grid. /// Tile für Marketplace-Decks im Explore-Tab. Nutzt `DeckCoverTile`
/// als Basis (selber Look + Größe wie `DeckStackTile` auf der Decks-
/// Seite). Footer: Karten-Count, Star-Count, Credits, Owner-Badge.
struct PublicDeckCard: View { struct PublicDeckCard: View {
let entry: PublicDeckEntry let entry: PublicDeckEntry
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { DeckCoverTile(
HStack { title: entry.title,
Text(entry.title) description: entry.description,
.font(.headline) category: parsedCategory,
.foregroundStyle(CardsTheme.foreground) seed: entry.slug,
.lineLimit(2) colorAccentHex: nil,
Spacer() isFeatured: entry.isFeatured
if entry.isFeatured { ) {
Image(systemName: "star.fill") footerContent
.font(.caption)
.foregroundStyle(CardsTheme.warning)
} }
} }
if let description = entry.description, !description.isEmpty {
Text(description)
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
.lineLimit(2)
}
HStack(spacing: 12) {
Label("\(entry.cardCount)", systemImage: "rectangle.stack")
Label("\(entry.starCount)", systemImage: "star")
if entry.isPaid {
Label("\(entry.priceCredits) Credits", systemImage: "creditcard")
.foregroundStyle(CardsTheme.primary)
}
}
.font(.caption2)
.foregroundStyle(CardsTheme.mutedForeground)
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")
.font(.caption2)
.foregroundStyle(WordeckTheme.mutedForeground)
Label("\(entry.starCount)", systemImage: "star.fill")
.font(.caption2)
.foregroundStyle(WordeckTheme.warning)
if entry.isPaid {
Label("\(entry.priceCredits)", systemImage: "creditcard")
.font(.caption2.weight(.semibold))
.foregroundStyle(WordeckTheme.primary)
}
Spacer()
}
HStack(spacing: 4) { HStack(spacing: 4) {
Text(entry.owner.displayName) Text(entry.owner.displayName)
.font(.caption2) .font(.caption2)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
.lineLimit(1)
if entry.owner.verifiedMana { if entry.owner.verifiedMana {
Image(systemName: "checkmark.seal.fill") Image(systemName: "checkmark.seal.fill")
.font(.caption2) .font(.caption2)
.foregroundStyle(CardsTheme.primary) .foregroundStyle(WordeckTheme.primary)
} }
} }
} }
.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 Wordeck-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(WordeckTheme.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: wordeck.com/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(WordeckTheme.mutedForeground)
}
}
private var publishProgressOverlay: some View {
ZStack {
Color.black.opacity(0.55).ignoresSafeArea()
VStack(spacing: 12) {
ProgressView().controlSize(.large).tint(WordeckTheme.primary)
Text("Wird veröffentlicht …")
.font(.headline)
.foregroundStyle(WordeckTheme.foreground)
Text("AI-Moderation läuft — kann ein paar Sekunden dauern.")
.font(.caption)
.foregroundStyle(WordeckTheme.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 = WordeckAPI(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 = WordeckAPI(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: WordeckAPI) 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: WordeckAPI) 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

@ -18,10 +18,10 @@ final class MarketplaceStore {
var browseSort: MarketplaceSort = .recent var browseSort: MarketplaceSort = .recent
var browseLanguage: String? var browseLanguage: String?
private let api: CardsAPI private let api: WordeckAPI
init(auth: AuthClient) { init(auth: AuthClient) {
api = CardsAPI(auth: auth) api = WordeckAPI(auth: auth)
} }
func loadExplore() async { func loadExplore() async {
@ -33,8 +33,9 @@ final class MarketplaceStore {
featured = res.featured featured = res.featured
trending = res.trending trending = res.trending
} catch { } catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
Log.api.error("Explore failed: \(self.errorMessage ?? "", privacy: .public)") errorMessage = message
Log.api.error("Explore failed: \(message, privacy: .public)")
} }
} }

View file

@ -1,13 +1,18 @@
import ManaAuthUI
import ManaCore import ManaCore
import SwiftData import SwiftData
import SwiftUI import SwiftUI
// swiftlint:disable type_body_length
/// Detail-View für ein Public-Deck. Subscribe-Button löst Auto-Fork /// Detail-View für ein Public-Deck. Subscribe-Button löst Auto-Fork
/// serverseitig aus und navigiert anschließend zur eigenen Deck-Detail. /// serverseitig aus und navigiert anschließend zur eigenen Deck-Detail.
/// Toolbar-Menu (") hostet Report + Block-Author (App-Review-Pflicht).
struct PublicDeckView: View { struct PublicDeckView: View {
let slug: String let slug: String
@Environment(AuthClient.self) private var auth @Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@Environment(\.modelContext) private var context @Environment(\.modelContext) private var context
@State private var detail: PublicDeckDetail? @State private var detail: PublicDeckDetail?
@State private var isLoading = false @State private var isLoading = false
@ -15,37 +20,102 @@ struct PublicDeckView: View {
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var subscribed: SubscribeResponse? @State private var subscribed: SubscribeResponse?
// Moderation-State
@State private var showReportSheet = false
@State private var showBlockConfirm = false
@State private var moderationToast: String?
var body: some View { var body: some View {
ZStack { ZStack {
CardsTheme.background.ignoresSafeArea() WordeckTheme.background.ignoresSafeArea()
content content
} }
.navigationTitle(detail?.deck.title ?? "Deck") .navigationTitle(detail?.deck.title ?? "Deck")
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
.toolbar {
if detail != nil {
ToolbarItem(placement: .topBarTrailing) {
moderationMenu
}
}
}
.task(id: slug) { .task(id: slug) {
await load() await load()
} }
.sheet(isPresented: $showReportSheet) {
NavigationStack {
ReportDeckSheet(slug: slug) { message in
moderationToast = message
}
}
}
.confirmationDialog(
"Author blockieren?",
isPresented: $showBlockConfirm,
titleVisibility: .visible,
presenting: detail?.owner
) { owner in
Button("\(owner.displayName) blockieren", role: .destructive) {
Task { await blockAuthor(slug: owner.slug, name: owner.displayName) }
}
Button("Abbrechen", role: .cancel) {}
} message: { _ in
Text("Decks dieses Authors erscheinen für dich nicht mehr im Marketplace.")
}
.overlay(alignment: .top) {
if let toast = moderationToast {
ToastBanner(text: toast)
.padding(.top, 8)
.task {
try? await Task.sleep(for: .seconds(3))
moderationToast = nil
}
}
}
}
private var moderationMenu: some View {
Menu {
Button {
authGate.require(reason: "marketplace-report") {
showReportSheet = true
}
} label: {
Label("Deck melden …", systemImage: "flag")
}
if let owner = detail?.owner {
Button(role: .destructive) {
authGate.require(reason: "marketplace-block") {
showBlockConfirm = true
}
} label: {
Label("\(owner.displayName) blockieren", systemImage: "hand.raised")
}
}
} label: {
Image(systemName: "ellipsis.circle")
}
} }
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
if isLoading, detail == nil { if isLoading, detail == nil {
ProgressView() ProgressView()
.tint(CardsTheme.primary) .tint(WordeckTheme.primary)
} else if let detail { } else if let detail {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
header(detail: detail) header(detail: detail)
Divider().background(CardsTheme.border) Divider().background(WordeckTheme.border)
metadata(detail: detail) metadata(detail: detail)
Divider().background(CardsTheme.border) Divider().background(WordeckTheme.border)
subscribeSection(detail: detail) subscribeSection(detail: detail)
if let errorMessage { if let errorMessage {
Text(errorMessage) Text(errorMessage)
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.error) .foregroundStyle(WordeckTheme.error)
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
} }
@ -57,7 +127,7 @@ struct PublicDeckView: View {
systemImage: "questionmark.folder", systemImage: "questionmark.folder",
description: Text(errorMessage) description: Text(errorMessage)
) )
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
} }
} }
@ -66,15 +136,15 @@ struct PublicDeckView: View {
HStack { HStack {
Text(detail.deck.title) Text(detail.deck.title)
.font(.title.bold()) .font(.title.bold())
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
if detail.deck.isFeatured { if detail.deck.isFeatured {
Image(systemName: "star.fill") Image(systemName: "star.fill")
.foregroundStyle(CardsTheme.warning) .foregroundStyle(WordeckTheme.warning)
} }
} }
if let description = detail.deck.description, !description.isEmpty { if let description = detail.deck.description, !description.isEmpty {
Text(description) Text(description)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
@ -85,13 +155,13 @@ struct PublicDeckView: View {
if let owner = detail.owner { if let owner = detail.owner {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "person.crop.circle") Image(systemName: "person.crop.circle")
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
Text(owner.displayName) Text(owner.displayName)
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
if owner.verifiedMana { if owner.verifiedMana {
Image(systemName: "checkmark.seal.fill") Image(systemName: "checkmark.seal.fill")
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.primary) .foregroundStyle(WordeckTheme.primary)
} }
} }
.font(.subheadline) .font(.subheadline)
@ -107,53 +177,54 @@ struct PublicDeckView: View {
} }
} }
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
if let changelog = detail.latestVersion?.changelog, !changelog.isEmpty { if let changelog = detail.latestVersion?.changelog, !changelog.isEmpty {
Text("Changelog") Text("Changelog")
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
.padding(.top, 8) .padding(.top, 8)
Text(changelog) Text(changelog)
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
@ViewBuilder
private func subscribeSection(detail: PublicDeckDetail) -> some View { private func subscribeSection(detail: PublicDeckDetail) -> some View {
VStack(spacing: 12) { VStack(spacing: 12) {
if let subscribed { if let subscribed {
Label("Abonniert — dein Fork ist in deiner Bibliothek", systemImage: "checkmark.circle.fill") Label("Abonniert — dein Fork ist in deiner Bibliothek", systemImage: "checkmark.circle.fill")
.foregroundStyle(CardsTheme.success) .foregroundStyle(WordeckTheme.success)
.padding() .padding()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(CardsTheme.success.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) .background(WordeckTheme.success.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
NavigationLink(value: subscribed.privateDeckId) { NavigationLink(value: subscribed.privateDeckId) {
Label("Zum eigenen Deck", systemImage: "arrow.right.circle") Label("Zum eigenen Deck", systemImage: "arrow.right.circle")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 10) .padding(.vertical, 10)
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) .background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.primaryForeground) .foregroundStyle(WordeckTheme.primaryForeground)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} else if detail.deck.isTakedown { } else if detail.deck.isTakedown {
Label("Dieses Deck wurde entfernt", systemImage: "exclamationmark.triangle") Label("Dieses Deck wurde entfernt", systemImage: "exclamationmark.triangle")
.foregroundStyle(CardsTheme.error) .foregroundStyle(WordeckTheme.error)
} else if detail.deck.latestVersionId == nil { } else if detail.deck.latestVersionId == nil {
Label("Noch keine Version veröffentlicht", systemImage: "clock") Label("Noch keine Version veröffentlicht", systemImage: "clock")
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} else { } else {
Button { Button {
authGate.require(reason: "marketplace-subscribe") {
Task { await subscribe(detail: detail) } Task { await subscribe(detail: detail) }
}
} label: { } label: {
HStack { HStack {
if isSubscribing { if isSubscribing {
ProgressView() ProgressView()
.controlSize(.small) .controlSize(.small)
.tint(CardsTheme.primaryForeground) .tint(WordeckTheme.primaryForeground)
} }
Text(detail.deck.priceCredits > 0 Text(detail.deck.priceCredits > 0
? "Abonnieren (\(detail.deck.priceCredits) Credits)" ? "Abonnieren (\(detail.deck.priceCredits) Credits)"
@ -162,8 +233,8 @@ struct PublicDeckView: View {
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 14) .padding(.vertical, 14)
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) .background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.primaryForeground) .foregroundStyle(WordeckTheme.primaryForeground)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(isSubscribing) .disabled(isSubscribing)
@ -175,7 +246,7 @@ struct PublicDeckView: View {
private func load() async { private func load() async {
isLoading = true isLoading = true
defer { isLoading = false } defer { isLoading = false }
let api = CardsAPI(auth: auth) let api = WordeckAPI(auth: auth)
do { do {
detail = try await api.publicDeck(slug: slug) detail = try await api.publicDeck(slug: slug)
} catch { } catch {
@ -183,11 +254,21 @@ struct PublicDeckView: View {
} }
} }
private func subscribe(detail: PublicDeckDetail) async { private func blockAuthor(slug: String, name: String) async {
let api = WordeckAPI(auth: auth)
do {
try await api.blockAuthor(slug: slug)
moderationToast = "\(name) blockiert."
} catch {
moderationToast = "Blockieren fehlgeschlagen: \(error.localizedDescription)"
}
}
private func subscribe(detail _: PublicDeckDetail) async {
isSubscribing = true isSubscribing = true
errorMessage = nil errorMessage = nil
defer { isSubscribing = false } defer { isSubscribing = false }
let api = CardsAPI(auth: auth) let api = WordeckAPI(auth: auth)
do { do {
let response = try await api.subscribe(slug: slug) let response = try await api.subscribe(slug: slug)
subscribed = response subscribed = response
@ -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(WordeckTheme.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 = WordeckAPI(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(WordeckTheme.foreground)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.regularMaterial, in: Capsule())
.overlay(Capsule().stroke(WordeckTheme.border, lineWidth: 0.5))
.padding(.horizontal, 16)
.transition(.move(edge: .top).combined(with: .opacity))
}
}

View file

@ -20,17 +20,17 @@ struct AudioPlayerButton: View {
? "speaker.slash.fill" ? "speaker.slash.fill"
: (isPlaying ? "pause.circle.fill" : "play.circle.fill")) : (isPlaying ? "pause.circle.fill" : "play.circle.fill"))
.font(.system(size: 48)) .font(.system(size: 48))
.foregroundStyle(failed ? CardsTheme.error : CardsTheme.primary) .foregroundStyle(failed ? WordeckTheme.error : WordeckTheme.primary)
Text(failed ? "Audio nicht verfügbar" : (isPlaying ? "Wiedergabe läuft" : "Anhören")) Text(failed ? "Audio nicht verfügbar" : (isPlaying ? "Wiedergabe läuft" : "Anhören"))
.font(.headline) .font(.headline)
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(20) .padding(20)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 12)) .background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 12))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.stroke(CardsTheme.border, lineWidth: 1) .stroke(WordeckTheme.border, lineWidth: 1)
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@ -45,7 +45,9 @@ struct AudioPlayerButton: View {
} }
private func load() async { private func load() async {
guard let cache = mediaCache else { failed = true; return } guard let cache = mediaCache else { failed = true
return
}
do { do {
let data = try await cache.data(for: mediaId) let data = try await cache.data(for: mediaId)
#if canImport(UIKit) #if canImport(UIKit)

View file

@ -1,12 +1,12 @@
import SwiftUI import SwiftUI
#if canImport(UIKit) #if canImport(UIKit)
import UIKit import UIKit
#elseif canImport(AppKit) #elseif canImport(AppKit)
import AppKit import AppKit
#endif #endif
/// Lädt ein authentifiziertes Image vom Cardecky-Media-Endpoint und /// Lädt ein authentifiziertes Image vom Wordeck-Media-Endpoint und
/// rendert es. Streamt erst beim ersten Mal, danach aus dem /// rendert es. Streamt erst beim ersten Mal, danach aus dem
/// MediaCache (LRU 200 MB). /// MediaCache (LRU 200 MB).
struct RemoteImage: View { struct RemoteImage: View {
@ -28,10 +28,10 @@ struct RemoteImage: View {
imageView(image) imageView(image)
} else if failed { } else if failed {
ContentUnavailableView("Bild konnte nicht geladen werden", systemImage: "photo.badge.exclamationmark") ContentUnavailableView("Bild konnte nicht geladen werden", systemImage: "photo.badge.exclamationmark")
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} else { } else {
ProgressView() ProgressView()
.tint(CardsTheme.primary) .tint(WordeckTheme.primary)
} }
} }
.task(id: mediaId) { .task(id: mediaId) {
@ -49,7 +49,9 @@ struct RemoteImage: View {
} }
private func load() async { private func load() async {
guard let cache = mediaCache else { failed = true; return } guard let cache = mediaCache else { failed = true
return
}
do { do {
let data = try await cache.data(for: mediaId) let data = try await cache.data(for: mediaId)
if let img = PlatformImage(data: data) { if let img = PlatformImage(data: data) {
@ -64,7 +66,7 @@ struct RemoteImage: View {
} }
#if canImport(UIKit) #if canImport(UIKit)
typealias PlatformImage = UIImage typealias PlatformImage = UIImage
#elseif canImport(AppKit) #elseif canImport(AppKit)
typealias PlatformImage = NSImage typealias PlatformImage = NSImage
#endif #endif

View file

@ -0,0 +1,89 @@
import ManaCore
import SwiftUI
/// Liste der vom User blockierten Marketplace-Authors. Entblock-Action
/// per Swipe analog zur iOS-Mail-Inbox.
///
/// App-Store-Guideline 5.1.1(v) verlangt: Block-Mechanismus für UGC
/// muss verwaltbar sein. Diese View ist der Verwaltungs-Endpunkt.
struct BlockedAuthorsView: View {
@Environment(AuthClient.self) private var auth
@State private var blocks: [BlockEntry] = []
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
ZStack {
WordeckTheme.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(WordeckTheme.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(WordeckTheme.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(WordeckTheme.mutedForeground)
}
.swipeActions {
Button("Entblocken") {
Task { await unblock(block) }
}
.tint(WordeckTheme.primary)
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundStyle(WordeckTheme.error)
.padding(.horizontal, 16)
}
}
}
private func load() async {
isLoading = true
defer { isLoading = false }
let api = WordeckAPI(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 = WordeckAPI(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,15 +40,25 @@ struct SettingsView: View {
} }
if notifications.authorization == .denied { if notifications.authorization == .denied {
Label("Benachrichtigungen sind in den iOS-Einstellungen blockiert.", Label(
systemImage: "exclamationmark.circle") "Benachrichtigungen sind in den iOS-Einstellungen blockiert.",
systemImage: "exclamationmark.circle"
)
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.warning) .foregroundStyle(WordeckTheme.warning)
}
}
Section("Marketplace") {
NavigationLink {
BlockedAuthorsView()
} label: {
Label("Blockierte Authors", systemImage: "hand.raised")
} }
} }
Section("Über") { Section("Über") {
LabeledContent("Server", value: "cardecky-api.mana.how") LabeledContent("Server", value: "api.wordeck.com")
LabeledContent("Auth", value: "auth.mana.how") LabeledContent("Auth", value: "auth.mana.how")
} }
} }

View file

@ -28,25 +28,26 @@ struct CardRenderer: View {
imageOcclusionView imageOcclusionView
case .audioFront: case .audioFront:
audioFrontView audioFrontView
case .typing, .multipleChoice: case .multipleChoice:
placeholderView MultipleChoiceCardView(card: card, isFlipped: isFlipped)
case .typing:
TypingCardView(card: card, isFlipped: isFlipped)
} }
} }
.padding(24) .padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
@ViewBuilder
private func basicView(front frontKey: String, back backKey: String) -> some View { private func basicView(front frontKey: String, back backKey: String) -> some View {
VStack(spacing: 16) { VStack(spacing: 16) {
text(card.fields[frontKey] ?? "") text(card.fields[frontKey] ?? "")
.font(.title2) .font(.title2)
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
if isFlipped { if isFlipped {
Divider().background(CardsTheme.border) Divider().background(WordeckTheme.border)
text(card.fields[backKey] ?? "") text(card.fields[backKey] ?? "")
.font(.title3) .font(.title3)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} }
} }
} }
@ -61,7 +62,7 @@ struct CardRenderer: View {
VStack(spacing: 12) { VStack(spacing: 12) {
text(rendered) text(rendered)
.font(.title3) .font(.title3)
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
} }
} }
@ -83,7 +84,7 @@ struct CardRenderer: View {
// Back: alle Masken transparent (Bild komplett sichtbar). // Back: alle Masken transparent (Bild komplett sichtbar).
if !isFlipped, isActive { if !isFlipped, isActive {
Rectangle() Rectangle()
.fill(CardsTheme.primary.opacity(0.92)) .fill(WordeckTheme.primary.opacity(0.92))
.frame( .frame(
width: region.w * geo.size.width, width: region.w * geo.size.width,
height: region.h * geo.size.height height: region.h * geo.size.height
@ -92,7 +93,7 @@ struct CardRenderer: View {
.overlay( .overlay(
Text(region.label?.isEmpty == false ? region.label! : "?") Text(region.label?.isEmpty == false ? region.label! : "?")
.font(.caption.weight(.bold)) .font(.caption.weight(.bold))
.foregroundStyle(CardsTheme.primaryForeground) .foregroundStyle(WordeckTheme.primaryForeground)
.offset(x: region.x * geo.size.width, y: region.y * geo.size.height), .offset(x: region.x * geo.size.width, y: region.y * geo.size.height),
alignment: .topLeading alignment: .topLeading
) )
@ -105,12 +106,12 @@ struct CardRenderer: View {
if isFlipped, let label = activeRegion?.label, !label.isEmpty { if isFlipped, let label = activeRegion?.label, !label.isEmpty {
Text(label) Text(label)
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
.foregroundStyle(CardsTheme.primary) .foregroundStyle(WordeckTheme.primary)
} }
if let note = card.fields["note"], !note.isEmpty { if let note = card.fields["note"], !note.isEmpty {
Text(note) Text(note)
.font(.caption) .font(.caption)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} }
} }
} }
@ -121,24 +122,23 @@ struct CardRenderer: View {
VStack(spacing: 16) { VStack(spacing: 16) {
AudioPlayerButton(mediaId: audioRef) AudioPlayerButton(mediaId: audioRef)
if isFlipped { if isFlipped {
Divider().background(CardsTheme.border) Divider().background(WordeckTheme.border)
text(card.fields["back"] ?? "") text(card.fields["back"] ?? "")
.font(.title3) .font(.title3)
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
} }
} }
} }
@ViewBuilder
private var placeholderView: some View { private var placeholderView: some View {
VStack(spacing: 8) { VStack(spacing: 8) {
Image(systemName: "questionmark.square.dashed") Image(systemName: "questionmark.square.dashed")
.font(.largeTitle) .font(.largeTitle)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
Text("Card-Type »\(card.type.rawValue)« kommt in einer späteren Phase") Text("Card-Type »\(card.type.rawValue)« kommt in einer späteren Phase")
.font(.caption) .font(.caption)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} }
} }

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(WordeckTheme.foreground)
switch phase {
case .loading:
ProgressView()
.tint(WordeckTheme.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(WordeckTheme.mutedForeground)
}
case .failed:
Text("Distractors konnten nicht geladen werden.")
.font(.caption)
.foregroundStyle(WordeckTheme.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(WordeckTheme.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(WordeckTheme.success)
} else if isSelected {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(WordeckTheme.error)
} else {
Image(systemName: "circle")
.foregroundStyle(WordeckTheme.mutedForeground.opacity(0.4))
}
} else if isSelected {
Image(systemName: "largecircle.fill.circle")
.foregroundStyle(WordeckTheme.primary)
} else {
Image(systemName: "circle")
.foregroundStyle(WordeckTheme.mutedForeground.opacity(0.4))
}
}
private func background(isCorrect: Bool, isSelected: Bool) -> Color {
if isFlipped {
if isCorrect { return WordeckTheme.success.opacity(0.12) }
if isSelected { return WordeckTheme.error.opacity(0.10) }
return WordeckTheme.surfaceHover
}
return isSelected
? WordeckTheme.primary.opacity(0.10)
: WordeckTheme.surface
}
private func border(isCorrect: Bool, isSelected: Bool) -> Color {
if isFlipped {
if isCorrect { return WordeckTheme.success.opacity(0.55) }
if isSelected { return WordeckTheme.error.opacity(0.55) }
return WordeckTheme.border
}
return isSelected ? WordeckTheme.primary.opacity(0.5) : WordeckTheme.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(WordeckTheme.border)
Text(card.fields["answer"] ?? "")
.font(.title3)
.foregroundStyle(WordeckTheme.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 = WordeckAPI(auth: auth)
let answer = card.fields["answer"] ?? ""
var distractors: [String] = []
// Erst answer-Feld versuchen, dann back-Feld als Fallback
// (Decks mit basic/basic-reverse-Karten daneben).
for field in ["answer", "back"] {
if distractors.count >= 3 { break }
if let result = try? await api.distractors(
deckId: card.deckId,
cardId: card.id,
field: field,
count: 3
) {
let filtered = result.filter { $0 != answer && !distractors.contains($0) }
distractors.append(contentsOf: filtered)
}
}
// Fallback aus statischem distractor_pool-Field (Web-Pattern)
if distractors.count < 3, let pool = card.fields["distractor_pool"] {
let poolItems = pool
.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty && $0 != answer && !distractors.contains($0) }
distractors.append(contentsOf: poolItems)
}
if distractors.isEmpty {
phase = .tooFew
return
}
let finalDistractors = Array(distractors.prefix(3))
options = ([answer] + finalDistractors).shuffled()
phase = .ready
}
}

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import SwiftData
import SwiftUI import SwiftUI
#if canImport(UIKit) #if canImport(UIKit)
import UIKit import UIKit
#endif #endif
/// Vollbild-Study-View. Wird per Navigation aus DeckListView geöffnet. /// Vollbild-Study-View. Wird per Navigation aus DeckListView geöffnet.
@ -18,7 +18,7 @@ struct StudySessionView: View {
var body: some View { var body: some View {
ZStack { ZStack {
CardsTheme.background.ignoresSafeArea() WordeckTheme.background.ignoresSafeArea()
content content
} }
.navigationTitle(deckName) .navigationTitle(deckName)
@ -30,7 +30,7 @@ struct StudySessionView: View {
if let session, case .studying = session.phase { if let session, case .studying = session.phase {
Text("\(session.remaining)") Text("\(session.remaining)")
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
.accessibilityLabel("\(session.remaining) Karten übrig") .accessibilityLabel("\(session.remaining) Karten übrig")
} }
} }
@ -50,7 +50,7 @@ struct StudySessionView: View {
switch session.phase { switch session.phase {
case .loading: case .loading:
ProgressView("Karten werden geladen …") ProgressView("Karten werden geladen …")
.tint(CardsTheme.primary) .tint(WordeckTheme.primary)
case .studying: case .studying:
studyingView(session: session) studyingView(session: session)
case .finished: case .finished:
@ -60,7 +60,7 @@ struct StudySessionView: View {
} }
} else { } else {
ProgressView() ProgressView()
.tint(CardsTheme.primary) .tint(WordeckTheme.primary)
} }
} }
@ -73,11 +73,24 @@ struct StudySessionView: View {
session.flip() session.flip()
} }
keyboardShortcuts(session: session) keyboardShortcuts(session: session)
bottomBar(session: session)
}
}
.padding(.bottom, 20)
.animation(.easeInOut(duration: 0.2), value: session.isFlipped)
.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 { if session.isFlipped {
RatingBar { rating in RatingBar { rating in
Task { await session.grade(rating) } Task { await session.grade(rating) }
} }
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.opacity)
} else { } else {
Button { Button {
flipHaptic() flipHaptic()
@ -87,33 +100,26 @@ struct StudySessionView: View {
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 14) .padding(.vertical, 14)
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) .background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.primaryForeground) .foregroundStyle(WordeckTheme.primaryForeground)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.horizontal, 16) .padding(.horizontal, 16)
.transition(.opacity)
} }
} }
} .frame(height: 52)
.padding(.bottom, 20)
.animation(.easeInOut(duration: 0.2), value: session.isFlipped)
.animation(.easeInOut(duration: 0.2), value: session.currentIndex)
} }
private func cardSurface(due: DueReview, isFlipped: Bool) -> some View { private func cardSurface(due: DueReview, isFlipped: Bool) -> some View {
RoundedRectangle(cornerRadius: 16) CardSurface(size: .hero, elevation: .raised) {
.fill(CardsTheme.surface)
.overlay(
CardRenderer( CardRenderer(
card: due.card, card: due.card,
subIndex: due.review.subIndex, subIndex: due.review.subIndex,
isFlipped: isFlipped isFlipped: isFlipped
) )
) .frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay( }
RoundedRectangle(cornerRadius: 16)
.stroke(CardsTheme.border, lineWidth: 1)
)
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 12) .padding(.top, 12)
} }
@ -122,14 +128,14 @@ struct StudySessionView: View {
VStack(spacing: 16) { VStack(spacing: 16) {
Image(systemName: "checkmark.seal.fill") Image(systemName: "checkmark.seal.fill")
.font(.system(size: 64)) .font(.system(size: 64))
.foregroundStyle(CardsTheme.success) .foregroundStyle(WordeckTheme.success)
Text(session.totalGraded == 0 ? "Keine Karten fällig" : "Fertig!") Text(session.totalGraded == 0 ? "Keine Karten fällig" : "Fertig!")
.font(.title.bold()) .font(.title.bold())
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
if session.totalGraded > 0 { if session.totalGraded > 0 {
Text("\(session.totalGraded) Karten gelernt") Text("\(session.totalGraded) Karten gelernt")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
} }
Button("Zurück") { dismiss() } Button("Zurück") { dismiss() }
.padding(.top, 24) .padding(.top, 24)
@ -141,14 +147,14 @@ struct StudySessionView: View {
VStack(spacing: 16) { VStack(spacing: 16) {
Image(systemName: "wifi.exclamationmark") Image(systemName: "wifi.exclamationmark")
.font(.system(size: 48)) .font(.system(size: 48))
.foregroundStyle(CardsTheme.error) .foregroundStyle(WordeckTheme.error)
Text("Karten konnten nicht geladen werden") Text("Karten konnten nicht geladen werden")
.font(.headline) .font(.headline)
.foregroundStyle(CardsTheme.foreground) .foregroundStyle(WordeckTheme.foreground)
Text(message) Text(message)
.font(.caption) .font(.caption)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(CardsTheme.mutedForeground) .foregroundStyle(WordeckTheme.mutedForeground)
.padding(.horizontal, 32) .padding(.horizontal, 32)
Button("Erneut versuchen") { Button("Erneut versuchen") {
Task { await session.start() } Task { await session.start() }
@ -160,7 +166,6 @@ struct StudySessionView: View {
/// Unsichtbare Buttons mit Keyboard-Shortcuts. Funktionieren auf /// Unsichtbare Buttons mit Keyboard-Shortcuts. Funktionieren auf
/// iPad (Magic Keyboard) und macOS. Space = flip, 1-4 = Rating. /// iPad (Magic Keyboard) und macOS. Space = flip, 1-4 = Rating.
@ViewBuilder
private func keyboardShortcuts(session: StudySession) -> some View { private func keyboardShortcuts(session: StudySession) -> some View {
Group { Group {
Button("Flip") { Button("Flip") {

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(WordeckTheme.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(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(inputFocused ? WordeckTheme.primary : WordeckTheme.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(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.foregroundStyle(WordeckTheme.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(WordeckTheme.mutedForeground)
.lineLimit(1)
}
Spacer(minLength: 0)
}
}
Divider().background(WordeckTheme.border)
Text(answer)
.font(.title3.weight(.medium))
.foregroundStyle(WordeckTheme.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: WordeckTheme.success.opacity(0.18)
case .close: WordeckTheme.warning.opacity(0.18)
case .wrong: WordeckTheme.error.opacity(0.18)
}
}
private func badgeForeground(for result: TypingMatch) -> Color {
switch result {
case .correct: WordeckTheme.success
case .close: WordeckTheme.warning
case .wrong: WordeckTheme.error
}
}
private func text(_ markdown: String) -> some View {
let attributed = (try? AttributedString(
markdown: markdown,
options: AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace
)
)) ?? AttributedString(markdown)
return Text(attributed)
.multilineTextAlignment(.leading)
}
}
#if canImport(UIKit)
import UIKit
#endif

View file

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

View file

@ -1,5 +1,261 @@
{ {
"sourceLanguage" : "de", "sourceLanguage" : "de",
"strings" : { }, "strings" : {
"… und %@ weitere" : {
},
"„%@“" : {
},
"@%@" : {
},
"%@" : {
},
"%@ fällig" : {
},
"%@ fällige Karten aus abonnierten Decks" : {
},
"%@ Karten" : {
},
"%@ Karten gelernt" : {
},
"3500 Zeichen. Je präziser, desto besser die Karten." : {
},
"Abmelden" : {
},
"Account löschen…" : {
},
"AI-Moderation läuft — kann ein paar Sekunden dauern." : {
},
"Alle Karten und Reviews dieses Decks werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden." : {
},
"Anmelden / Konto erstellen" : {
},
"Antwort anzeigen" : {
},
"Archivierte Decks erscheinen nicht in der Hauptliste. Bestehende FSRS-Reviews bleiben erhalten." : {
},
"Aus Teilen-Menü" : {
},
"Author-Profil anlegen" : {
},
"Beide Richtungen werden gelernt — front→back und back→front." : {
},
"Beschreibung" : {
},
"Bestehendes Deck" : {
},
"Bild" : {
},
"Blockiere Authors über das Menü oben rechts auf Marketplace-Decks." : {
},
"Browse den Marketplace im Entdecken-Tab — kein Konto nötig. Für eigene Decks und Cloud-Sync logge dich ein." : {
},
"Card-Type »%@« kommt in einer späteren Phase" : {
},
"Wordeck" : {
},
"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: wordeck.com/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 Wordeck anonym" : {
},
"Erst ein Deck erstellen." : {
},
"Erst-Publish: 1.0.0. Spätere Versionen müssen semver-größer sein." : {
},
"Format pro Zeile: vorne,hinten,typ. Typ-Spalte optional (Default basic)." : {
},
"Im Marketplace veröffentlichen" : {
},
"Image-Occlusion und Audio-Cards werden im CSV-Import übersprungen — die brauchen Datei-Uploads." : {
},
"Inbox" : {
},
"Karteikarten des Vereins mana e.V." : {
},
"Karten" : {
},
"Karten konnten nicht geladen werden" : {
},
"Keine" : {
},
"KI" : {
},
"KI generiert das Deck aus einer kurzen Beschreibung. 10 Anfragen pro Minute." : {
},
"KI liest Bilder oder PDFs und macht daraus Karten. Bis zu 5 Dateien." : {
},
"KI liest den Inhalt der Seite als zusätzliche Quelle." : {
},
"Lade Decks …" : {
},
"Leer" : {
},
"Leeres Deck — Karten anschließend selbst anlegen." : {
},
"Marketplace und lokale Decks funktionieren ohne Konto. Für KI-Karten, eigene Decks im Cloud-Sync und Marketplace-Veröffentlichung brauchst du ein Konto." : {
},
"Max. %@ Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB." : {
},
"Metadaten ändern: Marketplace-Webansicht → Deck → Bearbeiten." : {
},
"Mit Hint: `{{c1::Berlin::Hauptstadt von DE}}`" : {
},
"Neue Version: %@" : {
},
"Neues Marketplace-Deck" : {
},
"Nicht genug andere Karten im Deck für Multiple-Choice — tippe auf »Antwort anzeigen«." : {
},
"Noch keine Karten. Tippe auf »Karte hinzufügen«." : {
},
"Noch keine Maske" : {
},
"Öffentlich" : {
},
"OK" : {
},
"Pflicht-Schritt vor dem ersten Marketplace-Deck. Slug erscheint in Marketplace-URLs." : {
},
"Preis: %@ Credits" : {
},
"Privat" : {
},
"Quellen" : {
},
"Space" : {
},
"Thema" : {
},
"Tippe und ziehe auf das Bild, um eine Maske zu erstellen." : {
},
"Tippe unten auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab." : {
},
"Veröffentlicht: %@" : {
},
"Veröffentlichungs-Modus" : {
},
"Version" : {
},
"Versuche eine andere Suche oder Sortierung." : {
},
"Vorschau (%@ Karten)" : {
},
"Wählen …" : {
},
"Wir prüfen jede Meldung. Hass und Rechtsverletzungen werden bevorzugt behandelt." : {
},
"Wird veröffentlicht …" : {
},
"Zusätzliche URL (optional)" : {
}
},
"version" : "1.0" "version" : "1.0"
} }

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:wordeck.com</string>
</array>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.ev.mana.wordeck</string>
</array>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)ev.mana.wordeck</string>
</array>
</dict>
</plist>

View file

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

View file

@ -1,6 +1,6 @@
import Foundation import Foundation
import Testing import Testing
@testable import CardsNative @testable import WordeckNative
@Suite("Cloze") @Suite("Cloze")
struct ClozeTests { struct ClozeTests {

View file

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

View file

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

View file

@ -1,79 +0,0 @@
import Foundation
import Testing
@testable import CardsNative
@Suite("MaskRegions")
struct MaskRegionsTests {
@Test("Parsed Liste sortiert nach ID lexikographisch")
func parseSortsByIdLexically() {
let json = """
[
{"id":"m003","x":0.1,"y":0.1,"w":0.2,"h":0.2,"label":"C"},
{"id":"m001","x":0,"y":0,"w":0.1,"h":0.1,"label":"A"},
{"id":"m002","x":0.5,"y":0.5,"w":0.3,"h":0.3}
]
"""
let regions = MaskRegions.parse(json)
#expect(regions.count == 3)
#expect(regions[0].id == "m001")
#expect(regions[1].id == "m002")
#expect(regions[2].id == "m003")
#expect(regions[2].label == "C")
#expect(regions[1].label == nil)
}
@Test("Bei Parse-Fehler → leere Liste")
func parseInvalidReturnsEmpty() {
#expect(MaskRegions.parse("[}").isEmpty)
#expect(MaskRegions.parse("{}").isEmpty)
#expect(MaskRegions.parse("").isEmpty)
}
@Test("region(forSubIndex:) mappt aufsteigend")
func subIndexLookup() {
let json = """
[{"id":"b","x":0,"y":0,"w":0.1,"h":0.1},
{"id":"a","x":0,"y":0,"w":0.2,"h":0.2}]
"""
#expect(MaskRegions.region(for: json, subIndex: 0)?.id == "a")
#expect(MaskRegions.region(for: json, subIndex: 1)?.id == "b")
#expect(MaskRegions.region(for: json, subIndex: 2) == nil)
}
@Test("Encode-Roundtrip")
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),
]
let encoded = MaskRegions.encode(original)
let parsed = MaskRegions.parse(encoded)
#expect(parsed.count == 2)
#expect(parsed[0].id == "m1")
#expect(parsed[0].label == "test")
#expect(parsed[1].label == nil)
}
@Test("CardFieldsBuilder.imageOcclusion produziert korrekte Felder")
func builderImageOcclusion() {
let regions = [MaskRegion(id: "m1", x: 0, y: 0, w: 0.5, h: 0.5, label: "x")]
let fields = CardFieldsBuilder.imageOcclusion(
imageRef: "media_123",
regions: regions,
note: "Hinweis"
)
#expect(fields["image_ref"] == "media_123")
#expect(fields["note"] == "Hinweis")
let reparsed = MaskRegions.parse(fields["mask_regions"] ?? "")
#expect(reparsed.count == 1)
#expect(reparsed[0].id == "m1")
}
@Test("CardFieldsBuilder.audioFront produziert korrekte Felder")
func builderAudioFront() {
let fields = CardFieldsBuilder.audioFront(audioRef: "audio_456", back: "Antwort")
#expect(fields["audio_ref"] == "audio_456")
#expect(fields["back"] == "Antwort")
#expect(fields.count == 2)
}
}

View file

@ -1,12 +1,19 @@
import Foundation import Foundation
import Testing import Testing
@testable import CardsNative @testable import WordeckNative
@Suite("Mutation Body Encoding") @Suite("Mutation Body Encoding")
struct MutationEncodingTests { struct MutationEncodingTests {
private func encode<T: Encodable>(_ value: T) throws -> [String: Any] { private func encode(_ value: some Encodable) throws -> [String: Any] {
let data = try JSONEncoder().encode(value) let data = try JSONEncoder().encode(value)
return try JSONSerialization.jsonObject(with: data) as! [String: Any] guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw EncodeError.notADictionary
}
return dict
}
private enum EncodeError: Error {
case notADictionary
} }
@Test("DeckCreateBody nutzt snake_case und lässt nil weg") @Test("DeckCreateBody nutzt snake_case und lässt nil weg")

View file

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

View file

@ -0,0 +1,55 @@
import Foundation
import Testing
@testable import WordeckNative
@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

@ -1,11 +1,11 @@
import Testing import Testing
@testable import CardsNative @testable import WordeckNative
@Suite("AppConfig") @Suite("AppConfig")
struct AppConfigTests { struct AppConfigTests {
@Test("Cards-API zeigt auf cardecky-api.mana.how") @Test("Cards-API zeigt auf api.wordeck.com")
func apiBaseURLPointsToCardecky() { func apiBaseURLPointsToWordeck() {
#expect(AppConfig.apiBaseURL.absoluteString == "https://cardecky-api.mana.how") #expect(AppConfig.apiBaseURL.absoluteString == "https://api.wordeck.com")
} }
@Test("Auth zeigt auf auth.mana.how") @Test("Auth zeigt auf auth.mana.how")
@ -13,8 +13,8 @@ struct AppConfigTests {
#expect(AppConfig.manaAppConfig.authBaseURL.absoluteString == "https://auth.mana.how") #expect(AppConfig.manaAppConfig.authBaseURL.absoluteString == "https://auth.mana.how")
} }
@Test("Keychain-Service ist ev.mana.cardecky") @Test("Keychain-Service ist ev.mana.wordeck")
func keychainServiceIsAppSpecific() { func keychainServiceIsAppSpecific() {
#expect(AppConfig.manaAppConfig.keychainService == "ev.mana.cardecky") #expect(AppConfig.manaAppConfig.keychainService == "ev.mana.wordeck")
} }
} }

View file

@ -1,22 +0,0 @@
import SwiftUI
import WidgetKit
struct CardsDueWidget: Widget {
let kind: String = "ev.mana.cardecky.due"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: DueProvider()) { entry in
DueWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Karten heute")
.description("Zeigt deine fälligen Karten und Top-Decks.")
.supportedFamilies([
.systemSmall,
.systemMedium,
.accessoryCircular,
.accessoryInline,
.accessoryRectangular,
])
}
}

View file

@ -14,6 +14,8 @@ struct DueWidgetView: View {
smallView smallView
case .systemMedium: case .systemMedium:
mediumView mediumView
case .systemLarge:
largeView
case .accessoryCircular: case .accessoryCircular:
circularView circularView
case .accessoryInline: case .accessoryInline:
@ -75,6 +77,52 @@ struct DueWidgetView: View {
} }
} }
private var largeView: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("\(entry.totalDueCount)")
.font(.system(size: 56, weight: .bold))
.lineLimit(1)
.minimumScaleFactor(0.6)
VStack(alignment: .leading, spacing: 2) {
Text(entry.totalDueCount == 1 ? "Karte fällig" : "Karten fällig")
.font(.subheadline.weight(.medium))
Text("Heute")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
Divider()
VStack(alignment: .leading, spacing: 6) {
Text("Top-Decks")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
ForEach(entry.topDecks.prefix(6)) { deck in
HStack {
Text(deck.name)
.font(.callout)
.lineLimit(1)
Spacer(minLength: 8)
Text("\(deck.dueCount)")
.font(.callout.weight(.semibold))
.foregroundStyle(.secondary)
}
}
if entry.topDecks.isEmpty {
Text("Keine Decks mit fälligen Karten.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var circularView: some View { private var circularView: some View {
ZStack { ZStack {
Circle() Circle()

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.ev.mana.wordeck</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Wordeck Widget</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.ev.mana.wordeck</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,57 @@
import SwiftUI
import WidgetKit
struct WordeckDueWidget: Widget {
let kind: String = "ev.mana.wordeck.due"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: DueProvider()) { entry in
DueWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
.widgetURL(URL(string: "wordeck://study"))
}
.configurationDisplayName("Karten heute")
.description("Zeigt deine fälligen Karten und Top-Decks.")
.supportedFamilies([
.systemSmall,
.systemMedium,
.systemLarge,
.accessoryCircular,
.accessoryInline,
.accessoryRectangular,
])
}
}
// MARK: - Previews
private let demoEntry = DueEntry(
date: .now,
totalDueCount: 23,
topDecks: [
WidgetSnapshot.Entry(id: "d1", name: "Italienisch — Verben", dueCount: 12, colorHex: nil),
WidgetSnapshot.Entry(id: "d2", name: "iOS-Snippets", dueCount: 7, colorHex: nil),
WidgetSnapshot.Entry(id: "d3", name: "Anatomie", dueCount: 4, colorHex: nil),
WidgetSnapshot.Entry(id: "d4", name: "Sterne am Nordhimmel", dueCount: 2, colorHex: nil),
],
isPlaceholder: false
)
#Preview("Klein", as: .systemSmall) {
WordeckDueWidget()
} timeline: {
demoEntry
DueEntry.placeholder
}
#Preview("Mittel", as: .systemMedium) {
WordeckDueWidget()
} timeline: {
demoEntry
}
#Preview("Groß", as: .systemLarge) {
WordeckDueWidget()
} timeline: {
demoEntry
}

View file

@ -6,8 +6,8 @@ import WidgetKit
/// rectangular). Daten kommen aus dem shared App-Group-Container /// rectangular). Daten kommen aus dem shared App-Group-Container
/// (siehe `WidgetSnapshotStore` im Haupt-Target). /// (siehe `WidgetSnapshotStore` im Haupt-Target).
@main @main
struct CardsWidgetBundle: WidgetBundle { struct WordeckWidgetBundle: WidgetBundle {
var body: some Widget { var body: some Widget {
CardsDueWidget() WordeckDueWidget()
} }
} }

183
devlog/2026-05-12/data.json Normal file
View file

@ -0,0 +1,183 @@
{
"date": "2026-05-12",
"day_number": 1,
"weekday": "Dienstag",
"commits": 10,
"authors": [
{
"name": "Till JS",
"count": 10
}
],
"additions": 6964,
"deletions": 326,
"net_lines": 6638,
"files_changed": 77,
"new_files": 0,
"deleted_files": 0,
"session": {
"first_commit_at": "2026-05-12T17:29:45.000Z",
"last_commit_at": "2026-05-12T23:56:54.000Z",
"total_span_minutes": 387,
"active_minutes": 67,
"pauses": [
{
"from": "19:29",
"to": "00:06",
"minutes": 277
},
{
"from": "01:13",
"to": "01:56",
"minutes": 43
}
],
"longest_focus_minutes": 67
},
"top_dirs": [
{
"path": "PLAN.md",
"pct": 7
},
{
"path": "Sources/Features/Decks",
"pct": 7
},
{
"path": "Sources/Core/Domain",
"pct": 7
},
{
"path": "Sources/Core/Sync",
"pct": 6
},
{
"path": "Sources/Core/API",
"pct": 5
}
],
"top_extensions": [
{
"ext": ".swift",
"count": 79
},
{
"ext": ".md",
"count": 12
},
{
"ext": ".yml",
"count": 6
},
{
"ext": ".json",
"count": 5
},
{
"ext": ".entitlements",
"count": 3
},
{
"ext": ".plist",
"count": 3
}
],
"tags": [],
"commits_list": [
{
"hash": "28b20cd",
"short": "v0.1.0 — Phase β-0 Setup",
"type": null,
"scope": null,
"additions": 896,
"deletions": 0,
"timestamp": "2026-05-12T19:29:45+02:00"
},
{
"hash": "f664a00",
"short": "v0.2.0 — Phase β-1 Decks lesen",
"type": null,
"scope": null,
"additions": 809,
"deletions": 85,
"timestamp": "2026-05-13T00:06:28+02:00"
},
{
"hash": "3b861af",
"short": "v0.3.0 — Phase β-2 Study-Loop",
"type": null,
"scope": null,
"additions": 1013,
"deletions": 23,
"timestamp": "2026-05-13T00:16:11+02:00"
},
{
"hash": "cf1160b",
"short": "v0.4.0 — Phase β-3 Editor",
"type": null,
"scope": null,
"additions": 933,
"deletions": 22,
"timestamp": "2026-05-13T00:24:43+02:00"
},
{
"hash": "80eb370",
"short": "v0.5.0 — Phase β-4 Media + Advanced Card-Types",
"type": null,
"scope": null,
"additions": 922,
"deletions": 43,
"timestamp": "2026-05-13T00:35:36+02:00"
},
{
"hash": "07ada72",
"short": "v0.6.0 — Phase β-5 Marketplace",
"type": null,
"scope": null,
"additions": 1015,
"deletions": 24,
"timestamp": "2026-05-13T00:51:12+02:00"
},
{
"hash": "a1770fb",
"short": "v0.7.0 — Phase β-6 Native-Polish",
"type": null,
"scope": null,
"additions": 580,
"deletions": 13,
"timestamp": "2026-05-13T01:00:04+02:00"
},
{
"hash": "55359c5",
"short": "chore: gitignore widget-generated Info.plist + entitlements",
"type": null,
"scope": null,
"additions": 0,
"deletions": 39,
"timestamp": "2026-05-13T01:00:22+02:00"
},
{
"hash": "0b2ae16",
"short": "v0.8.0 — Phase β-7 App-Store-Vorbereitung",
"type": null,
"scope": null,
"additions": 786,
"deletions": 62,
"timestamp": "2026-05-13T01:13:27+02:00"
},
{
"hash": "bf23968",
"short": "docs: AASA-Endpoint markiert als done (Code-side)",
"type": null,
"scope": null,
"additions": 10,
"deletions": 15,
"timestamp": "2026-05-13T01:56:54+02:00"
}
],
"review_state": "auto",
"llm": {
"model": null,
"generated_at": null
}
}

105
devlog/2026-05-12/macher.md Normal file
View file

@ -0,0 +1,105 @@
---
date: 2026-05-12
day: 1
view: macher
weekday: Dienstag
commits: 10
review: written
---
# Dienstag, 2026-05-12 — Tag 1 (Macher-Sicht)
Phasen β-0 bis β-7 aus dem Greenfield-Playbook in einer Marathon-
Session. Skeleton → App-Store-Vorbereitung. Web-Parität als
Leitprinzip, FSRS bleibt server-authoritative, alles andere ist
Native-Re-Implementation des Web-Wordeck.
## Stats
10 Commits, +6 964 / 326 LoC, 77 Files. 79× `.swift`. Session
17:29 → 01:56, 67 aktive Minuten, längster Fokus 67 Min. Top-Dirs
sind sehr verteilt (alle 57 %) — Greenfield-Tag, der jeden
Subsystem-Bereich anfasst.
## Versions-Tags
- **v0.1.0 — β-0 Setup.** XcodeGen-Manifest, ManaCore + ManaTokens-
Package-Dependency, ManaCore-Login + Healthz-Probe gegen
api.wordeck.com.
- **v0.2.0 — β-1 Decks lesen.** SwiftData-Cache,
`/api/v1/decks`-Liste, Detail-View mit Karten-Anzahl,
Stats-Sektion.
- **v0.3.0 — β-2 Study-Loop.** Grading gegen `POST /api/v1/reviews/
:cardId/:subIndex/grade`. `ReviewQueue` für Offline-Grades,
Replay bei Reconnect. SubIndex-Berechnung für Cloze bleibt
server-side.
- **v0.4.0 — β-3 Editor.** Basic, Cloze, Typing, Multiple-Choice
als Editor-Surfaces. Content-Hash-Berechnung am Server.
- **v0.5.0 — β-4 Media + Advanced Card-Types.** Image- und
Audio-Front-Karten, Image-Occlusion-Mask via PencilKit
(einzige UIKit-Bridge), `mana-media`-Upload-Pfad.
- **v0.6.0 — β-5 Marketplace.** `/api/v1/marketplace/*`-Routen,
Subscribe-Flow, Universal-Links für Deck-Slugs.
- **v0.7.0 — β-6 Native-Polish.** WidgetKit (Due-Cards-Counter +
Heute-Streak), Local-Notifications für Review-Reminder, Share-
Extension „Save as Card".
- **chore: gitignore widget-generated Info.plist + entitlements.**
XcodeGen generiert die, sollen nicht im Git.
- **v0.8.0 — β-7 App-Store-Vorbereitung.** `PrivacyInfo.xcprivacy`,
AASA-Endpoint im Cards-API, Submission-Checkliste.
- **AASA-Endpoint als done markiert** code-seitig — DNS-/Cloudflare-
Roundtrip steht aus (gleiche Falle wie zitare-native).
## Architektur-Entscheidungen
- **Server-authoritative FSRS.** Grading-Calls immer an `POST
/api/v1/reviews/:cardId/:subIndex/grade`. Kein lokaler
ts-fsrs-Port. Vorteil: Algorithmen-Updates wirken sofort in
Native, ohne App-Update. Nachteil: Grading ist online-bedingt
(Offline-Queue löst das mit Round-Trip-Delay).
- **Offline-Read, Online-Write.** Decks + Due-Cards via SwiftData
gecacht (offline lesbar). Grades werden offline in einer
`ReviewQueue` persistiert, beim Reconnect der Reihe nach
abgesendet.
- **Cards-Domain-Logik bleibt am Server.** SubIndex-Berechnung
für Cloze, Image-Occlusion-Mask-Validation, Content-Hash —
alles Server. Native zeigt nur, was vom Server kommt.
- **Pure SwiftUI**, einzige UIKit-Bridge: `PencilKit` für Image-
Occlusion. SwiftUI hat keinen Pencil-Picker, nicht-vermeidbar.
- **mana-auth via ManaCore.** Eigene Auth-Implementierung verboten.
- **`forest`-Theme**, lokal in `WordeckTheme.swift` aus
`mana/packages/themes/src/variants/forest.css` gespiegelt.
Migration auf ManaTokens-Theme-Switch ist Phase ε.
- **Web gewinnt bei Konflikt.** Eleganteres Native-Verhalten geht
zuerst in Web, dann nach hier. Verhindert Drift.
## Trade-offs
- **67 Min Active für β-0 bis β-7** klingt absurd komprimiert.
Realer Grund: jeder β-Schritt ist „Web-Funktion X als SwiftUI
spiegeln" — die Domain-Arbeit (FSRS, Cloze, Content-Hash) ist
alles Server-side bereits gelöst. Native = Wire-Format +
Codable + View.
- **PencilKit-Bridge** als einzige UIKit-Konzession. Beschränkt;
Image-Occlusion kann sonst nicht ergonomisch sein.
- **Local-Notifications für Review-Reminder** sind Best-Effort.
iOS budgetiert hart; bei vielen Decks kann eine Erinnerung
verschoben werden.
- **+6 638 LoC netto in einer Schicht** — keine Architektur-Arbeit,
reine Übersetzung. Wäre das nicht so identisch zum Web-Modell,
wäre der Tag dreimal so lang.
## Offene Punkte
- **AASA-DNS-Roundtrip** für Universal-Links. Code da, DNS-Setup
in Cloudflare offen.
- **TestFlight-Build** mit allem an Bord — Apple-Submission Folge-
Tag.
- **Endurance-Test auf echtem Gerät** über mehrere Tage —
Battery-Drain durch Notifications, ReviewQueue-Drift bei langem
Offline, Widget-Refresh-Verhalten.
- **WebKit-Local-Cache** für rich-content-Karten (HTML-Rich-Text)
nicht implementiert — heute Plain-Text-Fallback.
- **Cards-Domain `forest`-Theme** ist hartcodiert; ManaTokens-
Theme-Switch kommt mit Plattform-Variants-Release.
- **ShareExtension** vorbereitet, aber App-Group-Entitlement
muss in Apple-Dev-Portal aktiviert werden.

View file

@ -0,0 +1,45 @@
---
date: 2026-05-12
day: 1
view: spieler
weekday: Dienstag
commits: 10
review: written
---
# Dienstag, 2026-05-12 — Tag 1
Wordeck gibt es bald auch als App. Heute ist die Basis entstanden:
Anmelden, Decks ansehen, lernen, Karten bearbeiten, Bilder dazulegen,
Marketplace durchsehen — alles offline-tauglich.
## Was die App heute schon kann
- **Anmelden** über deinen Verein-Account.
- **Deine Decks** sehen — und auch offline weiterhin, weil die App
sie zwischenspeichert.
- **Lernen** mit dem üblichen Bewerten-System (14). Wenn du gerade
kein Netz hast, sammelt die App die Bewertungen und schickt sie
später durch.
- **Karten bearbeiten** — die vier Standard-Typen: Vorder-/Rückseite,
Cloze, Tippen, Mehrfachauswahl.
- **Bilder + Audio** auf Karten — inkl. Bildausblenden (Image-Occlusion)
und „Audio zuerst, dann Antwort".
- **Marketplace** durchstöbern und Decks abonnieren.
- **Universal-Links** — Marketplace-Links aus Messenger öffnen direkt
die App.
- **Widget für den Home-Screen** und „Karte aus dem Web speichern"-
Share-Extension sind vorbereitet.
## Was offen ist
- **TestFlight + App Store** — Codeseitig ist alles fertig, der
Vertriebs-Slot bei Apple folgt.
- **Endurance-Test auf echtem iPhone** über mehrere Tage — heute
alles im Simulator getestet.
## Hintergrund
Wordeck lebt im Browser; manche Leute lernen aber lieber mit der
Hand am Telefon. Die App ist deshalb keine neue Wordeck, sondern
dieselbe Wordeck in iOS-Form — gleiche Karten, gleiches FSRS,
gleicher Account.

317
devlog/2026-05-13/data.json Normal file
View file

@ -0,0 +1,317 @@
{
"date": "2026-05-13",
"day_number": 2,
"weekday": "Mittwoch",
"commits": 22,
"authors": [
{
"name": "Till JS",
"count": 22
}
],
"additions": 6206,
"deletions": 1511,
"net_lines": 4695,
"files_changed": 87,
"new_files": 0,
"deleted_files": 0,
"session": {
"first_commit_at": "2026-05-13T11:17:41.000Z",
"last_commit_at": "2026-05-14T00:04:29.000Z",
"total_span_minutes": 767,
"active_minutes": 167,
"pauses": [
{
"from": "13:29",
"to": "14:06",
"minutes": 37
},
{
"from": "15:11",
"to": "15:46",
"minutes": 35
},
{
"from": "16:02",
"to": "17:20",
"minutes": 77
},
{
"from": "18:15",
"to": "19:08",
"minutes": 54
},
{
"from": "19:26",
"to": "01:23",
"minutes": 357
},
{
"from": "01:23",
"to": "02:03",
"minutes": 40
}
],
"longest_focus_minutes": 65
},
"top_dirs": [
{
"path": "Sources/Core/Domain",
"pct": 11
},
{
"path": "project.yml",
"pct": 9
},
{
"path": "Sources/Features/Decks",
"pct": 9
},
{
"path": "Sources/Features/Study",
"pct": 8
},
{
"path": "Sources/Core/Sync",
"pct": 6
}
],
"top_extensions": [
{
"ext": ".swift",
"count": 115
},
{
"ext": ".yml",
"count": 15
},
{
"ext": ".md",
"count": 10
},
{
"ext": ".xcstrings",
"count": 4
},
{
"ext": ".json",
"count": 2
},
{
"ext": ".gitignore",
"count": 1
}
],
"tags": [
"decks",
"auth",
"study",
"editor",
"ui"
],
"commits_list": [
{
"hash": "468ef29",
"short": "feat: DEVELOPMENT_TEAM=QP3GLU8PH3 für Code-Signing",
"type": null,
"scope": null,
"additions": 13,
"deletions": 8,
"timestamp": "2026-05-13T13:17:41+02:00"
},
{
"hash": "4dfb32b",
"short": "chore: Rebrand auf ev.mana.wordeck",
"type": null,
"scope": null,
"additions": 203,
"deletions": 94,
"timestamp": "2026-05-13T13:29:04+02:00"
},
{
"hash": "6d9a191",
"short": "chore: Archive-Polish — Versions-Sync + Orientations + Mac-Icon",
"type": null,
"scope": null,
"additions": 32,
"deletions": 7,
"timestamp": "2026-05-13T14:06:11+02:00"
},
{
"hash": "6a4d66f",
"short": "chore: Sendable-Warning + AppIcon-Asset-Cleanup",
"type": null,
"scope": null,
"additions": 19,
"deletions": 14,
"timestamp": "2026-05-13T14:12:29+02:00"
},
{
"hash": "e24e0e6",
"short": "fix(editor): PhotosPicker Sendable-Warning durch Sub-View struct",
"type": "fix",
"scope": "editor",
"additions": 105,
"deletions": 17,
"timestamp": "2026-05-13T14:39:09+02:00"
},
{
"hash": "2eb7f61",
"short": "fix: ITMS-90129 — DisplayName Cards → Wordeck + Build 2",
"type": null,
"scope": null,
"additions": 11,
"deletions": 11,
"timestamp": "2026-05-13T15:05:33+02:00"
},
{
"hash": "b5edf5c",
"short": "fix: User-facing Strings Cards → Wordeck komplett, Build 3",
"type": null,
"scope": null,
"additions": 19,
"deletions": 19,
"timestamp": "2026-05-13T15:11:11+02:00"
},
{
"hash": "f528ea4",
"short": "fix: PublicDeckOwner.pseudonym ist Bool, nicht String?",
"type": null,
"scope": null,
"additions": 10,
"deletions": 6,
"timestamp": "2026-05-13T15:46:20+02:00"
},
{
"hash": "e8b898a",
"short": "feat(decks): Card-Liste im DeckDetailView + listCards-API",
"type": "feat",
"scope": "decks",
"additions": 169,
"deletions": 17,
"timestamp": "2026-05-13T16:02:59+02:00"
},
{
"hash": "0b0872c",
"short": "fix: 404 auf allen Query-Endpoints — pulls ManaCore v1.0.1 fix",
"type": null,
"scope": null,
"additions": 3,
"deletions": 3,
"timestamp": "2026-05-13T17:20:03+02:00"
},
{
"hash": "aa94601",
"short": "feat(ui): Wordeck-Web-Design — Fan-Stack-Tiles + CardSurface",
"type": "feat",
"scope": "ui",
"additions": 396,
"deletions": 162,
"timestamp": "2026-05-13T17:28:11+02:00"
},
{
"hash": "8b1dd51",
"short": "feat(study): Multiple-Choice-Karten gerendert",
"type": "feat",
"scope": "study",
"additions": 225,
"deletions": 4,
"timestamp": "2026-05-13T17:34:07+02:00"
},
{
"hash": "505aa9d",
"short": "feat(study): Typing-Karten + Levenshtein-Match-Logik",
"type": "feat",
"scope": "study",
"additions": 323,
"deletions": 4,
"timestamp": "2026-05-13T17:39:39+02:00"
},
{
"hash": "33101d7",
"short": "feat(auth): DEBUG-Auto-Login (Memoro-Pattern)",
"type": "feat",
"scope": "auth",
"additions": 52,
"deletions": 12,
"timestamp": "2026-05-13T17:50:29+02:00"
},
{
"hash": "90201d7",
"short": "feat(decks): Tile-Redesign — Tap=Study, Edit-Icon, Explore-Konsistenz",
"type": "feat",
"scope": "decks",
"additions": 154,
"deletions": 74,
"timestamp": "2026-05-13T18:03:47+02:00"
},
{
"hash": "0e90f4b",
"short": "chore: PLAN.md auf v0.9.4-Stand + Localizable.xcstrings",
"type": null,
"scope": null,
"additions": 67,
"deletions": 8,
"timestamp": "2026-05-13T18:15:22+02:00"
},
{
"hash": "6805bd7",
"short": "feat(decks): iOS-26 tabViewBottomAccessory für „Neues Deck\"-Pille",
"type": "feat",
"scope": "decks",
"additions": 57,
"deletions": 10,
"timestamp": "2026-05-13T19:08:57+02:00"
},
{
"hash": "710ede6",
"short": "fix(api): code:nil bei AuthError.serverError-Calls",
"type": "fix",
"scope": "api",
"additions": 3,
"deletions": 3,
"timestamp": "2026-05-13T19:09:01+02:00"
},
{
"hash": "da66797",
"short": "feat(auth): ManaAuthUI-Migration — vollständige Auth-Reise nativ",
"type": "feat",
"scope": "auth",
"additions": 173,
"deletions": 99,
"timestamp": "2026-05-13T19:26:12+02:00"
},
{
"hash": "8ca7bd3",
"short": "feat(auth): Guest-Mode + Login-optionale Surface",
"type": "feat",
"scope": "auth",
"additions": 271,
"deletions": 155,
"timestamp": "2026-05-14T01:23:30+02:00"
},
{
"hash": "73f9081",
"short": "feat(decks): γ-1 bis γ-8 — AI/CSV-Import, Card-Edit, Pull-Update, Marketplace-Publish + Moderation + PDF",
"type": "feat",
"scope": "decks",
"additions": 3412,
"deletions": 435,
"timestamp": "2026-05-14T02:03:59+02:00"
},
{
"hash": "aece169",
"short": "chore(lint): SwiftLint-Config + 0-Warnings-Pass + Swift-6-Concurrency-Fixes",
"type": "chore",
"scope": "lint",
"additions": 489,
"deletions": 349,
"timestamp": "2026-05-14T02:04:29+02:00"
}
],
"review_state": "auto",
"llm": {
"model": null,
"generated_at": null
}
}

119
devlog/2026-05-13/macher.md Normal file
View file

@ -0,0 +1,119 @@
---
date: 2026-05-13
day: 2
view: macher
weekday: Mittwoch
commits: 22
review: written
---
# Mittwoch, 2026-05-13 — Tag 2 (Macher-Sicht)
Großer Polish-Tag mit Funktions-Vollausbau. Web-Design adaptiert,
alle 7 Karten-Typen rendern, ManaAuthUI-Migration zog die Auth-Reise
nativ, Guest-Mode landete, und der γ-1-bis-γ-8-Block brachte AI-/
CSV-Import, Card-Edit, Pull-Update, Marketplace-Publish, Moderation
und PDF-Export — alles in einer Schicht.
## Stats
22 Commits, +6 206 / 1 511 LoC, 87 Files. 115× `.swift`. Session
11:17 → 02:04, 167 aktive Minuten in 7 Blöcken, längster Fokus
65 Min. Top-Dirs: Domain (11 %), project.yml (9 %), Decks (9 %),
Study (8 %), Sync (6 %). Tags: decks, auth, study, editor, ui.
## Schritte (chronologisch)
- **DEVELOPMENT_TEAM=QP3GLU8PH3** für Code-Signing. Erstmal
Apple-Konto-Setup.
- **Rebrand auf `ev.mana.wordeck`.** Bundle-ID, DisplayName, Strings,
Icon-Asset. Vorher hieß die App intern „Cards", öffentlich aber
„Wordeck".
- **Archive-Polish** — Versions-Sync, Orientations, Mac-Icon.
- **Sendable-Warnings**: PhotosPicker zog Sub-View struct (Sendable-
Compliance für `PHPickerViewController`).
- **ITMS-90129** (Apple-Validation): DisplayName Cards → Wordeck in
Info.plist, Build 2.
- **User-facing Strings Cards → Wordeck** komplett, Build 3.
- **PublicDeckOwner.pseudonym ist Bool**, nicht `String?`
Wire-Format-Drift gegen Server-Schema gefangen.
- **Card-Liste im DeckDetailView + listCards-API.** Vorher zeigte
Detail nur Metadaten.
- **ManaCore v1.0.1 pull** — 404 auf Query-Endpoints (URL-Encoding
von `?`, siehe mana-swift-core Tag 2).
- **Wordeck-Web-Design — Fan-Stack-Tiles + CardSurface.** Tiles
fächern Karten auf, einheitlicher Card-Frame über Front/Back/
Cloze.
- **Multiple-Choice-Karten rendern.** UI-Render-Layer für MC, Server
liefert Choices + correctIndex.
- **Typing-Karten + Levenshtein-Match.** Antwort-Vergleich verzeiht
kleine Tippfehler (Edit-Distance ≤ 2).
- **DEBUG-Auto-Login** wie in Memoro-Native — schneller Iteration.
- **Tile-Redesign — Tap=Study, Edit-Icon, Explore-Konsistenz.**
Vorher war Tap = Edit, was niemandes Erwartung war.
- **iOS-26 `tabViewBottomAccessory` für „Neues Deck"-Pille.** Nutzt
den neuen iOS-26-API.
- **code:nil bei AuthError.serverError**-Fix in API-Wrapper.
- **ManaAuthUI-Migration** — alle Auth-Surfaces (Login/Signup/Verify/
Reset/Account/Delete) auf das frische `mana-swift-ui`-Package.
Eigene Views weg.
- **Guest-Mode + Login-optionale Surface.** Public-Marketplace-Decks
+ Read-only Study ohne Account. ManaAuthGate eskaliert pro Aktion.
- **γ-1 bis γ-8** — AI-Import-Sheet (Text-Prompt → mana-llm-Deck-
Generator), CSV-Import (Header-Parser + Map-Field), Card-Edit-
Sheet inkl. Image/Audio, Pull-Update (subscribed deck → new
version), Marketplace-Publish (Pseudonym oder echter Name),
Moderation-Surface (Report-Reason-Picker), PDF-Export
(PDFKit-Renderer). **3 412 / 435 LoC** in einem Commit — viel
generierter Sheet-Code, viel Wire-Format-Codable.
- **SwiftLint-Config + 0-Warnings-Pass + Swift-6-Concurrency-Fixes.**
CI-Bremse weg.
## Architektur-Entscheidungen
- **Auth-UI extern**, nicht selbst gebaut. Migration auf ManaAuthUI
(mana-swift-ui v0.1+) sparte ~600 LoC eigene Auth-Views und
zwang die anderen Apps in dieselbe Optik.
- **Guest-Mode als eigene Surface-Schicht**, nicht „signed-out".
Marketplace + Public-Study laufen ohne Account, ManaAuthGate
eskaliert pro Aktion (z.B. „Deck speichern").
- **Levenshtein für Typing-Match.** Edit-Distance ≤ 2 verzeiht
Tippfehler ohne triviale Lösungen zu akzeptieren. Server kennt
diese Logik nicht (Web hat sie ebenfalls clientseitig).
- **Card-Surface als shared View**, statt 7× Custom-Layout. Ein
Frame, in den 7 Content-Types einsteigen.
- **iOS-26-Features als progressive enhancement**
`tabViewBottomAccessory` ist nur ab iOS 26 verfügbar.
`#available`-Gate.
- **PDF-Export client-side** mit PDFKit, nicht Server. Server-
Render würde mana-pdf-Service nötig machen; client-Side ist
einfacher und privater.
- **DEBUG-Auto-Login-Pattern** aus Memoro übernommen — Iteration
ist 10× schneller wenn der Login-Step weg ist.
## Trade-offs
- **22 Commits in einer Session** ist viele Sinn-Abschnitte. Jeder
einzeln klein genug, dass `git log` lesbar bleibt. PR-Review
würde leiden, aber als Solo-Sprint OK.
- **AI-Import braucht mana-llm online.** Kein Offline-Fallback.
- **PublicDeckOwner.pseudonym=Bool** ist Web-Schema-Verschwurbel
(Server liefert `Boolean use_pseudonym'`, nicht den Pseudonym-
String — der ist anderswo). Hatte 30 Min Fehlersuche, weil Codable
silent failed.
- **Web gewinnt bei Konflikt** zwingt Native zu nachzuziehen. Wenn
Web vorab Refactor macht, hat Native immer 1-Tag-Delay.
- **iOS-26-only-Features** für Sub-Surfaces ist Verstoss gegen
„iOS 18 Minimum"-Politik aus CLAUDE.md. Akzeptiert via Gate.
## Offene Punkte
- **Endurance-Test echtes iPhone**: ReviewQueue-Drift bei langem
Offline, Battery-Drain, Memory unter Pull-Update.
- **PDF-Export-Quality** für Image-Occlusion-Karten — Maske wird
zurzeit ohne Pencil-Trace gerendert.
- **AI-Import-Token-Budget** sichtbar machen (Credits-Anzeige).
- **Wire-Format-Drift-Detection**: Pseudonym-Bool-Fall war
silent-failure; sollte ein zod-Schema-Mismatch-Smoke-Test
fangen.
- **Localizable.xcstrings EN-Fallback** angelegt, aber nicht
übersetzt.

Some files were not shown because too many files have changed in this diff Show more