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"
/>

View File

@@ -35,6 +35,7 @@ import { useAuthGuard } from '@/hooks/useAuthGuard';
import { STORAGE_KEYS, setUnauthorizedHandler } from '@/services/api';
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
import { fetchChallenges } from '@/store/challengesSlice';
import { loadTabBarConfigs } from '@/store/tabBarConfigSlice';
import AsyncStorage from '@/utils/kvStore';
import { logger } from '@/utils/logger';
import { Provider } from 'react-redux';
@@ -120,6 +121,11 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
}
}, [isLoggedIn]);
// 初始化底部栏配置
useEffect(() => {
dispatch(loadTabBarConfigs());
}, [dispatch]);
// ==================== 基础服务初始化(不需要权限,总是执行)====================
React.useEffect(() => {
const initializeBasicServices = async () => {
@@ -515,6 +521,7 @@ export default function RootLayout() {
options={{ headerShown: false }}
/>
<Stack.Screen name="badges/index" options={{ headerShown: false }} />
<Stack.Screen name="settings/tab-bar-config" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="dark" />

View File

@@ -34,6 +34,7 @@ import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import LottieView from 'lottie-react-native';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
@@ -1299,8 +1300,13 @@ export default function MedicationDetailScreen() {
</View>
{aiAnalysisLoading && (
<View style={styles.aiLoadingRow}>
<ActivityIndicator color={colors.primary} size="small" />
<View style={styles.aiLoadingContainer}>
<LottieView
source={require('@/assets/lottie/loading-blue.json')}
autoPlay
loop
style={styles.aiLoadingAnimation}
/>
<Text style={[styles.aiLoadingText, { color: colors.textSecondary }]}>
{t('medications.detail.aiAnalysis.analyzing')}
</Text>
@@ -2276,13 +2282,19 @@ const styles = StyleSheet.create({
fontSize: 12,
fontWeight: '700',
},
aiLoadingRow: {
flexDirection: 'row',
aiLoadingContainer: {
alignItems: 'center',
gap: 8,
justifyContent: 'center',
paddingVertical: 24,
gap: 12,
},
aiLoadingAnimation: {
width: 120,
height: 120,
},
aiLoadingText: {
fontSize: 13,
fontSize: 14,
fontWeight: '500',
},
aiHeroRow: {
flexDirection: 'row',

View File

@@ -0,0 +1,266 @@
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
resetToDefault,
selectTabBarConfigs,
toggleTabEnabled,
type TabConfig,
} from '@/store/tabBarConfigSlice';
import { Ionicons } from '@expo/vector-icons';
import { isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
ScrollView,
StyleSheet,
Switch,
Text,
TouchableOpacity,
View
} from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { palette } from '@/constants/Colors';
export default function TabBarConfigScreen() {
const { t } = useTranslation();
const router = useRouter();
const dispatch = useAppDispatch();
const safeAreaTop = useSafeAreaTop(60);
const configs = useAppSelector(selectTabBarConfigs);
const isGlassAvailable = isLiquidGlassAvailable();
// 处理开关切换
const handleToggle = useCallback(
(tabId: string) => {
dispatch(toggleTabEnabled(tabId));
},
[dispatch]
);
// 恢复默认设置
const handleReset = useCallback(() => {
Alert.alert(
t('personal.tabBarConfig.resetConfirm.title'),
t('personal.tabBarConfig.resetConfirm.message'),
[
{
text: t('personal.tabBarConfig.resetConfirm.cancel'),
style: 'cancel',
},
{
text: t('personal.tabBarConfig.resetConfirm.confirm'),
style: 'destructive',
onPress: () => {
dispatch(resetToDefault());
Alert.alert('', t('personal.tabBarConfig.resetSuccess'));
},
},
]
);
}, [dispatch, t]);
// 渲染单个 Tab 行
const renderTabRow = useCallback(
(item: TabConfig, index: number, total: number) => {
return (
<View key={item.id}>
<View style={styles.tabItem}>
{/* Tab 图标和名称 */}
<View style={styles.tabInfo}>
<View style={styles.iconContainer}>
<IconSymbol name={item.icon as any} size={24} color="#9370DB" />
</View>
<View style={styles.tabTextContainer}>
<Text style={styles.tabTitle}>{t(item.titleKey)}</Text>
{!item.canBeDisabled && (
<Text style={styles.tabSubtitle}>
{t('personal.tabBarConfig.cannotDisable')}
</Text>
)}
</View>
</View>
{/* 开关 */}
<Switch
value={item.enabled}
onValueChange={() => handleToggle(item.id)}
disabled={!item.canBeDisabled}
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
</View>
{/* 分割线 - 最后一项不显示 */}
{index < total - 1 && (
<View style={styles.separatorContainer}>
<View style={styles.separator} />
</View>
)}
</View>
);
},
[handleToggle, t]
);
return (
<View style={styles.container}>
<LinearGradient
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
/>
{/* 顶部导航栏 */}
<HeaderBar
title={t('personal.tabBarConfig.title')}
onBack={() => router.back()}
right={
<TouchableOpacity onPress={handleReset} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Text style={styles.headerRightButton}>
{t('personal.tabBarConfig.resetButton')}
</Text>
</TouchableOpacity>
}
/>
{/* 主内容区 */}
<ScrollView
style={styles.content}
contentContainerStyle={[styles.scrollContent, { paddingTop: safeAreaTop }]} // 增加顶部间距,因为 HeaderBar 现在是 absolute 的
showsVerticalScrollIndicator={false}
>
{/* 说明区域 */}
<View style={styles.headerSection}>
<Text style={styles.subtitle}>{t('personal.tabBarConfig.subtitle')}</Text>
<View style={styles.descriptionCard}>
<View style={styles.hintRow}>
<Ionicons name="information-circle-outline" size={20} color="#9370DB" />
<Text style={styles.descriptionText}>
{t('personal.tabBarConfig.description')}
</Text>
</View>
</View>
</View>
{/* Tab 列表 - 聚合在一个卡片中 */}
<View style={styles.sectionContainer}>
{configs.map((item, index) => renderTabRow(item, index, configs.length))}
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '60%', // 渐变覆盖上半部分即可
},
content: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingBottom: 40,
},
headerSection: {
marginBottom: 16,
},
subtitle: {
fontSize: 14,
color: '#6C757D',
marginBottom: 12,
},
descriptionCard: {
backgroundColor: 'rgba(255, 255, 255, 0.6)',
borderRadius: 12,
padding: 12,
gap: 8,
borderWidth: 1,
borderColor: 'rgba(147, 112, 219, 0.1)',
},
hintRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
descriptionText: {
flex: 1,
fontSize: 13,
color: '#2C3E50',
lineHeight: 18,
},
sectionContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
marginBottom: 20,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 8,
elevation: 2,
},
tabItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
paddingVertical: 16,
},
separatorContainer: {
paddingLeft: 68, // 40(icon) + 12(gap) + 16(padding)
paddingRight: 16,
},
separator: {
height: 1,
backgroundColor: '#F0F0F0',
},
tabInfo: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
iconContainer: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
tabTextContainer: {
flex: 1,
},
tabTitle: {
fontSize: 16,
fontWeight: '500',
color: '#2C3E50',
marginBottom: 2,
},
tabSubtitle: {
fontSize: 12,
color: '#9370DB',
},
switch: {
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
},
headerRightButton: {
fontSize: 15,
fontWeight: '600',
color: '#9370DB', // 使用主色调
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 672 KiB

File diff suppressed because one or more lines are too long

BIN
assets/machine.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -327,11 +327,11 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
const styles = StyleSheet.create({
card: {
borderRadius: 18,
borderRadius: 24,
position: 'relative',
},
cardSurface: {
borderRadius: 18,
borderRadius: 24,
overflow: 'hidden',
},
cardBody: {
@@ -354,7 +354,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderRadius: 18,
borderRadius: 24,
},
thumbnailImage: {
width: '70%',

View File

@@ -0,0 +1,272 @@
import { useI18n } from '@/hooks/useI18n';
import type { MedicationDisplayItem } from '@/types/medication';
import React, { useEffect } from 'react';
import { StyleSheet, TouchableOpacity, View } from 'react-native';
import Animated, {
Extrapolation,
interpolate,
type SharedValue,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
import { MedicationCard } from './MedicationCard';
type Props = {
medications: MedicationDisplayItem[];
colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors];
selectedDate: any;
onOpenDetails: (medication: MedicationDisplayItem) => void;
onCelebrate?: () => void;
};
const STACK_OFFSET = 12;
const STACK_SCALE_STEP = 0.04;
const MAX_STACK_VISIBLE = 3;
export function TakenMedicationsStack({
medications,
colors,
selectedDate,
onOpenDetails,
onCelebrate,
}: Props) {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const progress = useSharedValue(0);
useEffect(() => {
progress.value = withSpring(isExpanded ? 1 : 0, {
damping: 20,
stiffness: 200, // Faster spring
mass: 0.8,
});
}, [isExpanded, progress]);
const handleToggle = () => {
setIsExpanded(!isExpanded);
};
// Header arrow rotation style
const arrowStyle = useAnimatedStyle(() => {
return {
transform: [
{
rotate: `${interpolate(progress.value, [0, 1], [0, 180])}deg`,
},
],
};
});
if (medications.length === 0) {
return null;
}
return (
<View style={styles.container}>
{/* Stack/List Container */}
<View style={[styles.stackContainer, { minHeight: isExpanded ? undefined : 130 }]}>
{medications.map((item, index) => (
<CardItem
key={item.id || index}
item={item}
index={index}
total={medications.length}
progress={progress}
isExpanded={isExpanded}
colors={colors}
selectedDate={selectedDate}
onOpenDetails={onOpenDetails}
onCelebrate={onCelebrate}
onToggle={handleToggle}
/>
))}
</View>
</View>
);
}
const CardItem = ({
item,
index,
total,
progress,
isExpanded,
colors,
selectedDate,
onOpenDetails,
onCelebrate,
onToggle,
}: {
item: MedicationDisplayItem;
index: number;
total: number;
progress: SharedValue<number>;
isExpanded: boolean;
colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors];
selectedDate: any;
onOpenDetails: (medication: MedicationDisplayItem) => void;
onCelebrate?: () => void;
onToggle: () => void;
}) => {
// Only render top 3 cards when collapsed to save performance/visuals
// But we need to render all when expanding.
// We'll hide index >= MAX_STACK_VISIBLE when collapsed via opacity/zIndex.
const style = useAnimatedStyle(() => {
// Stack state (progress = 0)
const stackTranslateY = index * STACK_OFFSET;
const stackScale = 1 - index * STACK_SCALE_STEP;
const stackOpacity = index < MAX_STACK_VISIBLE ? 1 - index * 0.15 : 0;
const stackZIndex = total - index;
// List state (progress = 1)
// In list state, we rely on layout (relative positioning).
// However, to animate smoothly from absolute (stack) to relative (list),
// we need a strategy.
// Strategy: Always Absolute? No, height is dynamic.
// Strategy: Use negative margins for stack?
// Let's try:
// Collapsed: marginTop = -(height - offset).
// Expanded: marginTop = 16 (gap).
// But we don't know height.
// Alternative:
// Use 'top' offset relative to the first card?
// This is hard without measuring.
// Let's go with the "Transform" approach assuming standard card height for the stack effect,
// but switching to relative layout when expanded.
// Wait, switching 'position' prop is not animatable by useAnimatedStyle directly (requires Layout Animation).
// Let's keep it simple:
// When collapsed (progress 0):
// Items > 0 are absolutely positioned relative to the container (which wraps them all).
// Item 0 is relative.
// When expanded (progress 1):
// All items are relative.
// To smooth this, we can use interpolate for translateY.
return {
zIndex: stackZIndex,
opacity: interpolate(progress.value, [0, 1], [stackOpacity, 1]),
transform: [
{
scale: interpolate(progress.value, [0, 1], [stackScale, 1]),
},
{
translateY: interpolate(
progress.value,
[0, 1],
[stackTranslateY, 0] // In stack, they go down. In list, translation is 0 (relative flow handles pos).
),
},
],
};
});
// Logic for positioning:
// We'll use a container View for each card.
// When collapsed, the container height for index > 0 should be 0?
// That would pull them up.
const containerStyle = useAnimatedStyle(() => {
// We can animate the height of the wrapper view.
// But we don't know the content height.
// Assuming ~140px for card.
const approxHeight = 140;
if (index === 0) return {}; // First card always takes space
// For others:
// Collapsed: height is 0 (so they stack on top of first one, roughly)
// Expanded: height is 'auto' (we can't animate to auto easily in RN without LayoutAnimation)
return {
marginTop: interpolate(progress.value, [0, 1], [-approxHeight + STACK_OFFSET, 16], Extrapolation.CLAMP),
};
});
// Using Layout Animation for the actual position change support
// requires the parent to handle it.
// Simpler Visual Hack:
// When collapsed, we just set marginTop to a negative value that overlaps them.
// Since MedicationCard is roughly constant height, we can tune this.
// MedicationCard height is roughly 130-150.
// Let's guess -130 + 12.
const cardContainerStyle = useAnimatedStyle(() => {
// We assume a fixed height for the negative margin calculation logic.
// A better way is needed if heights vary wildly.
// But for now, let's use a safe estimated overlap.
const cardHeight = 140;
const collapsedMarginTop = index === 0 ? 0 : -(cardHeight - STACK_OFFSET);
const expandedMarginTop = index === 0 ? 0 : 16;
return {
marginTop: interpolate(progress.value, [0, 1], [collapsedMarginTop, expandedMarginTop]),
zIndex: total - index,
};
});
return (
<Animated.View style={[cardContainerStyle, style]}>
{/* When collapsed, clicking any card should expand. When expanded, open details. */}
{/* We can intercept touches if !isExpanded */}
<View style={{ position: 'relative' }}>
{/* Overlay to intercept clicks when collapsed */}
{!isExpanded && (
<TouchableOpacity
style={[StyleSheet.absoluteFill, { zIndex: 100, elevation: 100 }]}
onPress={onToggle}
activeOpacity={0.9}
/>
)}
<MedicationCard
medication={item}
colors={colors}
selectedDate={selectedDate}
onOpenDetails={isExpanded ? onOpenDetails : undefined} // Disable inner click when collapsed
onCelebrate={onCelebrate}
/>
</View>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
marginTop: 8,
gap: 12,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 8,
paddingHorizontal: 4,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
iconContainer: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 16,
fontWeight: '600',
},
stackContainer: {
position: 'relative',
// minHeight ensures space for the stack when collapsed
},
});

View File

@@ -83,6 +83,9 @@ export const ROUTES = {
// 药品相关路由
MEDICATION_EDIT_FREQUENCY: '/medications/edit-frequency',
MEDICATION_MANAGE: '/medications/manage-medications',
// 底部栏配置路由
TAB_BAR_CONFIG: '/settings/tab-bar-config',
} as const;
// 路由参数常量

View File

@@ -47,6 +47,7 @@ const personalScreenResources = {
language: '语言',
healthData: '健康数据授权',
medicalSources: '医学建议来源',
customization: '个性化',
},
menu: {
notificationSettings: '通知设置',
@@ -59,6 +60,7 @@ const personalScreenResources = {
deleteAccount: '注销帐号',
healthDataPermissions: '健康数据授权说明',
whoSource: '世界卫生组织 (WHO)',
tabBarConfig: '底部栏配置',
},
language: {
title: '语言',
@@ -77,6 +79,20 @@ const personalScreenResources = {
},
},
},
tabBarConfig: {
title: '底部栏配置',
subtitle: '自定义你的底部导航栏',
description: '使用开关控制标签的显示和隐藏',
resetButton: '恢复默认',
cannotDisable: '此标签不可关闭',
resetConfirm: {
title: '恢复默认设置?',
message: '将重置所有底部栏配置和显示状态',
cancel: '取消',
confirm: '确认恢复',
},
resetSuccess: '已恢复默认设置',
},
};
const badgesScreenResources = {
@@ -458,6 +474,9 @@ const medicationsResources = {
title: '今日暂无用药安排',
subtitle: '还未添加任何用药计划,快来补充吧。',
},
stack: {
completed: '已完成 ({{count}})',
},
dateFormats: {
today: '今天,{{date}}',
other: '{{date}}',
@@ -894,6 +913,7 @@ const resources = {
language: 'Language',
healthData: 'Health data permissions',
medicalSources: 'Medical Advice Sources',
customization: 'Customization',
},
menu: {
notificationSettings: 'Notification settings',
@@ -906,6 +926,7 @@ const resources = {
deleteAccount: 'Delete account',
healthDataPermissions: 'Health data disclosure',
whoSource: 'World Health Organization (WHO)',
tabBarConfig: 'Tab Bar Settings',
},
language: {
title: 'Language',
@@ -1224,6 +1245,9 @@ const resources = {
title: 'No medications scheduled for today',
subtitle: 'No medication plans added yet. Let\'s add some.',
},
stack: {
completed: 'Completed ({{count}})',
},
dateFormats: {
today: 'Today, {{date}}',
other: '{{date}}',
@@ -1607,6 +1631,20 @@ const resources = {
},
},
},
tabBarConfig: {
title: 'Tab Bar Settings',
subtitle: 'Customize your bottom navigation',
description: 'Use toggle to show or hide tabs',
resetButton: 'Reset to Default',
cannotDisable: 'This tab cannot be disabled',
resetConfirm: {
title: 'Reset to default?',
message: 'This will reset all tab bar settings and visibility',
cancel: 'Cancel',
confirm: 'Reset',
},
resetSuccess: 'Settings reset to default',
},
},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 672 KiB

View File

@@ -1,17 +1,17 @@
{
"images" : [
{
"filename" : "logo.png",
"filename" : "onBoarding.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "logo 1.png",
"filename" : "onBoarding 1.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "logo 2.png",
"filename" : "onBoarding 2.png",
"idiom" : "universal",
"scale" : "3x"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -1,15 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="EXPO-VIEWCONTROLLER-1">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24412" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="EXPO-VIEWCONTROLLER-1">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24053.1"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24405"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EXPO-SCENE-1">
<objects>
<viewController storyboardIdentifier="SplashScreenViewController" id="EXPO-VIEWCONTROLLER-1" sceneMemberID="viewController">
@@ -17,16 +17,16 @@
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<imageView id="EXPO-SplashScreen" userLabel="SplashScreenLogo" image="SplashScreenLogo" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
<rect key="frame" x="176.5" y="406" width="40" height="40"/>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="SplashScreenLogo" translatesAutoresizingMaskIntoConstraints="NO" id="EXPO-SplashScreen" userLabel="SplashScreenLogo">
<rect key="frame" x="26" y="255.33333333333334" width="341.33333333333331" height="341.33333333333326"/>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
<constraints>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerX" secondItem="EXPO-ContainerView" secondAttribute="centerX" id="cad2ab56f97c5429bf29decf850647a4216861d4"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerY" secondItem="EXPO-ContainerView" secondAttribute="centerY" id="1a145271b085b6ce89b1405a310f5b1bb7656595"/>
</constraints>
<color key="backgroundColor" name="SplashScreenBackground"/>
<constraints>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerY" secondItem="EXPO-ContainerView" secondAttribute="centerY" id="1a145271b085b6ce89b1405a310f5b1bb7656595"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerX" secondItem="EXPO-ContainerView" secondAttribute="centerX" id="cad2ab56f97c5429bf29decf850647a4216861d4"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
@@ -35,12 +35,9 @@
</scene>
</scenes>
<resources>
<image name="SplashScreenLogo" width="40" height="40"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<image name="SplashScreenLogo" width="341.33334350585938" height="341.33334350585938"/>
<namedColor name="SplashScreenBackground">
<color alpha="1.000" blue="1.00000000000000" green="1.00000000000000" red="1.00000000000000" customColorSpace="sRGB" colorSpace="custom"/>
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>
</document>

View File

@@ -20,6 +20,7 @@ import membershipReducer from './membershipSlice';
import moodReducer from './moodSlice';
import nutritionReducer from './nutritionSlice';
import scheduleExerciseReducer from './scheduleExerciseSlice';
import tabBarConfigReducer from './tabBarConfigSlice';
import trainingPlanReducer from './trainingPlanSlice';
import userReducer from './userSlice';
import waterReducer from './waterSlice';
@@ -113,6 +114,7 @@ export const store = configureStore({
fasting: fastingReducer,
medications: medicationsReducer,
badges: badgesReducer,
tabBarConfig: tabBarConfigReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware),

203
store/tabBarConfigSlice.ts Normal file
View File

@@ -0,0 +1,203 @@
import AsyncStorage from '@/utils/kvStore';
import { logger } from '@/utils/logger';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from './index';
// Tab 配置接口
export interface TabConfig {
id: string; // tab 标识符
icon: string; // SF Symbol 图标名
titleKey: string; // i18n 翻译 key
enabled: boolean; // 是否启用
canBeDisabled: boolean; // 是否可以被禁用
order: number; // 显示顺序
}
// State 接口
interface TabBarConfigState {
configs: TabConfig[];
isInitialized: boolean;
}
// 默认配置
export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
{
id: 'statistics',
icon: 'chart.pie.fill',
titleKey: 'statistics.tabs.health',
enabled: true,
canBeDisabled: false,
order: 1,
},
{
id: 'medications',
icon: 'pills.fill',
titleKey: 'statistics.tabs.medications',
enabled: true,
canBeDisabled: false,
order: 2,
},
{
id: 'fasting',
icon: 'timer',
titleKey: 'statistics.tabs.fasting',
enabled: true,
canBeDisabled: true, // 只有断食可以被关闭
order: 3,
},
{
id: 'challenges',
icon: 'trophy.fill',
titleKey: 'statistics.tabs.challenges',
enabled: true,
canBeDisabled: false,
order: 4,
},
{
id: 'personal',
icon: 'person.fill',
titleKey: 'statistics.tabs.personal',
enabled: true,
canBeDisabled: false,
order: 5,
},
];
// AsyncStorage key
const STORAGE_KEY = 'tab_bar_config';
// 初始状态
const initialState: TabBarConfigState = {
configs: DEFAULT_TAB_CONFIGS,
isInitialized: false,
};
const tabBarConfigSlice = createSlice({
name: 'tabBarConfig',
initialState,
reducers: {
// 设置配置(用于从 AsyncStorage 恢复)
setConfigs: (state, action: PayloadAction<TabConfig[]>) => {
state.configs = action.payload;
state.isInitialized = true;
},
// 切换 tab 启用状态
toggleTabEnabled: (state, action: PayloadAction<string>) => {
const tabId = action.payload;
const config = state.configs.find(c => c.id === tabId);
if (config && config.canBeDisabled) {
config.enabled = !config.enabled;
// 自动持久化到 AsyncStorage
saveConfigsToStorage(state.configs);
}
},
// 更新 tab 顺序(拖拽后)
reorderTabs: (state, action: PayloadAction<TabConfig[]>) => {
// 更新顺序,同时保持其他属性不变
const newConfigs = action.payload.map((config, index) => ({
...config,
order: index + 1,
}));
state.configs = newConfigs;
// 自动持久化到 AsyncStorage
saveConfigsToStorage(newConfigs);
},
// 恢复默认配置
resetToDefault: (state) => {
state.configs = DEFAULT_TAB_CONFIGS;
// 持久化到 AsyncStorage
saveConfigsToStorage(DEFAULT_TAB_CONFIGS);
},
// 标记已初始化
markInitialized: (state) => {
state.isInitialized = true;
},
},
});
// 持久化配置到 AsyncStorage
const saveConfigsToStorage = async (configs: TabConfig[]) => {
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(configs));
logger.info('底部栏配置已保存');
} catch (error) {
logger.error('保存底部栏配置失败:', error);
}
};
// 从 AsyncStorage 加载配置
export const loadTabBarConfigs = () => async (dispatch: any) => {
try {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (stored) {
const configs = JSON.parse(stored) as TabConfig[];
// 验证配置有效性
if (Array.isArray(configs) && configs.length > 0) {
// 合并默认配置,确保新增的 tab 也能显示
const mergedConfigs = mergeWithDefaults(configs);
dispatch(setConfigs(mergedConfigs));
logger.info('底部栏配置已加载');
return;
}
}
// 如果没有存储或无效,使用默认配置
dispatch(setConfigs(DEFAULT_TAB_CONFIGS));
dispatch(markInitialized());
} catch (error) {
logger.error('加载底部栏配置失败:', error);
// 出错时使用默认配置
dispatch(setConfigs(DEFAULT_TAB_CONFIGS));
dispatch(markInitialized());
}
};
// 合并存储的配置和默认配置
const mergeWithDefaults = (storedConfigs: TabConfig[]): TabConfig[] => {
const merged = [...storedConfigs];
// 检查是否有新增的默认 tab
DEFAULT_TAB_CONFIGS.forEach(defaultConfig => {
const exists = merged.find(c => c.id === defaultConfig.id);
if (!exists) {
// 新增的 tab添加到末尾
merged.push({
...defaultConfig,
order: merged.length + 1,
});
}
});
// 按 order 排序
return merged.sort((a, b) => a.order - b.order);
};
// Actions
export const {
setConfigs,
toggleTabEnabled,
reorderTabs,
resetToDefault,
markInitialized,
} = tabBarConfigSlice.actions;
// Selectors
export const selectTabBarConfigs = (state: RootState) => state.tabBarConfig.configs;
export const selectEnabledTabs = (state: RootState) =>
state.tabBarConfig.configs
.filter(config => config.enabled)
.sort((a, b) => a.order - b.order);
export const selectIsInitialized = (state: RootState) => state.tabBarConfig.isInitialized;
// 按 id 获取配置
export const selectTabConfigById = (tabId: string) => (state: RootState) =>
state.tabBarConfig.configs.find(config => config.id === tabId);
export default tabBarConfigSlice.reducer;