diff --git a/app.json b/app.json index c614e11..bec3213 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Out Live", "slug": "digital-pilates", - "version": "1.1.4", + "version": "1.1.5", "orientation": "portrait", "scheme": "digitalpilates", "userInterfaceStyle": "light", diff --git a/app/notification-settings.tsx b/app/notification-settings.tsx index 91594d6..7b704d0 100644 --- a/app/notification-settings.tsx +++ b/app/notification-settings.tsx @@ -8,10 +8,12 @@ import { getMoodReminderEnabled, getNotificationEnabled, getNutritionReminderEnabled, + getHRVReminderEnabled, setMedicationReminderEnabled, setMoodReminderEnabled, setNotificationEnabled, - setNutritionReminderEnabled + setNutritionReminderEnabled, + setHRVReminderEnabled } from '@/utils/userPreferences'; import { Ionicons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; @@ -29,21 +31,24 @@ export default function NotificationSettingsScreen() { const [medicationReminderEnabled, setMedicationReminderEnabledState] = useState(false); const [nutritionReminderEnabled, setNutritionReminderEnabledState] = useState(false); const [moodReminderEnabled, setMoodReminderEnabledState] = useState(false); + const [hrvReminderEnabled, setHrvReminderEnabledState] = useState(false); const [isLoading, setIsLoading] = useState(true); // 加载通知设置 const loadNotificationSettings = useCallback(async () => { try { - const [notification, medicationReminder, nutritionReminder, moodReminder] = await Promise.all([ + const [notification, medicationReminder, nutritionReminder, moodReminder, hrvReminder] = await Promise.all([ getNotificationEnabled(), getMedicationReminderEnabled(), getNutritionReminderEnabled(), getMoodReminderEnabled(), + getHRVReminderEnabled(), ]); setNotificationEnabledState(notification); setMedicationReminderEnabledState(medicationReminder); setNutritionReminderEnabledState(nutritionReminder); setMoodReminderEnabledState(moodReminder); + setHrvReminderEnabledState(hrvReminder); } catch (error) { console.error('Failed to load notification settings:', error); } finally { @@ -103,6 +108,8 @@ export default function NotificationSettingsScreen() { setNutritionReminderEnabledState(false); await setMoodReminderEnabled(false); setMoodReminderEnabledState(false); + await setHRVReminderEnabled(false); + setHrvReminderEnabledState(false); } catch (error) { console.error('Failed to disable push notifications:', error); Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.saveFailed')); @@ -173,6 +180,26 @@ export default function NotificationSettingsScreen() { } }; + // 处理 HRV 通知提醒开关变化 + const handleHrvReminderToggle = async (value: boolean) => { + try { + await setHRVReminderEnabled(value); + setHrvReminderEnabledState(value); + + if (value) { + await sendNotification({ + title: t('notificationSettings.alerts.hrvReminderEnabled.title'), + body: t('notificationSettings.alerts.hrvReminderEnabled.body'), + sound: true, + priority: 'high', + }); + } + } catch (error) { + console.error('Failed to set HRV reminder:', error); + Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.hrvReminderFailed')); + } + }; + // 渲染设置项 const renderSettingItem = ( icon: keyof typeof Ionicons.glyphMap, @@ -297,6 +324,16 @@ export default function NotificationSettingsScreen() { true )} + {renderSettingItem( + 'pulse-outline', + t('notificationSettings.items.hrvReminder.title'), + t('notificationSettings.items.hrvReminder.description'), + hrvReminderEnabled, + handleHrvReminderToggle, + !notificationEnabled, + true + )} + {renderSettingItem( 'happy-outline', t('notificationSettings.items.moodReminder.title'), @@ -432,4 +469,4 @@ const styles = StyleSheet.create({ height: 1, backgroundColor: '#F0F0F0', }, -}); \ No newline at end of file +}); diff --git a/components/workout/WorkoutDetailModal.tsx b/components/workout/WorkoutDetailModal.tsx index 7887d05..3fb1c76 100644 --- a/components/workout/WorkoutDetailModal.tsx +++ b/components/workout/WorkoutDetailModal.tsx @@ -1,20 +1,25 @@ -import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'; import dayjs from 'dayjs'; import 'dayjs/locale/en'; import 'dayjs/locale/zh-cn'; +import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { LinearGradient } from 'expo-linear-gradient'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Dimensions, Modal, + Platform, + Pressable, ScrollView, + Share, StyleSheet, Text, TouchableOpacity, TouchableWithoutFeedback, View, } from 'react-native'; +import ViewShot from 'react-native-view-shot'; import { useI18n } from '@/hooks/useI18n'; import { @@ -26,6 +31,7 @@ import { WorkoutActivityType, WorkoutData, } from '@/utils/health'; +import { Toast } from '@/utils/toast.utils'; export interface IntensityBadge { label: string; @@ -65,6 +71,8 @@ export function WorkoutDetailModal({ const [isMounted, setIsMounted] = useState(visible); const [shouldRenderChart, setShouldRenderChart] = useState(visible); const [showIntensityInfo, setShowIntensityInfo] = useState(false); + const [sharing, setSharing] = useState(false); + const shareContentRef = useRef(null); const locale = useMemo(() => (i18n.language?.startsWith('en') ? 'en' : 'zh-cn'), [i18n.language]); @@ -138,6 +146,49 @@ export function WorkoutDetailModal({ } }; + const handleShare = useCallback(async () => { + if (!shareContentRef.current || !workout || sharing) { + return; + } + setSharing(true); + try { + Toast.show({ + type: 'info', + text1: t('workoutDetail.share.generating', '正在生成分享卡片…'), + }); + const uri = await shareContentRef.current.capture?.({ + format: 'png', + quality: 0.95, + snapshotContentContainer: true, + }); + if (!uri) { + throw new Error('share-capture-failed'); + } + const shareTitle = t('workoutDetail.share.title', { defaultValue: activityName || t('workoutDetail.title', '锻炼详情') }); + const caloriesLabel = metrics?.calories != null + ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` + : '--'; + const shareMessage = t('workoutDetail.share.message', { + activity: activityName || t('workoutDetail.share.activityFallback', '锻炼'), + duration: metrics?.durationLabel ?? '--', + calories: caloriesLabel, + date: dateInfo.subtitle, + defaultValue: `我的${activityName || '锻炼'}:${dateInfo.subtitle},持续${metrics?.durationLabel ?? '--'},消耗${caloriesLabel}`, + }); + + await Share.share({ + title: shareTitle, + message: shareMessage, + url: Platform.OS === 'ios' ? uri : `file://${uri}`, + }); + } catch (error) { + console.warn('workout-detail-share-failed', error); + Toast.error(t('workoutDetail.share.failed', '分享失败,请稍后再试')); + } finally { + setSharing(false); + } + }, [activityName, dateInfo.subtitle, metrics?.calories, metrics?.durationLabel, sharing, t, workout]); + if (!isMounted) { return null; } @@ -176,7 +227,48 @@ export function WorkoutDetailModal({ {dateInfo.subtitle} - + {isLiquidGlassAvailable() ? ( + [ + styles.headerIconButton, + styles.glassButtonWrapper, + pressed && styles.headerIconPressed, + (loading || sharing || !workout) && styles.headerIconDisabled, + ]} + accessibilityRole="button" + accessibilityLabel={t('workoutDetail.share.accessibilityLabel', '分享锻炼记录')} + > + + + + + + + ) : ( + [ + styles.headerIconButton, + styles.headerIconFallback, + pressed && styles.headerIconPressed, + (loading || sharing || !workout) && styles.headerIconDisabled, + ]} + accessibilityRole="button" + accessibilityLabel={t('workoutDetail.share.accessibilityLabel', '分享锻炼记录')} + > + + + + + )} @@ -390,6 +482,238 @@ export function WorkoutDetailModal({ + + {/* Hidden share capture renders full content height for complete screenshots */} + + + + + + + + + {dateInfo.title} + {dateInfo.subtitle} + + + + + + + + + + {activityName} + {intensityBadge ? ( + + + {intensityBadge.label} + + + ) : null} + + + {dayjs(workout?.startDate || workout?.endDate) + .locale(locale) + .format(locale === 'en' ? 'dddd, MMM D, YYYY HH:mm' : 'YYYY年M月D日 dddd HH:mm')} + + + {loading ? ( + + + {t('workoutDetail.loading')} + + ) : metrics ? ( + <> + + + {t('workoutDetail.metrics.duration')} + {metrics.durationLabel} + + + {t('workoutDetail.metrics.calories')} + + {metrics.calories != null ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` : '--'} + + + + + + + {t('workoutDetail.metrics.intensity')} + + + + + + {formatMetsValue(metrics.mets)} + + + + {t('workoutDetail.metrics.averageHeartRate')} + + {metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.metrics.heartRateUnit')}` : '--'} + + + + {monthOccurrenceText ? ( + {monthOccurrenceText} + ) : null} + + ) : ( + + + {errorMessage || t('workoutDetail.errors.loadFailed')} + + {onRetry ? ( + + {t('workoutDetail.retry')} + + ) : null} + + )} + + + + + {t('workoutDetail.sections.heartRateRange')} + + + {loading ? ( + + + + ) : metrics ? ( + <> + + + {t('workoutDetail.sections.averageHeartRate')} + + {metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'} + + + + {t('workoutDetail.sections.maximumHeartRate')} + + {metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'} + + + + {t('workoutDetail.sections.minimumHeartRate')} + + {metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'} + + + + + {heartRateChart ? ( + LineChart ? ( + + {shouldRenderChart ? ( + /* @ts-ignore - react-native-chart-kit types are outdated */ + '#5C55FF', + strokeWidth: 2, + }, + ], + }} + width={chartWidth} + height={220} + fromZero={false} + yAxisSuffix={t('workoutDetail.sections.heartRateUnit')} + withInnerLines={false} + bezier + paddingRight={48} + chartConfig={{ + backgroundColor: '#FFFFFF', + backgroundGradientFrom: '#FFFFFF', + backgroundGradientTo: '#FFFFFF', + decimalPlaces: 0, + color: (opacity = 1) => `rgba(92, 85, 255, ${opacity})`, + labelColor: (opacity = 1) => `rgba(98, 105, 138, ${opacity})`, + propsForDots: { + r: '3', + strokeWidth: '2', + stroke: '#FFFFFF', + }, + fillShadowGradientFromOpacity: 0.1, + fillShadowGradientToOpacity: 0.02, + }} + style={styles.chartStyle} + /> + ) : ( + + + {t('workoutDetail.loading')} + + )} + + ) : ( + + + {t('workoutDetail.chart.unavailable')} + + ) + ) : ( + + + {t('workoutDetail.chart.noData')} + + )} + + ) : ( + + + {errorMessage || t('workoutDetail.errors.noHeartRateData')} + + + )} + + + + + {t('workoutDetail.sections.heartRateZones')} + + + {loading ? ( + + + + ) : metrics ? ( + metrics.heartRateZones.map((zone) => renderHeartRateZone(zone, t)) + ) : ( + {t('workoutDetail.errors.noZoneStats')} + )} + + + + + + {showIntensityInfo ? ( CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.1.4 + 1.1.5 CFBundleSignature ???? CFBundleURLTypes diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 6e11344..d113283 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2745,52 +2745,52 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - EASClient: de38c20c1dd9af7ff435afdc8b2c9b7c20d46767 - EXApplication: a9d1c46d473d36f61302a9a81db2379441f3f094 - EXConstants: e6e50cdfcb4524f40121d1fdcff24e97b7dcd2fd - EXImageLoader: e501c001bc40b8326605e82e6e80363c80fe06b5 + EASClient: 68127f1248d2b25fdc82dbbfb17be95d1c4700be + EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7 + EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3 + EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05 EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd - EXManifests: eaf327418721c4a7482576c579d215516d36319c - EXNotifications: 5edf06b6b41a52d8b5b3eccf0e951dc8a880a50b - Expo: a429ae95cbae859785ece75d42ff768d6176099f - ExpoAppleAuthentication: 414e4316f8e25a2afbc3943cf725579c910f24b8 - ExpoAsset: ee515c16290e521de1870dcdee66d78173fbc533 - ExpoBackgroundTask: e0efb5999d30593cbc0a2c443db70cc9310ec51c - ExpoBlur: b5b7a26572b3c33a11f0b2aa2f95c17c4c393b76 - ExpoCamera: d1894aad960492f4881c1f83967167963365d937 - ExpoClipboard: 99109306a2d9ed2fbd16f6b856e6267b2afa8472 - ExpoDocumentPicker: dbe4eeeb771891209fee044a6b0bafbd1fdcc4fa - ExpoFileSystem: 73a9f3f2e0affc61eba5b9326153f64870438af1 - ExpoFont: b881d43057dceb7b31ff767b24f612609e80f60f - ExpoGlassEffect: 3d147d753d3bfe1a5d6b7920560e206e3e98c19e - ExpoHaptics: b48d913e7e5f23816c6f130e525c9a6501b160b5 - ExpoHead: 4ad178d6b19c2bf786b0d1889a870ba3187c18ef - ExpoImage: 6eb842cd07817402640545c41884dd7f5fbfbca5 - ExpoImagePicker: bd0a5c81d7734548f6908a480609257e85d19ea8 - ExpoKeepAwake: 3f5e3ac53627849174f3603271df8e08f174ed4a - ExpoLinearGradient: f9e7182e5253d53b2de4134b69d70bbfc2d50588 - ExpoLinking: 50a65cd7beb6051ffc82f84ffb33961dd5e55a5b - ExpoLocalization: 6c6f0f89ad2822001ab0bc2eb6d4d980c77f080c - ExpoMediaLibrary: 648cee3f5dcba13410ec9cc8ac9a426e89a61a31 - ExpoModulesCore: bf3cd6f1f564c47031bcb1ee61b6aed1e0909eed - ExpoQuickActions: 62b9db8a20618be1cc19efa3b562ac963c803d58 - ExpoSplashScreen: f46795cd52cdad65d30e54043f04c86401c4f85d - ExpoSQLite: 36b8723223f322148606473507da446b1912500c - ExpoSymbols: ef7b8ac77ac2d496b1bc3f0f7daf5e19c3a9933a - ExpoSystemUI: 9441d46a8efbf9224d1b2e6b18042452ffd0ed79 - ExpoUI: 821b058da921ea4aa6172b36d080991ea6fb2fae - ExpoWebBrowser: 35223474c19e261c0661ecc7caa513d054244ab5 + EXManifests: 26e15640538c3d5ef028077ebcaf004b744d4932 + EXNotifications: a62e1f8e3edd258dc3b155d3caa49f32920f1c6c + Expo: 7af24402df45b9384900104e88a11896ffc48161 + ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e + ExpoAsset: d839c8eae8124470332408427327e8f88beb2dfd + ExpoBackgroundTask: c498ce99a10f125d8370a5b2f4405e2583a3c896 + ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f + ExpoCamera: 2a87c210f8955350ea5c70f1d539520b2fc5d940 + ExpoClipboard: af650d14765f19c60ce2a1eaf9dfe6445eff7365 + ExpoDocumentPicker: 2200eefc2817f19315fa18f0147e0b80ece86926 + ExpoFileSystem: 77157a101e03150a4ea4f854b4dd44883c93ae0a + ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961 + ExpoGlassEffect: 265fa3d75b46bc58262e4dfa513135fa9dfe4aac + ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84 + ExpoHead: fc0185d5c2a51ea599aff223aba5d61782301044 + ExpoImage: 9c3428921c536ab29e5c6721d001ad5c1f469566 + ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c + ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe + ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27 + ExpoLinking: 77455aa013e9b6a3601de03ecfab09858ee1b031 + ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca + ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe + ExpoModulesCore: bdc95c6daa1639e235a16350134152a0b28e5c72 + ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f + ExpoSplashScreen: 268b2f128dc04284c21010540a6c4dd9f95003e3 + ExpoSQLite: b312b02c8b77ab55951396e6cd13992f8db9215f + ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68 + ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7 + ExpoUI: b99a1d1ef5352a60bebf4f4fd3a50d2f896ae804 + ExpoWebBrowser: b973e1351fdcf5fec0c400997b1851f5a8219ec3 EXStructuredHeaders: c951e77f2d936f88637421e9588c976da5827368 - EXTaskManager: 53f87ed11659341c3f3f02c0041498ef293f5684 - EXUpdates: 04853b77f4a405c5b85039b9a2ccf1c71438028c - EXUpdatesInterface: 1436757deb0d574b84bba063bd024c315e0ec08b + EXTaskManager: cbbb80cbccea6487ccca0631809fbba2ed3e5271 + EXUpdates: 9042dc213f17593a02d59ef7dd9d297edf621936 + EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 lottie-ios: a881093fab623c467d3bce374367755c272bdd59 - lottie-react-native: 97a11537edc72d0763edab0c83e8cc8a0b9d8484 + lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c PurchasesHybridCommon: 71c94158ff8985657d37d5f3be05602881227619 RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043 @@ -2798,83 +2798,83 @@ SPEC CHECKSUMS: ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda React: 914f8695f9bf38e6418228c2ffb70021e559f92f React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71 - React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90 - React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151 - React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac - React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c + React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5 + React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599 + React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382 + React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da React-debug: 6328c2228e268846161f10082e80dc69eac2e90a - React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c - React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d - React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983 - React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4 - React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86 - React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d - React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28 - React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24 - React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf - React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc - React-ImageManager: 396128004783fc510e629124dce682d38d1088e7 - React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795 - React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0 - React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669 - React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51 - React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0 - React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb - React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270 - React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca - React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a - React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd - React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108 - React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c - react-native-render-html: 984dfe2294163d04bf5fe25d7c9f122e60e05ebe - react-native-safe-area-context: 53f796cb6c814661bbe99fbdfd0585d07b996cdd - react-native-view-shot: 26174e54ec6b4b7c5d70b86964b747919759adc1 - react-native-voice: f5e8eec2278451d0017eb6a30a6ccc726aca34e0 - react-native-webview: a4f0775a31b73cf13cfc3d2d2b119aa94ec76e49 - React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd + React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833 + React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66 + React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2 + React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a + React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8 + React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171 + React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f + React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6 + React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b + React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76 + React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e + React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d + React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de + React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3 + React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f + React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89 + React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f + React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e + React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a + React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf + React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28 + React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e + React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b + react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd + react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2 + react-native-view-shot: fb3c0774edb448f42705491802a455beac1502a2 + react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c + react-native-webview: b29007f4723bca10872028067b07abacfa1cb35a + React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c - React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f - React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490 + React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4 + React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526 React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916 - React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af - React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28 - React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264 - React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6 - React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266 - React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac - React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31 - React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000 - React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b - React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a - React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461 - React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e + React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e + React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016 + React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53 + React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea + React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797 + React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32 + React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b + React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20 + React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418 + React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035 + React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b + React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25 React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d - React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490 - React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812 - React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613 - React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4 - React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c - React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562 - React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7 - React-timing: 03c7217455d2bff459b27a3811be25796b600f47 - React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a - ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee - ReactCodegen: 4c44b74b77fc41ae25b9e2c7e9bd6e2bc772c23f - ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a + React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453 + React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89 + React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4 + React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0 + React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e + React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6 + React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f + React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801 + React-utils: 6e2035b53d087927768649a11a26c4e092448e34 + ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79 + ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc + ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8 ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a RevenueCat: d185cbff8be9425b5835042afd6889389bb756c8 - RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4 - RNCMaskedView: 3c9d7586e2b9bbab573591dcb823918bc4668005 - RNCPicker: e0149590451d5eae242cf686014a6f6d808f93c7 - RNDateTimePicker: 5e0a759109b63ebc661a4714712361d2d07142fe - RNDeviceInfo: 8b6fa8379062949dd79a009cf3d6b02a9c03ca59 - RNGestureHandler: 6a488ce85c88e82d8610db1108daf04e9b2d5162 - RNPurchases: 8a21795ad71f932dca9f48e9560924a3c64878c6 - RNReanimated: 43f611f1c85c90e0273df7399bf1536f8e2bd125 - RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51 - RNSentry: 54f8041cd06d7ccf484171edde72f1b07323fb2e - RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528 - RNWorklets: e2b9423934700160135e216d8a2e6cb7b194846c + RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4 + RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035 + RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca + RNDateTimePicker: 19ffa303c4524ec0a2dfdee2658198451c16b7f1 + RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c + RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3 + RNPurchases: 34da99c0e14ee484ed57e77dc06dcfe8e7cb1cee + RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4 + RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845 + RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e + RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34 + RNWorklets: 9eb6d567fa43984e96b6924a6df504b8a15980cd SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c diff --git a/services/hrvMonitor.ts b/services/hrvMonitor.ts index 03f319f..8580ddc 100644 --- a/services/hrvMonitor.ts +++ b/services/hrvMonitor.ts @@ -2,6 +2,7 @@ import { analyzeHRVData, fetchHRVWithStatus } from '@/utils/health'; import AsyncStorage from '@/utils/kvStore'; import { logger } from '@/utils/logger'; import { convertHrvToStressIndex, getStressLevelInfo, StressLevel } from '@/utils/stress'; +import { getHRVReminderEnabled, getNotificationEnabled } from '@/utils/userPreferences'; import { NativeEventEmitter, NativeModules } from 'react-native'; import { sendHRVStressNotification } from './hrvNotificationService'; @@ -88,6 +89,16 @@ class HRVMonitorService { this.lastProcessedTime = now; try { + const [notificationsEnabled, hrvReminderEnabled] = await Promise.all([ + getNotificationEnabled(), + getHRVReminderEnabled(), + ]); + + if (!notificationsEnabled || !hrvReminderEnabled) { + logger.info('[HRVMonitor] Notification preference disabled, skip HRV push'); + return; + } + const canNotify = await this.canSendNotification(now); if (!canNotify) { logger.info('[HRVMonitor] Notification recently sent, skipping'); diff --git a/utils/userPreferences.ts b/utils/userPreferences.ts index 6937e31..5f5f23f 100644 --- a/utils/userPreferences.ts +++ b/utils/userPreferences.ts @@ -14,6 +14,7 @@ const PREFERENCES_KEYS = { MEDICATION_REMINDER_ENABLED: 'user_preference_medication_reminder_enabled', NUTRITION_REMINDER_ENABLED: 'user_preference_nutrition_reminder_enabled', MOOD_REMINDER_ENABLED: 'user_preference_mood_reminder_enabled', + HRV_REMINDER_ENABLED: 'user_preference_hrv_reminder_enabled', } as const; // 用户偏好设置接口 @@ -30,6 +31,7 @@ export interface UserPreferences { medicationReminderEnabled: boolean; nutritionReminderEnabled: boolean; moodReminderEnabled: boolean; + hrvReminderEnabled: boolean; } // 默认的用户偏好设置 @@ -46,6 +48,7 @@ const DEFAULT_PREFERENCES: UserPreferences = { medicationReminderEnabled: true, // 默认开启药品提醒 nutritionReminderEnabled: true, // 默认开启营养提醒 moodReminderEnabled: true, // 默认开启心情提醒 + hrvReminderEnabled: true, // 默认开启 HRV 压力提醒 }; /** @@ -65,6 +68,7 @@ export const getUserPreferences = async (): Promise => { const medicationReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED); const nutritionReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.NUTRITION_REMINDER_ENABLED); const moodReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MOOD_REMINDER_ENABLED); + const hrvReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.HRV_REMINDER_ENABLED); return { quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount, @@ -79,6 +83,7 @@ export const getUserPreferences = async (): Promise => { medicationReminderEnabled: medicationReminderEnabled !== null ? medicationReminderEnabled === 'true' : DEFAULT_PREFERENCES.medicationReminderEnabled, nutritionReminderEnabled: nutritionReminderEnabled !== null ? nutritionReminderEnabled === 'true' : DEFAULT_PREFERENCES.nutritionReminderEnabled, moodReminderEnabled: moodReminderEnabled !== null ? moodReminderEnabled === 'true' : DEFAULT_PREFERENCES.moodReminderEnabled, + hrvReminderEnabled: hrvReminderEnabled !== null ? hrvReminderEnabled === 'true' : DEFAULT_PREFERENCES.hrvReminderEnabled, }; } catch (error) { console.error('获取用户偏好设置失败:', error); @@ -393,6 +398,7 @@ export const resetUserPreferences = async (): Promise => { await AsyncStorage.removeItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED); await AsyncStorage.removeItem(PREFERENCES_KEYS.NUTRITION_REMINDER_ENABLED); await AsyncStorage.removeItem(PREFERENCES_KEYS.MOOD_REMINDER_ENABLED); + await AsyncStorage.removeItem(PREFERENCES_KEYS.HRV_REMINDER_ENABLED); } catch (error) { console.error('重置用户偏好设置失败:', error); throw error; @@ -475,4 +481,23 @@ export const getMoodReminderEnabled = async (): Promise => { console.error('获取心情提醒开关状态失败:', error); return DEFAULT_PREFERENCES.moodReminderEnabled; } -}; \ No newline at end of file +}; + +export const setHRVReminderEnabled = async (enabled: boolean): Promise => { + try { + await AsyncStorage.setItem(PREFERENCES_KEYS.HRV_REMINDER_ENABLED, enabled.toString()); + } catch (error) { + console.error('设置 HRV 提醒开关失败:', error); + throw error; + } +}; + +export const getHRVReminderEnabled = async (): Promise => { + try { + const enabled = await AsyncStorage.getItem(PREFERENCES_KEYS.HRV_REMINDER_ENABLED); + return enabled !== null ? enabled === 'true' : DEFAULT_PREFERENCES.hrvReminderEnabled; + } catch (error) { + console.error('获取 HRV 提醒开关状态失败:', error); + return DEFAULT_PREFERENCES.hrvReminderEnabled; + } +};