feat(app): 新增HRV压力提醒设置与锻炼记录分享功能
- 通知设置页面新增 HRV 压力提醒开关,支持自定义开启或关闭压力监测推送 - 锻炼详情页集成分享功能,支持将运动数据生成精美长图并分享 - 优化 HRV 监测服务逻辑,在发送通知前检查用户偏好设置 - 更新多语言配置文件,添加相关文案翻译 - 将应用版本号更新至 1.1.5
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user