377 lines
13 KiB
Swift
377 lines
13 KiB
Swift
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..<interval) : 0
|
||
return minDate.addingTimeInterval(jitter)
|
||
}
|
||
|
||
private func readJsonDict(forKey key: String) -> [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<EmotionEntry>) -> ()) {
|
||
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(.center)
|
||
.lineSpacing(lineSpacingForFamily())
|
||
.lineLimit(lineLimitForFamily())
|
||
.minimumScaleFactor(0.78)
|
||
.padding(paddingForFamily())
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||
.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])
|
||
}
|
||
}
|
||
|