diff --git a/app.json b/app.json index d68a0f4..9c1d9f7 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Out Live", "slug": "digital-pilates", - "version": "1.0.12", + "version": "1.0.13", "orientation": "portrait", "scheme": "digitalpilates", "userInterfaceStyle": "light", diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index e84bbcf..40d0cff 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -3,6 +3,7 @@ import { DateSelector } from '@/components/DateSelector'; import { FitnessRingsCard } from '@/components/FitnessRingsCard'; import { MoodCard } from '@/components/MoodCard'; import { NutritionRadarCard } from '@/components/NutritionRadarCard'; +import CircumferenceCard from '@/components/statistic/CircumferenceCard'; import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard'; import SleepCard from '@/components/statistic/SleepCard'; import StepsCard from '@/components/StepsCard'; @@ -544,6 +545,9 @@ export default function ExploreScreen() { + + {/* 围度数据卡片 - 占满底部一行 */} + ); @@ -845,7 +849,6 @@ const styles = StyleSheet.create({ marginBottom: 16, }, masonryContainer: { - marginBottom: 16, flexDirection: 'row', gap: 16, marginTop: 6, @@ -908,6 +911,10 @@ const styles = StyleSheet.create({ top: 0, padding: 4, }, + circumferenceCard: { + marginBottom: 36, + marginTop: 10, + }, }); diff --git a/app/_layout.tsx b/app/_layout.tsx index 0456e8c..b15cb83 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -14,7 +14,7 @@ import { setupQuickActions } from '@/services/quickActions'; import { initializeWaterRecordBridge } from '@/services/waterRecordBridge'; import { WaterRecordSource } from '@/services/waterRecords'; import { store } from '@/store'; -import { rehydrateUserSync, setPrivacyAgreed } from '@/store/userSlice'; +import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice'; import { createWaterRecordAction } from '@/store/waterSlice'; import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health'; import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers'; @@ -23,15 +23,16 @@ import React from 'react'; import { DialogProvider } from '@/components/ui/DialogProvider'; import { ToastProvider } from '@/contexts/ToastContext'; +import { STORAGE_KEYS } from '@/services/api'; import { BackgroundTaskManager } from '@/services/backgroundTaskManager'; +import AsyncStorage from '@/utils/kvStore'; import { Provider } from 'react-redux'; function Bootstrapper({ children }: { children: React.ReactNode }) { const dispatch = useAppDispatch(); - const { privacyAgreed, profile } = useAppSelector((state) => state.user); + const { profile } = useAppSelector((state) => state.user); const [showPrivacyModal, setShowPrivacyModal] = React.useState(false); - const [userDataLoaded, setUserDataLoaded] = React.useState(false); // 初始化快捷动作处理 useQuickActions(); @@ -39,8 +40,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { React.useEffect(() => { const loadUserData = async () => { // 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态 - await dispatch(rehydrateUserSync()); - setUserDataLoaded(true); + await dispatch(fetchMyProfile()); }; const initHealthPermissions = async () => { @@ -129,14 +129,18 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { initializeNotifications(); // 冷启动时清空 AI 教练会话缓存 clearAiCoachSessionCache(); + }, [dispatch]); React.useEffect(() => { - // 当用户数据加载完成后,检查是否需要显示隐私同意弹窗 - if (userDataLoaded && !privacyAgreed) { - setShowPrivacyModal(true); + + const getPrivacyAgreed = async () => { + const str = await AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed) + + setShowPrivacyModal(str !== 'true'); } - }, [userDataLoaded, privacyAgreed]); + getPrivacyAgreed(); + }, []); const handlePrivacyAgree = () => { dispatch(setPrivacyAgreed()); diff --git a/components/statistic/CircumferenceCard.tsx b/components/statistic/CircumferenceCard.tsx new file mode 100644 index 0000000..a0af380 --- /dev/null +++ b/components/statistic/CircumferenceCard.tsx @@ -0,0 +1,189 @@ +import { FloatingSelectionModal, SelectionItem } from '@/components/ui/FloatingSelectionModal'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useAuthGuard } from '@/hooks/useAuthGuard'; +import { selectUserProfile, updateUserBodyMeasurements } from '@/store/userSlice'; +import React, { useState } from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +interface CircumferenceCardProps { + style?: any; +} + +const CircumferenceCard: React.FC = ({ style }) => { + const dispatch = useAppDispatch(); + const userProfile = useAppSelector(selectUserProfile); + + + console.log('userProfile', userProfile); + + + const { ensureLoggedIn } = useAuthGuard() + + const [modalVisible, setModalVisible] = useState(false); + const [selectedMeasurement, setSelectedMeasurement] = useState<{ + key: string; + label: string; + currentValue?: number; + } | null>(null); + + const measurements = [ + { + key: 'chestCircumference', + label: '胸围', + value: userProfile?.chestCircumference, + }, + { + key: 'waistCircumference', + label: '腰围', + value: userProfile?.waistCircumference, + }, + { + key: 'upperHipCircumference', + label: '上臀围', + value: userProfile?.upperHipCircumference, + }, + { + key: 'armCircumference', + label: '臂围', + value: userProfile?.armCircumference, + }, + { + key: 'thighCircumference', + label: '大腿围', + value: userProfile?.thighCircumference, + }, + { + key: 'calfCircumference', + label: '小腿围', + value: userProfile?.calfCircumference, + }, + ]; + + // Generate circumference options (30-150 cm) + const circumferenceOptions: SelectionItem[] = Array.from({ length: 121 }, (_, i) => { + const value = i + 30; + return { + label: `${value} cm`, + value: value, + }; + }); + + const handleMeasurementPress = async (measurement: typeof measurements[0]) => { + const isLoggedIn = await ensureLoggedIn(); + if (!isLoggedIn) { + // 如果未登录,用户会被重定向到登录页面 + return; + } + + setSelectedMeasurement({ + key: measurement.key, + label: measurement.label, + currentValue: measurement.value, + }); + setModalVisible(true); + }; + + const handleUpdateMeasurement = (value: string | number) => { + if (!selectedMeasurement) return; + + const updateData = { + [selectedMeasurement.key]: Number(value), + }; + + dispatch(updateUserBodyMeasurements(updateData)); + setModalVisible(false); + setSelectedMeasurement(null); + }; + + return ( + + 围度 (cm) + + + {measurements.map((measurement, index) => ( + handleMeasurementPress(measurement)} + activeOpacity={0.7} + > + {measurement.label} + + + {measurement.value ? measurement.value.toString() : '--'} + + + + ))} + + + { + setModalVisible(false); + setSelectedMeasurement(null); + }} + title={selectedMeasurement ? `设置${selectedMeasurement.label}` : '设置围度'} + items={circumferenceOptions} + selectedValue={selectedMeasurement?.currentValue} + onValueChange={() => { }} // Real-time update not needed + onConfirm={handleUpdateMeasurement} + confirmButtonText="确认" + pickerHeight={180} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 20, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.12, + shadowRadius: 12, + elevation: 6, + }, + title: { + fontSize: 14, + fontWeight: '600', + color: '#192126', + marginBottom: 16, + }, + measurementsContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + flexWrap: 'nowrap', + }, + measurementItem: { + alignItems: 'center', + }, + label: { + fontSize: 12, + color: '#888', + marginBottom: 8, + textAlign: 'center', + }, + valueContainer: { + backgroundColor: '#F5F5F7', + borderRadius: 10, + paddingHorizontal: 8, + paddingVertical: 6, + minWidth: 20, + alignItems: 'center', + justifyContent: 'center', + }, + value: { + fontSize: 14, + fontWeight: '600', + color: '#192126', + textAlign: 'center', + }, +}); + +export default CircumferenceCard; \ No newline at end of file diff --git a/components/ui/FloatingSelectionCard.tsx b/components/ui/FloatingSelectionCard.tsx new file mode 100644 index 0000000..ce389dd --- /dev/null +++ b/components/ui/FloatingSelectionCard.tsx @@ -0,0 +1,122 @@ +import { Ionicons } from '@expo/vector-icons'; +import { BlurView } from 'expo-blur'; +import React from 'react'; +import { + Modal, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +interface FloatingSelectionCardProps { + visible: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; +} + +export function FloatingSelectionCard({ + visible, + onClose, + title, + children +}: FloatingSelectionCardProps) { + return ( + + + + + + + + {title} + + + + {children} + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'flex-end', + alignItems: 'center', + }, + backdrop: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + container: { + alignItems: 'center', + marginBottom: 40, + }, + blurContainer: { + borderRadius: 20, + overflow: 'hidden', + backgroundColor: 'rgba(255, 255, 255, 0.95)', + minWidth: 340, + paddingVertical: 20, + paddingHorizontal: 16, + minHeight: 100, + }, + header: { + paddingBottom: 20, + alignItems: 'center', + }, + title: { + fontSize: 14, + fontWeight: '600', + color: '#636161ff', + }, + content: { + alignItems: 'center', + }, + closeButton: { + marginTop: 20, + }, + closeButtonInner: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(255, 255, 255, 0.9)', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, +}); \ No newline at end of file diff --git a/components/ui/FloatingSelectionModal.tsx b/components/ui/FloatingSelectionModal.tsx new file mode 100644 index 0000000..59840c2 --- /dev/null +++ b/components/ui/FloatingSelectionModal.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { FloatingSelectionCard } from './FloatingSelectionCard'; +import { SlidingSelection, SelectionItem } from './SlidingSelection'; + +interface FloatingSelectionModalProps { + visible: boolean; + onClose: () => void; + title: string; + items: SelectionItem[]; + selectedValue?: string | number; + onValueChange: (value: string | number, index: number) => void; + onConfirm?: (value: string | number, index: number) => void; + showConfirmButton?: boolean; + confirmButtonText?: string; + pickerHeight?: number; +} + +export function FloatingSelectionModal({ + visible, + onClose, + title, + items, + selectedValue, + onValueChange, + onConfirm, + showConfirmButton = true, + confirmButtonText = '确认', + pickerHeight = 150, +}: FloatingSelectionModalProps) { + const handleConfirm = (value: string | number, index: number) => { + if (onConfirm) { + onConfirm(value, index); + } + onClose(); + }; + + return ( + + + + ); +} + +// Export types for convenience +export type { SelectionItem } from './SlidingSelection'; \ No newline at end of file diff --git a/components/ui/SlidingSelection.tsx b/components/ui/SlidingSelection.tsx new file mode 100644 index 0000000..9198c8a --- /dev/null +++ b/components/ui/SlidingSelection.tsx @@ -0,0 +1,131 @@ +import React, { useState } from 'react'; +import { + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import WheelPickerExpo from 'react-native-wheel-picker-expo'; + +export interface SelectionItem { + label: string; + value: string | number; +} + +interface SlidingSelectionProps { + items: SelectionItem[]; + selectedValue?: string | number; + onValueChange: (value: string | number, index: number) => void; + onConfirm?: (value: string | number, index: number) => void; + showConfirmButton?: boolean; + confirmButtonText?: string; + height?: number; + itemTextStyle?: any; + selectedIndicatorStyle?: any; +} + +export function SlidingSelection({ + items, + selectedValue, + onValueChange, + onConfirm, + showConfirmButton = true, + confirmButtonText = '确认', + height = 150, + itemTextStyle, + selectedIndicatorStyle +}: SlidingSelectionProps) { + const [currentIndex, setCurrentIndex] = useState(() => { + if (selectedValue !== undefined) { + const index = items.findIndex(item => item.value === selectedValue); + return index >= 0 ? index : 0; + } + return 0; + }); + + const handleValueChange = (index: number) => { + setCurrentIndex(index); + const selectedItem = items[index]; + if (selectedItem) { + onValueChange(selectedItem.value, index); + } + }; + + const handleConfirm = () => { + const selectedItem = items[currentIndex]; + if (selectedItem && onConfirm) { + onConfirm(selectedItem.value, currentIndex); + } + }; + + return ( + + + ({ label: item.label, value: item.value }))} + onChange={({ item, index }) => handleValueChange(index)} + backgroundColor="transparent" + haptics + /> + + + {showConfirmButton && ( + + {confirmButtonText} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + width: '100%', + }, + pickerContainer: { + width: '100%', + justifyContent: 'center', + alignItems: 'center', + }, + picker: { + width: '100%', + height: '100%', + }, + itemText: { + fontSize: 16, + color: '#333', + fontWeight: '500', + }, + selectedIndicator: { + backgroundColor: 'rgba(74, 144, 226, 0.1)', + borderRadius: 8, + }, + confirmButton: { + backgroundColor: '#4A90E2', + paddingHorizontal: 32, + paddingVertical: 12, + borderRadius: 20, + marginTop: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.15, + shadowRadius: 4, + elevation: 4, + }, + confirmButtonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + }, +}); \ No newline at end of file diff --git a/hooks/useAuthGuard.ts b/hooks/useAuthGuard.ts index 5b5b8e7..ccd253f 100644 --- a/hooks/useAuthGuard.ts +++ b/hooks/useAuthGuard.ts @@ -19,9 +19,9 @@ export function useAuthGuard() { const router = useRouter(); const dispatch = useAppDispatch(); const currentPath = usePathname(); - const token = useAppSelector((s) => (s as any)?.user?.token as string | null); + const user = useAppSelector(state => state.user); - const isLoggedIn = !!token; + const isLoggedIn = !!user?.profile?.id; const ensureLoggedIn = useCallback(async (options?: EnsureOptions): Promise => { if (isLoggedIn) return true; diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist index 0b6fef4..2f26a49 100644 --- a/ios/OutLive/Info.plist +++ b/ios/OutLive/Info.plist @@ -25,7 +25,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.12 + 1.0.13 CFBundleSignature ???? CFBundleURLTypes diff --git a/services/users.ts b/services/users.ts index e88f703..6df0890 100644 --- a/services/users.ts +++ b/services/users.ts @@ -15,6 +15,12 @@ export type UpdateUserDto = { activityLevel?: number; // 活动水平 1-4 initialWeight?: number; // 初始体重 targetWeight?: number; // 目标体重 + chestCircumference?: number; // 胸围 + waistCircumference?: number; // 腰围 + upperHipCircumference?: number; // 上臀围 + armCircumference?: number; // 臂围 + thighCircumference?: number; // 大腿围 + calfCircumference?: number; // 小腿围 }; export async function updateUser(dto: UpdateUserDto): Promise> { @@ -28,4 +34,20 @@ export async function uploadImage(formData: FormData): Promise<{ fileKey: string }); } +export type BodyMeasurementsDto = { + chestCircumference?: number; // 胸围 + waistCircumference?: number; // 腰围 + upperHipCircumference?: number; // 上臀围 + armCircumference?: number; // 臂围 + thighCircumference?: number; // 大腿围 + calfCircumference?: number; // 小腿围 +}; + +export async function updateBodyMeasurements(dto: BodyMeasurementsDto): Promise<{ + code: number; + message: string; +}> { + return await api.put('/users/body-measurements', dto); +} + diff --git a/store/userSlice.ts b/store/userSlice.ts index cbd3cb9..38e8e54 100644 --- a/store/userSlice.ts +++ b/store/userSlice.ts @@ -1,5 +1,5 @@ import { api, setAuthToken, STORAGE_KEYS } from '@/services/api'; -import { updateUser, UpdateUserDto } from '@/services/users'; +import { BodyMeasurementsDto, updateBodyMeasurements, updateUser, UpdateUserDto } from '@/services/users'; import AsyncStorage from '@/utils/kvStore'; import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; import dayjs from 'dayjs'; @@ -53,6 +53,7 @@ function getPreloadedUserData() { export type Gender = 'male' | 'female' | ''; export type UserProfile = { + id?: string; name?: string; email?: string; gender?: Gender; @@ -68,6 +69,12 @@ export type UserProfile = { isVip?: boolean; freeUsageCount?: number; maxUsageCount?: number; + chestCircumference?: number; // 胸围 + waistCircumference?: number; // 腰围 + upperHipCircumference?: number; // 上臀围 + armCircumference?: number; // 臂围 + thighCircumference?: number; // 大腿围 + calfCircumference?: number; // 小腿围 }; export type WeightHistoryItem = { @@ -87,7 +94,6 @@ export type UserState = { profile: UserProfile; loading: boolean; error: string | null; - privacyAgreed: boolean; weightHistory: WeightHistoryItem[]; activityHistory: ActivityHistoryItem[]; }; @@ -107,7 +113,6 @@ const getInitialState = (): UserState => { }, loading: false, error: null, - privacyAgreed: preloaded.privacyAgreed, weightHistory: [], activityHistory: [], }; @@ -184,38 +189,6 @@ export const login = createAsyncThunk( } ); -// 同步重新hydrate用户数据,避免异步状态更新 -export const rehydrateUserSync = createAsyncThunk('user/rehydrateSync', async () => { - // 立即从预加载的数据获取,如果没有则异步获取 - if (preloadedUserData) { - return preloadedUserData; - } - - // 如果预加载的数据不存在,则执行正常的异步加载 - return await preloadUserData(); -}); - -export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => { - const [profileStr, privacyAgreedStr, token] = await Promise.all([ - AsyncStorage.getItem(STORAGE_KEYS.userProfile), - AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed), - AsyncStorage.getItem(STORAGE_KEYS.authToken), - ]); - - let profile: UserProfile = {}; - if (profileStr) { - try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = {}; } - } - const privacyAgreed = privacyAgreedStr === 'true'; - - // 如果有 token,需要设置到 API 客户端 - if (token) { - await setAuthToken(token); - } - - return { profile, privacyAgreed, token } as { profile: UserProfile; privacyAgreed: boolean; token: string | null }; -}); - export const setPrivacyAgreed = createAsyncThunk('user/setPrivacyAgreed', async () => { await AsyncStorage.setItem(STORAGE_KEYS.privacyAgreed, 'true'); return true; @@ -241,8 +214,12 @@ export const fetchMyProfile = createAsyncThunk('user/fetchMyProfile', async (_, await AsyncStorage.setItem(STORAGE_KEYS.userProfile, JSON.stringify(profile ?? {})); return profile; } catch (err: any) { - const message = err?.message ?? '获取用户信息失败'; - return rejectWithValue(message); + const profileStr = await AsyncStorage.getItem(STORAGE_KEYS.userProfile), + profile = JSON.parse(profileStr ?? '{}'); + + return profile + // const message = err?.message ?? '获取用户信息失败'; + // return rejectWithValue(message); } }); @@ -306,6 +283,19 @@ export const updateWeightRecord = createAsyncThunk( } ); +// 更新用户围度信息 +export const updateUserBodyMeasurements = createAsyncThunk( + 'user/updateBodyMeasurements', + async (measurementsDto: BodyMeasurementsDto, { rejectWithValue }) => { + try { + await updateBodyMeasurements(measurementsDto); + return measurementsDto; + } catch (err: any) { + return rejectWithValue(err?.message ?? '更新围度信息失败'); + } + } +); + const userSlice = createSlice({ name: 'user', initialState, @@ -338,22 +328,6 @@ const userSlice = createSlice({ state.loading = false; state.error = (action.payload as string) ?? '登录失败'; }) - .addCase(rehydrateUser.fulfilled, (state, action) => { - state.profile = action.payload.profile; - state.privacyAgreed = action.payload.privacyAgreed; - state.token = action.payload.token; - if (!state.profile?.name || !state.profile.name.trim()) { - state.profile.name = DEFAULT_MEMBER_NAME; - } - }) - .addCase(rehydrateUserSync.fulfilled, (state, action) => { - state.profile = action.payload.profile; - state.privacyAgreed = action.payload.privacyAgreed; - state.token = action.payload.token; - if (!state.profile?.name || !state.profile.name.trim()) { - state.profile.name = DEFAULT_MEMBER_NAME; - } - }) .addCase(fetchMyProfile.fulfilled, (state, action) => { state.profile = action.payload || {}; if (!state.profile?.name || !state.profile.name.trim()) { @@ -367,10 +341,8 @@ const userSlice = createSlice({ .addCase(logout.fulfilled, (state) => { state.token = null; state.profile = {}; - state.privacyAgreed = false; }) .addCase(setPrivacyAgreed.fulfilled, (state) => { - state.privacyAgreed = true; }) .addCase(fetchWeightHistory.fulfilled, (state, action) => { state.weightHistory = action.payload; @@ -408,6 +380,14 @@ const userSlice = createSlice({ }) .addCase(updateWeightRecord.rejected, (state, action) => { state.error = (action.payload as string) ?? '更新体重记录失败'; + }) + .addCase(updateUserBodyMeasurements.fulfilled, (state, action) => { + console.log('action.payload', action.payload); + + state.profile = { ...state.profile, ...action.payload }; + }) + .addCase(updateUserBodyMeasurements.rejected, (state, action) => { + state.error = (action.payload as string) ?? '更新围度信息失败'; }); }, });