feat(nutrition): 添加营养分析历史记录删除和图片预览功能
- 新增删除营养分析记录功能,支持本地状态更新和API调用 - 添加图片全屏预览功能,支持缩放和手势操作 - 实现Liquid Glass风格的删除按钮,包含兼容性处理 - 优化历史记录页面布局和交互体验 - 更新Memory Bank文档,添加Liquid Glass按钮实现指南
This commit is contained in:
@@ -61,4 +61,88 @@ const styles = StyleSheet.create({
|
||||
- `app/water/detail.tsx`
|
||||
- `app/profile/goals.tsx`
|
||||
- `app/workout/history.tsx`
|
||||
- `app/challenges/[id]/leaderboard.tsx`
|
||||
- `app/challenges/[id]/leaderboard.tsx`
|
||||
|
||||
## Liquid Glass 风格图标按钮实现
|
||||
|
||||
**最后更新**: 2025-10-16
|
||||
|
||||
### 问题描述
|
||||
在应用中实现符合 Liquid Glass 设计风格的图标按钮,需要考虑毛玻璃效果和兼容性处理。
|
||||
|
||||
### 解决方案
|
||||
使用 `GlassView` 组件实现毛玻璃效果,并提供不支持 Liquid Glass 的设备的降级方案。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 导入必要的组件和函数
|
||||
```typescript
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
```
|
||||
|
||||
#### 2. 检查设备支持情况
|
||||
```typescript
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
```
|
||||
|
||||
#### 3. 实现条件渲染的按钮
|
||||
```typescript
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
disabled={isLoading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isGlassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.glassButton}
|
||||
glassEffectStyle="clear" // 或 "regular"
|
||||
tintColor="rgba(244, 67, 54, 0.2)" // 自定义色调
|
||||
isInteractive={true} // 启用交互反馈
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#F44336" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.glassButton, styles.fallbackButton]}>
|
||||
<Ionicons name="trash-outline" size={20} color="#F44336" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
```
|
||||
|
||||
#### 4. 定义样式
|
||||
```typescript
|
||||
const styles = StyleSheet.create({
|
||||
glassButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18, // 圆形按钮
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden', // 保证玻璃边界圆角效果
|
||||
},
|
||||
fallbackButton: {
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(244, 67, 54, 0.3)',
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
1. **兼容性处理**:必须使用 `isLiquidGlassAvailable()` 检查设备支持情况
|
||||
2. **overflow: 'hidden'**:GlassView 组件需要设置此属性以保证圆角效果
|
||||
3. **降级样式**:为不支持 Liquid Glass 的设备提供视觉上相似的替代方案
|
||||
4. **交互反馈**:设置 `isInteractive={true}` 启用原生的触觉反馈
|
||||
5. **色调自定义**:通过 `tintColor` 属性自定义按钮的颜色主题
|
||||
|
||||
### 常用配置
|
||||
- **glassEffectStyle**: "clear"(透明)或 "regular"(常规)
|
||||
- **tintColor**: 根据按钮功能选择合适的颜色
|
||||
- 删除操作:红色系 `rgba(244, 67, 54, 0.2)`
|
||||
- 确认操作:绿色系 `rgba(76, 175, 80, 0.2)`
|
||||
- 信息操作:蓝色系 `rgba(33, 150, 243, 0.2)`
|
||||
|
||||
### 参考实现
|
||||
- `app/food/nutrition-analysis-history.tsx` - 删除按钮实现
|
||||
- `components/glass/button.tsx` - 通用 Glass 按钮组件
|
||||
- `app/(tabs)/_layout.tsx` - 标签栏按钮实现
|
||||
@@ -16,7 +16,7 @@
|
||||
### UI 框架和样式
|
||||
- **React Native Elements**: UI 组件库
|
||||
- **Expo UI**: 0.2.0-beta.7 - Expo UI 组件
|
||||
- **Expo Glass Effect**: 0.1.4 - Liquid Glass 毛玻璃效果
|
||||
- **Expo Glass Effect**: 0.1.4 - Liquid Glass 毛玻璃效果, 优先使用
|
||||
- **React Native Reanimated**: 4.1.0 - 高性能动画库
|
||||
- **React Native Gesture Handler**: 2.28.0 - 手势处理
|
||||
- **React Native SVG**: 15.12.1 - SVG 图形支持
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
deleteNutritionAnalysisRecord,
|
||||
getNutritionAnalysisRecords,
|
||||
type GetNutritionRecordsParams,
|
||||
type NutritionAnalysisRecord,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
import { triggerLightHaptic } from '@/utils/haptics';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
@@ -17,6 +19,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
BackHandler,
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
@@ -24,6 +27,7 @@ import {
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import ImageViewing from 'react-native-image-viewing';
|
||||
|
||||
export default function NutritionAnalysisHistoryScreen() {
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
@@ -39,6 +43,23 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||
const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// 处理Android返回键关闭图片预览
|
||||
useEffect(() => {
|
||||
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
if (showImagePreview) {
|
||||
setShowImagePreview(false);
|
||||
return true; // 阻止默认返回行为
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => backHandler.remove();
|
||||
}, [showImagePreview]);
|
||||
|
||||
// 获取历史记录
|
||||
const fetchRecords = useCallback(async (page: number = 1, isRefresh: boolean = false, currentStatusFilter?: string) => {
|
||||
@@ -177,6 +198,52 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
};
|
||||
};
|
||||
|
||||
// 处理图片预览
|
||||
const handleImagePreview = useCallback((imageUrl: string) => {
|
||||
triggerLightHaptic();
|
||||
setPreviewImageUri(imageUrl);
|
||||
setShowImagePreview(true);
|
||||
}, []);
|
||||
|
||||
// 处理删除记录
|
||||
const handleDeleteRecord = useCallback((recordId: number) => {
|
||||
Alert.alert(
|
||||
'确认删除',
|
||||
'确定要删除这条营养分析记录吗?此操作无法撤销。',
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
setDeletingId(recordId);
|
||||
await deleteNutritionAnalysisRecord(recordId);
|
||||
|
||||
// 从本地状态中移除删除的记录
|
||||
setRecords(prev => prev.filter(record => record.id !== recordId));
|
||||
setTotal(prev => Math.max(0, prev - 1));
|
||||
|
||||
// 触发轻微震动反馈
|
||||
triggerLightHaptic();
|
||||
|
||||
// 显示成功提示
|
||||
Alert.alert('成功', '记录已删除');
|
||||
} catch (error) {
|
||||
console.error('[HISTORY] 删除记录失败:', error);
|
||||
Alert.alert('错误', '删除失败,请稍后重试');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 渲染历史记录项
|
||||
const renderRecordItem = useCallback(({ item }: { item: NutritionAnalysisRecord }) => {
|
||||
const isExpanded = expandedItems.has(item.id);
|
||||
@@ -187,6 +254,11 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
{/* 头部信息 */}
|
||||
<View style={styles.recordHeader}>
|
||||
<View style={styles.recordInfo}>
|
||||
{isSuccess && (
|
||||
<Text style={styles.recordTitle}>
|
||||
识别 {item.nutritionCount} 项营养素
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.recordDate}>
|
||||
{dayjs(item.createdAt).format('YYYY年M月D日 HH:mm')}
|
||||
</Text>
|
||||
@@ -195,22 +267,54 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isSuccess && (
|
||||
<Text style={styles.nutritionCount}>
|
||||
识别 {item.nutritionCount} 项营养素
|
||||
</Text>
|
||||
)}
|
||||
{/* 删除按钮 */}
|
||||
<TouchableOpacity
|
||||
onPress={() => handleDeleteRecord(item.id)}
|
||||
disabled={deletingId === item.id}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isGlassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.glassDeleteButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(244, 67, 54, 0.2)"
|
||||
isInteractive={true}
|
||||
>
|
||||
{deletingId === item.id ? (
|
||||
<ActivityIndicator size="small" color="#F44336" />
|
||||
) : (
|
||||
<Ionicons name="trash-outline" size={20} color="#F44336" />
|
||||
)}
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.glassDeleteButton, styles.fallbackDeleteButton]}>
|
||||
{deletingId === item.id ? (
|
||||
<ActivityIndicator size="small" color="#F44336" />
|
||||
) : (
|
||||
<Ionicons name="trash-outline" size={20} color="#F44336" />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 图片预览 */}
|
||||
{item.imageUrl && (
|
||||
<View style={styles.imageContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.imageContainer}
|
||||
onPress={() => handleImagePreview(item.imageUrl)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: item.imageUrl }}
|
||||
style={styles.thumbnail}
|
||||
contentFit="cover"
|
||||
/>
|
||||
</View>
|
||||
{/* 预览提示图标 */}
|
||||
<View style={styles.previewHint}>
|
||||
<Ionicons name="expand-outline" size={16} color="#FFF" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 分析结果摘要 */}
|
||||
@@ -439,6 +543,33 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
ListFooterComponent={renderFooter}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图片预览 */}
|
||||
<ImageViewing
|
||||
images={previewImageUri ? [{ uri: previewImageUri }] : []}
|
||||
imageIndex={0}
|
||||
visible={showImagePreview}
|
||||
onRequestClose={() => setShowImagePreview(false)}
|
||||
swipeToCloseEnabled={true}
|
||||
doubleTapToZoomEnabled={true}
|
||||
HeaderComponent={() => (
|
||||
<View style={styles.imageViewerHeader}>
|
||||
<Text style={styles.imageViewerHeaderText}>
|
||||
{dayjs().format('YYYY年M月D日 HH:mm')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
FooterComponent={() => (
|
||||
<View style={styles.imageViewerFooter}>
|
||||
<TouchableOpacity
|
||||
style={styles.imageViewerFooterButton}
|
||||
onPress={() => setShowImagePreview(false)}
|
||||
>
|
||||
<Text style={styles.imageViewerFooterButtonText}>关闭</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -508,12 +639,17 @@ const styles = StyleSheet.create({
|
||||
recordInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
recordDate: {
|
||||
recordTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.text,
|
||||
marginBottom: 4,
|
||||
},
|
||||
recordDate: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.textSecondary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
@@ -525,19 +661,36 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '500',
|
||||
color: '#FFF',
|
||||
},
|
||||
nutritionCount: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.textSecondary,
|
||||
fontWeight: '500',
|
||||
glassDeleteButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackDeleteButton: {
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(244, 67, 54, 0.3)',
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
},
|
||||
imageContainer: {
|
||||
marginBottom: 12,
|
||||
position: 'relative',
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: 120,
|
||||
borderRadius: 12,
|
||||
},
|
||||
previewHint: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: 16,
|
||||
padding: 6,
|
||||
},
|
||||
summaryContainer: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
@@ -709,4 +862,41 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
// ImageViewing 组件样式
|
||||
imageViewerHeader: {
|
||||
position: 'absolute',
|
||||
top: 60,
|
||||
left: 20,
|
||||
right: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
zIndex: 1,
|
||||
},
|
||||
imageViewerHeaderText: {
|
||||
color: '#FFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
},
|
||||
imageViewerFooter: {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
left: 20,
|
||||
right: 20,
|
||||
alignItems: 'center',
|
||||
zIndex: 1,
|
||||
},
|
||||
imageViewerFooterButton: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 20,
|
||||
},
|
||||
imageViewerFooterButtonText: {
|
||||
color: '#FFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
@@ -451,13 +451,18 @@ const styles = StyleSheet.create({
|
||||
placeholderContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#F8F9FA',
|
||||
backgroundColor: '#FFFFFF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 20,
|
||||
borderWidth: 2,
|
||||
borderColor: '#E9ECEF',
|
||||
borderStyle: 'dashed',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.1)',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 4,
|
||||
},
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
placeholderContent: {
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -229,3 +229,16 @@ export async function getNutritionAnalysisRecords(params?: GetNutritionRecordsPa
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除营养成分分析记录
|
||||
*/
|
||||
export async function deleteNutritionAnalysisRecord(recordId: number): Promise<any> {
|
||||
try {
|
||||
const response = await api.delete(`/diet-records/nutrition-analysis-records/${recordId}`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[NUTRITION_RECORDS] 删除记录失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user