341 lines
12 KiB
Swift
341 lines
12 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
|
||
let preferred = Locale.preferredLanguages.first?.lowercased() ?? "en"
|
||
return preferred.hasPrefix("zh") ? "tc" : "en"
|
||
}
|
||
|
||
private func resolveTitle(lang: String) -> String {
|
||
lang == "en" ? "Mindfulness" : "正念"
|
||
}
|
||
|
||
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() -> (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], let text = item["text"] as? String, !text.isEmpty {
|
||
return (dayKey: dayKey, lang: lang, text: text)
|
||
}
|
||
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(), 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() {
|
||
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)
|
||
}
|
||
.configurationDisplayName("情绪小组件")
|
||
.description("一段温柔提醒,陪你回到当下。")
|
||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||
}
|
||
}
|
||
|