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