diff --git a/app.json b/app.json index b01ba53..1762424 100644 --- a/app.json +++ b/app.json @@ -1,6 +1,6 @@ { "expo": { - "name": "digital-pilates", + "name": "普拉提助手", "slug": "digital-pilates", "version": "1.0.3", "orientation": "portrait", diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index 67474a6..38f12e0 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -29,6 +29,7 @@ export default function ExploreScreen() { const colorTokens = Colors[theme]; const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000; const userProfile = useAppSelector((s) => s.user.profile); + // 使用 dayjs:当月日期与默认选中“今天” const days = getMonthDaysZh(); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); @@ -163,16 +164,6 @@ export default function ExploreScreen() { })} - {/* 打卡入口 */} - - 每日报告 - router.push('/checkin/calendar')} accessibilityRole="button"> - 查看打卡日历 - - - - {/* 取消卡片内 loading,保持静默刷新提升体验 */} - {/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */} @@ -230,8 +221,8 @@ export default function ExploreScreen() { {/* BMI 指数卡片 */} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index bd3a5cf..b316076 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -301,7 +301,10 @@ export default function HomeScreen() { > - 💪 + 计划管理 diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index f2f595b..3b3789b 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -45,12 +45,12 @@ export default function PersonalScreen() { // 数据格式化函数 const formatHeight = () => { if (userProfile.height == null) return '--'; - return `${Math.round(userProfile.height)}cm`; + return `${parseFloat(userProfile.height).toFixed(1)}cm`; }; const formatWeight = () => { if (userProfile.weight == null) return '--'; - return `${Math.round(userProfile.weight * 10) / 10}kg`; + return `${parseFloat(userProfile.weight).toFixed(1)}kg`; }; const formatAge = () => { diff --git a/app/_layout.tsx b/app/_layout.tsx index 0de09f5..93cbe29 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,22 +4,60 @@ import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; 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 { clearAiCoachSessionCache } from '@/services/aiCoachSession'; import { store } from '@/store'; -import { rehydrateUser } from '@/store/userSlice'; +import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice'; import React from 'react'; +import RNExitApp from 'react-native-exit-app'; + import { Provider } from 'react-redux'; function Bootstrapper({ children }: { children: React.ReactNode }) { const dispatch = useAppDispatch(); + const { privacyAgreed } = useAppSelector((state) => state.user); + const [showPrivacyModal, setShowPrivacyModal] = React.useState(false); + const [userDataLoaded, setUserDataLoaded] = React.useState(false); + React.useEffect(() => { - dispatch(rehydrateUser()); + const loadUserData = async () => { + await dispatch(rehydrateUser()); + setUserDataLoaded(true); + }; + + loadUserData(); // 冷启动时清空 AI 教练会话缓存 clearAiCoachSessionCache(); }, [dispatch]); - return <>{children}; + + React.useEffect(() => { + // 当用户数据加载完成后,检查是否需要显示隐私同意弹窗 + if (userDataLoaded && !privacyAgreed) { + setShowPrivacyModal(true); + } + }, [userDataLoaded, privacyAgreed]); + + const handlePrivacyAgree = () => { + dispatch(setPrivacyAgreed()); + setShowPrivacyModal(false); + }; + + const handlePrivacyDisagree = () => { + RNExitApp.exitApp(); + }; + + return ( + <> + {children} + + + ); } export default function RootLayout() { diff --git a/app/workout/today.tsx b/app/workout/today.tsx index 066f9f0..247a510 100644 --- a/app/workout/today.tsx +++ b/app/workout/today.tsx @@ -427,7 +427,7 @@ export default function TodayWorkoutScreen() { router.back()} withSafeTop={false} transparent={true} diff --git a/assets/images/icons/iconPlan.png b/assets/images/icons/iconPlan.png new file mode 100644 index 0000000..e12236e Binary files /dev/null and b/assets/images/icons/iconPlan.png differ diff --git a/components/PrivacyConsentModal.tsx b/components/PrivacyConsentModal.tsx new file mode 100644 index 0000000..18b580f --- /dev/null +++ b/components/PrivacyConsentModal.tsx @@ -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 ( + + + + 欢迎来到普拉提助手 + + + + 点击"同意并继续"代表您已阅读并理解 + + + + 《用户协议》 + + + + 《隐私政策》 + + + + 我们深知保护用户隐私信息的重要性,请认真进行阅读。 + + + 查看完整版 + + 《用户协议》 + + + + 《隐私政策》 + + + + + + 同意并继续 + + + + 不同意并退出 + + + + + ); +} + +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', + }, +}); diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e3039da..096cb1a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1730,6 +1730,8 @@ PODS: - Yoga - RNDateTimePicker (8.4.4): - React-Core + - RNExitApp (2.0.0): + - React-Core - RNGestureHandler (2.24.0): - DoubleConversion - glog @@ -2010,6 +2012,7 @@ DEPENDENCIES: - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)" - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)" + - RNExitApp (from `../node_modules/react-native-exit-app`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) @@ -2221,6 +2224,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-masked-view/masked-view" RNDateTimePicker: :path: "../node_modules/@react-native-community/datetimepicker" + RNExitApp: + :path: "../node_modules/react-native-exit-app" RNGestureHandler: :path: "../node_modules/react-native-gesture-handler" RNReanimated: @@ -2334,6 +2339,7 @@ SPEC CHECKSUMS: RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f RNCMaskedView: d4644e239e65383f96d2f32c40c297f09705ac96 RNDateTimePicker: 7d93eacf4bdf56350e4b7efd5cfc47639185e10c + RNExitApp: 4432b9b7cc5ccec9f91c94e507849891282befd4 RNGestureHandler: 6e640921d207f070e4bbcf79f4e6d0eabf323389 RNReanimated: 34e90d19560aebd52a2ad583fdc2de2cf7651bbb RNScreens: 241cfe8fc82737f3e132dd45779f9512928075b8 diff --git a/ios/digitalpilates/Info.plist b/ios/digitalpilates/Info.plist index 7056669..ceea14f 100644 --- a/ios/digitalpilates/Info.plist +++ b/ios/digitalpilates/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - digital-pilates + 普拉提助手 CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/package-lock.json b/package-lock.json index 88ff173..64e9e93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "react-dom": "19.0.0", "react-native": "0.79.5", "react-native-cos-sdk": "^1.2.1", + "react-native-exit-app": "^2.0.0", "react-native-gesture-handler": "~2.24.0", "react-native-health": "^1.19.0", "react-native-image-viewing": "^0.2.2", @@ -10881,6 +10882,12 @@ "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": { "version": "1.5.5", "resolved": "https://mirrors.tencent.com/npm/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz", diff --git a/package.json b/package.json index 59008ae..7eb4cb6 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "react-dom": "19.0.0", "react-native": "0.79.5", "react-native-cos-sdk": "^1.2.1", + "react-native-exit-app": "^2.0.0", "react-native-gesture-handler": "~2.24.0", "react-native-health": "^1.19.0", "react-native-image-viewing": "^0.2.2", diff --git a/services/api.ts b/services/api.ts index 93f5dd2..fd8d9af 100644 --- a/services/api.ts +++ b/services/api.ts @@ -74,6 +74,7 @@ export const api = { export const STORAGE_KEYS = { authToken: '@auth_token', userProfile: '@user_profile', + privacyAgreed: '@privacy_agreed', } as const; export async function loadPersistedToken(): Promise { diff --git a/store/userSlice.ts b/store/userSlice.ts index 44aabb2..0070de0 100644 --- a/store/userSlice.ts +++ b/store/userSlice.ts @@ -9,8 +9,8 @@ export type UserProfile = { email?: string; gender?: Gender; birthDate?: string; - weight?: number; - height?: number; + weight?: string; + height?: string; avatar?: string | null; dailyStepsGoal?: number; // 每日步数目标(用于 Explore 页等) dailyCaloriesGoal?: number; // 每日卡路里消耗目标 @@ -22,6 +22,7 @@ export type UserState = { profile: UserProfile; loading: boolean; error: string | null; + privacyAgreed: boolean; }; export const DEFAULT_MEMBER_NAME = '普拉提星球学员'; @@ -33,6 +34,7 @@ const initialState: UserState = { }, loading: false, error: null, + privacyAgreed: false, }; export type LoginPayload = Record & { @@ -105,22 +107,30 @@ export const login = createAsyncThunk( ); export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => { - const [token, profileStr] = await Promise.all([ + const [token, profileStr, privacyAgreedStr] = await Promise.all([ loadPersistedToken(), AsyncStorage.getItem(STORAGE_KEYS.userProfile), + AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed), ]); await setAuthToken(token); let profile: UserProfile = {}; if (profileStr) { 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 () => { await Promise.all([ AsyncStorage.removeItem(STORAGE_KEYS.authToken), AsyncStorage.removeItem(STORAGE_KEYS.userProfile), + AsyncStorage.removeItem(STORAGE_KEYS.privacyAgreed), ]); await setAuthToken(null); return true; @@ -176,6 +186,7 @@ const userSlice = createSlice({ .addCase(rehydrateUser.fulfilled, (state, action) => { state.token = action.payload.token; state.profile = action.payload.profile; + state.privacyAgreed = action.payload.privacyAgreed; if (!state.profile?.name || !state.profile.name.trim()) { state.profile.name = DEFAULT_MEMBER_NAME; } @@ -193,6 +204,10 @@ const userSlice = createSlice({ .addCase(logout.fulfilled, (state) => { state.token = null; state.profile = {}; + state.privacyAgreed = false; + }) + .addCase(setPrivacyAgreed.fulfilled, (state) => { + state.privacyAgreed = true; }); }, }); diff --git a/utils/devTools.ts b/utils/devTools.ts new file mode 100644 index 0000000..7f94b19 --- /dev/null +++ b/utils/devTools.ts @@ -0,0 +1,47 @@ +import { STORAGE_KEYS } from '@/services/api'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +/** + * 开发工具函数 - 清除隐私同意状态 + * 用于测试隐私同意弹窗功能 + */ +export const clearPrivacyAgreement = async (): Promise => { + try { + await AsyncStorage.removeItem(STORAGE_KEYS.privacyAgreed); + console.log('隐私同意状态已清除'); + } catch (error) { + console.error('清除隐私同意状态失败:', error); + } +}; + +/** + * 开发工具函数 - 检查隐私同意状态 + */ +export const checkPrivacyAgreement = async (): Promise => { + 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 => { + 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); + } +};