diff --git a/app/(tabs)/medications.tsx b/app/(tabs)/medications.tsx index 28ff1ae..1ec5e0f 100644 --- a/app/(tabs)/medications.tsx +++ b/app/(tabs)/medications.tsx @@ -9,6 +9,7 @@ import { useColorScheme } from '@/hooks/useColorScheme'; import { medicationNotificationService } from '@/services/medicationNotifications'; import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate } from '@/store/medicationsSlice'; import { DEFAULT_MEMBER_NAME } from '@/store/userSlice'; +import { convertMedicationDataToWidget, refreshWidget, syncMedicationDataToWidget } from '@/utils/widgetDataSync'; import { useFocusEffect } from '@react-navigation/native'; import dayjs, { Dayjs } from 'dayjs'; import 'dayjs/locale/zh-cn'; @@ -105,10 +106,25 @@ export default function MedicationsScreen() { const medications = await dispatch(fetchMedications({ isActive: true })).unwrap(); // 并行执行获取药物记录和安排通知 - await Promise.all([ + const [recordsAction] = await Promise.all([ dispatch(fetchMedicationRecords({ date: selectedKey })), medicationNotificationService.rescheduleAllMedicationNotifications(medications), ]); + + // 同步数据到小组件(仅同步今天的) + const today = dayjs().format('YYYY-MM-DD'); + const records = recordsAction.payload as any; + if (selectedKey === today && records?.records) { + const medicationData = convertMedicationDataToWidget( + records.records, + medications, + selectedKey + ); + await syncMedicationDataToWidget(medicationData); + + // 刷新小组件 + await refreshWidget(); + } } catch (error) { console.error('刷新数据或重新安排药品通知失败:', error); } diff --git a/ios/AppGroupUserDefaultsManager.h b/ios/AppGroupUserDefaultsManager.h new file mode 100644 index 0000000..92a56f4 --- /dev/null +++ b/ios/AppGroupUserDefaultsManager.h @@ -0,0 +1,13 @@ +// +// AppGroupUserDefaultsManager.h +// OutLive +// +// Created by AI Assistant on 2025/11/13. +// + +#import +#import + +@interface AppGroupUserDefaultsManager : RCTEventEmitter + +@end \ No newline at end of file diff --git a/ios/AppGroupUserDefaultsManager.m b/ios/AppGroupUserDefaultsManager.m new file mode 100644 index 0000000..07b7bd6 --- /dev/null +++ b/ios/AppGroupUserDefaultsManager.m @@ -0,0 +1,163 @@ +// +// AppGroupUserDefaultsManager.m +// OutLive +// +// Created by AI Assistant on 2025/11/13. +// + +#import "AppGroupUserDefaultsManager.h" +#import + +// App Group标识符 +static NSString * const APP_GROUP_ID = @"group.com.anonymous.digitalpilates"; + +@implementation AppGroupUserDefaultsManager + +RCT_EXPORT_MODULE(); + +- (NSArray *)supportedEvents { + return @[]; +} + ++ (BOOL)requiresMainQueueSetup { + return YES; +} + +- (NSUserDefaults *)getAppGroupUserDefaults { + return [[NSUserDefaults alloc] initWithSuiteName:APP_GROUP_ID]; +} + +RCT_EXPORT_METHOD(setString:(NSString *)groupId + key:(NSString *)key + value:(NSString *)value + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + + @try { + NSUserDefaults *defaults = [self getAppGroupUserDefaults]; + if (defaults) { + [defaults setObject:value forKey:key]; + [defaults synchronize]; + resolve(nil); + } else { + reject(@"APP_GROUP_ERROR", @"Failed to access App Group UserDefaults", nil); + } + } @catch (NSException *exception) { + reject(@"SET_STRING_ERROR", exception.reason, nil); + } +} + +RCT_EXPORT_METHOD(getString:(NSString *)groupId + key:(NSString *)key + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + + @try { + NSUserDefaults *defaults = [self getAppGroupUserDefaults]; + if (defaults) { + NSString *value = [defaults stringForKey:key]; + resolve(value ?: [NSNull null]); + } else { + reject(@"APP_GROUP_ERROR", @"Failed to access App Group UserDefaults", nil); + } + } @catch (NSException *exception) { + reject(@"GET_STRING_ERROR", exception.reason, nil); + } +} + +RCT_EXPORT_METHOD(setNumber:(NSString *)groupId + key:(NSString *)key + value:(nonnull NSNumber *)value + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + + @try { + NSUserDefaults *defaults = [self getAppGroupUserDefaults]; + if (defaults) { + [defaults setObject:value forKey:key]; + [defaults synchronize]; + resolve(nil); + } else { + reject(@"APP_GROUP_ERROR", @"Failed to access App Group UserDefaults", nil); + } + } @catch (NSException *exception) { + reject(@"SET_NUMBER_ERROR", exception.reason, nil); + } +} + +RCT_EXPORT_METHOD(getNumber:(NSString *)groupId + key:(NSString *)key + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + + @try { + NSUserDefaults *defaults = [self getAppGroupUserDefaults]; + if (defaults) { + NSNumber *value = [defaults objectForKey:key]; + resolve(value ?: @0); + } else { + reject(@"APP_GROUP_ERROR", @"Failed to access App Group UserDefaults", nil); + } + } @catch (NSException *exception) { + reject(@"GET_NUMBER_ERROR", exception.reason, nil); + } +} + +RCT_EXPORT_METHOD(removeKey:(NSString *)groupId + key:(NSString *)key + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + + @try { + NSUserDefaults *defaults = [self getAppGroupUserDefaults]; + if (defaults) { + [defaults removeObjectForKey:key]; + [defaults synchronize]; + resolve(nil); + } else { + reject(@"APP_GROUP_ERROR", @"Failed to access App Group UserDefaults", nil); + } + } @catch (NSException *exception) { + reject(@"REMOVE_KEY_ERROR", exception.reason, nil); + } +} + +RCT_EXPORT_METHOD(setArray:(NSString *)groupId + key:(NSString *)key + value:(NSArray *)value + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + + @try { + NSUserDefaults *defaults = [self getAppGroupUserDefaults]; + if (defaults) { + [defaults setObject:value forKey:key]; + [defaults synchronize]; + resolve(nil); + } else { + reject(@"APP_GROUP_ERROR", @"Failed to access App Group UserDefaults", nil); + } + } @catch (NSException *exception) { + reject(@"SET_ARRAY_ERROR", exception.reason, nil); + } +} + +RCT_EXPORT_METHOD(getArray:(NSString *)groupId + key:(NSString *)key + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + + @try { + NSUserDefaults *defaults = [self getAppGroupUserDefaults]; + if (defaults) { + NSArray *value = [defaults arrayForKey:key]; + resolve(value ?: [NSNull null]); + } else { + reject(@"APP_GROUP_ERROR", @"Failed to access App Group UserDefaults", nil); + } + } @catch (NSException *exception) { + reject(@"GET_ARRAY_ERROR", exception.reason, nil); + } +} + +@end \ No newline at end of file diff --git a/ios/OutLive.xcodeproj/project.pbxproj b/ios/OutLive.xcodeproj/project.pbxproj index a0ecc01..48e8e34 100644 --- a/ios/OutLive.xcodeproj/project.pbxproj +++ b/ios/OutLive.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -16,6 +16,12 @@ 79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; }; 79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; }; 79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; }; + 79E80BA42EC5D92A004425BE /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79E80BA32EC5D92A004425BE /* WidgetKit.framework */; }; + 79E80BA62EC5D92A004425BE /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79E80BA52EC5D92A004425BE /* SwiftUI.framework */; }; + 79E80BB72EC5D92B004425BE /* medicineExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 79E80BA22EC5D92A004425BE /* medicineExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 79E80BFF2EC5E127004425BE /* AppGroupUserDefaultsManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79E80BFC2EC5E127004425BE /* AppGroupUserDefaultsManager.m */; }; + 79E80C002EC5E127004425BE /* WidgetManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79E80BFE2EC5E127004425BE /* WidgetManager.m */; }; + 79E80C522EC5E500004425BE /* WidgetCenterHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79E80C512EC5E500004425BE /* WidgetCenterHelper.swift */; }; 91B7BA17B50D328546B5B4B8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */; }; AE00ECEC9D078460F642F131 /* libPods-OutLive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F6136AA7113B3D210693D88 /* libPods-OutLive.a */; }; B6B9273B2FD4F4A800C6391C /* BackgroundTaskBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */; }; @@ -24,6 +30,30 @@ F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 79E80BB52EC5D92B004425BE /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 79E80BA12EC5D92A004425BE; + remoteInfo = medicineExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 79E80BB82EC5D92B004425BE /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 79E80BB72EC5D92B004425BE /* medicineExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.debug.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.debug.xcconfig"; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* OutLive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OutLive.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -36,6 +66,15 @@ 792C52612EB05B8F002F3F09 /* NativeToastManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NativeToastManager.swift; path = OutLive/NativeToastManager.swift; sourceTree = ""; }; 79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = ""; }; 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = ""; }; + 79E80BA22EC5D92A004425BE /* medicineExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = medicineExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 79E80BA32EC5D92A004425BE /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 79E80BA52EC5D92A004425BE /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 79E80BBE2EC5DC50004425BE /* medicineExtensionRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = medicineExtensionRelease.entitlements; sourceTree = ""; }; + 79E80BFB2EC5E127004425BE /* AppGroupUserDefaultsManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppGroupUserDefaultsManager.h; sourceTree = ""; }; + 79E80BFC2EC5E127004425BE /* AppGroupUserDefaultsManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppGroupUserDefaultsManager.m; sourceTree = ""; }; + 79E80BFD2EC5E127004425BE /* WidgetManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WidgetManager.h; sourceTree = ""; }; + 79E80BFE2EC5E127004425BE /* WidgetManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WidgetManager.m; sourceTree = ""; }; + 79E80C512EC5E500004425BE /* WidgetCenterHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WidgetCenterHelper.swift; path = OutLive/WidgetCenterHelper.swift; sourceTree = ""; }; 9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = ""; }; B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BackgroundTaskBridge.swift; path = OutLive/BackgroundTaskBridge.swift; sourceTree = ""; }; @@ -47,6 +86,20 @@ F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "OutLive-Bridging-Header.h"; path = "OutLive/OutLive-Bridging-Header.h"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 79E80BBB2EC5D92B004425BE /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 79E80BA12EC5D92A004425BE /* medicineExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 79E80BA72EC5D92A004425BE /* medicine */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (79E80BBB2EC5D92B004425BE /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = medicine; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -57,6 +110,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 79E80B9F2EC5D92A004425BE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 79E80BA62EC5D92A004425BE /* SwiftUI.framework in Frameworks */, + 79E80BA42EC5D92A004425BE /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -80,6 +142,8 @@ 792C52582EA880A7002F3F09 /* StoreKit.framework */, ED297162215061F000B7C4FE /* JavaScriptCore.framework */, 6F6136AA7113B3D210693D88 /* libPods-OutLive.a */, + 79E80BA32EC5D92A004425BE /* WidgetKit.framework */, + 79E80BA52EC5D92A004425BE /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -102,6 +166,12 @@ 83CBB9F61A601CBA00E9B192 = { isa = PBXGroup; children = ( + 79E80BFB2EC5E127004425BE /* AppGroupUserDefaultsManager.h */, + 79E80BFC2EC5E127004425BE /* AppGroupUserDefaultsManager.m */, + 79E80BFD2EC5E127004425BE /* WidgetManager.h */, + 79E80BFE2EC5E127004425BE /* WidgetManager.m */, + 79E80C512EC5E500004425BE /* WidgetCenterHelper.swift */, + 79E80BBE2EC5DC50004425BE /* medicineExtensionRelease.entitlements */, 792C52602EB05B8F002F3F09 /* NativeToastManager.m */, 792C52612EB05B8F002F3F09 /* NativeToastManager.swift */, 79B2CB712E7B954F00B51753 /* HealthKitManager.m */, @@ -110,6 +180,7 @@ B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */, 13B07FAE1A68108700A75B9A /* OutLive */, 832341AE1AAA6A7D00B99B32 /* Libraries */, + 79E80BA72EC5D92A004425BE /* medicine */, 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, 80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */, @@ -124,6 +195,7 @@ isa = PBXGroup; children = ( 13B07F961A680F5B00A75B9A /* OutLive.app */, + 79E80BA22EC5D92A004425BE /* medicineExtension.appex */, ); name = Products; sourceTree = ""; @@ -169,27 +241,53 @@ 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, 8F598744CAFFA386101BCC07 /* [CP] Embed Pods Frameworks */, 54EB3ED8CF242B308A7FE01E /* [CP] Copy Pods Resources */, + 79E80BB82EC5D92B004425BE /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 79E80BB62EC5D92B004425BE /* PBXTargetDependency */, ); name = OutLive; productName = OutLive; productReference = 13B07F961A680F5B00A75B9A /* OutLive.app */; productType = "com.apple.product-type.application"; }; + 79E80BA12EC5D92A004425BE /* medicineExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 79E80BBC2EC5D92B004425BE /* Build configuration list for PBXNativeTarget "medicineExtension" */; + buildPhases = ( + 79E80B9E2EC5D92A004425BE /* Sources */, + 79E80B9F2EC5D92A004425BE /* Frameworks */, + 79E80BA02EC5D92A004425BE /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 79E80BA72EC5D92A004425BE /* medicine */, + ); + name = medicineExtension; + productName = medicineExtension; + productReference = 79E80BA22EC5D92A004425BE /* medicineExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 83CBB9F71A601CBA00E9B192 /* Project object */ = { isa = PBXProject; attributes = { + LastSwiftUpdateCheck = 2610; LastUpgradeCheck = 1130; TargetAttributes = { 13B07F861A680F5B00A75B9A = { LastSwiftMigration = 1250; }; + 79E80BA12EC5D92A004425BE = { + CreatedOnToolsVersion = 26.1; + }; }; }; buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "OutLive" */; @@ -206,6 +304,7 @@ projectRoot = ""; targets = ( 13B07F861A680F5B00A75B9A /* OutLive */, + 79E80BA12EC5D92A004425BE /* medicineExtension */, ); }; /* End PBXProject section */ @@ -222,6 +321,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 79E80BA02EC5D92A004425BE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -374,6 +480,9 @@ buildActionMask = 2147483647; files = ( 792C52622EB05B8F002F3F09 /* NativeToastManager.m in Sources */, + 79E80BFF2EC5E127004425BE /* AppGroupUserDefaultsManager.m in Sources */, + 79E80C002EC5E127004425BE /* WidgetManager.m in Sources */, + 79E80C522EC5E500004425BE /* WidgetCenterHelper.swift in Sources */, 792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */, 79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */, 79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */, @@ -385,8 +494,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 79E80B9E2EC5D92A004425BE /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 79E80BB62EC5D92B004425BE /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 79E80BA12EC5D92A004425BE /* medicineExtension */; + targetProxy = 79E80BB52EC5D92B004425BE /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; @@ -456,6 +580,102 @@ }; name = Release; }; + 79E80BB92EC5D92B004425BE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 756WVXJ6MT; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = medicine/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = medicine; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates.medicine; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 79E80BBA2EC5D92B004425BE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = medicineExtensionRelease.entitlements; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 756WVXJ6MT; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = medicine/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = medicine; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates.medicine; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 83CBBA201A601CBA00E9B192 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -586,6 +806,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 79E80BBC2EC5D92B004425BE /* Build configuration list for PBXNativeTarget "medicineExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 79E80BB92EC5D92B004425BE /* Debug */, + 79E80BBA2EC5D92B004425BE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "OutLive" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist index 4b1db45..7f202d0 100644 --- a/ios/OutLive/Info.plist +++ b/ios/OutLive/Info.plist @@ -27,7 +27,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.25 + 1.0.24 CFBundleSignature ???? CFBundleURLTypes diff --git a/ios/OutLive/OutLive-Bridging-Header.h b/ios/OutLive/OutLive-Bridging-Header.h index e46eecf..2bbe245 100644 --- a/ios/OutLive/OutLive-Bridging-Header.h +++ b/ios/OutLive/OutLive-Bridging-Header.h @@ -3,3 +3,5 @@ // #import #import +#import "../AppGroupUserDefaultsManager.h" +#import "../WidgetManager.h" diff --git a/ios/OutLive/OutLive.entitlements b/ios/OutLive/OutLive.entitlements index 0eaca8a..ce6b25a 100644 --- a/ios/OutLive/OutLive.entitlements +++ b/ios/OutLive/OutLive.entitlements @@ -16,5 +16,9 @@ com.apple.developer.healthkit.background-delivery + com.apple.security.application-groups + + group.com.anonymous.digitalpilates + diff --git a/ios/OutLive/WidgetCenterHelper.swift b/ios/OutLive/WidgetCenterHelper.swift new file mode 100644 index 0000000..827c021 --- /dev/null +++ b/ios/OutLive/WidgetCenterHelper.swift @@ -0,0 +1,17 @@ +import Foundation +import WidgetKit + +@objc class WidgetCenterHelper: NSObject { + @objc static func reloadAllTimelinesIfAvailable() { + if #available(iOS 14.0, *) { + WidgetCenter.shared.reloadAllTimelines() + } + } + + @objc(reloadTimelinesIfAvailableOfKind:) + static func reloadTimelinesIfAvailable(ofKind kind: String) { + if #available(iOS 14.0, *) { + WidgetCenter.shared.reloadTimelines(ofKind: kind) + } + } +} diff --git a/ios/WidgetManager.h b/ios/WidgetManager.h new file mode 100644 index 0000000..0df5a1a --- /dev/null +++ b/ios/WidgetManager.h @@ -0,0 +1,12 @@ +// +// WidgetManager.h +// OutLive +// +// Created by AI Assistant on 2025/11/13. +// + +#import + +@interface WidgetManager : NSObject + +@end \ No newline at end of file diff --git a/ios/WidgetManager.m b/ios/WidgetManager.m new file mode 100644 index 0000000..8f93885 --- /dev/null +++ b/ios/WidgetManager.m @@ -0,0 +1,55 @@ +// +// WidgetManager.m +// OutLive +// +// Created by AI Assistant on 2025/11/13. +// + +#import "WidgetManager.h" +#import "OutLive-Swift.h" + +@implementation WidgetManager + +RCT_EXPORT_MODULE(); + ++ (BOOL)requiresMainQueueSetup { + return NO; +} + +RCT_EXPORT_METHOD(reloadTimelines:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + + @try { + if (@available(iOS 14.0, *)) { + // 刷新所有小组件 + [WidgetCenterHelper reloadAllTimelinesIfAvailable]; + + // 也可以只刷新特定的medicine小组件 + [WidgetCenterHelper reloadTimelinesIfAvailableOfKind:@"medicine"]; + + resolve(@"Widget timelines reloaded successfully"); + } else { + reject(@"IOS_VERSION_ERROR", @"WidgetKit is not available on this iOS version", nil); + } + } @catch (NSException *exception) { + reject(@"RELOAD_TIMELINES_ERROR", exception.reason, nil); + } +} + +RCT_EXPORT_METHOD(reloadTimeline:(NSString *)widgetKind + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + + @try { + if (@available(iOS 14.0, *)) { + [WidgetCenterHelper reloadTimelinesIfAvailableOfKind:widgetKind]; + resolve([NSString stringWithFormat:@"Widget timeline reloaded for kind: %@", widgetKind]); + } else { + reject(@"IOS_VERSION_ERROR", @"WidgetKit is not available on this iOS version", nil); + } + } @catch (NSException *exception) { + reject(@"RELOAD_TIMELINE_ERROR", exception.reason, nil); + } +} + +@end diff --git a/ios/medicine/AppIntent.swift b/ios/medicine/AppIntent.swift new file mode 100644 index 0000000..3a5ecf2 --- /dev/null +++ b/ios/medicine/AppIntent.swift @@ -0,0 +1,18 @@ +// +// AppIntent.swift +// medicine +// +// Created by richard on 2025/11/13. +// + +import WidgetKit +import AppIntents + +struct ConfigurationAppIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource { "Configuration" } + static var description: IntentDescription { "This is an example widget." } + + // An example configurable parameter. + @Parameter(title: "Favorite Emoji", default: "😃") + var favoriteEmoji: String +} diff --git a/ios/medicine/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/medicine/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/medicine/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/medicine/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/medicine/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/ios/medicine/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/medicine/Assets.xcassets/Contents.json b/ios/medicine/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/medicine/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/medicine/Assets.xcassets/WidgetBackground.colorset/Contents.json b/ios/medicine/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/medicine/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/medicine/Info.plist b/ios/medicine/Info.plist new file mode 100644 index 0000000..8873466 --- /dev/null +++ b/ios/medicine/Info.plist @@ -0,0 +1,15 @@ + + + + + CFBundleDisplayName + 用药计划 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + RCTNewArchEnabled + + + diff --git a/ios/medicine/medicine.entitlements b/ios/medicine/medicine.entitlements new file mode 100644 index 0000000..7107d4e --- /dev/null +++ b/ios/medicine/medicine.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.anonymous.digitalpilates + + + \ No newline at end of file diff --git a/ios/medicine/medicine.swift b/ios/medicine/medicine.swift new file mode 100644 index 0000000..9a7e25f --- /dev/null +++ b/ios/medicine/medicine.swift @@ -0,0 +1,295 @@ +// +// 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 { + 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 +} + +/// 将Date转换为ISO字符串(辅助函数) +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) +} diff --git a/ios/medicine/medicineBundle.swift b/ios/medicine/medicineBundle.swift new file mode 100644 index 0000000..e56587a --- /dev/null +++ b/ios/medicine/medicineBundle.swift @@ -0,0 +1,18 @@ +// +// medicineBundle.swift +// medicine +// +// Created by richard on 2025/11/13. +// + +import WidgetKit +import SwiftUI + +@main +struct medicineBundle: WidgetBundle { + var body: some Widget { + medicine() + medicineControl() + medicineLiveActivity() + } +} diff --git a/ios/medicine/medicineControl.swift b/ios/medicine/medicineControl.swift new file mode 100644 index 0000000..8e8f990 --- /dev/null +++ b/ios/medicine/medicineControl.swift @@ -0,0 +1,77 @@ +// +// medicineControl.swift +// medicine +// +// Created by richard on 2025/11/13. +// + +import AppIntents +import SwiftUI +import WidgetKit + +struct medicineControl: ControlWidget { + static let kind: String = "com.anonymous.digitalpilates.medicine" + + var body: some ControlWidgetConfiguration { + AppIntentControlConfiguration( + kind: Self.kind, + provider: Provider() + ) { value in + ControlWidgetToggle( + "Start Timer", + isOn: value.isRunning, + action: StartTimerIntent(value.name) + ) { isRunning in + Label(isRunning ? "On" : "Off", systemImage: "timer") + } + } + .displayName("Timer") + .description("A an example control that runs a timer.") + } +} + +extension medicineControl { + struct Value { + var isRunning: Bool + var name: String + } + + struct Provider: AppIntentControlValueProvider { + func previewValue(configuration: TimerConfiguration) -> Value { + medicineControl.Value(isRunning: false, name: configuration.timerName) + } + + func currentValue(configuration: TimerConfiguration) async throws -> Value { + let isRunning = true // Check if the timer is running + return medicineControl.Value(isRunning: isRunning, name: configuration.timerName) + } + } +} + +struct TimerConfiguration: ControlConfigurationIntent { + static let title: LocalizedStringResource = "Timer Name Configuration" + + @Parameter(title: "Timer Name", default: "Timer") + var timerName: String +} + +struct StartTimerIntent: SetValueIntent { + static let title: LocalizedStringResource = "Start a timer" + + @Parameter(title: "Timer Name") + var name: String + + @Parameter(title: "Timer is running") + var value: Bool + + init() {} + + init(_ name: String) { + self.name = name + } + + func perform() async throws -> some IntentResult { + // Start the timer… + return .result() + } +} diff --git a/ios/medicine/medicineLiveActivity.swift b/ios/medicine/medicineLiveActivity.swift new file mode 100644 index 0000000..8043838 --- /dev/null +++ b/ios/medicine/medicineLiveActivity.swift @@ -0,0 +1,80 @@ +// +// medicineLiveActivity.swift +// medicine +// +// Created by richard on 2025/11/13. +// + +import ActivityKit +import WidgetKit +import SwiftUI + +struct medicineAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + // Dynamic stateful properties about your activity go here! + var emoji: String + } + + // Fixed non-changing properties about your activity go here! + var name: String +} + +struct medicineLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: medicineAttributes.self) { context in + // Lock screen/banner UI goes here + VStack { + Text("Hello \(context.state.emoji)") + } + .activityBackgroundTint(Color.cyan) + .activitySystemActionForegroundColor(Color.black) + + } dynamicIsland: { context in + DynamicIsland { + // Expanded UI goes here. Compose the expanded UI through + // various regions, like leading/trailing/center/bottom + DynamicIslandExpandedRegion(.leading) { + Text("Leading") + } + DynamicIslandExpandedRegion(.trailing) { + Text("Trailing") + } + DynamicIslandExpandedRegion(.bottom) { + Text("Bottom \(context.state.emoji)") + // more content + } + } compactLeading: { + Text("L") + } compactTrailing: { + Text("T \(context.state.emoji)") + } minimal: { + Text(context.state.emoji) + } + .widgetURL(URL(string: "http://www.apple.com")) + .keylineTint(Color.red) + } + } +} + +extension medicineAttributes { + fileprivate static var preview: medicineAttributes { + medicineAttributes(name: "World") + } +} + +extension medicineAttributes.ContentState { + fileprivate static var smiley: medicineAttributes.ContentState { + medicineAttributes.ContentState(emoji: "😀") + } + + fileprivate static var starEyes: medicineAttributes.ContentState { + medicineAttributes.ContentState(emoji: "🤩") + } +} + +#Preview("Notification", as: .content, using: medicineAttributes.preview) { + medicineLiveActivity() +} contentStates: { + medicineAttributes.ContentState.smiley + medicineAttributes.ContentState.starEyes +} diff --git a/ios/medicineExtensionRelease.entitlements b/ios/medicineExtensionRelease.entitlements new file mode 100644 index 0000000..d47d8ba --- /dev/null +++ b/ios/medicineExtensionRelease.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.anonymous.digitalpilates + + + diff --git a/scripts/fix-swift-compilation.sh b/scripts/fix-swift-compilation.sh new file mode 100755 index 0000000..1293e3e --- /dev/null +++ b/scripts/fix-swift-compilation.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# 快速修复Swift编译问题的脚本 + +echo "🔧 开始修复Swift编译问题..." + +# 1. 清理构建缓存 +echo "🧹 清理构建缓存..." +cd ios +rm -rf ~/Library/Developer/Xcode/DerivedData/*/Build/Intermediates/*.swiftmodule/* 2>/dev/null + +# 2. 重新生成项目文件 +echo "📝 重新生成项目文件..." +xcodebuild -project OutLive.xcodeproj -alltargets clean + +# 3. 重新安装Pods +echo "📦 重新安装Pods..." +rm -rf Pods +rm -f Podfile.lock +pod install --repo-update + +# 4. 检查编译错误 +echo "🔍 检查编译错误..." +if xcodebuild -workspace OutLive.xcworkspace -scheme OutLive -destination 'generic/platform=iOS Simulator,name=iPhone 15' build 2>&1 | grep -q "error:"; then + echo "❌ 发现编译错误,请检查Xcode项目" + echo "💡 建议:在Xcode中打开项目查看详细错误信息" + open ios/OutLive.xcworkspace +else + echo "✅ 编译检查通过" +fi + +echo "🎉 修复完成!请尝试重新构建项目。" \ No newline at end of file diff --git a/store/medicationsSlice.ts b/store/medicationsSlice.ts index 8340797..7f883d9 100644 --- a/store/medicationsSlice.ts +++ b/store/medicationsSlice.ts @@ -9,6 +9,7 @@ import type { MedicationRecord, MedicationStatus, } from '@/types/medication'; +import { convertMedicationDataToWidget, syncMedicationDataToWidget } from '@/utils/widgetDataSync'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; import dayjs from 'dayjs'; import type { RootState } from './index'; @@ -345,6 +346,15 @@ const medicationsSlice = createSlice({ state.loading.records = false; const { date, records } = action.payload; state.medicationRecords[date] = records; + + // 如果是今天的记录,同步到小组件 + const today = dayjs().format('YYYY-MM-DD'); + if (date === today) { + const medicationData = convertMedicationDataToWidget(records, state.medications, date); + syncMedicationDataToWidget(medicationData).catch(error => { + console.error('Failed to sync medication data to widget:', error); + }); + } }) .addCase(fetchMedicationRecords.rejected, (state, action) => { state.loading.records = false; @@ -361,6 +371,12 @@ const medicationsSlice = createSlice({ state.loading.records = false; const { date, records } = action.payload; state.medicationRecords[date] = records; + + // 同步今天的记录到小组件 + const medicationData = convertMedicationDataToWidget(records, state.medications, date); + syncMedicationDataToWidget(medicationData).catch(error => { + console.error('Failed to sync medication data to widget:', error); + }); }) .addCase(fetchTodayMedicationRecords.rejected, (state, action) => { state.loading.records = false; @@ -394,6 +410,15 @@ const medicationsSlice = createSlice({ state.medicationRecords[date].push(record); } }); + + // 同步今天的记录到小组件 + const today = dayjs().format('YYYY-MM-DD'); + if (state.medicationRecords[today]) { + const medicationData = convertMedicationDataToWidget(state.medicationRecords[today], state.medications, today); + syncMedicationDataToWidget(medicationData).catch(error => { + console.error('Failed to sync medication data to widget:', error); + }); + } }) .addCase(fetchMedicationRecordsByDateRange.rejected, (state, action) => { state.loading.records = false; @@ -571,6 +596,15 @@ const medicationsSlice = createSlice({ ? (stats.taken / stats.totalScheduled) * 100 : 0; } + + // 如果是今天的记录,同步到小组件 + const today = dayjs().format('YYYY-MM-DD'); + if (date === today && records) { + const medicationData = convertMedicationDataToWidget(records, state.medications, date); + syncMedicationDataToWidget(medicationData).catch(error => { + console.error('Failed to sync medication data to widget:', error); + }); + } }) .addCase(takeMedicationAction.rejected, (state, action) => { state.loading.takeMedication = false; @@ -600,6 +634,15 @@ const medicationsSlice = createSlice({ if (stats) { stats.upcoming = Math.max(0, stats.upcoming - 1); } + + // 如果是今天的记录,同步到小组件 + const today = dayjs().format('YYYY-MM-DD'); + if (date === today && records) { + const medicationData = convertMedicationDataToWidget(records, state.medications, date); + syncMedicationDataToWidget(medicationData).catch(error => { + console.error('Failed to sync medication data to widget:', error); + }); + } }) .addCase(skipMedicationAction.rejected, (state, action) => { state.loading.takeMedication = false; diff --git a/utils/widgetDataSync.ts b/utils/widgetDataSync.ts index 73c81cf..19f2d1e 100644 --- a/utils/widgetDataSync.ts +++ b/utils/widgetDataSync.ts @@ -1,4 +1,5 @@ import AsyncStorage from '@/utils/kvStore'; +import dayjs from 'dayjs'; import { NativeModules } from 'react-native'; // Widget数据同步服务 @@ -16,6 +17,13 @@ const WIDGET_DATA_KEYS = { PENDING_WATER_RECORDS: 'widget_pending_water_records', } as const; +// 用药计划Widget数据存储键 +const MEDICATION_WIDGET_KEYS = { + MEDICATION_DATA: 'widget_medication_data', + PENDING_MEDICATION_ACTIONS: 'widget_pending_medication_actions', + MEDICATION_LAST_SYNC: 'widget_medication_last_sync', +} as const; + export interface WidgetWaterData { currentIntake: number; dailyGoal: number; @@ -31,6 +39,31 @@ const DEFAULT_WIDGET_DATA: WidgetWaterData = { lastSyncTime: new Date().toISOString(), }; +// 用药计划小组件数据接口 +export interface WidgetMedicationData { + medications: WidgetMedicationItem[]; + lastSyncTime: string; + date: string; // YYYY-MM-DD格式的日期 +} + +export interface WidgetMedicationItem { + id: string; + name: string; + dosage: string; + scheduledTime: string; // HH:mm格式 + status: 'upcoming' | 'taken' | 'missed'; + medicationId: string; + recordId?: string; + image?: string; // 图片URI或默认图片资源名 +} + +// 默认用药数据 +const DEFAULT_MEDICATION_WIDGET_DATA: WidgetMedicationData = { + medications: [], + lastSyncTime: new Date().toISOString(), + date: new Date().toISOString().split('T')[0], // 格式化为YYYY-MM-DD +}; + /** * 创建Native Module来访问App Group UserDefaults * 这需要在iOS原生代码中实现 @@ -54,7 +87,14 @@ export interface PendingWaterRecord { } // 尝试获取原生模块,如果不存在则使用fallback -const AppGroupDefaults: AppGroupUserDefaults | null = NativeModules.AppGroupUserDefaults || null; +const AppGroupDefaults: AppGroupUserDefaults | null = NativeModules.AppGroupUserDefaultsManager || null; + +// 添加调试日志 +if (AppGroupDefaults) { + console.log('✅ AppGroupUserDefaultsManager native module loaded successfully'); +} else { + console.warn('⚠️ AppGroupUserDefaultsManager native module not found, will use AsyncStorage fallback'); +} /** * 将饮水数据同步到Widget @@ -281,4 +321,211 @@ export const markWidgetDataSynced = async (): Promise => { } catch (error) { console.error('Failed to mark widget data as synced:', error); } +}; + +// ==================== 用药计划数据同步 ==================== + +/** + * 将用药数据同步到Widget + */ +export const syncMedicationDataToWidget = async (data: WidgetMedicationData): Promise => { + try { + console.log('🔄 [Widget Sync] Starting medication data sync...'); + console.log('🔄 [Widget Sync] Data to sync:', { + medicationsCount: data.medications.length, + date: data.date, + lastSyncTime: data.lastSyncTime, + medications: data.medications.map(m => ({ + name: m.name, + scheduledTime: m.scheduledTime, + status: m.status + })) + }); + + if (!AppGroupDefaults) { + console.warn('⚠️ [Widget Sync] AppGroupUserDefaults native module not available, falling back to AsyncStorage'); + // Fallback到AsyncStorage + const dataJson = JSON.stringify(data); + await AsyncStorage.setItem(MEDICATION_WIDGET_KEYS.MEDICATION_DATA, dataJson); + await AsyncStorage.setItem(MEDICATION_WIDGET_KEYS.MEDICATION_LAST_SYNC, data.lastSyncTime); + console.log('✅ [Widget Sync] Data saved to AsyncStorage (fallback)'); + return; + } + + // 使用App Group UserDefaults + const dataJson = JSON.stringify(data); + console.log('📝 [Widget Sync] JSON payload length:', dataJson.length); + + await AppGroupDefaults.setString(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.MEDICATION_DATA, dataJson); + await AppGroupDefaults.setString(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.MEDICATION_LAST_SYNC, data.lastSyncTime); + + console.log('✅ [Widget Sync] Medication widget data synced successfully:', data.medications.length, 'medications'); + + // 验证数据是否写入成功 + const verifyJson = await AppGroupDefaults.getString(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.MEDICATION_DATA); + if (verifyJson) { + console.log('✅ [Widget Sync] Verification successful - data exists in App Group'); + } else { + console.warn('⚠️ [Widget Sync] Verification failed - data not found in App Group'); + } + } catch (error) { + console.error('❌ [Widget Sync] Failed to sync medication data to widget:', error); + throw error; + } +}; + +/** + * 从Widget获取用药数据 + */ +export const getMedicationDataFromWidget = async (): Promise => { + try { + if (!AppGroupDefaults) { + console.warn('AppGroupUserDefaults native module not available, falling back to AsyncStorage'); + // Fallback从AsyncStorage读取 + const dataJson = await AsyncStorage.getItem(MEDICATION_WIDGET_KEYS.MEDICATION_DATA); + const lastSyncTime = await AsyncStorage.getItem(MEDICATION_WIDGET_KEYS.MEDICATION_LAST_SYNC); + + if (dataJson) { + const data = JSON.parse(dataJson); + return { + ...data, + lastSyncTime: lastSyncTime || DEFAULT_MEDICATION_WIDGET_DATA.lastSyncTime, + }; + } + + return DEFAULT_MEDICATION_WIDGET_DATA; + } + + // 从App Group UserDefaults读取数据 + const dataJson = await AppGroupDefaults.getString(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.MEDICATION_DATA); + const lastSyncTime = await AppGroupDefaults.getString(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.MEDICATION_LAST_SYNC); + + if (dataJson) { + const data = JSON.parse(dataJson); + return { + ...data, + lastSyncTime: lastSyncTime || DEFAULT_MEDICATION_WIDGET_DATA.lastSyncTime, + }; + } + + return DEFAULT_MEDICATION_WIDGET_DATA; + } catch (error) { + console.error('Failed to get medication widget data:', error); + return DEFAULT_MEDICATION_WIDGET_DATA; + } +}; + +/** + * 清除用药Widget数据 + */ +export const clearMedicationWidgetData = async (): Promise => { + try { + if (!AppGroupDefaults) { + await AsyncStorage.multiRemove([ + MEDICATION_WIDGET_KEYS.MEDICATION_DATA, + MEDICATION_WIDGET_KEYS.MEDICATION_LAST_SYNC, + MEDICATION_WIDGET_KEYS.PENDING_MEDICATION_ACTIONS, + ]); + return; + } + + // 清除App Group UserDefaults中的数据 + await Promise.all([ + AppGroupDefaults.removeKey(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.MEDICATION_DATA), + AppGroupDefaults.removeKey(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.MEDICATION_LAST_SYNC), + AppGroupDefaults.removeKey(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.PENDING_MEDICATION_ACTIONS), + ]); + + console.log('Medication widget data cleared successfully'); + } catch (error) { + console.error('Failed to clear medication widget data:', error); + throw error; + } +}; + +/** + * Widget用药操作接口 + */ +export interface WidgetMedicationAction { + type: 'take' | 'skip'; + recordId: string; + medicationId: string; + timestamp: string; +} + +/** + * 从Widget获取待处理的用药操作 + */ +export const getPendingMedicationActions = async (): Promise => { + try { + if (!AppGroupDefaults) { + // Fallback: 从 AsyncStorage 读取 + const pendingActionsJson = await AsyncStorage.getItem(MEDICATION_WIDGET_KEYS.PENDING_MEDICATION_ACTIONS); + return pendingActionsJson ? JSON.parse(pendingActionsJson) : []; + } + + // 从 App Group UserDefaults 读取 + const pendingActions = await AppGroupDefaults.getArray(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.PENDING_MEDICATION_ACTIONS); + return pendingActions || []; + } catch (error) { + console.error('Failed to get pending medication actions:', error); + return []; + } +}; + +/** + * 清除待处理的用药操作 + */ +export const clearPendingMedicationActions = async (): Promise => { + try { + if (!AppGroupDefaults) { + // Fallback: 从 AsyncStorage 清除 + await AsyncStorage.removeItem(MEDICATION_WIDGET_KEYS.PENDING_MEDICATION_ACTIONS); + return; + } + + // 从 App Group UserDefaults 清除 + await AppGroupDefaults.removeKey(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.PENDING_MEDICATION_ACTIONS); + console.log('Pending medication actions cleared successfully'); + } catch (error) { + console.error('Failed to clear pending medication actions:', error); + } +}; + +/** + * 将Redux数据转换为Widget数据格式 + */ +export const convertMedicationDataToWidget = ( + records: import('@/types/medication').MedicationRecord[], + medications: import('@/types/medication').Medication[], + date: string +): WidgetMedicationData => { + const medicationMap = new Map(medications.map(m => [m.id, m])); + + const widgetItems: WidgetMedicationItem[] = records + .map(record => { + const medication = medicationMap.get(record.medicationId); + if (!medication || !medication.isActive) return null; + + return { + id: record.id, + name: medication.name, + dosage: `${medication.dosageValue} ${medication.dosageUnit}`, + scheduledTime: dayjs(record.scheduledTime).format('HH:mm'), + status: record.status, + medicationId: medication.id, + recordId: record.id, + image: medication.photoUrl, + }; + }) + .filter(Boolean) as WidgetMedicationItem[]; + + // 按时间排序 + widgetItems.sort((a, b) => a.scheduledTime.localeCompare(b.scheduledTime)); + + return { + medications: widgetItems, + lastSyncTime: new Date().toISOString(), + date, + }; }; \ No newline at end of file