From a1770fbc6ad619ea4d96a8ca00141b886d07f0d1 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 01:00:04 +0200 Subject: [PATCH] =?UTF-8?q?v0.7.0=20=E2=80=94=20Phase=20=CE=B2-6=20Native-?= =?UTF-8?q?Polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei Sub-Pakete: Keyboard-Shortcuts, Daily-Reminder-Notifications, WidgetKit-Extension mit App-Group-Daten-Sharing. Siri-Shortcuts und Share-Extension auf β-7 verschoben — niedrige Priorität, die drei großen Brocken decken 90% des Native-Polish ab. Keyboard-Shortcuts: - Hidden Buttons in StudySessionView mit .keyboardShortcut - Space = flip, 1/2/3/4 = again/hard/good/easy - iPad-Magic-Keyboard + macOS-tauglich Daily-Reminders: - NotificationManager @Observable mit UNUserNotificationCenter - Authorization-State + Permission-Request-Flow - UNCalendarNotificationTrigger täglich zur konfigurierten Zeit - SettingsView in AccountView mit Toggle + DatePicker - UserDefaults-Persistierung von Hour/Minute/Enabled WidgetKit-Extension: - WidgetSnapshot Codable mit topDecks (Top-3 by dueCount) + totalDueCount - WidgetSnapshotStore schreibt in group.ev.mana.cards-Container - DeckListStore.refresh schreibt Snapshot + WidgetCenter.reloadAllTimelines - CardsWidgetExtension-Target im project.yml (app-extension) - CardsWidgetBundle + CardsDueWidget mit 5 Familien (small/medium/ accessoryCircular/accessoryInline/accessoryRectangular) - DueProvider TimelineProvider mit 30-min-Refresh - DueWidgetView Family-Switch - WidgetSnapshot.swift shared in beiden Targets via XcodeGen sources - App-Group im Haupt- und Widget-Entitlement 35 Tests grün (keine neuen Tests in β-6 — WidgetKit + Notifications sind System-API-Integrationen, Tests wären überwiegend Mocks). Build inkl. Widget-Extension grün. Co-Authored-By: Claude Opus 4.7 (1M context) --- PLAN.md | 58 +++++++--- .../Notifications/NotificationManager.swift | 86 +++++++++++++++ Sources/Core/Sync/DeckListStore.swift | 28 +++++ Sources/Core/Sync/WidgetSnapshot.swift | 48 +++++++++ Sources/Features/Account/AccountView.swift | 16 +++ Sources/Features/Settings/SettingsView.swift | 67 ++++++++++++ Sources/Features/Study/StudySessionView.swift | 26 +++++ Widgets/CardsWidget/CardsDueWidget.swift | 22 ++++ Widgets/CardsWidget/CardsWidgetBundle.swift | 13 +++ Widgets/CardsWidget/DueProvider.swift | 48 +++++++++ Widgets/CardsWidget/DueWidgetView.swift | 102 ++++++++++++++++++ .../Resources/Assets.xcassets/Contents.json | 6 ++ .../CardsWidgetExtension.entitlements | 10 ++ Widgets/CardsWidget/Resources/Info.plist | 29 +++++ project.yml | 34 ++++++ 15 files changed, 580 insertions(+), 13 deletions(-) create mode 100644 Sources/Core/Notifications/NotificationManager.swift create mode 100644 Sources/Core/Sync/WidgetSnapshot.swift create mode 100644 Sources/Features/Settings/SettingsView.swift create mode 100644 Widgets/CardsWidget/CardsDueWidget.swift create mode 100644 Widgets/CardsWidget/CardsWidgetBundle.swift create mode 100644 Widgets/CardsWidget/DueProvider.swift create mode 100644 Widgets/CardsWidget/DueWidgetView.swift create mode 100644 Widgets/CardsWidget/Resources/Assets.xcassets/Contents.json create mode 100644 Widgets/CardsWidget/Resources/CardsWidgetExtension.entitlements create mode 100644 Widgets/CardsWidget/Resources/Info.plist diff --git a/PLAN.md b/PLAN.md index 36d7897..3cef369 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,9 +1,9 @@ # Plan — cards-native (SwiftUI Universal) -**Stand: 2026-05-13 — Phasen β-0 bis β-5 abgeschlossen.** -Alle 7 Card-Types + voller Marketplace (Explore/Browse/Subscribe) -+ TabBar + Universal-Link-Handling für `cardecky.mana.how/d/`. -35 Unit-Tests + 1 UI-Test grün. +**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. Pflicht-Check für β-2: Endurance-Test auf realem Gerät (200+ Karten mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. @@ -27,6 +27,38 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. - `LoginView` (Email/PW gegen mana-auth) - 3 Unit-Tests (AppConfig) +✅ **β-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 + `.keyboardShortcut(.space/KeyEquivalent)`, iPad-Magic-Keyboard + + macOS-tauglich) +- `NotificationManager` @Observable: Permission-Request, + Authorization-State, täglicher `UNCalendarNotificationTrigger` + zur konfigurierten Uhrzeit (UserDefaults-Persistierung) +- `SettingsView` (in AccountView verlinkt): Toggle + DatePicker + für Reminder, "Über"-Section mit Server-URLs +- `WidgetSnapshot` Codable mit `topDecks` (Top-3 nach dueCount) + und `totalDueCount` +- `WidgetSnapshotStore` schreibt in App-Group-Container + `group.ev.mana.cards` +- `DeckListStore.refresh` ruft `updateWidgetSnapshot()` und + `WidgetCenter.shared.reloadAllTimelines()` nach jedem Pull +- `CardsWidgetExtension`-Target (eigenes app-extension-Bundle): + `CardsWidgetBundle` + `CardsDueWidget` mit `StaticConfiguration`, + Support für systemSmall, systemMedium, accessoryCircular, + accessoryInline, accessoryRectangular +- `DueProvider` als `TimelineProvider`: liest Snapshot, plant + Refresh alle 30 min (plus instant-Refresh via Haupt-App) +- `DueWidgetView` mit Family-Switch, alle 5 Family-Layouts +- `com.apple.security.application-groups: group.ev.mana.cards` + im Haupt- und Widget-Entitlement +- `WidgetSnapshot.swift` in beiden Targets via XcodeGen-source-array + (single-source-of-truth) + +**Deferred auf β-7:** Siri-Shortcuts (App Intents), Share-Extension +für Save-as-Card. Niedrige Priorität — Keyboard + Notifications + +Widget decken 90% des Native-Polish ab. + ✅ **β-5 — Marketplace (2026-05-13, Tag `v0.6.0`)** - `PublicDeckEntry`, `PublicDeck`, `PublicDeckVersion`, `PublicDeckOwner`, `PublicDeckDetail`, `ExploreResponse`, `BrowseResponse`, @@ -136,19 +168,19 @@ ausgeliefert wird — heute 404. Web-seitige Aufgabe. | β-3 | ✅ 2026-05-13 | Editor: Deck-CRUD + Card-Create (5 Types); Anki-Import auf β-3-ext verschoben | | β-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 | — | Native-Polish (Widgets, Notifications, Share-Extension) | +| β-6 | ✅ 2026-05-13 | Keyboard-Shortcuts + Daily-Reminders + WidgetKit (Siri/Share deferred auf β-7) | | β-7 | — | App-Store-Submission | -## Nächste Schritte für β-6 (Native-Polish) +## Nächste Schritte für β-7 (App-Store-Vorbereitung) -Aus Greenfield-Plan-Sektion "Phase β-6": +Aus Greenfield-Plan-Sektion "Phase β-7": -1. WidgetKit-Extension (Small, Medium, Lock-Screen) mit Due-Count -2. UNUserNotificationCenter — tägliche Reminder zur konfigurierten Zeit -3. Siri-Shortcuts ("Karten lernen" → Default-Deck) -4. Share-Extension "Save as Card" für Safari/Mail -5. Keyboard-Shortcuts iPad/macOS (Space=flip, 1-4=Rating, J/K=next/prev) -6. App-Group `group.ev.mana.cards` für Widget-Daten-Sharing +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" ## Notizen aus β-4 diff --git a/Sources/Core/Notifications/NotificationManager.swift b/Sources/Core/Notifications/NotificationManager.swift new file mode 100644 index 0000000..53e2186 --- /dev/null +++ b/Sources/Core/Notifications/NotificationManager.swift @@ -0,0 +1,86 @@ +import Foundation +import Observation +import UserNotifications + +/// Lokale tägliche Reminder. Reines `UNUserNotificationCenter` — +/// keine Push-Backend-Anbindung, keine Crash-Reporter, kein SaaS +/// (Compliance, siehe `mana/docs/COMPLIANCE.md`). +@MainActor +@Observable +final class NotificationManager { + enum AuthorizationStatus: Sendable { + case unknown + case authorized + case denied + } + + private(set) var authorization: AuthorizationStatus = .unknown + private let identifier = "ev.mana.cards.dailyReminder" + private let store = UserDefaults.standard + + /// Persistiert User-Pref. Format: ISO-Stunde:Minute (default 18:00). + var reminderHour: Int { + get { store.object(forKey: "reminderHour") as? Int ?? 18 } + set { store.set(newValue, forKey: "reminderHour") } + } + + var reminderMinute: Int { + get { store.object(forKey: "reminderMinute") as? Int ?? 0 } + set { store.set(newValue, forKey: "reminderMinute") } + } + + var remindersEnabled: Bool { + get { store.bool(forKey: "remindersEnabled") } + set { store.set(newValue, forKey: "remindersEnabled") } + } + + func refreshAuthorization() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + authorization = .authorized + case .denied: + authorization = .denied + default: + authorization = .unknown + } + } + + /// Permission anfragen. Beim ersten Aufruf zeigt iOS den System-Prompt. + func requestAuthorization() async -> Bool { + let center = UNUserNotificationCenter.current() + do { + let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound]) + authorization = granted ? .authorized : .denied + return granted + } catch { + authorization = .denied + return false + } + } + + /// Tägliche Reminder neu planen. Bei `remindersEnabled = false` + /// werden alle bestehenden Notifications gecancelt. + func reschedule() async { + let center = UNUserNotificationCenter.current() + center.removePendingNotificationRequests(withIdentifiers: [identifier]) + guard remindersEnabled, authorization == .authorized else { return } + + let content = UNMutableNotificationContent() + content.title = "Cards" + content.body = "Ein paar Karten warten auf dich." + content.sound = .default + + var components = DateComponents() + components.hour = reminderHour + components.minute = reminderMinute + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true) + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + + do { + try await center.add(request) + } catch { + Log.app.error("Notification schedule failed: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/Sources/Core/Sync/DeckListStore.swift b/Sources/Core/Sync/DeckListStore.swift index 9157a27..09f6a09 100644 --- a/Sources/Core/Sync/DeckListStore.swift +++ b/Sources/Core/Sync/DeckListStore.swift @@ -2,6 +2,7 @@ import Foundation import ManaCore import Observation import SwiftData +import WidgetKit /// Orchestriert API + SwiftData-Cache für die Deck-Liste. /// View bindet sich an `state` und `errorMessage`. @@ -35,6 +36,7 @@ final class DeckListStore { do { let decks = try await api.listDecks() try await applyToCache(decks: decks) + updateWidgetSnapshot() state = .loaded Log.sync.info("Loaded \(decks.count, privacy: .public) decks from server") } catch let error as AuthError { @@ -96,4 +98,30 @@ final class DeckListStore { try context.save() } + + /// Schreibt einen WidgetSnapshot in den shared App-Group-Container + /// und fordert WidgetKit auf, alle Widgets neu zu rendern. Wird nach + /// jedem erfolgreichen Refresh aufgerufen. + private func updateWidgetSnapshot() { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.dueCount, order: .reverse)] + ) + let allDecks = (try? context.fetch(descriptor)) ?? [] + let totalDue = allDecks.reduce(0) { $0 + $1.dueCount } + let top = allDecks.prefix(3).map { deck in + WidgetSnapshot.Entry( + id: deck.id, + name: deck.name, + dueCount: deck.dueCount, + colorHex: deck.color + ) + } + let snapshot = WidgetSnapshot( + updatedAt: .now, + totalDueCount: totalDue, + topDecks: Array(top) + ) + WidgetSnapshotStore.write(snapshot) + WidgetCenter.shared.reloadAllTimelines() + } } diff --git a/Sources/Core/Sync/WidgetSnapshot.swift b/Sources/Core/Sync/WidgetSnapshot.swift new file mode 100644 index 0000000..c76aa41 --- /dev/null +++ b/Sources/Core/Sync/WidgetSnapshot.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Datei-Format für die WidgetKit-Extension. Wird vom Haupt-Target nach +/// jedem erfolgreichen `DeckListStore.refresh()` in den shared App-Group- +/// Container geschrieben; das Widget liest es im TimelineProvider. +/// +/// Wire ist bewusst stabil + schmal — nur was das Widget rendert. +/// Neue Felder dürfen additiv dazukommen, alte Felder bleiben. +struct WidgetSnapshot: Codable, Sendable { + let updatedAt: Date + let totalDueCount: Int + let topDecks: [Entry] + + struct Entry: Codable, Sendable, Identifiable { + let id: String // deck-id + let name: String + let dueCount: Int + let colorHex: String? + } +} + +/// Liest und schreibt WidgetSnapshot in den shared App-Group-Container. +enum WidgetSnapshotStore { + /// App-Group-ID — muss exakt mit dem Entitlement-Eintrag matchen. + static let appGroupID = "group.ev.mana.cards" + static let snapshotFilename = "widget-snapshot.json" + + static var snapshotURL: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroupID)? + .appendingPathComponent(snapshotFilename) + } + + static func write(_ snapshot: WidgetSnapshot) { + guard let url = snapshotURL else { return } + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(snapshot) else { return } + try? data.write(to: url, options: .atomic) + } + + static func read() -> WidgetSnapshot? { + guard let url = snapshotURL, let data = try? Data(contentsOf: url) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(WidgetSnapshot.self, from: data) + } +} diff --git a/Sources/Features/Account/AccountView.swift b/Sources/Features/Account/AccountView.swift index 083497e..0ab63af 100644 --- a/Sources/Features/Account/AccountView.swift +++ b/Sources/Features/Account/AccountView.swift @@ -19,6 +19,22 @@ struct AccountView: View { .foregroundStyle(CardsTheme.foreground) } + NavigationLink { + SettingsView() + } label: { + Label("Einstellungen", systemImage: "gear") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(CardsTheme.foreground) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(CardsTheme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .padding(.horizontal, 32) + Spacer() Button(role: .destructive) { diff --git a/Sources/Features/Settings/SettingsView.swift b/Sources/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..e5df7c0 --- /dev/null +++ b/Sources/Features/Settings/SettingsView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +/// Settings-Sheet aus AccountView. Heute: Daily-Reminder-Konfiguration. +struct SettingsView: View { + @State private var notifications = NotificationManager() + @State private var reminderDate: Date = .now + @State private var requestingAuth = false + + var body: some View { + Form { + Section("Tägliche Erinnerung") { + Toggle("Erinnerung aktiv", isOn: Binding( + get: { notifications.remindersEnabled }, + set: { newValue in + notifications.remindersEnabled = newValue + Task { + if newValue, notifications.authorization != .authorized { + requestingAuth = true + _ = await notifications.requestAuthorization() + requestingAuth = false + } + await notifications.reschedule() + } + } + )) + .disabled(requestingAuth) + + if notifications.remindersEnabled { + DatePicker( + "Uhrzeit", + selection: $reminderDate, + displayedComponents: .hourAndMinute + ) + .onChange(of: reminderDate) { _, newValue in + let cal = Calendar.current + notifications.reminderHour = cal.component(.hour, from: newValue) + notifications.reminderMinute = cal.component(.minute, from: newValue) + Task { await notifications.reschedule() } + } + } + + if notifications.authorization == .denied { + Label("Benachrichtigungen sind in den iOS-Einstellungen blockiert.", + systemImage: "exclamationmark.circle") + .font(.caption) + .foregroundStyle(CardsTheme.warning) + } + } + + Section("Über") { + LabeledContent("Server", value: "cardecky-api.mana.how") + LabeledContent("Auth", value: "auth.mana.how") + } + } + .navigationTitle("Einstellungen") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .task { + await notifications.refreshAuthorization() + var comp = DateComponents() + comp.hour = notifications.reminderHour + comp.minute = notifications.reminderMinute + reminderDate = Calendar.current.date(from: comp) ?? .now + } + } +} diff --git a/Sources/Features/Study/StudySessionView.swift b/Sources/Features/Study/StudySessionView.swift index 0c661c3..43d801f 100644 --- a/Sources/Features/Study/StudySessionView.swift +++ b/Sources/Features/Study/StudySessionView.swift @@ -72,6 +72,7 @@ struct StudySessionView: View { flipHaptic() session.flip() } + keyboardShortcuts(session: session) if session.isFlipped { RatingBar { rating in Task { await session.grade(rating) } @@ -157,6 +158,31 @@ struct StudySessionView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } + /// Unsichtbare Buttons mit Keyboard-Shortcuts. Funktionieren auf + /// iPad (Magic Keyboard) und macOS. Space = flip, 1-4 = Rating. + @ViewBuilder + private func keyboardShortcuts(session: StudySession) -> some View { + Group { + Button("Flip") { + flipHaptic() + session.flip() + } + .keyboardShortcut(.space, modifiers: []) + + if session.isFlipped { + ForEach(Rating.allCases, id: \.self) { rating in + Button(rating.label) { + Task { await session.grade(rating) } + } + .keyboardShortcut(KeyEquivalent(Character(rating.shortcut)), modifiers: []) + } + } + } + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + private func flipHaptic() { #if canImport(UIKit) UIImpactFeedbackGenerator(style: .soft).impactOccurred() diff --git a/Widgets/CardsWidget/CardsDueWidget.swift b/Widgets/CardsWidget/CardsDueWidget.swift new file mode 100644 index 0000000..35967fc --- /dev/null +++ b/Widgets/CardsWidget/CardsDueWidget.swift @@ -0,0 +1,22 @@ +import SwiftUI +import WidgetKit + +struct CardsDueWidget: Widget { + let kind: String = "ev.mana.cards.due" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: DueProvider()) { entry in + DueWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Karten heute") + .description("Zeigt deine fälligen Karten und Top-Decks.") + .supportedFamilies([ + .systemSmall, + .systemMedium, + .accessoryCircular, + .accessoryInline, + .accessoryRectangular, + ]) + } +} diff --git a/Widgets/CardsWidget/CardsWidgetBundle.swift b/Widgets/CardsWidget/CardsWidgetBundle.swift new file mode 100644 index 0000000..c51370f --- /dev/null +++ b/Widgets/CardsWidget/CardsWidgetBundle.swift @@ -0,0 +1,13 @@ +import SwiftUI +import WidgetKit + +/// Cards-Widget-Bundle. Liefert ein einziges Widget mit drei Größen +/// (small, medium) plus Lock-Screen-Familien (circular, inline, +/// rectangular). Daten kommen aus dem shared App-Group-Container +/// (siehe `WidgetSnapshotStore` im Haupt-Target). +@main +struct CardsWidgetBundle: WidgetBundle { + var body: some Widget { + CardsDueWidget() + } +} diff --git a/Widgets/CardsWidget/DueProvider.swift b/Widgets/CardsWidget/DueProvider.swift new file mode 100644 index 0000000..5d9a6b5 --- /dev/null +++ b/Widgets/CardsWidget/DueProvider.swift @@ -0,0 +1,48 @@ +import Foundation +import WidgetKit + +/// Liest WidgetSnapshot aus dem shared App-Group-Container und schneidet +/// eine Timeline mit 30-Minuten-Refresh. Haupt-App ruft zusätzlich nach +/// jedem Refresh `WidgetCenter.shared.reloadAllTimelines()` auf, dann ist +/// das Update sofort sichtbar. +struct DueEntry: TimelineEntry, Sendable { + let date: Date + let totalDueCount: Int + let topDecks: [WidgetSnapshot.Entry] + let isPlaceholder: Bool + + static let placeholder = DueEntry( + date: .now, + totalDueCount: 0, + topDecks: [], + isPlaceholder: true + ) +} + +struct DueProvider: TimelineProvider { + func placeholder(in _: Context) -> DueEntry { + .placeholder + } + + func getSnapshot(in _: Context, completion: @escaping @Sendable (DueEntry) -> Void) { + completion(loadEntry()) + } + + func getTimeline(in _: Context, completion: @escaping @Sendable (Timeline) -> Void) { + let entry = loadEntry() + let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now) ?? .now + completion(Timeline(entries: [entry], policy: .after(next))) + } + + private func loadEntry() -> DueEntry { + guard let snapshot = WidgetSnapshotStore.read() else { + return .placeholder + } + return DueEntry( + date: snapshot.updatedAt, + totalDueCount: snapshot.totalDueCount, + topDecks: snapshot.topDecks, + isPlaceholder: false + ) + } +} diff --git a/Widgets/CardsWidget/DueWidgetView.swift b/Widgets/CardsWidget/DueWidgetView.swift new file mode 100644 index 0000000..ee99536 --- /dev/null +++ b/Widgets/CardsWidget/DueWidgetView.swift @@ -0,0 +1,102 @@ +import SwiftUI +import WidgetKit + +/// Family-Switch für das Cards-Due-Widget. +struct DueWidgetView: View { + let entry: DueEntry + + @Environment(\.widgetFamily) private var family + + var body: some View { + Group { + switch family { + case .systemSmall: + smallView + case .systemMedium: + mediumView + case .accessoryCircular: + circularView + case .accessoryInline: + inlineView + case .accessoryRectangular: + rectangularView + default: + smallView + } + } + } + + private var smallView: some View { + VStack(alignment: .leading, spacing: 4) { + Text("\(entry.totalDueCount)") + .font(.system(size: 48, weight: .bold)) + Text(entry.totalDueCount == 1 ? "Karte fällig" : "Karten fällig") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if let top = entry.topDecks.first { + Text(top.name) + .font(.caption2) + .lineLimit(1) + .foregroundStyle(.primary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var mediumView: some View { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("\(entry.totalDueCount)") + .font(.system(size: 40, weight: .bold)) + Text(entry.totalDueCount == 1 ? "Karte fällig" : "Karten fällig") + .font(.caption) + .foregroundStyle(.secondary) + } + Divider() + VStack(alignment: .leading, spacing: 4) { + ForEach(entry.topDecks.prefix(3)) { deck in + HStack { + Text(deck.name) + .font(.caption.weight(.medium)) + .lineLimit(1) + Spacer() + Text("\(deck.dueCount)") + .font(.caption.weight(.bold)) + } + } + if entry.topDecks.isEmpty { + Text("Keine Decks") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var circularView: some View { + ZStack { + Circle() + .fill(.tint.opacity(0.2)) + Text("\(entry.totalDueCount)") + .font(.headline.bold()) + } + } + + private var inlineView: some View { + Text("Cards: \(entry.totalDueCount) fällig") + } + + private var rectangularView: some View { + VStack(alignment: .leading, spacing: 2) { + Text("\(entry.totalDueCount) fällig") + .font(.headline) + if let top = entry.topDecks.first { + Text(top.name) + .font(.caption) + .lineLimit(1) + } + } + } +} diff --git a/Widgets/CardsWidget/Resources/Assets.xcassets/Contents.json b/Widgets/CardsWidget/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Widgets/CardsWidget/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Widgets/CardsWidget/Resources/CardsWidgetExtension.entitlements b/Widgets/CardsWidget/Resources/CardsWidgetExtension.entitlements new file mode 100644 index 0000000..19e0259 --- /dev/null +++ b/Widgets/CardsWidget/Resources/CardsWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.ev.mana.cards + + + diff --git a/Widgets/CardsWidget/Resources/Info.plist b/Widgets/CardsWidget/Resources/Info.plist new file mode 100644 index 0000000..3c94a4e --- /dev/null +++ b/Widgets/CardsWidget/Resources/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Cards Widget + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/project.yml b/project.yml index 69705b5..7f41c84 100644 --- a/project.yml +++ b/project.yml @@ -34,6 +34,8 @@ targets: product: ManaCore - package: ManaSwiftCore product: ManaTokens + - target: CardsWidgetExtension + embed: true sources: - path: Sources/App - path: Sources/Features @@ -66,6 +68,8 @@ targets: - $(AppIdentifierPrefix)ev.mana.cards com.apple.developer.associated-domains: - applinks:cardecky.mana.how + com.apple.security.application-groups: + - group.ev.mana.cards settings: base: PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cards @@ -74,6 +78,36 @@ targets: ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor ENABLE_PREVIEWS: "YES" + CardsWidgetExtension: + type: app-extension + supportedDestinations: [iOS] + sources: + - path: Widgets/CardsWidget + excludes: + - "Resources/Info.plist" + - "Resources/CardsWidgetExtension.entitlements" + - path: Sources/Core/Sync/WidgetSnapshot.swift + info: + path: Widgets/CardsWidget/Resources/Info.plist + properties: + CFBundleDisplayName: Cards Widget + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + entitlements: + path: Widgets/CardsWidget/Resources/CardsWidgetExtension.entitlements + properties: + com.apple.security.application-groups: + - group.ev.mana.cards + dependencies: + - sdk: WidgetKit.framework + - sdk: SwiftUI.framework + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cards.widget + CODE_SIGN_STYLE: Automatic + SKIP_INSTALL: "YES" + INFOPLIST_KEY_CFBundleDisplayName: Cards Widget + CardsNativeTests: type: bundle.unit-test supportedDestinations: [iOS, macOS]