feat(ui): 添加底部标签栏自定义配置功能和药物堆叠展示
- 新增底部标签栏配置页面,支持切换标签显示/隐藏和恢复默认设置 - 实现已服用药物的堆叠卡片展示,优化药物列表视觉层次 - 集成Redux状态管理底部标签栏配置,支持本地持久化 - 优化个人中心页面背景渐变效果,移除装饰性圆圈元素 - 更新启动页和应用图标为新的品牌视觉 - 药物详情页AI分析加载动画替换为Lottie动画 - 调整药物卡片圆角半径提升视觉一致性 - 新增多语言支持(中英文)用于标签栏配置界面 主要改进: 1. 用户可以自定义底部导航栏显示内容 2. 已完成的药物以堆叠形式展示,节省空间 3. 配置数据通过AsyncStorage持久化保存 4. 支持默认配置恢复功能
@@ -12,7 +12,9 @@ import { IconSymbol } from '@/components/ui/IconSymbol';
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||||||
|
import { useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { selectEnabledTabs } from '@/store/tabBarConfigSlice';
|
||||||
|
|
||||||
// Tab configuration
|
// Tab configuration
|
||||||
type TabConfig = {
|
type TabConfig = {
|
||||||
@@ -34,6 +36,9 @@ export default function TabLayout() {
|
|||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const glassEffectAvailable = isLiquidGlassAvailable();
|
const glassEffectAvailable = isLiquidGlassAvailable();
|
||||||
|
|
||||||
|
// 获取已启用的标签配置(按自定义顺序)
|
||||||
|
const enabledTabs = useAppSelector(selectEnabledTabs);
|
||||||
|
|
||||||
// Helper function to determine if a tab is selected
|
// Helper function to determine if a tab is selected
|
||||||
const isTabSelected = (routeName: string): boolean => {
|
const isTabSelected = (routeName: string): boolean => {
|
||||||
@@ -174,42 +179,45 @@ export default function TabLayout() {
|
|||||||
tabBarShowLabel: false,
|
tabBarShowLabel: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 根据配置渲染标签页
|
||||||
if (glassEffectAvailable) {
|
if (glassEffectAvailable) {
|
||||||
return <NativeTabs>
|
return (
|
||||||
<NativeTabs.Trigger name="statistics">
|
<NativeTabs>
|
||||||
<Label>{t('statistics.tabs.health')}</Label>
|
{enabledTabs.map((tab) => {
|
||||||
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
|
const tabConfig = TAB_CONFIGS[tab.id];
|
||||||
</NativeTabs.Trigger>
|
if (!tabConfig) return null;
|
||||||
<NativeTabs.Trigger name="medications">
|
|
||||||
<Icon sf="pills.fill" drawable="custom_android_drawable" />
|
return (
|
||||||
<Label>{t('statistics.tabs.medications')}</Label>
|
<NativeTabs.Trigger key={tab.id} name={tab.id}>
|
||||||
</NativeTabs.Trigger>
|
<Icon sf={tabConfig.icon as any} drawable="custom_android_drawable" />
|
||||||
<NativeTabs.Trigger name="fasting">
|
<Label>{t(tabConfig.titleKey)}</Label>
|
||||||
<Icon sf="timer" drawable="custom_android_drawable" />
|
</NativeTabs.Trigger>
|
||||||
<Label>{t('statistics.tabs.fasting')}</Label>
|
);
|
||||||
</NativeTabs.Trigger>
|
})}
|
||||||
<NativeTabs.Trigger name="challenges">
|
</NativeTabs>
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 确定初始路由(第一个启用的标签)
|
||||||
|
const initialRouteName = enabledTabs.length > 0 ? enabledTabs[0].id : 'statistics';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
initialRouteName="statistics"
|
initialRouteName={initialRouteName}
|
||||||
screenOptions={({ route }) => getScreenOptions(route.name)}
|
screenOptions={({ route }) => getScreenOptions(route.name)}
|
||||||
>
|
>
|
||||||
|
{enabledTabs.map((tab) => {
|
||||||
<Tabs.Screen name="statistics" options={{ title: t('statistics.tabs.health') }} />
|
const tabConfig = TAB_CONFIGS[tab.id];
|
||||||
<Tabs.Screen name="medications" options={{ title: t('statistics.tabs.medications') }} />
|
if (!tabConfig) return null;
|
||||||
<Tabs.Screen name="fasting" options={{ title: t('statistics.tabs.fasting') }} />
|
|
||||||
<Tabs.Screen name="challenges" options={{ title: t('statistics.tabs.challenges') }} />
|
return (
|
||||||
<Tabs.Screen name="personal" options={{ title: t('statistics.tabs.personal') }} />
|
<Tabs.Screen
|
||||||
|
key={tab.id}
|
||||||
|
name={tab.id}
|
||||||
|
options={{ title: t(tabConfig.titleKey) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
|
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
|
||||||
import { DateSelector } from '@/components/DateSelector';
|
import { DateSelector } from '@/components/DateSelector';
|
||||||
import { MedicationCard } from '@/components/medication/MedicationCard';
|
import { MedicationCard } from '@/components/medication/MedicationCard';
|
||||||
|
import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack';
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||||
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
|
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
|
||||||
@@ -189,6 +190,16 @@ export default function MedicationsScreen() {
|
|||||||
return medicationsWithImages.filter((item: any) => item.status === activeFilter);
|
return medicationsWithImages.filter((item: any) => item.status === activeFilter);
|
||||||
}, [activeFilter, medicationsWithImages]);
|
}, [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 counts = useMemo(() => {
|
||||||
const taken = medicationsWithImages.filter((item: any) => item.status === 'taken').length;
|
const taken = medicationsWithImages.filter((item: any) => item.status === 'taken').length;
|
||||||
// "未服用"计数包含 missed(已错过)和 upcoming(待服用)
|
// "未服用"计数包含 missed(已错过)和 upcoming(待服用)
|
||||||
@@ -354,7 +365,8 @@ export default function MedicationsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.cardsWrapper}>
|
<View style={styles.cardsWrapper}>
|
||||||
{filteredMedications.map((item: any) => (
|
{/* 渲染未服用的药物 */}
|
||||||
|
{activeMedications.map((item: any) => (
|
||||||
<MedicationCard
|
<MedicationCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
medication={item}
|
medication={item}
|
||||||
@@ -364,6 +376,17 @@ export default function MedicationsScreen() {
|
|||||||
onCelebrate={handleMedicationTakenCelebration}
|
onCelebrate={handleMedicationTakenCelebration}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* 渲染已完成(服用/跳过)的药物堆叠 */}
|
||||||
|
{completedMedications.length > 0 && (
|
||||||
|
<TakenMedicationsStack
|
||||||
|
medications={completedMedications}
|
||||||
|
colors={colors}
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
onOpenDetails={(item) => handleOpenMedicationDetails(item.medicationId)}
|
||||||
|
onCelebrate={handleMedicationTakenCelebration}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
||||||
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
|
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
|
||||||
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||||
|
import { palette } from '@/constants/Colors';
|
||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
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(需要连续点击三次用户名激活)
|
// 开发者section(需要连续点击三次用户名激活)
|
||||||
...(showDeveloperSection ? [{
|
...(showDeveloperSection ? [{
|
||||||
title: t('personal.sections.developer'),
|
title: t('personal.sections.developer'),
|
||||||
@@ -698,16 +709,12 @@ export default function PersonalScreen() {
|
|||||||
|
|
||||||
{/* 背景渐变 */}
|
{/* 背景渐变 */}
|
||||||
<LinearGradient
|
<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}
|
style={styles.gradientBackground}
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 0, y: 1 }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 装饰性圆圈 */}
|
|
||||||
<View style={styles.decorativeCircle1} />
|
|
||||||
<View style={styles.decorativeCircle2} />
|
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
@@ -759,33 +766,14 @@ export default function PersonalScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
backgroundColor: '#F5F5F5',
|
||||||
},
|
},
|
||||||
gradientBackground: {
|
gradientBackground: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
height: '60%',
|
||||||
},
|
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
scrollView: {
|
scrollView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ export default function ExploreScreen() {
|
|||||||
<View style={styles.headerContent}>
|
<View style={styles.headerContent}>
|
||||||
{/* 左边logo */}
|
{/* 左边logo */}
|
||||||
<Image
|
<Image
|
||||||
source={require('@/assets/icon.icon/Assets/icon-1756312748268.png')}
|
source={require('@/assets/machine.png')}
|
||||||
style={styles.logoImage}
|
style={styles.logoImage}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { useAuthGuard } from '@/hooks/useAuthGuard';
|
|||||||
import { STORAGE_KEYS, setUnauthorizedHandler } from '@/services/api';
|
import { STORAGE_KEYS, setUnauthorizedHandler } from '@/services/api';
|
||||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
|
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
|
||||||
import { fetchChallenges } from '@/store/challengesSlice';
|
import { fetchChallenges } from '@/store/challengesSlice';
|
||||||
|
import { loadTabBarConfigs } from '@/store/tabBarConfigSlice';
|
||||||
import AsyncStorage from '@/utils/kvStore';
|
import AsyncStorage from '@/utils/kvStore';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
@@ -120,6 +121,11 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [isLoggedIn]);
|
}, [isLoggedIn]);
|
||||||
|
|
||||||
|
// 初始化底部栏配置
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(loadTabBarConfigs());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
// ==================== 基础服务初始化(不需要权限,总是执行)====================
|
// ==================== 基础服务初始化(不需要权限,总是执行)====================
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const initializeBasicServices = async () => {
|
const initializeBasicServices = async () => {
|
||||||
@@ -515,6 +521,7 @@ export default function RootLayout() {
|
|||||||
options={{ headerShown: false }}
|
options={{ headerShown: false }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="badges/index" 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.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="dark" />
|
<StatusBar style="dark" />
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { Image } from 'expo-image';
|
|||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import LottieView from 'lottie-react-native';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -1299,8 +1300,13 @@ export default function MedicationDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{aiAnalysisLoading && (
|
{aiAnalysisLoading && (
|
||||||
<View style={styles.aiLoadingRow}>
|
<View style={styles.aiLoadingContainer}>
|
||||||
<ActivityIndicator color={colors.primary} size="small" />
|
<LottieView
|
||||||
|
source={require('@/assets/lottie/loading-blue.json')}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
style={styles.aiLoadingAnimation}
|
||||||
|
/>
|
||||||
<Text style={[styles.aiLoadingText, { color: colors.textSecondary }]}>
|
<Text style={[styles.aiLoadingText, { color: colors.textSecondary }]}>
|
||||||
{t('medications.detail.aiAnalysis.analyzing')}
|
{t('medications.detail.aiAnalysis.analyzing')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -2276,13 +2282,19 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
aiLoadingRow: {
|
aiLoadingContainer: {
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 24,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
aiLoadingAnimation: {
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
},
|
},
|
||||||
aiLoadingText: {
|
aiLoadingText: {
|
||||||
fontSize: 13,
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
aiHeroRow: {
|
aiHeroRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
266
app/settings/tab-bar-config.tsx
Normal 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', // 使用主色调
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
assets/logo.png
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 672 KiB |
1
assets/lottie/loading-blue.json
Normal file
BIN
assets/machine.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
@@ -327,11 +327,11 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
borderRadius: 18,
|
borderRadius: 24,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
cardSurface: {
|
cardSurface: {
|
||||||
borderRadius: 18,
|
borderRadius: 24,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
cardBody: {
|
cardBody: {
|
||||||
@@ -354,7 +354,7 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
borderRadius: 18,
|
borderRadius: 24,
|
||||||
},
|
},
|
||||||
thumbnailImage: {
|
thumbnailImage: {
|
||||||
width: '70%',
|
width: '70%',
|
||||||
|
|||||||
272
components/medication/TakenMedicationsStack.tsx
Normal 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
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -83,6 +83,9 @@ export const ROUTES = {
|
|||||||
// 药品相关路由
|
// 药品相关路由
|
||||||
MEDICATION_EDIT_FREQUENCY: '/medications/edit-frequency',
|
MEDICATION_EDIT_FREQUENCY: '/medications/edit-frequency',
|
||||||
MEDICATION_MANAGE: '/medications/manage-medications',
|
MEDICATION_MANAGE: '/medications/manage-medications',
|
||||||
|
|
||||||
|
// 底部栏配置路由
|
||||||
|
TAB_BAR_CONFIG: '/settings/tab-bar-config',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 路由参数常量
|
// 路由参数常量
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const personalScreenResources = {
|
|||||||
language: '语言',
|
language: '语言',
|
||||||
healthData: '健康数据授权',
|
healthData: '健康数据授权',
|
||||||
medicalSources: '医学建议来源',
|
medicalSources: '医学建议来源',
|
||||||
|
customization: '个性化',
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
notificationSettings: '通知设置',
|
notificationSettings: '通知设置',
|
||||||
@@ -59,6 +60,7 @@ const personalScreenResources = {
|
|||||||
deleteAccount: '注销帐号',
|
deleteAccount: '注销帐号',
|
||||||
healthDataPermissions: '健康数据授权说明',
|
healthDataPermissions: '健康数据授权说明',
|
||||||
whoSource: '世界卫生组织 (WHO)',
|
whoSource: '世界卫生组织 (WHO)',
|
||||||
|
tabBarConfig: '底部栏配置',
|
||||||
},
|
},
|
||||||
language: {
|
language: {
|
||||||
title: '语言',
|
title: '语言',
|
||||||
@@ -77,6 +79,20 @@ const personalScreenResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
tabBarConfig: {
|
||||||
|
title: '底部栏配置',
|
||||||
|
subtitle: '自定义你的底部导航栏',
|
||||||
|
description: '使用开关控制标签的显示和隐藏',
|
||||||
|
resetButton: '恢复默认',
|
||||||
|
cannotDisable: '此标签不可关闭',
|
||||||
|
resetConfirm: {
|
||||||
|
title: '恢复默认设置?',
|
||||||
|
message: '将重置所有底部栏配置和显示状态',
|
||||||
|
cancel: '取消',
|
||||||
|
confirm: '确认恢复',
|
||||||
|
},
|
||||||
|
resetSuccess: '已恢复默认设置',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const badgesScreenResources = {
|
const badgesScreenResources = {
|
||||||
@@ -458,6 +474,9 @@ const medicationsResources = {
|
|||||||
title: '今日暂无用药安排',
|
title: '今日暂无用药安排',
|
||||||
subtitle: '还未添加任何用药计划,快来补充吧。',
|
subtitle: '还未添加任何用药计划,快来补充吧。',
|
||||||
},
|
},
|
||||||
|
stack: {
|
||||||
|
completed: '已完成 ({{count}})',
|
||||||
|
},
|
||||||
dateFormats: {
|
dateFormats: {
|
||||||
today: '今天,{{date}}',
|
today: '今天,{{date}}',
|
||||||
other: '{{date}}',
|
other: '{{date}}',
|
||||||
@@ -894,6 +913,7 @@ const resources = {
|
|||||||
language: 'Language',
|
language: 'Language',
|
||||||
healthData: 'Health data permissions',
|
healthData: 'Health data permissions',
|
||||||
medicalSources: 'Medical Advice Sources',
|
medicalSources: 'Medical Advice Sources',
|
||||||
|
customization: 'Customization',
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
notificationSettings: 'Notification settings',
|
notificationSettings: 'Notification settings',
|
||||||
@@ -906,6 +926,7 @@ const resources = {
|
|||||||
deleteAccount: 'Delete account',
|
deleteAccount: 'Delete account',
|
||||||
healthDataPermissions: 'Health data disclosure',
|
healthDataPermissions: 'Health data disclosure',
|
||||||
whoSource: 'World Health Organization (WHO)',
|
whoSource: 'World Health Organization (WHO)',
|
||||||
|
tabBarConfig: 'Tab Bar Settings',
|
||||||
},
|
},
|
||||||
language: {
|
language: {
|
||||||
title: 'Language',
|
title: 'Language',
|
||||||
@@ -1224,6 +1245,9 @@ const resources = {
|
|||||||
title: 'No medications scheduled for today',
|
title: 'No medications scheduled for today',
|
||||||
subtitle: 'No medication plans added yet. Let\'s add some.',
|
subtitle: 'No medication plans added yet. Let\'s add some.',
|
||||||
},
|
},
|
||||||
|
stack: {
|
||||||
|
completed: 'Completed ({{count}})',
|
||||||
|
},
|
||||||
dateFormats: {
|
dateFormats: {
|
||||||
today: 'Today, {{date}}',
|
today: 'Today, {{date}}',
|
||||||
other: '{{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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 672 KiB |
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "logo.png",
|
"filename" : "onBoarding.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "logo 1.png",
|
"filename" : "onBoarding 1.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "logo 2.png",
|
"filename" : "onBoarding 2.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 225 KiB |
BIN
ios/OutLive/Images.xcassets/SplashScreenLogo.imageset/onBoarding 1.png
vendored
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
ios/OutLive/Images.xcassets/SplashScreenLogo.imageset/onBoarding 2.png
vendored
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
ios/OutLive/Images.xcassets/SplashScreenLogo.imageset/onBoarding.png
vendored
Normal file
|
After Width: | Height: | Size: 124 KiB |
@@ -1,15 +1,15 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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"/>
|
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<deployment identifier="iOS"/>
|
<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="Named colors" minToolsVersion="9.0"/>
|
||||||
<capability name="Safe area layout guides" 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"/>
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<scenes>
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
<scene sceneID="EXPO-SCENE-1">
|
<scene sceneID="EXPO-SCENE-1">
|
||||||
<objects>
|
<objects>
|
||||||
<viewController storyboardIdentifier="SplashScreenViewController" id="EXPO-VIEWCONTROLLER-1" sceneMemberID="viewController">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<imageView id="EXPO-SplashScreen" userLabel="SplashScreenLogo" image="SplashScreenLogo" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="SplashScreenLogo" translatesAutoresizingMaskIntoConstraints="NO" id="EXPO-SplashScreen" userLabel="SplashScreenLogo">
|
||||||
<rect key="frame" x="176.5" y="406" width="40" height="40"/>
|
<rect key="frame" x="26" y="255.33333333333334" width="341.33333333333331" height="341.33333333333326"/>
|
||||||
</imageView>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
|
<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"/>
|
<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>
|
</view>
|
||||||
</viewController>
|
</viewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
@@ -35,12 +35,9 @@
|
|||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="SplashScreenLogo" width="40" height="40"/>
|
<image name="SplashScreenLogo" width="341.33334350585938" height="341.33334350585938"/>
|
||||||
<systemColor name="systemBackgroundColor">
|
|
||||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
</systemColor>
|
|
||||||
<namedColor name="SplashScreenBackground">
|
<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>
|
</namedColor>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import membershipReducer from './membershipSlice';
|
|||||||
import moodReducer from './moodSlice';
|
import moodReducer from './moodSlice';
|
||||||
import nutritionReducer from './nutritionSlice';
|
import nutritionReducer from './nutritionSlice';
|
||||||
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
||||||
|
import tabBarConfigReducer from './tabBarConfigSlice';
|
||||||
import trainingPlanReducer from './trainingPlanSlice';
|
import trainingPlanReducer from './trainingPlanSlice';
|
||||||
import userReducer from './userSlice';
|
import userReducer from './userSlice';
|
||||||
import waterReducer from './waterSlice';
|
import waterReducer from './waterSlice';
|
||||||
@@ -113,6 +114,7 @@ export const store = configureStore({
|
|||||||
fasting: fastingReducer,
|
fasting: fastingReducer,
|
||||||
medications: medicationsReducer,
|
medications: medicationsReducer,
|
||||||
badges: badgesReducer,
|
badges: badgesReducer,
|
||||||
|
tabBarConfig: tabBarConfigReducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
||||||
|
|||||||
203
store/tabBarConfigSlice.ts
Normal 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;
|
||||||