feat(app): 新增HRV压力提醒设置与锻炼记录分享功能

- 通知设置页面新增 HRV 压力提醒开关,支持自定义开启或关闭压力监测推送
- 锻炼详情页集成分享功能,支持将运动数据生成精美长图并分享
- 优化 HRV 监测服务逻辑,在发送通知前检查用户偏好设置
- 更新多语言配置文件,添加相关文案翻译
- 将应用版本号更新至 1.1.5
This commit is contained in:
richarjiang
2025-12-16 11:27:11 +08:00
parent 409f125db1
commit 5e11da34ee
9 changed files with 569 additions and 122 deletions

View File

@@ -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<ViewShot | null>(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({
<Text style={styles.headerSubtitle}>{dateInfo.subtitle}</Text>
</View>
<View style={styles.headerSpacer} />
{isLiquidGlassAvailable() ? (
<Pressable
onPress={handleShare}
disabled={loading || sharing || !workout}
style={({ pressed }) => [
styles.headerIconButton,
styles.glassButtonWrapper,
pressed && styles.headerIconPressed,
(loading || sharing || !workout) && styles.headerIconDisabled,
]}
accessibilityRole="button"
accessibilityLabel={t('workoutDetail.share.accessibilityLabel', '分享锻炼记录')}
>
<GlassView glassEffectStyle="regular" tintColor="rgba(255,255,255,0.9)" isInteractive style={styles.glassButton}>
<View style={styles.glassButtonInner}>
<Ionicons name="share-outline" size={20} color="#1E2148" />
</View>
</GlassView>
</Pressable>
) : (
<Pressable
onPress={handleShare}
disabled={loading || sharing || !workout}
style={({ pressed }) => [
styles.headerIconButton,
styles.headerIconFallback,
pressed && styles.headerIconPressed,
(loading || sharing || !workout) && styles.headerIconDisabled,
]}
accessibilityRole="button"
accessibilityLabel={t('workoutDetail.share.accessibilityLabel', '分享锻炼记录')}
>
<LinearGradient
colors={['#EEF2FF', '#E0E7FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.glassButtonInner}
>
<Ionicons name="share-outline" size={20} color="#1E2148" />
</LinearGradient>
</Pressable>
)}
</View>
<View style={styles.heroIconWrapper}>
@@ -390,6 +482,238 @@ export function WorkoutDetailModal({
<View style={styles.homeIndicatorSpacer} />
</ScrollView>
</View>
{/* Hidden share capture renders full content height for complete screenshots */}
<ViewShot
ref={shareContentRef}
style={[styles.sheetContainer, styles.shareCaptureContainer]}
collapsable={false}
options={{ format: 'png', quality: 0.95, snapshotContentContainer: true }}
>
<LinearGradient
colors={['#FFFFFF', '#F3F5FF']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.gradientBackground}
/>
<View style={styles.handleWrapper}>
<View style={styles.handle} />
</View>
<View style={styles.headerRow}>
<View style={styles.headerIconButton} />
<View style={styles.headerTitleWrapper}>
<Text style={styles.headerTitle}>{dateInfo.title}</Text>
<Text style={styles.headerSubtitle}>{dateInfo.subtitle}</Text>
</View>
<View style={styles.headerIconButton} />
</View>
<View style={styles.heroIconWrapper}>
<MaterialCommunityIcons name="run" size={160} color="#E8EAFE" />
</View>
<ScrollView
bounces={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.contentContainer}
>
<View style={[styles.summaryCard, loading ? styles.summaryCardLoading : null]}>
<View style={styles.summaryHeader}>
<Text style={styles.activityName}>{activityName}</Text>
{intensityBadge ? (
<View
style={[
styles.intensityPill,
{ backgroundColor: intensityBadge.background },
]}
>
<Text style={[styles.intensityPillText, { color: intensityBadge.color }]}>
{intensityBadge.label}
</Text>
</View>
) : null}
</View>
<Text style={styles.summarySubtitle}>
{dayjs(workout?.startDate || workout?.endDate)
.locale(locale)
.format(locale === 'en' ? 'dddd, MMM D, YYYY HH:mm' : 'YYYY年M月D日 dddd HH:mm')}
</Text>
{loading ? (
<View style={styles.loadingBlock}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.loadingLabel}>{t('workoutDetail.loading')}</Text>
</View>
) : metrics ? (
<>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.duration')}</Text>
<Text style={styles.metricValue}>{metrics.durationLabel}</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.calories')}</Text>
<Text style={styles.metricValue}>
{metrics.calories != null ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` : '--'}
</Text>
</View>
</View>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<View style={styles.metricTitleRow}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.intensity')}</Text>
<View style={styles.metricInfoButton}>
<MaterialCommunityIcons name="information-outline" size={16} color="#7780AA" />
</View>
</View>
<Text style={styles.metricValue}>
{formatMetsValue(metrics.mets)}
</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.averageHeartRate')}</Text>
<Text style={styles.metricValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.metrics.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
{monthOccurrenceText ? (
<Text style={styles.monthOccurrenceText}>{monthOccurrenceText}</Text>
) : null}
</>
) : (
<View style={styles.errorBlock}>
<Text style={styles.errorText}>
{errorMessage || t('workoutDetail.errors.loadFailed')}
</Text>
{onRetry ? (
<View style={[styles.retryButton, styles.retryButtonDisabled]}>
<Text style={styles.retryButtonText}>{t('workoutDetail.retry')}</Text>
</View>
) : null}
</View>
)}
</View>
<View style={[styles.section, loading ? styles.sectionHeartRateLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateRange')}</Text>
</View>
{loading ? (
<View style={styles.sectionLoading}>
<ActivityIndicator color="#5C55FF" />
</View>
) : metrics ? (
<>
<View style={styles.heartRateSummaryRow}>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}>{t('workoutDetail.sections.averageHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}>{t('workoutDetail.sections.maximumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}>{t('workoutDetail.sections.minimumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
{heartRateChart ? (
LineChart ? (
<View style={styles.chartWrapper}>
{shouldRenderChart ? (
/* @ts-ignore - react-native-chart-kit types are outdated */
<LineChart
data={{
labels: heartRateChart.labels,
datasets: [
{
data: heartRateChart.data,
color: () => '#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}
/>
) : (
<View style={[styles.chartLoading, { width: chartWidth }]}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.chartLoadingText}>{t('workoutDetail.loading')}</Text>
</View>
)}
</View>
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="chart-line-variant" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.unavailable')}</Text>
</View>
)
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="heart-off-outline" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.noData')}</Text>
</View>
)}
</>
) : (
<View style={styles.sectionError}>
<Text style={styles.errorTextSmall}>
{errorMessage || t('workoutDetail.errors.noHeartRateData')}
</Text>
</View>
)}
</View>
<View style={[styles.section, loading ? styles.sectionZonesLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateZones')}</Text>
</View>
{loading ? (
<View style={styles.sectionLoading}>
<ActivityIndicator color="#5C55FF" />
</View>
) : metrics ? (
metrics.heartRateZones.map((zone) => renderHeartRateZone(zone, t))
) : (
<Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</Text>
)}
</View>
<View style={styles.homeIndicatorSpacer} />
</ScrollView>
</ViewShot>
{showIntensityInfo ? (
<Modal
transparent
@@ -634,6 +958,39 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
headerIconFallback: {
overflow: 'hidden',
},
headerIconPressed: {
opacity: 0.75,
},
headerIconDisabled: {
opacity: 0.4,
},
glassButtonWrapper: {
overflow: 'hidden',
},
glassButton: {
borderRadius: 20,
width: '100%',
height: '100%',
},
glassButtonInner: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
borderRadius: 20,
},
shareCaptureContainer: {
position: 'absolute',
top: -9999,
left: 0,
right: 0,
opacity: 0,
zIndex: -1,
},
headerTitleWrapper: {
flex: 1,
alignItems: 'center',
@@ -761,6 +1118,9 @@ const styles = StyleSheet.create({
backgroundColor: '#5C55FF',
borderRadius: 16,
},
retryButtonDisabled: {
opacity: 0.4,
},
retryButtonText: {
color: '#FFFFFF',
fontWeight: '600',
@@ -970,8 +1330,4 @@ const styles = StyleSheet.create({
intensityHigh: {
color: '#FF6767',
},
headerSpacer: {
width: 40,
height: 40,
},
});