filename:
app/(tabs)/Metrics.jsx
branch:
master
back to repo
import { View, Text, StyleSheet, ScrollView, DeviceEventEmitter, Modal, Button, Animated } from 'react-native'
import React, { useEffect, useMemo, useCallback, useRef } from 'react'
import { useState } from 'react';
import { TouchableOpacity } from 'react-native';
import { AntDesign, MaterialCommunityIcons, Ionicons } from '@expo/vector-icons';
import { Dropdown } from 'react-native-element-dropdown';
import { getAuth } from 'firebase/auth';
import { FIREBASE_APP } from '../../FirebaseConfig';
import MetricsGraphElement from '../../components/MetricsGraphElement';
import { calculateAwards } from '../../functions/awardCalculations';
import { useAppTheme, subscribeToTheme } from '../../hooks/colorScheme';
import { assembleMetricsHistory } from '../../functions/assembleMetricsHistory';
export default function Metrics() {
const [colors, setColors] = useState(useAppTheme());
const fadeAnim = useRef(new Animated.Value(0)).current;
const awardFadeAnims = useRef({
streak: new Animated.Value(0),
perfectDays: new Animated.Value(0),
totalDays: new Animated.Value(0),
averageAccuracy: new Animated.Value(0)
}).current;
const smartTruncate = (text, limit) => {
return text.length > limit ? text.slice(0, limit) + '...' : text;
};
const [metrics, setMetrics] = useState([]);
const [awards, setAwards] = useState({
streak: 0,
totalDays: 0,
averageAccuracy: 0,
perfectDays: 0
});
const [displayConfig, setDisplayConfig] = useState({
maxValues: {
protein: 200,
calories: 2100,
fats: 70,
carbs: 250
},
maxItems: 7,
barWidth: 30,
tracking: "calories",
spacing: 10
});
const [stat, setStat] = useState('calories');
const [time, setTime] = useState(1);
const auth = getAuth(FIREBASE_APP);
// Memoize static data
const statSelectorData = useMemo(() => ([
{ value: 'calories', label: 'Calories' }, // Calories first
{ value: 'protein', label: 'Protein' },
{ value: 'fat', label: 'Fat' },
{ value: 'carbs', label: 'Carbs' }
]), []);
const timeSelectorData = useMemo(() => [
{value: 1, label: 'Week'},
...(metrics.length >= 15 ? [{value: 2, label: 'Month'}] : []),
...(metrics.length >= 60 ? [{value: 3, label: 'Year'}] : []),
...(metrics.length >= 365 ? [{value: 4, label: 'All Time'}] : [])
], [metrics.length]);
const getUnitForNutrient = useCallback((nutrient) => {
const units = {
protein: 'g',
fats: 'g',
carbs: 'g',
calories: ''
};
return units[nutrient] || '';
}, []);
const findMax = useCallback((array) => {
return Math.max(
...array.map(element => Math.max(
element.actual[displayConfig.tracking],
element.goals[displayConfig.tracking]
))
);
}, [displayConfig.tracking]);
const getData = useCallback(async () => {
try {
if (!auth.currentUser) {
console.warn('No user logged in');
return;
}
fadeAnim.setValue(0); // Reset graph opacity
// Reset all award animations
Object.keys(awardFadeAnims).forEach(key => {
awardFadeAnims[key].setValue(0);
});
const metricsData = await assembleMetricsHistory(auth.currentUser.uid);
if (!Array.isArray(metricsData) || metricsData.length === 0) {
console.warn('No metrics data returned');
setMetrics([]);
return;
}
setMetrics(metricsData);
// Calculate awards immediately after setting metrics
const calculatedAwards = calculateAwards(metricsData);
setAwards(calculatedAwards);
const reversedData = [...metricsData].reverse();
const currentMaxItems = Math.min(displayConfig.maxItems, metricsData.length);
const relevantData = reversedData.slice(0, currentMaxItems);
if (relevantData.length > 0) {
const maxValues = {};
Object.keys(relevantData[0].actual).forEach(nutrient => {
maxValues[nutrient] = findMax(relevantData);
});
setDisplayConfig(prev => ({
...prev,
maxValues
}));
// Start all animations together after data is processed
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 500,
useNativeDriver: true
}),
...Object.keys(awardFadeAnims).map(key =>
Animated.timing(awardFadeAnims[key], {
toValue: 1,
duration: 500,
useNativeDriver: true
})
)
]).start();
}
} catch (error) {
console.error("Error fetching metrics:", error);
setMetrics([]);
}
}, [displayConfig.maxItems, findMax, auth.currentUser, fadeAnim, awardFadeAnims]);
const getDisplayData = useMemo(() => {
const maxForDisplay = 120;
if (!metrics.length) return [];
const allData = [...metrics].reverse();
if(displayConfig.maxItems > maxForDisplay) {
const divisor = Math.ceil(displayConfig.maxItems / maxForDisplay);
const temp = [];
allData.slice(0, displayConfig.maxItems).reverse().forEach((item, index) => {
if(index % divisor === 0) {
temp.push(item);
}
});
return temp;
}
return allData.slice(0, displayConfig.maxItems).reverse();
}, [metrics, displayConfig.maxItems]);
const handleStatChange = useCallback((item) => {
setStat(item.value);
setDisplayConfig(prev => ({
...prev,
tracking: item.value
}));
}, []);
const handleTimeChange = useCallback((item) => {
let newBarWidth;
let newMaxItems;
switch(item.value) {
case 1: // Week
newMaxItems = 7;
newBarWidth = 30;
break;
case 2: // Month
newMaxItems = 30;
newBarWidth = 8;
break;
case 3: // Year
newMaxItems = 365;
newBarWidth = 2.5;
break;
case 4: // All Time
newMaxItems = metrics.length;
newBarWidth = 2;
break;
default:
newMaxItems = 7;
newBarWidth = 30;
}
setTime(item.value);
setDisplayConfig(prev => ({
...prev,
maxItems: newMaxItems,
barWidth: newBarWidth,
spacing: item.value === 4 ? 2 : 10
}));
}, [metrics.length]);
useEffect(() => {
let mounted = true;
const loadData = async () => {
if (!mounted) return;
await getData();
};
const subscription = DeviceEventEmitter.addListener('foodHistoryChanged', loadData);
loadData();
return () => {
mounted = false;
subscription.remove();
setMetrics([]);
setAwards({
streak: 0,
totalDays: 0,
averageAccuracy: 0,
perfectDays: 0
});
};
}, [getData]);
useEffect(() => {
const unsubscribe = auth.onAuthStateChanged(user => {
if (!user) {
setMetrics([]);
setAwards({
streak: 0,
totalDays: 0,
averageAccuracy: 0,
perfectDays: 0
});
}
});
return () => unsubscribe();
}, []);
useEffect(() => {
const unsubscribe = subscribeToTheme(newColors => {
setColors(newColors);
});
return () => unsubscribe();
}, []);
const [streakDetailsVisible, setStreakDetailsVisible] = useState(false);
const toggleStreakDetails = () => setStreakDetailsVisible(!streakDetailsVisible);
const [perfectDetailsVisible, setPerfectDetailsVisible] = useState(false);
const togglePerfectDetails = () => setPerfectDetailsVisible(!perfectDetailsVisible);
const [totalDetailsVisible, setTotalDetailsVisible] = useState(false);
const toggleTotalDetails = () => setTotalDetailsVisible(!totalDetailsVisible);
const [accuracyDetailsVisible, setAccuracyDetailsVisible] = useState(false);
const toggleAccuracyDetails = () => setAccuracyDetailsVisible(!accuracyDetailsVisible);
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.contentWrapper}>
<View style={[styles.card, styles.graphCard, { backgroundColor: colors.boxes }]}>
<View style={styles.graphTitleContainer}>
<View style={[styles.dropdownWrapper, styles.statWrapper]}>
<TouchableOpacity
activeOpacity={0.7}
style={[styles.dropdownButton, { backgroundColor: colors.boxes }]}
>
<Dropdown
containerStyle={[styles.dropdownContainer, {backgroundColor: colors.boxes}]}
style={styles.titleDropdown}
selectedTextStyle={[styles.titleDropdownText, {color: colors.text}]}
placeholderStyle={[styles.titleDropdownText, {color: colors.text}]}
itemTextStyle={[styles.dropdownText, {color: colors.text}]}
activeColor={colors.innerBoxes}
iconColor={colors.accent}
iconStyle={styles.dropdownIcon}
valueField="value"
labelField="label"
data={statSelectorData}
value={stat}
onChange={handleStatChange}
showsVerticalScrollIndicator={false}
renderLeftIcon={() => (
<AntDesign name="barschart" size={20} color={colors.accent} style={{marginRight: 8}} />
)}
/>
</TouchableOpacity>
</View>
<View style={[styles.dropdownWrapper, styles.timeWrapper]}>
<TouchableOpacity
activeOpacity={0.7}
style={[styles.dropdownButton, { backgroundColor: colors.boxes }]}
>
<Dropdown
containerStyle={[styles.dropdownContainer, {backgroundColor: colors.boxes}]}
style={styles.timeDropdown}
selectedTextStyle={[styles.titleDropdownText, {color: colors.text}]}
placeholderStyle={[styles.titleDropdownText, {color: colors.text}]}
itemTextStyle={[styles.dropdownText, {color: colors.text}]}
activeColor={colors.innerBoxes}
iconColor={colors.accent}
iconStyle={styles.dropdownIcon}
valueField="value"
labelField="label"
data={timeSelectorData}
value={time}
onChange={handleTimeChange}
showsVerticalScrollIndicator={false}
renderLeftIcon={() => (
<AntDesign name="calendar" size={20} color={colors.accent} style={{marginRight: 8}} />
)}
/>
</TouchableOpacity>
</View>
</View>
<View style={[{flexDirection: "row", marginTop: 10, width: '100%'}]}>
<View style={[{backgroundColor: colors.text, width: 2, height: 190, marginRight: 5}]}>
<Text
numberOfLines={1}
style={[{
fontSize: 8,
backgroundColor: colors.boxes,
color: colors.text,
alignSelf: 'center',
width: 30,
height: 12,
zIndex: 10,
textAlign: 'center'
}]}
>
{smartTruncate(
Math.round(displayConfig.maxValues[displayConfig.tracking]) +
getUnitForNutrient(displayConfig.tracking),
5
)}
</Text>
<View style={[{backgroundColor: colors.text, width: 10, height: 2, alignSelf: 'center'}]}></View>
</View>
<Animated.View style={[styles.graphWrapper, { opacity: fadeAnim }]}>
<ScrollView
horizontal
scrollEnabled={false}
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{getDisplayData.map((item) => (
<MetricsGraphElement
key={item.date}
values={[
item.actual[displayConfig.tracking],
item.goals[displayConfig.tracking],
colors.greenColor,
displayConfig.barWidth,
displayConfig.maxValues[displayConfig.tracking],
]}
/>
))}
</ScrollView>
</Animated.View>
</View>
<View style={[{backgroundColor: colors.text, width: '100%', height: 2, justifyContent: 'center'}]}>
<View style={[{backgroundColor: colors.text, width: 2, height: 10, alignSelf: 'flex-end'}]}></View>
</View>
<View style={[{width: '100%', flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 15, marginTop: 5}]}>
<Text style={{color: colors.text}}>
{getDisplayData.length > 0
? `${new Date(getDisplayData[0].date).toDateString().split(' ')[1]} ${new Date(getDisplayData[0].date).toDateString().split(' ')[2]}${time >= 3 ? ' ' + new Date(getDisplayData[0].date).toDateString().split(' ')[3] : ''}`
: ''
}
</Text>
<Text style={{color: colors.text}}>
{getDisplayData.length > 0
? `${new Date().toDateString().split(' ')[1]} ${new Date().toDateString().split(' ')[2]}${time >= 3 ? ' ' + new Date(getDisplayData[getDisplayData.length - 1].date).toDateString().split(' ')[3] : ''}`
: ''
}
</Text>
</View>
</View>
<View style={[ styles.awardsCard, { }]}>
<View style={[styles.awardRowConatiner, {}]}>
<TouchableOpacity
onPress={toggleStreakDetails}
activeOpacity={0.7}
>
<View style={[styles.award, styles.card, {backgroundColor: colors.boxes}]}>
<View style={styles.awardContent}>
<MaterialCommunityIcons name="fire" size={40} color={colors.accent} />
<Text style={[styles.awardTitle, {color: colors.text}]}>Daily Streak</Text>
<Animated.Text style={[styles.awardValue, {color: colors.text, opacity: awardFadeAnims.streak}]}>
{awards.streak} days
</Animated.Text>
</View>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={togglePerfectDetails}
activeOpacity={0.7}
>
<View style={[styles.award, styles.card, {backgroundColor: colors.boxes}]}>
<View style={styles.awardContent}>
<MaterialCommunityIcons name="star-circle" size={35} color={colors.blueColor} />
<Text style={[styles.awardTitle, {color: colors.text}]}>Perfect Days</Text>
<Animated.Text style={[styles.awardValue, {color: colors.text, opacity: awardFadeAnims.perfectDays}]}>
{awards.perfectDays}
</Animated.Text>
</View>
</View>
</TouchableOpacity>
</View>
<View style={[styles.awardRowConatiner, {}]}>
<TouchableOpacity
onPress={toggleTotalDetails}
activeOpacity={0.7}
>
<View style={[styles.award, styles.card, {backgroundColor: colors.boxes}]}>
<View style={styles.awardContent}>
<Ionicons name="calendar" size={35} color={colors.greenColor} />
<Text style={[styles.awardTitle, {color: colors.text}]}>Total Days</Text>
<Animated.Text style={[styles.awardValue, {color: colors.text, opacity: awardFadeAnims.totalDays}]}>
{awards.totalDays}
</Animated.Text>
</View>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={toggleAccuracyDetails}
activeOpacity={0.7}
>
<View style={[styles.award, styles.card, {backgroundColor: colors.boxes}]}>
<View style={styles.awardContent}>
<MaterialCommunityIcons name="target" size={40} color={colors.yellowColor} />
<Text style={[styles.awardTitle, {color: colors.text}]}>Goal Accuracy</Text>
<Animated.Text style={[styles.awardValue, {color: colors.text, opacity: awardFadeAnims.averageAccuracy}]}>
{awards.averageAccuracy}%
</Animated.Text>
</View>
</View>
</TouchableOpacity>
</View>
</View>
</View>
<Modal
animationType="fade"
transparent={true}
visible={streakDetailsVisible}
onRequestClose={toggleStreakDetails}
>
<View style={[styles.modalView, styles.card, { backgroundColor: colors.boxes }]}>
<MaterialCommunityIcons name="fire" size={60} color={colors.accent} />
<Animated.Text style={[styles.awardValue, {color: colors.text, opacity: awardFadeAnims.streak}]}>
{awards.streak} days
</Animated.Text>
<Text style={[styles.modalText, {color: colors.text}]}>{'\n'}Get back to work</Text>
<Button
style={[styles.modalText]}
color={colors.accent}
title="Close"
onPress={toggleStreakDetails}
/>
</View>
</Modal>
<Modal
animationType="fade"
transparent={true}
visible={perfectDetailsVisible}
onRequestClose={togglePerfectDetails}
>
<View style={[styles.modalView, styles.card, { backgroundColor: colors.boxes }]}>
<MaterialCommunityIcons name="star-circle" size={60} color={colors.blueColor} />
<Animated.Text style={[styles.awardValue, {color: colors.text, opacity: awardFadeAnims.perfectDays}]}>
{awards.perfectDays} days
</Animated.Text>
<Text style={[styles.modalText, {color: colors.text}]}>{'\n'}You should try harder</Text>
<Button
style={[styles.modalText]}
color={colors.accent}
title="Close"
onPress={togglePerfectDetails}
/>
</View>
</Modal>
<Modal
animationType="fade"
transparent={true}
visible={totalDetailsVisible}
onRequestClose={toggleTotalDetails}
>
<View style={[styles.modalView, styles.card, { backgroundColor: colors.boxes }]}>
<Ionicons name="calendar" size={60} color={colors.greenColor} />
<Animated.Text style={[styles.awardValue, {color: colors.text, opacity: awardFadeAnims.totalDays}]}>
{awards.totalDays} days
</Animated.Text>
<Text style={[styles.modalText, {color: colors.text}]}>{'\n'}Thats not very good</Text>
<Button
style={[styles.modalText]}
color={colors.accent}
title="Close"
onPress={toggleTotalDetails}
/>
</View>
</Modal>
<Modal
animationType="fade"
transparent={true}
visible={accuracyDetailsVisible}
onRequestClose={toggleAccuracyDetails}
>
<View style={[styles.modalView, styles.card, { backgroundColor: colors.boxes }]}>
<MaterialCommunityIcons name="target" size={60} color={colors.yellowColor} />
<Animated.Text style={[styles.awardValue, {color: colors.text, opacity: awardFadeAnims.averageAccuracy}]}>
{awards.averageAccuracy}%
</Animated.Text>
<Text style={[styles.modalText, {color: colors.text}]}>{'\n'}You can do better</Text>
<Button
style={[styles.modalText]}
color={colors.accent}
title="Close"
onPress={toggleAccuracyDetails}
/>
</View>
</Modal>
</View>
)}
const styles = StyleSheet.create({
container: {
flex: 1,
},
modalText: {
fontSize: 16,
},
modalView: {
gap: 5,
justifyContent: 'center',
marginHorizontal: 'auto',
marginVertical: 'auto',
alignItems: 'center',
width: '80%',
height: '35%',
},
contentWrapper: {
flex: 1,
padding: 16,
gap: 16,
},
card: {
borderRadius: 20,
padding: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 5,
},
graphCard: {
marginTop: 65,
flex: 0,
minHeight: 350,
},
awardsCard: {
flex: 1,
gap: 16,
},
awardRowConatiner: {
flex: 1,
gap: 16,
justifyContent: 'space-evenly',
alignItems: 'center',
flexDirection: 'row',
},
award: {
borderRadius: 20,
width: 130,
height: 130,
justifyContent: 'center',
alignItems: 'center',
padding: 10,
},
awardContent: {
alignItems: 'center',
justifyContent: 'center',
gap: 5,
},
awardTitle: {
fontSize: 14,
fontWeight: '600',
textAlign: 'center',
},
awardValue: {
fontSize: 18,
fontWeight: '700',
textAlign: 'center',
},
graphTitleContainer: {
marginBottom: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
},
graphWrapper: {
flex: 1,
marginBottom: 5,
alignItems: 'center',
justifyContent: 'center',
width: '100%',
},
scrollContent: {
flexDirection: 'row',
justifyContent: 'space-evenly',
alignItems: 'flex-end',
width: '100%',
},
dropdownWrapper: {
flex: 1,
maxWidth: '49%',
},
dropdownContainer: {
borderRadius: 8,
overflow: 'hidden',
},
dropdownIcon: {
width: 16,
height: 16,
},
dropdownText: {
fontSize: 16,
fontWeight: '600',
paddingVertical: 8,
},
dropdownButton: {
borderRadius: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
titleDropdown: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
height: 48,
},
timeDropdown: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
height: 48,
},
titleDropdownText: {
fontSize: 18,
fontWeight: '700',
textAlign: 'center',
flex: 1,
},
timeDropdownText: {
fontSize: 16,
opacity: 0.7,
fontWeight: '400',
textAlign: 'center',
flex: 1,
},
});