feat: 更新隐私同意弹窗和应用名称

- 将应用名称修改为“每日普拉提”,提升品牌识别度
- 新增隐私同意弹窗,确保用户在使用应用前同意隐私政策
- 更新 Redux 状态管理,添加隐私同意状态的处理
- 优化用户信息页面,确保体重和身高的格式化显示
- 更新今日训练页面标题为“快速训练”,提升用户体验
- 添加开发工具函数,便于测试隐私同意功能
This commit is contained in:
2025-08-15 20:44:06 +08:00
parent 6b6c4fdbad
commit 97e89b9bf0
15 changed files with 326 additions and 26 deletions

View File

@@ -1,6 +1,6 @@
{ {
"expo": { "expo": {
"name": "digital-pilates", "name": "普拉提助手",
"slug": "digital-pilates", "slug": "digital-pilates",
"version": "1.0.3", "version": "1.0.3",
"orientation": "portrait", "orientation": "portrait",

View File

@@ -29,6 +29,7 @@ export default function ExploreScreen() {
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000; const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
const userProfile = useAppSelector((s) => s.user.profile); const userProfile = useAppSelector((s) => s.user.profile);
// 使用 dayjs当月日期与默认选中“今天” // 使用 dayjs当月日期与默认选中“今天”
const days = getMonthDaysZh(); const days = getMonthDaysZh();
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
@@ -163,16 +164,6 @@ export default function ExploreScreen() {
})} })}
</ScrollView> </ScrollView>
{/* 打卡入口 */}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 24, marginBottom: 8 }}>
<Text style={styles.sectionTitle}></Text>
<TouchableOpacity onPress={() => router.push('/checkin/calendar')} accessibilityRole="button">
<Text style={{ color: '#6B7280', fontWeight: '700' }}></Text>
</TouchableOpacity>
</View>
{/* 取消卡片内 loading保持静默刷新提升体验 */}
{/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */} {/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */}
<View style={styles.metricsRow}> <View style={styles.metricsRow}>
<View style={[styles.trainingCard, styles.metricsLeft]}> <View style={[styles.trainingCard, styles.metricsLeft]}>
@@ -230,8 +221,8 @@ export default function ExploreScreen() {
{/* BMI 指数卡片 */} {/* BMI 指数卡片 */}
<BMICard <BMICard
weight={userProfile?.weight} weight={userProfile?.weight ? parseFloat(userProfile.weight) : undefined}
height={userProfile?.height} height={userProfile?.height ? parseFloat(userProfile.height) : undefined}
/> />
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>

View File

@@ -301,7 +301,10 @@ export default function HomeScreen() {
> >
<View style={styles.featureIconWrapper}> <View style={styles.featureIconWrapper}>
<View style={styles.featureIconPlaceholder}> <View style={styles.featureIconPlaceholder}>
<ThemedText style={styles.featureIconText}>💪</ThemedText> <Image
source={require('@/assets/images/icons/iconPlan.png')}
style={styles.featureIconImage}
/>
</View> </View>
</View> </View>
<ThemedText style={styles.featureTitle}></ThemedText> <ThemedText style={styles.featureTitle}></ThemedText>

View File

@@ -45,12 +45,12 @@ export default function PersonalScreen() {
// 数据格式化函数 // 数据格式化函数
const formatHeight = () => { const formatHeight = () => {
if (userProfile.height == null) return '--'; if (userProfile.height == null) return '--';
return `${Math.round(userProfile.height)}cm`; return `${parseFloat(userProfile.height).toFixed(1)}cm`;
}; };
const formatWeight = () => { const formatWeight = () => {
if (userProfile.weight == null) return '--'; if (userProfile.weight == null) return '--';
return `${Math.round(userProfile.weight * 10) / 10}kg`; return `${parseFloat(userProfile.weight).toFixed(1)}kg`;
}; };
const formatAge = () => { const formatAge = () => {

View File

@@ -4,22 +4,60 @@ import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated'; import 'react-native-reanimated';
import { useAppDispatch } from '@/hooks/redux'; import PrivacyConsentModal from '@/components/PrivacyConsentModal';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { clearAiCoachSessionCache } from '@/services/aiCoachSession'; import { clearAiCoachSessionCache } from '@/services/aiCoachSession';
import { store } from '@/store'; import { store } from '@/store';
import { rehydrateUser } from '@/store/userSlice'; import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice';
import React from 'react'; import React from 'react';
import RNExitApp from 'react-native-exit-app';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
function Bootstrapper({ children }: { children: React.ReactNode }) { function Bootstrapper({ children }: { children: React.ReactNode }) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { privacyAgreed } = useAppSelector((state) => state.user);
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
const [userDataLoaded, setUserDataLoaded] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
dispatch(rehydrateUser()); const loadUserData = async () => {
await dispatch(rehydrateUser());
setUserDataLoaded(true);
};
loadUserData();
// 冷启动时清空 AI 教练会话缓存 // 冷启动时清空 AI 教练会话缓存
clearAiCoachSessionCache(); clearAiCoachSessionCache();
}, [dispatch]); }, [dispatch]);
return <>{children}</>;
React.useEffect(() => {
// 当用户数据加载完成后,检查是否需要显示隐私同意弹窗
if (userDataLoaded && !privacyAgreed) {
setShowPrivacyModal(true);
}
}, [userDataLoaded, privacyAgreed]);
const handlePrivacyAgree = () => {
dispatch(setPrivacyAgreed());
setShowPrivacyModal(false);
};
const handlePrivacyDisagree = () => {
RNExitApp.exitApp();
};
return (
<>
{children}
<PrivacyConsentModal
visible={showPrivacyModal}
onAgree={handlePrivacyAgree}
onDisagree={handlePrivacyDisagree}
/>
</>
);
} }
export default function RootLayout() { export default function RootLayout() {

View File

@@ -427,7 +427,7 @@ export default function TodayWorkoutScreen() {
<SafeAreaView style={styles.contentWrapper}> <SafeAreaView style={styles.contentWrapper}>
<HeaderBar <HeaderBar
title="今日训练" title="快速训练"
onBack={() => router.back()} onBack={() => router.back()}
withSafeTop={false} withSafeTop={false}
transparent={true} transparent={true}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -0,0 +1,191 @@
import { router } from 'expo-router';
import React from 'react';
import {
Dimensions,
Modal,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
const { width } = Dimensions.get('window');
interface PrivacyConsentModalProps {
visible: boolean;
onAgree: () => void;
onDisagree: () => void;
}
export default function PrivacyConsentModal({
visible,
onAgree,
onDisagree,
}: PrivacyConsentModalProps) {
const handleUserAgreementPress = () => {
router.push('/legal/user-agreement');
};
const handlePrivacyPolicyPress = () => {
router.push('/legal/privacy-policy');
};
return (
<Modal
visible={visible}
transparent
animationType="fade"
statusBarTranslucent
>
<View style={styles.overlay}>
<View style={styles.container}>
<Text style={styles.title}></Text>
<View style={styles.contentContainer}>
<Text style={styles.description}>
"同意并继续"
</Text>
<View style={styles.linksContainer}>
<TouchableOpacity onPress={handleUserAgreementPress}>
<Text style={styles.link}></Text>
</TouchableOpacity>
<Text style={styles.and}> </Text>
<TouchableOpacity onPress={handlePrivacyPolicyPress}>
<Text style={styles.link}></Text>
</TouchableOpacity>
</View>
<Text style={styles.description}>
</Text>
<View style={styles.viewFullContainer}>
<Text style={styles.viewFull}> </Text>
<TouchableOpacity onPress={handleUserAgreementPress}>
<Text style={styles.link}></Text>
</TouchableOpacity>
<Text style={styles.viewFull}> </Text>
<TouchableOpacity onPress={handlePrivacyPolicyPress}>
<Text style={styles.link}></Text>
</TouchableOpacity>
</View>
</View>
<TouchableOpacity style={styles.agreeButton} onPress={onAgree}>
<Text style={styles.agreeButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.disagreeButton} onPress={onDisagree}>
<Text style={styles.disagreeButtonText}>退</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 20,
},
container: {
backgroundColor: 'white',
borderRadius: 20,
padding: 24,
width: width * 0.85,
maxWidth: 400,
alignItems: 'center',
},
iconContainer: {
marginBottom: 20,
alignItems: 'center',
},
characterContainer: {
position: 'relative',
alignItems: 'center',
},
iconText: {
fontSize: 48,
marginBottom: 8,
},
balloons: {
position: 'absolute',
top: -5,
right: -25,
flexDirection: 'row',
gap: 4,
},
balloon: {
width: 12,
height: 16,
borderRadius: 6,
},
title: {
fontSize: 20,
fontWeight: '600',
color: '#1F2937',
marginBottom: 20,
textAlign: 'center',
},
contentContainer: {
marginBottom: 24,
},
description: {
fontSize: 14,
color: '#6B7280',
lineHeight: 20,
textAlign: 'center',
},
linksContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
flexWrap: 'wrap',
},
link: {
fontSize: 14,
color: '#8B5FE6',
textDecorationLine: 'underline',
},
and: {
fontSize: 14,
color: '#6B7280',
},
viewFullContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
flexWrap: 'wrap',
marginTop: 4,
},
viewFull: {
fontSize: 14,
color: '#6B7280',
},
agreeButton: {
backgroundColor: '#8B5FE6',
borderRadius: 25,
paddingVertical: 14,
paddingHorizontal: 40,
width: '100%',
marginBottom: 12,
},
agreeButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
textAlign: 'center',
},
disagreeButton: {
paddingVertical: 14,
paddingHorizontal: 40,
},
disagreeButtonText: {
color: '#9CA3AF',
fontSize: 16,
fontWeight: '500',
textAlign: 'center',
},
});

View File

@@ -1730,6 +1730,8 @@ PODS:
- Yoga - Yoga
- RNDateTimePicker (8.4.4): - RNDateTimePicker (8.4.4):
- React-Core - React-Core
- RNExitApp (2.0.0):
- React-Core
- RNGestureHandler (2.24.0): - RNGestureHandler (2.24.0):
- DoubleConversion - DoubleConversion
- glog - glog
@@ -2010,6 +2012,7 @@ DEPENDENCIES:
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)" - "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)"
- "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)" - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)"
- RNExitApp (from `../node_modules/react-native-exit-app`)
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNReanimated (from `../node_modules/react-native-reanimated`) - RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`) - RNScreens (from `../node_modules/react-native-screens`)
@@ -2221,6 +2224,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-masked-view/masked-view" :path: "../node_modules/@react-native-masked-view/masked-view"
RNDateTimePicker: RNDateTimePicker:
:path: "../node_modules/@react-native-community/datetimepicker" :path: "../node_modules/@react-native-community/datetimepicker"
RNExitApp:
:path: "../node_modules/react-native-exit-app"
RNGestureHandler: RNGestureHandler:
:path: "../node_modules/react-native-gesture-handler" :path: "../node_modules/react-native-gesture-handler"
RNReanimated: RNReanimated:
@@ -2334,6 +2339,7 @@ SPEC CHECKSUMS:
RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f
RNCMaskedView: d4644e239e65383f96d2f32c40c297f09705ac96 RNCMaskedView: d4644e239e65383f96d2f32c40c297f09705ac96
RNDateTimePicker: 7d93eacf4bdf56350e4b7efd5cfc47639185e10c RNDateTimePicker: 7d93eacf4bdf56350e4b7efd5cfc47639185e10c
RNExitApp: 4432b9b7cc5ccec9f91c94e507849891282befd4
RNGestureHandler: 6e640921d207f070e4bbcf79f4e6d0eabf323389 RNGestureHandler: 6e640921d207f070e4bbcf79f4e6d0eabf323389
RNReanimated: 34e90d19560aebd52a2ad583fdc2de2cf7651bbb RNReanimated: 34e90d19560aebd52a2ad583fdc2de2cf7651bbb
RNScreens: 241cfe8fc82737f3e132dd45779f9512928075b8 RNScreens: 241cfe8fc82737f3e132dd45779f9512928075b8

View File

@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>digital-pilates</string> <string>普拉提助手</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>

7
package-lock.json generated
View File

@@ -38,6 +38,7 @@
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-native": "0.79.5", "react-native": "0.79.5",
"react-native-cos-sdk": "^1.2.1", "react-native-cos-sdk": "^1.2.1",
"react-native-exit-app": "^2.0.0",
"react-native-gesture-handler": "~2.24.0", "react-native-gesture-handler": "~2.24.0",
"react-native-health": "^1.19.0", "react-native-health": "^1.19.0",
"react-native-image-viewing": "^0.2.2", "react-native-image-viewing": "^0.2.2",
@@ -10881,6 +10882,12 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-exit-app": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-native-exit-app/-/react-native-exit-app-2.0.0.tgz",
"integrity": "sha512-vr9e/8jgPcKCBw6qo0QLxfeMiTwExydghbYDqpLZYAGWR+6cbgnhvOxwdYj/JWR7ZtOALrRA4GMGSvU/ayxM7w==",
"license": "MIT"
},
"node_modules/react-native-fit-image": { "node_modules/react-native-fit-image": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://mirrors.tencent.com/npm/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz", "resolved": "https://mirrors.tencent.com/npm/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz",

View File

@@ -41,6 +41,7 @@
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-native": "0.79.5", "react-native": "0.79.5",
"react-native-cos-sdk": "^1.2.1", "react-native-cos-sdk": "^1.2.1",
"react-native-exit-app": "^2.0.0",
"react-native-gesture-handler": "~2.24.0", "react-native-gesture-handler": "~2.24.0",
"react-native-health": "^1.19.0", "react-native-health": "^1.19.0",
"react-native-image-viewing": "^0.2.2", "react-native-image-viewing": "^0.2.2",

View File

@@ -74,6 +74,7 @@ export const api = {
export const STORAGE_KEYS = { export const STORAGE_KEYS = {
authToken: '@auth_token', authToken: '@auth_token',
userProfile: '@user_profile', userProfile: '@user_profile',
privacyAgreed: '@privacy_agreed',
} as const; } as const;
export async function loadPersistedToken(): Promise<string | null> { export async function loadPersistedToken(): Promise<string | null> {

View File

@@ -9,8 +9,8 @@ export type UserProfile = {
email?: string; email?: string;
gender?: Gender; gender?: Gender;
birthDate?: string; birthDate?: string;
weight?: number; weight?: string;
height?: number; height?: string;
avatar?: string | null; avatar?: string | null;
dailyStepsGoal?: number; // 每日步数目标(用于 Explore 页等) dailyStepsGoal?: number; // 每日步数目标(用于 Explore 页等)
dailyCaloriesGoal?: number; // 每日卡路里消耗目标 dailyCaloriesGoal?: number; // 每日卡路里消耗目标
@@ -22,6 +22,7 @@ export type UserState = {
profile: UserProfile; profile: UserProfile;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
privacyAgreed: boolean;
}; };
export const DEFAULT_MEMBER_NAME = '普拉提星球学员'; export const DEFAULT_MEMBER_NAME = '普拉提星球学员';
@@ -33,6 +34,7 @@ const initialState: UserState = {
}, },
loading: false, loading: false,
error: null, error: null,
privacyAgreed: false,
}; };
export type LoginPayload = Record<string, any> & { export type LoginPayload = Record<string, any> & {
@@ -105,22 +107,30 @@ export const login = createAsyncThunk(
); );
export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => { export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => {
const [token, profileStr] = await Promise.all([ const [token, profileStr, privacyAgreedStr] = await Promise.all([
loadPersistedToken(), loadPersistedToken(),
AsyncStorage.getItem(STORAGE_KEYS.userProfile), AsyncStorage.getItem(STORAGE_KEYS.userProfile),
AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed),
]); ]);
await setAuthToken(token); await setAuthToken(token);
let profile: UserProfile = {}; let profile: UserProfile = {};
if (profileStr) { if (profileStr) {
try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = {}; } try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = {}; }
} }
return { token, profile } as { token: string | null; profile: UserProfile }; const privacyAgreed = privacyAgreedStr === 'true';
return { token, profile, privacyAgreed } as { token: string | null; profile: UserProfile; privacyAgreed: boolean };
});
export const setPrivacyAgreed = createAsyncThunk('user/setPrivacyAgreed', async () => {
await AsyncStorage.setItem(STORAGE_KEYS.privacyAgreed, 'true');
return true;
}); });
export const logout = createAsyncThunk('user/logout', async () => { export const logout = createAsyncThunk('user/logout', async () => {
await Promise.all([ await Promise.all([
AsyncStorage.removeItem(STORAGE_KEYS.authToken), AsyncStorage.removeItem(STORAGE_KEYS.authToken),
AsyncStorage.removeItem(STORAGE_KEYS.userProfile), AsyncStorage.removeItem(STORAGE_KEYS.userProfile),
AsyncStorage.removeItem(STORAGE_KEYS.privacyAgreed),
]); ]);
await setAuthToken(null); await setAuthToken(null);
return true; return true;
@@ -176,6 +186,7 @@ const userSlice = createSlice({
.addCase(rehydrateUser.fulfilled, (state, action) => { .addCase(rehydrateUser.fulfilled, (state, action) => {
state.token = action.payload.token; state.token = action.payload.token;
state.profile = action.payload.profile; state.profile = action.payload.profile;
state.privacyAgreed = action.payload.privacyAgreed;
if (!state.profile?.name || !state.profile.name.trim()) { if (!state.profile?.name || !state.profile.name.trim()) {
state.profile.name = DEFAULT_MEMBER_NAME; state.profile.name = DEFAULT_MEMBER_NAME;
} }
@@ -193,6 +204,10 @@ const userSlice = createSlice({
.addCase(logout.fulfilled, (state) => { .addCase(logout.fulfilled, (state) => {
state.token = null; state.token = null;
state.profile = {}; state.profile = {};
state.privacyAgreed = false;
})
.addCase(setPrivacyAgreed.fulfilled, (state) => {
state.privacyAgreed = true;
}); });
}, },
}); });

47
utils/devTools.ts Normal file
View File

@@ -0,0 +1,47 @@
import { STORAGE_KEYS } from '@/services/api';
import AsyncStorage from '@react-native-async-storage/async-storage';
/**
* 开发工具函数 - 清除隐私同意状态
* 用于测试隐私同意弹窗功能
*/
export const clearPrivacyAgreement = async (): Promise<void> => {
try {
await AsyncStorage.removeItem(STORAGE_KEYS.privacyAgreed);
console.log('隐私同意状态已清除');
} catch (error) {
console.error('清除隐私同意状态失败:', error);
}
};
/**
* 开发工具函数 - 检查隐私同意状态
*/
export const checkPrivacyAgreement = async (): Promise<boolean> => {
try {
const privacyAgreed = await AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed);
const isAgreed = privacyAgreed === 'true';
console.log('隐私同意状态:', isAgreed);
return isAgreed;
} catch (error) {
console.error('检查隐私同意状态失败:', error);
return false;
}
};
/**
* 开发工具函数 - 清除所有用户数据
* 用于完全重置应用状态
*/
export const clearAllUserData = async (): Promise<void> => {
try {
await Promise.all([
AsyncStorage.removeItem(STORAGE_KEYS.authToken),
AsyncStorage.removeItem(STORAGE_KEYS.userProfile),
AsyncStorage.removeItem(STORAGE_KEYS.privacyAgreed),
]);
console.log('所有用户数据已清除');
} catch (error) {
console.error('清除用户数据失败:', error);
}
};