v0.1.0 — Phase β-0 Setup
Repo-Skelett für cards-native, native SwiftUI-Universal-App für Cardecky (mana e.V.). Web-Parität zu cardecky.mana.how. - project.yml mit Bundle ev.mana.cards, ManaSwiftCore-Dep via path - AppConfig: auth.mana.how + cardecky-api.mana.how, Keychain ev.mana.cards - CardsTheme: forest-Werte aus mana/packages/themes/.../forest.css - LoginView (Email/PW gegen mana-auth via ManaCore.AuthClient) - DashboardView als β-1-Placeholder mit cardecky-api-Reachability-Probe - Log unter Subsystem ev.mana.cards - 3 AppConfig-Tests - iOS-Simulator-Build grün Phasen-Plan: mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
28b20cd934
21 changed files with 896 additions and 0 deletions
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.DS_Store
|
||||
.build/
|
||||
.swiftpm/
|
||||
DerivedData/
|
||||
Package.resolved
|
||||
xcuserdata/
|
||||
|
||||
# XcodeGen output
|
||||
*.xcodeproj
|
||||
Sources/Resources/Info.plist
|
||||
Sources/Resources/CardsNative.entitlements
|
||||
10
.swiftformat
Normal file
10
.swiftformat
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
--swiftversion 6.0
|
||||
--indent 4
|
||||
--maxwidth 120
|
||||
--wraparguments before-first
|
||||
--wrapparameters before-first
|
||||
--wrapcollections before-first
|
||||
--commas inline
|
||||
--semicolons never
|
||||
--self remove
|
||||
--importgrouping testable-bottom
|
||||
26
.swiftlint.yml
Normal file
26
.swiftlint.yml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
disabled_rules:
|
||||
- todo
|
||||
- trailing_comma
|
||||
|
||||
opt_in_rules:
|
||||
- empty_count
|
||||
- empty_string
|
||||
- explicit_init
|
||||
- first_where
|
||||
- sorted_first_last
|
||||
- toggle_bool
|
||||
|
||||
line_length:
|
||||
warning: 120
|
||||
error: 160
|
||||
ignores_comments: true
|
||||
|
||||
identifier_name:
|
||||
min_length: 2
|
||||
excluded:
|
||||
- id
|
||||
- ok
|
||||
|
||||
included:
|
||||
- Sources
|
||||
- Tests
|
||||
183
CLAUDE.md
Normal file
183
CLAUDE.md
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
# CLAUDE.md — cards-native repo
|
||||
|
||||
Guidance für Claude Code in diesem Repository.
|
||||
|
||||
> **Wenn du gerade neu bist:** lies zuerst [`PLAN.md`](PLAN.md) und
|
||||
> `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md` (übergeordneter
|
||||
> Greenfield-Plan). Dieses CLAUDE.md ist die Konventions- und
|
||||
> Cross-Repo-Referenz.
|
||||
|
||||
## 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-
|
||||
Affordances (Widgets, Notifications, Universal-Links, Pencil).
|
||||
|
||||
```
|
||||
HTTPS/JWT ┌──────────────────┐
|
||||
cards-api ◄───────────── │ cards-native │ SwiftUI
|
||||
cardecky-api.mana.how │ (this repo) │ SwiftData (Cache)
|
||||
│ ev.mana.cards │ WidgetKit (β-6)
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
**Phase β-0 — Setup (2026-05-12).** Repo-Skelett, ManaCore + ManaTokens
|
||||
als Package-Dependency, Login + Cardecky-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
|
||||
Konflikt zwischen Native und Web → **Web gewinnt**. Native ist
|
||||
Re-Implementation, kein neues Produkt.
|
||||
|
||||
Datenmodell, FSRS-Verhalten, Marketplace-Slugs, Sharing-URLs:
|
||||
identisch zu Web.
|
||||
|
||||
## Architektonische Invarianten
|
||||
|
||||
Beschlossen. Nicht ohne explizite Diskussion antasten.
|
||||
|
||||
1. **Server-authoritative FSRS.** Grading-Calls gehen *immer* an
|
||||
`POST /api/v1/reviews/:cardId/:subIndex/grade`. Kein lokaler
|
||||
ts-fsrs-Port.
|
||||
2. **Offline-Read, Online-Write.** Decks + Due-Cards via SwiftData
|
||||
gecacht (offline sichtbar). Grades werden bei Offline in einer
|
||||
lokalen Queue persistiert und beim Reconnect der Reihe nach
|
||||
abgesendet.
|
||||
3. **mana-auth via ManaCore.** `import ManaCore`,
|
||||
`AuthClient(config: AppConfig.manaAppConfig)`. Eigene
|
||||
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.cards`.** Reverse-Domain mana-ev.ch.
|
||||
Universal-Link-Domain: `cardecky.mana.how`.
|
||||
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
|
||||
(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
|
||||
zuerst in die Web-App, dann nach hier.
|
||||
|
||||
## Konventionen
|
||||
|
||||
- **Swift 6.0**, Strict Concurrency komplett
|
||||
- **iOS 18 / iPadOS 18 / macOS 15** Minimum
|
||||
- **SwiftUI** als einziges UI-Framework
|
||||
- **XcodeGen** als SOT: `project.yml` definiert Targets, Info.plist,
|
||||
Entitlements. `.xcodeproj`, generierte Info.plist und Entitlements
|
||||
sind **nicht** im Git
|
||||
- **SwiftFormat** mit `.swiftformat` (4-space, 120-col, sorted imports)
|
||||
- **SwiftLint** mit `.swiftlint.yml`
|
||||
- **Logging:** App-Subsystem `ev.mana.cards` 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
|
||||
|
||||
Wire-Format gegen `https://cardecky-api.mana.how/api/v1/*`. Quelle der
|
||||
Wahrheit: `../cards/apps/api/src/routes/*.ts`. Bei neuem DTO
|
||||
verifizieren:
|
||||
|
||||
1. Path + Method gegen den Hono-Handler prüfen
|
||||
2. Response-Schema (`zod`) gegen `Codable`-Struct mappen
|
||||
3. snake_case via `CodingKeys`, Optionale Felder explizit `Optional<T>`
|
||||
4. Test-Fixture aus echtem Server-Response in `Tests/UnitTests/`
|
||||
|
||||
## Repo-Layout
|
||||
|
||||
```
|
||||
cards-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
|
||||
│ ├── Features/
|
||||
│ │ ├── Account/ LoginView, AccountView (ab β-1)
|
||||
│ │ ├── Decks/ DashboardView (Placeholder), DeckList (β-1)
|
||||
│ │ ├── Study/ (β-2)
|
||||
│ │ ├── Editor/ (β-3)
|
||||
│ │ ├── Marketplace/ (β-5)
|
||||
│ │ ├── Stats/ (β-1)
|
||||
│ │ └── Imports/ (β-3)
|
||||
│ ├── Core/
|
||||
│ │ ├── Auth/ AppConfig (ManaAppConfig-Provider)
|
||||
│ │ ├── API/ CardsAPI (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.cards)
|
||||
│ │ └── Theme/ CardsTheme (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)
|
||||
├── Tests/
|
||||
│ ├── UnitTests/
|
||||
│ └── UITests/
|
||||
└── docs/
|
||||
```
|
||||
|
||||
## Wichtige Cross-Repo-Doks
|
||||
|
||||
- `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md` — vollständiger
|
||||
Phasen-Plan und Architektur-Entscheidungen
|
||||
- `../mana/docs/MANA_SWIFT.md` — native-Plattform-SOT
|
||||
- `../mana/docs/MANA_AUTH_FEDERATION.md` — Auth-Protokoll, das
|
||||
ManaCore implementiert
|
||||
- `../mana/docs/COMPLIANCE.md` — Telemetrie/Auth/Bezahl-Regeln, gilt
|
||||
auch nativ
|
||||
- `../cards/CLAUDE.md` — Cards-Repo, Web + API
|
||||
- `../cards/STATUS.md` — Web-Phasenstand (Funktions-Referenz)
|
||||
- `../mana-swift-core/CLAUDE.md` — geteilter Code, Konventionen
|
||||
|
||||
## Lokal entwickeln
|
||||
|
||||
**Pre-Requisites:**
|
||||
- Xcode 16+
|
||||
- `brew install xcodegen swiftformat swiftlint`
|
||||
- `../mana-swift-core/` muss als Schwester-Verzeichnis existieren
|
||||
(Package-Dependency via `path: ../mana-swift-core`)
|
||||
|
||||
**Workflow:**
|
||||
```bash
|
||||
xcodegen generate
|
||||
open CardsNative.xcodeproj
|
||||
```
|
||||
|
||||
**Vor jedem Commit:**
|
||||
```bash
|
||||
swiftformat Sources Tests
|
||||
swiftlint --strict
|
||||
```
|
||||
|
||||
## Phasen-Disziplin
|
||||
|
||||
Jede Phase aus dem Greenfield-Plan hat ein verifizierbares
|
||||
Erfolgskriterium. Nicht in die nächste Phase reinarbeiten, bevor
|
||||
die vorherige abgeschlossen ist:
|
||||
|
||||
- β-0: leerer Build + Login + API-Reachability-Probe (**JETZT**)
|
||||
- β-1: Decks-Liste mit SwiftData-Cache
|
||||
- β-2: Study-Loop + Offline-Grade-Queue + Endurance-Test auf realem Gerät
|
||||
- β-3: Card-/Deck-Editor (basic, cloze, typing, multiple-choice)
|
||||
- β-4: Media + image-occlusion + audio-front
|
||||
- β-5: Marketplace + Universal-Links
|
||||
- β-6: Native-Polish (Widgets, Notifications, Share-Extension)
|
||||
- β-7: App-Store-Submission
|
||||
|
||||
Bei Phasen-Wechsel: PLAN.md aktualisieren + Greenfield-Plan abhaken.
|
||||
63
PLAN.md
Normal file
63
PLAN.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Plan — cards-native (SwiftUI Universal)
|
||||
|
||||
**Stand: 2026-05-12 — Phase β-0 abgeschlossen.** Repo lebt lokal,
|
||||
ManaCore + ManaTokens als Package-Dependency, Login funktioniert,
|
||||
Cardecky-API-Reachability-Probe.
|
||||
|
||||
> **SOT:** `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`.
|
||||
> Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc
|
||||
> hat die ganze Architektur-Begründung.
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
✅ **β-0 — Setup**
|
||||
- Repo-Skelett unter `git.mana.how/till/cards-native`
|
||||
- `project.yml` mit Bundle-ID `ev.mana.cards`, 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.cards`
|
||||
- `CardsTheme.swift` mit forest-Werten (lokal nachgebaut aus
|
||||
`mana/packages/themes/src/variants/forest.css`)
|
||||
- `LoginView` (Email/PW gegen mana-auth)
|
||||
- `DashboardView` als β-1-Placeholder mit API-Reachability-Indikator
|
||||
- 3 Unit-Tests (AppConfig)
|
||||
- iOS-Simulator-Build grün
|
||||
|
||||
## Phasen (Detail in Greenfield-Plan)
|
||||
|
||||
| Phase | Status | Inhalt |
|
||||
|---|---|---|
|
||||
| β-0 | ✅ 2026-05-12 | Setup, Login, API-Probe |
|
||||
| β-1 | ⏳ | Decks lesen, SwiftData-Cache |
|
||||
| β-2 | — | Study-Loop, Offline-Grade-Queue, Endurance-Test |
|
||||
| β-3 | — | Card-/Deck-Editor (basic, cloze, typing, multiple-choice) |
|
||||
| β-4 | — | Media, image-occlusion (PencilKit), audio-front |
|
||||
| β-5 | — | Marketplace, Universal-Links |
|
||||
| β-6 | — | Native-Polish (Widgets, Notifications, Share-Extension) |
|
||||
| β-7 | — | App-Store-Submission |
|
||||
|
||||
## Nächste Schritte für β-1
|
||||
|
||||
Aus Greenfield-Plan-Sektion "Phase β-1 — Decks lesen":
|
||||
|
||||
1. `Deck`-`Codable`-Struct nach Wire-Format aus
|
||||
`../cards/apps/api/src/routes/decks.ts` + `cards/packages/cards-domain/src/schemas/`
|
||||
2. `CardsAPI.decks() -> [Deck]` mit `GET /api/v1/decks`
|
||||
3. `DeckListView` mit Pull-to-Refresh, Card/Due-Counts
|
||||
4. `CachedDeck` als SwiftData-Model mit `lastFetchedAt`
|
||||
5. Offline-Display bei fehlendem Netz
|
||||
6. Inbox-Banner aus `?forked_from_marketplace=true`-Query
|
||||
|
||||
**Erfolgskriterium:** Web-Account-Decks vollständig in identischer
|
||||
Reihenfolge sichtbar, Pull-to-Refresh aktualisiert Counts.
|
||||
|
||||
## Cross-Refs
|
||||
|
||||
- `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md` — Greenfield-Plan SOT
|
||||
- `../mana/docs/MANA_SWIFT.md` — Plattform-SOT
|
||||
- `../cards/CLAUDE.md` — Cards-Repo
|
||||
- `../cards/STATUS.md` — Web-Phasenstand (Referenz)
|
||||
- `../mana-swift-core/CLAUDE.md` — ManaCore-Konventionen
|
||||
- `CLAUDE.md` — Repo-Konventionen
|
||||
36
README.md
Normal file
36
README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# cards-native
|
||||
|
||||
Native SwiftUI-Universal-App (iOS / iPadOS / macOS) für Cardecky —
|
||||
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.
|
||||
> 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-
|
||||
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.cards │ WidgetKit (β-6)
|
||||
└──────────────────┘
|
||||
│
|
||||
┌─────────────────────────────────────────┐
|
||||
▼ ▼
|
||||
ManaCore (Auth, Transport) ManaTokens (Designwerte)
|
||||
git.mana.how/till/mana-swift-core v1.0.0+
|
||||
```
|
||||
|
||||
## Lokal entwickeln
|
||||
|
||||
```bash
|
||||
xcodegen generate
|
||||
open CardsNative.xcodeproj # iPhone-17-Simulator
|
||||
```
|
||||
|
||||
Konventionen, Invarianten, Phasen-Disziplin: [`CLAUDE.md`](CLAUDE.md).
|
||||
22
Sources/App/CardsNativeApp.swift
Normal file
22
Sources/App/CardsNativeApp.swift
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import ManaCore
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CardsNativeApp: App {
|
||||
@State private var auth: AuthClient
|
||||
|
||||
init() {
|
||||
let auth = AuthClient(config: AppConfig.manaAppConfig)
|
||||
auth.bootstrap()
|
||||
_auth = State(initialValue: auth)
|
||||
Log.app.info("Cards starting — auth status: \(String(describing: auth.status), privacy: .public)")
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environment(auth)
|
||||
.tint(CardsTheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
Sources/App/RootView.swift
Normal file
18
Sources/App/RootView.swift
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import ManaCore
|
||||
import SwiftUI
|
||||
|
||||
/// Top-Level-Switch: Login vs Dashboard.
|
||||
/// Ab Phase β-1 wird Dashboard durch eine echte Tab-Bar (Decks / Study /
|
||||
/// Stats / Account) ersetzt.
|
||||
struct RootView: View {
|
||||
@Environment(AuthClient.self) private var auth
|
||||
|
||||
var body: some View {
|
||||
switch auth.status {
|
||||
case .signedIn:
|
||||
DashboardView()
|
||||
case .unknown, .signedOut, .signingIn, .error:
|
||||
LoginView()
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Sources/Core/API/CardsAPI.swift
Normal file
22
Sources/Core/API/CardsAPI.swift
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import Foundation
|
||||
import ManaCore
|
||||
|
||||
/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
|
||||
/// aus ManaCore, der die Cardecky-Endpoints kennt.
|
||||
///
|
||||
/// In Phase β-0 ist die API leer — Endpoints kommen ab β-1 (Decks),
|
||||
/// β-2 (Reviews), β-3 (Editor), β-4 (Media), β-5 (Marketplace).
|
||||
actor CardsAPI {
|
||||
private let transport: AuthenticatedTransport
|
||||
|
||||
init(auth: AuthClient) {
|
||||
transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth)
|
||||
}
|
||||
|
||||
/// Health-Probe für β-0 — verifiziert dass cardecky-api erreichbar
|
||||
/// ist und der eigene JWT akzeptiert wird.
|
||||
func healthCheck() async throws -> Bool {
|
||||
let (_, http) = try await transport.request(path: "/healthz")
|
||||
return http.statusCode == 200
|
||||
}
|
||||
}
|
||||
15
Sources/Core/Auth/AppConfig.swift
Normal file
15
Sources/Core/Auth/AppConfig.swift
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import Foundation
|
||||
import ManaCore
|
||||
|
||||
/// App-spezifische Konfiguration für Cards. Implementiert `ManaAppConfig`
|
||||
/// aus ManaCore und ergänzt die Cards-eigene `apiBaseURL` (cardecky-api,
|
||||
/// getrennt von mana-auth).
|
||||
enum AppConfig {
|
||||
static let manaAppConfig: ManaAppConfig = DefaultManaAppConfig(
|
||||
authBaseURL: URL(string: "https://auth.mana.how")!,
|
||||
keychainService: "ev.mana.cards",
|
||||
keychainAccessGroup: nil
|
||||
)
|
||||
|
||||
static let apiBaseURL = URL(string: "https://cardecky-api.mana.how")!
|
||||
}
|
||||
13
Sources/Core/Telemetry/Log.swift
Normal file
13
Sources/Core/Telemetry/Log.swift
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// App-eigene OSLog-Logger unter Subsystem `ev.mana.cards`.
|
||||
/// ManaCore loggt unter `ev.mana.core` parallel — siehe
|
||||
/// `mana-swift-core/Sources/ManaCore/Telemetry/CoreLog.swift`.
|
||||
enum Log {
|
||||
static let app = Logger(subsystem: "ev.mana.cards", category: "app")
|
||||
static let auth = Logger(subsystem: "ev.mana.cards", category: "auth")
|
||||
static let api = Logger(subsystem: "ev.mana.cards", category: "api")
|
||||
static let study = Logger(subsystem: "ev.mana.cards", category: "study")
|
||||
static let sync = Logger(subsystem: "ev.mana.cards", category: "sync")
|
||||
}
|
||||
99
Sources/Core/Theme/CardsTheme.swift
Normal file
99
Sources/Core/Theme/CardsTheme.swift
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
78
Sources/Features/Account/LoginView.swift
Normal file
78
Sources/Features/Account/LoginView.swift
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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))
|
||||
}
|
||||
58
Sources/Features/Decks/DashboardView.swift
Normal file
58
Sources/Features/Decks/DashboardView.swift
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import ManaCore
|
||||
import SwiftUI
|
||||
|
||||
/// Phase β-0-Placeholder. Wird in β-1 durch eine echte Tab-Bar mit
|
||||
/// Decks / Study / Stats / Account ersetzt.
|
||||
struct DashboardView: View {
|
||||
@Environment(AuthClient.self) private var auth
|
||||
@State private var apiReachable: Bool?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CardsTheme.background.ignoresSafeArea()
|
||||
VStack(spacing: 24) {
|
||||
Text("Cards")
|
||||
.font(.largeTitle.bold())
|
||||
.foregroundStyle(CardsTheme.primary)
|
||||
|
||||
if let email = auth.currentEmail {
|
||||
Text("Angemeldet als \(email)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
}
|
||||
|
||||
ContentUnavailableView {
|
||||
Label("β-1 in Vorbereitung", systemImage: "rectangle.stack")
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
} description: {
|
||||
Text("Decks- und Study-Views kommen in der nächsten Phase.")
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
}
|
||||
|
||||
if let reachable = apiReachable {
|
||||
Label(
|
||||
reachable ? "cardecky-api erreichbar" : "cardecky-api nicht erreichbar",
|
||||
systemImage: reachable ? "checkmark.circle.fill" : "xmark.circle.fill"
|
||||
)
|
||||
.foregroundStyle(reachable ? CardsTheme.success : CardsTheme.error)
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
Button("Abmelden", role: .destructive) {
|
||||
Task { await auth.signOut() }
|
||||
}
|
||||
.padding(.top, 24)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
.task {
|
||||
let api = CardsAPI(auth: auth)
|
||||
apiReachable = (try? await api.healthCheck()) ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DashboardView()
|
||||
.environment(AuthClient(config: AppConfig.manaAppConfig))
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.231",
|
||||
"green" : "0.502",
|
||||
"red" : "0.106"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
Sources/Resources/Assets.xcassets/Contents.json
Normal file
6
Sources/Resources/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
5
Sources/Resources/Localizable.xcstrings
Normal file
5
Sources/Resources/Localizable.xcstrings
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"sourceLanguage" : "de",
|
||||
"strings" : { },
|
||||
"version" : "1.0"
|
||||
}
|
||||
9
Tests/UITests/CardsNativeUITests.swift
Normal file
9
Tests/UITests/CardsNativeUITests.swift
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import XCTest
|
||||
|
||||
final class CardsNativeUITests: XCTestCase {
|
||||
func testAppLaunches() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
XCTAssertTrue(app.staticTexts["Cards"].waitForExistence(timeout: 5))
|
||||
}
|
||||
}
|
||||
20
Tests/UnitTests/CardsNativeTests.swift
Normal file
20
Tests/UnitTests/CardsNativeTests.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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.cards")
|
||||
func keychainServiceIsAppSpecific() {
|
||||
#expect(AppConfig.manaAppConfig.keychainService == "ev.mana.cards")
|
||||
}
|
||||
}
|
||||
97
project.yml
Normal file
97
project.yml
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
name: CardsNative
|
||||
|
||||
options:
|
||||
bundleIdPrefix: ev.mana
|
||||
createIntermediateGroups: true
|
||||
deploymentTarget:
|
||||
iOS: "18.0"
|
||||
macOS: "15.0"
|
||||
developmentLanguage: de
|
||||
groupSortPosition: top
|
||||
generateEmptyDirectories: true
|
||||
|
||||
packages:
|
||||
ManaSwiftCore:
|
||||
path: ../mana-swift-core
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
MARKETING_VERSION: "0.1.0"
|
||||
GENERATE_INFOPLIST_FILE: "NO"
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: "YES"
|
||||
DEAD_CODE_STRIPPING: "YES"
|
||||
CLANG_ENABLE_MODULES: "YES"
|
||||
|
||||
targets:
|
||||
CardsNative:
|
||||
type: application
|
||||
supportedDestinations: [iOS, macOS]
|
||||
dependencies:
|
||||
- package: ManaSwiftCore
|
||||
product: ManaCore
|
||||
- package: ManaSwiftCore
|
||||
product: ManaTokens
|
||||
sources:
|
||||
- path: Sources/App
|
||||
- path: Sources/Features
|
||||
- path: Sources/Core
|
||||
- path: Sources/Resources
|
||||
excludes:
|
||||
- "Info.plist"
|
||||
- "CardsNative.entitlements"
|
||||
info:
|
||||
path: Sources/Resources/Info.plist
|
||||
properties:
|
||||
CFBundleShortVersionString: "0.1.0"
|
||||
CFBundleVersion: "1"
|
||||
CFBundleDevelopmentRegion: de
|
||||
CFBundleDisplayName: Cards
|
||||
LSApplicationCategoryType: "public.app-category.education"
|
||||
UILaunchScreen: {}
|
||||
CFBundleURLTypes:
|
||||
- CFBundleURLName: ev.mana.cards
|
||||
CFBundleURLSchemes:
|
||||
- cards
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
entitlements:
|
||||
path: Sources/Resources/CardsNative.entitlements
|
||||
properties:
|
||||
com.apple.security.app-sandbox: true
|
||||
com.apple.security.network.client: true
|
||||
com.apple.security.files.user-selected.read-write: true
|
||||
keychain-access-groups:
|
||||
- $(AppIdentifierPrefix)ev.mana.cards
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cards
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||
ENABLE_PREVIEWS: "YES"
|
||||
|
||||
CardsNativeTests:
|
||||
type: bundle.unit-test
|
||||
supportedDestinations: [iOS, macOS]
|
||||
sources:
|
||||
- Tests/UnitTests
|
||||
dependencies:
|
||||
- target: CardsNative
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cards.tests
|
||||
GENERATE_INFOPLIST_FILE: "YES"
|
||||
|
||||
CardsNativeUITests:
|
||||
type: bundle.ui-testing
|
||||
supportedDestinations: [iOS, macOS]
|
||||
sources:
|
||||
- Tests/UITests
|
||||
dependencies:
|
||||
- target: CardsNative
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cards.uitests
|
||||
GENERATE_INFOPLIST_FILE: "YES"
|
||||
Loading…
Add table
Add a link
Reference in a new issue