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

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