diff --git a/app/health/family-invite.tsx b/app/health/family-invite.tsx index 618df1d..de1d88d 100644 --- a/app/health/family-invite.tsx +++ b/app/health/family-invite.tsx @@ -1,7 +1,6 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { - createFamilyGroup, fetchFamilyGroup, generateInviteCode, selectFamilyGroup, @@ -13,16 +12,16 @@ import { Ionicons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { Stack } from 'expo-router'; import React, { useEffect, useState } from 'react'; -import { - ActivityIndicator, - Alert, - Modal, - ScrollView, - Share, - StyleSheet, - Text, - TouchableOpacity, - View +import { + ActivityIndicator, + Alert, + Modal, + ScrollView, + Share, + StyleSheet, + Text, + TouchableOpacity, + View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -47,11 +46,6 @@ export default function FamilyInviteScreen() { // 处理邀请按钮点击 const handleInvite = async () => { try { - // 如果没有家庭组,先创建一个 - if (!familyGroup) { - await dispatch(createFamilyGroup('我的家庭')).unwrap(); - } - // 生成邀请码 await dispatch(generateInviteCode(24)).unwrap(); diff --git a/app/health/profile.tsx b/app/health/profile.tsx index d5fe6b3..ec812b6 100644 --- a/app/health/profile.tsx +++ b/app/health/profile.tsx @@ -3,20 +3,29 @@ import { BasicInfoTab } from '@/components/health/tabs/BasicInfoTab'; import { CheckupRecordsTab } from '@/components/health/tabs/CheckupRecordsTab'; import { HealthHistoryTab } from '@/components/health/tabs/HealthHistoryTab'; import { MedicalRecordsTab } from '@/components/health/tabs/MedicalRecordsTab'; +import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet'; import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { ROUTES } from '@/constants/Routes'; -import { useAppSelector } from '@/hooks/redux'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; import { useI18n } from '@/hooks/useI18n'; +import { + fetchFamilyGroup, + joinFamilyGroup, + selectFamilyGroup, +} from '@/store/familyHealthSlice'; import { selectHealthHistoryProgress } from '@/store/healthSlice'; import { DEFAULT_MEMBER_NAME } from '@/store/userSlice'; +import { Toast } from '@/utils/toast.utils'; import { Ionicons } from '@expo/vector-icons'; +import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { Stack, useRouter } from 'expo-router'; -import React, { useMemo, useState } from 'react'; -import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function HealthProfileScreen() { @@ -25,8 +34,19 @@ export default function HealthProfileScreen() { const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; const { t } = useI18n(); + const dispatch = useAppDispatch(); + const { ensureLoggedIn } = useAuthGuard(); + const glassAvailable = isLiquidGlassAvailable(); const [activeTab, setActiveTab] = useState(0); + const [joinModalVisible, setJoinModalVisible] = useState(false); + const [inviteCodeInput, setInviteCodeInput] = useState(''); + const [relationshipInput, setRelationshipInput] = useState(''); + const [isJoining, setIsJoining] = useState(false); + const [joinError, setJoinError] = useState(null); + + // Redux state + const familyGroup = useAppSelector(selectFamilyGroup); // Mock user data - in a real app this would come from Redux/Context const userProfile = useAppSelector((state) => state.user.profile); @@ -59,6 +79,61 @@ export default function HealthProfileScreen() { return Math.round((filledCount / totalFields) * 100); }, [userProfile.height, userProfile.weight, userProfile.waistCircumference]); + // 初始化获取家庭组信息 + useEffect(() => { + dispatch(fetchFamilyGroup()); + }, [dispatch]); + + // 重置弹窗状态 + useEffect(() => { + if (!joinModalVisible) { + setInviteCodeInput(''); + setRelationshipInput(''); + setJoinError(null); + } + }, [joinModalVisible]); + + // 打开加入弹窗 + const handleOpenJoin = useCallback(async () => { + const ok = await ensureLoggedIn(); + if (!ok) return; + setJoinModalVisible(true); + }, [ensureLoggedIn]); + + // 提交加入家庭组 + const handleSubmitJoin = useCallback(async () => { + if (isJoining) return; + const ok = await ensureLoggedIn(); + if (!ok) return; + + const code = inviteCodeInput.trim().toUpperCase(); + const relationship = relationshipInput.trim(); + + if (!code) { + setJoinError('请输入邀请码'); + return; + } + if (!relationship) { + setJoinError('请输入与创建者的关系'); + return; + } + + setIsJoining(true); + setJoinError(null); + + try { + await dispatch(joinFamilyGroup({ inviteCode: code, relationship })).unwrap(); + await dispatch(fetchFamilyGroup()); + setJoinModalVisible(false); + Toast.success('成功加入家庭组'); + } catch (error) { + const message = typeof error === 'string' ? error : '加入失败,请检查邀请码是否正确'; + setJoinError(message); + } finally { + setIsJoining(false); + } + }, [dispatch, ensureLoggedIn, inviteCodeInput, isJoining, relationshipInput]); + const gradientColors: [string, string] = theme === 'dark' ? ['#1f2230', '#10131e'] @@ -107,6 +182,25 @@ export default function HealthProfileScreen() { transparent right={ + {/* 加入家庭组按钮 - 仅在未加入家庭组时显示 */} + {!familyGroup && ( + + {glassAvailable ? ( + + 加入 + + ) : ( + + 加入 + + )} + + )} @@ -125,7 +219,10 @@ export default function HealthProfileScreen() { {displayName} - + router.push(ROUTES.HEALTH_FAMILY_INVITE)} + > @@ -205,7 +302,44 @@ export default function HealthProfileScreen() { - {/* Privacy Warning Footer - Removed as requested */} + {/* 加入家庭组弹窗 */} + setJoinModalVisible(false)} + onConfirm={handleSubmitJoin} + title="加入家庭组" + description="输入家人分享的邀请码,加入家庭健康管理" + confirmText={isJoining ? '加入中...' : '加入'} + cancelText="取消" + loading={isJoining} + content={ + + setInviteCodeInput(text.toUpperCase())} + autoCapitalize="characters" + autoCorrect={false} + keyboardType="default" + maxLength={12} + /> + + {joinError && joinModalVisible ? ( + {joinError} + ) : null} + + } + /> ); } @@ -339,5 +473,45 @@ const styles = StyleSheet.create({ textAlign: 'center', lineHeight: 18, }, - + joinButtonGlass: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 16, + minWidth: 60, + alignItems: 'center', + justifyContent: 'center', + borderWidth: StyleSheet.hairlineWidth, + borderColor: 'rgba(255,255,255,0.45)', + }, + joinButtonLabel: { + fontSize: 12, + fontWeight: '700', + color: '#0f1528', + letterSpacing: 0.5, + fontFamily: 'AliBold', + }, + joinButtonFallback: { + backgroundColor: 'rgba(255,255,255,0.7)', + }, + modalInputWrapper: { + borderRadius: 14, + borderWidth: 1, + borderColor: '#e5e7eb', + backgroundColor: '#f8fafc', + paddingHorizontal: 12, + paddingVertical: 10, + gap: 6, + }, + modalInput: { + paddingVertical: 12, + fontSize: 16, + fontWeight: '600', + letterSpacing: 0.5, + color: '#0f1528', + }, + modalError: { + marginTop: 10, + fontSize: 12, + color: '#ef4444', + }, }); diff --git a/app/weight-records.tsx b/app/weight-records.tsx index 836c89e..0eaaeb5 100644 --- a/app/weight-records.tsx +++ b/app/weight-records.tsx @@ -265,9 +265,9 @@ export default function WeightRecordsPage() { kg - + 0 ? "trending-down" : "trending-up"} size={16} color="#ffffff" /> - {totalWeightLoss > 0 ? '+' : ''}{totalWeightLoss.toFixed(1)} kg + {totalWeightLoss > 0 ? '-' : totalWeightLoss < 0 ? '+' : ''}{Math.abs(totalWeightLoss).toFixed(1)} kg diff --git a/services/healthProfile.ts b/services/healthProfile.ts index 5848859..7cadca5 100644 --- a/services/healthProfile.ts +++ b/services/healthProfile.ts @@ -172,14 +172,6 @@ export async function getFamilyGroup(): Promise { } } -/** - * 创建家庭组 - * @param name 家庭组名称 - */ -export async function createFamilyGroup(name: string): Promise { - return api.post('/api/health-profiles/family/group', { name }); -} - /** * 生成家庭组邀请码 * @param expiresInHours 过期时间(小时),默认24小时 diff --git a/store/familyHealthSlice.ts b/store/familyHealthSlice.ts index 2fad17e..c14d481 100644 --- a/store/familyHealthSlice.ts +++ b/store/familyHealthSlice.ts @@ -2,8 +2,8 @@ * 家庭健康管理 Redux Slice */ -import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; import * as healthProfileApi from '@/services/healthProfile'; +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { RootState } from './index'; // ==================== State 类型定义 ==================== @@ -56,21 +56,6 @@ export const fetchFamilyGroup = createAsyncThunk( } ); -/** - * 创建家庭组 - */ -export const createFamilyGroup = createAsyncThunk( - 'familyHealth/createGroup', - async (name: string, { rejectWithValue }) => { - try { - const data = await healthProfileApi.createFamilyGroup(name); - return data; - } catch (err: any) { - return rejectWithValue(err?.message ?? '创建家庭组失败'); - } - } -); - /** * 生成邀请码 */ @@ -208,20 +193,6 @@ const familyHealthSlice = createSlice({ state.error = action.payload as string; }) - // 创建家庭组 - .addCase(createFamilyGroup.pending, (state) => { - state.loading = true; - state.error = null; - }) - .addCase(createFamilyGroup.fulfilled, (state, action) => { - state.loading = false; - state.familyGroup = action.payload; - }) - .addCase(createFamilyGroup.rejected, (state, action) => { - state.loading = false; - state.error = action.payload as string; - }) - // 生成邀请码 .addCase(generateInviteCode.pending, (state) => { state.inviteLoading = true;