v0.8.0 — Phase β-7 App-Store-Vorbereitung

Feature-komplett für TestFlight. App-Icon-Platzhalter, Siri-Shortcut,
Share-Extension, Release-Checklist mit allen externen Apple-Schritten.

- scripts/make-appicon.swift: CoreGraphics-basierter Generator für
  1024×1024 forest-green PNG mit "C"-Letter und Karten-Stack-Schatten
- Asset-Catalog auf Single-Size-AppIcon-Pattern umgestellt
- StudyCardsIntent + CardsAppShortcuts (App Intents): Siri-
  Shortcut "Karten lernen mit Cards" / "Mit Cards lernen"
- CardsShareExtension Target: ShareViewController (UIKit-Bootstrap +
  SwiftUI-Hosting), ShareEditorView mit Text-Edit
- PendingShare + PendingShareStore shared in App-Group
  group.ev.mana.cards
- DeckListView zeigt PendingShare-Banner; Tap navigiert zu
  PendingShareConsumeView mit Deck-Picker + Front/Back-Felder, Submit
  → POST /cards, danach store.remove
- Info.plist: NSPhotoLibraryUsageDescription für Image-Occlusion-
  Picker, NSUserActivityTypes für Universal-Links
- docs/RELEASE_CHECKLIST.md mit externen Schritten: Apple-Developer-
  Portal, App-IDs, App-Group, AASA, Xcode-Archive, TestFlight-Plan,
  App-Store-Connect-Felder, Compliance-Verifikation
- UI-Test robuster (akzeptiert Login oder Decks/Entdecken als
  Launch-Erfolg, unabhängig vom Simulator-Keychain-State)
- 35 Tests + 1 UI-Test grün, alle drei Targets bauen

App-Store-Submission selbst ist externe Aktion und passiert nicht
durch dieses Repo — Schritte in docs/RELEASE_CHECKLIST.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-13 01:13:27 +02:00
parent 55359c5333
commit 0b2ae167b7
16 changed files with 783 additions and 59 deletions

2
.gitignore vendored
View file

@ -9,3 +9,5 @@ xcuserdata/
*.xcodeproj
Sources/Resources/Info.plist
Sources/Resources/CardsNative.entitlements
Widgets/CardsWidget/Resources/Info.plist
Widgets/CardsWidget/Resources/CardsWidgetExtension.entitlements

53
PLAN.md
View file

@ -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

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.ev.mana.cards</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Als Karte speichern</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>

View file

@ -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)
}
}
}
}
}

View file

@ -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))
}
}
}

View file

@ -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"
)
}
}

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -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"
}
],

View file

@ -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")
}
}

153
docs/RELEASE_CHECKLIST.md Normal file
View file

@ -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": "<TEAMID>.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/<slug>` 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.

View file

@ -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]

View file

@ -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