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

主要改进:
1. 用户可以自定义底部导航栏显示内容
2. 已完成的药物以堆叠形式展示,节省空间
3. 配置数据通过AsyncStorage持久化保存
4. 支持默认配置恢复功能
2025-11-20 17:55:17 +08:00

272 lines
8.0 KiB
TypeScript

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
},
});