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);
+ }
+};