From 0bd59ed14838414904229560a8b0ebcb49a9cff0 Mon Sep 17 00:00:00 2001 From: Till Date: Thu, 14 May 2026 12:15:22 +0200 Subject: [PATCH] =?UTF-8?q?=CE=B6-0=20Setup:=20Repo-Skelett,=20iOS-Build?= =?UTF-8?q?=20gr=C3=BCn,=20Healthz=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project.yml mit Bundle ev.mana.zitare + Widget + ShareExt-Targets - ManaSwiftCore (ManaCore + ManaTokens) + ManaSwiftUI (ManaAuthUI) als Package-Dependencies via path: - Pure SwiftUI für Native-Surfaces, WKWebView nur für Lese-Tabs (Hybrid-Sonderfall vs cards/memoro/manaspur, dokumentiert im Playbook ZITARE_NATIVE_GREENFIELD.md) - Theme: paper-Variant aus @mana/themes - ZitareAPI.healthCheck via direct URLSession (öffentlicher Endpoint, kein AuthenticatedTransport-Gate) - 6/6 AppConfigTests + 1/1 UI-Smoke grün auf iPhone 16e Simulator - Live: zitare-api.mana.how/healthz → HTTP/2 200 Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 31 +++ .swiftformat | 10 + .swiftlint.yml | 49 ++++ CLAUDE.md | 233 ++++++++++++++++++ PLAN.md | 95 +++++++ README.md | 46 ++++ ShareExtension/ShareViewController.swift | 58 +++++ Sources/App/RootView.swift | 105 ++++++++ Sources/App/ZitareNativeApp.swift | 24 ++ Sources/Core/API/ZitareAPI.swift | 39 +++ Sources/Core/Auth/AppConfig.swift | 30 +++ Sources/Core/Telemetry/Log.swift | 16 ++ Sources/Core/Theme/ZitareTheme.swift | 105 ++++++++ Sources/Features/Account/AccountView.swift | 106 ++++++++ Sources/Features/Settings/SettingsView.swift | 19 ++ Sources/Features/Submit/SubmitQuoteView.swift | 32 +++ Sources/Features/WebShell/WebShellView.swift | 38 +++ .../AccentColor.colorset/Contents.json | 20 ++ .../AppIcon.appiconset/Contents.json | 13 + .../Resources/Assets.xcassets/Contents.json | 6 + Sources/Resources/Localizable.xcstrings | 48 ++++ Tests/UITests/SmokeUITests.swift | 17 ++ Tests/UnitTests/AppConfigTests.swift | 34 +++ Widgets/ZitareWidget/ZitareWidgetBundle.swift | 85 +++++++ project.yml | 209 ++++++++++++++++ 25 files changed, 1468 insertions(+) create mode 100644 .gitignore create mode 100644 .swiftformat create mode 100644 .swiftlint.yml create mode 100644 CLAUDE.md create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 ShareExtension/ShareViewController.swift create mode 100644 Sources/App/RootView.swift create mode 100644 Sources/App/ZitareNativeApp.swift create mode 100644 Sources/Core/API/ZitareAPI.swift create mode 100644 Sources/Core/Auth/AppConfig.swift create mode 100644 Sources/Core/Telemetry/Log.swift create mode 100644 Sources/Core/Theme/ZitareTheme.swift create mode 100644 Sources/Features/Account/AccountView.swift create mode 100644 Sources/Features/Settings/SettingsView.swift create mode 100644 Sources/Features/Submit/SubmitQuoteView.swift create mode 100644 Sources/Features/WebShell/WebShellView.swift create mode 100644 Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Sources/Resources/Assets.xcassets/Contents.json create mode 100644 Sources/Resources/Localizable.xcstrings create mode 100644 Tests/UITests/SmokeUITests.swift create mode 100644 Tests/UnitTests/AppConfigTests.swift create mode 100644 Widgets/ZitareWidget/ZitareWidgetBundle.swift create mode 100644 project.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b98b06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +## XcodeGen-Output (regenerierbar aus project.yml) +*.xcodeproj/ +Sources/Resources/Info.plist +Sources/Resources/ZitareNative.entitlements +Widgets/ZitareWidget/Resources/Info.plist +Widgets/ZitareWidget/Resources/ZitareWidgetExtension.entitlements +ShareExtension/Resources/Info.plist +ShareExtension/Resources/ZitareShareExtension.entitlements + +## Xcode user state +xcuserdata/ +*.xcworkspace/xcuserdata/ +*.xcuserstate +DerivedData/ + +## SwiftPM +.build/ +Package.resolved +.swiftpm/ + +## macOS +.DS_Store + +## Build artifacts +build/ +*.dSYM.zip +*.ipa + +## Editor +.vscode/ +.idea/ diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..c1b2534 --- /dev/null +++ b/.swiftformat @@ -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 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..72a6883 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,49 @@ +disabled_rules: + - todo + - trailing_comma + - opening_brace + +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 + - i + - j + - n + - m + - x + - y + - w + - h + - r + - g + - b + - a + - c + - d + - s + - f + - p + - q + - t + - l + +included: + - Sources + - Widgets + - ShareExtension + - Tests diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7e60b1c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,233 @@ +# CLAUDE.md — zitare-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/ZITARE_NATIVE_GREENFIELD.md`](../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md). +> Dieses CLAUDE.md ist die Konventions- und Cross-Repo-Referenz. + +## Was dieses Repo ist + +**Zitare Native** — native SwiftUI-Universal-App (iOS / iPadOS / +macOS) für **Zitare**, den öffentlichen Zitat-Korpus des Vereins +**mana e.V.** + +``` + HTTPS ┌──────────────────┐ + zitare.com ◄──────────── │ zitare-native │ WKWebView (Lesen) + (statisch, public) │ (this repo) │ SwiftUI (Submit) + │ ev.mana.zitare │ WidgetKit + zitare-api ◄──────────── │ │ SwiftData (Snapshot-Cache) + zitare-api.mana.how │ │ CoreSpotlight + └──────────────────┘ +``` + +## Status + +**Phase ζ-0 — Setup.** Repo-Skelett, `project.yml`, leerer Build im +Simulator. Phasen ζ-1 bis ζ-7 in +[`../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md`](../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md). + +## Leitprinzip: Verteilungs-USP, nicht Funktions-USP + +Anders als die anderen drei nativen Apps (cards/memoro/manaspur) gibt +es bei Zitare **keinen Hardware-Vorteil** gegenüber dem Browser. Die +Web-App ist mobile-responsive, statisch prerendered, hat Pagefind- +client-Suche. Native bringt: + +1. **Home-Screen-Widget** „Zitat des Tages" +2. **ShareExtension** als Ziel für markierten Text +3. **Spotlight-Index** für system-weite Suche +4. **Native Submit-View** mit ManaAuthUI + +Alles andere (Lesen, Filtern, Search, Edit, Moderation) bleibt im +`WKWebView` gegen `zitare.com` / `zitare.mana.how`. + +## Architektonische Invarianten + +Beschlossen. Nicht ohne explizite Diskussion antasten. + +1. **Hybrid ausnahmsweise.** Lese-Surfaces via `WKWebView`, Native- + Surfaces (Widget, ShareExt, Submit, Spotlight) pure SwiftUI. Diese + Trennung ist **fest** — keine schleichende Native-Re-Implementation + von Read-Routes. +2. **Read-only via Web, Submit via SwiftUI.** Submit ist der einzige + schreibende Pfad in v1. Edit, Moderation, History bleiben Web. +3. **Snapshot lokal gespiegelt für Widget + Spotlight.** Beim Launch + `https://zitare.com/index-min.json` pullen, in SwiftData + persistieren, App-Group `group.ev.mana.zitare` reicht es an Widget + + ShareExtension durch. **Nicht** für den WebView-Pfad — der lädt + live. +4. **mana-auth via ManaCore + ManaAuthUI.** Submit-Pfad nutzt + `AuthClient` und die fertigen Views aus ManaAuthUI. Keine eigene + Auth. WebView gegen `zitare.mana.how` bekommt JWT per Cookie- + Injection (`mana.access` auf `.mana.how`). +5. **Universal-Link-Domain: `zitare.com`.** AASA auf + `https://zitare.com/.well-known/apple-app-site-association`. + `zitare.mana.how` ist *kein* applinks-Ziel. +6. **Theme: `paper`-Variant default.** Werte aus `@mana/themes/paper`, + lokal als `ZitareTheme.swift` nachgebaut. +7. **Bundle-ID `ev.mana.zitare`.** Reverse-Domain mana-ev.ch. + App-Display-Name: „Zitare". Category: `public.app-category.reference`. +8. **Pure SwiftUI für Native-Surfaces.** WKWebView ist die einzige + UIKit-Bridge. +9. **Web gewinnt bei Konflikt.** Funktion wandert zuerst in + `zitare/apps/zitare/` oder `zitare/apps/api/`, dann ins WebView- + bzw. Native-Surface hier. + +## Konventionen + +- **Swift 6.0**, Strict Concurrency komplett +- **iOS 18 / iPadOS 18 / macOS 15** Minimum +- **SwiftUI** als einziges UI-Framework, `WKWebView` via + `UIViewRepresentable`/`NSViewRepresentable` die einzige Bridge +- **XcodeGen** als SOT: `project.yml` definiert Targets, Info.plist, + Entitlements. `.xcodeproj`, generierte Info.plist und Entitlements + sind **nicht** im Git +- **SwiftFormat** mit `.swiftformat` +- **SwiftLint** mit `.swiftlint.yml` +- **Logging:** App-Subsystem `ev.mana.zitare` via + `Sources/Core/Telemetry/Log.swift` +- **Persistenz:** SwiftData für Snapshot-Cache + PendingSubmission- + Queue, JWT im Keychain (über ManaCore) +- **Lokalisierung:** DE primary, EN fallback via `Localizable.xcstrings` + +## Zitare-API-Wire-Format + +Wire-Format gegen `https://zitare-api.mana.how/api/v1/*`. Quelle der +Wahrheit: `../zitare/apps/api/src/routes/*.ts`. Bei neuem DTO: + +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` +4. Test-Fixture aus echtem Server-Response in `Tests/UnitTests/` + +## Snapshot-Vertrag + +`https://zitare.com/index-min.json` ist der **lokale Korpus-Spiegel**. +Heute Build-Output (`zitare/apps/zitare/src/content/index-min.json`), +ausgeliefert als statische Datei. + +```json +{ + "generatedAt": "2026-05-08T20:48:48.795Z", + "count": 11, + "quotes": [ + { + "slug": "...", + "authorSlug": "...", + "language": "de", + "themeSlugs": [...], + "regionSlugs": [...], + ... + } + ] +} +``` + +Native-Konsumenten: `SnapshotSync` (App), `DailyQuoteWidget` +(WidgetExtension via App-Group), `SpotlightIndexer` (App), `Submit`- +View (Auto-Complete für Author/Theme). + +## Repo-Layout + +``` +zitare-native/ +├── project.yml XcodeGen-Manifest (SOT) +├── PLAN.md Phasen-Tracking +├── CLAUDE.md dieses File +├── README.md +├── .swiftformat, .swiftlint.yml, .gitignore +├── Sources/ +│ ├── App/ ZitareNativeApp (@main), RootView +│ ├── Features/ +│ │ ├── WebShell/ WebShellView (WKWebView-Wrapper, ζ-1) +│ │ ├── Submit/ SubmitQuoteView (ζ-3) +│ │ ├── Account/ AccountView (ζ-0 stub) +│ │ └── Settings/ SettingsView (ζ-5) +│ ├── Core/ +│ │ ├── Auth/ AppConfig (ManaAppConfig-Provider) +│ │ ├── API/ ZitareAPI (Quote-DTOs, Submit, Share) +│ │ ├── Snapshot/ SnapshotSync, SnapshotStore (ζ-2) +│ │ ├── Spotlight/ SpotlightIndexer (ζ-4) +│ │ ├── Telemetry/ Log (OSLog, ev.mana.zitare) +│ │ └── Theme/ ZitareTheme (paper-Werte) +│ ├── Widgets/ WidgetKit-Extension (ζ-2) +│ ├── ShareExtension/ „An Zitare schicken" (ζ-4) +│ └── Resources/ +│ ├── Assets.xcassets AppIcon, AccentColor +│ ├── Localizable.xcstrings +│ ├── Info.plist (XcodeGen-generiert, gitignored) +│ └── ZitareNative.entitlements (generiert, gitignored) +├── Tests/ +│ ├── UnitTests/ +│ └── UITests/ +└── docs/ +``` + +## Wichtige Cross-Repo-Doks + +- `../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md` — vollständiger + Phasen-Plan und Architektur-Begründungen +- `../mana/docs/MANA_SWIFT.md` — Plattform-SOT für alle nativen Apps +- `../mana/docs/MANA_AUTH_FEDERATION.md` — Auth-Protokoll, Cookie-SSO +- `../mana/docs/COMPLIANCE.md` — Telemetrie/Auth/Bezahl-Regeln plus + Plattform-Lock-In-Diskurs (gilt auch native) +- `../zitare/CLAUDE.md` — Web-App-Konventionen +- `../zitare/STATUS.md` — Web-Phasenstand (Funktions-Referenz) +- `../zitare/app-manifest.json` — Föderations-Vertrag (Shares / + Accepts / Link-Patterns) +- `../mana-swift-core/CLAUDE.md` — ManaCore + ManaTokens +- `../mana-swift-ui/CLAUDE.md` — ManaAuthUI + ManaAuthGate + +## Lokal entwickeln + +**Pre-Requisites:** +- Xcode 16+ +- `brew install xcodegen swiftformat swiftlint` +- `../mana-swift-core/` und `../mana-swift-ui/` als Schwester- + Verzeichnisse (Package-Dependency via `path:`) + +**Workflow:** +```bash +xcodegen generate +open ZitareNative.xcodeproj +``` + +**Vor jedem Commit:** +```bash +swiftformat Sources Widgets ShareExtension 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 + ManaCore-Login + Healthz-Probe (**JETZT**) +- ζ-1: WebShellView + Universal-Links + Cookie-SSO-Bridge +- ζ-2: Snapshot-Sync + DailyQuoteWidget auf realem Gerät +- ζ-3: Submit-View nativ mit ManaAuthGate +- ζ-4: Spotlight + ShareExtension + App Intents +- ζ-5: Polish, Theme-Sync, iPad-Split-Layout, Accessibility +- ζ-6: App-Store-Submission + +Bei Phasen-Wechsel: PLAN.md aktualisieren + Memory-Eintrag +`project_zitare_native.md` nachziehen (sobald angelegt). + +## Don't do + +- **Keine Native-Re-Implementation der Read-Routes.** Wenn dir die + Web-Quote-Ansicht im WebView nicht gefällt, fixe sie in + `../zitare/apps/zitare/src/routes/(read)/`. Nicht hier eine zweite + bauen. +- **Kein eigener FSRS-Port, kein eigener Pagefind-Klon.** Existiert + beides nicht für Zitare und soll nicht entstehen. +- **Keine Push-Notification-Pipeline.** Widget reicht. +- **Keine externen UI-Libs / kein Sentry / kein Crash-Reporting- + SaaS.** OSLog only (Compliance). +- **Keine offline-Volltext-Funktion im WebView.** Wenn Offline- + Lesen Use-Case wird → PWA-Pfad (Option A in `zitare/CLAUDE.md`), + nicht hier. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..eeb283e --- /dev/null +++ b/PLAN.md @@ -0,0 +1,95 @@ +# Plan — zitare-native (SwiftUI Hybrid) + +**Stand: 2026-05-14 — Phase ζ-0 Setup.** Repo-Skelett, `project.yml`, +`CLAUDE.md`, leerer Build steht aus. Vollständige Phasen-Begründung +in [`../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md`](../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md). + +> **SOT:** das Greenfield-Playbook. Dieses File ist nur die App-lokale +> Status-Spur. + +## Aktueller Stand + +🚧 **ζ-0 — Setup (2026-05-14, in Arbeit)** + +- [x] Repo-Skelett unter `~/Documents/Code/zitare-native/` +- [x] `project.yml` mit Bundle `ev.mana.zitare`, drei Targets + (App + Widget + ShareExt), ManaSwiftCore + ManaSwiftUI via + `path: ../mana-swift-core` / `path: ../mana-swift-ui` +- [x] `.swiftformat`, `.swiftlint.yml`, `.gitignore` +- [x] `CLAUDE.md`, `README.md`, `PLAN.md` +- [x] Source-Stubs (App, RootView, AppConfig, ZitareAPI, Log, + ZitareTheme, Resources) +- [x] `xcodegen generate` lokal grün (2026-05-14) +- [x] `swiftlint --strict` 0 violations in 14 files +- [x] Leerer Build im iOS-Simulator (iPhone 16e, Xcode 26.2 SDK, + `xcodebuild ... -destination 'platform=iOS Simulator,name=iPhone 16e'`) +- [x] Unit-Tests grün (6/6 AppConfigTests) + UI-Smoke grün +- [ ] Leerer Build auf macOS — **blockiert auf Apple-Developer-Portal- + Setup.** App-Group `group.ev.mana.zitare` muss im Portal + registriert werden, sonst Provisioning-Profile-Fehler. Same + Blocker wie cards-native ζ-7 / memoro-native (siehe Memory + `project_memoro_native.md` „Apple-Dev-Portal App-Group- + Aktivierung nötig vor Test"). +- [ ] ManaCore-Login mit Founder-Account, JWT im Keychain (manueller + Test im Simulator, nicht im CI) +- [x] `/healthz`-Probe gegen `zitare-api.mana.how` loggt 200 (live + gegen Mac-Mini-API verifiziert 2026-05-14 12:14, HTTP/2 200, + OSLog „Healthz: OK") +- [ ] AASA auf `https://zitare.com/.well-known/apple-app-site-association` + (Aufgabe ans Zitare-Web-Repo, blockiert ζ-1 nicht ζ-0) +- [ ] Git-Repo `git.mana.how/till/zitare-native` (push) + +### ζ-0 Verifikations-Log + +``` +2026-05-14 12:08 xcodebuild iOS Simulator iPhone 16e — BUILD SUCCEEDED + (nach Fix: .iso8601withFractional war cards-native- + local; in zitare-native auskommentiert, ζ-3 Port-TODO) +2026-05-14 12:10 Unit-Tests: 6/6 AppConfigTests passed in 0.03s +2026-05-14 12:11 UI-Smoke: test_appLaunches passed in 5.79s + (nach Fix: Test suchte "Zitare" das nur im Account- + Tab ist, jetzt auf Default-Tab "Lesen" geändert) +2026-05-14 12:09 xcodebuild macOS — BUILD FAILED, blockiert auf + Apple-Dev-Portal App-Group-Registrierung +2026-05-14 12:14 iPhone 16e Simulator: App-Launch + Live-Healthz + gegen zitare-api.mana.how → HTTP/2 200, OSLog + "[ev.mana.zitare:app] Zitare starting — auth + status: signedOut" und "[ev.mana.zitare:api] + Healthz: OK" (Fix: AuthenticatedTransport rejecte + notSignedIn auf public Endpoint, jetzt direkter + URLSession-Call für /healthz) +``` + +## Phasen-Übersicht + +| Phase | Ziel | Erfolg | Status | +|---|---|---|---| +| ζ-0 | Setup, leerer Build, Login | iOS-Build ✅ + Tests ✅, Mac + Login + Healthz Live offen | 🚧 (90%) | +| ζ-1 | WebShellView + Universal-Links | UL öffnet App auf Quote-Detail | ⏳ | +| ζ-2 | Snapshot-Sync + DailyQuoteWidget | Widget auf realem Gerät zeigt Zitat | ⏳ | +| ζ-3 | Submit-View nativ | Founder submittet Quote, Draft in /admin/queue | ⏳ | +| ζ-4 | Spotlight + ShareExt + App Intents | Spotlight findet, ShareExt POSTet | ⏳ | +| ζ-5 | Polish (Theme-Sync, iPad, A11y) | Eine Woche ohne Safari-Tab nutzbar | ⏳ | +| ζ-6 | App-Store-Submission | Approved | ⏳ | + +## Web-Vorbedingungen (Aufgabe an `../zitare/`) + +- [ ] AASA-Eintrag auf `https://zitare.com/.well-known/apple-app-site-association` + mit `appID: QP3GLU8PH3.ev.mana.zitare` +- [ ] `index-min.json` als versionierter, ETag-versehener Endpoint + (oder einigen, dass die Build-Output-Datei stabil bleibt) +- [ ] `POST /api/v1/share/receive` mit `mana/text`-Envelope-Handler + (Manifest registriert, Code TBD) +- [ ] `zitare.com` Cloudflare-Zone-Onboarding (steht im + `zitare/STATUS.md` als offen) +- [ ] Cookie-SSO-Compat auf `zitare.mana.how` end-to-end testen + (Phase 2.G im Web-Repo code-fertig, Live-Test offen) + +## Verifikations-Lücken + +Wird nach jedem ζ-Schritt befüllt. + +## Quirks + +Wird im Verlauf befüllt. Format-Vorbild: `../zitare/STATUS.md` Sektion +„Quirks". diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef857e3 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# zitare-native + +Native SwiftUI-Universal-App (iOS / iPadOS / macOS) für **Zitare**, +den öffentlichen Zitat-Korpus des Vereins **mana e.V.** + +> **Hybrid-Architektur.** Lese-Surfaces laufen im `WKWebView` gegen +> `zitare.com`. Native-Code ist nur dort, wo Native einen +> Distributions-Hebel hat: Home-Screen-Widget, ShareExtension, +> Spotlight-Index und Submit-View. Anders als +> [cards-native](../cards-native), [memoro-native](../memoro-native) +> und [manaspur-native](../manaspur-native) (alle „pure SwiftUI") — +> die Begründung steht im Playbook. + +## Status + +**Phase ζ-0 — Setup.** Repo-Skelett, `project.yml`, leerer Build. +Vollständiger 7-Phasen-Plan in +[`../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md`](../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md). +Phasen-Tracking in [`PLAN.md`](PLAN.md). + +## Lokal entwickeln + +```bash +brew install xcodegen swiftformat swiftlint +xcodegen generate +open ZitareNative.xcodeproj +``` + +Voraussetzung: `../mana-swift-core/` und `../mana-swift-ui/` als +Schwester-Verzeichnisse (Package-Dependencies via `path:`). + +## Bundle-IDs + +| Target | Bundle | +|---|---| +| App | `ev.mana.zitare` | +| Widget Extension | `ev.mana.zitare.widget` | +| Share Extension | `ev.mana.zitare.share` | +| App Group | `group.ev.mana.zitare` | + +## Cross-Refs + +- Playbook: [`../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md`](../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md) +- Web-App: [`../zitare/CLAUDE.md`](../zitare/CLAUDE.md) +- Plattform-SOT: [`../mana/docs/MANA_SWIFT.md`](../mana/docs/MANA_SWIFT.md) +- Shared Code: [`../mana-swift-core/`](../mana-swift-core/), [`../mana-swift-ui/`](../mana-swift-ui/) diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift new file mode 100644 index 0000000..2a3ee4b --- /dev/null +++ b/ShareExtension/ShareViewController.swift @@ -0,0 +1,58 @@ +import SwiftUI +import UIKit +import UniformTypeIdentifiers + +/// Phase ζ-4 Placeholder — Share-Extension „An Zitare schicken". +/// +/// Aufgaben in ζ-4: +/// +/// - Empfängt `NSExtensionActivationSupportsText` + `WebURL`. +/// - SwiftUI-Sheet mit Quote-Preview, Source-URL, „Als Draft +/// speichern"-Button. +/// - POST an `https://zitare-api.mana.how/api/v1/share/receive` +/// mit Envelope-Type `mana/text` (siehe `zitare/app-manifest.json`). +/// - Bei Network-Failure: in `PendingShareStore` unter App-Group +/// persistieren, die App synct beim nächsten Launch. +/// +/// Erfordert ManaCore-Auth-Sharing über Keychain-Access-Group + +/// App-Group. Auth-Bridge-Pattern aus cards-native (ShareExtension/) +/// übernehmen, wenn ζ-4 startet. +class ShareViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + let hosting = UIHostingController( + rootView: ShareRootView(onDone: { [weak self] in + self?.extensionContext?.completeRequest(returningItems: nil) + }) + ) + addChild(hosting) + view.addSubview(hosting.view) + hosting.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hosting.view.topAnchor.constraint(equalTo: view.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + hosting.didMove(toParent: self) + } +} + +struct ShareRootView: View { + let onDone: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "quote.opening") + .font(.system(size: 48)) + Text("An Zitare schicken") + .font(.headline) + Text("ζ-4 — TODO: POST /api/v1/share/receive") + .font(.caption) + .foregroundStyle(.secondary) + Button("Schließen", action: onDone) + .buttonStyle(.borderedProminent) + } + .padding() + } +} diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift new file mode 100644 index 0000000..a3678f1 --- /dev/null +++ b/Sources/App/RootView.swift @@ -0,0 +1,105 @@ +import ManaCore +import SwiftUI + +/// Top-Level-View: TabView mit drei Tabs. +/// +/// **Phase ζ-0 — Setup.** Tabs zeigen aktuell nur Placeholder-Views. +/// Ab Phase ζ-1 wird der Lesen-Tab durch `WebShellView` ersetzt, +/// der `zitare.com` im `WKWebView` rendert. +struct RootView: View { + @Environment(AuthClient.self) private var auth + @State private var selectedTab: AppTab = .read + @State private var healthStatus: HealthStatus = .unknown + + var body: some View { + TabView(selection: $selectedTab) { + placeholderView( + title: "Lesen", + subtitle: "ζ-1: WebShellView gegen zitare.com", + systemImage: "book" + ) + .tabItem { Label("Lesen", systemImage: "book") } + .tag(AppTab.read) + + placeholderView( + title: "Erkunden", + subtitle: "ζ-1: WebShell auf zitare.com/explore", + systemImage: "sparkle.magnifyingglass" + ) + .tabItem { Label("Erkunden", systemImage: "sparkle.magnifyingglass") } + .tag(AppTab.explore) + + AccountView(healthStatus: healthStatus) + .tabItem { Label("Konto", systemImage: "person.circle") } + .tag(AppTab.account) + } + .task { + await probeHealth() + } + .onOpenURL { url in + handle(url: url) + } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + if let url = activity.webpageURL { handle(url: url) } + } + } + + /// Phase ζ-0 Probe: ein Aufruf gegen `/healthz` bei App-Start. + /// Ab Phase ζ-1 wandert das in einen Service, der den Status global + /// trackt und im AccountView anzeigt. + private func probeHealth() async { + let api = ZitareAPI(auth: auth) + do { + let ok = try await api.healthCheck() + healthStatus = ok ? .ok : .down + Log.api.info("Healthz: \(ok ? "OK" : "DOWN")") + } catch { + healthStatus = .down + Log.api.warning( + "Healthz fehlgeschlagen: \(String(describing: error), privacy: .public)" + ) + } + } + + /// Deep-Link-Routing. Phase ζ-0: nur loggen. Phase ζ-1: in den + /// passenden Tab + Pfad routen. + private func handle(url: URL) { + Log.app.info("Deep-Link empfangen: \(url.absoluteString, privacy: .public)") + // ζ-1 TODO: parse zitare.com/q/, /a/, /c/ + // und in WebShellView mit entsprechender URL laden. + } + + private func placeholderView( + title: String, + subtitle: String, + systemImage: String + ) -> some View { + VStack(spacing: 16) { + Image(systemName: systemImage) + .font(.system(size: 48)) + .foregroundStyle(ZitareTheme.primary) + Text(title) + .font(.title2) + .fontWeight(.semibold) + Text(subtitle) + .font(.callout) + .foregroundStyle(ZitareTheme.mutedForeground) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(ZitareTheme.background) + } +} + +enum AppTab: Hashable { + case read + case explore + case account +} + +enum HealthStatus { + case unknown + case ok + case down +} diff --git a/Sources/App/ZitareNativeApp.swift b/Sources/App/ZitareNativeApp.swift new file mode 100644 index 0000000..2183fbb --- /dev/null +++ b/Sources/App/ZitareNativeApp.swift @@ -0,0 +1,24 @@ +import ManaCore +import SwiftUI + +@main +struct ZitareNativeApp: App { + @State private var auth: AuthClient + + init() { + let auth = AuthClient(config: AppConfig.manaAppConfig) + auth.bootstrap() + _auth = State(initialValue: auth) + Log.app.info( + "Zitare starting — auth status: \(String(describing: auth.status), privacy: .public)" + ) + } + + var body: some Scene { + WindowGroup { + RootView() + .environment(auth) + .tint(ZitareTheme.primary) + } + } +} diff --git a/Sources/Core/API/ZitareAPI.swift b/Sources/Core/API/ZitareAPI.swift new file mode 100644 index 0000000..d55dbf1 --- /dev/null +++ b/Sources/Core/API/ZitareAPI.swift @@ -0,0 +1,39 @@ +import Foundation +import ManaCore + +/// Zitare-spezifischer API-Client. Wrapper um `AuthenticatedTransport` +/// aus ManaCore, der die zitare-api-Endpoints kennt. +/// +/// Phase ζ-0: nur Health-Probe. Endpoints für Submit, Share-Receive, +/// Quote-Lookup folgen in ζ-3 / ζ-4. +actor ZitareAPI { + let transport: AuthenticatedTransport + let decoder: JSONDecoder + + init(auth: AuthClient) { + transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth) + decoder = JSONDecoder() + // ζ-3 TODO: bei echten DTOs `.iso8601withFractional`-Extension + // aus cards-native portieren (Server liefert ISO8601 mit + // Fractional-Seconds, Standard `.iso8601` schluckt das nicht). + } + + /// `GET /healthz` — verifiziert dass zitare-api erreichbar ist. + /// Öffentlicher Endpoint, läuft direkt via `URLSession` (nicht + /// `AuthenticatedTransport`), damit auch nicht-eingeloggte Apps + /// die API-Erreichbarkeit prüfen können. + func healthCheck() async throws -> Bool { + let url = AppConfig.apiBaseURL.appendingPathComponent("healthz") + let (_, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse else { return false } + return http.statusCode == 200 + } + + // MARK: - Phase ζ-3: Submit + + // func submitQuote(_ draft: QuoteDraft) async throws -> SubmittedQuote { ... } + + // MARK: - Phase ζ-4: Share-Receive + + // func receiveShare(_ envelope: ShareEnvelope) async throws -> ShareReceipt { ... } +} diff --git a/Sources/Core/Auth/AppConfig.swift b/Sources/Core/Auth/AppConfig.swift new file mode 100644 index 0000000..3bd6828 --- /dev/null +++ b/Sources/Core/Auth/AppConfig.swift @@ -0,0 +1,30 @@ +import Foundation +import ManaCore + +/// App-spezifische Konfiguration für Zitare. Implementiert +/// `ManaAppConfig` aus ManaCore und ergänzt die Zitare-eigene +/// `apiBaseURL` (zitare-api, getrennt von mana-auth) sowie +/// `webBaseURL` (zitare.com, für WKWebView und Universal-Links) +/// und `appBaseURL` (zitare.mana.how, für eingeloggte Pfade). +enum AppConfig { + static let manaAppConfig: ManaAppConfig = DefaultManaAppConfig( + authBaseURL: URL(string: "https://auth.mana.how")!, + keychainService: "ev.mana.zitare", + keychainAccessGroup: nil + ) + + /// `zitare-api.mana.how` — API-Backend (Hono+Bun). + static let apiBaseURL = URL(string: "https://zitare-api.mana.how")! + + /// `zitare.com` — öffentliches statisches Frontend. Universal-Link- + /// Domain. WKWebView-Default für Lesen-Surfaces. + static let webBaseURL = URL(string: "https://zitare.com")! + + /// `zitare.mana.how` — SPA-Surface für eingeloggte Pfade (Submit, + /// Edit, Moderation). Bekommt den `mana.access`-Cookie injiziert + /// für Cookie-SSO. + static let appBaseURL = URL(string: "https://zitare.mana.how")! + + /// App-Group für Daten-Sharing zwischen App ↔ Widget ↔ ShareExt. + static let appGroup = "group.ev.mana.zitare" +} diff --git a/Sources/Core/Telemetry/Log.swift b/Sources/Core/Telemetry/Log.swift new file mode 100644 index 0000000..59d69a2 --- /dev/null +++ b/Sources/Core/Telemetry/Log.swift @@ -0,0 +1,16 @@ +import Foundation +import OSLog + +/// App-eigene OSLog-Logger unter Subsystem `ev.mana.zitare`. +/// 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.zitare", category: "app") + static let auth = Logger(subsystem: "ev.mana.zitare", category: "auth") + static let api = Logger(subsystem: "ev.mana.zitare", category: "api") + static let web = Logger(subsystem: "ev.mana.zitare", category: "web") + static let snapshot = Logger(subsystem: "ev.mana.zitare", category: "snapshot") + static let widget = Logger(subsystem: "ev.mana.zitare", category: "widget") + static let spotlight = Logger(subsystem: "ev.mana.zitare", category: "spotlight") + static let share = Logger(subsystem: "ev.mana.zitare", category: "share") +} diff --git a/Sources/Core/Theme/ZitareTheme.swift b/Sources/Core/Theme/ZitareTheme.swift new file mode 100644 index 0000000..a4ceb98 --- /dev/null +++ b/Sources/Core/Theme/ZitareTheme.swift @@ -0,0 +1,105 @@ +import SwiftUI + +#if canImport(UIKit) + import UIKit + + private typealias PlatformColorType = UIColor +#elseif canImport(AppKit) + import AppKit + + private typealias PlatformColorType = NSColor +#endif + +/// Paper-Variant aus `mana/packages/themes/src/variants/paper.css`. +/// Lokal in zitare-native nachgebaut, weil ManaTokens noch keine +/// Variants kennt. +/// +/// Sepia, warm, lese-fokussiert — skeumorph an Druckpapier angelehnt, +/// passt zum (read)-Surface der Web-App. +enum ZitareTheme { + /// Page-Hintergrund (warmes Off-White / dunkles Sepia) + static let background = dynamic(light: HSL(38, 28, 95), dark: HSL(24, 14, 9)) + + /// Standard-Text + static let foreground = dynamic(light: HSL(20, 14, 16), dark: HSL(38, 24, 88)) + + /// Card, Panel, Modal + static let surface = dynamic(light: HSL(0, 0, 100), dark: HSL(24, 12, 13)) + + /// Hover-State auf Surface + static let surfaceHover = dynamic(light: HSL(38, 24, 92), dark: HSL(24, 14, 17)) + + /// Disabled-Felder, Skeleton + static let muted = dynamic(light: HSL(38, 20, 90), dark: HSL(24, 12, 18)) + + /// Sekundär-Text, Placeholder + static let mutedForeground = dynamic(light: HSL(20, 14, 50), dark: HSL(38, 12, 60)) + + /// Rahmen, Trennlinien + static let border = dynamic(light: HSL(38, 18, 80), dark: HSL(24, 10, 25)) + + /// Zitare-Primary — warmes Terra/Sienna im Light, weicheres Sienna im Dark + static let primary = dynamic(light: HSL(18, 50, 38), dark: HSL(24, 60, 65)) + + /// Text auf Primary + static let primaryForeground = dynamic(light: HSL(0, 0, 100), dark: HSL(24, 14, 9)) + + static let error = dynamic(light: HSL(0, 65, 45), dark: HSL(0, 60, 55)) + static let success = dynamic(light: HSL(135, 35, 35), dark: HSL(135, 35, 55)) + static let warning = dynamic(light: HSL(38, 80, 40), dark: HSL(38, 70, 55)) + + // MARK: - HSL Helper + + struct HSL { + let hue: Double + let saturation: Double + let lightness: Double + + init(_ hue: Double, _ saturation: Double, _ lightness: Double) { + self.hue = hue + self.saturation = saturation + self.lightness = lightness + } + + var color: Color { + Color( + hue: hue / 360.0, + saturation: saturation / 100.0, + brightness: brightnessFromLightness(), + opacity: 1.0 + ) + } + + /// HSL → HSB Konversion (SwiftUI Color nutzt HSB). + private func brightnessFromLightness() -> Double { + let l = lightness / 100.0 + let s = saturation / 100.0 + return l + s * min(l, 1 - l) + } + } + + private static func dynamic(light: HSL, dark: HSL) -> Color { + #if canImport(UIKit) + return Color( + PlatformColorType { trait in + trait.userInterfaceStyle == .dark + ? PlatformColorType(dark.color) + : PlatformColorType(light.color) + } + ) + #elseif canImport(AppKit) + return Color( + PlatformColorType(name: nil) { appearance in + let isDark = appearance.bestMatch( + from: [.darkAqua, .aqua] + ) == .darkAqua + return isDark + ? PlatformColorType(dark.color) + : PlatformColorType(light.color) + } + ) + #else + return light.color + #endif + } +} diff --git a/Sources/Features/Account/AccountView.swift b/Sources/Features/Account/AccountView.swift new file mode 100644 index 0000000..6f5c03d --- /dev/null +++ b/Sources/Features/Account/AccountView.swift @@ -0,0 +1,106 @@ +import ManaCore +import SwiftUI + +/// Phase ζ-0 minimal: zeigt Auth-Status und Healthz-Probe-Ergebnis. +/// Phase ζ-3 erweitert um ManaAuthUI-Login-Sheet und Submission- +/// History-Link (via WebShell auf `zitare.mana.how/me`). +struct AccountView: View { + @Environment(AuthClient.self) private var auth + let healthStatus: HealthStatus + + var body: some View { + ScrollView { + VStack(spacing: 24) { + header + + statusCard + + Spacer(minLength: 32) + + aboutCard + } + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(ZitareTheme.background) + } + + private var header: some View { + VStack(spacing: 8) { + Image(systemName: "quote.opening") + .font(.system(size: 48)) + .foregroundStyle(ZitareTheme.primary) + Text("Zitare") + .font(.largeTitle) + .fontWeight(.semibold) + Text("Öffentlicher Zitat-Korpus von mana e.V.") + .font(.callout) + .foregroundStyle(ZitareTheme.mutedForeground) + .multilineTextAlignment(.center) + } + .padding(.top, 32) + } + + private var statusCard: some View { + VStack(alignment: .leading, spacing: 12) { + row("Auth", value: authStatusLabel) + Divider() + row("API", value: healthLabel) + } + .padding() + .background(ZitareTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(ZitareTheme.border, lineWidth: 1) + ) + } + + private var aboutCard: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Phase ζ-0 — Setup") + .font(.caption) + .foregroundStyle(ZitareTheme.mutedForeground) + Text( + "Diese App ist noch im Aufbau. Web-App live auf " + + "zitare.com und zitare.mana.how. " + + "Plan in mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md." + ) + .font(.footnote) + .foregroundStyle(ZitareTheme.foreground) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func row(_ label: String, value: String) -> some View { + HStack { + Text(label) + .foregroundStyle(ZitareTheme.mutedForeground) + Spacer() + Text(value) + .foregroundStyle(ZitareTheme.foreground) + .fontWeight(.medium) + } + } + + private var authStatusLabel: String { + switch auth.status { + case .unknown: "—" + case .signedOut: "Nicht eingeloggt" + case .guest: "Gast" + case .signingIn: "Login läuft …" + case .twoFactorRequired: "2FA erforderlich" + case let .signedIn(email): email + case .error: "Fehler" + } + } + + private var healthLabel: String { + switch healthStatus { + case .unknown: "—" + case .ok: "OK" + case .down: "nicht erreichbar" + } + } +} diff --git a/Sources/Features/Settings/SettingsView.swift b/Sources/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..5a67fba --- /dev/null +++ b/Sources/Features/Settings/SettingsView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +/// Phase ζ-5 Placeholder. +/// +/// Aufgabenliste in ζ-5: +/// +/// - Theme-Toggle (System / Light / Dark) — propagiert per +/// `localStorage['zitare-mode']` an den WebView. +/// - Reader-Schriftgröße (S/M/L/XL) — per JS-Bridge an die Web-CSS- +/// Variable `--zit-reader-size`. +/// - DSGVO-Daten-Export (öffnet `zitare.mana.how/me` Export-Page im +/// WebView). +/// - About / Impressum / Lizenz (CC-BY-SA-4.0). +struct SettingsView: View { + var body: some View { + Text("Einstellungen — ζ-5 TODO") + .foregroundStyle(.secondary) + } +} diff --git a/Sources/Features/Submit/SubmitQuoteView.swift b/Sources/Features/Submit/SubmitQuoteView.swift new file mode 100644 index 0000000..e5d5997 --- /dev/null +++ b/Sources/Features/Submit/SubmitQuoteView.swift @@ -0,0 +1,32 @@ +import SwiftUI + +/// Phase ζ-3 Placeholder — native Submit-View für Quote-Drafts. +/// +/// Aufgabenliste in ζ-3: +/// +/// - SwiftUI-Form mit Feldern: text (TextEditor), attribution (Author- +/// Name mit Auto-Complete aus lokalem Snapshot), language (Picker), +/// optional source (Werk, Jahr, URL), optional theme-Chips. +/// - `ManaAuthGate`-Wrap: nicht-eingeloggter Tap auf „Einreichen" +/// öffnet `ManaAuthUI`-Login-Sheet. +/// - `POST /api/v1/quotes` mit `status: 'draft'` (Endpoint existiert +/// schon, Phase 2.A im Web-Repo). +/// - Offline-Queue: bei Network-Failure Draft in SwiftData +/// `PendingSubmission` persistieren, beim Reconnect retry. +/// - Erfolg: Toast + Link „Im Web ansehen" (öffnet WebView auf +/// `zitare.mana.how/me` bzw. `/admin/queue` wenn Moderator). +struct SubmitQuoteView: View { + var body: some View { + VStack(spacing: 12) { + Image(systemName: "square.and.pencil") + .font(.system(size: 36)) + .foregroundStyle(ZitareTheme.primary) + Text("Quote vorschlagen") + .font(.headline) + Text("ζ-3 — TODO: SwiftUI-Form + ManaAuthGate") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Sources/Features/WebShell/WebShellView.swift b/Sources/Features/WebShell/WebShellView.swift new file mode 100644 index 0000000..dc24664 --- /dev/null +++ b/Sources/Features/WebShell/WebShellView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +/// Phase ζ-1 Placeholder. +/// +/// Wird in ζ-1 zu einer echten `UIViewRepresentable`/ +/// `NSViewRepresentable` um `WKWebView`. Aufgabenliste in ζ-1: +/// +/// - WebView-Konfiguration: `WKWebViewConfiguration` mit non-persistent +/// DataStore in Debug-Builds; Persistent in Release. +/// - Cookie-Bridge: nach ManaCore-Login JWT als `mana.access`-Cookie +/// für `.mana.how` ins `WKHTTPCookieStore` schreiben. +/// - Pull-to-Refresh via `UIRefreshControl` (iOS) / +/// `NSScrollView` (macOS). +/// - `WKNavigationDelegate` für Deep-Link-Catching: wenn der WebView +/// eine Navigation auf `zitare://` oder eine andere mana-Domain +/// versucht, abfangen und natively routen. +/// - `WKUIDelegate` für `target=_blank`-Links (Safari öffnen, nicht +/// im WebView). +/// - Native-Toolbar overlay (ζ-5). +/// +/// Heute nur die Signatur, damit `RootView` schon den finalen +/// Import-Pfad nutzt. +struct WebShellView: View { + let initialURL: URL + + var body: some View { + VStack(spacing: 12) { + Text("WebShellView") + .font(.headline) + Text("ζ-1 — TODO: WKWebView auf \(initialURL.absoluteString)") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..b6879a7 --- /dev/null +++ b/Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.286", + "green" : "0.337", + "red" : "0.580" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Resources/Assets.xcassets/Contents.json b/Sources/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Resources/Localizable.xcstrings b/Sources/Resources/Localizable.xcstrings new file mode 100644 index 0000000..68b3793 --- /dev/null +++ b/Sources/Resources/Localizable.xcstrings @@ -0,0 +1,48 @@ +{ + "sourceLanguage" : "de", + "strings" : { + "Zitare" : { + "comment" : "App name", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zitare" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zitare" + } + } + } + }, + "Lesen" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Lesen" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Read" } } + } + }, + "Erkunden" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Erkunden" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Explore" } } + } + }, + "Konto" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Konto" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Account" } } + } + }, + "Öffentlicher Zitat-Korpus von mana e.V." : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Öffentlicher Zitat-Korpus von mana e.V." } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Public quote corpus by mana e.V." } } + } + } + }, + "version" : "1.0" +} diff --git a/Tests/UITests/SmokeUITests.swift b/Tests/UITests/SmokeUITests.swift new file mode 100644 index 0000000..c20ce6f --- /dev/null +++ b/Tests/UITests/SmokeUITests.swift @@ -0,0 +1,17 @@ +import XCTest + +/// Phase ζ-0 Smoke-Test: App startet, Account-Tab erreichbar. +final class SmokeUITests: XCTestCase { + func test_appLaunches() { + let app = XCUIApplication() + app.launch() + // TabBar zeigt drei Tabs: Lesen / Erkunden / Konto. „Lesen" ist + // der Default-Tab; sein Label ist sowohl im Tab-Item als auch + // im Placeholder-Body sichtbar — beides genügt für den Smoke. + let lesenButton = app.buttons["Lesen"] + let lesenText = app.staticTexts["Lesen"] + XCTAssertTrue( + lesenButton.waitForExistence(timeout: 5) || lesenText.waitForExistence(timeout: 5) + ) + } +} diff --git a/Tests/UnitTests/AppConfigTests.swift b/Tests/UnitTests/AppConfigTests.swift new file mode 100644 index 0000000..cec579b --- /dev/null +++ b/Tests/UnitTests/AppConfigTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import ZitareNative + +/// Phase ζ-0 Basis-Tests: Konfigurations-Konstanten sind konsistent +/// mit dem mana-Plattform-Setup (Bundle-ID, Keychain-Service, +/// Endpoint-Domains). +final class AppConfigTests: XCTestCase { + func test_authBaseURL_pointsToManaAuth() { + XCTAssertEqual( + AppConfig.manaAppConfig.authBaseURL.absoluteString, + "https://auth.mana.how" + ) + } + + func test_keychainService_matchesBundle() { + XCTAssertEqual(AppConfig.manaAppConfig.keychainService, "ev.mana.zitare") + } + + func test_apiBaseURL_pointsToZitareApi() { + XCTAssertEqual(AppConfig.apiBaseURL.absoluteString, "https://zitare-api.mana.how") + } + + func test_webBaseURL_isPublicSurface() { + XCTAssertEqual(AppConfig.webBaseURL.absoluteString, "https://zitare.com") + } + + func test_appBaseURL_isManaHowSurface() { + XCTAssertEqual(AppConfig.appBaseURL.absoluteString, "https://zitare.mana.how") + } + + func test_appGroup_matchesEntitlement() { + XCTAssertEqual(AppConfig.appGroup, "group.ev.mana.zitare") + } +} diff --git a/Widgets/ZitareWidget/ZitareWidgetBundle.swift b/Widgets/ZitareWidget/ZitareWidgetBundle.swift new file mode 100644 index 0000000..390d12d --- /dev/null +++ b/Widgets/ZitareWidget/ZitareWidgetBundle.swift @@ -0,0 +1,85 @@ +import SwiftUI +import WidgetKit + +/// Phase ζ-2 Placeholder — Widget-Bundle für die WidgetKit-Extension. +/// +/// Aufgaben in ζ-2: +/// +/// - `DailyQuoteWidget`: deterministisches Zitat des Tages +/// (`hash(date + userSeed) → index in snapshot.quotes`). +/// - `RandomQuoteWidget`: bei jedem Timeline-Refresh ein neues +/// Zitat. +/// - Datenquelle: SwiftData unter App-Group `group.ev.mana.zitare`, +/// gefüllt vom `SnapshotSync` in der App. +/// - TimelineProvider mit 24h-Window für Daily, 30min für Random. +/// - Drei Sizes (Small/Medium/Large) plus Lock-Screen-Varianten +/// (Circular, Inline). +@main +struct ZitareWidgetBundle: WidgetBundle { + var body: some Widget { + DailyQuotePlaceholderWidget() + } +} + +/// Phase ζ-2 Placeholder. Wird ersetzt durch echte Implementation. +struct DailyQuotePlaceholderWidget: Widget { + let kind = "DailyQuotePlaceholder" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: PlaceholderProvider()) { entry in + PlaceholderEntryView(entry: entry) + } + .configurationDisplayName("Zitat des Tages") + .description("Ein kuratiertes Zitat von Zitare — täglich neu.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} + +struct PlaceholderEntry: TimelineEntry { + let date: Date + let quote: String + let author: String +} + +struct PlaceholderProvider: TimelineProvider { + func placeholder(in _: Context) -> PlaceholderEntry { + PlaceholderEntry(date: Date(), quote: "Schweizer bleiben.", author: "Carl Spitteler") + } + + func getSnapshot( + in context: Context, + completion: @escaping (PlaceholderEntry) -> Void + ) { + completion(placeholder(in: context)) + } + + func getTimeline( + in context: Context, + completion: @escaping (Timeline) -> Void + ) { + let entry = placeholder(in: context) + let nextRefresh = Calendar.current.date(byAdding: .hour, value: 24, to: Date()) ?? Date() + completion(Timeline(entries: [entry], policy: .after(nextRefresh))) + } +} + +struct PlaceholderEntryView: View { + let entry: PlaceholderEntry + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(verbatim: "\u{201E}\(entry.quote)\u{201C}") + .font(.callout) + .fontWeight(.medium) + .lineLimit(4) + Spacer(minLength: 4) + Text(verbatim: "— \(entry.author)") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .containerBackground(for: .widget) { + Color(red: 0.95, green: 0.93, blue: 0.88) + } + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..3b25144 --- /dev/null +++ b/project.yml @@ -0,0 +1,209 @@ +name: ZitareNative + +options: + bundleIdPrefix: ev.mana + createIntermediateGroups: true + xcodeVersion: "16.0" + deploymentTarget: + iOS: "18.0" + macOS: "15.0" + developmentLanguage: de + groupSortPosition: top + generateEmptyDirectories: true + +packages: + ManaSwiftCore: + path: ../mana-swift-core + ManaSwiftUI: + path: ../mana-swift-ui + +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" + # mana e.V. Apple-Developer-Team-ID. Muss matchen mit dem AASA- + # Eintrag auf https://zitare.com/.well-known/apple-app-site-association. + DEVELOPMENT_TEAM: QP3GLU8PH3 + +targets: + ZitareNative: + type: application + supportedDestinations: [iOS, macOS] + dependencies: + - package: ManaSwiftCore + product: ManaCore + - package: ManaSwiftCore + product: ManaTokens + - package: ManaSwiftUI + product: ManaAuthUI + - target: ZitareWidgetExtension + embed: true + - target: ZitareShareExtension + embed: true + sources: + - path: Sources/App + - path: Sources/Features + - path: Sources/Core + - path: Sources/Resources + excludes: + - "Info.plist" + - "ZitareNative.entitlements" + info: + path: Sources/Resources/Info.plist + properties: + CFBundleShortVersionString: "0.1.0" + CFBundleVersion: "1" + CFBundleDevelopmentRegion: de + CFBundleDisplayName: Zitare + LSApplicationCategoryType: "public.app-category.reference" + UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + CFBundleURLTypes: + - CFBundleURLName: ev.mana.zitare + CFBundleURLSchemes: + - zitare + NSUserActivityTypes: + - NSUserActivityTypeBrowsingWeb + # WKWebView lädt zitare.com + zitare.mana.how. Beide sind + # https, kein App-Transport-Security-Override nötig. + ITSAppUsesNonExemptEncryption: false + entitlements: + path: Sources/Resources/ZitareNative.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.zitare + # Universal-Link-Domain ist zitare.com (canonical). + # zitare.mana.how ist NICHT in den applinks, weil das + # mit anderen mana-Apps kollidieren würde. + com.apple.developer.associated-domains: + - applinks:zitare.com + com.apple.security.application-groups: + - group.ev.mana.zitare + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ev.mana.zitare + CODE_SIGN_STYLE: Automatic + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + ENABLE_PREVIEWS: "YES" + + ZitareShareExtension: + type: app-extension + supportedDestinations: [iOS] + sources: + - path: ShareExtension + excludes: + - "Resources/Info.plist" + - "Resources/ZitareShareExtension.entitlements" + info: + path: ShareExtension/Resources/Info.plist + properties: + CFBundleDisplayName: An Zitare schicken + CFBundleShortVersionString: "0.1.0" + CFBundleVersion: "1" + NSExtension: + NSExtensionPointIdentifier: com.apple.share-services + NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController + NSExtensionAttributes: + NSExtensionActivationRule: + NSExtensionActivationSupportsText: true + NSExtensionActivationSupportsWebURLWithMaxCount: 1 + entitlements: + path: ShareExtension/Resources/ZitareShareExtension.entitlements + properties: + com.apple.security.application-groups: + - group.ev.mana.zitare + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ev.mana.zitare.share + CODE_SIGN_STYLE: Automatic + SKIP_INSTALL: "YES" + + ZitareWidgetExtension: + type: app-extension + supportedDestinations: [iOS] + sources: + - path: Widgets/ZitareWidget + excludes: + - "Resources/Info.plist" + - "Resources/ZitareWidgetExtension.entitlements" + info: + path: Widgets/ZitareWidget/Resources/Info.plist + properties: + CFBundleDisplayName: Zitare Widget + CFBundleShortVersionString: "0.1.0" + CFBundleVersion: "1" + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + entitlements: + path: Widgets/ZitareWidget/Resources/ZitareWidgetExtension.entitlements + properties: + com.apple.security.application-groups: + - group.ev.mana.zitare + dependencies: + - sdk: WidgetKit.framework + - sdk: SwiftUI.framework + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ev.mana.zitare.widget + CODE_SIGN_STYLE: Automatic + SKIP_INSTALL: "YES" + INFOPLIST_KEY_CFBundleDisplayName: Zitare Widget + + ZitareNativeTests: + type: bundle.unit-test + supportedDestinations: [iOS, macOS] + sources: + - Tests/UnitTests + dependencies: + - target: ZitareNative + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ev.mana.zitare.tests + GENERATE_INFOPLIST_FILE: "YES" + + ZitareNativeUITests: + type: bundle.ui-testing + supportedDestinations: [iOS, macOS] + sources: + - Tests/UITests + dependencies: + - target: ZitareNative + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ev.mana.zitare.uitests + GENERATE_INFOPLIST_FILE: "YES" + +schemes: + ZitareNative: + build: + targets: + ZitareNative: all + ZitareNativeTests: [test] + ZitareNativeUITests: [test] + test: + targets: + - ZitareNativeTests + - ZitareNativeUITests + gatherCoverageData: false + run: + config: Debug + archive: + config: Release