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