import SwiftData import SwiftUI import WidgetKit /// Widget-Bundle für die ZitareWidget-Extension. /// /// **Phase ζ-2:** liest aus dem App-Group-`ModelContainer`, der von /// der App via `SnapshotSync` befüllt wird. Falls die App-Group im /// Apple-Developer-Portal noch nicht aktiviert ist oder die App noch /// nie gelaufen ist, fällt das Widget auf einen Placeholder-Quote /// zurück. @main struct ZitareWidgetBundle: WidgetBundle { var body: some Widget { DailyQuoteWidget() } } // MARK: - Daily-Quote-Widget struct DailyQuoteWidget: Widget { let kind = "DailyQuoteWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: DailyQuoteProvider()) { entry in DailyQuoteEntryView(entry: entry) } .configurationDisplayName("Zitat des Tages") .description("Ein kuratiertes Zitat von Zitare — täglich neu.") .supportedFamilies([ .systemSmall, .systemMedium, .systemLarge, .accessoryInline, .accessoryRectangular, ]) } } // MARK: - Entry + Provider struct DailyQuoteEntry: TimelineEntry { let date: Date let text: String let author: String let isPlaceholder: Bool } struct DailyQuoteProvider: TimelineProvider { static let placeholder = DailyQuoteEntry( date: Date(), text: "Wir wollen Schweizer bleiben.", author: "Carl Spitteler", isPlaceholder: true ) func placeholder(in _: Context) -> DailyQuoteEntry { Self.placeholder } func getSnapshot( in _: Context, completion: @escaping (DailyQuoteEntry) -> Void ) { completion(currentEntry(for: Date())) } func getTimeline( in _: Context, completion: @escaping (Timeline) -> Void ) { let now = Date() let entry = currentEntry(for: now) // Nächster Refresh genau um Mitternacht — dort dreht der // hash(date)-Picker, also wechselt das Zitat. let calendar = Calendar.current let nextMidnight = calendar.nextDate( after: now, matching: DateComponents(hour: 0, minute: 0), matchingPolicy: .nextTime ) ?? now.addingTimeInterval(24 * 60 * 60) completion(Timeline(entries: [entry], policy: .after(nextMidnight))) } private func currentEntry(for date: Date) -> DailyQuoteEntry { guard let pick = pickQuote(for: date) else { return DailyQuoteProvider.placeholder } return DailyQuoteEntry( date: date, text: pick.text, author: pick.author, isPlaceholder: false ) } private func pickQuote(for date: Date) -> (text: String, author: String)? { do { let container = try SnapshotContainer.make() let context = ModelContext(container) let quotes = try context.fetch(FetchDescriptor()) guard !quotes.isEmpty else { return nil } let sorted = quotes.sorted { $0.slug < $1.slug } let dayKey = Calendar.current.dateComponents([.year, .month, .day], from: date) let seed = (dayKey.year ?? 0) * 10000 + (dayKey.month ?? 0) * 100 + (dayKey.day ?? 0) let index = abs(seed) % sorted.count let pick = sorted[index] return (pick.text, pick.authorName ?? pick.authorSlug ?? "Unbekannt") } catch { return nil } } } // MARK: - View struct DailyQuoteEntryView: View { let entry: DailyQuoteEntry @Environment(\.widgetFamily) private var family var body: some View { switch family { case .systemSmall, .systemMedium, .systemLarge: homeScreenBody .containerBackground(for: .widget) { // Paper-Variant-Background. Statisch — Tokens aus // ManaTokens wären schöner, aber app-extension- // Targets können das Package nicht ohne XcodeGen- // Reibung mitziehen. LinearGradient( colors: [ Color(red: 0.95, green: 0.93, blue: 0.88), Color(red: 0.93, green: 0.91, blue: 0.85) ], startPoint: .topLeading, endPoint: .bottomTrailing ) } .widgetURL(URL(string: "zitare://heute")) case .accessoryInline: accessoryInlineBody .widgetURL(URL(string: "zitare://heute")) case .accessoryRectangular: accessoryRectangularBody .containerBackground(.fill.tertiary, for: .widget) .widgetURL(URL(string: "zitare://heute")) default: homeScreenBody .containerBackground(.fill.tertiary, for: .widget) } } private var homeScreenBody: some View { VStack(alignment: .leading, spacing: family == .systemSmall ? 6 : 10) { Text(verbatim: "\u{201E}\(entry.text)\u{201C}") .font(font(for: family)) .fontWeight(.medium) .foregroundStyle(Color(red: 0.22, green: 0.16, blue: 0.12)) .lineLimit(lineLimit(for: family)) .multilineTextAlignment(.leading) Spacer(minLength: 0) Text(verbatim: "— \(entry.author)") .font(.caption) .foregroundStyle(Color(red: 0.49, green: 0.35, blue: 0.24)) if entry.isPlaceholder { Text("Öffne Zitare einmal, um dein Tageszitat zu laden.") .font(.caption2) .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .padding(family == .systemSmall ? 10 : 14) } // MARK: Lock-Screen — Inline private var accessoryInlineBody: some View { Label( "„\(entry.text.prefix(40))\(entry.text.count > 40 ? "…" : "")“ — \(entry.author)", systemImage: "quote.opening" ) } // MARK: Lock-Screen — Rectangular private var accessoryRectangularBody: some View { VStack(alignment: .leading, spacing: 2) { Text(verbatim: "\u{201E}\(entry.text)\u{201C}") .font(.caption.weight(.medium)) .lineLimit(2) Text(verbatim: "— \(entry.author)") .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } } private func font(for family: WidgetFamily) -> Font { switch family { case .systemSmall: .footnote case .systemMedium: .callout case .systemLarge: .title3 default: .callout } } private func lineLimit(for family: WidgetFamily) -> Int { switch family { case .systemSmall: 4 case .systemMedium: 4 case .systemLarge: 8 default: 4 } } } #Preview("Klein", as: .systemSmall) { DailyQuoteWidget() } timeline: { DailyQuoteProvider.placeholder } #Preview("Groß", as: .systemLarge) { DailyQuoteWidget() } timeline: { DailyQuoteProvider.placeholder } #Preview("Mittel", as: .systemMedium) { DailyQuoteWidget() } timeline: { DailyQuoteProvider.placeholder DailyQuoteEntry( date: Date(), text: "Wer fertig ist, dem ist nichts recht zu machen; ein Werdender wird immer dankbar sein.", author: "Johann Wolfgang von Goethe", isPlaceholder: false ) }