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 { 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 = {
|
||||
@@ -35,6 +37,9 @@ export default function TabLayout() {
|
||||
const pathname = usePathname();
|
||||
const glassEffectAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// 获取已启用的标签配置(按自定义顺序)
|
||||
const enabledTabs = useAppSelector(selectEnabledTabs);
|
||||
|
||||
// Helper function to determine if a tab is selected
|
||||
const isTabSelected = (routeName: string): boolean => {
|
||||
const routeMap: Record<string, string> = {
|
||||
@@ -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>
|
||||
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)}
|
||||
>
|
||||
{enabledTabs.map((tab) => {
|
||||
const tabConfig = TAB_CONFIGS[tab.id];
|
||||
if (!tabConfig) return null;
|
||||
|
||||
<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') }} />
|
||||
return (
|
||||
<Tabs.Screen
|
||||
key={tab.id}
|
||||
name={tab.id}
|
||||
options={{ title: t(tabConfig.titleKey) }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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',
|
||||
|
||||
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({
|
||||
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%',
|
||||
|
||||
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_MANAGE: '/medications/manage-medications',
|
||||
|
||||
// 底部栏配置路由
|
||||
TAB_BAR_CONFIG: '/settings/tab-bar-config',
|
||||
} as const;
|
||||
|
||||
// 路由参数常量
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 672 KiB |
@@ -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"
|
||||
}
|
||||
|
||||
|
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"?>
|
||||
<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>
|
||||
@@ -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
@@ -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;
|
||||