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:
parent
55359c5333
commit
0b2ae167b7
16 changed files with 783 additions and 59 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -9,3 +9,5 @@ xcuserdata/
|
||||||
*.xcodeproj
|
*.xcodeproj
|
||||||
Sources/Resources/Info.plist
|
Sources/Resources/Info.plist
|
||||||
Sources/Resources/CardsNative.entitlements
|
Sources/Resources/CardsNative.entitlements
|
||||||
|
Widgets/CardsWidget/Resources/Info.plist
|
||||||
|
Widgets/CardsWidget/Resources/CardsWidgetExtension.entitlements
|
||||||
|
|
|
||||||
53
PLAN.md
53
PLAN.md
|
|
@ -1,9 +1,10 @@
|
||||||
# Plan — cards-native (SwiftUI Universal)
|
# Plan — cards-native (SwiftUI Universal)
|
||||||
|
|
||||||
**Stand: 2026-05-13 — Phasen β-0 bis β-6 abgeschlossen.**
|
**Stand: 2026-05-13 — Phasen β-0 bis β-7 abgeschlossen.**
|
||||||
Alle 7 Card-Types + Marketplace + Native-Polish (Keyboard-Shortcuts,
|
Feature-komplett für TestFlight. Alle 7 Card-Types + Marketplace
|
||||||
Daily-Reminder-Notifications, WidgetKit-Extension mit App-Group).
|
+ Keyboard/Daily-Reminder/Widget + Siri-Shortcut + Share-Extension
|
||||||
35 Unit-Tests + 1 UI-Test grün, Widget-Build grün.
|
+ 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
|
Pflicht-Check für β-2: Endurance-Test auf realem Gerät (200+ Karten
|
||||||
mit Flugmodus zwischendurch) steht aus — Aufgabe für Till.
|
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)
|
- `LoginView` (Email/PW gegen mana-auth)
|
||||||
- 3 Unit-Tests (AppConfig)
|
- 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`)**
|
✅ **β-6 — Native-Polish (2026-05-13, Tag `v0.7.0`)**
|
||||||
- Keyboard-Shortcuts in `StudySessionView`: Space = flip,
|
- Keyboard-Shortcuts in `StudySessionView`: Space = flip,
|
||||||
1/2/3/4 = again/hard/good/easy (über hidden Buttons mit
|
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) |
|
| β-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) |
|
| β-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) |
|
| β-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)
|
1. Apple-Developer-Konfiguration (Team-ID, App-IDs, App-Group, Profiles)
|
||||||
2. Localized App-Store-Screenshots
|
2. App-Icon-Platzhalter durch Designer-Icon ersetzen
|
||||||
3. TestFlight-Build, eine Woche Beta-Test
|
3. AASA-Endpoint auf `cardecky.mana.how` (Cards-Web-Repo)
|
||||||
4. App-Store-Submission unter `ev.mana.cards`, Verein-Developer-Account
|
4. Xcode-Archive + TestFlight-Upload
|
||||||
5. (β-6-Carryover) Siri-Shortcuts via App Intents
|
5. Endurance- und Cross-Device-Tests im TestFlight-Beta
|
||||||
6. (β-6-Carryover) Share-Extension "Save as Card"
|
6. App-Store-Connect-Listing (Description, Screenshots, Privacy)
|
||||||
|
7. Submission
|
||||||
|
|
||||||
## Notizen aus β-4
|
## Notizen aus β-4
|
||||||
|
|
||||||
|
|
|
||||||
10
ShareExtension/Resources/CardsShareExtension.entitlements
Normal file
10
ShareExtension/Resources/CardsShareExtension.entitlements
Normal 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>
|
||||||
41
ShareExtension/Resources/Info.plist
Normal file
41
ShareExtension/Resources/Info.plist
Normal 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>
|
||||||
60
ShareExtension/ShareEditorView.swift
Normal file
60
ShareExtension/ShareEditorView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
ShareExtension/ShareViewController.swift
Normal file
78
ShareExtension/ShareViewController.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Sources/Core/Intents/StudyAppIntents.swift
Normal file
38
Sources/Core/Intents/StudyAppIntents.swift
Normal 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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
Sources/Core/Sync/PendingShareStore.swift
Normal file
61
Sources/Core/Sync/PendingShareStore.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ struct DeckListView: View {
|
||||||
@State private var store: DeckListStore?
|
@State private var store: DeckListStore?
|
||||||
@State private var showAccount = false
|
@State private var showAccount = false
|
||||||
@State private var showCreate = false
|
@State private var showCreate = false
|
||||||
|
@State private var pendingShares: [PendingShare] = []
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
|
@ -23,6 +24,12 @@ struct DeckListView: View {
|
||||||
.navigationDestination(for: String.self) { deckId in
|
.navigationDestination(for: String.self) { deckId in
|
||||||
DeckDetailView(deckId: deckId)
|
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 }
|
.toolbar { toolbar }
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await store?.refresh()
|
await store?.refresh()
|
||||||
|
|
@ -39,6 +46,10 @@ struct DeckListView: View {
|
||||||
store = DeckListStore(auth: auth, context: context)
|
store = DeckListStore(auth: auth, context: context)
|
||||||
}
|
}
|
||||||
await store?.refresh()
|
await store?.refresh()
|
||||||
|
pendingShares = PendingShareStore.readAll()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
pendingShares = PendingShareStore.readAll()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showAccount) {
|
.sheet(isPresented: $showAccount) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
|
@ -59,6 +70,7 @@ struct DeckListView: View {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
|
pendingShareSection
|
||||||
inboxBannerSection
|
inboxBannerSection
|
||||||
ownDecksSection
|
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 {
|
private var emptyState: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
if store?.state == .loading {
|
if store?.state == .loading {
|
||||||
|
|
|
||||||
116
Sources/Features/Decks/PendingShareConsumeView.swift
Normal file
116
Sources/Features/Decks/PendingShareConsumeView.swift
Normal 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 |
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "AppIcon-1024.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|
@ -12,6 +13,7 @@
|
||||||
"value" : "dark"
|
"value" : "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "AppIcon-1024.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|
@ -23,58 +25,15 @@
|
||||||
"value" : "tinted"
|
"value" : "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "AppIcon-1024.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "AppIcon-1024.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"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"
|
"size" : "512x512"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,23 @@ final class CardsNativeUITests: XCTestCase {
|
||||||
func testAppLaunches() throws {
|
func testAppLaunches() throws {
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
app.launch()
|
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
153
docs/RELEASE_CHECKLIST.md
Normal 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.
|
||||||
36
project.yml
36
project.yml
|
|
@ -36,6 +36,8 @@ targets:
|
||||||
product: ManaTokens
|
product: ManaTokens
|
||||||
- target: CardsWidgetExtension
|
- target: CardsWidgetExtension
|
||||||
embed: true
|
embed: true
|
||||||
|
- target: CardsShareExtension
|
||||||
|
embed: true
|
||||||
sources:
|
sources:
|
||||||
- path: Sources/App
|
- path: Sources/App
|
||||||
- path: Sources/Features
|
- path: Sources/Features
|
||||||
|
|
@ -57,6 +59,9 @@ targets:
|
||||||
- CFBundleURLName: ev.mana.cards
|
- CFBundleURLName: ev.mana.cards
|
||||||
CFBundleURLSchemes:
|
CFBundleURLSchemes:
|
||||||
- cards
|
- cards
|
||||||
|
NSUserActivityTypes:
|
||||||
|
- NSUserActivityTypeBrowsingWeb
|
||||||
|
NSPhotoLibraryUsageDescription: "Cards greift auf deine Fotos zu, damit du Bilder zu Image-Occlusion-Karten hinzufügen kannst."
|
||||||
ITSAppUsesNonExemptEncryption: false
|
ITSAppUsesNonExemptEncryption: false
|
||||||
entitlements:
|
entitlements:
|
||||||
path: Sources/Resources/CardsNative.entitlements
|
path: Sources/Resources/CardsNative.entitlements
|
||||||
|
|
@ -78,6 +83,37 @@ targets:
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||||
ENABLE_PREVIEWS: "YES"
|
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:
|
CardsWidgetExtension:
|
||||||
type: app-extension
|
type: app-extension
|
||||||
supportedDestinations: [iOS]
|
supportedDestinations: [iOS]
|
||||||
|
|
|
||||||
82
scripts/make-appicon.swift
Normal file
82
scripts/make-appicon.swift
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue