fix:小组件- PUSH

This commit is contained in:
吕新雨
2026-02-03 17:43:58 +08:00
parent d742b398ef
commit c1c2c6197d
66 changed files with 4888 additions and 479 deletions

View File

@@ -1,49 +1,261 @@
import Foundation
import WidgetKit
import SwiftUI
// V1Small/Medium/Large + Home
// 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 {
EmotionEntry(date: Date())
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(EmotionEntry(date: Date()))
completion(placeholder(in: context))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) {
// V1
let entry = EmotionEntry(date: Date())
let nextUpdate = Calendar.current.date(byAdding: .day, value: 7, to: Date())
?? Date().addingTimeInterval(60 * 60 * 24 * 7)
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
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 title = "正念"
private let text = "你已经很努力了,今天也值得被温柔对待。"
private let deepLink = URL(string: "client:///(app)/home")
var body: some View {
switch family {
case .systemSmall:
smallView()
case .systemMedium:
mediumView()
case .systemLarge:
largeView()
default:
smallView()
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.14, green: 0.18, blue: 0.28),
])
// //
Text(entry.text)
.font(fontForFamily())
.foregroundColor(Color.white.opacity(0.92))
.multilineTextAlignment(.leading)
.lineSpacing(lineSpacingForFamily())
.lineLimit(lineLimitForFamily())
.minimumScaleFactor(0.78)
.padding(paddingForFamily())
}
.widgetURL(deepLink)
}
// iOS 15
@@ -69,122 +281,52 @@ struct EmotionWidgetView: View {
.cornerRadius(18)
}
private func chip(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.9))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.white.opacity(0.14))
.cornerRadius(999)
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 smallView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.13, green: 0.16, blue: 0.22),
])
VStack(alignment: .leading, spacing: 10) {
HStack {
chip(title)
Spacer(minLength: 0)
}
Text(text)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(2)
.lineLimit(4)
Spacer(minLength: 0)
Text("点我回到 App")
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color.white.opacity(0.65))
}
.padding(14)
private func lineSpacingForFamily() -> CGFloat {
switch family {
case .systemLarge:
return 4
default:
return 3
}
.widgetURL(deepLink)
}
private func mediumView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.09, green: 0.11, blue: 0.17),
])
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 10) {
chip(title)
Text(text)
.font(.system(size: 17, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(3)
.lineLimit(5)
Spacer(minLength: 0)
Text("轻轻呼吸,回到当下")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.white.opacity(0.7))
}
//
VStack(alignment: .trailing, spacing: 8) {
Text(entry.date, style: .time)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.8))
Spacer(minLength: 0)
Text("今日")
.font(.system(size: 28, weight: .bold))
.foregroundColor(Color.white.opacity(0.12))
}
}
.padding(16)
private func lineLimitForFamily() -> Int {
switch family {
case .systemSmall:
return 5
case .systemMedium:
return 6
case .systemLarge:
return 8
default:
return 5
}
.widgetURL(deepLink)
}
private func largeView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.14, green: 0.18, blue: 0.28),
])
VStack(alignment: .leading, spacing: 14) {
HStack {
chip(title)
Spacer(minLength: 0)
Text(entry.date, style: .time)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.78))
}
Text(text)
.font(.system(size: 20, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(4)
.lineLimit(8)
Spacer(minLength: 0)
HStack {
Text("点我回到 Home")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.white.opacity(0.7))
Spacer(minLength: 0)
Text("🌿")
.font(.system(size: 18))
.opacity(0.9)
}
}
.padding(18)
private func paddingForFamily() -> CGFloat {
switch family {
case .systemSmall:
return 14
case .systemMedium:
return 16
case .systemLarge:
return 18
default:
return 14
}
.widgetURL(deepLink)
}
}