feat(家庭健康): 优化家庭组加入流程,移除自动创建家庭组逻辑
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import {
|
import {
|
||||||
createFamilyGroup,
|
|
||||||
fetchFamilyGroup,
|
fetchFamilyGroup,
|
||||||
generateInviteCode,
|
generateInviteCode,
|
||||||
selectFamilyGroup,
|
selectFamilyGroup,
|
||||||
@@ -13,16 +12,16 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
Modal,
|
Modal,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Share,
|
Share,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
@@ -47,11 +46,6 @@ export default function FamilyInviteScreen() {
|
|||||||
// 处理邀请按钮点击
|
// 处理邀请按钮点击
|
||||||
const handleInvite = async () => {
|
const handleInvite = async () => {
|
||||||
try {
|
try {
|
||||||
// 如果没有家庭组,先创建一个
|
|
||||||
if (!familyGroup) {
|
|
||||||
await dispatch(createFamilyGroup('我的家庭')).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成邀请码
|
// 生成邀请码
|
||||||
await dispatch(generateInviteCode(24)).unwrap();
|
await dispatch(generateInviteCode(24)).unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,29 @@ import { BasicInfoTab } from '@/components/health/tabs/BasicInfoTab';
|
|||||||
import { CheckupRecordsTab } from '@/components/health/tabs/CheckupRecordsTab';
|
import { CheckupRecordsTab } from '@/components/health/tabs/CheckupRecordsTab';
|
||||||
import { HealthHistoryTab } from '@/components/health/tabs/HealthHistoryTab';
|
import { HealthHistoryTab } from '@/components/health/tabs/HealthHistoryTab';
|
||||||
import { MedicalRecordsTab } from '@/components/health/tabs/MedicalRecordsTab';
|
import { MedicalRecordsTab } from '@/components/health/tabs/MedicalRecordsTab';
|
||||||
|
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { ROUTES } from '@/constants/Routes';
|
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 { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { useI18n } from '@/hooks/useI18n';
|
import { useI18n } from '@/hooks/useI18n';
|
||||||
|
import {
|
||||||
|
fetchFamilyGroup,
|
||||||
|
joinFamilyGroup,
|
||||||
|
selectFamilyGroup,
|
||||||
|
} from '@/store/familyHealthSlice';
|
||||||
import { selectHealthHistoryProgress } from '@/store/healthSlice';
|
import { selectHealthHistoryProgress } from '@/store/healthSlice';
|
||||||
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
||||||
|
import { Toast } from '@/utils/toast.utils';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { Stack, useRouter } from 'expo-router';
|
import { Stack, useRouter } from 'expo-router';
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
export default function HealthProfileScreen() {
|
export default function HealthProfileScreen() {
|
||||||
@@ -25,8 +34,19 @@ export default function HealthProfileScreen() {
|
|||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { ensureLoggedIn } = useAuthGuard();
|
||||||
|
const glassAvailable = isLiquidGlassAvailable();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState(0);
|
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<string | null>(null);
|
||||||
|
|
||||||
|
// Redux state
|
||||||
|
const familyGroup = useAppSelector(selectFamilyGroup);
|
||||||
|
|
||||||
// Mock user data - in a real app this would come from Redux/Context
|
// Mock user data - in a real app this would come from Redux/Context
|
||||||
const userProfile = useAppSelector((state) => state.user.profile);
|
const userProfile = useAppSelector((state) => state.user.profile);
|
||||||
@@ -59,6 +79,61 @@ export default function HealthProfileScreen() {
|
|||||||
return Math.round((filledCount / totalFields) * 100);
|
return Math.round((filledCount / totalFields) * 100);
|
||||||
}, [userProfile.height, userProfile.weight, userProfile.waistCircumference]);
|
}, [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] =
|
const gradientColors: [string, string] =
|
||||||
theme === 'dark'
|
theme === 'dark'
|
||||||
? ['#1f2230', '#10131e']
|
? ['#1f2230', '#10131e']
|
||||||
@@ -107,6 +182,25 @@ export default function HealthProfileScreen() {
|
|||||||
transparent
|
transparent
|
||||||
right={
|
right={
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
{/* 加入家庭组按钮 - 仅在未加入家庭组时显示 */}
|
||||||
|
{!familyGroup && (
|
||||||
|
<TouchableOpacity activeOpacity={0.85} onPress={handleOpenJoin} style={{ marginRight: 10 }}>
|
||||||
|
{glassAvailable ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.joinButtonGlass}
|
||||||
|
glassEffectStyle="regular"
|
||||||
|
tintColor="rgba(255,255,255,0.18)"
|
||||||
|
isInteractive
|
||||||
|
>
|
||||||
|
<Text style={styles.joinButtonLabel}>加入</Text>
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.joinButtonGlass, styles.joinButtonFallback]}>
|
||||||
|
<Text style={[styles.joinButtonLabel, { color: colorTokens.text }]}>加入</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
<TouchableOpacity style={{ marginRight: 12 }}>
|
<TouchableOpacity style={{ marginRight: 12 }}>
|
||||||
<Ionicons name="settings-outline" size={22} color="#1F2937" />
|
<Ionicons name="settings-outline" size={22} color="#1F2937" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -125,7 +219,10 @@ export default function HealthProfileScreen() {
|
|||||||
<Image source={{ uri: avatarUrl }} style={styles.miniAvatar} />
|
<Image source={{ uri: avatarUrl }} style={styles.miniAvatar} />
|
||||||
<Text style={styles.miniAvatarName}>{displayName}</Text>
|
<Text style={styles.miniAvatarName}>{displayName}</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity style={styles.addButton}>
|
<TouchableOpacity
|
||||||
|
style={styles.addButton}
|
||||||
|
onPress={() => router.push(ROUTES.HEALTH_FAMILY_INVITE)}
|
||||||
|
>
|
||||||
<Ionicons name="add" size={16} color="#6B7280" />
|
<Ionicons name="add" size={16} color="#6B7280" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -205,7 +302,44 @@ export default function HealthProfileScreen() {
|
|||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* Privacy Warning Footer - Removed as requested */}
|
{/* 加入家庭组弹窗 */}
|
||||||
|
<ConfirmationSheet
|
||||||
|
visible={joinModalVisible}
|
||||||
|
onClose={() => setJoinModalVisible(false)}
|
||||||
|
onConfirm={handleSubmitJoin}
|
||||||
|
title="加入家庭组"
|
||||||
|
description="输入家人分享的邀请码,加入家庭健康管理"
|
||||||
|
confirmText={isJoining ? '加入中...' : '加入'}
|
||||||
|
cancelText="取消"
|
||||||
|
loading={isJoining}
|
||||||
|
content={
|
||||||
|
<View style={styles.modalInputWrapper}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.modalInput}
|
||||||
|
placeholder="请输入邀请码"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={inviteCodeInput}
|
||||||
|
onChangeText={(text) => setInviteCodeInput(text.toUpperCase())}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="default"
|
||||||
|
maxLength={12}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.modalInput, { marginTop: 12 }]}
|
||||||
|
placeholder="与创建者的关系(如:配偶、父母、子女)"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={relationshipInput}
|
||||||
|
onChangeText={setRelationshipInput}
|
||||||
|
autoCorrect={false}
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
{joinError && joinModalVisible ? (
|
||||||
|
<Text style={styles.modalError}>{joinError}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -339,5 +473,45 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
lineHeight: 18,
|
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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -265,9 +265,9 @@ export default function WeightRecordsPage() {
|
|||||||
<Text style={styles.mainStatUnit}>kg</Text>
|
<Text style={styles.mainStatUnit}>kg</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.totalLossTag}>
|
<View style={styles.totalLossTag}>
|
||||||
<Ionicons name={totalWeightLoss <= 0 ? "trending-down" : "trending-up"} size={16} color="#ffffff" />
|
<Ionicons name={totalWeightLoss > 0 ? "trending-down" : "trending-up"} size={16} color="#ffffff" />
|
||||||
<Text style={styles.totalLossText}>
|
<Text style={styles.totalLossText}>
|
||||||
{totalWeightLoss > 0 ? '+' : ''}{totalWeightLoss.toFixed(1)} kg
|
{totalWeightLoss > 0 ? '-' : totalWeightLoss < 0 ? '+' : ''}{Math.abs(totalWeightLoss).toFixed(1)} kg
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -172,14 +172,6 @@ export async function getFamilyGroup(): Promise<FamilyGroup | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建家庭组
|
|
||||||
* @param name 家庭组名称
|
|
||||||
*/
|
|
||||||
export async function createFamilyGroup(name: string): Promise<FamilyGroup> {
|
|
||||||
return api.post<FamilyGroup>('/api/health-profiles/family/group', { name });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成家庭组邀请码
|
* 生成家庭组邀请码
|
||||||
* @param expiresInHours 过期时间(小时),默认24小时
|
* @param expiresInHours 过期时间(小时),默认24小时
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* 家庭健康管理 Redux Slice
|
* 家庭健康管理 Redux Slice
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
|
||||||
import * as healthProfileApi from '@/services/healthProfile';
|
import * as healthProfileApi from '@/services/healthProfile';
|
||||||
|
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||||
import { RootState } from './index';
|
import { RootState } from './index';
|
||||||
|
|
||||||
// ==================== State 类型定义 ====================
|
// ==================== 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;
|
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) => {
|
.addCase(generateInviteCode.pending, (state) => {
|
||||||
state.inviteLoading = true;
|
state.inviteLoading = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user