diff --git a/.gitignore b/.gitignore
index 8a4826f..4136871 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,5 @@ xcuserdata/
*.xcodeproj
Sources/Resources/Info.plist
Sources/Resources/CardsNative.entitlements
+Widgets/CardsWidget/Resources/Info.plist
+Widgets/CardsWidget/Resources/CardsWidgetExtension.entitlements
diff --git a/PLAN.md b/PLAN.md
index 3cef369..61aa4a5 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1,9 +1,10 @@
# Plan — cards-native (SwiftUI Universal)
-**Stand: 2026-05-13 — Phasen β-0 bis β-6 abgeschlossen.**
-Alle 7 Card-Types + Marketplace + Native-Polish (Keyboard-Shortcuts,
-Daily-Reminder-Notifications, WidgetKit-Extension mit App-Group).
-35 Unit-Tests + 1 UI-Test grün, Widget-Build grün.
+**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.
Pflicht-Check für β-2: Endurance-Test auf realem Gerät (200+ Karten
mit Flugmodus zwischendurch) steht aus — Aufgabe für Till.
@@ -27,6 +28,28 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till.
- `LoginView` (Email/PW gegen mana-auth)
- 3 Unit-Tests (AppConfig)
+✅ **β-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
+ Single-Size-Pattern umgestellt. **Vor App-Store-Submit durch Designer-
+ Icon ersetzen** (siehe `docs/RELEASE_CHECKLIST.md`).
+- `StudyCardsIntent` + `CardsAppShortcuts` (App Intents Framework):
+ Siri-Shortcut "Karten lernen mit Cards" / "Mit Cards lernen", öffnet
+ die App, App-Shortcut-Provider macht ihn ohne Konfiguration sichtbar.
+- `CardsShareExtension`-Target (app-extension): empfängt Text/URL aus
+ Safari/Mail-Share-Sheets, SwiftUI-Mini-Editor, persistiert
+ `PendingShare` in App-Group. Haupt-App zeigt Banner in DeckListView,
+ Tap → `PendingShareConsumeView` mit Deck-Picker + Front/Back-Felder,
+ Submit → `POST /cards`, danach `PendingShareStore.remove`.
+- `PendingShare` + `PendingShareStore` shared in beiden Targets.
+- `NSPhotoLibraryUsageDescription` + `NSUserActivityTypes` in Info.plist
+ ergänzt für Image-Occlusion-Picker und Universal-Links.
+- `docs/RELEASE_CHECKLIST.md` — externe Schritte: Apple-Developer-
+ Portal-Konfiguration, AASA-Endpoint, TestFlight-Test-Plan, App-Store-
+ Connect-Felder, Compliance-Verifikation.
+- UI-Test robuster gegen Keychain-State (akzeptiert sowohl Login als
+ auch Decks/Entdecken als gestartete App).
+
✅ **β-6 — Native-Polish (2026-05-13, Tag `v0.7.0`)**
- Keyboard-Shortcuts in `StudySessionView`: Space = flip,
1/2/3/4 = again/hard/good/easy (über hidden Buttons mit
@@ -169,18 +192,22 @@ ausgeliefert wird — heute 404. Web-seitige Aufgabe.
| β-4 | ✅ 2026-05-13 | Media-Upload, image-occlusion (Touch-Mask-Editor), audio-front (AVAudioPlayer) |
| β-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 | — | App-Store-Submission |
+| β-7 | ✅ 2026-05-13 | App-Icon-Platzhalter + Siri-Shortcut + Share-Extension + Release-Checklist (externe Apple-Schritte siehe docs/RELEASE_CHECKLIST.md) |
-## Nächste Schritte für β-7 (App-Store-Vorbereitung)
+## Nächste Schritte: TestFlight + App-Store
-Aus Greenfield-Plan-Sektion "Phase β-7":
+Alle remaining steps sind **externe Aktionen** außerhalb des Repos —
+Apple-Developer-Portal, App-Store-Connect, Xcode-Archive, das
+Cards-Web-Repo (AASA). Strukturierte Liste in
+[`docs/RELEASE_CHECKLIST.md`](docs/RELEASE_CHECKLIST.md):
-1. App-Icon (drei Größen iOS, plus macOS-Idiom)
-2. Localized App-Store-Screenshots
-3. TestFlight-Build, eine Woche Beta-Test
-4. App-Store-Submission unter `ev.mana.cards`, Verein-Developer-Account
-5. (β-6-Carryover) Siri-Shortcuts via App Intents
-6. (β-6-Carryover) Share-Extension "Save as Card"
+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)
+4. Xcode-Archive + TestFlight-Upload
+5. Endurance- und Cross-Device-Tests im TestFlight-Beta
+6. App-Store-Connect-Listing (Description, Screenshots, Privacy)
+7. Submission
## Notizen aus β-4
diff --git a/ShareExtension/Resources/CardsShareExtension.entitlements b/ShareExtension/Resources/CardsShareExtension.entitlements
new file mode 100644
index 0000000..19e0259
--- /dev/null
+++ b/ShareExtension/Resources/CardsShareExtension.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.ev.mana.cards
+
+
+
diff --git a/ShareExtension/Resources/Info.plist b/ShareExtension/Resources/Info.plist
new file mode 100644
index 0000000..228f71f
--- /dev/null
+++ b/ShareExtension/Resources/Info.plist
@@ -0,0 +1,41 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Als Karte speichern
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ NSExtension
+
+ NSExtensionAttributes
+
+ NSExtensionActivationRule
+
+ NSExtensionActivationSupportsText
+
+ NSExtensionActivationSupportsWebURLWithMaxCount
+ 1
+
+
+ NSExtensionPointIdentifier
+ com.apple.share-services
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).ShareViewController
+
+
+
diff --git a/ShareExtension/ShareEditorView.swift b/ShareExtension/ShareEditorView.swift
new file mode 100644
index 0000000..842afa3
--- /dev/null
+++ b/ShareExtension/ShareEditorView.swift
@@ -0,0 +1,60 @@
+import SwiftUI
+
+/// Mini-Editor in der Share-Extension. User kann den Text noch
+/// anpassen, dann "Speichern" → PendingShare landet in der Haupt-App.
+struct ShareEditorView: View {
+ let initialText: String
+ let sourceURL: String?
+ let onSave: (String) -> Void
+ let onCancel: () -> Void
+
+ @State private var text: String
+
+ init(
+ text: String,
+ sourceURL: String?,
+ onSave: @escaping (String) -> Void,
+ onCancel: @escaping () -> Void
+ ) {
+ initialText = text
+ self.sourceURL = sourceURL
+ self.onSave = onSave
+ self.onCancel = onCancel
+ _text = State(initialValue: text)
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Vorderseite") {
+ TextField("Text", text: $text, axis: .vertical)
+ .lineLimit(4 ... 10)
+ }
+ if let sourceURL {
+ Section("Quelle") {
+ Text(sourceURL)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ Section {
+ Text("Wähle das Ziel-Deck in der Cards-App.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .navigationTitle("Als Karte speichern")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Abbrechen", action: onCancel)
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Speichern") { onSave(text) }
+ .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ }
+ }
+ }
+}
diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift
new file mode 100644
index 0000000..d7b5cea
--- /dev/null
+++ b/ShareExtension/ShareViewController.swift
@@ -0,0 +1,78 @@
+import SwiftUI
+import UIKit
+import UniformTypeIdentifiers
+
+/// Share-Extension-Entrypoint. Liest geteilten Text/URL aus
+/// `extensionContext`, persistiert als `PendingShare` in der App-Group,
+/// schließt sich nach Bestätigung.
+final class ShareViewController: UIViewController {
+ private var sharedText: String = ""
+ private var sharedURL: String?
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = .systemBackground
+ loadSharedContent { [weak self] in
+ self?.presentEditor()
+ }
+ }
+
+ private func loadSharedContent(completion: @escaping () -> Void) {
+ guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
+ let providers = item.attachments
+ else {
+ completion()
+ return
+ }
+
+ let group = DispatchGroup()
+ for provider in providers {
+ if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
+ 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 }
+ }
+ 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()
+ }
+ }
+ }
+ group.notify(queue: .main, execute: completion)
+ }
+
+ private func presentEditor() {
+ let host = UIHostingController(rootView: ShareEditorView(
+ text: sharedText,
+ sourceURL: sharedURL,
+ onSave: { [weak self] cleanedText in
+ self?.saveAndClose(text: cleanedText)
+ },
+ onCancel: { [weak self] in
+ self?.cancel()
+ }
+ ))
+ host.modalPresentationStyle = .formSheet
+ present(host, animated: true)
+ }
+
+ private func saveAndClose(text: String) {
+ let share = PendingShare(text: text, sourceURL: sharedURL)
+ PendingShareStore.append(share)
+ dismiss(animated: true) { [weak self] in
+ self?.extensionContext?.completeRequest(returningItems: nil)
+ }
+ }
+
+ private func cancel() {
+ dismiss(animated: true) { [weak self] in
+ self?.extensionContext?.cancelRequest(withError: NSError(domain: "ev.mana.cards.share", code: 0))
+ }
+ }
+}
diff --git a/Sources/Core/Intents/StudyAppIntents.swift b/Sources/Core/Intents/StudyAppIntents.swift
new file mode 100644
index 0000000..a98efc2
--- /dev/null
+++ b/Sources/Core/Intents/StudyAppIntents.swift
@@ -0,0 +1,38 @@
+import AppIntents
+import SwiftUI
+
+/// Siri-Shortcut: "Karten lernen". Öffnet die App im Decks-Tab.
+/// Tatsächliche Tab-Aktivierung passiert via `cards://` URL-Scheme, das
+/// RootView bereits handhabt.
+struct StudyCardsIntent: AppIntent {
+ static let title: LocalizedStringResource = "Karten lernen"
+ static let description = IntentDescription(
+ "Öffnet Cards und zeigt deine Decks mit fälligen Karten.",
+ categoryName: "Lernen"
+ )
+ static let openAppWhenRun: Bool = true
+
+ @MainActor
+ func perform() async throws -> some IntentResult {
+ // Die App ist beim Ausführen schon geöffnet (openAppWhenRun);
+ // weitere Navigation passiert in RootView via Deep-Link, falls
+ // ein Parameter dazukommt. v1: simple App-Open.
+ .result()
+ }
+}
+
+/// Macht Intents direkt nach Install in Siri/Shortcuts-App sichtbar.
+struct CardsAppShortcuts: AppShortcutsProvider {
+ static var appShortcuts: [AppShortcut] {
+ AppShortcut(
+ intent: StudyCardsIntent(),
+ phrases: [
+ "Karten lernen mit \(.applicationName)",
+ "Mit \(.applicationName) lernen",
+ "\(.applicationName) öffnen",
+ ],
+ shortTitle: "Karten lernen",
+ systemImageName: "rectangle.stack"
+ )
+ }
+}
diff --git a/Sources/Core/Sync/PendingShareStore.swift b/Sources/Core/Sync/PendingShareStore.swift
new file mode 100644
index 0000000..87eca02
--- /dev/null
+++ b/Sources/Core/Sync/PendingShareStore.swift
@@ -0,0 +1,61 @@
+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 {
+ let id: String
+ let text: String
+ let sourceURL: String?
+ let capturedAt: Date
+
+ init(text: String, sourceURL: String? = nil, capturedAt: Date = .now) {
+ id = "\(capturedAt.timeIntervalSince1970)-\(UUID().uuidString.prefix(8))"
+ self.text = text
+ self.sourceURL = sourceURL
+ self.capturedAt = capturedAt
+ }
+}
+
+enum PendingShareStore {
+ static let appGroupID = "group.ev.mana.cards"
+ static let filename = "pending-shares.json"
+
+ static var url: URL? {
+ FileManager.default
+ .containerURL(forSecurityApplicationGroupIdentifier: appGroupID)?
+ .appendingPathComponent(filename)
+ }
+
+ /// FIFO-Liste aller noch nicht konsumierten Shares.
+ static func readAll() -> [PendingShare] {
+ guard let url, let data = try? Data(contentsOf: url) else { return [] }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return (try? decoder.decode([PendingShare].self, from: data)) ?? []
+ }
+
+ /// Append-only-Write. Bei Concurrent-Writes aus Extension + Haupt-App
+ /// 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 }
+ var all = readAll()
+ all.append(share)
+ write(all)
+ }
+
+ /// Entfernt einen Eintrag (wenn die Haupt-App ihn als Karte gespeichert hat).
+ static func remove(id: String) {
+ let all = readAll().filter { $0.id != id }
+ write(all)
+ }
+
+ private static func write(_ shares: [PendingShare]) {
+ guard let url else { return }
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ guard let data = try? encoder.encode(shares) else { return }
+ try? data.write(to: url, options: .atomic)
+ }
+}
diff --git a/Sources/Features/Decks/DeckListView.swift b/Sources/Features/Decks/DeckListView.swift
index a1830a2..fff8437 100644
--- a/Sources/Features/Decks/DeckListView.swift
+++ b/Sources/Features/Decks/DeckListView.swift
@@ -12,6 +12,7 @@ struct DeckListView: View {
@State private var store: DeckListStore?
@State private var showAccount = false
@State private var showCreate = false
+ @State private var pendingShares: [PendingShare] = []
var body: some View {
NavigationStack {
@@ -23,6 +24,12 @@ struct DeckListView: View {
.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()
@@ -39,6 +46,10 @@ struct DeckListView: View {
store = DeckListStore(auth: auth, context: context)
}
await store?.refresh()
+ pendingShares = PendingShareStore.readAll()
+ }
+ .onAppear {
+ pendingShares = PendingShareStore.readAll()
}
.sheet(isPresented: $showAccount) {
NavigationStack {
@@ -59,6 +70,7 @@ struct DeckListView: View {
emptyState
} else {
List {
+ pendingShareSection
inboxBannerSection
ownDecksSection
}
@@ -67,6 +79,38 @@ struct DeckListView: View {
}
}
+ @ViewBuilder
+ private var pendingShareSection: some View {
+ if !pendingShares.isEmpty {
+ Section {
+ ForEach(pendingShares) { share in
+ NavigationLink(value: PendingShareRoute(share: share)) {
+ HStack(spacing: 12) {
+ Image(systemName: "square.and.arrow.down")
+ .foregroundStyle(CardsTheme.primary)
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Aus Teilen-Menü")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(CardsTheme.foreground)
+ Text(share.text)
+ .font(.caption)
+ .foregroundStyle(CardsTheme.mutedForeground)
+ .lineLimit(2)
+ }
+ Spacer()
+ }
+ .padding()
+ .background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
+ }
+ .buttonStyle(.plain)
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
+ }
+ }
+ }
+ }
+
private var emptyState: some View {
VStack(spacing: 16) {
if store?.state == .loading {
diff --git a/Sources/Features/Decks/PendingShareConsumeView.swift b/Sources/Features/Decks/PendingShareConsumeView.swift
new file mode 100644
index 0000000..00266bb
--- /dev/null
+++ b/Sources/Features/Decks/PendingShareConsumeView.swift
@@ -0,0 +1,116 @@
+import ManaCore
+import SwiftData
+import SwiftUI
+
+struct PendingShareRoute: Hashable {
+ let share: PendingShare
+}
+
+/// User wählt Ziel-Deck, optional Back-Text. Submit → CardCreate
+/// via API, anschließend wird die PendingShare aus dem Store entfernt.
+struct PendingShareConsumeView: View {
+ let share: PendingShare
+ let onDone: () -> Void
+
+ @Environment(AuthClient.self) private var auth
+ @Environment(\.dismiss) private var dismiss
+ @Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck]
+
+ @State private var selectedDeckId: String?
+ @State private var front: String
+ @State private var back: String = ""
+ @State private var isSubmitting = false
+ @State private var errorMessage: String?
+
+ init(share: PendingShare, onDone: @escaping () -> Void) {
+ self.share = share
+ self.onDone = onDone
+ _front = State(initialValue: share.text)
+ }
+
+ var body: some View {
+ Form {
+ Section("Ziel-Deck") {
+ if decks.isEmpty {
+ Text("Erst ein Deck erstellen.")
+ .foregroundStyle(CardsTheme.mutedForeground)
+ } else {
+ Picker("Deck", selection: $selectedDeckId) {
+ Text("Wählen …").tag(String?.none)
+ ForEach(decks) { deck in
+ Text(deck.name).tag(String?.some(deck.id))
+ }
+ }
+ }
+ }
+ Section("Vorderseite") {
+ TextField("Front", text: $front, axis: .vertical)
+ .lineLimit(2 ... 6)
+ }
+ Section("Rückseite") {
+ TextField("Back (optional, default: Quell-URL)", text: $back, axis: .vertical)
+ .lineLimit(2 ... 4)
+ }
+ if let sourceURL = share.sourceURL {
+ Section("Quelle") {
+ Text(sourceURL)
+ .font(.caption)
+ .foregroundStyle(CardsTheme.mutedForeground)
+ }
+ }
+ if let errorMessage {
+ Section {
+ Text(errorMessage)
+ .font(.footnote)
+ .foregroundStyle(CardsTheme.error)
+ }
+ }
+ }
+ .navigationTitle("Geteilten Inhalt speichern")
+ #if os(iOS)
+ .navigationBarTitleDisplayMode(.inline)
+ #endif
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Verwerfen", role: .destructive) {
+ PendingShareStore.remove(id: share.id)
+ onDone()
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Speichern") { Task { await submit() } }
+ .disabled(selectedDeckId == nil || front.trimmed.isEmpty || isSubmitting)
+ }
+ }
+ }
+
+ private func submit() async {
+ guard let deckId = selectedDeckId else { return }
+ isSubmitting = true
+ errorMessage = nil
+ defer { isSubmitting = false }
+
+ let backText = back.trimmed.isEmpty ? (share.sourceURL ?? "—") : back.trimmed
+ let api = CardsAPI(auth: auth)
+ let body = CardCreateBody(
+ deckId: deckId,
+ type: .basic,
+ fields: CardFieldsBuilder.basic(front: front.trimmed, back: backText),
+ mediaRefs: nil
+ )
+ do {
+ _ = try await api.createCard(body)
+ onDone()
+ dismiss()
+ } catch {
+ errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
+ }
+ }
+}
+
+private extension String {
+ var trimmed: String {
+ trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
diff --git a/Sources/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/Sources/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
new file mode 100644
index 0000000..76f939e
Binary files /dev/null and b/Sources/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ
diff --git a/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
index ffdfe15..79657a3 100644
--- a/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,6 +1,7 @@
{
"images" : [
{
+ "filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
@@ -12,6 +13,7 @@
"value" : "dark"
}
],
+ "filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
@@ -23,58 +25,15 @@
"value" : "tinted"
}
],
+ "filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
+ "filename" : "AppIcon-1024.png",
"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"
}
],
diff --git a/Tests/UITests/CardsNativeUITests.swift b/Tests/UITests/CardsNativeUITests.swift
index 1e6d3dd..41b0ee2 100644
--- a/Tests/UITests/CardsNativeUITests.swift
+++ b/Tests/UITests/CardsNativeUITests.swift
@@ -4,6 +4,23 @@ final class CardsNativeUITests: XCTestCase {
func testAppLaunches() throws {
let app = XCUIApplication()
app.launch()
- XCTAssertTrue(app.staticTexts["Cards"].waitForExistence(timeout: 5))
+ // App ist gestartet, sobald entweder das LoginView "Cards"
+ // 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 decksTitle = app.staticTexts["Decks"]
+ let exploreTab = app.staticTexts["Entdecken"]
+
+ let deadline = Date().addingTimeInterval(5)
+ var found = false
+ while Date() < deadline {
+ if loginTitle.exists || decksTitle.exists || exploreTab.exists {
+ found = true
+ break
+ }
+ usleep(100_000)
+ }
+ XCTAssertTrue(found, "Erwartete App-Surface (Cards | Decks | Entdecken) erschien nicht innerhalb 5 s")
}
}
diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md
new file mode 100644
index 0000000..ed743bf
--- /dev/null
+++ b/docs/RELEASE_CHECKLIST.md
@@ -0,0 +1,153 @@
+# RELEASE_CHECKLIST — cards-native
+
+Externe Schritte vor App-Store-Submission. Alles unter dieser
+Sektion läuft NICHT durch das Repo — sondern durch das Apple-
+Developer-Portal, App-Store-Connect, das Cards-Web-Repo (für
+AASA) und über Xcode (für Build + Sign).
+
+## Vor TestFlight (intern, kein Apple-Review)
+
+### Apple-Developer-Konfiguration
+
+- [ ] **Team-ID prüfen** für den mana-e.V.-Apple-Developer-Account.
+ Eintragen in `project.yml > settings > base > DEVELOPMENT_TEAM`
+ (oder per-Scheme in Xcode unter "Signing & Capabilities").
+- [ ] **App-ID `ev.mana.cards`** im Developer-Portal anlegen, falls
+ noch nicht da. Mit Capabilities: App Groups, Keychain Sharing,
+ Associated Domains.
+- [ ] **App-ID `ev.mana.cards.share`** + **`ev.mana.cards.widget`** für
+ die Extensions analog anlegen, ebenfalls mit App Groups.
+- [ ] **App-Group `group.ev.mana.cards`** im Portal anlegen und allen
+ drei App-IDs zuweisen.
+- [ ] **Keychain-Access-Group**: heute `ev.mana.cards`. Wenn
+ Shared-Keychain mit `memoro-native` gewünscht (siehe
+ `mana/docs/MANA_SWIFT.md` Phase γ), auf
+ `$(AppIdentifierPrefix)ev.mana.shared` umstellen und
+ `AppConfig.manaAppConfig.keychainAccessGroup` setzen.
+- [ ] **Provisioning Profiles** für alle drei Targets generieren
+ (Development + Distribution). Bei "Automatic Signing" macht
+ Xcode das beim ersten Build selbst, wenn das Team-ID stimmt.
+
+### Asset-Polish
+
+- [ ] **AppIcon ersetzen.** Heute Platzhalter aus
+ `scripts/make-appicon.swift` (forest-green "C"). Vor Submission
+ durch ein vom Designer erstelltes Icon austauschen
+ (`Sources/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png`).
+- [ ] **Marketing-Versionsnummer** in `project.yml` setzen
+ (`MARKETING_VERSION` → "1.0.0").
+- [ ] **Build-Nummer** monoton inkrementieren bei jedem TestFlight-
+ Upload (`CURRENT_PROJECT_VERSION`).
+
+### Server-seitige Vorbedingungen
+
+- [ ] **AASA-Endpoint** auf `cardecky.mana.how/.well-known/apple-app-site-association`
+ ausliefern (heute 404). Format:
+ ```json
+ {
+ "applinks": {
+ "apps": [],
+ "details": [{
+ "appID": ".ev.mana.cards",
+ "paths": ["/d/*"]
+ }]
+ }
+ }
+ ```
+ Content-Type muss `application/json` sein (nicht `text/html`).
+ Aufgabe ans Cards-Web-Repo.
+- [ ] **cardecky-api.mana.how** muss erreichbar bleiben — die App
+ ist 100% Online-write. Health-Probe verifizieren.
+
+### Build + Archive
+
+- [ ] `xcodegen generate`
+- [ ] In Xcode: Product → Archive (Destination "Any iOS Device")
+- [ ] Im Organizer: Validate App
+- [ ] Distribute App → TestFlight & App Store
+
+### TestFlight-Test-Plan
+
+- [ ] **Endurance:** 200+ Karten lernen, Flugmodus zwischendurch.
+- [ ] **Cross-Device:** Web↔Native parallel. Karte gegrade in App →
+ Web zeigt nach Reload identischen Review-State.
+- [ ] **Widget:** ans Home-Screen pinnen, Due-Count nach App-Refresh.
+- [ ] **Universal-Link:** `https://cardecky.mana.how/d/` in
+ Safari öffnen → App startet auf Explore-Tab + Public-Deck-Detail.
+- [ ] **Share-Extension:** Text in Safari markieren → Teilen → "Als
+ Karte speichern" → Karte landet in der App.
+- [ ] **Siri-Shortcut:** "Hey Siri, Karten lernen" → App öffnet.
+- [ ] **Daily-Reminder:** Tagesgrenze überqueren, Notification kommt
+ zur konfigurierten Uhrzeit.
+- [ ] **Login/Logout:** kompletter Auth-Roundtrip.
+- [ ] **Offline-Grades:** Reviews offline machen, online gehen,
+ `PendingGrade`-Queue läuft leer.
+
+## Vor App-Store-Submission (öffentliches Review)
+
+### App-Store-Connect
+
+- [ ] **App-Eintrag erstellen** unter https://appstoreconnect.apple.com
+ mit Bundle-ID `ev.mana.cards`.
+- [ ] **App-Name** + **Subtitle** (max 30 Zeichen):
+ - Name: "Cards"
+ - Subtitle: "Karteikarten — Verein mana"
+- [ ] **Description** (de + en, max 4000 Zeichen). Vorschlag siehe
+ `docs/MARKETING_COPY.md` (existiert noch nicht — TODO).
+- [ ] **Keywords** (max 100 Zeichen, comma-separated):
+ "Karteikarten,Spaced Repetition,Lernen,Vokabeln,Anki,Flashcards,FSRS,mana,Verein,Open Source"
+- [ ] **Screenshots** für iPhone 16 Pro Max + iPhone SE-3 + iPad Pro.
+ 6.7", 6.5", 5.5", iPad 12.9" — siehe Apple's Specs.
+- [ ] **Privacy-Policy-URL** — vermutlich `cardecky.mana.how/privacy`
+ oder `mana-ev.ch/privacy`. **Verifizieren.**
+- [ ] **Support-URL** — `cardecky.mana.how/help` oder Verein-Kontakt.
+- [ ] **Marketing-URL** (optional) — `cardecky.mana.how`.
+- [ ] **Age-Rating**: vermutlich 4+ (no objectionable content).
+- [ ] **Pricing**: Free.
+- [ ] **App-Privacy** (Data Type Declaration):
+ - Email-Adresse (für Login)
+ - User-Inhalt (Karten, Decks)
+ - Nutzungsdaten (FSRS-Reviews)
+ - **Nicht** für Tracking verwendet.
+
+### Compliance / Verein-Werte
+
+- [ ] **Lese `mana/docs/COMPLIANCE.md`** und prüfe, dass keine
+ Telemetrie, kein Crash-Reporter, kein SaaS-Tracker in Submission-
+ Build (Stripe + APNs sind die akzeptierten Ausnahmen — Cards
+ nutzt keines davon heute).
+- [ ] **Subscriptions / In-App-Purchases**: Marketplace-Paid-Decks
+ sind über `mana-credits` abgewickelt, nicht über StoreKit. Im
+ App-Store-Connect "no In-App-Purchases" auswählen, **außer**
+ wir wollen vor Submission StoreKit für `mana-credits` einführen
+ (separate Architektur-Frage).
+
+### Hub-App vs Standalone-App (siehe MANA_SWIFT.md)
+
+- [x] Entschieden: separate Apps. memoro-native und cards-native sind
+ eigenständige App-Store-Einträge. Keine Hub-App.
+
+## Carryover-Tasks (β-6 / β-7-Reste)
+
+- [x] Siri-Shortcut via App Intents (`StudyCardsIntent`) — funktional,
+ v1: nur "App öffnen". Erweiterung "Direkt in Default-Deck-Study"
+ kann später kommen.
+- [x] Share-Extension "Save as Card" — Pragma-Lösung: Extension
+ speichert `PendingShare` in App-Group, Haupt-App zeigt Banner,
+ User wählt Ziel-Deck und Back-Text. Kein direkter API-Call aus
+ der Extension (Auth-State wäre kompliziert, Pragma reicht für v1).
+- [ ] **Card-Edit** (PATCH `/cards/:id`): heute deferred, ergänzen wenn
+ Beta-User danach fragen.
+- [ ] **Anki-Import**: heute deferred, weil Web client-side parsed
+ und kein Server-Endpoint existiert. Native bräuchte eigenen
+ `.apkg`-Parser (sqlite-basiert) — eigener Sprint.
+
+## Nach Submission
+
+- [ ] **Monitoring**: nach Release `cardecky-api.mana.how/healthz` und
+ Rate-Limit-Auslastung beobachten — Native-App kann Last-Spitzen
+ erzeugen.
+- [ ] **DSGVO-Endpoint** (`/api/v1/dsgvo/export`, `/delete`) testweise
+ aus Native triggerbar machen (β-7-Extension oder β-8).
+- [ ] **Update-Cadence**: erstmal alle 2-4 Wochen ein Build.
+ Build-Nummer monoton, Marketing-Version semver.
diff --git a/project.yml b/project.yml
index 7f41c84..f78d34b 100644
--- a/project.yml
+++ b/project.yml
@@ -36,6 +36,8 @@ targets:
product: ManaTokens
- target: CardsWidgetExtension
embed: true
+ - target: CardsShareExtension
+ embed: true
sources:
- path: Sources/App
- path: Sources/Features
@@ -57,6 +59,9 @@ targets:
- CFBundleURLName: ev.mana.cards
CFBundleURLSchemes:
- cards
+ NSUserActivityTypes:
+ - NSUserActivityTypeBrowsingWeb
+ NSPhotoLibraryUsageDescription: "Cards greift auf deine Fotos zu, damit du Bilder zu Image-Occlusion-Karten hinzufügen kannst."
ITSAppUsesNonExemptEncryption: false
entitlements:
path: Sources/Resources/CardsNative.entitlements
@@ -78,6 +83,37 @@ targets:
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
ENABLE_PREVIEWS: "YES"
+ CardsShareExtension:
+ type: app-extension
+ supportedDestinations: [iOS]
+ sources:
+ - path: ShareExtension
+ excludes:
+ - "Resources/Info.plist"
+ - "Resources/CardsShareExtension.entitlements"
+ - path: Sources/Core/Sync/PendingShareStore.swift
+ info:
+ path: ShareExtension/Resources/Info.plist
+ properties:
+ CFBundleDisplayName: Als Karte speichern
+ NSExtension:
+ NSExtensionPointIdentifier: com.apple.share-services
+ NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
+ NSExtensionAttributes:
+ NSExtensionActivationRule:
+ NSExtensionActivationSupportsText: true
+ NSExtensionActivationSupportsWebURLWithMaxCount: 1
+ entitlements:
+ path: ShareExtension/Resources/CardsShareExtension.entitlements
+ properties:
+ com.apple.security.application-groups:
+ - group.ev.mana.cards
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cards.share
+ CODE_SIGN_STYLE: Automatic
+ SKIP_INSTALL: "YES"
+
CardsWidgetExtension:
type: app-extension
supportedDestinations: [iOS]
diff --git a/scripts/make-appicon.swift b/scripts/make-appicon.swift
new file mode 100644
index 0000000..9c2afc1
--- /dev/null
+++ b/scripts/make-appicon.swift
@@ -0,0 +1,82 @@
+#!/usr/bin/env swift
+// Generiert ein 1024×1024-AppIcon-PNG als Platzhalter.
+// forest-green Hintergrund + großes weißes "C" mit Karten-Stack-Andeutung.
+//
+// Aufruf:
+// swift scripts/make-appicon.swift
+//
+// Schreibt nach: Sources/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
+//
+// Dies ist ein Platzhalter — vor App-Store-Submission durch ein
+// professionelles Icon ersetzen (siehe docs/RELEASE_CHECKLIST.md).
+
+import AppKit
+import CoreGraphics
+
+let size = 1024
+let scale: CGFloat = 1.0
+let cs = CGColorSpaceCreateDeviceRGB()
+guard let ctx = CGContext(
+ data: nil,
+ width: size, height: size,
+ bitsPerComponent: 8, bytesPerRow: 0,
+ space: cs,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+) else {
+ print("CGContext creation failed")
+ exit(1)
+}
+
+// HSL(142, 76%, 28%) — forest primary light. Hand-konvertiert zu sRGB.
+let background = CGColor(red: 0.043, green: 0.494, blue: 0.227, alpha: 1)
+ctx.setFillColor(background)
+ctx.fill(CGRect(x: 0, y: 0, width: size, height: size))
+
+// Subtile Karten-Stack-Schatten hinter dem C
+let shadow1 = CGColor(red: 1, green: 1, blue: 1, alpha: 0.08)
+let shadow2 = CGColor(red: 1, green: 1, blue: 1, alpha: 0.05)
+let cardW = CGFloat(size) * 0.62
+let cardH = CGFloat(size) * 0.82
+let cardX = (CGFloat(size) - cardW) / 2
+let cardY = (CGFloat(size) - cardH) / 2
+
+ctx.setFillColor(shadow2)
+ctx.beginPath()
+ctx.addPath(CGPath(roundedRect: CGRect(x: cardX + 30, y: cardY - 30, width: cardW, height: cardH),
+ cornerWidth: 48, cornerHeight: 48, transform: nil))
+ctx.fillPath()
+
+ctx.setFillColor(shadow1)
+ctx.beginPath()
+ctx.addPath(CGPath(roundedRect: CGRect(x: cardX + 15, y: cardY - 15, width: cardW, height: cardH),
+ cornerWidth: 48, cornerHeight: 48, transform: nil))
+ctx.fillPath()
+
+// Großer weißer "C"-Buchstabe — Helvetica-Neue-Bold
+let attrs: [NSAttributedString.Key: Any] = [
+ .font: NSFont(name: "HelveticaNeue-Bold", size: 720) ?? NSFont.boldSystemFont(ofSize: 720),
+ .foregroundColor: NSColor.white,
+]
+let str = NSAttributedString(string: "C", attributes: attrs)
+let line = CTLineCreateWithAttributedString(str)
+let bounds = CTLineGetImageBounds(line, ctx)
+let tx = (CGFloat(size) - bounds.width) / 2 - bounds.origin.x
+let ty = (CGFloat(size) - bounds.height) / 2 - bounds.origin.y
+ctx.textPosition = CGPoint(x: tx, y: ty)
+CTLineDraw(line, ctx)
+
+// PNG schreiben
+guard let cgImage = ctx.makeImage() else {
+ print("makeImage failed")
+ exit(1)
+}
+let bitmap = NSBitmapImageRep(cgImage: cgImage)
+guard let data = bitmap.representation(using: .png, properties: [:]) else {
+ print("PNG encoding failed")
+ exit(1)
+}
+
+let target = URL(fileURLWithPath: "Sources/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png")
+try data.write(to: target)
+print("Wrote \(target.path) (\(data.count) bytes)")
+_ = scale // suppress unused warning