Compare commits
34 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9392303da | ||
|
|
9527240bcc | ||
|
|
19fee75c47 | ||
|
|
a59863487e | ||
|
|
73d95aeecc | ||
|
|
ba856bbea8 | ||
|
|
2f7f3d8c0b | ||
|
|
f6b7c05296 | ||
|
|
542082772a | ||
|
|
4f154bfb39 | ||
|
|
57e472ff34 | ||
|
|
2194da5b2c | ||
|
|
c0b0c902bc | ||
|
|
8280d3d0ba | ||
|
|
aece169360 | ||
|
|
73f9081fa1 | ||
|
|
8ca7bd3636 | ||
|
|
da6679770b | ||
|
|
710ede6acd | ||
|
|
6805bd78c7 | ||
|
|
0e90f4b1c1 | ||
|
|
90201d7199 | ||
|
|
33101d703d | ||
|
|
505aa9db19 | ||
|
|
8b1dd5158f | ||
|
|
aa94601409 | ||
|
|
0b0872c8c0 | ||
|
|
e8b898a51d | ||
|
|
f528ea448a | ||
|
|
b5edf5cf2e | ||
|
|
2eb7f61f9c | ||
|
|
e24e0e6825 | ||
|
|
6a4d66fd74 | ||
|
|
6d9a191527 |
110 changed files with 6877 additions and 2166 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
.DS_Store
|
||||
.build/
|
||||
build/
|
||||
.swiftpm/
|
||||
DerivedData/
|
||||
Package.resolved
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
disabled_rules:
|
||||
- todo
|
||||
- trailing_comma
|
||||
# opening_brace kollidiert mit SwiftFormats `wrapMultilineStatementBraces`,
|
||||
# das bei Multi-Line-Conditions das `{` auf eine eigene Zeile wirft.
|
||||
# SwiftFormat gewinnt — ist im Pre-Commit-Hook.
|
||||
- opening_brace
|
||||
|
||||
opt_in_rules:
|
||||
- empty_count
|
||||
|
|
@ -18,8 +22,31 @@ line_length:
|
|||
identifier_name:
|
||||
min_length: 2
|
||||
excluded:
|
||||
# Standard-Identifier
|
||||
- id
|
||||
- ok
|
||||
# Mathematische/algorithmische Konventionen (loops, indizes,
|
||||
# Koordinaten, distances) — kürzer ist hier klarer als verbose.
|
||||
- i
|
||||
- j
|
||||
- n
|
||||
- m
|
||||
- x
|
||||
- y
|
||||
- w
|
||||
- h
|
||||
- r
|
||||
- g
|
||||
- b
|
||||
- a
|
||||
- c
|
||||
- d
|
||||
- s
|
||||
- f
|
||||
- p
|
||||
- q
|
||||
- t
|
||||
- l
|
||||
|
||||
included:
|
||||
- Sources
|
||||
|
|
|
|||
42
CLAUDE.md
42
CLAUDE.md
|
|
@ -1,4 +1,4 @@
|
|||
# CLAUDE.md — cards-native repo
|
||||
# CLAUDE.md — wordeck-native repo
|
||||
|
||||
Guidance für Claude Code in diesem Repository.
|
||||
|
||||
|
|
@ -10,27 +10,27 @@ Guidance für Claude Code in diesem Repository.
|
|||
## Was dieses Repo ist
|
||||
|
||||
**Cards Native** — native SwiftUI-Universal-App (iOS / iPadOS / macOS)
|
||||
für **Cardecky**, die Spaced-Repetition-Karten-App des Vereins
|
||||
**mana e.V.** Web-Parität zu `cardecky.mana.how`, plus native iOS-
|
||||
für **Wordeck**, die Spaced-Repetition-Karten-App des Vereins
|
||||
**mana e.V.** Web-Parität zu `wordeck.com`, plus native iOS-
|
||||
Affordances (Widgets, Notifications, Universal-Links, Pencil).
|
||||
|
||||
```
|
||||
HTTPS/JWT ┌──────────────────┐
|
||||
cards-api ◄───────────── │ cards-native │ SwiftUI
|
||||
cardecky-api.mana.how │ (this repo) │ SwiftData (Cache)
|
||||
│ ev.mana.cardecky │ WidgetKit (β-6)
|
||||
cards-api ◄───────────── │ wordeck-native │ SwiftUI
|
||||
api.wordeck.com │ (this repo) │ SwiftData (Cache)
|
||||
│ ev.mana.wordeck │ WidgetKit (β-6)
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
**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`.
|
||||
|
||||
## 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
|
||||
Re-Implementation, kein neues Produkt.
|
||||
|
||||
|
|
@ -53,12 +53,12 @@ Beschlossen. Nicht ohne explizite Diskussion antasten.
|
|||
Auth-Implementierung ist verboten.
|
||||
4. **Pure SwiftUI.** Keine externen UI-Libraries. AppKit/UIKit nur
|
||||
als Bridge wenn zwingend (z.B. `PencilKit` für Image-Occlusion).
|
||||
5. **Bundle-ID `ev.mana.cardecky`.** Reverse-Domain mana-ev.ch.
|
||||
Universal-Link-Domain: `cardecky.mana.how`.
|
||||
5. **Bundle-ID `ev.mana.wordeck`.** Reverse-Domain mana-ev.ch.
|
||||
Universal-Link-Domain: `wordeck.com`.
|
||||
6. **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.
|
||||
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`).
|
||||
Migration auf ManaTokens-Theme-Switch ist Phase ε.
|
||||
8. **Web gewinnt bei Konflikt.** Eleganteres Native-Verhalten geht
|
||||
|
|
@ -74,16 +74,16 @@ Beschlossen. Nicht ohne explizite Diskussion antasten.
|
|||
sind **nicht** im Git
|
||||
- **SwiftFormat** mit `.swiftformat` (4-space, 120-col, sorted imports)
|
||||
- **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
|
||||
`ev.mana.core`
|
||||
- **Persistenz:** SwiftData für Deck/Card-Cache (ab β-1), JWT im
|
||||
Keychain (über ManaCore)
|
||||
- **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
|
||||
verifizieren:
|
||||
|
||||
|
|
@ -95,14 +95,14 @@ verifizieren:
|
|||
## Repo-Layout
|
||||
|
||||
```
|
||||
cards-native/
|
||||
wordeck-native/
|
||||
├── project.yml XcodeGen-Manifest (SOT)
|
||||
├── PLAN.md Phase-Tracking (gekürzt aus Greenfield-Plan)
|
||||
├── CLAUDE.md dieses File
|
||||
├── README.md
|
||||
├── .swiftformat, .swiftlint.yml
|
||||
├── Sources/
|
||||
│ ├── App/ CardsNativeApp (@main), RootView
|
||||
│ ├── App/ WordeckNativeApp (@main), RootView
|
||||
│ ├── Features/
|
||||
│ │ ├── Account/ LoginView, AccountView (ab β-1)
|
||||
│ │ ├── Decks/ DashboardView (Placeholder), DeckList (β-1)
|
||||
|
|
@ -113,19 +113,19 @@ cards-native/
|
|||
│ │ └── Imports/ (β-3)
|
||||
│ ├── Core/
|
||||
│ │ ├── Auth/ AppConfig (ManaAppConfig-Provider)
|
||||
│ │ ├── API/ CardsAPI (AuthenticatedTransport-Wrapper)
|
||||
│ │ ├── API/ WordeckAPI (AuthenticatedTransport-Wrapper)
|
||||
│ │ ├── Domain/ (Card-Type-Enums, Rating-Enum — ab β-2)
|
||||
│ │ ├── Storage/ (SwiftData-Models — ab β-1)
|
||||
│ │ ├── Sync/ (ReviewQueue, MediaCache — ab β-2/β-4)
|
||||
│ │ ├── Telemetry/ OSLog (Subsystem ev.mana.cardecky)
|
||||
│ │ └── Theme/ CardsTheme (forest-Werte)
|
||||
│ │ ├── Telemetry/ OSLog (Subsystem ev.mana.wordeck)
|
||||
│ │ └── Theme/ WordeckTheme (forest-Werte)
|
||||
│ ├── Widgets/ (WidgetKit-Extension — ab β-6)
|
||||
│ ├── ShareExtension/ (Save-as-Card — ab β-6)
|
||||
│ └── Resources/
|
||||
│ ├── Assets.xcassets
|
||||
│ ├── Localizable.xcstrings
|
||||
│ ├── Info.plist (generiert, gitignored)
|
||||
│ └── CardsNative.entitlements (generiert, gitignored)
|
||||
│ └── WordeckNative.entitlements (generiert, gitignored)
|
||||
├── Tests/
|
||||
│ ├── UnitTests/
|
||||
│ └── UITests/
|
||||
|
|
@ -156,7 +156,7 @@ cards-native/
|
|||
**Workflow:**
|
||||
```bash
|
||||
xcodegen generate
|
||||
open CardsNative.xcodeproj
|
||||
open WordeckNative.xcodeproj
|
||||
```
|
||||
|
||||
**Vor jedem Commit:**
|
||||
|
|
|
|||
118
PLAN.md
118
PLAN.md
|
|
@ -1,13 +1,20 @@
|
|||
# Plan — cards-native (SwiftUI Universal)
|
||||
# Plan — wordeck-native (SwiftUI Universal)
|
||||
|
||||
**Stand: 2026-05-13 — Phasen β-0 bis β-7 abgeschlossen.**
|
||||
Feature-komplett für TestFlight. Alle 7 Card-Types + Marketplace
|
||||
+ Keyboard/Daily-Reminder/Widget + Siri-Shortcut + Share-Extension
|
||||
+ App-Icon-Platzhalter + Release-Checklist. 35 Unit-Tests + 1 UI-Test
|
||||
grün, alle drei Targets (Haupt-App + Widget + Share) bauen.
|
||||
**Stand: 2026-05-13 — TestFlight Build 11 (v0.9.4).** Alle Phasen
|
||||
β-0 bis β-7 + Polish-Iterationen. 43 Unit-Tests + 1 UI-Test grün.
|
||||
|
||||
**Wordeck-Web-Look übernommen** (v0.9.0 ff.): Fan-Stack-Tiles
|
||||
(5:7 Aspect, 3 rotierte Background-Layer), CardSurface in
|
||||
md/lg/hero, RatingBar mit Good-Emphasis. Tap auf Tile = Study-
|
||||
Mode, Pencil-Icon unten rechts = Deck-Detail. Identische Tile-
|
||||
Optik in Decks- und Entdecken-Tab.
|
||||
|
||||
**App-Store-Connect:** 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
|
||||
mit Flugmodus zwischendurch) steht aus — Aufgabe für Till.
|
||||
mit Flugmodus zwischendurch) steht aus.
|
||||
|
||||
> **SOT:** `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`.
|
||||
> Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc
|
||||
|
|
@ -16,18 +23,52 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till.
|
|||
## Aktueller Stand
|
||||
|
||||
✅ **β-0 — Setup (2026-05-12, Tag `v0.1.0`)**
|
||||
- Repo-Skelett unter `git.mana.how/till/cards-native`
|
||||
- `project.yml` mit Bundle-ID `ev.mana.cardecky`, ManaSwiftCore via
|
||||
- Repo-Skelett unter `git.mana.how/till/wordeck-native`
|
||||
- `project.yml` mit Bundle-ID `ev.mana.wordeck`, ManaSwiftCore via
|
||||
`path: ../mana-swift-core`
|
||||
- `AppConfig` als `ManaAppConfig`-Provider:
|
||||
- Auth: `https://auth.mana.how`
|
||||
- API: `https://cardecky-api.mana.how`
|
||||
- Keychain-Service: `ev.mana.cardecky`
|
||||
- `CardsTheme.swift` mit forest-Werten (lokal nachgebaut aus
|
||||
- API: `https://api.wordeck.com`
|
||||
- Keychain-Service: `ev.mana.wordeck`
|
||||
- `WordeckTheme.swift` mit forest-Werten (lokal nachgebaut aus
|
||||
`mana/packages/themes/src/variants/forest.css`)
|
||||
- `LoginView` (Email/PW gegen mana-auth)
|
||||
- 3 Unit-Tests (AppConfig)
|
||||
|
||||
✅ **Post-β-7-Polish (2026-05-13, Tags v0.8.1 → v0.9.4)**
|
||||
|
||||
Live-Fixes nach Apple-Submission und Real-Device-Tests:
|
||||
- `v0.8.1` 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`)**
|
||||
- App-Icon-Platzhalter: `scripts/make-appicon.swift` generiert 1024×1024
|
||||
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)
|
||||
und `totalDueCount`
|
||||
- `WidgetSnapshotStore` schreibt in App-Group-Container
|
||||
`group.ev.mana.cardecky`
|
||||
`group.ev.mana.wordeck`
|
||||
- `DeckListStore.refresh` ruft `updateWidgetSnapshot()` und
|
||||
`WidgetCenter.shared.reloadAllTimelines()` nach jedem Pull
|
||||
- `CardsWidgetExtension`-Target (eigenes app-extension-Bundle):
|
||||
`CardsWidgetBundle` + `CardsDueWidget` mit `StaticConfiguration`,
|
||||
- `WordeckWidgetExtension`-Target (eigenes app-extension-Bundle):
|
||||
`WordeckWidgetBundle` + `CardsDueWidget` mit `StaticConfiguration`,
|
||||
Support für systemSmall, systemMedium, accessoryCircular,
|
||||
accessoryInline, accessoryRectangular
|
||||
- `DueProvider` als `TimelineProvider`: liest Snapshot, plant
|
||||
Refresh alle 30 min (plus instant-Refresh via Haupt-App)
|
||||
- `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
|
||||
- `WidgetSnapshot.swift` in beiden Targets via XcodeGen-source-array
|
||||
(single-source-of-truth)
|
||||
|
|
@ -87,7 +128,7 @@ Widget decken 90% des Native-Polish ab.
|
|||
`PublicDeckDetail`, `ExploreResponse`, `BrowseResponse`,
|
||||
`SubscribeResponse` Codable-DTOs mit snake_case
|
||||
- `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:)
|
||||
- `MarketplaceStore` @Observable mit Explore-State + Browse-State
|
||||
- `ExploreView` mit Featured + Trending Carousels, Browse-Link
|
||||
|
|
@ -97,13 +138,13 @@ Widget decken 90% des Native-Polish ab.
|
|||
- `PublicDeckCard` + `BrowseRow` Komponenten mit forest-Theme
|
||||
- `RootView` → TabBar (Decks / Entdecken / Account) statt Single-View
|
||||
- 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`
|
||||
- `associated-domains: applinks:cardecky.mana.how` im entitlement
|
||||
- `associated-domains: applinks:wordeck.com` im entitlement
|
||||
- 5 neue Marketplace-Decoding-Tests (35 Total grün)
|
||||
|
||||
**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.
|
||||
|
||||
✅ **β-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)
|
||||
- `CardFieldsBuilder.imageOcclusion`, `.audioFront` mit korrekter
|
||||
`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
|
||||
- `MediaCache` actor mit LRU 200 MB (sortiert nach `contentModificationDate`)
|
||||
- `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
|
||||
weggelassen)
|
||||
- `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),
|
||||
Color-Picker mit 8-Preset-Palette aus forest-Theme, Category-Picker
|
||||
(11 Kategorien mit deutschen Labels), Visibility-Segmented-Control
|
||||
|
|
@ -154,7 +195,7 @@ ausgeliefert wird — heute 404. Web-seitige Aufgabe.
|
|||
- `Cloze`-Helpers (extractClusterIds, subIndexCount, clusterId,
|
||||
renderPrompt, renderAnswer, hint) — 1:1-Port aus
|
||||
`cards/packages/cards-domain/src/cloze.ts`
|
||||
- `CardsAPI.dueReviews(deckId:)`, `CardsAPI.gradeReview(...)` mit
|
||||
- `WordeckAPI.dueReviews(deckId:)`, `WordeckAPI.gradeReview(...)` mit
|
||||
ISO8601-Encoder
|
||||
- `PendingGrade` SwiftData-Model + `GradeQueue` für Offline-Submit
|
||||
(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
|
||||
`DeckCategory`, `DeckVisibility`, `FsrsSettings`
|
||||
- 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)
|
||||
- `DeckListStore` orchestriert API + Cache, paralleles Counts-Fetching
|
||||
via TaskGroup
|
||||
|
|
@ -193,6 +234,33 @@ ausgeliefert wird — heute 404. Web-seitige Aufgabe.
|
|||
| β-5 | ✅ 2026-05-13 | Marketplace (Explore/Browse/Subscribe) + TabBar + Universal-Link-Handler (AASA server-side pending) |
|
||||
| β-6 | ✅ 2026-05-13 | Keyboard-Shortcuts + Daily-Reminders + WidgetKit (Siri/Share deferred auf β-7) |
|
||||
| β-7 | ✅ 2026-05-13 | App-Icon-Platzhalter + Siri-Shortcut + Share-Extension + Release-Checklist (externe Apple-Schritte siehe docs/RELEASE_CHECKLIST.md) |
|
||||
| **Wordeck-Cleanup** | ✅ 2026-05-18 | Image-Occlusion + Audio-Front-Code raus (Server seit Migration `0004_wordeck_text_only.sql` text-only). Gelöscht: MediaCache, MediaEnvironment, RemoteImage, AudioPlayerButton, MaskEditorView, CardEditorMediaFields, CardEditorPayload, Media.swift. CardType-Enum auf 5 Werte reduziert, `media_refs` aus Card+CardCreateBody+CardUpdateBody+CardCreate-Call-Sites raus, `WordeckAPI.uploadMedia/.fetchMedia/.deleteMedia` raus, `makeMultipartBody` (Single-File) raus. |
|
||||
| **AI-Media-raus** | ✅ 2026-05-18 | `/decks/from-image`-Endpoint existiert serverseitig gar nicht — gesamten Native-Code rausgenommen: `aiMedia`-Case + Sub-Sections in `DeckEditorView`, `generateDeckFromMedia` + `makeFromImageMultipartBody`, `GenerationMediaFile`-Struct, `PhotosUI`-Import, `PlatformImage`-typealias, `NSPhotoLibraryUsageDescription` aus `project.yml`. ModePicker auf 3 Optionen (Leer/KI/CSV). Auch Test fix: `WordeckNativeTests` nutzt jetzt `ManaSharedKeychainGroup` statt String-Literal. 37/37 Tests grün. |
|
||||
| **ζ-1 (Offline-Sync)** | ✅ 2026-05-18 | `CachedCard` + `CachedDueReview` SwiftData-Models, `DeckListStore.refresh()` zieht Cards+Due-Reviews pro Deck parallel (TaskGroup) und ersetzt den Snapshot atomar. `StudySession.start()` fällt bei Netz-Fehler auf den Cache zurück, setzt `isOfflineSession`-Flag für UX-Banner. `DueReview` bekommt programmatischen `init(review:card:)` für die Rekonstruktion. `ModelContainer` um die zwei Models erweitert (additive Migration, sollte automatisch durchlaufen). DSGVO-Logout-Wipe in `AccountView`: vor jedem `signOut()` und nach `deleteAccount` werden `CachedDeck`+`CachedCard`+`CachedDueReview`+`PendingGrade` aus dem Context gelöscht. iOS-Build grün, swiftlint --strict clean, 37/37 Tests passen. |
|
||||
|
||||
## Geplant: ζ-2..4
|
||||
|
||||
Konzept in [`docs/OFFLINE_SYNC.md`](docs/OFFLINE_SYNC.md).
|
||||
|
||||
| Phase | Inhalt | Aufwand |
|
||||
|---|---|---|
|
||||
| ζ-2 | Distractor-Pool für MC-Karten (pro MC-Karte 10 Distractors mit-cachen) | 0,5 Tag |
|
||||
| ζ-3 | `SettingsView`-Cache-Footprint anzeigen + manueller Cache-Clear | 0,5 Tag |
|
||||
| ζ-4 (optional) | `BGAppRefreshTask`, Wi-Fi-Only-Toggle | 0,5 Tag |
|
||||
|
||||
Server-authoritative-FSRS bleibt — kein lokales FSRS, nur Snapshot.
|
||||
|
||||
## Pflicht-Verifikation für ζ-1 (Endurance auf realem Gerät)
|
||||
|
||||
- [ ] **SwiftData-Migration:** alte App von TestFlight installieren, dann
|
||||
über Xcode mit ζ-1-Build überschreiben — Cache muss durchlaufen, kein
|
||||
Crash. (Additive Schema-Change sollte automatisch gehen, aber unverifiziert.)
|
||||
- [ ] **Offline-Study:** 50+ Karten lernen mit Flugmodus, App killen,
|
||||
neu öffnen, weiter lernen — alle Grades landen am Server nach Reconnect.
|
||||
- [ ] **Logout-Wipe:** Abmelden, anderer Account anmelden — keine Karten/Decks
|
||||
des Vorgängers in der DeckListView sichtbar.
|
||||
- [ ] **Cross-Check mit Web:** Karte offline gegradet → Web zeigt identischen
|
||||
Review-State nach Reload.
|
||||
|
||||
## Nächste Schritte: TestFlight + App-Store
|
||||
|
||||
|
|
@ -203,7 +271,7 @@ Cards-Web-Repo (AASA). Strukturierte Liste in
|
|||
|
||||
1. Apple-Developer-Konfiguration (Team-ID, App-IDs, App-Group, Profiles)
|
||||
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
|
||||
5. Endurance- und Cross-Device-Tests im TestFlight-Beta
|
||||
6. App-Store-Connect-Listing (Description, Screenshots, Privacy)
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -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.**
|
||||
|
||||
> **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,
|
||||
> die iOS-Hardware besser nutzt.
|
||||
|
||||
## 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
|
||||
`../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`.
|
||||
|
||||
```
|
||||
HTTPS/JWT ┌──────────────────┐
|
||||
cards-api ◄───────────── │ cards-native │ SwiftUI
|
||||
cardecky-api.mana.how │ ev.mana.cardecky │ WidgetKit (β-6)
|
||||
cards-api ◄───────────── │ wordeck-native │ SwiftUI
|
||||
api.wordeck.com │ ev.mana.wordeck │ WidgetKit (β-6)
|
||||
└──────────────────┘
|
||||
│
|
||||
┌─────────────────────────────────────────┐
|
||||
|
|
@ -30,7 +30,7 @@ Reachability-Check. Vollständiger Phasen-Plan in
|
|||
|
||||
```bash
|
||||
xcodegen generate
|
||||
open CardsNative.xcodeproj # iPhone-17-Simulator
|
||||
open WordeckNative.xcodeproj # iPhone-17-Simulator
|
||||
```
|
||||
|
||||
Konventionen, Invarianten, Phasen-Disziplin: [`CLAUDE.md`](CLAUDE.md).
|
||||
|
|
|
|||
14
ShareExtension/Resources/WordeckShareExtension.entitlements
Normal file
14
ShareExtension/Resources/WordeckShareExtension.entitlements
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?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>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)ev.mana.session</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -39,7 +39,7 @@ struct ShareEditorView: View {
|
|||
}
|
||||
}
|
||||
Section {
|
||||
Text("Wähle das Ziel-Deck in der Cards-App.")
|
||||
Text("Wähle das Ziel-Deck in der Wordeck-App.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,16 +31,27 @@ final class ShareViewController: UIViewController {
|
|||
group.enter()
|
||||
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in
|
||||
if let url = item as? URL {
|
||||
self.sharedURL = url.absoluteString
|
||||
if self.sharedText.isEmpty { self.sharedText = url.absoluteString }
|
||||
let absolute = url.absoluteString
|
||||
DispatchQueue.main.async {
|
||||
self.sharedURL = absolute
|
||||
if self.sharedText.isEmpty { self.sharedText = absolute }
|
||||
group.leave()
|
||||
}
|
||||
} else {
|
||||
group.leave()
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
} else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||
group.enter()
|
||||
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
|
||||
if let text = item as? String { self.sharedText = text }
|
||||
group.leave()
|
||||
if let text = item as? String {
|
||||
DispatchQueue.main.async {
|
||||
self.sharedText = text
|
||||
group.leave()
|
||||
}
|
||||
} else {
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -72,7 +83,7 @@ final class ShareViewController: UIViewController {
|
|||
|
||||
private func cancel() {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
import ManaCore
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CardsNativeApp: App {
|
||||
let container: ModelContainer
|
||||
@State private var auth: AuthClient
|
||||
private let mediaCache: MediaCache
|
||||
|
||||
init() {
|
||||
do {
|
||||
container = try ModelContainer(for: CachedDeck.self, PendingGrade.self)
|
||||
} catch {
|
||||
fatalError("Failed to init ModelContainer: \(error)")
|
||||
}
|
||||
let auth = AuthClient(config: AppConfig.manaAppConfig)
|
||||
auth.bootstrap()
|
||||
_auth = State(initialValue: auth)
|
||||
mediaCache = MediaCache(api: CardsAPI(auth: auth))
|
||||
Log.app.info("Cards starting — auth status: \(String(describing: auth.status), privacy: .public)")
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environment(auth)
|
||||
.environment(\.mediaCache, mediaCache)
|
||||
.tint(CardsTheme.primary)
|
||||
}
|
||||
.modelContainer(container)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +1,97 @@
|
|||
import ManaAuthUI
|
||||
import ManaCore
|
||||
import SwiftUI
|
||||
|
||||
/// Top-Level-Switch: Login vs Haupt-App. Haupt-App ist eine TabBar mit
|
||||
/// drei Tabs (Decks / Entdecken / Account).
|
||||
/// Top-Level-View: TabBar mit drei Tabs (Decks / Entdecken / Account).
|
||||
/// Kein harter Login-Gate mehr — 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 {
|
||||
@Environment(AuthClient.self) private var auth
|
||||
@Environment(ManaAuthGate.self) private var authGate
|
||||
@State private var selectedTab: AppTab = .decks
|
||||
@State private var pendingDeepLinkSlug: String?
|
||||
@State private var showCreateDeck = false
|
||||
@State private var showSignUpSheet = false
|
||||
@State private var showForgotSheet = false
|
||||
@State private var resetPasswordToken: String?
|
||||
|
||||
private let sourceAppUrl = URL(string: "https://wordeck.com/auth/verify")!
|
||||
private let resetUniversalLink = URL(string: "https://wordeck.com/auth/reset")!
|
||||
|
||||
var body: some View {
|
||||
switch auth.status {
|
||||
case .signedIn:
|
||||
mainTabs
|
||||
.onOpenURL { url in handle(url: url) }
|
||||
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
|
||||
if let url = activity.webpageURL { handle(url: url) }
|
||||
mainTabs
|
||||
.onOpenURL { url in handle(url: url) }
|
||||
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
|
||||
if let url = activity.webpageURL { handle(url: url) }
|
||||
}
|
||||
.manaBrand(WordeckBrand.manaBrand)
|
||||
.manaAuthGate(authGate) {
|
||||
gateSignInContent
|
||||
}
|
||||
.sheet(item: Binding(
|
||||
get: { resetPasswordToken.map(IdentifiedString.init) },
|
||||
set: { resetPasswordToken = $0?.value }
|
||||
)) { token in
|
||||
ManaResetPasswordView(
|
||||
token: token.value,
|
||||
auth: auth,
|
||||
onDone: { resetPasswordToken = nil }
|
||||
)
|
||||
.manaBrand(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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
case .unknown, .signedOut, .signingIn, .error:
|
||||
LoginView()
|
||||
}
|
||||
}
|
||||
|
||||
/// Content für das ``ManaAuthGate``-Sheet — wenn ein gegateter Button
|
||||
/// gedrückt wird, fliegt der User in den Sign-In-Flow. Sign-Up und
|
||||
/// Forgot-Password werden als verschachtelte Sheets aufgeklappt,
|
||||
/// damit aus dem Gate-Sheet alle Auth-Pfade erreichbar bleiben.
|
||||
private var gateSignInContent: some View {
|
||||
NavigationStack {
|
||||
ManaLoginView(
|
||||
auth: auth,
|
||||
onSignUpTapped: { showSignUpSheet = true },
|
||||
onForgotTapped: { showForgotSheet = true }
|
||||
)
|
||||
.manaBrand(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 {
|
||||
TabView(selection: $selectedTab) {
|
||||
DeckListView()
|
||||
DeckListView(showCreate: $showCreateDeck)
|
||||
.tabItem { Label("Decks", systemImage: "rectangle.stack") }
|
||||
.tag(AppTab.decks)
|
||||
|
||||
|
|
@ -38,20 +105,44 @@ struct RootView: View {
|
|||
.tabItem { Label("Account", systemImage: "person.crop.circle") }
|
||||
.tag(AppTab.account)
|
||||
}
|
||||
.decksCreateAccessory(visible: selectedTab == .decks) {
|
||||
authGate.require(reason: "deck-create-accessory") {
|
||||
showCreateDeck = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Universal-Link- und URL-Scheme-Handler:
|
||||
/// - `https://cardecky.mana.how/d/<slug>` → Explore-Tab + PublicDeckView
|
||||
/// - `https://wordeck.com/d/<slug>` → Explore-Tab + PublicDeckView
|
||||
/// - `https://wordeck.com/auth/reset?token=…` → ManaResetPasswordView
|
||||
/// - `cards://study/<deckId>` → später (β-6 Notifications)
|
||||
private func handle(url: URL) {
|
||||
Log.app.info("Open URL: \(url.absoluteString, privacy: .public)")
|
||||
if url.host == "cardecky.mana.how" || url.scheme == "cards" {
|
||||
let parts = url.pathComponents.filter { $0 != "/" }
|
||||
if parts.count >= 2, parts[0] == "d" {
|
||||
pendingDeepLinkSlug = parts[1]
|
||||
selectedTab = .explore
|
||||
guard url.host == "wordeck.com" || url.scheme == "cards" else { return }
|
||||
|
||||
let parts = url.pathComponents.filter { $0 != "/" }
|
||||
|
||||
// Auth-Reset-Link aus der Passwort-Vergessen-Email.
|
||||
if parts == ["auth", "reset"] {
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
if let token = components?.queryItems?.first(where: { $0.name == "token" })?.value {
|
||||
resetPasswordToken = token
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if parts.count >= 2, parts[0] == "d" {
|
||||
pendingDeepLinkSlug = parts[1]
|
||||
selectedTab = .explore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper für `.sheet(item:)` mit einem String-Value (Reset-Token).
|
||||
private struct IdentifiedString: Identifiable {
|
||||
let value: String
|
||||
var id: String {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,3 +151,38 @@ enum AppTab: Hashable {
|
|||
case explore
|
||||
case account
|
||||
}
|
||||
|
||||
private extension View {
|
||||
/// iOS 26: floating „Neues Deck"-Pille via `.tabViewBottomAccessory`,
|
||||
/// nur sichtbar wenn der Decks-Tab aktiv ist. iOS 18 fällt auf den
|
||||
/// bestehenden `.bottomBar`-„+"-Toolbar-Button in `DeckListView` zurück.
|
||||
///
|
||||
/// Den Modifier nur konditional anwenden — sonst rendert das System
|
||||
/// auch bei leerem Inhalt die leere Glass-Hülle (sichtbar als toter
|
||||
/// Streifen über der TabBar auf Entdecken/Account).
|
||||
@ViewBuilder
|
||||
func decksCreateAccessory(visible: Bool, onTap: @escaping () -> Void) -> some View {
|
||||
if #available(iOS 26.0, *), visible {
|
||||
tabViewBottomAccessory {
|
||||
DeckCreateAccessoryPill(action: onTap)
|
||||
}
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 26.0, *)
|
||||
private struct DeckCreateAccessoryPill: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Label("Neues Deck", systemImage: "plus")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.glass)
|
||||
.tint(WordeckTheme.primary)
|
||||
.accessibilityLabel("Neues Deck erstellen")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
Sources/App/WordeckNativeApp.swift
Normal file
39
Sources/App/WordeckNativeApp.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import ManaAuthUI
|
||||
import ManaCore
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct WordeckNativeApp: App {
|
||||
let container: ModelContainer
|
||||
@State private var auth: AuthClient
|
||||
@State private var authGate: ManaAuthGate
|
||||
|
||||
init() {
|
||||
do {
|
||||
container = try ModelContainer(
|
||||
for: CachedDeck.self,
|
||||
CachedCard.self,
|
||||
CachedDueReview.self,
|
||||
PendingGrade.self
|
||||
)
|
||||
} catch {
|
||||
fatalError("Failed to init ModelContainer: \(error)")
|
||||
}
|
||||
let auth = AuthClient(config: AppConfig.manaAppConfig)
|
||||
auth.bootstrap()
|
||||
_auth = State(initialValue: auth)
|
||||
_authGate = State(initialValue: ManaAuthGate(auth: auth))
|
||||
Log.app.info("Wordeck starting — auth status: \(String(describing: auth.status), privacy: .public)")
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environment(auth)
|
||||
.environment(authGate)
|
||||
.tint(WordeckTheme.primary)
|
||||
}
|
||||
.modelContainer(container)
|
||||
}
|
||||
}
|
||||
20
Sources/Core/API/WordeckAPI+Generation.swift
Normal file
20
Sources/Core/API/WordeckAPI+Generation.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import Foundation
|
||||
import ManaCore
|
||||
|
||||
/// AI-Deck-Generierung aus Text-Prompt — 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 10–60s
|
||||
/// (synchron, kein Streaming).
|
||||
func generateDeckFromText(_ body: DeckGenerateBody) async throws -> DeckGenerateResponse {
|
||||
let data = try makeJSON(body)
|
||||
let (responseData, http) = try await transport.request(
|
||||
path: "/api/v1/decks/generate",
|
||||
method: "POST",
|
||||
body: data
|
||||
)
|
||||
try ensureOK(http, data: responseData)
|
||||
return try decoder.decode(DeckGenerateResponse.self, from: responseData)
|
||||
}
|
||||
}
|
||||
59
Sources/Core/API/WordeckAPI+Marketplace.swift
Normal file
59
Sources/Core/API/WordeckAPI+Marketplace.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
import Foundation
|
||||
import ManaCore
|
||||
|
||||
/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
|
||||
/// aus ManaCore, der die Cardecky-Endpoints kennt.
|
||||
actor CardsAPI {
|
||||
private let transport: AuthenticatedTransport
|
||||
private let decoder: JSONDecoder
|
||||
// swiftlint:disable type_body_length
|
||||
|
||||
/// Wordeck-API-Client. Wrapper um `AuthenticatedTransport`
|
||||
/// aus ManaCore, der die Wordeck-Endpoints kennt. Marketplace-Moderation
|
||||
/// + Self-Endpoints + AI-Generation sind in `WordeckAPI+Marketplace.swift`
|
||||
/// und `WordeckAPI+Generation.swift` ausgelagert.
|
||||
actor WordeckAPI {
|
||||
let transport: AuthenticatedTransport
|
||||
let decoder: JSONDecoder
|
||||
|
||||
init(auth: AuthClient) {
|
||||
transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth)
|
||||
|
|
@ -13,7 +17,7 @@ actor CardsAPI {
|
|||
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.
|
||||
func healthCheck() async throws -> Bool {
|
||||
let (_, http) = try await transport.request(path: "/healthz")
|
||||
|
|
@ -44,6 +48,29 @@ actor CardsAPI {
|
|||
return try decoder.decode(CardListResponse.self, from: data).total
|
||||
}
|
||||
|
||||
/// `GET /api/v1/cards?deck_id=...` — komplette Liste der Karten
|
||||
/// für den Browse-Modus im DeckDetailView.
|
||||
func listCards(deckId: String) async throws -> [Card] {
|
||||
let (data, http) = try await transport.request(path: "/api/v1/cards?deck_id=\(deckId)")
|
||||
try ensureOK(http, data: data)
|
||||
return try decoder.decode(CardListResponse.self, from: data).cards
|
||||
}
|
||||
|
||||
/// `GET /api/v1/decks/:deckId/distractors` — N zufällige Feldwerte
|
||||
/// aus anderen Karten desselben Decks. Server-Schema erlaubt nur
|
||||
/// `front`, `back`, `answer`, `question` als field.
|
||||
func distractors(
|
||||
deckId: String,
|
||||
cardId: String,
|
||||
field: String = "answer",
|
||||
count: Int = 3
|
||||
) async throws -> [String] {
|
||||
let path = "/api/v1/decks/\(deckId)/distractors?card_id=\(cardId)&field=\(field)&count=\(count)"
|
||||
let (data, http) = try await transport.request(path: path)
|
||||
try ensureOK(http, data: data)
|
||||
return try decoder.decode(DistractorsResponse.self, from: data).distractors
|
||||
}
|
||||
|
||||
/// `GET /api/v1/reviews/due?deck_id=...&limit=500` — Anzahl fälliger
|
||||
/// Reviews in einem Deck.
|
||||
func dueCount(deckId: String) async throws -> Int {
|
||||
|
|
@ -74,7 +101,7 @@ actor CardsAPI {
|
|||
var items: [URLQueryItem] = [
|
||||
.init(name: "sort", value: sort.rawValue),
|
||||
.init(name: "limit", value: "\(limit)"),
|
||||
.init(name: "offset", value: "\(offset)"),
|
||||
.init(name: "offset", value: "\(offset)")
|
||||
]
|
||||
if let query, !query.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
items.append(.init(name: "q", value: query))
|
||||
|
|
@ -120,44 +147,6 @@ actor CardsAPI {
|
|||
try ensureOK(http, data: data)
|
||||
}
|
||||
|
||||
// MARK: - Media
|
||||
|
||||
/// `POST /api/v1/media/upload` — Multipart-Upload. Max 25 MiB.
|
||||
/// Erlaubte MIMEs: image/*, audio/*, video/*.
|
||||
func uploadMedia(data: Data, filename: String, mimeType: String) async throws -> MediaUploadResponse {
|
||||
let boundary = "cards-native-\(UUID().uuidString)"
|
||||
let body = makeMultipartBody(
|
||||
file: data,
|
||||
filename: filename,
|
||||
mimeType: mimeType,
|
||||
boundary: boundary
|
||||
)
|
||||
let (response, http) = try await transport.request(
|
||||
path: "/api/v1/media/upload",
|
||||
method: "POST",
|
||||
body: body,
|
||||
contentType: "multipart/form-data; boundary=\(boundary)"
|
||||
)
|
||||
try ensureOK(http, data: response)
|
||||
return try decoder.decode(MediaUploadResponse.self, from: response)
|
||||
}
|
||||
|
||||
/// `GET /api/v1/media/:id` — streamt das Media-File. Antwortet mit
|
||||
/// raw bytes (kein JSON), Caller schreibt das auf Disk via MediaCache.
|
||||
func fetchMedia(id: String) async throws -> Data {
|
||||
let (data, http) = try await transport.request(path: "/api/v1/media/\(id)")
|
||||
guard (200 ..< 300).contains(http.statusCode) else {
|
||||
throw AuthError.serverError(status: http.statusCode, message: "media fetch failed")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/media/:id` — Soft-Forget. (Endpoint heute nicht
|
||||
/// implementiert serverseitig; Stub bleibt für späteren Use.)
|
||||
func deleteMedia(id _: String) async throws {
|
||||
throw AuthError.serverError(status: 501, message: "media delete not implemented on server")
|
||||
}
|
||||
|
||||
// MARK: - Deck-Mutations
|
||||
|
||||
/// `POST /api/v1/decks` — Deck anlegen.
|
||||
|
|
@ -195,6 +184,87 @@ actor CardsAPI {
|
|||
try ensureOK(http, data: data)
|
||||
}
|
||||
|
||||
/// `POST /api/v1/decks/:id/duplicate` — Server-seitige Kopie mit
|
||||
/// "(Kopie)"-Suffix, ohne FSRS-Verlauf, ohne Marketplace-Pointer.
|
||||
@discardableResult
|
||||
func duplicateDeck(id: String) async throws -> Deck {
|
||||
let (data, http) = try await transport.request(
|
||||
path: "/api/v1/decks/\(id)/duplicate",
|
||||
method: "POST"
|
||||
)
|
||||
try ensureOK(http, data: data)
|
||||
return try decoder.decode(Deck.self, from: data)
|
||||
}
|
||||
|
||||
/// `POST /api/v1/marketplace/private/:deckId/pull-update` — Smart-Merge-
|
||||
/// Pull. Holt neue/geänderte Karten aus der jüngsten Marketplace-Version
|
||||
/// in das geforkte private Deck. Removed-Karten bleiben lokal (User-
|
||||
/// Choice gewinnt). 422 wenn das Deck kein Fork ist.
|
||||
func pullUpdate(deckId: String) async throws -> PullUpdateResponse {
|
||||
let (data, http) = try await transport.request(
|
||||
path: "/api/v1/marketplace/private/\(deckId)/pull-update",
|
||||
method: "POST"
|
||||
)
|
||||
try ensureOK(http, data: data)
|
||||
return try decoder.decode(PullUpdateResponse.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Marketplace-Publish
|
||||
|
||||
/// `POST /api/v1/marketplace/authors/me` — Author-Profil upserten.
|
||||
/// Pflicht-Schritt vor dem ersten Deck-Init im Marketplace.
|
||||
func upsertAuthor(_ body: AuthorUpsertBody) async throws {
|
||||
let payload = try makeJSON(body)
|
||||
let (data, http) = try await transport.request(
|
||||
path: "/api/v1/marketplace/authors/me",
|
||||
method: "POST",
|
||||
body: payload
|
||||
)
|
||||
try ensureOK(http, data: data)
|
||||
}
|
||||
|
||||
/// `GET /api/v1/marketplace/authors/me` — eigenes Author-Profil
|
||||
/// lesen, gibt `nil` zurück wenn noch keins existiert.
|
||||
func myAuthor() async throws -> Bool {
|
||||
let (data, http) = try await transport.request(path: "/api/v1/marketplace/authors/me")
|
||||
try ensureOK(http, data: data)
|
||||
// Server liefert entweder Author-Objekt oder JSON-null.
|
||||
if let raw = try? JSONSerialization.jsonObject(with: data), raw is NSNull {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// `POST /api/v1/marketplace/decks` — Marketplace-Deck-Init.
|
||||
/// Erstellt nur Metadaten; Karten folgen via `publishMarketplaceVersion`.
|
||||
@discardableResult
|
||||
func initMarketplaceDeck(_ body: MarketplaceDeckInitBody) async throws -> PublicDeck {
|
||||
let payload = try makeJSON(body)
|
||||
let (data, http) = try await transport.request(
|
||||
path: "/api/v1/marketplace/decks",
|
||||
method: "POST",
|
||||
body: payload
|
||||
)
|
||||
try ensureOK(http, data: data)
|
||||
return try decoder.decode(PublicDeck.self, from: data)
|
||||
}
|
||||
|
||||
/// `POST /api/v1/marketplace/decks/:slug/publish` — neue Version
|
||||
/// publishen. Karten werden serverseitig durch AI-Moderation geschickt.
|
||||
func publishMarketplaceVersion(
|
||||
slug: String,
|
||||
body: MarketplacePublishBody
|
||||
) async throws -> MarketplacePublishResponse {
|
||||
let payload = try makeJSON(body)
|
||||
let (data, http) = try await transport.request(
|
||||
path: "/api/v1/marketplace/decks/\(slug)/publish",
|
||||
method: "POST",
|
||||
body: payload
|
||||
)
|
||||
try ensureOK(http, data: data)
|
||||
return try decoder.decode(MarketplacePublishResponse.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Card-Mutations
|
||||
|
||||
/// `POST /api/v1/cards` — Karte anlegen. Server validiert `fields`
|
||||
|
|
@ -270,44 +340,24 @@ actor CardsAPI {
|
|||
|
||||
// MARK: - JSON-Encoding
|
||||
|
||||
private func makeJSON<T: Encodable>(_ value: T) throws -> Data {
|
||||
func makeJSON(_ value: some Encodable) throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
return try encoder.encode(value)
|
||||
}
|
||||
|
||||
// MARK: - Multipart
|
||||
|
||||
private func makeMultipartBody(
|
||||
file: Data,
|
||||
filename: String,
|
||||
mimeType: String,
|
||||
boundary: String
|
||||
) -> Data {
|
||||
var body = Data()
|
||||
let lineBreak = "\r\n"
|
||||
let header = """
|
||||
--\(boundary)\(lineBreak)\
|
||||
Content-Disposition: form-data; name="file"; filename="\(filename)"\(lineBreak)\
|
||||
Content-Type: \(mimeType)\(lineBreak)\(lineBreak)
|
||||
"""
|
||||
body.append(header.data(using: .utf8) ?? Data())
|
||||
body.append(file)
|
||||
body.append(lineBreak.data(using: .utf8) ?? Data())
|
||||
body.append("--\(boundary)--\(lineBreak)".data(using: .utf8) ?? Data())
|
||||
return body
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
|
||||
func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
|
||||
guard (200 ..< 300).contains(http.statusCode) else {
|
||||
let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error
|
||||
throw AuthError.serverError(status: http.statusCode, message: message)
|
||||
throw AuthError.serverError(status: http.statusCode, code: nil, message: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable type_body_length
|
||||
|
||||
private struct CardsServerError: Decodable {
|
||||
let error: String?
|
||||
}
|
||||
|
|
@ -2,14 +2,19 @@ import Foundation
|
|||
import ManaCore
|
||||
|
||||
/// 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).
|
||||
enum AppConfig {
|
||||
static let manaAppConfig: ManaAppConfig = DefaultManaAppConfig(
|
||||
authBaseURL: URL(string: "https://auth.mana.how")!,
|
||||
keychainService: "ev.mana.cardecky",
|
||||
keychainAccessGroup: nil
|
||||
keychainService: ManaSharedKeychainGroup,
|
||||
// Explizit auf TeamID.BundleID, statt nil. Vermeidet Logout
|
||||
// bei TestFlight-Cert-Drift (siehe mana-swift-core v1.5.1).
|
||||
keychainAccessGroup: ManaSharedKeychainGroup,
|
||||
// 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")!
|
||||
}
|
||||
|
|
|
|||
18
Sources/Core/Auth/AuthClient+EnsureSignedIn.swift
Normal file
18
Sources/Core/Auth/AuthClient+EnsureSignedIn.swift
Normal 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
|
||||
}
|
||||
}
|
||||
17
Sources/Core/Auth/DebugCredentials.swift
Normal file
17
Sources/Core/Auth/DebugCredentials.swift
Normal 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
|
||||
164
Sources/Core/Domain/CSVParser.swift
Normal file
164
Sources/Core/Domain/CSVParser.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
import Foundation
|
||||
|
||||
/// Card-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toCardDto`
|
||||
/// und `cards/packages/cards-domain/src/schemas/card.ts`.
|
||||
struct Card: Codable, Identifiable, Hashable, Sendable {
|
||||
/// Card-DTO. Wire-Format aus `wordeck/apps/api/src/lib/dto.ts:toCardDto`
|
||||
/// und `wordeck/packages/wordeck-domain/src/schemas/card.ts`.
|
||||
///
|
||||
/// Seit Wordeck-Rebrand (2026-05-17) text-only — kein `media_refs`
|
||||
/// mehr im Schema.
|
||||
struct Card: Codable, Identifiable, Hashable {
|
||||
let id: String
|
||||
let deckId: String
|
||||
let userId: String
|
||||
let type: CardType
|
||||
let fields: [String: String]
|
||||
let mediaRefs: [String]
|
||||
let contentHash: String?
|
||||
let createdAt: Date
|
||||
let updatedAt: Date
|
||||
|
|
@ -19,22 +21,18 @@ struct Card: Codable, Identifiable, Hashable, Sendable {
|
|||
case userId = "user_id"
|
||||
case type
|
||||
case fields
|
||||
case mediaRefs = "media_refs"
|
||||
case contentHash = "content_hash"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
}
|
||||
|
||||
/// Card-Type-Enum. Vollständig aus `CardTypeSchema`. In β-2 rendern
|
||||
/// wir nur `basic`, `basic-reverse`, `cloze`. Die anderen Types
|
||||
/// kommen in β-3 und β-4 dazu, sind aber jetzt schon decodierbar.
|
||||
enum CardType: String, Codable, Sendable, CaseIterable {
|
||||
/// Card-Type-Enum. Wordeck ist text-only (Rebrand 2026-05-17) —
|
||||
/// `image-occlusion` und `audio-front` sind aus dem Schema raus.
|
||||
enum CardType: String, Codable, CaseIterable {
|
||||
case basic
|
||||
case basicReverse = "basic-reverse"
|
||||
case cloze
|
||||
case imageOcclusion = "image-occlusion"
|
||||
case audioFront = "audio-front"
|
||||
case typing
|
||||
case multipleChoice = "multiple-choice"
|
||||
}
|
||||
|
|
@ -43,7 +41,7 @@ enum CardType: String, Codable, Sendable, CaseIterable {
|
|||
/// Server liefert nur 4 Felder (id, deckId, type, fields) als Drizzle-
|
||||
/// Joined-Subset — Achtung: `deckId` hier in **camelCase**, nicht
|
||||
/// snake_case wie sonst.
|
||||
struct ReviewCard: Codable, Hashable, Sendable {
|
||||
struct ReviewCard: Codable, Hashable {
|
||||
let id: String
|
||||
let deckId: String
|
||||
let type: CardType
|
||||
|
|
|
|||
|
|
@ -8,31 +8,25 @@ import Foundation
|
|||
/// - cloze: `text` (mit `{{cN::...}}`-Clustern)
|
||||
/// - typing: `front`, `answer`
|
||||
/// - multiple-choice: `front`, `answer`
|
||||
/// - image-occlusion: `image_ref`, `mask_regions` (β-4)
|
||||
/// - audio-front: `audio_ref`, `back` (β-4)
|
||||
struct CardCreateBody: Encodable, Sendable {
|
||||
struct CardCreateBody: Encodable {
|
||||
let deckId: String
|
||||
let type: CardType
|
||||
let fields: [String: String]
|
||||
let mediaRefs: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case deckId = "deck_id"
|
||||
case type
|
||||
case fields
|
||||
case mediaRefs = "media_refs"
|
||||
}
|
||||
}
|
||||
|
||||
/// Body für `PATCH /api/v1/cards/:id`. Nur `fields` und `media_refs` —
|
||||
/// Body für `PATCH /api/v1/cards/:id`. Nur `fields` ist änderbar —
|
||||
/// Type und deck_id sind immutable (Server-Schema).
|
||||
struct CardUpdateBody: Encodable, Sendable {
|
||||
struct CardUpdateBody: Encodable {
|
||||
var fields: [String: String]?
|
||||
var mediaRefs: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fields
|
||||
case mediaRefs = "media_refs"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,12 +12,18 @@ import Foundation
|
|||
/// 1-basierte Cluster-ID. Mehrere Cluster pro Karte → mehrere
|
||||
/// Sub-Index-Reviews.
|
||||
enum Cloze {
|
||||
// swiftlint:disable large_tuple
|
||||
|
||||
/// Pattern für `{{cN::answer(::hint)?}}`. Pro Call konstruiert,
|
||||
/// weil `Regex` unter Strict-Concurrency nicht Sendable ist.
|
||||
/// Tuple-Output (whole-match, id, answer, hint?) ist Regex-Builder-
|
||||
/// bedingt — Lint-Regel `large_tuple` greift hier nicht.
|
||||
private static var clusterPattern: Regex<(Substring, Substring, Substring, Substring?)> {
|
||||
#/\{\{c(\d+)::([^}]*?)(?:::([^}]*?))?\}\}/#
|
||||
}
|
||||
|
||||
// swiftlint:enable large_tuple
|
||||
|
||||
/// Distinct Cluster-IDs, sortiert.
|
||||
static func extractClusterIds(_ text: String) -> [Int] {
|
||||
var ids = Set<Int>()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import Foundation
|
|||
|
||||
/// Deck-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toDeckDto`.
|
||||
/// snake_case-Felder via `CodingKeys`, Optionals explizit nullable.
|
||||
struct Deck: Codable, Identifiable, Hashable, Sendable {
|
||||
struct Deck: Codable, Identifiable, Hashable {
|
||||
let id: String
|
||||
let userId: String
|
||||
let name: String
|
||||
|
|
@ -35,20 +35,20 @@ struct Deck: Codable, Identifiable, Hashable, Sendable {
|
|||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
/// Geforkt aus dem Cardecky-Marketplace?
|
||||
/// Geforkt aus dem Wordeck-Marketplace?
|
||||
var isFromMarketplace: Bool {
|
||||
forkedFromMarketplaceDeckId != nil
|
||||
}
|
||||
}
|
||||
|
||||
enum DeckVisibility: String, Codable, Sendable {
|
||||
enum DeckVisibility: String, Codable {
|
||||
case `private`
|
||||
case space
|
||||
case `public`
|
||||
}
|
||||
|
||||
/// Aus `cards/packages/cards-domain/src/schemas/deck.ts:DECK_CATEGORY_IDS`.
|
||||
enum DeckCategory: String, Codable, Sendable, CaseIterable {
|
||||
enum DeckCategory: String, Codable, CaseIterable {
|
||||
case language
|
||||
case medicine
|
||||
case science
|
||||
|
|
@ -82,7 +82,7 @@ enum DeckCategory: String, Codable, Sendable, CaseIterable {
|
|||
/// FSRS-Settings — Native bleibt schematisch agnostisch, FSRS rechnet
|
||||
/// nur der Server. Wir behalten die Felder als roh-JSON, damit eine
|
||||
/// neue Setting auf dem Server uns nicht bricht.
|
||||
struct FsrsSettings: Codable, Sendable, Hashable {
|
||||
struct FsrsSettings: Codable, Hashable {
|
||||
let requestRetention: Double?
|
||||
let maximumInterval: Int?
|
||||
let enableFuzz: Bool?
|
||||
|
|
@ -114,17 +114,23 @@ struct FsrsSettings: Codable, Sendable, Hashable {
|
|||
}
|
||||
|
||||
/// Server-Response von `GET /api/v1/decks`.
|
||||
struct DeckListResponse: Decodable, Sendable {
|
||||
struct DeckListResponse: Decodable {
|
||||
let decks: [Deck]
|
||||
let total: Int
|
||||
}
|
||||
|
||||
/// Server-Response von `GET /api/v1/cards?deck_id=...`.
|
||||
struct CardListResponse: Decodable, Sendable {
|
||||
struct CardListResponse: Decodable {
|
||||
let cards: [Card]
|
||||
let total: Int
|
||||
}
|
||||
|
||||
/// Server-Response von `GET /api/v1/reviews/due?deck_id=...`.
|
||||
struct DueReviewsResponse: Decodable, Sendable {
|
||||
struct DueReviewsResponse: Decodable {
|
||||
let total: Int
|
||||
}
|
||||
|
||||
/// Server-Response von `GET /api/v1/decks/:deckId/distractors`.
|
||||
struct DistractorsResponse: Decodable {
|
||||
let distractors: [String]
|
||||
}
|
||||
|
|
|
|||
34
Sources/Core/Domain/DeckGeneration.swift
Normal file
34
Sources/Core/Domain/DeckGeneration.swift
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import Foundation
|
||||
|
||||
/// Body für `POST /api/v1/decks/generate` — AI-Text-Generierung.
|
||||
/// Aus `wordeck/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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response von `/decks/generate`.
|
||||
struct DeckGenerateResponse: Decodable {
|
||||
let deck: Deck
|
||||
let cardsCreated: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case deck
|
||||
case cardsCreated = "cards_created"
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import Foundation
|
|||
|
||||
/// Body für `POST /api/v1/decks`. Aus `DeckCreateSchema` in
|
||||
/// `cards/packages/cards-domain/src/schemas/deck.ts`.
|
||||
struct DeckCreateBody: Encodable, Sendable {
|
||||
struct DeckCreateBody: Encodable {
|
||||
let name: String
|
||||
let description: String?
|
||||
let color: String?
|
||||
|
|
@ -19,7 +19,7 @@ struct DeckCreateBody: Encodable, Sendable {
|
|||
}
|
||||
|
||||
/// Body für `PATCH /api/v1/decks/:id`. Alle Felder optional plus `archived`.
|
||||
struct DeckUpdateBody: Encodable, Sendable {
|
||||
struct DeckUpdateBody: Encodable {
|
||||
var name: String?
|
||||
var description: String?
|
||||
var color: String?
|
||||
|
|
@ -36,3 +36,37 @@ struct DeckUpdateBody: Encodable, Sendable {
|
|||
case archived
|
||||
}
|
||||
}
|
||||
|
||||
/// Kurze Marketplace-Version-Info: Semver + Version-ID.
|
||||
struct PullUpdateVersion: Decodable {
|
||||
let semver: String
|
||||
let versionId: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case semver
|
||||
case versionId = "version_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// Response von `POST /api/v1/marketplace/private/:deckId/pull-update`.
|
||||
/// `up_to_date == true` heißt: keine neue Marketplace-Version verfügbar,
|
||||
/// die anderen Counts sind dann 0.
|
||||
struct PullUpdateResponse: Decodable {
|
||||
let upToDate: Bool
|
||||
let from: PullUpdateVersion?
|
||||
let to: PullUpdateVersion?
|
||||
let added: Int
|
||||
let changed: Int
|
||||
let removed: Int
|
||||
let cardsInserted: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case upToDate = "up_to_date"
|
||||
case from
|
||||
case to
|
||||
case added
|
||||
case changed
|
||||
case removed
|
||||
case cardsInserted = "cards_inserted"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Foundation
|
||||
|
||||
/// Browse-Eintrag aus `/api/v1/marketplace/decks` und `.../explore`.
|
||||
struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable {
|
||||
struct PublicDeckEntry: Codable, Hashable, Identifiable {
|
||||
let slug: String
|
||||
let title: String
|
||||
let description: String?
|
||||
|
|
@ -16,7 +16,9 @@ struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable {
|
|||
let createdAt: Date
|
||||
let owner: PublicDeckOwner
|
||||
|
||||
var id: String { slug }
|
||||
var id: String {
|
||||
slug
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case slug, title, description, language, category, license
|
||||
|
|
@ -29,15 +31,21 @@ struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable {
|
|||
case owner
|
||||
}
|
||||
|
||||
var isPaid: Bool { priceCredits > 0 }
|
||||
var isPaid: Bool {
|
||||
priceCredits > 0
|
||||
}
|
||||
}
|
||||
|
||||
struct PublicDeckOwner: Codable, Hashable, Sendable {
|
||||
struct PublicDeckOwner: Codable, Hashable {
|
||||
let slug: String
|
||||
let displayName: String
|
||||
let verifiedMana: Bool
|
||||
let verifiedCommunity: Bool
|
||||
let pseudonym: String?
|
||||
/// Flag: Autor tritt pseudonym auf (Anzeigename verbergen).
|
||||
/// Server-Schema ist `boolean NOT NULL DEFAULT false`. War in v0.8.x
|
||||
/// fälschlich als `String?` (Anzeige-Pseudonym) interpretiert —
|
||||
/// führte zu Decoder-typeMismatch beim Öffnen eines Marketplace-Decks.
|
||||
let pseudonym: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case slug
|
||||
|
|
@ -53,24 +61,24 @@ struct PublicDeckOwner: Codable, Hashable, Sendable {
|
|||
displayName = try c.decode(String.self, forKey: .displayName)
|
||||
verifiedMana = try c.decode(Bool.self, forKey: .verifiedMana)
|
||||
verifiedCommunity = try c.decode(Bool.self, forKey: .verifiedCommunity)
|
||||
pseudonym = try c.decodeIfPresent(String.self, forKey: .pseudonym)
|
||||
pseudonym = (try? c.decode(Bool.self, forKey: .pseudonym)) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/// Response von `GET /api/v1/marketplace/explore`.
|
||||
struct ExploreResponse: Decodable, Sendable {
|
||||
struct ExploreResponse: Decodable {
|
||||
let featured: [PublicDeckEntry]
|
||||
let trending: [PublicDeckEntry]
|
||||
}
|
||||
|
||||
/// Response von `GET /api/v1/marketplace/decks`.
|
||||
struct BrowseResponse: Decodable, Sendable {
|
||||
struct BrowseResponse: Decodable {
|
||||
let items: [PublicDeckEntry]
|
||||
let total: Int
|
||||
}
|
||||
|
||||
/// Vollständiges Public-Deck aus `GET /api/v1/marketplace/decks/:slug`.
|
||||
struct PublicDeck: Codable, Hashable, Sendable, Identifiable {
|
||||
struct PublicDeck: Codable, Hashable, Identifiable {
|
||||
let id: String
|
||||
let slug: String
|
||||
let title: String
|
||||
|
|
@ -96,7 +104,7 @@ struct PublicDeck: Codable, Hashable, Sendable, Identifiable {
|
|||
}
|
||||
}
|
||||
|
||||
struct PublicDeckVersion: Codable, Hashable, Sendable, Identifiable {
|
||||
struct PublicDeckVersion: Codable, Hashable, Identifiable {
|
||||
let id: String
|
||||
let deckId: String
|
||||
let semver: String
|
||||
|
|
@ -119,7 +127,7 @@ struct PublicDeckVersion: Codable, Hashable, Sendable, Identifiable {
|
|||
}
|
||||
|
||||
/// Response von `GET /api/v1/marketplace/decks/:slug`.
|
||||
struct PublicDeckDetail: Decodable, Sendable {
|
||||
struct PublicDeckDetail: Decodable {
|
||||
let deck: PublicDeck
|
||||
let latestVersion: PublicDeckVersion?
|
||||
let owner: PublicDeckOwner?
|
||||
|
|
@ -132,7 +140,7 @@ struct PublicDeckDetail: Decodable, Sendable {
|
|||
}
|
||||
|
||||
/// Response von `POST /api/v1/marketplace/decks/:slug/subscribe`.
|
||||
struct SubscribeResponse: Decodable, Sendable {
|
||||
struct SubscribeResponse: Decodable {
|
||||
let subscribed: Bool
|
||||
let deckSlug: String
|
||||
let currentVersionId: String?
|
||||
|
|
@ -147,7 +155,7 @@ struct SubscribeResponse: Decodable, Sendable {
|
|||
}
|
||||
|
||||
/// Browse-Sort-Optionen aus `BrowseQuerySchema`.
|
||||
enum MarketplaceSort: String, Sendable, CaseIterable {
|
||||
enum MarketplaceSort: String, CaseIterable {
|
||||
case recent
|
||||
case popular
|
||||
case trending
|
||||
|
|
|
|||
122
Sources/Core/Domain/MarketplaceModeration.swift
Normal file
122
Sources/Core/Domain/MarketplaceModeration.swift
Normal 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]
|
||||
}
|
||||
107
Sources/Core/Domain/MarketplacePublish.swift
Normal file
107
Sources/Core/Domain/MarketplacePublish.swift
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
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'`.
|
||||
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.
|
||||
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:
|
||||
let front = card.fields["front"] ?? ""
|
||||
let answer = card.fields["answer"] ?? ""
|
||||
return MarketplacePublishCard(
|
||||
type: "type-in",
|
||||
fields: ["question": front, "expected": answer]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
/// Response von `POST /api/v1/media/upload`.
|
||||
struct MediaUploadResponse: Decodable, Sendable {
|
||||
let id: String
|
||||
let url: String
|
||||
let mimeType: String
|
||||
let kind: MediaKind
|
||||
let sizeBytes: Int
|
||||
let originalFilename: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case url
|
||||
case mimeType = "mime_type"
|
||||
case kind
|
||||
case sizeBytes = "size_bytes"
|
||||
case originalFilename = "original_filename"
|
||||
}
|
||||
}
|
||||
|
||||
enum MediaKind: String, Codable, Sendable {
|
||||
case image
|
||||
case audio
|
||||
case video
|
||||
case other
|
||||
}
|
||||
|
||||
/// Image-Occlusion-Mask-Region.
|
||||
/// `mask_regions`-Feld ist ein JSON-Array-**String** in `fields`,
|
||||
/// nicht ein Object — Server-Schema-Constraint (`fields: Record<string,string>`).
|
||||
struct MaskRegion: Codable, Hashable, Sendable, Identifiable {
|
||||
let id: String
|
||||
let x: Double // 0..1 relativ
|
||||
let y: Double
|
||||
let w: Double
|
||||
let h: Double
|
||||
let label: String?
|
||||
|
||||
init(id: String, x: Double, y: Double, w: Double, h: Double, label: String? = nil) {
|
||||
self.id = id
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.w = w
|
||||
self.h = h
|
||||
self.label = label
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers zum Parsen/Serialisieren von `mask_regions` als JSON-String.
|
||||
enum MaskRegions {
|
||||
/// 1:1-Port aus `cards-domain/image-occlusion.ts:parseMaskRegions`.
|
||||
/// Bei Parse- oder Schema-Fehler: leere Liste. Sortiert nach ID
|
||||
/// (lexikographisch, gleich wie Server-Sortierung).
|
||||
static func parse(_ json: String) -> [MaskRegion] {
|
||||
guard let data = json.data(using: .utf8) else { return [] }
|
||||
guard let regions = try? JSONDecoder().decode([MaskRegion].self, from: data) else { return [] }
|
||||
return regions.sorted { $0.id < $1.id }
|
||||
}
|
||||
|
||||
/// Sub-Index → Region (Sortier-Reihenfolge).
|
||||
static func region(for json: String, subIndex: Int) -> MaskRegion? {
|
||||
let all = parse(json)
|
||||
return all.indices.contains(subIndex) ? all[subIndex] : nil
|
||||
}
|
||||
|
||||
/// Anzahl Regionen → Anzahl Sub-Index-Reviews.
|
||||
static func count(_ json: String) -> Int {
|
||||
parse(json).count
|
||||
}
|
||||
|
||||
/// Serialisiert eine Liste zu einem JSON-Array-String fürs `fields`-Feld.
|
||||
static func encode(_ regions: [MaskRegion]) -> String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
guard let data = try? encoder.encode(regions) else { return "[]" }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
extension CardFieldsBuilder {
|
||||
/// `image-occlusion`-Fields: `image_ref` (media_id) +
|
||||
/// `mask_regions` (stringified JSON-Array) + optional `note`.
|
||||
static func imageOcclusion(
|
||||
imageRef: String,
|
||||
regions: [MaskRegion],
|
||||
note: String? = nil
|
||||
) -> [String: String] {
|
||||
var fields: [String: String] = [
|
||||
"image_ref": imageRef,
|
||||
"mask_regions": MaskRegions.encode(regions),
|
||||
]
|
||||
if let note, !note.isEmpty {
|
||||
fields["note"] = note
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
/// `audio-front`-Fields: `audio_ref` (media_id) + `back` (Antwort-Text).
|
||||
static func audioFront(audioRef: String, back: String) -> [String: String] {
|
||||
["audio_ref": audioRef, "back": back]
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import Foundation
|
|||
|
||||
/// Rating-Werte für `POST /reviews/:cardId/:subIndex/grade`.
|
||||
/// Aus `cards/packages/cards-domain/src/schemas/review.ts:RatingSchema`.
|
||||
enum Rating: String, Codable, Sendable, CaseIterable {
|
||||
enum Rating: String, Codable, CaseIterable {
|
||||
case again
|
||||
case hard
|
||||
case good
|
||||
|
|
@ -30,7 +30,7 @@ enum Rating: String, Codable, Sendable, CaseIterable {
|
|||
}
|
||||
|
||||
/// FSRS-Review-State. Aus `ReviewStateSchema`.
|
||||
enum ReviewState: String, Codable, Sendable {
|
||||
enum ReviewState: String, Codable {
|
||||
case new
|
||||
case learning
|
||||
case review
|
||||
|
|
@ -38,7 +38,7 @@ enum ReviewState: String, Codable, Sendable {
|
|||
}
|
||||
|
||||
/// Review-DTO. Wire-Format aus `cards/apps/api/src/routes/reviews.ts:toReviewDto`.
|
||||
struct Review: Codable, Hashable, Sendable {
|
||||
struct Review: Codable, Hashable {
|
||||
let cardId: String
|
||||
let subIndex: Int
|
||||
let userId: String
|
||||
|
|
@ -71,11 +71,21 @@ struct Review: Codable, Hashable, Sendable {
|
|||
}
|
||||
|
||||
/// Eintrag aus `/reviews/due?deck_id=X` — Review + zugehörige Card.
|
||||
struct DueReview: Codable, Hashable, Sendable, Identifiable {
|
||||
struct DueReview: Codable, Hashable, Identifiable {
|
||||
let review: Review
|
||||
let card: ReviewCard
|
||||
|
||||
var id: String { "\(review.cardId)-\(review.subIndex)" }
|
||||
var id: String {
|
||||
"\(review.cardId)-\(review.subIndex)"
|
||||
}
|
||||
|
||||
/// Programmatischer Memberwise-Init — fürs Rekonstruieren aus
|
||||
/// `CachedDueReview` (offline-Fallback). Wird von Swift nicht
|
||||
/// auto-synthesiert, weil der custom `init(from decoder:)` da ist.
|
||||
init(review: Review, card: ReviewCard) {
|
||||
self.review = review
|
||||
self.card = card
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
|
@ -96,13 +106,13 @@ struct DueReview: Codable, Hashable, Sendable, Identifiable {
|
|||
}
|
||||
|
||||
/// Wrapper-Response von `GET /api/v1/reviews/due?deck_id=X`.
|
||||
struct DueReviewsListResponse: Decodable, Sendable {
|
||||
struct DueReviewsListResponse: Decodable {
|
||||
let reviews: [DueReview]
|
||||
let total: Int
|
||||
}
|
||||
|
||||
/// Body für `POST /reviews/:cardId/:subIndex/grade`.
|
||||
struct GradeReviewBody: Encodable, Sendable {
|
||||
struct GradeReviewBody: Encodable {
|
||||
let rating: Rating
|
||||
let reviewedAt: Date
|
||||
|
||||
|
|
|
|||
77
Sources/Core/Domain/Typing.swift
Normal file
77
Sources/Core/Domain/Typing.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import SwiftUI
|
|||
struct StudyCardsIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Karten lernen"
|
||||
static let description = IntentDescription(
|
||||
"Öffnet Cards und zeigt deine Decks mit fälligen Karten.",
|
||||
"Öffnet Wordeck und zeigt deine Decks mit fälligen Karten.",
|
||||
categoryName: "Lernen"
|
||||
)
|
||||
static let openAppWhenRun: Bool = true
|
||||
|
|
@ -29,7 +29,7 @@ struct CardsAppShortcuts: AppShortcutsProvider {
|
|||
phrases: [
|
||||
"Karten lernen mit \(.applicationName)",
|
||||
"Mit \(.applicationName) lernen",
|
||||
"\(.applicationName) öffnen",
|
||||
"\(.applicationName) öffnen"
|
||||
],
|
||||
shortTitle: "Karten lernen",
|
||||
systemImageName: "rectangle.stack"
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ import UserNotifications
|
|||
@MainActor
|
||||
@Observable
|
||||
final class NotificationManager {
|
||||
enum AuthorizationStatus: Sendable {
|
||||
enum AuthorizationStatus {
|
||||
case unknown
|
||||
case authorized
|
||||
case denied
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/// Persistiert User-Pref. Format: ISO-Stunde:Minute (default 18:00).
|
||||
|
|
@ -67,7 +67,7 @@ final class NotificationManager {
|
|||
guard remindersEnabled, authorization == .authorized else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Cards"
|
||||
content.title = "Wordeck"
|
||||
content.body = "Ein paar Karten warten auf dich."
|
||||
content.sound = .default
|
||||
|
||||
|
|
|
|||
43
Sources/Core/Storage/CachedCard.swift
Normal file
43
Sources/Core/Storage/CachedCard.swift
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Lokales Cache-Model für eine einzelne Karte. Wird beim
|
||||
/// `DeckListStore.refresh` mitgezogen, damit die Liste der Karten
|
||||
/// auch offline verfügbar ist. Server bleibt Wahrheit — alle Edits
|
||||
/// laufen über die API, der Cache wird nur beim Re-Fetch aktualisiert.
|
||||
@Model
|
||||
final class CachedCard {
|
||||
@Attribute(.unique) var id: String
|
||||
var deckId: String
|
||||
var userId: String
|
||||
var typeRaw: String
|
||||
var fields: [String: String]
|
||||
var contentHash: String?
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
var lastFetchedAt: Date
|
||||
|
||||
init(card: Card) {
|
||||
id = card.id
|
||||
deckId = card.deckId
|
||||
userId = card.userId
|
||||
typeRaw = card.type.rawValue
|
||||
fields = card.fields
|
||||
contentHash = card.contentHash
|
||||
createdAt = card.createdAt
|
||||
updatedAt = card.updatedAt
|
||||
lastFetchedAt = .now
|
||||
}
|
||||
|
||||
func update(from card: Card) {
|
||||
typeRaw = card.type.rawValue
|
||||
fields = card.fields
|
||||
contentHash = card.contentHash
|
||||
updatedAt = card.updatedAt
|
||||
lastFetchedAt = .now
|
||||
}
|
||||
|
||||
var type: CardType? {
|
||||
CardType(rawValue: typeRaw)
|
||||
}
|
||||
}
|
||||
88
Sources/Core/Storage/CachedDueReview.swift
Normal file
88
Sources/Core/Storage/CachedDueReview.swift
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Snapshot eines `DueReview` (Review + Card-Subset) zum Zeitpunkt des
|
||||
/// letzten Sync. Wird verwendet, wenn `StudySession` keine Verbindung
|
||||
/// zum Server bekommt — der User lernt dann die Karten, die zum Sync-
|
||||
/// Zeitpunkt fällig waren.
|
||||
///
|
||||
/// Server-authoritative-FSRS bleibt: die `due`/`stability`/… Werte
|
||||
/// kommen vom Server, lokal wird nie gerechnet. Beim nächsten Sync
|
||||
/// liefert der Server eine neue Due-Liste.
|
||||
@Model
|
||||
final class CachedDueReview {
|
||||
/// Eindeutiger Schlüssel: `"<cardId>-<subIndex>"`. SwiftData braucht
|
||||
/// einen primären Identifier pro Model, das natürliche compound-
|
||||
/// Schlüssel auf cardId+subIndex.
|
||||
@Attribute(.unique) var compoundId: String
|
||||
|
||||
var cardId: String
|
||||
var subIndex: Int
|
||||
var deckId: String
|
||||
var userId: String
|
||||
|
||||
// Review-State (server-authoritative, hier nur Snapshot)
|
||||
var due: Date
|
||||
var stability: Double
|
||||
var difficulty: Double
|
||||
var elapsedDays: Double
|
||||
var scheduledDays: Double
|
||||
var learningSteps: Int
|
||||
var reps: Int
|
||||
var lapses: Int
|
||||
var stateRaw: String
|
||||
var lastReview: Date?
|
||||
|
||||
// Card-Snapshot (für offline-Rendering — was die Study-View braucht)
|
||||
var cardType: String
|
||||
var cardFields: [String: String]
|
||||
|
||||
var snapshottedAt: Date
|
||||
|
||||
init(dueReview: DueReview, deckId: String, userId: String) {
|
||||
compoundId = "\(dueReview.review.cardId)-\(dueReview.review.subIndex)"
|
||||
cardId = dueReview.review.cardId
|
||||
subIndex = dueReview.review.subIndex
|
||||
self.deckId = deckId
|
||||
self.userId = userId
|
||||
due = dueReview.review.due
|
||||
stability = dueReview.review.stability
|
||||
difficulty = dueReview.review.difficulty
|
||||
elapsedDays = dueReview.review.elapsedDays
|
||||
scheduledDays = dueReview.review.scheduledDays
|
||||
learningSteps = dueReview.review.learningSteps
|
||||
reps = dueReview.review.reps
|
||||
lapses = dueReview.review.lapses
|
||||
stateRaw = dueReview.review.state.rawValue
|
||||
lastReview = dueReview.review.lastReview
|
||||
cardType = dueReview.card.type.rawValue
|
||||
cardFields = dueReview.card.fields
|
||||
snapshottedAt = .now
|
||||
}
|
||||
|
||||
/// Rekonstruiert einen `DueReview` für die `StudySession`-Queue.
|
||||
/// Gibt `nil` zurück, wenn der Type/State im Enum nicht mehr existiert
|
||||
/// (z.B. nach Schema-Migration).
|
||||
func toDueReview() -> DueReview? {
|
||||
guard let state = ReviewState(rawValue: stateRaw),
|
||||
let type = CardType(rawValue: cardType)
|
||||
else { return nil }
|
||||
let review = Review(
|
||||
cardId: cardId,
|
||||
subIndex: subIndex,
|
||||
userId: userId,
|
||||
due: due,
|
||||
stability: stability,
|
||||
difficulty: difficulty,
|
||||
elapsedDays: elapsedDays,
|
||||
scheduledDays: scheduledDays,
|
||||
learningSteps: learningSteps,
|
||||
reps: reps,
|
||||
lapses: lapses,
|
||||
state: state,
|
||||
lastReview: lastReview
|
||||
)
|
||||
let card = ReviewCard(id: cardId, deckId: deckId, type: type, fields: cardFields)
|
||||
return DueReview(review: review, card: card)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,10 +6,14 @@ import WidgetKit
|
|||
|
||||
/// Orchestriert API + SwiftData-Cache für die Deck-Liste.
|
||||
/// View bindet sich an `state` und `errorMessage`.
|
||||
///
|
||||
/// Seit ζ-1 (2026-05-18) zieht der Store auch Karten + Due-Reviews
|
||||
/// pro Deck mit (offline-Read für die Study-View). Siehe
|
||||
/// `docs/OFFLINE_SYNC.md`.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DeckListStore {
|
||||
enum State: Sendable {
|
||||
enum State {
|
||||
case idle
|
||||
case loading
|
||||
case loaded
|
||||
|
|
@ -19,23 +23,33 @@ final class DeckListStore {
|
|||
private(set) var state: State = .idle
|
||||
private(set) var errorMessage: String?
|
||||
|
||||
private let api: CardsAPI
|
||||
private let api: WordeckAPI
|
||||
private let context: ModelContext
|
||||
private let auth: AuthClient
|
||||
|
||||
init(auth: AuthClient, context: ModelContext) {
|
||||
api = CardsAPI(auth: auth)
|
||||
api = WordeckAPI(auth: auth)
|
||||
self.context = context
|
||||
self.auth = auth
|
||||
}
|
||||
|
||||
/// Holt Decks vom Server, aktualisiert Cache. Bei Netzfehler bleibt
|
||||
/// der Cache (offline-readable).
|
||||
/// Holt Decks + Karten + Due-Reviews vom Server, aktualisiert Cache.
|
||||
/// Bei Netzfehler bleibt der Cache (offline-readable). Im Guest-Mode
|
||||
/// wird kein Server-Call versucht.
|
||||
func refresh() async {
|
||||
guard case .signedIn = auth.status else {
|
||||
state = .idle
|
||||
errorMessage = nil
|
||||
return
|
||||
}
|
||||
|
||||
state = .loading
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let decks = try await api.listDecks()
|
||||
try await applyToCache(decks: decks)
|
||||
let perDeck = try await fetchPerDeckPayloads(decks: decks)
|
||||
try await applyToCache(decks: decks, perDeck: perDeck)
|
||||
updateWidgetSnapshot()
|
||||
state = .loaded
|
||||
Log.sync.info("Loaded \(decks.count, privacy: .public) decks from server")
|
||||
|
|
@ -50,58 +64,103 @@ final class DeckListStore {
|
|||
}
|
||||
}
|
||||
|
||||
private func applyToCache(decks remoteDecks: [Deck]) async throws {
|
||||
let remoteIDs = Set(remoteDecks.map(\.id))
|
||||
/// Snapshot pro Deck, geholt in einer parallelen TaskGroup.
|
||||
private struct PerDeckPayload {
|
||||
let cards: [Card]
|
||||
let dueReviews: [DueReview]
|
||||
}
|
||||
|
||||
// 1. Bestehende Cache-Entries finden
|
||||
let descriptor = FetchDescriptor<CachedDeck>()
|
||||
let cached = (try? context.fetch(descriptor)) ?? []
|
||||
let cachedByID = Dictionary(uniqueKeysWithValues: cached.map { ($0.id, $0) })
|
||||
|
||||
// 2. Gelöschte Decks aus Cache entfernen
|
||||
for cachedDeck in cached where !remoteIDs.contains(cachedDeck.id) {
|
||||
context.delete(cachedDeck)
|
||||
}
|
||||
|
||||
// 3. Counts parallel holen
|
||||
let counts = await withTaskGroup(of: (String, Int, Int).self) { group in
|
||||
for deck in remoteDecks {
|
||||
private func fetchPerDeckPayloads(decks: [Deck]) async throws -> [String: PerDeckPayload] {
|
||||
try await withThrowingTaskGroup(of: (String, PerDeckPayload).self) { group in
|
||||
for deck in decks {
|
||||
group.addTask { [api] in
|
||||
async let cards = api.cardCount(deckId: deck.id)
|
||||
async let due = api.dueCount(deckId: deck.id)
|
||||
let cardCount = (try? await cards) ?? 0
|
||||
let dueCount = (try? await due) ?? 0
|
||||
return (deck.id, cardCount, dueCount)
|
||||
async let cards = api.listCards(deckId: deck.id)
|
||||
async let due = api.dueReviews(deckId: deck.id, limit: 500)
|
||||
return try await (deck.id, PerDeckPayload(cards: cards, dueReviews: due))
|
||||
}
|
||||
}
|
||||
var result: [String: (cardCount: Int, dueCount: Int)] = [:]
|
||||
for await (id, c, d) in group {
|
||||
result[id] = (c, d)
|
||||
var result: [String: PerDeckPayload] = [:]
|
||||
for try await (id, payload) in group {
|
||||
result[id] = payload
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Neue/aktualisierte Decks einarbeiten
|
||||
for deck in remoteDecks {
|
||||
let counts = counts[deck.id] ?? (0, 0)
|
||||
if let existing = cachedByID[deck.id] {
|
||||
existing.update(from: deck, cardCount: counts.cardCount, dueCount: counts.dueCount)
|
||||
} else {
|
||||
let cachedDeck = CachedDeck(
|
||||
deck: deck,
|
||||
cardCount: counts.cardCount,
|
||||
dueCount: counts.dueCount
|
||||
)
|
||||
context.insert(cachedDeck)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyToCache(
|
||||
decks remoteDecks: [Deck],
|
||||
perDeck: [String: PerDeckPayload]
|
||||
) async throws {
|
||||
applyDecks(remoteDecks, perDeck: perDeck)
|
||||
applyCards(remoteDecks, perDeck: perDeck)
|
||||
applyDueReviews(remoteDecks, perDeck: perDeck)
|
||||
try context.save()
|
||||
}
|
||||
|
||||
private func applyDecks(_ remoteDecks: [Deck], perDeck: [String: PerDeckPayload]) {
|
||||
let remoteIDs = Set(remoteDecks.map(\.id))
|
||||
let cachedDecks = (try? context.fetch(FetchDescriptor<CachedDeck>())) ?? []
|
||||
let cachedDeckByID = Dictionary(uniqueKeysWithValues: cachedDecks.map { ($0.id, $0) })
|
||||
|
||||
for cachedDeck in cachedDecks where !remoteIDs.contains(cachedDeck.id) {
|
||||
context.delete(cachedDeck)
|
||||
}
|
||||
|
||||
for deck in remoteDecks {
|
||||
let cardCount = perDeck[deck.id]?.cards.count ?? 0
|
||||
let dueCount = perDeck[deck.id]?.dueReviews.count ?? 0
|
||||
if let existing = cachedDeckByID[deck.id] {
|
||||
existing.update(from: deck, cardCount: cardCount, dueCount: dueCount)
|
||||
} else {
|
||||
context.insert(CachedDeck(deck: deck, cardCount: cardCount, dueCount: dueCount))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Karten: Upsert pro remoteDeck, Orphans (Karten von gelöschten
|
||||
/// Decks oder serverseits gelöschte Karten) löschen.
|
||||
private func applyCards(_ remoteDecks: [Deck], perDeck: [String: PerDeckPayload]) {
|
||||
let allCachedCards = (try? context.fetch(FetchDescriptor<CachedCard>())) ?? []
|
||||
let cachedCardByID = Dictionary(uniqueKeysWithValues: allCachedCards.map { ($0.id, $0) })
|
||||
var remoteCardIDs: Set<String> = []
|
||||
|
||||
for deck in remoteDecks {
|
||||
guard let cards = perDeck[deck.id]?.cards else { continue }
|
||||
for card in cards {
|
||||
remoteCardIDs.insert(card.id)
|
||||
if let existing = cachedCardByID[card.id] {
|
||||
existing.update(from: card)
|
||||
} else {
|
||||
context.insert(CachedCard(card: card))
|
||||
}
|
||||
}
|
||||
}
|
||||
for cachedCard in allCachedCards where !remoteCardIDs.contains(cachedCard.id) {
|
||||
context.delete(cachedCard)
|
||||
}
|
||||
}
|
||||
|
||||
/// Due-Reviews: Snapshot überschreibt komplett. Server-`due`-Zeiten
|
||||
/// können sich ändern, also kein Merge — voll ersetzen.
|
||||
private func applyDueReviews(_ remoteDecks: [Deck], perDeck: [String: PerDeckPayload]) {
|
||||
let allCachedDues = (try? context.fetch(FetchDescriptor<CachedDueReview>())) ?? []
|
||||
for cached in allCachedDues {
|
||||
context.delete(cached)
|
||||
}
|
||||
for deck in remoteDecks {
|
||||
guard let dues = perDeck[deck.id]?.dueReviews else { continue }
|
||||
for due in dues {
|
||||
context.insert(CachedDueReview(
|
||||
dueReview: due,
|
||||
deckId: deck.id,
|
||||
userId: due.review.userId
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schreibt einen WidgetSnapshot in den shared App-Group-Container
|
||||
/// und fordert WidgetKit auf, alle Widgets neu zu rendern. Wird nach
|
||||
/// jedem erfolgreichen Refresh aufgerufen.
|
||||
/// und fordert WidgetKit auf, alle Widgets neu zu rendern.
|
||||
private func updateWidgetSnapshot() {
|
||||
let descriptor = FetchDescriptor<CachedDeck>(
|
||||
sortBy: [SortDescriptor(\.dueCount, order: .reverse)]
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ final class GradeQueue {
|
|||
private(set) var isDraining = false
|
||||
private(set) var lastDrainError: String?
|
||||
|
||||
private let api: CardsAPI
|
||||
private let api: WordeckAPI
|
||||
private let context: ModelContext
|
||||
|
||||
init(api: CardsAPI, context: ModelContext) {
|
||||
init(api: WordeckAPI, context: ModelContext) {
|
||||
self.api = api
|
||||
self.context = context
|
||||
}
|
||||
|
|
@ -30,8 +30,9 @@ final class GradeQueue {
|
|||
)
|
||||
context.insert(grade)
|
||||
try? context.save()
|
||||
let rawRating = rating.rawValue
|
||||
Log.study.info(
|
||||
"Queued grade for \(cardId, privacy: .public)/\(subIndex, privacy: .public): \(rating.rawValue, privacy: .public)"
|
||||
"Queued grade \(cardId, privacy: .public)/\(subIndex, privacy: .public): \(rawRating, privacy: .public)"
|
||||
)
|
||||
await drain()
|
||||
}
|
||||
|
|
@ -73,8 +74,10 @@ final class GradeQueue {
|
|||
grade.lastError = msg
|
||||
try? context.save()
|
||||
lastDrainError = msg
|
||||
let cid = grade.cardId
|
||||
let sub = grade.subIndex
|
||||
Log.study.notice(
|
||||
"Drain stopped for \(grade.cardId, privacy: .public)/\(grade.subIndex, privacy: .public): \(msg, privacy: .public)"
|
||||
"Drain stopped \(cid, privacy: .public)/\(sub, privacy: .public): \(msg, privacy: .public)"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
import Foundation
|
||||
import ManaCore
|
||||
|
||||
/// Persistenter Disk-Cache für Cards-Media-Files. Bilder/Audio werden
|
||||
/// einmal vom Server geladen und danach lokal serviert — der Server
|
||||
/// setzt `Cache-Control: private, immutable`, das honorieren wir hier.
|
||||
///
|
||||
/// LRU-Verdrängung mit Soft-Limit (Default 200 MB).
|
||||
actor MediaCache {
|
||||
private let root: URL
|
||||
private let api: CardsAPI
|
||||
private let maxBytes: Int
|
||||
|
||||
init(api: CardsAPI, maxBytes: Int = 200 * 1024 * 1024) {
|
||||
self.api = api
|
||||
self.maxBytes = maxBytes
|
||||
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
root = caches.appendingPathComponent("cards-media", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
/// Liefert die lokale URL eines Media-Files. Lädt vom Server, falls
|
||||
/// nicht im Cache. Wirft `AuthError`, wenn der Download scheitert.
|
||||
func localURL(for mediaId: String) async throws -> URL {
|
||||
let target = root.appendingPathComponent(mediaId)
|
||||
if FileManager.default.fileExists(atPath: target.path) {
|
||||
try? FileManager.default.setAttributes([.modificationDate: Date.now], ofItemAtPath: target.path)
|
||||
return target
|
||||
}
|
||||
let data = try await api.fetchMedia(id: mediaId)
|
||||
try data.write(to: target, options: .atomic)
|
||||
try? await pruneIfNeeded()
|
||||
return target
|
||||
}
|
||||
|
||||
/// Direktes Lesen — für UI-Komponenten, die `Data` brauchen (z.B. AVAudioPlayer).
|
||||
func data(for mediaId: String) async throws -> Data {
|
||||
try Data(contentsOf: try await localURL(for: mediaId))
|
||||
}
|
||||
|
||||
/// LRU-Eviction: bei Überschreitung des Limits ältesten zuerst löschen.
|
||||
private func pruneIfNeeded() async throws {
|
||||
let resourceKeys: Set<URLResourceKey> = [.fileSizeKey, .contentModificationDateKey]
|
||||
guard let items = try? FileManager.default.contentsOfDirectory(
|
||||
at: root,
|
||||
includingPropertiesForKeys: Array(resourceKeys)
|
||||
) else { return }
|
||||
|
||||
let withMeta = items.compactMap { url -> (url: URL, size: Int, date: Date)? in
|
||||
let values = try? url.resourceValues(forKeys: resourceKeys)
|
||||
guard let size = values?.fileSize, let date = values?.contentModificationDate else { return nil }
|
||||
return (url, size, date)
|
||||
}
|
||||
|
||||
let totalBytes = withMeta.reduce(0) { $0 + $1.size }
|
||||
guard totalBytes > maxBytes else { return }
|
||||
|
||||
let sortedOldestFirst = withMeta.sorted { $0.date < $1.date }
|
||||
var remaining = totalBytes
|
||||
for item in sortedOldestFirst {
|
||||
if remaining <= maxBytes { break }
|
||||
try? FileManager.default.removeItem(at: item.url)
|
||||
remaining -= item.size
|
||||
Log.sync.info("MediaCache evicted \(item.url.lastPathComponent, privacy: .public) (\(item.size, privacy: .public)B)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Wipe — für Sign-out o.ä.
|
||||
func clear() {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Environment-Key, der den shared `MediaCache` durch die View-Hierarchie
|
||||
/// reicht. App-Entrypoint setzt den Wert; Views lesen via
|
||||
/// `@Environment(\.mediaCache)`.
|
||||
private struct MediaCacheKey: EnvironmentKey {
|
||||
static let defaultValue: MediaCache? = nil
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var mediaCache: MediaCache? {
|
||||
get { self[MediaCacheKey.self] }
|
||||
set { self[MediaCacheKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import Foundation
|
|||
/// Inbox für Share-Extension. Die Extension persistiert hier, die
|
||||
/// Haupt-App liest beim Start und zeigt einen Banner mit
|
||||
/// "→ Als Karte speichern". Shared App-Group-Container.
|
||||
struct PendingShare: Codable, Identifiable, Hashable, Sendable {
|
||||
struct PendingShare: Codable, Identifiable, Hashable {
|
||||
let id: String
|
||||
let text: String
|
||||
let sourceURL: String?
|
||||
|
|
@ -18,7 +18,7 @@ struct PendingShare: Codable, Identifiable, Hashable, Sendable {
|
|||
}
|
||||
|
||||
enum PendingShareStore {
|
||||
static let appGroupID = "group.ev.mana.cardecky"
|
||||
static let appGroupID = "group.ev.mana.wordeck"
|
||||
static let filename = "pending-shares.json"
|
||||
|
||||
static var url: URL? {
|
||||
|
|
@ -39,7 +39,7 @@ enum PendingShareStore {
|
|||
/// kann ein Eintrag verloren gehen — akzeptabel, weil Extension nur
|
||||
/// schreibt wenn User aktiv "Teilen" tippt.
|
||||
static func append(_ share: PendingShare) {
|
||||
guard let url else { return }
|
||||
guard url != nil else { return }
|
||||
var all = readAll()
|
||||
all.append(share)
|
||||
write(all)
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ import Foundation
|
|||
///
|
||||
/// Wire ist bewusst stabil + schmal — nur was das Widget rendert.
|
||||
/// Neue Felder dürfen additiv dazukommen, alte Felder bleiben.
|
||||
struct WidgetSnapshot: Codable, Sendable {
|
||||
struct WidgetSnapshot: Codable {
|
||||
let updatedAt: Date
|
||||
let totalDueCount: Int
|
||||
let topDecks: [Entry]
|
||||
|
||||
struct Entry: Codable, Sendable, Identifiable {
|
||||
let id: String // deck-id
|
||||
struct Entry: Codable, Identifiable {
|
||||
let id: String // deck-id
|
||||
let name: String
|
||||
let dueCount: Int
|
||||
let colorHex: String?
|
||||
|
|
@ -22,7 +22,7 @@ struct WidgetSnapshot: Codable, Sendable {
|
|||
/// Liest und schreibt WidgetSnapshot in den shared App-Group-Container.
|
||||
enum WidgetSnapshotStore {
|
||||
/// 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 var snapshotURL: URL? {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import Foundation
|
||||
import ManaCore
|
||||
import OSLog
|
||||
|
||||
/// App-eigene OSLog-Logger unter Subsystem `ev.mana.cardecky`.
|
||||
/// ManaCore loggt unter `ev.mana.core` parallel — siehe
|
||||
/// `mana-swift-core/Sources/ManaCore/Telemetry/CoreLog.swift`.
|
||||
/// App-eigene OSLog-Logger gegen ``AppConfig/manaAppConfig``. Standard-
|
||||
/// Kategorien (`app`/`auth`/`api`) kommen aus ``ManaAppLog`` (mana-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 {
|
||||
static let app = Logger(subsystem: "ev.mana.cardecky", category: "app")
|
||||
static let auth = Logger(subsystem: "ev.mana.cardecky", category: "auth")
|
||||
static let api = Logger(subsystem: "ev.mana.cardecky", category: "api")
|
||||
static let study = Logger(subsystem: "ev.mana.cardecky", category: "study")
|
||||
static let sync = Logger(subsystem: "ev.mana.cardecky", category: "sync")
|
||||
private static let mana = ManaAppLog(AppConfig.manaAppConfig)
|
||||
|
||||
static let app = mana.app
|
||||
static let auth = mana.auth
|
||||
static let api = mana.api
|
||||
static let study = mana.category("study")
|
||||
static let sync = mana.category("sync")
|
||||
}
|
||||
|
|
|
|||
108
Sources/Core/Theme/CardSurface.swift
Normal file
108
Sources/Core/Theme/CardSurface.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
25
Sources/Core/Theme/WordeckBrand.swift
Normal file
25
Sources/Core/Theme/WordeckBrand.swift
Normal 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
|
||||
)
|
||||
}
|
||||
29
Sources/Core/Theme/WordeckTheme.swift
Normal file
29
Sources/Core/Theme/WordeckTheme.swift
Normal 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
|
||||
}
|
||||
|
|
@ -1,59 +1,219 @@
|
|||
import ManaAuthUI
|
||||
import ManaCore
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct AccountView: View {
|
||||
@Environment(AuthClient.self) private var auth
|
||||
@Environment(ManaAuthGate.self) private var authGate
|
||||
@Environment(\.modelContext) private var context
|
||||
@State private var showChangeEmail = false
|
||||
@State private var showChangePassword = false
|
||||
@State private var showDeleteAccount = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CardsTheme.background.ignoresSafeArea()
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "person.crop.circle.fill")
|
||||
.resizable()
|
||||
.frame(width: 80, height: 80)
|
||||
.foregroundStyle(CardsTheme.primary)
|
||||
|
||||
if let email = auth.currentEmail {
|
||||
Text(email)
|
||||
.font(.headline)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
WordeckTheme.background.ignoresSafeArea()
|
||||
Group {
|
||||
switch auth.status {
|
||||
case .signedIn:
|
||||
signedInContent
|
||||
case .guest, .signedOut, .error, .unknown:
|
||||
guestContent
|
||||
case .signingIn, .twoFactorRequired:
|
||||
ProgressView().tint(WordeckTheme.primary)
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
SettingsView()
|
||||
} label: {
|
||||
Label("Einstellungen", systemImage: "gear")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8))
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(CardsTheme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task { await auth.signOut() }
|
||||
} label: {
|
||||
Text("Abmelden")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
|
||||
.foregroundStyle(CardsTheme.error)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.padding(.top, 48)
|
||||
}
|
||||
.navigationTitle("Account")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.manaBrand(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: {
|
||||
Task { await wipeLocalCache() }
|
||||
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".
|
||||
//
|
||||
// DSGVO: Cache (Karten + Due-Reviews + Decks +
|
||||
// pending Grades) wird vor dem signOut gewipet, damit
|
||||
// ein anderer User auf demselben Gerät keine Daten
|
||||
// des Vorgängers sieht.
|
||||
Task {
|
||||
await wipeLocalCache()
|
||||
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)
|
||||
}
|
||||
|
||||
/// Löscht alle lokal gecachten User-Daten: Decks, Karten, fällige
|
||||
/// Reviews und die offline Grade-Queue. Wird vor jedem signOut und
|
||||
/// vor Account-Löschung aufgerufen.
|
||||
private func wipeLocalCache() async {
|
||||
try? context.delete(model: CachedDeck.self)
|
||||
try? context.delete(model: CachedCard.self)
|
||||
try? context.delete(model: CachedDueReview.self)
|
||||
try? context.delete(model: PendingGrade.self)
|
||||
try? context.save()
|
||||
Log.app.info("Local cache wiped (signOut / delete-account)")
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
122
Sources/Features/Decks/DeckCoverTile.swift
Normal file
122
Sources/Features/Decks/DeckCoverTile.swift
Normal 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
|
||||
}
|
||||
|
|
@ -2,8 +2,16 @@ import ManaCore
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
/// Deck-Detail mit Aktionen: Lernen, Karte hinzufügen, Bearbeiten, Löschen.
|
||||
/// Wird per Tap auf eine Deck-Row aus der DeckListView geöffnet.
|
||||
// swiftlint:disable file_length
|
||||
// swiftlint:disable type_body_length
|
||||
|
||||
/// Deck-Detail mit Aktionen + Card-Liste. Wird per Tap auf eine Deck-Row
|
||||
/// aus der DeckListView geöffnet.
|
||||
///
|
||||
/// `type_body_length` ist bewusst übersprungen — Detail-View hostet
|
||||
/// 5 verschiedene Sheets (Edit, CardCreate, CardEdit, Publish, Print),
|
||||
/// Confirmation-Dialog + Alerts; aufspalten ginge nur über Multi-State-
|
||||
/// Plumbing zwischen Parent und Children.
|
||||
struct DeckDetailView: View {
|
||||
let deckId: String
|
||||
|
||||
|
|
@ -17,6 +25,18 @@ struct DeckDetailView: View {
|
|||
@State private var showDeleteConfirm = false
|
||||
@State private var navigateToStudy = false
|
||||
@State private var deleteError: String?
|
||||
@State private var editingCard: Card?
|
||||
|
||||
@State private var cards: [Card] = []
|
||||
@State private var isLoadingCards = false
|
||||
@State private var cardsError: String?
|
||||
|
||||
@State private var isPullingUpdate = false
|
||||
@State private var isDuplicating = false
|
||||
@State private var pullAlert: AlertMessage?
|
||||
@State private var actionError: String?
|
||||
@State private var showPublishSheet = false
|
||||
@State private var showPrintSheet = false
|
||||
|
||||
init(deckId: String) {
|
||||
self.deckId = deckId
|
||||
|
|
@ -25,12 +45,12 @@ struct DeckDetailView: View {
|
|||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CardsTheme.background.ignoresSafeArea()
|
||||
WordeckTheme.background.ignoresSafeArea()
|
||||
if let deck = decks.first {
|
||||
content(deck: deck)
|
||||
} else {
|
||||
ContentUnavailableView("Deck nicht gefunden", systemImage: "questionmark.folder")
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
}
|
||||
.navigationTitle(decks.first?.name ?? "")
|
||||
|
|
@ -49,11 +69,44 @@ struct DeckDetailView: View {
|
|||
}
|
||||
.sheet(isPresented: $showCardEditor) {
|
||||
NavigationStack {
|
||||
CardEditorView(deckId: deckId) { _ in
|
||||
Task { await refreshAfterEdit() }
|
||||
CardEditorView(mode: .create(deckId: deckId)) { _ in
|
||||
Task {
|
||||
await refreshAfterEdit()
|
||||
await loadCards()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingCard) { card in
|
||||
NavigationStack {
|
||||
CardEditorView(mode: .edit(card: card)) { _ in
|
||||
Task {
|
||||
await refreshAfterEdit()
|
||||
await loadCards()
|
||||
editingCard = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPublishSheet) {
|
||||
if let deck = decks.first {
|
||||
NavigationStack {
|
||||
MarketplacePublishView(privateDeck: deck) { _ in
|
||||
showPublishSheet = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPrintSheet) {
|
||||
NavigationStack {
|
||||
DeckPrintView(deckId: deckId)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Fertig") { showPrintSheet = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Deck löschen?",
|
||||
isPresented: $showDeleteConfirm,
|
||||
|
|
@ -64,28 +117,56 @@ struct DeckDetailView: View {
|
|||
}
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Alle Karten und Reviews dieses Decks werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.")
|
||||
Text(
|
||||
"""
|
||||
Alle Karten und Reviews dieses Decks werden ebenfalls \
|
||||
gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
"""
|
||||
)
|
||||
}
|
||||
.navigationDestination(isPresented: $navigateToStudy) {
|
||||
if let deck = decks.first {
|
||||
StudySessionView(deckId: deck.id, deckName: deck.name)
|
||||
}
|
||||
}
|
||||
.task(id: deckId) {
|
||||
await loadCards()
|
||||
}
|
||||
.refreshable {
|
||||
await loadCards()
|
||||
}
|
||||
.alert(item: $pullAlert) { alert in
|
||||
Alert(title: Text(alert.title), message: Text(alert.message), dismissButton: .default(Text("OK")))
|
||||
}
|
||||
.alert(
|
||||
"Aktion fehlgeschlagen",
|
||||
isPresented: Binding(
|
||||
get: { actionError != nil },
|
||||
set: { if !$0 { actionError = nil } }
|
||||
),
|
||||
presenting: actionError
|
||||
) { _ in
|
||||
Button("OK") { actionError = nil }
|
||||
} message: { message in
|
||||
Text(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func content(deck: CachedDeck) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header(deck: deck)
|
||||
actions(deck: deck)
|
||||
if let deleteError {
|
||||
Text(deleteError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(CardsTheme.error)
|
||||
.padding(.horizontal, 16)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header(deck: deck)
|
||||
actions(deck: deck)
|
||||
if let deleteError {
|
||||
Text(deleteError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(WordeckTheme.error)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
cardListSection
|
||||
}
|
||||
Spacer()
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
|
||||
private func header(deck: CachedDeck) -> some View {
|
||||
|
|
@ -93,25 +174,25 @@ struct DeckDetailView: View {
|
|||
HStack {
|
||||
Text(deck.name)
|
||||
.font(.title.bold())
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
if deck.isFromMarketplace {
|
||||
Image(systemName: "globe")
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
}
|
||||
if let description = deck.deckDescription, !description.isEmpty {
|
||||
Text(description)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
HStack(spacing: 16) {
|
||||
Label("\(deck.cardCount) Karten", systemImage: "rectangle.stack")
|
||||
if deck.dueCount > 0 {
|
||||
Label("\(deck.dueCount) fällig", systemImage: "clock.badge.exclamationmark")
|
||||
.foregroundStyle(CardsTheme.primary)
|
||||
.foregroundStyle(WordeckTheme.primary)
|
||||
}
|
||||
if let category = deck.category {
|
||||
Text(category.label)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
}
|
||||
.font(.footnote)
|
||||
|
|
@ -121,75 +202,180 @@ struct DeckDetailView: View {
|
|||
|
||||
private func actions(deck: CachedDeck) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
navigateToStudy = true
|
||||
} label: {
|
||||
Label("Karten lernen", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(CardsTheme.primaryForeground)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(deck.dueCount == 0)
|
||||
|
||||
Button {
|
||||
showCardEditor = true
|
||||
} label: {
|
||||
Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(CardsTheme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
showEditor = true
|
||||
} label: {
|
||||
Label("Bearbeiten", systemImage: "pencil")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(CardsTheme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
showDeleteConfirm = true
|
||||
} label: {
|
||||
Label("Löschen", systemImage: "trash")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(CardsTheme.error)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
primaryActions
|
||||
secondaryActions(deck: deck)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var primaryActions: some View {
|
||||
Button {
|
||||
navigateToStudy = true
|
||||
} label: {
|
||||
Label("Karten lernen", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(WordeckTheme.primaryForeground)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled((decks.first?.dueCount ?? 0) == 0)
|
||||
|
||||
Button {
|
||||
showCardEditor = true
|
||||
} label: {
|
||||
Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(WordeckTheme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func secondaryActions(deck: CachedDeck) -> some View {
|
||||
DeckSecondaryActions(
|
||||
isForkedFromMarketplace: deck.isFromMarketplace,
|
||||
isPullingUpdate: isPullingUpdate,
|
||||
isDuplicating: isDuplicating,
|
||||
onPullUpdate: { Task { await pullUpdate() } },
|
||||
onDuplicate: { Task { await duplicate() } },
|
||||
onPublish: { showPublishSheet = true },
|
||||
onPrint: { showPrintSheet = true },
|
||||
onEdit: { showEditor = true },
|
||||
onDelete: { showDeleteConfirm = true }
|
||||
)
|
||||
}
|
||||
|
||||
private var cardListSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Karten")
|
||||
.font(.headline)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
Spacer()
|
||||
if !cards.isEmpty {
|
||||
Text("\(cards.count)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
let store = DeckListStore(auth: auth, context: context)
|
||||
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 {
|
||||
deleteError = nil
|
||||
let api = CardsAPI(auth: auth)
|
||||
let api = WordeckAPI(auth: auth)
|
||||
do {
|
||||
try await api.deleteDeck(id: deckId)
|
||||
// Cache nachziehen
|
||||
if let deck = decks.first {
|
||||
context.delete(deck)
|
||||
try? context.save()
|
||||
|
|
@ -200,3 +386,71 @@ 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"] ?? "—"
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +1,82 @@
|
|||
import ManaAuthUI
|
||||
import ManaCore
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
/// β-1 Hauptbildschirm: Liste aller Decks mit Card- und Due-Counts.
|
||||
/// Web-Vorbild: `cards/apps/web/src/routes/decks/+page.svelte`.
|
||||
/// Navigations-Routen für die DeckListView. Tap auf eine Tile geht
|
||||
/// direkt in Study-Mode, Tap auf den Edit-Button in den Deck-Detail-
|
||||
/// View für Browse + Edit.
|
||||
enum DeckRoute: Hashable {
|
||||
case study(deckId: String, deckName: String)
|
||||
case detail(deckId: String)
|
||||
}
|
||||
|
||||
// swiftlint:disable type_body_length
|
||||
|
||||
/// Decks-Hauptbildschirm im Wordeck-Look: horizontale Scroll-Reihen
|
||||
/// mit Fan-Stack-Karten-Tiles. Web-Vorbild:
|
||||
/// `cards/apps/web/src/routes/decks/+page.svelte`.
|
||||
struct DeckListView: View {
|
||||
@Environment(AuthClient.self) private var auth
|
||||
@Environment(ManaAuthGate.self) private var authGate
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck]
|
||||
|
||||
@Binding var showCreate: Bool
|
||||
|
||||
private var isGuest: Bool {
|
||||
if case .signedIn = auth.status { false } else { true }
|
||||
}
|
||||
|
||||
@State private var store: DeckListStore?
|
||||
@State private var showAccount = false
|
||||
@State private var showCreate = false
|
||||
@State private var pendingShares: [PendingShare] = []
|
||||
@State private var path = NavigationPath()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
NavigationStack(path: $path) {
|
||||
ZStack {
|
||||
CardsTheme.background.ignoresSafeArea()
|
||||
WordeckTheme.background.ignoresSafeArea()
|
||||
content
|
||||
}
|
||||
.navigationTitle("Decks")
|
||||
.navigationDestination(for: String.self) { deckId in
|
||||
DeckDetailView(deckId: deckId)
|
||||
}
|
||||
.navigationDestination(for: PendingShareRoute.self) { route in
|
||||
PendingShareConsumeView(share: route.share, onDone: {
|
||||
PendingShareStore.remove(id: route.share.id)
|
||||
pendingShares = PendingShareStore.readAll()
|
||||
})
|
||||
}
|
||||
.toolbar { toolbar }
|
||||
.refreshable {
|
||||
await store?.refresh()
|
||||
}
|
||||
.sheet(isPresented: $showCreate) {
|
||||
NavigationStack {
|
||||
DeckEditorView(mode: .create) { _ in
|
||||
Task { await store?.refresh() }
|
||||
#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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if store == nil {
|
||||
store = DeckListStore(auth: auth, context: context)
|
||||
.navigationDestination(for: PendingShareRoute.self) { route in
|
||||
PendingShareConsumeView(share: route.share, onDone: {
|
||||
PendingShareStore.remove(id: route.share.id)
|
||||
pendingShares = PendingShareStore.readAll()
|
||||
})
|
||||
}
|
||||
await store?.refresh()
|
||||
pendingShares = PendingShareStore.readAll()
|
||||
}
|
||||
.onAppear {
|
||||
pendingShares = PendingShareStore.readAll()
|
||||
}
|
||||
.sheet(isPresented: $showAccount) {
|
||||
NavigationStack {
|
||||
AccountView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Fertig") { showAccount = false }
|
||||
}
|
||||
.toolbar { toolbar }
|
||||
.refreshable {
|
||||
await store?.refresh()
|
||||
}
|
||||
.sheet(isPresented: $showCreate) {
|
||||
NavigationStack {
|
||||
DeckEditorView(mode: .create) { _ in
|
||||
Task { await store?.refresh() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if store == nil {
|
||||
store = DeckListStore(auth: auth, context: context)
|
||||
}
|
||||
await store?.refresh()
|
||||
pendingShares = PendingShareStore.readAll()
|
||||
}
|
||||
.onAppear {
|
||||
pendingShares = PendingShareStore.readAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,45 +85,135 @@ struct DeckListView: View {
|
|||
if decks.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
List {
|
||||
pendingShareSection
|
||||
inboxBannerSection
|
||||
ownDecksSection
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
pendingShareSection
|
||||
inboxBanner
|
||||
deckSection(title: "Eigene Decks", icon: "rectangle.stack", decks: ownDecks)
|
||||
if !subscribedDecks.isEmpty {
|
||||
deckSection(title: "Abonniert", icon: "globe", decks: subscribedDecks)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
private var ownDecks: [CachedDeck] {
|
||||
decks.filter { !$0.isFromMarketplace }
|
||||
}
|
||||
|
||||
private var subscribedDecks: [CachedDeck] {
|
||||
decks.filter(\.isFromMarketplace)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func deckSection(title: String, icon: String, decks: [CachedDeck]) -> some View {
|
||||
if !decks.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(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
|
||||
private var pendingShareSection: some View {
|
||||
if !pendingShares.isEmpty {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(pendingShares) { share in
|
||||
NavigationLink(value: PendingShareRoute(share: share)) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "square.and.arrow.down")
|
||||
.foregroundStyle(CardsTheme.primary)
|
||||
.foregroundStyle(WordeckTheme.warning)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Aus Teilen-Menü")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
Text(share.text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
.padding()
|
||||
.background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(14)
|
||||
.background(
|
||||
WordeckTheme.warning.opacity(0.12),
|
||||
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,168 +221,75 @@ struct DeckListView: View {
|
|||
VStack(spacing: 16) {
|
||||
if store?.state == .loading {
|
||||
ProgressView()
|
||||
.tint(CardsTheme.primary)
|
||||
.tint(WordeckTheme.primary)
|
||||
Text("Lade Decks …")
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
} else if let message = store?.errorMessage {
|
||||
ContentUnavailableView {
|
||||
Label("Decks konnten nicht geladen werden", systemImage: "wifi.exclamationmark")
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
} description: {
|
||||
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 {
|
||||
ContentUnavailableView {
|
||||
Label("Noch keine Decks", systemImage: "rectangle.stack")
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
} description: {
|
||||
Text("Erstelle dein erstes Deck auf cardecky.mana.how oder ziehe nach unten zum Aktualisieren.")
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
Text(
|
||||
"""
|
||||
Tippe unten auf »+«, um dein erstes Deck zu erstellen, \
|
||||
oder browse den Marketplace im Entdecken-Tab.
|
||||
"""
|
||||
)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var inboxBannerSection: some View {
|
||||
if let inbox = decks.first(where: { $0.isFromMarketplace && $0.dueCount > 0 }) {
|
||||
Section {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "tray.full.fill")
|
||||
.foregroundStyle(CardsTheme.primary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Inbox")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
Text("\(inbox.dueCount) fällige Karten aus abonnierten Decks")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var ownDecksSection: some View {
|
||||
Section {
|
||||
ForEach(decks) { deck in
|
||||
NavigationLink(value: deck.id) {
|
||||
DeckRow(deck: deck)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
showCreate = true
|
||||
} label: {
|
||||
Image(systemName: "plus.circle")
|
||||
.foregroundStyle(CardsTheme.primary)
|
||||
}
|
||||
.accessibilityLabel("Deck hinzufügen")
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showAccount = true
|
||||
} label: {
|
||||
Image(systemName: accountIcon)
|
||||
.foregroundStyle(CardsTheme.primary)
|
||||
}
|
||||
.accessibilityLabel("Account")
|
||||
}
|
||||
}
|
||||
|
||||
private var accountIcon: String {
|
||||
if case .signedIn = auth.status { return "person.crop.circle.fill" }
|
||||
return "person.crop.circle.badge.exclamationmark"
|
||||
}
|
||||
}
|
||||
|
||||
/// Einzelne Deck-Zeile in der Liste.
|
||||
struct DeckRow: View {
|
||||
let deck: CachedDeck
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Farbiger Streifen aus deck.color (Hex), default forest-primary
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(deckColor)
|
||||
.frame(width: 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(deck.name)
|
||||
.font(.headline)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
if deck.isFromMarketplace {
|
||||
Image(systemName: "globe")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
}
|
||||
}
|
||||
|
||||
if let category = deck.category {
|
||||
Text(category.label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Label("\(deck.cardCount)", systemImage: "rectangle.stack")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
if deck.dueCount > 0 {
|
||||
Label("\(deck.dueCount) fällig", systemImage: "clock.badge.exclamationmark")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(CardsTheme.primary)
|
||||
// Auf iOS 26 übernimmt das `.tabViewBottomAccessory` aus RootView die
|
||||
// „Neues Deck"-Pille. Doppelten „+"-Button im Liquid-Glass-Layout
|
||||
// vermeiden — bottomBar-Button nur auf iOS < 26 zeigen.
|
||||
if #unavailable(iOS 26.0) {
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
Button {
|
||||
authGate.require(reason: "deck-create-toolbar") {
|
||||
showCreate = true
|
||||
}
|
||||
} label: {
|
||||
Label("Deck hinzufügen", systemImage: "plus")
|
||||
.labelStyle(.iconOnly)
|
||||
.foregroundStyle(WordeckTheme.primary)
|
||||
}
|
||||
.accessibilityLabel("Deck hinzufügen")
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 12)
|
||||
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private var deckColor: Color {
|
||||
guard let hex = deck.color, let rgb = parseHex(hex) else {
|
||||
return CardsTheme.primary
|
||||
}
|
||||
return Color.manaHexLocal(rgb)
|
||||
}
|
||||
|
||||
private func parseHex(_ hex: String) -> UInt32? {
|
||||
var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.hasPrefix("#") { trimmed = String(trimmed.dropFirst()) }
|
||||
return UInt32(trimmed, radix: 16)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Color {
|
||||
/// Lokales Hex-Helper analog zu `ManaTokens.Color.manaHex`. Hier
|
||||
/// dupliziert, weil DeckRow nicht von ManaTokens abhängen muss.
|
||||
static func manaHexLocal(_ rgb: UInt32) -> Color {
|
||||
let r = Double((rgb >> 16) & 0xFF) / 255.0
|
||||
let g = Double((rgb >> 8) & 0xFF) / 255.0
|
||||
let b = Double(rgb & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable type_body_length
|
||||
|
|
|
|||
56
Sources/Features/Decks/DeckPrintView.swift
Normal file
56
Sources/Features/Decks/DeckPrintView.swift
Normal 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
|
||||
138
Sources/Features/Decks/DeckSecondaryActions.swift
Normal file
138
Sources/Features/Decks/DeckSecondaryActions.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
85
Sources/Features/Decks/DeckStackTile.swift
Normal file
85
Sources/Features/Decks/DeckStackTile.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ struct PendingShareConsumeView: View {
|
|||
Section("Ziel-Deck") {
|
||||
if decks.isEmpty {
|
||||
Text("Erst ein Deck erstellen.")
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
} else {
|
||||
Picker("Deck", selection: $selectedDeckId) {
|
||||
Text("Wählen …").tag(String?.none)
|
||||
|
|
@ -55,14 +55,14 @@ struct PendingShareConsumeView: View {
|
|||
Section("Quelle") {
|
||||
Text(sourceURL)
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
}
|
||||
if let errorMessage {
|
||||
Section {
|
||||
Text(errorMessage)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(CardsTheme.error)
|
||||
.foregroundStyle(WordeckTheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -92,12 +92,11 @@ struct PendingShareConsumeView: View {
|
|||
defer { isSubmitting = false }
|
||||
|
||||
let backText = back.trimmed.isEmpty ? (share.sourceURL ?? "—") : back.trimmed
|
||||
let api = CardsAPI(auth: auth)
|
||||
let api = WordeckAPI(auth: auth)
|
||||
let body = CardCreateBody(
|
||||
deckId: deckId,
|
||||
type: .basic,
|
||||
fields: CardFieldsBuilder.basic(front: front.trimmed, back: backText),
|
||||
mediaRefs: nil
|
||||
fields: CardFieldsBuilder.basic(front: front.trimmed, back: backText)
|
||||
)
|
||||
do {
|
||||
_ = try await api.createCard(body)
|
||||
|
|
|
|||
78
Sources/Features/Editor/CSVImportFormSections.swift
Normal file
78
Sources/Features/Editor/CSVImportFormSections.swift
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +1,86 @@
|
|||
import ManaCore
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Card-Create-View. Type-Picker oben, type-spezifische Felder unten.
|
||||
/// 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.
|
||||
struct CardEditorView: View {
|
||||
let deckId: String
|
||||
let onCreated: (Card) -> Void
|
||||
enum Mode {
|
||||
case create(deckId: String)
|
||||
case edit(card: Card)
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
let onSaved: (Card) -> Void
|
||||
|
||||
@Environment(AuthClient.self) private var auth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var type: CardType = .basic
|
||||
@State private var front: String = ""
|
||||
@State private var back: String = ""
|
||||
@State private var clozeText: String = ""
|
||||
@State private var typingAnswer: String = ""
|
||||
@State private var multipleChoiceAnswer: String = ""
|
||||
@State private var type: CardType
|
||||
@State private var front: String
|
||||
@State private var back: String
|
||||
@State private var clozeText: String
|
||||
@State private var typingAnswer: String
|
||||
@State private var multipleChoiceAnswer: String
|
||||
@State private var isSubmitting = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
// Image-Occlusion-State
|
||||
@State private var imagePickerItem: PhotosPickerItem?
|
||||
@State private var occlusionImage: PlatformImage?
|
||||
@State private var occlusionImageData: Data?
|
||||
@State private var occlusionMimeType: String = "image/jpeg"
|
||||
@State private var occlusionRegions: [MaskRegion] = []
|
||||
@State private var occlusionNote: String = ""
|
||||
|
||||
// Audio-Front-State
|
||||
@State private var audioFileURL: URL?
|
||||
@State private var showAudioPicker = false
|
||||
|
||||
private static let supportedTypes: [CardType] = [
|
||||
.basic, .basicReverse, .cloze, .typing, .multipleChoice,
|
||||
.imageOcclusion, .audioFront,
|
||||
.basic, .basicReverse, .cloze, .typing, .multipleChoice
|
||||
]
|
||||
|
||||
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 = ""
|
||||
|
||||
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"] ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
_type = State(initialValue: initialType)
|
||||
_front = State(initialValue: initialFront)
|
||||
_back = State(initialValue: initialBack)
|
||||
_clozeText = State(initialValue: initialCloze)
|
||||
_typingAnswer = State(initialValue: initialTyping)
|
||||
_multipleChoiceAnswer = State(initialValue: initialMC)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Card-Type") {
|
||||
Picker("Typ", selection: $type) {
|
||||
ForEach(Self.supportedTypes, id: \.self) { t in
|
||||
Text(label(for: t)).tag(t)
|
||||
if isCreate {
|
||||
Section("Card-Type") {
|
||||
Picker("Typ", selection: $type) {
|
||||
ForEach(Self.supportedTypes, id: \.self) { cardType in
|
||||
Text(label(for: cardType)).tag(cardType)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
|
||||
typeFields
|
||||
|
|
@ -58,11 +89,12 @@ struct CardEditorView: View {
|
|||
Section {
|
||||
Text(errorMessage)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(CardsTheme.error)
|
||||
.foregroundStyle(WordeckTheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Neue Karte")
|
||||
.disabled(isSubmitting)
|
||||
.navigationTitle(isCreate ? "Neue Karte" : "Karte bearbeiten")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
|
|
@ -71,8 +103,10 @@ struct CardEditorView: View {
|
|||
Button("Abbrechen") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Erstellen") { Task { await submit() } }
|
||||
.disabled(!canSubmit || isSubmitting)
|
||||
Button(isCreate ? "Erstellen" : "Speichern") {
|
||||
Task { await submit() }
|
||||
}
|
||||
.disabled(!canSubmit || isSubmitting)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -93,33 +127,36 @@ struct CardEditorView: View {
|
|||
Section {
|
||||
Text("Beide Richtungen werden gelernt — front→back und back→front.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
}
|
||||
|
||||
case .cloze:
|
||||
Section("Cloze-Text") {
|
||||
TextField("Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.",
|
||||
text: $clozeText, axis: .vertical)
|
||||
.lineLimit(3 ... 8)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.monospaced()
|
||||
TextField(
|
||||
"Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.",
|
||||
text: $clozeText,
|
||||
axis: .vertical
|
||||
)
|
||||
.lineLimit(3 ... 8)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.monospaced()
|
||||
}
|
||||
Section {
|
||||
let count = Cloze.subIndexCount(clozeText)
|
||||
if count > 0 {
|
||||
Label("\(count) Lücken erkannt → \(count) Reviews", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.success)
|
||||
.foregroundStyle(WordeckTheme.success)
|
||||
} else {
|
||||
Label("Mindestens ein Cluster `{{c1::...}}` erforderlich", systemImage: "exclamationmark.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.warning)
|
||||
.foregroundStyle(WordeckTheme.warning)
|
||||
}
|
||||
Text("Mit Hint: `{{c1::Berlin::Hauptstadt von DE}}`")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
|
||||
case .typing:
|
||||
|
|
@ -142,127 +179,14 @@ struct CardEditorView: View {
|
|||
Section {
|
||||
Text("Distractor-Optionen werden zur Lernzeit automatisch aus anderen Karten desselben Decks gezogen.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
}
|
||||
|
||||
case .imageOcclusion:
|
||||
imageOcclusionFields
|
||||
|
||||
case .audioFront:
|
||||
audioFrontFields
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var imageOcclusionFields: some View {
|
||||
Section("Bild") {
|
||||
PhotosPicker(selection: $imagePickerItem, matching: .images) {
|
||||
if occlusionImage == nil {
|
||||
Label("Bild auswählen", systemImage: "photo")
|
||||
} else {
|
||||
Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
.onChange(of: imagePickerItem) { _, newItem in
|
||||
Task { await loadPickedImage(newItem) }
|
||||
}
|
||||
}
|
||||
|
||||
if let image = occlusionImage {
|
||||
Section("Masken") {
|
||||
MaskEditorView(image: image, regions: $occlusionRegions)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Hinweis (optional)") {
|
||||
TextField("z.B. Kurz-Erklärung", text: $occlusionNote, axis: .vertical)
|
||||
.lineLimit(1 ... 3)
|
||||
}
|
||||
|
||||
Section {
|
||||
if occlusionImage == nil {
|
||||
Label("Erst Bild wählen", systemImage: "info.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
} else if occlusionRegions.isEmpty {
|
||||
Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.warning)
|
||||
} else {
|
||||
Label("\(occlusionRegions.count) Masken → \(occlusionRegions.count) Reviews",
|
||||
systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.success)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 isCreate: Bool {
|
||||
if case .create = mode { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
|
|
@ -275,10 +199,22 @@ struct CardEditorView: View {
|
|||
!front.trimmed.isEmpty && !typingAnswer.trimmed.isEmpty
|
||||
case .multipleChoice:
|
||||
!front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty
|
||||
case .imageOcclusion:
|
||||
occlusionImageData != nil && !occlusionRegions.isEmpty
|
||||
case .audioFront:
|
||||
audioFileURL != nil && !back.trimmed.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
private func buildFields() -> [String: String] {
|
||||
switch type {
|
||||
case .basic, .basicReverse:
|
||||
CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed)
|
||||
case .cloze:
|
||||
CardFieldsBuilder.cloze(text: clozeText.trimmed)
|
||||
case .typing:
|
||||
CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed)
|
||||
case .multipleChoice:
|
||||
CardFieldsBuilder.multipleChoice(
|
||||
front: front.trimmed,
|
||||
answer: multipleChoiceAnswer.trimmed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,50 +222,21 @@ struct CardEditorView: View {
|
|||
isSubmitting = true
|
||||
errorMessage = nil
|
||||
defer { isSubmitting = false }
|
||||
let api = CardsAPI(auth: auth)
|
||||
let api = WordeckAPI(auth: auth)
|
||||
let fields = buildFields()
|
||||
|
||||
do {
|
||||
let fields: [String: String]
|
||||
var mediaRefs: [String]? = nil
|
||||
switch type {
|
||||
case .basic, .basicReverse:
|
||||
fields = CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed)
|
||||
case .cloze:
|
||||
fields = CardFieldsBuilder.cloze(text: clozeText.trimmed)
|
||||
case .typing:
|
||||
fields = CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed)
|
||||
case .multipleChoice:
|
||||
fields = CardFieldsBuilder.multipleChoice(front: front.trimmed, answer: multipleChoiceAnswer.trimmed)
|
||||
case .imageOcclusion:
|
||||
guard let data = occlusionImageData else { return }
|
||||
let media = try await api.uploadMedia(
|
||||
data: data,
|
||||
filename: "occlusion.\(occlusionMimeType.contains("png") ? "png" : "jpg")",
|
||||
mimeType: occlusionMimeType
|
||||
)
|
||||
fields = CardFieldsBuilder.imageOcclusion(
|
||||
imageRef: media.id,
|
||||
regions: occlusionRegions,
|
||||
note: occlusionNote.trimmed.isEmpty ? nil : occlusionNote.trimmed
|
||||
)
|
||||
mediaRefs = [media.id]
|
||||
case .audioFront:
|
||||
guard let url = audioFileURL else { return }
|
||||
let didStart = url.startAccessingSecurityScopedResource()
|
||||
defer { if didStart { url.stopAccessingSecurityScopedResource() } }
|
||||
let data = try Data(contentsOf: url)
|
||||
let media = try await api.uploadMedia(
|
||||
data: data,
|
||||
filename: url.lastPathComponent,
|
||||
mimeType: audioMimeType(for: url)
|
||||
)
|
||||
fields = CardFieldsBuilder.audioFront(audioRef: media.id, back: back.trimmed)
|
||||
mediaRefs = [media.id]
|
||||
let card: Card = switch mode {
|
||||
case let .create(deckId):
|
||||
try await api.createCard(CardCreateBody(
|
||||
deckId: deckId,
|
||||
type: type,
|
||||
fields: fields
|
||||
))
|
||||
case let .edit(existing):
|
||||
try await api.updateCard(id: existing.id, body: CardUpdateBody(fields: fields))
|
||||
}
|
||||
|
||||
let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: mediaRefs)
|
||||
let card = try await api.createCard(body)
|
||||
onCreated(card)
|
||||
onSaved(card)
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||
|
|
@ -343,8 +250,6 @@ struct CardEditorView: View {
|
|||
case .cloze: "Lückentext"
|
||||
case .typing: "Eintippen"
|
||||
case .multipleChoice: "Multiple Choice"
|
||||
case .imageOcclusion: "Bild-Verdeckung"
|
||||
case .audioFront: "Audio"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
Sources/Features/Editor/DeckEditorHelpers.swift
Normal file
55
Sources/Features/Editor/DeckEditorHelpers.swift
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import Foundation
|
||||
import ManaCore
|
||||
|
||||
/// Konstanten für `DeckEditorView` — Farbpalette.
|
||||
/// Werte gespiegelt aus dem `forest`-Theme.
|
||||
enum DeckEditorPresets {
|
||||
/// 8 Farb-Presets aus dem forest-Theme. Freie Hex-Werte später
|
||||
/// via Custom-Picker.
|
||||
static let colors: [String] = [
|
||||
"#10803D", // forest primary light
|
||||
"#1E3A2F", // forest dark
|
||||
"#D97706", // amber
|
||||
"#DC2626", // red
|
||||
"#2563EB", // blue
|
||||
"#7C3AED", // violet
|
||||
"#0D9488", // teal
|
||||
"#737373" // neutral
|
||||
]
|
||||
}
|
||||
|
||||
/// 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"
|
||||
}
|
||||
|
||||
/// AuthError-Server-Codes auf nutzerfreundliche deutsche Texte mappen.
|
||||
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."
|
||||
}
|
||||
}
|
||||
|
|
@ -1,187 +1,547 @@
|
|||
import ManaCore
|
||||
import SwiftUI
|
||||
|
||||
/// Deck-Create und Deck-Edit in einer View. `existing == nil` → Create-
|
||||
/// Modus mit "Erstellen"-Button. Sonst Edit-Modus mit "Speichern".
|
||||
// swiftlint:disable file_length
|
||||
// swiftlint:disable type_body_length
|
||||
|
||||
/// Deck-Create und Deck-Edit in einer View. Im Create-Modus stehen drei
|
||||
/// Sub-Modi zur Wahl: manuell („Leer"), AI-Text („Mit KI") und CSV.
|
||||
/// Edit-Modus zeigt nur das manuelle Formular.
|
||||
///
|
||||
/// Web-Vorbild: `wordeck/apps/web/src/routes/decks/new/+page.svelte`.
|
||||
struct DeckEditorView: View {
|
||||
enum Mode: Sendable {
|
||||
enum Mode {
|
||||
case create
|
||||
case edit(deckId: String)
|
||||
}
|
||||
|
||||
/// Drei Sub-Modi im Create-Sheet.
|
||||
enum CreateMode: Hashable {
|
||||
case manual
|
||||
case aiText
|
||||
case csv
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
let onSaved: (Deck) -> Void
|
||||
|
||||
@Environment(AuthClient.self) private var auth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// Manual fields (Edit + Create.manual)
|
||||
@State private var name: String
|
||||
@State private var description: String
|
||||
@State private var color: String
|
||||
@State private var category: DeckCategory?
|
||||
@State private var visibility: DeckVisibility
|
||||
@State private var isSubmitting = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var archived: Bool
|
||||
|
||||
/// Vorgefüllte Farbpalette aus dem forest-Theme. User können
|
||||
/// freie Hex-Werte später via Picker setzen (β-3-extension).
|
||||
private static let presetColors: [String] = [
|
||||
"#10803D", // forest primary light
|
||||
"#1E3A2F", // forest dark
|
||||
"#D97706", // amber
|
||||
"#DC2626", // red
|
||||
"#2563EB", // blue
|
||||
"#7C3AED", // violet
|
||||
"#0D9488", // teal
|
||||
"#737373", // neutral
|
||||
]
|
||||
/// Create-mode selector
|
||||
@State private var createMode: CreateMode = .manual
|
||||
|
||||
// AI-Text
|
||||
@State private var aiPrompt: String = ""
|
||||
@State private var aiCount: Int = 15
|
||||
@State private var aiLanguage: GenerationLanguage = .de
|
||||
@State private var aiUrl: String = ""
|
||||
|
||||
// CSV-Import
|
||||
@State private var csvRows: [CSVRow] = []
|
||||
@State private var csvDeckName: String = ""
|
||||
@State private var showCSVImporter: Bool = false
|
||||
@State private var csvImportProgress: Int = 0
|
||||
|
||||
// Submission
|
||||
@State private var isSubmitting = false
|
||||
@State private var generationTask: Task<Void, Never>?
|
||||
@State private var errorMessage: String?
|
||||
|
||||
init(mode: Mode, existing: CachedDeck? = nil, onSaved: @escaping (Deck) -> Void) {
|
||||
self.mode = mode
|
||||
self.onSaved = onSaved
|
||||
_name = State(initialValue: existing?.name ?? "")
|
||||
_description = State(initialValue: existing?.deckDescription ?? "")
|
||||
_color = State(initialValue: existing?.color ?? Self.presetColors[0])
|
||||
_color = State(initialValue: existing?.color ?? DeckEditorPresets.colors[0])
|
||||
_category = State(initialValue: existing?.category)
|
||||
_visibility = State(initialValue: DeckVisibility(rawValue: existing?.visibilityRaw ?? "private") ?? .private)
|
||||
_archived = State(initialValue: existing?.archivedAt != nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Name") {
|
||||
TextField("Deck-Name", text: $name)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
}
|
||||
|
||||
Section("Beschreibung") {
|
||||
TextField("optional", text: $description, axis: .vertical)
|
||||
.lineLimit(2 ... 4)
|
||||
}
|
||||
|
||||
Section("Farbe") {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(Self.presetColors, id: \.self) { hex in
|
||||
colorSwatch(hex)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
ZStack {
|
||||
Form {
|
||||
if isCreate {
|
||||
modePickerSection
|
||||
}
|
||||
formSections
|
||||
errorSection
|
||||
}
|
||||
.disabled(isSubmitting)
|
||||
|
||||
Section("Kategorie") {
|
||||
Picker("Kategorie", selection: $category) {
|
||||
Text("Keine").tag(DeckCategory?.none)
|
||||
ForEach(DeckCategory.allCases, id: \.self) { cat in
|
||||
Text(cat.label).tag(DeckCategory?.some(cat))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Sichtbarkeit") {
|
||||
Picker("Sichtbarkeit", selection: $visibility) {
|
||||
Text("Privat").tag(DeckVisibility.private)
|
||||
Text("Space").tag(DeckVisibility.space)
|
||||
Text("Öffentlich").tag(DeckVisibility.public)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Section {
|
||||
Text(errorMessage)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(CardsTheme.error)
|
||||
}
|
||||
if isSubmitting, activeMode != .manual {
|
||||
GenerationOverlay(
|
||||
message: overlayMessage,
|
||||
onCancel: { generationTask?.cancel() }
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle(isCreate ? "Neues Deck" : "Deck bearbeiten")
|
||||
.navigationTitle(navTitle)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(isCreate ? "Erstellen" : "Speichern") {
|
||||
Task { await submit() }
|
||||
}
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || isSubmitting)
|
||||
}
|
||||
}
|
||||
.toolbar { toolbar }
|
||||
.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("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 .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 .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
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func colorSwatch(_ hex: String) -> some View {
|
||||
let isSelected = color == hex
|
||||
private var activeMode: CreateMode {
|
||||
isCreate ? createMode : .manual
|
||||
}
|
||||
|
||||
private var navTitle: String {
|
||||
switch activeMode {
|
||||
case .manual: isCreate ? "Neues Deck" : "Deck bearbeiten"
|
||||
case .aiText: "Mit KI generieren"
|
||||
case .csv: "Aus CSV importieren"
|
||||
}
|
||||
}
|
||||
|
||||
private var confirmLabel: String {
|
||||
switch activeMode {
|
||||
case .manual: isCreate ? "Erstellen" : "Speichern"
|
||||
case .aiText: "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 .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: - CSV ingest
|
||||
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
// 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, .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:
|
||||
CardFieldsBuilder.basic(front: row.front, back: row.back)
|
||||
case .cloze:
|
||||
CardFieldsBuilder.cloze(text: row.front)
|
||||
case .typing:
|
||||
CardFieldsBuilder.typing(front: row.front, answer: row.back)
|
||||
case .multipleChoice:
|
||||
CardFieldsBuilder.multipleChoice(front: row.front, answer: row.back)
|
||||
}
|
||||
_ = try await api.createCard(CardCreateBody(
|
||||
deckId: deck.id,
|
||||
type: row.type,
|
||||
fields: fields
|
||||
))
|
||||
csvImportProgress = index + 1
|
||||
}
|
||||
return deck
|
||||
}
|
||||
|
||||
private var aiTextBody: DeckGenerateBody {
|
||||
DeckGenerateBody(
|
||||
prompt: aiPrompt.trimmingCharacters(in: .whitespaces),
|
||||
language: aiLanguage,
|
||||
count: aiCount,
|
||||
url: DeckEditorHelpers.nonEmpty(aiUrl)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable type_body_length
|
||||
|
||||
// MARK: - Manual form
|
||||
|
||||
private struct ManualFormSections: View {
|
||||
@Binding var name: String
|
||||
@Binding var description: String
|
||||
@Binding var color: String
|
||||
@Binding var category: DeckCategory?
|
||||
@Binding var visibility: DeckVisibility
|
||||
/// `nil` im Create-Modus — dann wird der Toggle nicht gezeigt.
|
||||
var archived: Binding<Bool>?
|
||||
|
||||
var body: some View {
|
||||
Section("Name") {
|
||||
TextField("Deck-Name", text: $name)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
}
|
||||
|
||||
Section("Beschreibung") {
|
||||
TextField("optional", text: $description, axis: .vertical)
|
||||
.lineLimit(2 ... 4)
|
||||
}
|
||||
|
||||
Section("Farbe") {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(DeckEditorPresets.colors, id: \.self) { hex in
|
||||
ColorSwatchButton(hex: hex, isSelected: color == hex) {
|
||||
color = hex
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Kategorie") {
|
||||
Picker("Kategorie", selection: $category) {
|
||||
Text("Keine").tag(DeckCategory?.none)
|
||||
ForEach(DeckCategory.allCases, id: \.self) { cat in
|
||||
Text(cat.label).tag(DeckCategory?.some(cat))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Sichtbarkeit") {
|
||||
Picker("Sichtbarkeit", selection: $visibility) {
|
||||
Text("Privat").tag(DeckVisibility.private)
|
||||
Text("Space").tag(DeckVisibility.space)
|
||||
Text("Öffentlich").tag(DeckVisibility.public)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if let archived {
|
||||
Section {
|
||||
Toggle("Archiviert", isOn: archived)
|
||||
} footer: {
|
||||
Text("Archivierte Decks erscheinen nicht in der Hauptliste. Bestehende FSRS-Reviews bleiben erhalten.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ColorSwatchButton: View {
|
||||
let hex: String
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(Color.swatchFromHex(hex))
|
||||
.frame(width: 36, height: 36)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1)
|
||||
.stroke(isSelected ? WordeckTheme.foreground : WordeckTheme.border, lineWidth: isSelected ? 3 : 1)
|
||||
)
|
||||
.onTapGesture { color = hex }
|
||||
}
|
||||
|
||||
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
|
||||
.onTapGesture(perform: onTap)
|
||||
}
|
||||
}
|
||||
|
||||
// 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("3–500 Zeichen. Je präziser, desto besser die Karten.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
static func swatchFromHex(_ hex: String) -> Color {
|
||||
var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.hasPrefix("#") { trimmed = String(trimmed.dropFirst()) }
|
||||
guard let rgb = UInt32(trimmed, radix: 16) else {
|
||||
return CardsTheme.primary
|
||||
return WordeckTheme.primary
|
||||
}
|
||||
let r = Double((rgb >> 16) & 0xFF) / 255.0
|
||||
let g = Double((rgb >> 8) & 0xFF) / 255.0
|
||||
let b = Double(rgb & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
let red = Double((rgb >> 16) & 0xFF) / 255.0
|
||||
let green = Double((rgb >> 8) & 0xFF) / 255.0
|
||||
let blue = Double(rgb & 0xFF) / 255.0
|
||||
return Color(red: red, green: green, blue: blue)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Mask-Editor: Bild anzeigen, mit Drag-Gesten Rechtecke zeichnen, jede
|
||||
/// Region mit Label versehen. Coordinaten 0..1 relativ zur Bild-Größe.
|
||||
///
|
||||
/// Output binding ist `regions`. Caller serialisiert via `MaskRegions.encode()`.
|
||||
struct MaskEditorView: View {
|
||||
let image: PlatformImage
|
||||
@Binding var regions: [MaskRegion]
|
||||
|
||||
@State private var dragStart: CGPoint?
|
||||
@State private var dragEnd: CGPoint?
|
||||
@State private var nextIdCounter: Int = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Tippe und ziehe auf das Bild, um eine Maske zu erstellen.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
|
||||
imageCanvas
|
||||
.aspectRatio(image.size.width / max(image.size.height, 1), contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
if regions.isEmpty {
|
||||
Text("Noch keine Maske")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
} else {
|
||||
ForEach(regions) { region in
|
||||
maskRow(region: region)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var imageCanvas: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .topLeading) {
|
||||
#if canImport(UIKit)
|
||||
Image(uiImage: image).resizable().aspectRatio(contentMode: .fit)
|
||||
#else
|
||||
Image(nsImage: image).resizable().aspectRatio(contentMode: .fit)
|
||||
#endif
|
||||
|
||||
ForEach(regions) { region in
|
||||
overlayRect(for: region, in: geo.size)
|
||||
}
|
||||
|
||||
if let dragStart, let dragEnd {
|
||||
let rect = normalizedRect(from: dragStart, to: dragEnd)
|
||||
Rectangle()
|
||||
.stroke(CardsTheme.warning, lineWidth: 2)
|
||||
.background(Rectangle().fill(CardsTheme.warning.opacity(0.2)))
|
||||
.frame(width: rect.width, height: rect.height)
|
||||
.offset(x: rect.minX, y: rect.minY)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 4)
|
||||
.onChanged { value in
|
||||
if dragStart == nil { dragStart = value.startLocation }
|
||||
dragEnd = value.location
|
||||
}
|
||||
.onEnded { value in
|
||||
commitDrag(start: value.startLocation, end: value.location, in: geo.size)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func overlayRect(for region: MaskRegion, in size: CGSize) -> some View {
|
||||
Rectangle()
|
||||
.fill(CardsTheme.primary.opacity(0.6))
|
||||
.frame(width: region.w * size.width, height: region.h * size.height)
|
||||
.offset(x: region.x * size.width, y: region.y * size.height)
|
||||
.overlay(
|
||||
Text(region.label?.isEmpty == false ? region.label! : region.id)
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(CardsTheme.primaryForeground)
|
||||
.padding(2)
|
||||
.offset(x: region.x * size.width + 2, y: region.y * size.height + 2),
|
||||
alignment: .topLeading
|
||||
)
|
||||
}
|
||||
|
||||
private func maskRow(region: MaskRegion) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "square.dashed")
|
||||
.foregroundStyle(CardsTheme.primary)
|
||||
TextField("Label (optional)", text: Binding(
|
||||
get: { region.label ?? "" },
|
||||
set: { newValue in updateLabel(for: region.id, to: newValue) }
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button(role: .destructive) {
|
||||
regions.removeAll { $0.id == region.id }
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.foregroundStyle(CardsTheme.error)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateLabel(for id: String, to value: String) {
|
||||
guard let idx = regions.firstIndex(where: { $0.id == id }) else { return }
|
||||
let old = regions[idx]
|
||||
regions[idx] = MaskRegion(id: old.id, x: old.x, y: old.y, w: old.w, h: old.h, label: value)
|
||||
}
|
||||
|
||||
private func normalizedRect(from start: CGPoint, to end: CGPoint) -> CGRect {
|
||||
let x = min(start.x, end.x)
|
||||
let y = min(start.y, end.y)
|
||||
let w = abs(end.x - start.x)
|
||||
let h = abs(end.y - start.y)
|
||||
return CGRect(x: x, y: y, width: w, height: h)
|
||||
}
|
||||
|
||||
private func commitDrag(start: CGPoint, end: CGPoint, in size: CGSize) {
|
||||
defer {
|
||||
dragStart = nil
|
||||
dragEnd = nil
|
||||
}
|
||||
let rect = normalizedRect(from: start, to: end)
|
||||
// Mindestgröße 1% der Bildkante — Tap-Klicks ignorieren
|
||||
guard rect.width > size.width * 0.01, rect.height > size.height * 0.01 else { return }
|
||||
nextIdCounter += 1
|
||||
let id = String(format: "m%03d", nextIdCounter)
|
||||
let normalized = MaskRegion(
|
||||
id: id,
|
||||
x: rect.minX / size.width,
|
||||
y: rect.minY / size.height,
|
||||
w: rect.width / size.width,
|
||||
h: rect.height / size.height,
|
||||
label: nil
|
||||
)
|
||||
regions.append(normalized)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,10 +9,10 @@ struct BrowseView: View {
|
|||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CardsTheme.background.ignoresSafeArea()
|
||||
WordeckTheme.background.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
filters
|
||||
Divider().background(CardsTheme.border)
|
||||
Divider().background(WordeckTheme.border)
|
||||
resultsList
|
||||
}
|
||||
}
|
||||
|
|
@ -20,8 +20,11 @@ struct BrowseView: View {
|
|||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.searchable(text: $queryText, placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Decks suchen")
|
||||
.searchable(
|
||||
text: $queryText,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Decks suchen"
|
||||
)
|
||||
.onSubmit(of: .search) {
|
||||
store?.browseQuery = queryText
|
||||
Task { await store?.browse() }
|
||||
|
|
@ -62,7 +65,7 @@ struct BrowseView: View {
|
|||
if store.isLoadingBrowse, store.browseResults.isEmpty {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.tint(CardsTheme.primary)
|
||||
.tint(WordeckTheme.primary)
|
||||
Spacer()
|
||||
} else if store.browseResults.isEmpty {
|
||||
ContentUnavailableView(
|
||||
|
|
@ -70,7 +73,7 @@ struct BrowseView: View {
|
|||
systemImage: "magnifyingglass",
|
||||
description: Text("Versuche eine andere Suche oder Sortierung.")
|
||||
)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
} else {
|
||||
List {
|
||||
ForEach(store.browseResults) { entry in
|
||||
|
|
@ -101,17 +104,17 @@ struct BrowseRow: View {
|
|||
HStack {
|
||||
Text(entry.title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
if entry.isFeatured {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.warning)
|
||||
.foregroundStyle(WordeckTheme.warning)
|
||||
}
|
||||
}
|
||||
if let description = entry.description, !description.isEmpty {
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
.lineLimit(2)
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
|
|
@ -119,23 +122,23 @@ struct BrowseRow: View {
|
|||
Label("\(entry.starCount)", systemImage: "star")
|
||||
if entry.isPaid {
|
||||
Label("\(entry.priceCredits)", systemImage: "creditcard")
|
||||
.foregroundStyle(CardsTheme.primary)
|
||||
.foregroundStyle(WordeckTheme.primary)
|
||||
}
|
||||
if let language = entry.language {
|
||||
Text(language.uppercased())
|
||||
.font(.caption2.weight(.semibold))
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 1)
|
||||
.background(CardsTheme.muted, in: Capsule())
|
||||
.background(WordeckTheme.muted, in: Capsule())
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,35 +16,38 @@ struct ExploreView: View {
|
|||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
ZStack {
|
||||
CardsTheme.background.ignoresSafeArea()
|
||||
WordeckTheme.background.ignoresSafeArea()
|
||||
content
|
||||
}
|
||||
.navigationTitle("Entdecken")
|
||||
.navigationDestination(for: MarketplaceRoute.self) { route in
|
||||
switch route {
|
||||
case .browse:
|
||||
BrowseView()
|
||||
case let .publicDeck(slug):
|
||||
PublicDeckView(slug: slug)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.navigationDestination(for: MarketplaceRoute.self) { route in
|
||||
switch route {
|
||||
case .browse:
|
||||
BrowseView()
|
||||
case let .publicDeck(slug):
|
||||
PublicDeckView(slug: slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: String.self) { deckId in
|
||||
DeckDetailView(deckId: deckId)
|
||||
}
|
||||
.refreshable {
|
||||
await store?.loadExplore()
|
||||
}
|
||||
.task {
|
||||
if store == nil {
|
||||
store = MarketplaceStore(auth: auth)
|
||||
.navigationDestination(for: String.self) { deckId in
|
||||
DeckDetailView(deckId: deckId)
|
||||
}
|
||||
.refreshable {
|
||||
await store?.loadExplore()
|
||||
}
|
||||
.task {
|
||||
if store == nil {
|
||||
store = MarketplaceStore(auth: auth)
|
||||
}
|
||||
await store?.loadExplore()
|
||||
}
|
||||
.onChange(of: deepLinkSlug) { _, newSlug in
|
||||
guard let slug = newSlug else { return }
|
||||
path = [.publicDeck(slug: slug)]
|
||||
deepLinkSlug = nil
|
||||
}
|
||||
await store?.loadExplore()
|
||||
}
|
||||
.onChange(of: deepLinkSlug) { _, newSlug in
|
||||
guard let slug = newSlug else { return }
|
||||
path = [.publicDeck(slug: slug)]
|
||||
deepLinkSlug = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,22 +56,22 @@ struct ExploreView: View {
|
|||
if let store {
|
||||
if store.isLoadingExplore, store.featured.isEmpty, store.trending.isEmpty {
|
||||
ProgressView()
|
||||
.tint(CardsTheme.primary)
|
||||
.tint(WordeckTheme.primary)
|
||||
} else if let message = store.errorMessage, store.featured.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Marketplace nicht erreichbar",
|
||||
systemImage: "wifi.exclamationmark",
|
||||
description: Text(message)
|
||||
)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
if !store.featured.isEmpty {
|
||||
section(title: "Vorgestellt", items: store.featured)
|
||||
section(title: "Vorgestellt", icon: "star.fill", items: store.featured)
|
||||
}
|
||||
if !store.trending.isEmpty {
|
||||
section(title: "Im Trend", items: store.trending)
|
||||
section(title: "Im Trend", icon: "flame.fill", items: store.trending)
|
||||
}
|
||||
|
||||
NavigationLink(value: MarketplaceRoute.browse) {
|
||||
|
|
@ -79,40 +82,51 @@ struct ExploreView: View {
|
|||
.font(.footnote)
|
||||
}
|
||||
.padding()
|
||||
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
||||
.background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(CardsTheme.border, lineWidth: 1)
|
||||
.stroke(WordeckTheme.border, lineWidth: 1)
|
||||
)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func section(title: String, items: [PublicDeckEntry]) -> some View {
|
||||
private func section(title: String, icon: String, items: [PublicDeckEntry]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(title)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.padding(.horizontal, 16)
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(WordeckTheme.primary)
|
||||
Text(title)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
Text("\(items.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
ForEach(items) { item in
|
||||
NavigationLink(value: MarketplaceRoute.publicDeck(slug: item.slug)) {
|
||||
PublicDeckCard(entry: item)
|
||||
.frame(width: 240)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 12)
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -123,58 +137,57 @@ enum MarketplaceRoute: Hashable {
|
|||
case publicDeck(slug: String)
|
||||
}
|
||||
|
||||
/// Public-Deck-Karten-Tile in Featured/Trending-Carousels und Browse-Grid.
|
||||
/// Tile für Marketplace-Decks im Explore-Tab. Nutzt `DeckCoverTile`
|
||||
/// als Basis (selber Look + Größe wie `DeckStackTile` auf der Decks-
|
||||
/// Seite). Footer: Karten-Count, Star-Count, Credits, Owner-Badge.
|
||||
struct PublicDeckCard: View {
|
||||
let entry: PublicDeckEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(entry.title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.lineLimit(2)
|
||||
Spacer()
|
||||
if entry.isFeatured {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.warning)
|
||||
}
|
||||
}
|
||||
if let description = entry.description, !description.isEmpty {
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.lineLimit(2)
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
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)
|
||||
DeckCoverTile(
|
||||
title: entry.title,
|
||||
description: entry.description,
|
||||
category: parsedCategory,
|
||||
seed: entry.slug,
|
||||
colorAccentHex: nil,
|
||||
isFeatured: entry.isFeatured
|
||||
) {
|
||||
footerContent
|
||||
}
|
||||
}
|
||||
|
||||
private var parsedCategory: DeckCategory? {
|
||||
guard let category = entry.category else { return nil }
|
||||
return DeckCategory(rawValue: category)
|
||||
}
|
||||
|
||||
private var footerContent: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Label("\(entry.cardCount)", systemImage: "rectangle.stack")
|
||||
.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) {
|
||||
Text(entry.owner.displayName)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
.lineLimit(1)
|
||||
if entry.owner.verifiedMana {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
474
Sources/Features/Marketplace/MarketplacePublishView.swift
Normal file
474
Sources/Features/Marketplace/MarketplacePublishView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -18,10 +18,10 @@ final class MarketplaceStore {
|
|||
var browseSort: MarketplaceSort = .recent
|
||||
var browseLanguage: String?
|
||||
|
||||
private let api: CardsAPI
|
||||
private let api: WordeckAPI
|
||||
|
||||
init(auth: AuthClient) {
|
||||
api = CardsAPI(auth: auth)
|
||||
api = WordeckAPI(auth: auth)
|
||||
}
|
||||
|
||||
func loadExplore() async {
|
||||
|
|
@ -33,8 +33,9 @@ final class MarketplaceStore {
|
|||
featured = res.featured
|
||||
trending = res.trending
|
||||
} catch {
|
||||
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||
Log.api.error("Explore failed: \(self.errorMessage ?? "", privacy: .public)")
|
||||
let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||
errorMessage = message
|
||||
Log.api.error("Explore failed: \(message, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import ManaAuthUI
|
||||
import ManaCore
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
// swiftlint:disable type_body_length
|
||||
|
||||
/// Detail-View für ein Public-Deck. Subscribe-Button löst Auto-Fork
|
||||
/// serverseitig aus und navigiert anschließend zur eigenen Deck-Detail.
|
||||
/// Toolbar-Menu („…") hostet Report + Block-Author (App-Review-Pflicht).
|
||||
struct PublicDeckView: View {
|
||||
let slug: String
|
||||
|
||||
@Environment(AuthClient.self) private var auth
|
||||
@Environment(ManaAuthGate.self) private var authGate
|
||||
@Environment(\.modelContext) private var context
|
||||
@State private var detail: PublicDeckDetail?
|
||||
@State private var isLoading = false
|
||||
|
|
@ -15,37 +20,102 @@ struct PublicDeckView: View {
|
|||
@State private var errorMessage: String?
|
||||
@State private var subscribed: SubscribeResponse?
|
||||
|
||||
// Moderation-State
|
||||
@State private var showReportSheet = false
|
||||
@State private var showBlockConfirm = false
|
||||
@State private var moderationToast: String?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CardsTheme.background.ignoresSafeArea()
|
||||
WordeckTheme.background.ignoresSafeArea()
|
||||
content
|
||||
}
|
||||
.navigationTitle(detail?.deck.title ?? "Deck")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
if detail != nil {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
moderationMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: slug) {
|
||||
await load()
|
||||
}
|
||||
.sheet(isPresented: $showReportSheet) {
|
||||
NavigationStack {
|
||||
ReportDeckSheet(slug: slug) { message in
|
||||
moderationToast = message
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Author blockieren?",
|
||||
isPresented: $showBlockConfirm,
|
||||
titleVisibility: .visible,
|
||||
presenting: detail?.owner
|
||||
) { owner in
|
||||
Button("\(owner.displayName) blockieren", role: .destructive) {
|
||||
Task { await blockAuthor(slug: owner.slug, name: owner.displayName) }
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
} message: { _ in
|
||||
Text("Decks dieses Authors erscheinen für dich nicht mehr im Marketplace.")
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if let toast = moderationToast {
|
||||
ToastBanner(text: toast)
|
||||
.padding(.top, 8)
|
||||
.task {
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
moderationToast = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var moderationMenu: some View {
|
||||
Menu {
|
||||
Button {
|
||||
authGate.require(reason: "marketplace-report") {
|
||||
showReportSheet = true
|
||||
}
|
||||
} label: {
|
||||
Label("Deck melden …", systemImage: "flag")
|
||||
}
|
||||
if let owner = detail?.owner {
|
||||
Button(role: .destructive) {
|
||||
authGate.require(reason: "marketplace-block") {
|
||||
showBlockConfirm = true
|
||||
}
|
||||
} label: {
|
||||
Label("\(owner.displayName) blockieren", systemImage: "hand.raised")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if isLoading, detail == nil {
|
||||
ProgressView()
|
||||
.tint(CardsTheme.primary)
|
||||
.tint(WordeckTheme.primary)
|
||||
} else if let detail {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header(detail: detail)
|
||||
Divider().background(CardsTheme.border)
|
||||
Divider().background(WordeckTheme.border)
|
||||
metadata(detail: detail)
|
||||
Divider().background(CardsTheme.border)
|
||||
Divider().background(WordeckTheme.border)
|
||||
subscribeSection(detail: detail)
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.error)
|
||||
.foregroundStyle(WordeckTheme.error)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +127,7 @@ struct PublicDeckView: View {
|
|||
systemImage: "questionmark.folder",
|
||||
description: Text(errorMessage)
|
||||
)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,15 +136,15 @@ struct PublicDeckView: View {
|
|||
HStack {
|
||||
Text(detail.deck.title)
|
||||
.font(.title.bold())
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
if detail.deck.isFeatured {
|
||||
Image(systemName: "star.fill")
|
||||
.foregroundStyle(CardsTheme.warning)
|
||||
.foregroundStyle(WordeckTheme.warning)
|
||||
}
|
||||
}
|
||||
if let description = detail.deck.description, !description.isEmpty {
|
||||
Text(description)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
|
@ -85,13 +155,13 @@ struct PublicDeckView: View {
|
|||
if let owner = detail.owner {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
Text(owner.displayName)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
if owner.verifiedMana {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.primary)
|
||||
.foregroundStyle(WordeckTheme.primary)
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
|
|
@ -107,63 +177,64 @@ struct PublicDeckView: View {
|
|||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
|
||||
if let changelog = detail.latestVersion?.changelog, !changelog.isEmpty {
|
||||
Text("Changelog")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
.padding(.top, 8)
|
||||
Text(changelog)
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func subscribeSection(detail: PublicDeckDetail) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
if let subscribed {
|
||||
Label("Abonniert — dein Fork ist in deiner Bibliothek", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(CardsTheme.success)
|
||||
.foregroundStyle(WordeckTheme.success)
|
||||
.padding()
|
||||
.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) {
|
||||
Label("Zum eigenen Deck", systemImage: "arrow.right.circle")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(CardsTheme.primaryForeground)
|
||||
.background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(WordeckTheme.primaryForeground)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else if detail.deck.isTakedown {
|
||||
Label("Dieses Deck wurde entfernt", systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(CardsTheme.error)
|
||||
.foregroundStyle(WordeckTheme.error)
|
||||
} else if detail.deck.latestVersionId == nil {
|
||||
Label("Noch keine Version veröffentlicht", systemImage: "clock")
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
} else {
|
||||
Button {
|
||||
Task { await subscribe(detail: detail) }
|
||||
authGate.require(reason: "marketplace-subscribe") {
|
||||
Task { await subscribe(detail: detail) }
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
if isSubscribing {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(CardsTheme.primaryForeground)
|
||||
.tint(WordeckTheme.primaryForeground)
|
||||
}
|
||||
Text(detail.deck.priceCredits > 0
|
||||
? "Abonnieren (\(detail.deck.priceCredits) Credits)"
|
||||
: "Abonnieren")
|
||||
? "Abonnieren (\(detail.deck.priceCredits) Credits)"
|
||||
: "Abonnieren")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(CardsTheme.primaryForeground)
|
||||
.background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(WordeckTheme.primaryForeground)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isSubscribing)
|
||||
|
|
@ -175,7 +246,7 @@ struct PublicDeckView: View {
|
|||
private func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
let api = CardsAPI(auth: auth)
|
||||
let api = WordeckAPI(auth: auth)
|
||||
do {
|
||||
detail = try await api.publicDeck(slug: slug)
|
||||
} 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
|
||||
errorMessage = nil
|
||||
defer { isSubscribing = false }
|
||||
let api = CardsAPI(auth: auth)
|
||||
let api = WordeckAPI(auth: auth)
|
||||
do {
|
||||
let response = try await api.subscribe(slug: slug)
|
||||
subscribed = response
|
||||
|
|
@ -199,3 +280,5 @@ struct PublicDeckView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable type_body_length
|
||||
|
|
|
|||
109
Sources/Features/Marketplace/ReportDeckSheet.swift
Normal file
109
Sources/Features/Marketplace/ReportDeckSheet.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
/// Audio-Wiedergabe-Button für `audio-front`-Karten. Lädt das File einmal
|
||||
/// per MediaCache, spielt mit AVAudioPlayer ab.
|
||||
struct AudioPlayerButton: View {
|
||||
let mediaId: String
|
||||
|
||||
@Environment(\.mediaCache) private var mediaCache
|
||||
@State private var player: AVAudioPlayer?
|
||||
@State private var isPlaying = false
|
||||
@State private var failed = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
togglePlayback()
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: failed
|
||||
? "speaker.slash.fill"
|
||||
: (isPlaying ? "pause.circle.fill" : "play.circle.fill"))
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(failed ? CardsTheme.error : CardsTheme.primary)
|
||||
Text(failed ? "Audio nicht verfügbar" : (isPlaying ? "Wiedergabe läuft" : "Anhören"))
|
||||
.font(.headline)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(20)
|
||||
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(CardsTheme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(failed)
|
||||
.task(id: mediaId) {
|
||||
await load()
|
||||
}
|
||||
.onDisappear {
|
||||
player?.stop()
|
||||
isPlaying = false
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let cache = mediaCache else { failed = true; return }
|
||||
do {
|
||||
let data = try await cache.data(for: mediaId)
|
||||
#if canImport(UIKit)
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
player = try AVAudioPlayer(data: data)
|
||||
player?.prepareToPlay()
|
||||
} catch {
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
|
||||
private func togglePlayback() {
|
||||
guard let player else { return }
|
||||
if player.isPlaying {
|
||||
player.pause()
|
||||
isPlaying = false
|
||||
} else {
|
||||
player.currentTime = 0
|
||||
player.play()
|
||||
isPlaying = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Lädt ein authentifiziertes Image vom Cardecky-Media-Endpoint und
|
||||
/// rendert es. Streamt erst beim ersten Mal, danach aus dem
|
||||
/// MediaCache (LRU 200 MB).
|
||||
struct RemoteImage: View {
|
||||
let mediaId: String
|
||||
let contentMode: ContentMode
|
||||
|
||||
@Environment(\.mediaCache) private var mediaCache
|
||||
@State private var image: PlatformImage?
|
||||
@State private var failed = false
|
||||
|
||||
init(mediaId: String, contentMode: ContentMode = .fit) {
|
||||
self.mediaId = mediaId
|
||||
self.contentMode = contentMode
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
imageView(image)
|
||||
} else if failed {
|
||||
ContentUnavailableView("Bild konnte nicht geladen werden", systemImage: "photo.badge.exclamationmark")
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
} else {
|
||||
ProgressView()
|
||||
.tint(CardsTheme.primary)
|
||||
}
|
||||
}
|
||||
.task(id: mediaId) {
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func imageView(_ image: PlatformImage) -> some View {
|
||||
#if canImport(UIKit)
|
||||
Image(uiImage: image).resizable().aspectRatio(contentMode: contentMode)
|
||||
#elseif canImport(AppKit)
|
||||
Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let cache = mediaCache else { failed = true; return }
|
||||
do {
|
||||
let data = try await cache.data(for: mediaId)
|
||||
if let img = PlatformImage(data: data) {
|
||||
image = img
|
||||
} else {
|
||||
failed = true
|
||||
}
|
||||
} catch {
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
typealias PlatformImage = UIImage
|
||||
#elseif canImport(AppKit)
|
||||
typealias PlatformImage = NSImage
|
||||
#endif
|
||||
89
Sources/Features/Settings/BlockedAuthorsView.swift
Normal file
89
Sources/Features/Settings/BlockedAuthorsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,15 +40,25 @@ struct SettingsView: View {
|
|||
}
|
||||
|
||||
if notifications.authorization == .denied {
|
||||
Label("Benachrichtigungen sind in den iOS-Einstellungen blockiert.",
|
||||
systemImage: "exclamationmark.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.warning)
|
||||
Label(
|
||||
"Benachrichtigungen sind in den iOS-Einstellungen blockiert.",
|
||||
systemImage: "exclamationmark.circle"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(WordeckTheme.warning)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Marketplace") {
|
||||
NavigationLink {
|
||||
BlockedAuthorsView()
|
||||
} label: {
|
||||
Label("Blockierte Authors", systemImage: "hand.raised")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Über") {
|
||||
LabeledContent("Server", value: "cardecky-api.mana.how")
|
||||
LabeledContent("Server", value: "api.wordeck.com")
|
||||
LabeledContent("Auth", value: "auth.mana.how")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Rendert die Karten-Inhalte je nach `CardType`. Front-/Back-Seite
|
||||
/// werden über `isFlipped` gesteuert.
|
||||
///
|
||||
/// β-2 deckt `basic`, `basic-reverse`, `cloze` ab. Restliche Typen
|
||||
/// zeigen einen Placeholder mit Hinweis auf die kommende Phase.
|
||||
/// werden über `isFlipped` gesteuert. Wordeck ist text-only — alle
|
||||
/// Card-Types rendern ausschließlich Markdown-Text.
|
||||
struct CardRenderer: View {
|
||||
let card: ReviewCard
|
||||
let subIndex: Int
|
||||
|
|
@ -24,29 +22,26 @@ struct CardRenderer: View {
|
|||
}
|
||||
case .cloze:
|
||||
clozeView
|
||||
case .imageOcclusion:
|
||||
imageOcclusionView
|
||||
case .audioFront:
|
||||
audioFrontView
|
||||
case .typing, .multipleChoice:
|
||||
placeholderView
|
||||
case .multipleChoice:
|
||||
MultipleChoiceCardView(card: card, isFlipped: isFlipped)
|
||||
case .typing:
|
||||
TypingCardView(card: card, isFlipped: isFlipped)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func basicView(front frontKey: String, back backKey: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
text(card.fields[frontKey] ?? "")
|
||||
.font(.title2)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
if isFlipped {
|
||||
Divider().background(CardsTheme.border)
|
||||
Divider().background(WordeckTheme.border)
|
||||
text(card.fields[backKey] ?? "")
|
||||
.font(.title3)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -61,84 +56,7 @@ struct CardRenderer: View {
|
|||
VStack(spacing: 12) {
|
||||
text(rendered)
|
||||
.font(.title3)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var imageOcclusionView: some View {
|
||||
let imageRef = card.fields["image_ref"] ?? ""
|
||||
let maskJSON = card.fields["mask_regions"] ?? "[]"
|
||||
let regions = MaskRegions.parse(maskJSON)
|
||||
let activeRegion = regions.indices.contains(subIndex) ? regions[subIndex] : nil
|
||||
|
||||
VStack(spacing: 12) {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .topLeading) {
|
||||
RemoteImage(mediaId: imageRef, contentMode: .fit)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
ForEach(regions) { region in
|
||||
let isActive = region.id == activeRegion?.id
|
||||
// Front: aktive Maske opak, andere transparent.
|
||||
// Back: alle Masken transparent (Bild komplett sichtbar).
|
||||
if !isFlipped, isActive {
|
||||
Rectangle()
|
||||
.fill(CardsTheme.primary.opacity(0.92))
|
||||
.frame(
|
||||
width: region.w * geo.size.width,
|
||||
height: region.h * geo.size.height
|
||||
)
|
||||
.offset(x: region.x * geo.size.width, y: region.y * geo.size.height)
|
||||
.overlay(
|
||||
Text(region.label?.isEmpty == false ? region.label! : "?")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(CardsTheme.primaryForeground)
|
||||
.offset(x: region.x * geo.size.width, y: region.y * geo.size.height),
|
||||
alignment: .topLeading
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.aspectRatio(4 / 3, contentMode: .fit)
|
||||
|
||||
if isFlipped, let label = activeRegion?.label, !label.isEmpty {
|
||||
Text(label)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(CardsTheme.primary)
|
||||
}
|
||||
if let note = card.fields["note"], !note.isEmpty {
|
||||
Text(note)
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var audioFrontView: some View {
|
||||
let audioRef = card.fields["audio_ref"] ?? ""
|
||||
VStack(spacing: 16) {
|
||||
AudioPlayerButton(mediaId: audioRef)
|
||||
if isFlipped {
|
||||
Divider().background(CardsTheme.border)
|
||||
text(card.fields["back"] ?? "")
|
||||
.font(.title3)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var placeholderView: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "questionmark.square.dashed")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
Text("Card-Type »\(card.type.rawValue)« kommt in einer späteren Phase")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
202
Sources/Features/Study/MultipleChoiceCardView.swift
Normal file
202
Sources/Features/Study/MultipleChoiceCardView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Vier Rating-Buttons unten am Bildschirm. Tap → onRate(rating)
|
||||
/// plus Haptic-Feedback.
|
||||
/// Vier Rating-Buttons mit emphasis auf "Good" (full-width primary).
|
||||
/// Web-Vorbild: `cards/apps/web/src/routes/study/[deckId]/+page.svelte`
|
||||
/// — `.grade.again/.hard/.good/.easy`-Klassen.
|
||||
struct RatingBar: View {
|
||||
let onRate: (Rating) -> Void
|
||||
|
||||
|
|
@ -16,17 +17,24 @@ struct RatingBar: View {
|
|||
triggerHaptic(for: rating)
|
||||
onRate(rating)
|
||||
} label: {
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(rating.label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(rating.shortcut)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(kbdBackground(for: rating), in: RoundedRectangle(cornerRadius: 4))
|
||||
.foregroundStyle(kbdForeground(for: rating))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(background(for: rating), in: RoundedRectangle(cornerRadius: 10))
|
||||
.background(background(for: rating), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.foregroundStyle(foreground(for: rating))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.stroke(borderColor(for: rating), lineWidth: rating == .good ? 0 : 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
|
@ -34,30 +42,52 @@ struct RatingBar: View {
|
|||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
/// `good` ist die Hero-Action (primary full background) — analog
|
||||
/// zum Web-Default-Klick. Andere bekommen subtle tinted borders.
|
||||
private func background(for rating: Rating) -> Color {
|
||||
switch rating {
|
||||
case .again: CardsTheme.error.opacity(0.12)
|
||||
case .hard: CardsTheme.warning.opacity(0.12)
|
||||
case .good: CardsTheme.primary.opacity(0.12)
|
||||
case .easy: CardsTheme.success.opacity(0.12)
|
||||
case .again: WordeckTheme.error.opacity(0.06)
|
||||
case .hard: WordeckTheme.warning.opacity(0.06)
|
||||
case .good: WordeckTheme.primary
|
||||
case .easy: WordeckTheme.success.opacity(0.06)
|
||||
}
|
||||
}
|
||||
|
||||
private func foreground(for rating: Rating) -> Color {
|
||||
switch rating {
|
||||
case .again: CardsTheme.error
|
||||
case .hard: CardsTheme.warning
|
||||
case .good: CardsTheme.primary
|
||||
case .easy: CardsTheme.success
|
||||
case .again: WordeckTheme.error
|
||||
case .hard: WordeckTheme.warning
|
||||
case .good: WordeckTheme.primaryForeground
|
||||
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) {
|
||||
#if canImport(UIKit)
|
||||
let generator = UIImpactFeedbackGenerator(
|
||||
style: rating == .easy ? .heavy : .medium
|
||||
)
|
||||
generator.impactOccurred()
|
||||
let style: UIImpactFeedbackGenerator.FeedbackStyle =
|
||||
rating == .easy ? .heavy : .medium
|
||||
UIImpactFeedbackGenerator(style: style).impactOccurred()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,15 @@ import SwiftData
|
|||
|
||||
/// State-Machine für eine Lern-Session. Lädt Due-Reviews beim Start,
|
||||
/// rendert eine Karte nach der anderen, schickt Grades via GradeQueue ab.
|
||||
///
|
||||
/// Seit ζ-1 (2026-05-18): wenn der Server-Call scheitert, fällt die
|
||||
/// Session auf den `CachedDueReview`-Snapshot vom letzten Sync zurück.
|
||||
/// Der User lernt dann offline. Grades laufen wie immer in die
|
||||
/// `GradeQueue` und drainen beim Reconnect.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class StudySession {
|
||||
enum Phase: Sendable {
|
||||
enum Phase {
|
||||
case loading
|
||||
case studying
|
||||
case finished
|
||||
|
|
@ -20,17 +25,22 @@ final class StudySession {
|
|||
private(set) var currentIndex: Int = 0
|
||||
private(set) var isFlipped: Bool = false
|
||||
private(set) var totalGraded: Int = 0
|
||||
/// `true` wenn die Session aus dem lokalen Snapshot statt vom Server
|
||||
/// gestartet wurde. View kann ein Offline-Banner zeigen.
|
||||
private(set) var isOfflineSession: Bool = false
|
||||
|
||||
let deckId: String
|
||||
let deckName: String
|
||||
|
||||
private let api: CardsAPI
|
||||
private let api: WordeckAPI
|
||||
private let context: ModelContext
|
||||
private let gradeQueue: GradeQueue
|
||||
|
||||
init(deckId: String, deckName: String, auth: AuthClient, context: ModelContext) {
|
||||
self.deckId = deckId
|
||||
self.deckName = deckName
|
||||
api = CardsAPI(auth: auth)
|
||||
self.context = context
|
||||
api = WordeckAPI(auth: auth)
|
||||
gradeQueue = GradeQueue(api: api, context: context)
|
||||
}
|
||||
|
||||
|
|
@ -50,19 +60,47 @@ final class StudySession {
|
|||
currentIndex = 0
|
||||
isFlipped = false
|
||||
totalGraded = 0
|
||||
isOfflineSession = false
|
||||
if queue.isEmpty {
|
||||
phase = .finished
|
||||
} else {
|
||||
phase = .studying
|
||||
}
|
||||
Log.study.info("Session start — \(self.queue.count, privacy: .public) due in deck \(self.deckId, privacy: .public)")
|
||||
let count = queue.count
|
||||
let id = deckId
|
||||
Log.study.info("Session start — \(count, privacy: .public) due in deck \(id, privacy: .public)")
|
||||
} catch {
|
||||
let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||
phase = .failed(msg)
|
||||
Log.study.error("Session start failed: \(msg, privacy: .public)")
|
||||
// Server nicht erreichbar oder Auth-Fehler → Cache-Fallback.
|
||||
queue = loadFromCache()
|
||||
currentIndex = 0
|
||||
isFlipped = false
|
||||
totalGraded = 0
|
||||
if queue.isEmpty {
|
||||
let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||
phase = .failed(msg)
|
||||
Log.study.error("Session start failed (no cache): \(msg, privacy: .public)")
|
||||
} else {
|
||||
isOfflineSession = true
|
||||
phase = .studying
|
||||
let count = queue.count
|
||||
let id = deckId
|
||||
Log.study
|
||||
.notice("Offline-Session — \(count, privacy: .public) cached due in deck \(id, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFromCache() -> [DueReview] {
|
||||
let deckId = deckId
|
||||
var descriptor = FetchDescriptor<CachedDueReview>(
|
||||
predicate: #Predicate<CachedDueReview> { $0.deckId == deckId },
|
||||
sortBy: [SortDescriptor(\.due, order: .forward)]
|
||||
)
|
||||
descriptor.fetchLimit = 500
|
||||
let cached = (try? context.fetch(descriptor)) ?? []
|
||||
return cached.compactMap { $0.toDueReview() }
|
||||
}
|
||||
|
||||
func flip() {
|
||||
guard case .studying = phase else { return }
|
||||
isFlipped.toggle()
|
||||
|
|
@ -86,7 +124,8 @@ final class StudySession {
|
|||
isFlipped = false
|
||||
if currentIndex >= queue.count {
|
||||
phase = .finished
|
||||
Log.study.info("Session finished — graded \(self.totalGraded, privacy: .public)")
|
||||
let count = totalGraded
|
||||
Log.study.info("Session finished — graded \(count, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import SwiftData
|
|||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Vollbild-Study-View. Wird per Navigation aus DeckListView geöffnet.
|
||||
|
|
@ -18,7 +18,7 @@ struct StudySessionView: View {
|
|||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CardsTheme.background.ignoresSafeArea()
|
||||
WordeckTheme.background.ignoresSafeArea()
|
||||
content
|
||||
}
|
||||
.navigationTitle(deckName)
|
||||
|
|
@ -30,7 +30,7 @@ struct StudySessionView: View {
|
|||
if let session, case .studying = session.phase {
|
||||
Text("\(session.remaining)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
.accessibilityLabel("\(session.remaining) Karten übrig")
|
||||
}
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ struct StudySessionView: View {
|
|||
switch session.phase {
|
||||
case .loading:
|
||||
ProgressView("Karten werden geladen …")
|
||||
.tint(CardsTheme.primary)
|
||||
.tint(WordeckTheme.primary)
|
||||
case .studying:
|
||||
studyingView(session: session)
|
||||
case .finished:
|
||||
|
|
@ -60,12 +60,15 @@ struct StudySessionView: View {
|
|||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
.tint(CardsTheme.primary)
|
||||
.tint(WordeckTheme.primary)
|
||||
}
|
||||
}
|
||||
|
||||
private func studyingView(session: StudySession) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
if session.isOfflineSession {
|
||||
offlineBanner
|
||||
}
|
||||
if let due = session.current {
|
||||
cardSurface(due: due, isFlipped: session.isFlipped)
|
||||
.onTapGesture {
|
||||
|
|
@ -73,26 +76,7 @@ struct StudySessionView: View {
|
|||
session.flip()
|
||||
}
|
||||
keyboardShortcuts(session: session)
|
||||
if session.isFlipped {
|
||||
RatingBar { rating in
|
||||
Task { await session.grade(rating) }
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
} else {
|
||||
Button {
|
||||
flipHaptic()
|
||||
session.flip()
|
||||
} label: {
|
||||
Text("Antwort anzeigen")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(CardsTheme.primaryForeground)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
bottomBar(session: session)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
|
|
@ -100,36 +84,87 @@ struct StudySessionView: View {
|
|||
.animation(.easeInOut(duration: 0.2), value: session.currentIndex)
|
||||
}
|
||||
|
||||
/// Banner für Offline-Sessions. Erklärt dem User ehrlich, dass er
|
||||
/// gerade die Karten lernt, die zum letzten Sync fällig waren —
|
||||
/// neue Karten kommen erst nach Wiederverbindung.
|
||||
private var offlineBanner: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.slash")
|
||||
Text("Offline — Karten vom letzten Sync")
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(WordeckTheme.muted, in: Capsule())
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 4)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
/// Fixe Höhe, damit der Wechsel zwischen "Antwort anzeigen" und
|
||||
/// `RatingBar` die Card oben nicht stauchen kann — sonst proportioniert
|
||||
/// `.aspectRatio(.fit)` die Card neu und das Layout springt.
|
||||
private func bottomBar(session: StudySession) -> some View {
|
||||
ZStack {
|
||||
if session.isFlipped {
|
||||
RatingBar { rating in
|
||||
Task { await session.grade(rating) }
|
||||
}
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
Button {
|
||||
flipHaptic()
|
||||
session.flip()
|
||||
} label: {
|
||||
Text("Antwort anzeigen")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(WordeckTheme.primaryForeground)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 16)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(height: 52)
|
||||
}
|
||||
|
||||
private func cardSurface(due: DueReview, isFlipped: Bool) -> some View {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(CardsTheme.surface)
|
||||
.overlay(
|
||||
CardRenderer(
|
||||
card: due.card,
|
||||
subIndex: due.review.subIndex,
|
||||
isFlipped: isFlipped
|
||||
)
|
||||
CardSurface(size: .hero, elevation: .raised) {
|
||||
CardRenderer(
|
||||
card: due.card,
|
||||
subIndex: due.review.subIndex,
|
||||
isFlipped: isFlipped
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(CardsTheme.border, lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
|
||||
private func finishedView(session: StudySession) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(CardsTheme.success)
|
||||
.foregroundStyle(WordeckTheme.success)
|
||||
Text(session.totalGraded == 0 ? "Keine Karten fällig" : "Fertig!")
|
||||
.font(.title.bold())
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
if session.totalGraded > 0 {
|
||||
Text("\(session.totalGraded) Karten gelernt")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
}
|
||||
if session.isOfflineSession {
|
||||
Text("Weitere Karten erst nach Verbindung verfügbar.")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
Button("Zurück") { dismiss() }
|
||||
.padding(.top, 24)
|
||||
|
|
@ -141,14 +176,14 @@ struct StudySessionView: View {
|
|||
VStack(spacing: 16) {
|
||||
Image(systemName: "wifi.exclamationmark")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(CardsTheme.error)
|
||||
.foregroundStyle(WordeckTheme.error)
|
||||
Text("Karten konnten nicht geladen werden")
|
||||
.font(.headline)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.foregroundStyle(WordeckTheme.foreground)
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.foregroundStyle(WordeckTheme.mutedForeground)
|
||||
.padding(.horizontal, 32)
|
||||
Button("Erneut versuchen") {
|
||||
Task { await session.start() }
|
||||
|
|
@ -160,7 +195,6 @@ struct StudySessionView: View {
|
|||
|
||||
/// Unsichtbare Buttons mit Keyboard-Shortcuts. Funktionieren auf
|
||||
/// iPad (Magic Keyboard) und macOS. Space = flip, 1-4 = Rating.
|
||||
@ViewBuilder
|
||||
private func keyboardShortcuts(session: StudySession) -> some View {
|
||||
Group {
|
||||
Button("Flip") {
|
||||
|
|
@ -185,7 +219,7 @@ struct StudySessionView: View {
|
|||
|
||||
private func flipHaptic() {
|
||||
#if canImport(UIKit)
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
|
|||
192
Sources/Features/Study/TypingCardView.swift
Normal file
192
Sources/Features/Study/TypingCardView.swift
Normal 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
|
||||
|
|
@ -29,12 +29,6 @@
|
|||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-1024.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,261 @@
|
|||
{
|
||||
"sourceLanguage" : "de",
|
||||
"strings" : { },
|
||||
"strings" : {
|
||||
"… und %@ weitere" : {
|
||||
|
||||
},
|
||||
"„%@“" : {
|
||||
|
||||
},
|
||||
"@%@" : {
|
||||
|
||||
},
|
||||
"%@" : {
|
||||
|
||||
},
|
||||
"%@ fällig" : {
|
||||
|
||||
},
|
||||
"%@ fällige Karten aus abonnierten Decks" : {
|
||||
|
||||
},
|
||||
"%@ Karten" : {
|
||||
|
||||
},
|
||||
"%@ Karten gelernt" : {
|
||||
|
||||
},
|
||||
"3–500 Zeichen. Je präziser, desto besser die Karten." : {
|
||||
|
||||
},
|
||||
"Abmelden" : {
|
||||
|
||||
},
|
||||
"Account löschen…" : {
|
||||
|
||||
},
|
||||
"AI-Moderation läuft — kann ein paar Sekunden dauern." : {
|
||||
|
||||
},
|
||||
"Alle Karten und Reviews dieses Decks werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden." : {
|
||||
|
||||
},
|
||||
"Anmelden / 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"
|
||||
}
|
||||
}
|
||||
24
Sources/Resources/WordeckNative.entitlements
Normal file
24
Sources/Resources/WordeckNative.entitlements
Normal 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.session</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import XCTest
|
||||
|
||||
final class CardsNativeUITests: XCTestCase {
|
||||
func testAppLaunches() throws {
|
||||
final class WordeckNativeUITests: XCTestCase {
|
||||
func testAppLaunches() {
|
||||
let app = XCUIApplication()
|
||||
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
|
||||
// von beiden hängt davon ab, ob der Simulator-Keychain noch
|
||||
// eine Session hält.
|
||||
let loginTitle = app.staticTexts["Cards"]
|
||||
let loginTitle = app.staticTexts["Wordeck"]
|
||||
let decksTitle = app.staticTexts["Decks"]
|
||||
let exploreTab = app.staticTexts["Entdecken"]
|
||||
|
||||
|
|
@ -21,6 +21,6 @@ final class CardsNativeUITests: XCTestCase {
|
|||
}
|
||||
usleep(100_000)
|
||||
}
|
||||
XCTAssertTrue(found, "Erwartete App-Surface (Cards | Decks | Entdecken) erschien nicht innerhalb 5 s")
|
||||
XCTAssertTrue(found, "Erwartete App-Surface (Wordeck | Decks | Entdecken) erschien nicht innerhalb 5 s")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import Testing
|
||||
@testable import CardsNative
|
||||
|
||||
@Suite("AppConfig")
|
||||
struct AppConfigTests {
|
||||
@Test("Cards-API zeigt auf cardecky-api.mana.how")
|
||||
func apiBaseURLPointsToCardecky() {
|
||||
#expect(AppConfig.apiBaseURL.absoluteString == "https://cardecky-api.mana.how")
|
||||
}
|
||||
|
||||
@Test("Auth zeigt auf auth.mana.how")
|
||||
func authBaseURLPointsToManaAuth() {
|
||||
#expect(AppConfig.manaAppConfig.authBaseURL.absoluteString == "https://auth.mana.how")
|
||||
}
|
||||
|
||||
@Test("Keychain-Service ist ev.mana.cardecky")
|
||||
func keychainServiceIsAppSpecific() {
|
||||
#expect(AppConfig.manaAppConfig.keychainService == "ev.mana.cardecky")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CardsNative
|
||||
@testable import WordeckNative
|
||||
|
||||
@Suite("Cloze")
|
||||
struct ClozeTests {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CardsNative
|
||||
@testable import WordeckNative
|
||||
|
||||
@Suite("Deck-JSON-Decoding")
|
||||
struct DeckDecodingTests {
|
||||
@Test("Wire-Format aus toDeckDto decodet sauber")
|
||||
func decodesDeckFromWireFormat() throws {
|
||||
let json = """
|
||||
let json = Data("""
|
||||
{
|
||||
"id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
"user_id": "user_123",
|
||||
|
|
@ -23,7 +23,7 @@ struct DeckDecodingTests {
|
|||
"created_at": "2026-05-12T10:30:00.123Z",
|
||||
"updated_at": "2026-05-12T15:45:00.456Z"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
""".utf8)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601withFractional
|
||||
|
|
@ -41,7 +41,7 @@ struct DeckDecodingTests {
|
|||
|
||||
@Test("Marketplace-Forks werden erkannt")
|
||||
func recognizesMarketplaceFork() throws {
|
||||
let json = """
|
||||
let json = Data("""
|
||||
{
|
||||
"id": "deck_456",
|
||||
"user_id": "user_123",
|
||||
|
|
@ -58,7 +58,7 @@ struct DeckDecodingTests {
|
|||
"created_at": "2026-05-01T00:00:00.000Z",
|
||||
"updated_at": "2026-05-01T00:00:00.000Z"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
""".utf8)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601withFractional
|
||||
|
|
@ -71,7 +71,7 @@ struct DeckDecodingTests {
|
|||
|
||||
@Test("DeckListResponse-Wrapper")
|
||||
func decodesListResponse() throws {
|
||||
let json = """
|
||||
let json = Data("""
|
||||
{
|
||||
"decks": [
|
||||
{
|
||||
|
|
@ -93,7 +93,7 @@ struct DeckDecodingTests {
|
|||
],
|
||||
"total": 1
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
""".utf8)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601withFractional
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CardsNative
|
||||
@testable import WordeckNative
|
||||
|
||||
@Suite("Marketplace-JSON-Decoding")
|
||||
struct MarketplaceDecodingTests {
|
||||
|
|
@ -12,7 +12,7 @@ struct MarketplaceDecodingTests {
|
|||
|
||||
@Test("PublicDeckEntry aus Browse-Response")
|
||||
func decodesPublicDeckEntry() throws {
|
||||
let json = """
|
||||
let json = Data("""
|
||||
{
|
||||
"slug": "geografie-welt-top30",
|
||||
"title": "Geografie Welt Top 30",
|
||||
|
|
@ -31,10 +31,10 @@ struct MarketplaceDecodingTests {
|
|||
"display_name": "mana-Kuratoren",
|
||||
"verified_mana": true,
|
||||
"verified_community": false,
|
||||
"pseudonym": null
|
||||
"pseudonym": false
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
""".utf8)
|
||||
|
||||
let entry = try decoder().decode(PublicDeckEntry.self, from: json)
|
||||
#expect(entry.slug == "geografie-welt-top30")
|
||||
|
|
@ -46,12 +46,12 @@ struct MarketplaceDecodingTests {
|
|||
|
||||
@Test("ExploreResponse mit featured + trending")
|
||||
func decodesExploreResponse() throws {
|
||||
let json = """
|
||||
let json = Data("""
|
||||
{
|
||||
"featured": [],
|
||||
"trending": []
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
""".utf8)
|
||||
let res = try decoder().decode(ExploreResponse.self, from: json)
|
||||
#expect(res.featured.isEmpty)
|
||||
#expect(res.trending.isEmpty)
|
||||
|
|
@ -59,7 +59,7 @@ struct MarketplaceDecodingTests {
|
|||
|
||||
@Test("PublicDeckDetail mit camelCase 'latest_version'")
|
||||
func decodesPublicDeckDetail() throws {
|
||||
let json = """
|
||||
let json = Data("""
|
||||
{
|
||||
"deck": {
|
||||
"id": "deck_1",
|
||||
|
|
@ -88,7 +88,7 @@ struct MarketplaceDecodingTests {
|
|||
},
|
||||
"owner": null
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
""".utf8)
|
||||
|
||||
let detail = try decoder().decode(PublicDeckDetail.self, from: json)
|
||||
#expect(detail.deck.slug == "english-a2")
|
||||
|
|
@ -99,14 +99,14 @@ struct MarketplaceDecodingTests {
|
|||
|
||||
@Test("SubscribeResponse mit private_deck_id")
|
||||
func decodesSubscribeResponse() throws {
|
||||
let json = """
|
||||
let json = Data("""
|
||||
{
|
||||
"subscribed": true,
|
||||
"deck_slug": "english-a2",
|
||||
"current_version_id": "v_1",
|
||||
"private_deck_id": "private_deck_xyz"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
""".utf8)
|
||||
let res = try decoder().decode(SubscribeResponse.self, from: json)
|
||||
#expect(res.subscribed == true)
|
||||
#expect(res.privateDeckId == "private_deck_xyz")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,19 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CardsNative
|
||||
@testable import WordeckNative
|
||||
|
||||
@Suite("Mutation Body Encoding")
|
||||
struct MutationEncodingTests {
|
||||
private func encode<T: Encodable>(_ value: T) throws -> [String: Any] {
|
||||
private func encode(_ value: some Encodable) throws -> [String: Any] {
|
||||
let data = try JSONEncoder().encode(value)
|
||||
return try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||
guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw EncodeError.notADictionary
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
private enum EncodeError: Error {
|
||||
case notADictionary
|
||||
}
|
||||
|
||||
@Test("DeckCreateBody nutzt snake_case und lässt nil weg")
|
||||
|
|
@ -40,8 +47,7 @@ struct MutationEncodingTests {
|
|||
let body = CardCreateBody(
|
||||
deckId: "deck_1",
|
||||
type: .basic,
|
||||
fields: CardFieldsBuilder.basic(front: "Hallo", back: "Hello"),
|
||||
mediaRefs: nil
|
||||
fields: CardFieldsBuilder.basic(front: "Hallo", back: "Hello")
|
||||
)
|
||||
let json = try encode(body)
|
||||
#expect(json["deck_id"] as? String == "deck_1")
|
||||
|
|
@ -57,8 +63,7 @@ struct MutationEncodingTests {
|
|||
let body = CardCreateBody(
|
||||
deckId: "d",
|
||||
type: .basicReverse,
|
||||
fields: CardFieldsBuilder.basic(front: "a", back: "b"),
|
||||
mediaRefs: nil
|
||||
fields: CardFieldsBuilder.basic(front: "a", back: "b")
|
||||
)
|
||||
let json = try encode(body)
|
||||
#expect(json["type"] as? String == "basic-reverse")
|
||||
|
|
@ -69,8 +74,7 @@ struct MutationEncodingTests {
|
|||
let body = CardCreateBody(
|
||||
deckId: "d",
|
||||
type: .cloze,
|
||||
fields: CardFieldsBuilder.cloze(text: "Die {{c1::Sonne}} scheint."),
|
||||
mediaRefs: nil
|
||||
fields: CardFieldsBuilder.cloze(text: "Die {{c1::Sonne}} scheint.")
|
||||
)
|
||||
let json = try encode(body)
|
||||
#expect(json["type"] as? String == "cloze")
|
||||
|
|
@ -83,8 +87,7 @@ struct MutationEncodingTests {
|
|||
let body = CardCreateBody(
|
||||
deckId: "d",
|
||||
type: .multipleChoice,
|
||||
fields: CardFieldsBuilder.multipleChoice(front: "Q", answer: "A"),
|
||||
mediaRefs: nil
|
||||
fields: CardFieldsBuilder.multipleChoice(front: "Q", answer: "A")
|
||||
)
|
||||
let json = try encode(body)
|
||||
#expect(json["type"] as? String == "multiple-choice")
|
||||
|
|
@ -92,7 +95,7 @@ struct MutationEncodingTests {
|
|||
|
||||
@Test("CardUpdateBody nur mit fields")
|
||||
func cardUpdateBodyFieldsOnly() throws {
|
||||
let body = CardUpdateBody(fields: ["front": "neu"], mediaRefs: nil)
|
||||
let body = CardUpdateBody(fields: ["front": "neu"])
|
||||
let json = try encode(body)
|
||||
#expect((json["fields"] as? [String: String])?["front"] == "neu")
|
||||
#expect(json["media_refs"] == nil)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CardsNative
|
||||
@testable import WordeckNative
|
||||
|
||||
@Suite("Review-JSON-Decoding")
|
||||
struct ReviewDecodingTests {
|
||||
@Test("Review-Wire-Format decodet vollständig")
|
||||
func decodesReview() throws {
|
||||
let json = """
|
||||
let json = Data("""
|
||||
{
|
||||
"card_id": "card_1",
|
||||
"sub_index": 0,
|
||||
|
|
@ -22,7 +22,7 @@ struct ReviewDecodingTests {
|
|||
"state": "review",
|
||||
"last_review": "2026-05-10T10:00:00.000Z"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
""".utf8)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601withFractional
|
||||
|
|
@ -39,7 +39,7 @@ struct ReviewDecodingTests {
|
|||
func decodesDueReview() throws {
|
||||
// Achtung: Server liefert hier `deckId` camelCase im embedded card,
|
||||
// weil das aus Drizzle direkt rauskommt, nicht durch toCardDto.
|
||||
let json = """
|
||||
let json = Data("""
|
||||
{
|
||||
"card_id": "c1",
|
||||
"sub_index": 0,
|
||||
|
|
@ -61,7 +61,7 @@ struct ReviewDecodingTests {
|
|||
"fields": {"front": "Was ist 1+1?", "back": "2"}
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
""".utf8)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601withFractional
|
||||
|
|
|
|||
55
Tests/UnitTests/TypingTests.swift
Normal file
55
Tests/UnitTests/TypingTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
26
Tests/UnitTests/WordeckNativeTests.swift
Normal file
26
Tests/UnitTests/WordeckNativeTests.swift
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import ManaCore
|
||||
import Testing
|
||||
@testable import WordeckNative
|
||||
|
||||
@Suite("AppConfig")
|
||||
struct AppConfigTests {
|
||||
@Test("Wordeck-API zeigt auf api.wordeck.com")
|
||||
func apiBaseURLPointsToWordeck() {
|
||||
#expect(AppConfig.apiBaseURL.absoluteString == "https://api.wordeck.com")
|
||||
}
|
||||
|
||||
@Test("Auth zeigt auf auth.mana.how")
|
||||
func authBaseURLPointsToManaAuth() {
|
||||
#expect(AppConfig.manaAppConfig.authBaseURL.absoluteString == "https://auth.mana.how")
|
||||
}
|
||||
|
||||
/// Cross-App-SSO: alle nativen mana-Apps teilen sich
|
||||
/// `ManaSharedKeychainGroup` (= "ev.mana.session"), damit JWT +
|
||||
/// Refresh-Token zwischen Apps geteilt werden können. Referenz
|
||||
/// statt String-Literal, sonst driftet's bei jeder Plattform-
|
||||
/// Aktualisierung.
|
||||
@Test("Keychain-Service nutzt geteilte Mana-Group")
|
||||
func keychainServiceUsesSharedGroup() {
|
||||
#expect(AppConfig.manaAppConfig.keychainService == ManaSharedKeychainGroup)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,8 @@ struct DueWidgetView: View {
|
|||
smallView
|
||||
case .systemMedium:
|
||||
mediumView
|
||||
case .systemLarge:
|
||||
largeView
|
||||
case .accessoryCircular:
|
||||
circularView
|
||||
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 {
|
||||
ZStack {
|
||||
Circle()
|
||||
|
|
@ -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>
|
||||
31
Widgets/WordeckWidget/Resources/Info.plist
Normal file
31
Widgets/WordeckWidget/Resources/Info.plist
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?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>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?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>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)ev.mana.session</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
57
Widgets/WordeckWidget/WordeckDueWidget.swift
Normal file
57
Widgets/WordeckWidget/WordeckDueWidget.swift
Normal 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
|
||||
}
|
||||
|
|
@ -6,8 +6,8 @@ import WidgetKit
|
|||
/// rectangular). Daten kommen aus dem shared App-Group-Container
|
||||
/// (siehe `WidgetSnapshotStore` im Haupt-Target).
|
||||
@main
|
||||
struct CardsWidgetBundle: WidgetBundle {
|
||||
struct WordeckWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
CardsDueWidget()
|
||||
WordeckDueWidget()
|
||||
}
|
||||
}
|
||||
183
devlog/2026-05-12/data.json
Normal file
183
devlog/2026-05-12/data.json
Normal 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
105
devlog/2026-05-12/macher.md
Normal 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 5–7 %) — 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.
|
||||
45
devlog/2026-05-12/spieler.md
Normal file
45
devlog/2026-05-12/spieler.md
Normal 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 (1–4). 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.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue