- 新增底部标签栏配置页面,支持切换标签显示/隐藏和恢复默认设置 - 实现已服用药物的堆叠卡片展示,优化药物列表视觉层次 - 集成Redux状态管理底部标签栏配置,支持本地持久化 - 优化个人中心页面背景渐变效果,移除装饰性圆圈元素 - 更新启动页和应用图标为新的品牌视觉 - 药物详情页AI分析加载动画替换为Lottie动画 - 调整药物卡片圆角半径提升视觉一致性 - 新增多语言支持(中英文)用于标签栏配置界面 主要改进: 1. 用户可以自定义底部导航栏显示内容 2. 已完成的药物以堆叠形式展示,节省空间 3. 配置数据通过AsyncStorage持久化保存 4. 支持默认配置恢复功能
272 lines
8.0 KiB
TypeScript
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
|
|
},
|
|
}); |