Files
height_manager/client/screens/stats/index.tsx

347 lines
12 KiB
TypeScript
Raw Normal View History

import React, { useState, useEffect, useMemo } from 'react';
import { View, ScrollView, TouchableOpacity, TextInput, Modal, Alert } from 'react-native';
import { useTheme } from '@/hooks/useTheme';
import { Screen } from '@/components/Screen';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { FontAwesome6 } from '@expo/vector-icons';
import { createStyles } from './styles';
const MOCK_USER_ID = 'mock-user-001';
interface WeightRecord {
id: string;
weight: number;
note: string;
recordedAt: string;
}
export default function StatsScreen() {
const { theme, isDark } = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const [weightRecords, setWeightRecords] = useState<WeightRecord[]>([]);
const [modalVisible, setModalVisible] = useState(false);
const [newWeight, setNewWeight] = useState('');
const [newNote, setNewNote] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
// 获取体重记录
const fetchWeightRecords = async () => {
setLoading(true);
try {
/**
* server/src/routes/weight-records.ts
* GET /api/v1/weight-records
* Query userId: string
*/
const response = await fetch(
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/weight-records?userId=${MOCK_USER_ID}`
);
const data = await response.json();
if (data.success) {
setWeightRecords(data.data);
}
} catch (error) {
console.error('Failed to fetch weight records:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchWeightRecords();
}, []);
// 添加体重记录
const addWeightRecord = async () => {
if (!newWeight) {
Alert.alert('提示', '请输入体重');
return;
}
setSaving(true);
try {
const recordData = {
userId: MOCK_USER_ID,
weight: parseFloat(newWeight),
note: newNote,
recordedAt: new Date().toISOString(),
};
/**
* server/src/routes/weight-records.ts
* POST /api/v1/weight-records
* Body userId: string, weight: number, note?: string, recordedAt: string
*/
const response = await fetch(
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/weight-records`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recordData),
}
);
const data = await response.json();
if (data.success) {
Alert.alert('成功', '记录已保存', [
{ text: '确定', onPress: () => resetModal() },
]);
fetchWeightRecords();
} else {
Alert.alert('失败', data.error || '保存失败,请重试');
}
} catch (error) {
console.error('Save error:', error);
Alert.alert('错误', '保存失败,请检查网络连接');
} finally {
setSaving(false);
}
};
const resetModal = () => {
setModalVisible(false);
setNewWeight('');
setNewNote('');
};
// 删除记录
const deleteRecord = async (id: string) => {
Alert.alert('确认删除', '确定要删除这条记录吗?', [
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: async () => {
try {
/**
* server/src/routes/weight-records.ts
* DELETE /api/v1/weight-records/:id
* Path id: string
*/
const response = await fetch(
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/weight-records/${id}`,
{
method: 'DELETE',
}
);
const data = await response.json();
if (data.success) {
fetchWeightRecords();
}
} catch (error) {
console.error('Delete error:', error);
}
},
},
]);
};
return (
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.header}>
<ThemedText variant="h2" color={theme.textPrimary}>
</ThemedText>
<TouchableOpacity
style={styles.addButton}
onPress={() => setModalVisible(true)}
>
<FontAwesome6 name="plus" size={18} color={theme.buttonPrimaryText} />
</TouchableOpacity>
</View>
{/* 体重趋势 */}
<ThemedView level="root" style={styles.chartSection}>
<View style={styles.sectionHeader}>
<ThemedText variant="h4" color={theme.textPrimary}>
</ThemedText>
{weightRecords.length > 0 && (
<ThemedText variant="small" color={theme.primary}>
{weightRecords.length}
</ThemedText>
)}
</View>
{weightRecords.length === 0 ? (
<ThemedText variant="small" color={theme.textMuted} style={styles.emptyText}>
</ThemedText>
) : (
<View style={styles.recordsList}>
{weightRecords.map((record, index) => (
<View key={record.id} style={styles.recordItem}>
<View style={styles.recordInfo}>
<View style={styles.recordWeight}>
<ThemedText variant="h3" color={theme.primary}>
{record.weight}
</ThemedText>
<ThemedText variant="small" color={theme.textMuted}>
kg
</ThemedText>
</View>
<View>
<ThemedText variant="small" color={theme.textSecondary}>
{new Date(record.recordedAt).toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
})}
</ThemedText>
{record.note && (
<ThemedText variant="caption" color={theme.textMuted}>
{record.note}
</ThemedText>
)}
</View>
</View>
{index > 0 && (
<View style={styles.changeBadge}>
<FontAwesome6
name={record.weight < weightRecords[index - 1].weight ? 'arrow-down' : 'arrow-up'}
size={12}
color={record.weight < weightRecords[index - 1].weight ? '#10B981' : '#EF4444'}
/>
<ThemedText
variant="caption"
color={record.weight < weightRecords[index - 1].weight ? '#10B981' : '#EF4444'}
>
{Math.abs(record.weight - weightRecords[index - 1].weight).toFixed(1)}
</ThemedText>
</View>
)}
<TouchableOpacity onPress={() => deleteRecord(record.id)}>
<FontAwesome6 name="trash" size={16} color={theme.textMuted} />
</TouchableOpacity>
</View>
))}
</View>
)}
</ThemedView>
{/* 统计信息 */}
{weightRecords.length >= 2 && (
<ThemedView level="root" style={styles.statsSection}>
<ThemedText variant="h4" color={theme.textPrimary} style={styles.sectionTitle}>
</ThemedText>
<View style={styles.statRow}>
<View style={styles.statItem}>
<ThemedText variant="small" color={theme.textMuted}>
</ThemedText>
<ThemedText variant="h4" color={theme.textPrimary}>
{weightRecords[weightRecords.length - 1].weight} kg
</ThemedText>
</View>
<View style={[styles.statItem, styles.statBorder]}>
<ThemedText variant="small" color={theme.textMuted}>
</ThemedText>
<ThemedText variant="h4" color={theme.primary}>
{weightRecords[0].weight} kg
</ThemedText>
</View>
<View style={styles.statItem}>
<ThemedText variant="small" color={theme.textMuted}>
</ThemedText>
<ThemedText
variant="h4"
color={
weightRecords[0].weight < weightRecords[weightRecords.length - 1].weight
? '#10B981'
: '#EF4444'
}
>
{weightRecords[0].weight < weightRecords[weightRecords.length - 1].weight ? '-' : '+'}
{Math.abs(
weightRecords[0].weight - weightRecords[weightRecords.length - 1].weight
).toFixed(1)}{' '}
kg
</ThemedText>
</View>
</View>
</ThemedView>
)}
</ScrollView>
{/* 添加记录 Modal */}
<Modal visible={modalVisible} transparent animationType="fade">
<View style={styles.modalContainer}>
<ThemedView level="default" style={styles.modalContent}>
<View style={styles.modalHeader}>
<ThemedText variant="h4" color={theme.textPrimary}>
</ThemedText>
<TouchableOpacity onPress={resetModal}>
<FontAwesome6 name="xmark" size={24} color={theme.textSecondary} />
</TouchableOpacity>
</View>
<View style={styles.modalBody}>
<View style={styles.inputGroup}>
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
(kg)
</ThemedText>
<TextInput
style={styles.input}
placeholder="输入体重"
placeholderTextColor={theme.textMuted}
value={newWeight}
onChangeText={setNewWeight}
keyboardType="decimal-pad"
/>
</View>
<View style={styles.inputGroup}>
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
()
</ThemedText>
<TextInput
style={[styles.input, styles.textArea]}
placeholder="添加备注"
placeholderTextColor={theme.textMuted}
value={newNote}
onChangeText={setNewNote}
multiline
numberOfLines={3}
/>
</View>
</View>
<View style={styles.modalFooter}>
<TouchableOpacity
style={[styles.modalButton, styles.cancelButton]}
onPress={resetModal}
>
<ThemedText variant="smallMedium" color={theme.textSecondary}>
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalButton, styles.confirmButton]}
onPress={addWeightRecord}
>
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
{saving ? '保存中...' : '保存'}
</ThemedText>
</TouchableOpacity>
</View>
</ThemedView>
</View>
</Modal>
</Screen>
);
}