import Foundation import WidgetKit import SwiftUI // Daily Widget Reco:从 App Group 读取缓存;过期则请求后端 /v1/reco/widget;每日刷新(尽力而为) private let appGroupSuiteName = "group.com.damer.mindfulness" private let keyWidgetConfig = "widget.config.v1" private let keyWidgetUserProfile = "widget.userProfile.v1_2" private let keyWidgetDailyReco = "widget.dailyReco.v1" private let fallbackTextTC = "你已經很努力了,今天也值得被溫柔對待。" private let fallbackTextEN = "You’ve been doing great — you deserve kindness today." private func defaults() -> UserDefaults? { UserDefaults(suiteName: appGroupSuiteName) } private func isoNow() -> String { ISO8601DateFormatter().string(from: Date()) } private func localDayKey(_ date: Date = Date()) -> String { let fmt = DateFormatter() fmt.calendar = Calendar.current fmt.timeZone = TimeZone.current fmt.dateFormat = "yyyy-MM-dd" return fmt.string(from: date) } private func resolveLang() -> String { // 仅支持 en/tc:根据设备语言选择 // - 传统中文(Hant / TW / HK / MO)=> tc // - 其他语言(含简中 zh-Hans / zh-CN)=> en(默认) let preferred = (Locale.preferredLanguages.first ?? "en").lowercased() if preferred.hasPrefix("zh-hant") { return "tc" } if preferred.hasPrefix("zh-tw") { return "tc" } if preferred.hasPrefix("zh-hk") { return "tc" } if preferred.hasPrefix("zh-mo") { return "tc" } return "en" } private func resolveTitle(lang: String) -> String { // 需求:品牌文案统一为 Dear Mama return "Dear Mama" } private func resolveFooterHint(lang: String) -> String { lang == "en" ? "Tap to open the app" : "點我回到 App" } private func joinUrl(base: String, path: String) -> String { let b = base.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "/+$", with: "", options: .regularExpression) if path.hasPrefix("/") { return "\(b)\(path)" } return "\(b)/\(path)" } private func nextDailyRefreshDate(from now: Date) -> Date { // 下一天 00:10~01:00 之间随机一个时间点(用户本地时区) var cal = Calendar.current cal.timeZone = TimeZone.current guard let tomorrow = cal.date(byAdding: .day, value: 1, to: now) else { return now.addingTimeInterval(60 * 60 * 6) } let start = cal.startOfDay(for: tomorrow) let minDate = cal.date(byAdding: .minute, value: 10, to: start) ?? start.addingTimeInterval(60 * 10) let maxDate = cal.date(byAdding: .hour, value: 1, to: start) ?? start.addingTimeInterval(60 * 60) let interval = max(0, maxDate.timeIntervalSince(minDate)) let jitter = interval > 0 ? Double.random(in: 0.. [String: Any]? { guard let raw = defaults()?.string(forKey: key) else { return nil } guard let data = raw.data(using: .utf8) else { return nil } let obj = try? JSONSerialization.jsonObject(with: data, options: []) return obj as? [String: Any] } private func writeJsonDict(_ dict: [String: Any], forKey key: String) { guard let data = try? JSONSerialization.data(withJSONObject: dict, options: []) else { return } guard let raw = String(data: data, encoding: .utf8) else { return } defaults()?.set(raw, forKey: key) } private func readCachedText(family: WidgetFamily) -> (dayKey: String?, lang: String, text: String)? { guard let d = readJsonDict(forKey: keyWidgetDailyReco) else { return nil } let lang = (d["lang"] as? String) ?? resolveLang() let dayKey = d["day_key"] as? String if let item = d["item"] as? [String: Any] { if let text = pickWidgetText(item: item, family: family), !text.isEmpty { return (dayKey: dayKey, lang: lang, text: text) } } return nil } private func familyKey(_ family: WidgetFamily) -> String { switch family { case .systemSmall: return "small" case .systemMedium: return "medium" case .systemLarge: return "large" default: return "small" } } private func pickWidgetText(item: [String: Any], family: WidgetFamily?) -> String? { // 优先使用 App 侧预换行的结果(wrapped_text_by_family),否则回退 raw text if let family = family, let wrappedByFamily = item["wrapped_text_by_family"] as? [String: Any] { let key = familyKey(family) if let v = wrappedByFamily[key] as? String, !v.isEmpty { return v } } if let raw = item["text"] as? String, !raw.isEmpty { return raw } return nil } private func readApiBaseUrl() -> String? { guard let d = readJsonDict(forKey: keyWidgetConfig) else { return nil } let base = d["apiBaseUrl"] as? String return base?.trimmingCharacters(in: .whitespacesAndNewlines) } private func readUserProfileDict() -> [String: Any]? { guard let d = readJsonDict(forKey: keyWidgetUserProfile) else { return nil } return d["user_profile"] as? [String: Any] } private func saveDailyReco(lang: String, dayKey: String, contentId: Int, text: String, meta: [String: Any]?) { var dict: [String: Any] = [ "schema_version": 1, "saved_at": isoNow(), "day_key": dayKey, "lang": lang, "source": "widget", "item": [ "content_id": contentId, "text": text ] ] if let meta = meta { dict["meta"] = meta } writeJsonDict(dict, forKey: keyWidgetDailyReco) } private func fetchDailyRecoFromServer() async -> (lang: String, text: String, contentId: Int, meta: [String: Any]?)? { guard let baseUrl = readApiBaseUrl(), !baseUrl.isEmpty else { return nil } guard let userProfile = readUserProfileDict() else { return nil } let lang = resolveLang() let urlStr = joinUrl(base: baseUrl, path: "/v1/reco/widget") guard let url = URL(string: urlStr) else { return nil } var req = URLRequest(url: url) req.httpMethod = "POST" req.timeoutInterval = 12 req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue(lang, forHTTPHeaderField: "Accept-Language") let body: [String: Any] = [ "k": 1, "user_profile": userProfile, "already_recommended_ids": [], "touched_or_viewed_ids": [] ] req.httpBody = try? JSONSerialization.data(withJSONObject: body, options: []) do { let (data, res) = try await URLSession.shared.data(for: req) guard let httpRes = res as? HTTPURLResponse, (200..<300).contains(httpRes.statusCode) else { return nil } let obj = try JSONSerialization.jsonObject(with: data, options: []) guard let root = obj as? [String: Any] else { return nil } guard let items = root["items"] as? [[String: Any]], let first = items.first else { return nil } guard let text = first["text"] as? String, !text.isEmpty else { return nil } let contentId = (first["content_id"] as? Int) ?? Int((first["content_id"] as? NSNumber)?.intValue ?? -1) if contentId < 0 { return nil } let meta = root["meta"] as? [String: Any] return (lang: lang, text: text, contentId: contentId, meta: meta) } catch { return nil } } struct EmotionProvider: TimelineProvider { func placeholder(in context: Context) -> EmotionEntry { let lang = resolveLang() return EmotionEntry( date: Date(), lang: lang, title: resolveTitle(lang: lang), text: lang == "en" ? fallbackTextEN : fallbackTextTC, footerHint: resolveFooterHint(lang: lang) ) } func getSnapshot(in context: Context, completion: @escaping (EmotionEntry) -> ()) { completion(placeholder(in: context)) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { Task { let lang = resolveLang() let today = localDayKey(Date()) // 1) 今日缓存优先 if let cached = readCachedText(family: context.family), cached.dayKey == today { let entry = EmotionEntry( date: Date(), lang: cached.lang, title: resolveTitle(lang: cached.lang), text: cached.text, footerHint: resolveFooterHint(lang: cached.lang) ) completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date())))) return } // 2) 过期/缺失:尝试拉取后端 if let fetched = await fetchDailyRecoFromServer() { saveDailyReco(lang: fetched.lang, dayKey: today, contentId: fetched.contentId, text: fetched.text, meta: fetched.meta) let entry = EmotionEntry( date: Date(), lang: fetched.lang, title: resolveTitle(lang: fetched.lang), text: fetched.text, footerHint: resolveFooterHint(lang: fetched.lang) ) completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date())))) return } // 3) 网络失败:用最近缓存或兜底 if let cached = readCachedText(family: context.family) { let entry = EmotionEntry( date: Date(), lang: cached.lang, title: resolveTitle(lang: cached.lang), text: cached.text, footerHint: resolveFooterHint(lang: cached.lang) ) completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date())))) return } let entry = EmotionEntry( date: Date(), lang: lang, title: resolveTitle(lang: lang), text: lang == "en" ? fallbackTextEN : fallbackTextTC, footerHint: resolveFooterHint(lang: lang) ) completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date())))) } } } struct EmotionEntry: TimelineEntry { let date: Date let lang: String let title: String let text: String let footerHint: String } struct EmotionWidgetView: View { var entry: EmotionProvider.Entry @Environment(\.widgetFamily) var family private let deepLink = URL(string: "client:///(app)/home") private let widgetBackgroundColor = Color(red: 1.0, green: 250.0 / 255.0, blue: 229.0 / 255.0) // #FFFAE5 private let widgetTextColor = Color(red: 98.0 / 255.0, green: 59.0 / 255.0, blue: 59.0 / 255.0) // #623B3B var body: some View { // 只显示一句话(不显示标题/提示/时间等装饰元素) Text(entry.text) .font(fontForFamily()) .foregroundColor(widgetTextColor) .multilineTextAlignment(.leading) .lineSpacing(lineSpacingForFamily()) .lineLimit(lineLimitForFamily()) .minimumScaleFactor(0.78) .padding(paddingForFamily()) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .widgetSolidBackground(widgetBackgroundColor) .widgetURL(deepLink) } private func fontForFamily() -> Font { switch family { case .systemSmall: return .system(size: 16, weight: .semibold) case .systemMedium: return .system(size: 18, weight: .semibold) case .systemLarge: return .system(size: 22, weight: .semibold) default: return .system(size: 16, weight: .semibold) } } private func lineSpacingForFamily() -> CGFloat { switch family { case .systemLarge: return 4 default: return 3 } } private func lineLimitForFamily() -> Int { switch family { case .systemSmall: return 5 case .systemMedium: return 6 case .systemLarge: return 8 default: return 5 } } private func paddingForFamily() -> CGFloat { switch family { case .systemSmall: return 14 case .systemMedium: return 16 case .systemLarge: return 18 default: return 14 } } } private struct WidgetSolidBackgroundModifier: ViewModifier { let color: Color func body(content: Content) -> some View { if #available(iOSApplicationExtension 17.0, *) { content.containerBackground(for: .widget) { color } } else { content .background(color) .ignoresSafeArea() } } } private extension View { func widgetSolidBackground(_ color: Color) -> some View { modifier(WidgetSolidBackgroundModifier(color: color)) } } @main struct EmotionWidget: Widget { let kind: String = "EmotionWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in EmotionWidgetView(entry: entry) } // 名称/描述:支持多语言(使用 Widget Extension 自己的 Localizable.strings) .configurationDisplayName("WIDGET_DISPLAY_NAME") .description("WIDGET_DESCRIPTION") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } }