Files
mindfulness/client/ios/情绪小组件/EmotionWidget.swift
2026-02-05 01:14:13 +08:00

341 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = "Youve 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:1001: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])
}
}