Files
digital-pilates/ios/medicine/medicine.swift
richarjiang b0e93eedae feat(ios): 添加用药计划Widget小组件支持
- 创建medicineExtension小组件,支持iOS桌面显示用药计划
- 实现App Group数据共享机制,支持主应用与小组件数据同步
- 添加AppGroupUserDefaultsManager原生模块,提供跨应用数据访问能力
- 添加WidgetManager和WidgetCenterHelper,实现小组件刷新控制
- 在medications页面和Redux store中集成小组件数据同步逻辑
- 支持实时同步今日用药状态(待服用/已服用/已错过)到小组件
- 配置App Group entitlements (group.com.anonymous.digitalpilates)
- 更新Xcode项目配置,添加WidgetKit和SwiftUI框架支持
2025-11-14 08:51:02 +08:00

296 lines
9.0 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.

//
// medicine.swift
// medicine
//
// Created by richard on 2025/11/13.
//
import WidgetKit
import SwiftUI
// MARK: -
struct MedicationItem: Codable, Identifiable {
let id: String
let name: String
let dosage: String
let scheduledTime: String
let status: String
let medicationId: String
let recordId: String?
let image: String?
}
struct MedicationWidgetData: Codable {
let medications: [MedicationItem]
let lastSyncTime: String
let date: String
}
// MARK: - Timeline Provider
struct Provider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> MedicationEntry {
MedicationEntry(
date: Date(),
medications: [],
displayDate: formatDate(Date()),
lastSyncTime: Date().timeIntervalSince1970
)
}
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> MedicationEntry {
let medicationData = loadMedicationDataFromAppGroup()
let lastSyncTimeInterval = parseTimeInterval(from: medicationData.lastSyncTime)
return MedicationEntry(
date: Date(),
medications: medicationData.medications,
displayDate: medicationData.date,
lastSyncTime: lastSyncTimeInterval
)
}
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<MedicationEntry> {
let currentDate = Date()
let medicationData = loadMedicationDataFromAppGroup()
let lastSyncTimeInterval = parseTimeInterval(from: medicationData.lastSyncTime)
let entry = MedicationEntry(
date: currentDate,
medications: medicationData.medications,
displayDate: medicationData.date,
lastSyncTime: lastSyncTimeInterval
)
// - 15
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
return Timeline(entries: [entry], policy: .after(nextUpdate))
}
}
// MARK: - Timeline Entry
struct MedicationEntry: TimelineEntry {
let date: Date
let medications: [MedicationItem]
let displayDate: String
let lastSyncTime: TimeInterval
}
// MARK: - Widget View
struct medicineEntryView: View {
var entry: Provider.Entry
var body: some View {
VStack(spacing: 8) {
//
Text(entry.displayDate)
.font(.caption)
.foregroundColor(.secondary)
//
if entry.medications.isEmpty {
Text("今日无用药计划")
.font(.body)
.foregroundColor(.secondary)
} else {
LazyVStack(spacing: 6) {
ForEach(entry.medications, id: \.id) { medication in
MedicationRowView(medication: medication)
}
}
}
Spacer()
//
Text("最后更新: \(formatTime(from: entry.lastSyncTime))")
.font(.caption2)
.foregroundColor(Color.secondary)
}
.padding()
}
}
// MARK: -
struct MedicationRowView: View {
let medication: MedicationItem
var body: some View {
HStack {
//
Circle()
.fill(statusColor)
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(medication.name)
.font(.caption)
.fontWeight(.medium)
.lineLimit(1)
Text("\(medication.scheduledTime)\(medication.dosage)")
.font(.caption2)
.foregroundColor(.secondary)
}
Spacer()
//
Text(statusText)
.font(.caption2)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(statusColor.opacity(0.2))
.foregroundColor(statusColor)
.cornerRadius(4)
}
.padding(.vertical, 2)
}
private var statusColor: Color {
switch medication.status {
case "taken":
return .green
case "missed":
return .red
default:
return .blue
}
}
private var statusText: String {
switch medication.status {
case "taken":
return "已服用"
case "missed":
return "已错过"
default:
return "待服用"
}
}
}
// MARK: - Widget Configuration
struct medicine: Widget {
let kind: String = "medicine"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
medicineEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("用药计划")
.description("显示今日用药计划和服药状态")
}
}
// MARK: - Helper Functions
private func loadMedicationDataFromAppGroup() -> MedicationWidgetData {
guard let userDefaults = UserDefaults(suiteName: "group.com.anonymous.digitalpilates") else {
print("❌ Failed to initialize UserDefaults with App Group")
return MedicationWidgetData(
medications: [],
lastSyncTime: Date().toISOString(),
date: formatDate(Date())
)
}
guard let dataJson = userDefaults.string(forKey: "widget_medication_data") else {
print("⚠️ No medication data found in App Group UserDefaults")
return MedicationWidgetData(
medications: [],
lastSyncTime: Date().toISOString(),
date: formatDate(Date())
)
}
print("✅ Found medication data JSON: \(dataJson.prefix(100))...")
guard let data = dataJson.data(using: .utf8) else {
print("❌ Failed to convert JSON string to Data")
return MedicationWidgetData(
medications: [],
lastSyncTime: Date().toISOString(),
date: formatDate(Date())
)
}
do {
let medicationData = try JSONDecoder().decode(MedicationWidgetData.self, from: data)
print("✅ Successfully decoded medication data: \(medicationData.medications.count) medications")
return medicationData
} catch {
print("❌ Failed to decode medication data: \(error)")
return MedicationWidgetData(
medications: [],
lastSyncTime: Date().toISOString(),
date: formatDate(Date())
)
}
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "M月d日"
return formatter.string(from: date)
}
private func formatTime(from timeInterval: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: timeInterval)
let formatter = DateFormatter()
formatter.timeStyle = .short
formatter.locale = Locale(identifier: "zh_CN")
return formatter.string(from: date)
}
/// - ISO
private func parseTimeInterval(from string: String) -> TimeInterval {
//
if let timestamp = Double(string) {
return timestamp
}
// ISO 8601
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = isoFormatter.date(from: string) {
return date.timeIntervalSince1970
}
// ISO
isoFormatter.formatOptions = [.withInternetDateTime]
if let date = isoFormatter.date(from: string) {
return date.timeIntervalSince1970
}
//
return Date().timeIntervalSince1970
}
/// DateISO
extension Date {
func toISOString() -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: self)
}
}
extension ConfigurationAppIntent {
fileprivate static var smiley: ConfigurationAppIntent {
let intent = ConfigurationAppIntent()
intent.favoriteEmoji = "😀"
return intent
}
fileprivate static var starEyes: ConfigurationAppIntent {
let intent = ConfigurationAppIntent()
intent.favoriteEmoji = "🤩"
return intent
}
}
#Preview(as: .systemSmall) {
medicine()
} timeline: {
MedicationEntry(date: .now, medications: [], displayDate: formatDate(Date()), lastSyncTime: Date().timeIntervalSince1970)
MedicationEntry(date: .now, medications: [], displayDate: formatDate(Date()), lastSyncTime: Date().timeIntervalSince1970)
}