feat(ui): 添加底部标签栏自定义配置功能和药物堆叠展示

- 新增底部标签栏配置页面,支持切换标签显示/隐藏和恢复默认设置
- 实现已服用药物的堆叠卡片展示,优化药物列表视觉层次
- 集成Redux状态管理底部标签栏配置,支持本地持久化
- 优化个人中心页面背景渐变效果,移除装饰性圆圈元素
- 更新启动页和应用图标为新的品牌视觉
- 药物详情页AI分析加载动画替换为Lottie动画
- 调整药物卡片圆角半径提升视觉一致性
- 新增多语言支持(中英文)用于标签栏配置界面

主要改进:
1. 用户可以自定义底部导航栏显示内容
2. 已完成的药物以堆叠形式展示,节省空间
3. 配置数据通过AsyncStorage持久化保存
4. 支持默认配置恢复功能
This commit is contained in:
richarjiang
2025-11-20 17:55:17 +08:00
parent 84abfa2506
commit 29942feee9
25 changed files with 906 additions and 86 deletions

View File

@@ -12,7 +12,9 @@ import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
import { useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { selectEnabledTabs } from '@/store/tabBarConfigSlice';
// Tab configuration
type TabConfig = {
@@ -34,6 +36,9 @@ export default function TabLayout() {
const colorTokens = Colors[theme];
const pathname = usePathname();
const glassEffectAvailable = isLiquidGlassAvailable();
// 获取已启用的标签配置(按自定义顺序)
const enabledTabs = useAppSelector(selectEnabledTabs);
// Helper function to determine if a tab is selected
const isTabSelected = (routeName: string): boolean => {
@@ -174,42 +179,45 @@ export default function TabLayout() {
tabBarShowLabel: false,
});
// 根据配置渲染标签页
if (glassEffectAvailable) {
return <NativeTabs>
<NativeTabs.Trigger name="statistics">
<Label>{t('statistics.tabs.health')}</Label>
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="medications">
<Icon sf="pills.fill" drawable="custom_android_drawable" />
<Label>{t('statistics.tabs.medications')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="fasting">
<Icon sf="timer" drawable="custom_android_drawable" />
<Label>{t('statistics.tabs.fasting')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="challenges">
<Icon sf="trophy.fill" drawable="custom_android_drawable" />
<Label>{t('statistics.tabs.challenges')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="personal">
<Icon sf="person.fill" drawable="custom_settings_drawable" />
<Label>{t('statistics.tabs.personal')}</Label>
</NativeTabs.Trigger>
</NativeTabs>
return (
<NativeTabs>
{enabledTabs.map((tab) => {
const tabConfig = TAB_CONFIGS[tab.id];
if (!tabConfig) return null;
return (
<NativeTabs.Trigger key={tab.id} name={tab.id}>
<Icon sf={tabConfig.icon as any} drawable="custom_android_drawable" />
<Label>{t(tabConfig.titleKey)}</Label>
</NativeTabs.Trigger>
);
})}
</NativeTabs>
);
}
// 确定初始路由(第一个启用的标签)
const initialRouteName = enabledTabs.length > 0 ? enabledTabs[0].id : 'statistics';
return (
<Tabs
initialRouteName="statistics"
initialRouteName={initialRouteName}
screenOptions={({ route }) => getScreenOptions(route.name)}
>
<Tabs.Screen name="statistics" options={{ title: t('statistics.tabs.health') }} />
<Tabs.Screen name="medications" options={{ title: t('statistics.tabs.medications') }} />
<Tabs.Screen name="fasting" options={{ title: t('statistics.tabs.fasting') }} />
<Tabs.Screen name="challenges" options={{ title: t('statistics.tabs.challenges') }} />
<Tabs.Screen name="personal" options={{ title: t('statistics.tabs.personal') }} />
{enabledTabs.map((tab) => {
const tabConfig = TAB_CONFIGS[tab.id];
if (!tabConfig) return null;
return (
<Tabs.Screen
key={tab.id}
name={tab.id}
options={{ title: t(tabConfig.titleKey) }}
/>
);
})}
</Tabs>
);
}

View File

@@ -1,6 +1,7 @@
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
import { DateSelector } from '@/components/DateSelector';
import { MedicationCard } from '@/components/medication/MedicationCard';
import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack';
import { ThemedText } from '@/components/ThemedText';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
@@ -189,6 +190,16 @@ export default function MedicationsScreen() {
return medicationsWithImages.filter((item: any) => item.status === activeFilter);
}, [activeFilter, medicationsWithImages]);
const activeMedications = useMemo(() => {
if (activeFilter !== 'all') return filteredMedications;
return filteredMedications.filter((item: any) => item.status !== 'taken' && item.status !== 'skipped');
}, [activeFilter, filteredMedications]);
const completedMedications = useMemo(() => {
if (activeFilter !== 'all') return [];
return filteredMedications.filter((item: any) => item.status === 'taken' || item.status === 'skipped');
}, [activeFilter, filteredMedications]);
const counts = useMemo(() => {
const taken = medicationsWithImages.filter((item: any) => item.status === 'taken').length;
// "未服用"计数包含 missed已错过和 upcoming待服用
@@ -354,7 +365,8 @@ export default function MedicationsScreen() {
</View>
) : (
<View style={styles.cardsWrapper}>
{filteredMedications.map((item: any) => (
{/* 渲染未服用的药物 */}
{activeMedications.map((item: any) => (
<MedicationCard
key={item.id}
medication={item}
@@ -364,6 +376,17 @@ export default function MedicationsScreen() {
onCelebrate={handleMedicationTakenCelebration}
/>
))}
{/* 渲染已完成(服用/跳过)的药物堆叠 */}
{completedMedications.length > 0 && (
<TakenMedicationsStack
medications={completedMedications}
colors={colors}
selectedDate={selectedDate}
onOpenDetails={(item) => handleOpenMedicationDetails(item.medicationId)}
onCelebrate={handleMedicationTakenCelebration}
/>
)}
</View>
)}
</ScrollView>

View File

@@ -1,6 +1,7 @@
import ActivityHeatMap from '@/components/ActivityHeatMap';
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { palette } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
@@ -586,6 +587,16 @@ export default function PersonalScreen() {
},
],
},
{
title: t('personal.sections.customization'),
items: [
{
icon: 'albums-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.tabBarConfig'),
onPress: () => router.push(ROUTES.TAB_BAR_CONFIG),
},
],
},
// 开发者section需要连续点击三次用户名激活
...(showDeveloperSection ? [{
title: t('personal.sections.developer'),
@@ -698,16 +709,12 @@ export default function PersonalScreen() {
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<ScrollView
style={styles.scrollView}
contentContainerStyle={{
@@ -759,33 +766,14 @@ export default function PersonalScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 40,
right: 20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
height: '60%',
},
scrollView: {
flex: 1,

View File

@@ -386,7 +386,7 @@ export default function ExploreScreen() {
<View style={styles.headerContent}>
{/* 左边logo */}
<Image
source={require('@/assets/icon.icon/Assets/icon-1756312748268.png')}
source={require('@/assets/machine.png')}
style={styles.logoImage}
resizeMode="cover"
/>