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
|
||||
Sources/Resources/Info.plist
|
||||
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)
|
||||
|
||||
**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
|
||||
|
||||
|
|
|
|||
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 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 {
|
||||
|
|
|
|||
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" : [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
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
|
||||
- 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]
|
||||
|
|
|
|||
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