feat: Phase 4 — 9 new features (v1.1)
Some checks failed
CI / Build & test backend (push) Failing after 14m56s

REQ-MOB-010: BarcodeScreen.tsx — barcode scanner via react-native-camera
REQ-VIZ-001: WeeklyCalorieChart.tsx — 7-day bar chart on History screen
REQ-VIZ-002: Streak tracker — GET /meals/streak + HomeScreen badge
REQ-UX-001: Quick-add calories — POST /meals/quick-add + QuickAddScreen
REQ-UX-002: Food favourites — UserFoodMemory.favourite + toggle endpoint + FoodRow star
REQ-UX-003: GoalBanner.tsx — in-app slide-in when daily target hit
REQ-EXP-001: ExportController — GET /export/meals CSV download
REQ-WTR-001: Water tracking — WaterEntry entity + POST/GET /water + DailyDetails widget
REQ-UX-004: Daily logging reminder — HomeScreen after-18:00 banner

Also: Flyway V2 (favourite), V3 (water_entries), V4 (source constraints)
Traceability, CHANGELOG, PLAN updated after each feature
This commit is contained in:
2026-05-19 02:11:23 +03:00
parent 904f1c43b3
commit 12820632e7
46 changed files with 8151 additions and 63 deletions

View File

@@ -8,23 +8,42 @@ import { Spacing } from '../theme/spacing';
interface FoodRowProps {
item: FoodItem;
onSelect: (item: FoodItem) => void;
/** Whether this food is currently starred. REQ-UX-002 */
isFavourite?: boolean;
/** Called when the star icon is tapped. REQ-UX-002 */
onToggleFavourite?: () => void;
}
/**
* Single food result row in the search screen.
* REQ-MOB-006
* Includes an optional star toggle for the favourites feature.
* REQ-MOB-006, REQ-UX-002
*/
export default function FoodRow({ item, onSelect }: FoodRowProps) {
export default function FoodRow({ item, onSelect, isFavourite, onToggleFavourite }: FoodRowProps) {
return (
<TouchableOpacity
style={styles.row}
onPress={() => onSelect(item)}
accessibilityRole="button"
accessibilityLabel={`${item.name}, ${item.caloriesPer100g} calories per 100 grams`}
>
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.kcal}>{item.caloriesPer100g} kcal / 100g</Text>
</TouchableOpacity>
<View style={styles.row}>
<TouchableOpacity
style={styles.main}
onPress={() => onSelect(item)}
accessibilityRole="button"
accessibilityLabel={`${item.name}, ${item.caloriesPer100g} calories per 100 grams`}
>
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.kcal}>{item.caloriesPer100g} kcal / 100g</Text>
</TouchableOpacity>
{onToggleFavourite && (
<TouchableOpacity
style={styles.starButton}
onPress={onToggleFavourite}
accessibilityRole="button"
accessibilityLabel={isFavourite ? `Remove ${item.name} from favourites` : `Add ${item.name} to favourites`}
accessibilityState={{ selected: isFavourite }}
>
<Text style={styles.star}>{isFavourite ? '⭐' : '☆'}</Text>
</TouchableOpacity>
)}
</View>
);
}
@@ -32,11 +51,23 @@ const styles = StyleSheet.create({
row: {
minHeight: Spacing.touchTarget,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: Colors.gray100,
flexDirection: 'row',
alignItems: 'center',
},
main: {
flex: 1,
paddingVertical: Spacing.sm,
justifyContent: 'center',
},
name: { fontSize: 16, fontWeight: '500', color: Colors.gray900 },
kcal: { fontSize: 13, color: Colors.gray500, marginTop: 2 },
starButton: {
width: Spacing.touchTarget,
height: Spacing.touchTarget,
justifyContent: 'center',
alignItems: 'center',
},
star: { fontSize: 20 },
});

View File

@@ -0,0 +1,90 @@
// Generated by GitHub Copilot
import React, { useEffect, useRef } from 'react';
import { Animated, Text, StyleSheet, AccessibilityInfo } from 'react-native';
import { Colors } from '../theme/colors';
import { Spacing } from '../theme/spacing';
interface GoalBannerProps {
/** When true the banner slides in; hides automatically after 4 seconds. */
visible: boolean;
}
/**
* Slide-in banner shown when the user reaches their daily calorie goal.
* Auto-dismisses after 4 seconds.
* In-app only — no native push notification required.
* REQ-UX-003
*
* Accessibility: announces the goal achievement via `AccessibilityInfo.announceForAccessibility`
* so screen readers hear it even though the banner is transient.
*/
export default function GoalBanner({ visible }: GoalBannerProps) {
const slideAnim = useRef(new Animated.Value(-80)).current;
const opacityAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (!visible) return;
// Announce for screen readers
AccessibilityInfo.announceForAccessibility("Goal reached! You've hit your daily calorie target.");
// Slide in
Animated.parallel([
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, bounciness: 8 }),
Animated.timing(opacityAnim, { toValue: 1, duration: 200, useNativeDriver: true }),
]).start();
// Auto-dismiss after 4 seconds
const timer = setTimeout(() => {
Animated.parallel([
Animated.timing(slideAnim, { toValue: -80, duration: 300, useNativeDriver: true }),
Animated.timing(opacityAnim, { toValue: 0, duration: 300, useNativeDriver: true }),
]).start();
}, 4000);
return () => clearTimeout(timer);
}, [visible, slideAnim, opacityAnim]);
if (!visible) return null;
return (
<Animated.View
style={[
styles.banner,
{ transform: [{ translateY: slideAnim }], opacity: opacityAnim },
]}
accessible
accessibilityRole="alert"
accessibilityLabel="Goal reached! You've hit your daily calorie target."
>
<Text style={styles.text}>🎉 Goal reached! Daily target hit.</Text>
</Animated.View>
);
}
const styles = StyleSheet.create({
banner: {
position: 'absolute',
top: Spacing.sm,
left: Spacing.md,
right: Spacing.md,
backgroundColor: Colors.primary,
borderRadius: Spacing.borderRadius.md,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
zIndex: 999,
elevation: 6,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
minHeight: Spacing.touchTarget,
justifyContent: 'center',
},
text: {
color: Colors.white,
fontSize: 15,
fontWeight: '600',
textAlign: 'center',
},
});

View File

@@ -0,0 +1,209 @@
// Generated by GitHub Copilot
import React from 'react';
import { View, Text, StyleSheet, useWindowDimensions } from 'react-native';
import { Colors } from '../theme/colors';
import { Spacing } from '../theme/spacing';
interface DayData {
/** ISO date string YYYY-MM-DD */
date: string;
totalCalories: number;
}
interface WeeklyCalorieChartProps {
/** Exactly 7 days of data, oldest first. Missing days should have totalCalories: 0. */
days: DayData[];
/** User's daily calorie target — drawn as a dashed target line. */
target: number;
}
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const CHART_HEIGHT = 120;
/**
* Proportional-height bar chart for the last 7 days.
* Green bars = at/under target; amber bars = over target.
* Pure React Native View implementation — no extra dependencies.
* REQ-VIZ-001
*
* Accessibility: aria summary label on the containing View describes
* the week's totals for screen reader users.
*/
export default function WeeklyCalorieChart({ days, target }: WeeklyCalorieChartProps) {
const { width } = useWindowDimensions();
const chartWidth = width - Spacing.md * 2;
const maxCalories = Math.max(...days.map(d => d.totalCalories), target, 1);
// Fraction of chart height a given kcal value occupies
const heightFraction = (kcal: number) =>
Math.min(Math.round((kcal / maxCalories) * CHART_HEIGHT), CHART_HEIGHT);
// Target line position from bottom (percentage of chart area)
const targetY = CHART_HEIGHT - heightFraction(target);
const totalForWeek = days.reduce((sum, d) => sum + d.totalCalories, 0);
const accessibilitySummary = `Weekly chart: ${Math.round(totalForWeek)} kcal total over 7 days. Target is ${target} kcal per day.`;
return (
<View
style={[styles.container, { width: chartWidth }]}
accessible
accessibilityLabel={accessibilitySummary}
>
<Text style={styles.title}>Last 7 days</Text>
{/* Chart area */}
<View style={[styles.chartArea, { height: CHART_HEIGHT }]}>
{/* Target line */}
<View
style={[styles.targetLine, { bottom: heightFraction(target) }]}
accessibilityElementsHidden
/>
{/* Bars */}
<View style={styles.barsRow}>
{days.map((day, index) => {
const barHeight = heightFraction(day.totalCalories);
const overTarget = day.totalCalories > target;
const dayOfWeek = DAY_LABELS[new Date(day.date).getDay()];
const kcalLabel = day.totalCalories > 0
? `${Math.round(day.totalCalories)}`
: '';
return (
<View
key={day.date}
style={styles.barColumn}
accessible
accessibilityLabel={`${dayOfWeek}: ${day.totalCalories > 0 ? Math.round(day.totalCalories) + ' kcal' : 'no data'}`}
>
{/* Kcal label above bar */}
{day.totalCalories > 0 && (
<Text style={styles.barLabel} numberOfLines={1}>{kcalLabel}</Text>
)}
{/* Bar */}
<View
style={[
styles.bar,
{ height: Math.max(barHeight, 2) },
overTarget ? styles.barOver : styles.barUnder,
]}
/>
{/* Day label */}
<Text style={styles.dayLabel}>{dayOfWeek}</Text>
</View>
);
})}
</View>
</View>
{/* Legend */}
<View style={styles.legend}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: Colors.primary }]} />
<Text style={styles.legendText}>At/under target</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: Colors.warning }]} />
<Text style={styles.legendText}>Over target</Text>
</View>
<View style={styles.legendItem}>
<View style={styles.legendLineSample} />
<Text style={styles.legendText}>{target} kcal goal</Text>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: Colors.background,
borderRadius: Spacing.borderRadius.md,
padding: Spacing.md,
marginBottom: Spacing.md,
borderWidth: 1,
borderColor: Colors.gray100,
},
title: {
fontSize: 14,
fontWeight: '600',
color: Colors.gray500,
marginBottom: Spacing.sm,
},
chartArea: {
position: 'relative',
marginBottom: Spacing.xs,
},
targetLine: {
position: 'absolute',
left: 0,
right: 0,
height: 1,
borderWidth: 1,
borderColor: Colors.gray300,
borderStyle: 'dashed',
zIndex: 1,
},
barsRow: {
flexDirection: 'row',
alignItems: 'flex-end',
height: CHART_HEIGHT,
gap: Spacing.xs,
},
barColumn: {
flex: 1,
alignItems: 'center',
justifyContent: 'flex-end',
height: CHART_HEIGHT,
},
barLabel: {
fontSize: 9,
color: Colors.gray500,
marginBottom: 2,
textAlign: 'center',
},
bar: {
width: '80%',
borderRadius: 3,
minHeight: 2,
},
barUnder: { backgroundColor: Colors.primary },
barOver: { backgroundColor: Colors.warning },
dayLabel: {
fontSize: 10,
color: Colors.gray500,
marginTop: 4,
textAlign: 'center',
},
legend: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: Spacing.sm,
marginTop: Spacing.xs,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
legendDot: {
width: 8,
height: 8,
borderRadius: 4,
},
legendLineSample: {
width: 16,
height: 1,
borderWidth: 1,
borderColor: Colors.gray300,
borderStyle: 'dashed',
},
legendText: {
fontSize: 10,
color: Colors.gray500,
},
});

View File

@@ -13,6 +13,8 @@ import SearchScreen from '../screens/SearchScreen';
import AIResultScreen from '../screens/AIResultScreen';
import EditMealScreen from '../screens/EditMealScreen';
import CameraScreen from '../screens/CameraScreen';
import BarcodeScreen from '../screens/BarcodeScreen';
import QuickAddScreen from '../screens/QuickAddScreen';
import DailyDetailsScreen from '../screens/DailyDetailsScreen';
import LoginScreen from '../screens/LoginScreen';
import RegisterScreen from '../screens/RegisterScreen';
@@ -38,6 +40,8 @@ export type HomeStackParamList = {
DailyDetails: { date: string };
Search: undefined;
Camera: undefined;
Barcode: undefined;
QuickAdd: undefined;
AIResult: { analysisId: string; suggestions: any[] };
EditMeal: { items: any[]; analysisId?: string };
};
@@ -69,6 +73,8 @@ function HomeNavigator() {
<HomeStack.Screen name="DailyDetails" component={DailyDetailsScreen} options={{ title: 'Details' }} />
<HomeStack.Screen name="Search" component={SearchScreen} options={{ title: 'Search Food' }} />
<HomeStack.Screen name="Camera" component={CameraScreen} options={{ headerShown: false }} />
<HomeStack.Screen name="Barcode" component={BarcodeScreen} options={{ headerShown: false }} />
<HomeStack.Screen name="QuickAdd" component={QuickAddScreen} options={{ title: 'Quick Add' }} />
<HomeStack.Screen name="AIResult" component={AIResultScreen} options={{ title: 'We detected' }} />
<HomeStack.Screen name="EditMeal" component={EditMealScreen} options={{ title: 'Edit Meal' }} />
</HomeStack.Navigator>

View File

@@ -0,0 +1,177 @@
// Generated by GitHub Copilot
import React, { useState, useCallback } from 'react';
import {
View, Text, StyleSheet, TouchableOpacity, Alert, ActivityIndicator,
} from 'react-native';
import { RNCamera } from 'react-native-camera';
import { useNavigation } from '@react-navigation/native';
import { getFoodByBarcode, createMeal } from '../services/api';
import { Colors } from '../theme/colors';
import { Spacing } from '../theme/spacing';
/**
* Full-screen barcode scanner.
* Reads EAN-13, EAN-8, UPC-A/E barcodes via react-native-camera.
* On successful scan → calls GET /foods/barcode/{code} → logs meal entry.
* REQ-MOB-010, REQ-FOOD-003
*/
export default function BarcodeScreen() {
const navigation = useNavigation<any>();
const [scanning, setScanning] = useState(true);
const [loading, setLoading] = useState(false);
/**
* Fired by RNCamera when a barcode is detected.
* Guards against repeated firings with the `scanning` flag.
*/
const onBarCodeRead = useCallback(
async ({ data }: { data: string; type: string }) => {
if (!scanning || loading) return;
setScanning(false);
setLoading(true);
try {
const { data: food } = await getFoodByBarcode(data);
// Pre-fill 100g portion and save immediately as a snack
await createMeal({
date: new Date().toISOString().split('T')[0],
mealType: 'snack',
source: 'barcode',
items: [{ foodItemId: food.id, grams: 100 }],
});
Alert.alert(
'Added!',
`${food.name}${Math.round(food.caloriesPer100g)} kcal/100g logged.`,
[{ text: 'OK', onPress: () => navigation.goBack() }],
);
} catch (err: any) {
const notFound = err?.response?.status === 404;
Alert.alert(
notFound ? 'Product not found' : 'Error',
notFound
? 'This barcode is not in our database. Try searching manually.'
: 'Could not look up barcode. Please try again.',
[{ text: 'Scan again', onPress: () => { setLoading(false); setScanning(true); } }],
);
} finally {
setLoading(false);
}
},
[scanning, loading, navigation],
);
return (
<View style={styles.container}>
<RNCamera
style={styles.camera}
type={RNCamera.Constants.Type.back}
captureAudio={false}
onBarCodeRead={onBarCodeRead}
barCodeTypes={[
RNCamera.Constants.BarCodeType.ean13,
RNCamera.Constants.BarCodeType.ean8,
RNCamera.Constants.BarCodeType.upca,
RNCamera.Constants.BarCodeType.upce,
]}
accessibilityLabel="Barcode scanner camera"
>
{/* Aim overlay */}
<View style={styles.overlay}>
<View style={styles.topDim} />
<View style={styles.middleRow}>
<View style={styles.sideDim} />
<View style={styles.scanWindow}>
{/* Corner brackets */}
<View style={[styles.corner, styles.topLeft]} />
<View style={[styles.corner, styles.topRight]} />
<View style={[styles.corner, styles.bottomLeft]} />
<View style={[styles.corner, styles.bottomRight]} />
</View>
<View style={styles.sideDim} />
</View>
<View style={styles.bottomDim}>
<Text style={styles.hint} accessibilityLabel="Point camera at barcode">
Point at a product barcode
</Text>
</View>
</View>
</RNCamera>
{loading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color={Colors.white} />
<Text style={styles.loadingText}>Looking up product</Text>
</View>
)}
<TouchableOpacity
style={styles.cancelButton}
onPress={() => navigation.goBack()}
accessibilityRole="button"
accessibilityLabel="Cancel barcode scan"
>
<Text style={styles.cancelText}>Cancel</Text>
</TouchableOpacity>
</View>
);
}
const DIM_COLOR = 'rgba(0,0,0,0.55)';
const CORNER_SIZE = 20;
const CORNER_BORDER = 3;
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.gray900 },
camera: { flex: 1 },
overlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
topDim: { flex: 1, backgroundColor: DIM_COLOR },
middleRow: { flexDirection: 'row', height: 200 },
sideDim: { flex: 1, backgroundColor: DIM_COLOR },
scanWindow: {
width: 280,
height: 200,
borderRadius: 4,
overflow: 'hidden',
},
bottomDim: {
flex: 1,
backgroundColor: DIM_COLOR,
alignItems: 'center',
paddingTop: Spacing.lg,
},
hint: {
color: Colors.white,
fontSize: 14,
textAlign: 'center',
opacity: 0.9,
},
corner: {
position: 'absolute',
width: CORNER_SIZE,
height: CORNER_SIZE,
borderColor: Colors.white,
},
topLeft: { top: 0, left: 0, borderTopWidth: CORNER_BORDER, borderLeftWidth: CORNER_BORDER },
topRight: { top: 0, right: 0, borderTopWidth: CORNER_BORDER, borderRightWidth: CORNER_BORDER },
bottomLeft: { bottom: 0, left: 0, borderBottomWidth: CORNER_BORDER, borderLeftWidth: CORNER_BORDER },
bottomRight: { bottom: 0, right: 0, borderBottomWidth: CORNER_BORDER, borderRightWidth: CORNER_BORDER },
loadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.7)',
justifyContent: 'center',
alignItems: 'center',
gap: Spacing.sm,
},
loadingText: { color: Colors.white, fontSize: 16 },
cancelButton: {
position: 'absolute',
top: Spacing.xl,
left: Spacing.md,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: Spacing.borderRadius?.md ?? 8,
minHeight: Spacing.touchTarget,
justifyContent: 'center',
},
cancelText: { color: Colors.white, fontSize: 16 },
});

View File

@@ -1,13 +1,166 @@
// Generated by GitHub Copilot
import React, { useEffect, useState } from 'react';
import { View, Text, ScrollView, StyleSheet } from 'react-native';
import { View, Text, ScrollView, StyleSheet, TouchableOpacity } from 'react-native';
import { useRoute } from '@react-navigation/native';
import { getDailyOverview, DailyOverview } from '../services/api';
import { getDailyOverview, DailyOverview, getWaterDaily, logWater } from '../services/api';
import MealItemRow from '../components/MealItemRow';
import ProgressBar from '../components/ProgressBar';
import { Colors } from '../theme/colors';
import { Spacing } from '../theme/spacing';
const DAILY_WATER_GOAL_ML = 2000;
const QUICK_ADD_OPTIONS = [250, 330, 500];
/**
* Daily details — calorie total + macro breakdown + full item list + water tracker.
* REQ-MOB-007, REQ-INT-004, REQ-WTR-001
*/
export default function DailyDetailsScreen() {
const route = useRoute<any>();
const date: string = route.params?.date ?? new Date().toISOString().split('T')[0];
const [overview, setOverview] = useState<DailyOverview | null>(null);
const [waterMl, setWaterMl] = useState(0);
useEffect(() => {
getDailyOverview(date).then(r => setOverview(r.data)).catch(() => {});
getWaterDaily(date).then(r => setWaterMl(r.data.totalMl)).catch(() => {});
}, [date]);
const handleAddWater = async (ml: number) => {
try {
const { data } = await logWater(date, ml);
setWaterMl(data.totalMl);
} catch { /* silent */ }
};
if (!overview) return null;
const progress = overview.target > 0 ? Math.min(overview.totalCalories / overview.target, 1) : 0;
const waterProgress = Math.min(waterMl / DAILY_WATER_GOAL_ML, 1);
// Aggregate macros across all meal items (REQ-INT-004)
const macros = overview.meals.flatMap(m => m.items).reduce(
(acc, item) => ({
protein: acc.protein + (item.foodItem.proteinG ?? 0) * item.quantityGrams / 100,
fat: acc.fat + (item.foodItem.fatG ?? 0) * item.quantityGrams / 100,
carbs: acc.carbs + (item.foodItem.carbsG ?? 0) * item.quantityGrams / 100,
}),
{ protein: 0, fat: 0, carbs: 0 }
);
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.heading}>Today Summary</Text>
<Text style={styles.kcal}>
{Math.round(overview.totalCalories)} / {overview.target} kcal
</Text>
<ProgressBar progress={progress} />
{/* Macro breakdown (REQ-INT-004) */}
<View style={styles.macros}>
<MacroItem label="Protein" value={Math.round(macros.protein)} unit="g" />
<MacroItem label="Carbs" value={Math.round(macros.carbs)} unit="g" />
<MacroItem label="Fat" value={Math.round(macros.fat)} unit="g" />
</View>
{/* Water intake widget (REQ-WTR-001) */}
<View style={styles.waterCard} accessible accessibilityLabel={`Water: ${waterMl} of ${DAILY_WATER_GOAL_ML} ml`}>
<View style={styles.waterHeader}>
<Text style={styles.sectionTitle}>💧 Water</Text>
<Text style={styles.waterTotal}>{waterMl} / {DAILY_WATER_GOAL_ML} ml</Text>
</View>
<ProgressBar progress={waterProgress} />
<View style={styles.waterButtons}>
{QUICK_ADD_OPTIONS.map(ml => (
<TouchableOpacity
key={ml}
style={styles.waterChip}
onPress={() => handleAddWater(ml)}
accessibilityRole="button"
accessibilityLabel={`Add ${ml} millilitres of water`}
>
<Text style={styles.waterChipText}>+{ml}ml</Text>
</TouchableOpacity>
))}
</View>
</View>
<Text style={styles.sectionTitle}>Meals</Text>
{overview.meals.map(meal => (
<View key={meal.id} style={styles.mealSection}>
<Text style={styles.mealType}>{meal.mealType.charAt(0).toUpperCase() + meal.mealType.slice(1)}</Text>
{meal.items.map(item => (
<MealItemRow key={item.id} item={item} />
))}
</View>
))}
</ScrollView>
);
}
function MacroItem({ label, value, unit }: { label: string; value: number; unit: string }) {
return (
<View style={macroStyles.item} accessible accessibilityLabel={`${label}: ${value}${unit}`}>
<Text style={macroStyles.value}>{value}{unit}</Text>
<Text style={macroStyles.label}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.background },
content: { padding: Spacing.md },
heading: { fontSize: 20, fontWeight: '600', color: Colors.gray900, marginBottom: Spacing.sm },
kcal: { fontSize: 28, fontWeight: '700', color: Colors.gray900, marginBottom: Spacing.sm },
macros: {
flexDirection: 'row',
justifyContent: 'space-around',
backgroundColor: Colors.backgroundMuted,
borderRadius: Spacing.borderRadius.md,
paddingVertical: Spacing.md,
marginVertical: Spacing.md,
},
waterCard: {
backgroundColor: '#EFF6FF',
borderWidth: 1,
borderColor: '#BFDBFE',
borderRadius: Spacing.borderRadius.md,
padding: Spacing.md,
marginBottom: Spacing.md,
},
waterHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: Spacing.sm,
},
waterTotal: { fontSize: 14, color: Colors.gray700 },
waterButtons: {
flexDirection: 'row',
gap: Spacing.sm,
marginTop: Spacing.sm,
},
waterChip: {
flex: 1,
backgroundColor: '#DBEAFE',
borderRadius: Spacing.borderRadius.sm,
paddingVertical: Spacing.sm,
alignItems: 'center',
minHeight: Spacing.touchTarget,
justifyContent: 'center',
},
waterChipText: { fontSize: 14, fontWeight: '600', color: '#1D4ED8' },
sectionTitle: { fontSize: 18, fontWeight: '600', color: Colors.gray900, marginBottom: Spacing.sm },
mealSection: { marginBottom: Spacing.md },
mealType: { fontSize: 14, fontWeight: '600', color: Colors.gray500, marginBottom: Spacing.xs },
});
const macroStyles = StyleSheet.create({
item: { alignItems: 'center' },
value: { fontSize: 20, fontWeight: '700', color: Colors.gray900 },
label: { fontSize: 12, color: Colors.gray500, marginTop: 2 },
});
/**
* Daily details — calorie total + macro breakdown + full item list.
* REQ-MOB-007, REQ-INT-004

View File

@@ -1,38 +1,62 @@
// Generated by GitHub Copilot
import React, { useEffect, useState } from 'react';
import { View, Text, FlatList, TouchableOpacity, StyleSheet } from 'react-native';
import { getMealHistory, MealEntry } from '../services/api';
import { getMealHistory, getProfile, MealEntry } from '../services/api';
import WeeklyCalorieChart from '../components/WeeklyCalorieChart';
import { Colors } from '../theme/colors';
import { Spacing } from '../theme/spacing';
import { useNavigation } from '@react-navigation/native';
/**
* History screen — per-day calorie totals for the past 30 days.
* REQ-MOB-008, REQ-HIST-001
* History screen — weekly chart + per-day calorie totals for the past 30 days.
* REQ-MOB-008, REQ-HIST-001, REQ-VIZ-001
*/
export default function HistoryScreen() {
const navigation = useNavigation<any>();
const [history, setHistory] = useState<{ date: string; totalCalories: number }[]>([]);
const [weekDays, setWeekDays] = useState<{ date: string; totalCalories: number }[]>([]);
const [target, setTarget] = useState(2000);
useEffect(() => {
const to = new Date().toISOString().split('T')[0];
const from = new Date(Date.now() - 30 * 86400000).toISOString().split('T')[0];
getMealHistory(from, to).then(({ data }) => {
// Fetch target and history in parallel
Promise.all([
getMealHistory(from, to),
getProfile(),
]).then(([{ data: meals }, { data: profile }]) => {
setTarget(profile.dailyCaloriesTarget ?? 2000);
// Aggregate calories per day
const byDate: Record<string, number> = {};
data.forEach(m => {
(meals as MealEntry[]).forEach(m => {
byDate[m.date] = (byDate[m.date] ?? 0) + m.totalCalories;
});
const sorted = Object.entries(byDate)
.map(([date, totalCalories]) => ({ date, totalCalories }))
.sort((a, b) => b.date.localeCompare(a.date));
setHistory(sorted);
// Build last-7-days array (oldest → newest), filling gaps with 0
const week: { date: string; totalCalories: number }[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(Date.now() - i * 86400000).toISOString().split('T')[0];
week.push({ date: d, totalCalories: byDate[d] ?? 0 });
}
setWeekDays(week);
}).catch(() => {});
}, []);
return (
<View style={styles.container}>
<Text style={styles.heading} accessibilityRole="header">History</Text>
{weekDays.length === 7 && (
<WeeklyCalorieChart days={weekDays} target={target} />
)}
<FlatList
data={history}
keyExtractor={item => item.date}

View File

@@ -7,7 +7,8 @@ import {
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import CalorieCard from '../components/CalorieCard';
import FAB from '../components/FAB';
import { DailyOverview, MealEntry, getDailyOverview, createMeal } from '../services/api';
import GoalBanner from '../components/GoalBanner';
import { DailyOverview, MealEntry, getDailyOverview, createMeal, getStreak } from '../services/api';
import { Colors } from '../theme/colors';
import { Spacing } from '../theme/spacing';
@@ -23,16 +24,30 @@ export default function HomeScreen() {
const [refreshing, setRefreshing] = useState(false);
const [addModalVisible, setAddModalVisible] = useState(false);
const [yesterdayLunch, setYesterdayLunch] = useState<MealEntry | null>(null);
const [streak, setStreak] = useState<number>(0);
const [goalReached, setGoalReached] = useState(false);
const [showLogReminder, setShowLogReminder] = useState(false);
const load = useCallback(async () => {
try {
const { data } = await getDailyOverview(today);
setOverview(data);
// Show goal achievement banner when target is reached (REQ-UX-003)
if (data.remaining !== undefined && data.remaining <= 0) {
setGoalReached(true);
}
// Show logging reminder if it's after 18:00 and no meals logged today (REQ-UX-004)
const hour = new Date().getHours();
if (hour >= 18 && data.totalCalories === 0) {
setShowLogReminder(true);
}
// Load yesterday's lunch for repeat shortcut (REQ-INT-003)
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
const { data: yd } = await getDailyOverview(yesterday);
const lunch = yd.meals.find(m => m.mealType === 'lunch') ?? null;
setYesterdayLunch(lunch);
const { data: streakData } = await getStreak();
setStreak(streakData.currentStreak);
} catch {
// Silent fail on network errors — show stale data
}
@@ -80,6 +95,17 @@ export default function HomeScreen() {
/>
)}
{/* Streak badge (REQ-VIZ-002) */}
{streak > 0 && (
<View
style={styles.streakBadge}
accessible
accessibilityLabel={`${streak} day streak`}
>
<Text style={styles.streakText}>🔥 {streak} day streak</Text>
</View>
)}
{(['breakfast', 'lunch', 'dinner', 'snack'] as const).map(type => (
(grouped[type] ?? []).length > 0 && (
<View key={type} style={styles.section}>
@@ -116,6 +142,29 @@ export default function HomeScreen() {
{/* FAB — 1-tap Add Meal (REQ-MOB-001, UX rule) */}
<FAB onPress={() => setAddModalVisible(true)} />
{/* Goal achievement banner (REQ-UX-003) */}
<GoalBanner visible={goalReached} />
{/* Daily logging reminder banner — shown after 18:00 if nothing logged (REQ-UX-004) */}
{showLogReminder && (
<View
style={styles.reminderBanner}
accessible
accessibilityRole="alert"
accessibilityLabel="Evening reminder: you haven't logged any meals today"
>
<Text style={styles.reminderText}>🌙 Don't forget to log today's meals!</Text>
<TouchableOpacity
onPress={() => setShowLogReminder(false)}
accessibilityRole="button"
accessibilityLabel="Dismiss reminder"
style={styles.reminderDismiss}
>
<Text style={styles.reminderDismissText}>✕</Text>
</TouchableOpacity>
</View>
)}
{/* Add Meal bottom sheet (REQ-MOB-002) */}
<Modal
visible={addModalVisible}
@@ -133,6 +182,8 @@ export default function HomeScreen() {
{[
{ label: '📷 Take Photo', screen: 'Camera' },
{ label: '🔍 Search Food', screen: 'Search' },
{ label: '📦 Scan Barcode', screen: 'Barcode' },
{ label: '⚡ Quick Add', screen: 'QuickAdd' },
].map(({ label, screen }) => (
<TouchableOpacity
key={screen}
@@ -175,6 +226,17 @@ const styles = StyleSheet.create({
},
mealRowText: { fontSize: 16, color: Colors.gray900 },
mealRowKcal: { fontSize: 14, color: Colors.gray500 },
streakBadge: {
backgroundColor: '#FFF7ED',
borderWidth: 1,
borderColor: '#FED7AA',
borderRadius: Spacing.borderRadius.md,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
alignSelf: 'flex-start',
marginBottom: Spacing.sm,
},
streakText: { fontSize: 14, fontWeight: '600', color: '#C2410C' },
repeatCard: {
backgroundColor: Colors.aiSuggestionBg,
borderWidth: 1,
@@ -204,4 +266,26 @@ const styles = StyleSheet.create({
sheetOptionText: { fontSize: 16, color: Colors.gray900 },
sheetCancel: { paddingVertical: Spacing.md, alignItems: 'center', minHeight: Spacing.touchTarget, justifyContent: 'center' },
sheetCancelText: { fontSize: 16, color: Colors.error },
reminderBanner: {
position: 'absolute',
bottom: 90,
left: Spacing.md,
right: Spacing.md,
backgroundColor: Colors.gray900,
borderRadius: Spacing.borderRadius.md,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
elevation: 6,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
minHeight: Spacing.touchTarget,
},
reminderText: { color: Colors.white, fontSize: 14, flex: 1 },
reminderDismiss: { padding: Spacing.sm, minWidth: Spacing.touchTarget, alignItems: 'center' },
reminderDismissText: { color: Colors.white, fontSize: 16, fontWeight: '600' },
});

View File

@@ -1,10 +1,10 @@
// Generated by GitHub Copilot
import React, { useEffect, useState } from 'react';
import {
View, Text, TextInput, ScrollView, StyleSheet, Alert,
View, Text, TextInput, ScrollView, StyleSheet, Alert, Share,
} from 'react-native';
import { Picker } from '@react-native-picker/picker';
import { getProfile, updateProfile } from '../services/api';
import { getProfile, updateProfile, exportMeals } from '../services/api';
import Button from '../components/Button';
import { Colors } from '../theme/colors';
import { Spacing } from '../theme/spacing';
@@ -22,6 +22,7 @@ export default function ProfileScreen() {
const [target, setTarget] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(false);
const [exporting, setExporting] = useState(false);
useEffect(() => {
getProfile().then(({ data }) => {
@@ -52,6 +53,30 @@ export default function ProfileScreen() {
}
};
const handleExport = async () => {
setExporting(true);
try {
const to = new Date().toISOString().split('T')[0];
const from = new Date(Date.now() - 90 * 86400000).toISOString().split('T')[0];
const { data } = await exportMeals(from, to);
// data is a Blob — convert to base64 for Share API
const reader = new FileReader();
reader.readAsDataURL(data);
reader.onloadend = async () => {
const base64 = (reader.result as string).split(',')[1];
await Share.share({
title: 'Calorie Counter export',
message: `Calorie log ${from} to ${to}`,
url: `data:text/csv;base64,${base64}`,
});
};
} catch {
Alert.alert('Export failed', 'Could not export data.');
} finally {
setExporting(false);
}
};
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.heading} accessibilityRole="header">Profile</Text>
@@ -90,6 +115,16 @@ export default function ProfileScreen() {
) : (
<Button label="Edit Profile" variant="secondary" onPress={() => setEditing(true)} />
)}
{/* Export data (REQ-EXP-001) */}
<View style={styles.exportSection}>
<Button
label={exporting ? 'Exporting…' : '📤 Export last 90 days'}
variant="ghost"
onPress={handleExport}
disabled={exporting}
/>
</View>
</ScrollView>
);
}
@@ -131,6 +166,7 @@ const styles = StyleSheet.create({
borderRadius: Spacing.borderRadius.md,
padding: Spacing.md, marginVertical: Spacing.md, alignItems: 'center',
},
exportSection: { marginTop: Spacing.xl },
targetLabel: { fontSize: 13, color: Colors.gray500 },
targetValue: { fontSize: 28, fontWeight: '700', color: Colors.primaryDark },
});

View File

@@ -0,0 +1,179 @@
// Generated by GitHub Copilot
import React, { useState } from 'react';
import {
View, Text, TextInput, StyleSheet, TouchableOpacity, Alert,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import Button from '../components/Button';
import { quickAddCalories } from '../services/api';
import { Colors } from '../theme/colors';
import { Spacing } from '../theme/spacing';
const MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack'] as const;
type MealType = typeof MEAL_TYPES[number];
/**
* Quick-add screen — enter a raw calorie amount and meal type without searching.
* Calls POST /meals/quick-add on the backend.
* REQ-UX-001
*/
export default function QuickAddScreen() {
const navigation = useNavigation<any>();
const [calories, setCalories] = useState('');
const [label, setLabel] = useState('');
const [mealType, setMealType] = useState<MealType>('snack');
const [loading, setLoading] = useState(false);
const canSubmit = calories.length > 0 && parseInt(calories, 10) > 0;
const submit = async () => {
const kcal = parseInt(calories, 10);
if (!kcal || kcal < 1 || kcal > 9999) {
Alert.alert('Invalid amount', 'Enter a value between 1 and 9999 kcal.');
return;
}
setLoading(true);
try {
await quickAddCalories({
date: new Date().toISOString().split('T')[0],
mealType,
calories: kcal,
label: label.trim() || undefined,
});
Alert.alert('Added!', `${kcal} kcal logged as ${mealType}.`, [
{ text: 'OK', onPress: () => navigation.goBack() },
]);
} catch {
Alert.alert('Could not log calories. Please try again.');
} finally {
setLoading(false);
}
};
return (
<View style={styles.container}>
<Text style={styles.heading} accessibilityRole="header">Quick Add</Text>
<Text style={styles.sub}>Log calories without searching</Text>
{/* Calorie input */}
<Text style={styles.label}>Calories (kcal)</Text>
<TextInput
style={styles.input}
value={calories}
onChangeText={t => setCalories(t.replace(/[^0-9]/g, ''))}
keyboardType="number-pad"
maxLength={4}
placeholder="e.g. 400"
placeholderTextColor={Colors.gray500}
accessibilityLabel="Calories"
returnKeyType="done"
/>
{/* Optional label */}
<Text style={styles.label}>Label (optional)</Text>
<TextInput
style={styles.input}
value={label}
onChangeText={setLabel}
placeholder="e.g. Protein bar"
placeholderTextColor={Colors.gray500}
accessibilityLabel="Optional food label"
maxLength={100}
returnKeyType="done"
/>
{/* Meal type selector */}
<Text style={styles.label}>Meal type</Text>
<View style={styles.typeRow}>
{MEAL_TYPES.map(type => (
<TouchableOpacity
key={type}
style={[styles.typeChip, mealType === type && styles.typeChipActive]}
onPress={() => setMealType(type)}
accessibilityRole="radio"
accessibilityState={{ checked: mealType === type }}
accessibilityLabel={type}
>
<Text style={[styles.typeChipText, mealType === type && styles.typeChipTextActive]}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
<View style={styles.cta}>
<Button
label={`Log ${calories || '0'} kcal`}
onPress={submit}
loading={loading}
disabled={!canSubmit}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.background,
padding: Spacing.md,
},
heading: {
fontSize: 24,
fontWeight: '600',
color: Colors.gray900,
marginBottom: Spacing.xs,
},
sub: {
fontSize: 14,
color: Colors.gray500,
marginBottom: Spacing.lg,
},
label: {
fontSize: 14,
fontWeight: '500',
color: Colors.gray700,
marginBottom: Spacing.xs,
marginTop: Spacing.md,
},
input: {
height: Spacing.touchTarget,
borderWidth: 1,
borderColor: Colors.gray300,
borderRadius: Spacing.borderRadius.sm,
paddingHorizontal: Spacing.md,
fontSize: 16,
color: Colors.gray900,
},
typeRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: Spacing.sm,
marginTop: Spacing.xs,
},
typeChip: {
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderRadius: Spacing.borderRadius.sm,
borderWidth: 1,
borderColor: Colors.gray300,
minHeight: Spacing.touchTarget,
justifyContent: 'center',
},
typeChipActive: {
backgroundColor: Colors.primary,
borderColor: Colors.primary,
},
typeChipText: {
fontSize: 14,
color: Colors.gray700,
},
typeChipTextActive: {
color: Colors.white,
fontWeight: '600',
},
cta: {
marginTop: Spacing.xl,
},
});

View File

@@ -1,26 +1,32 @@
// Generated by GitHub Copilot
import React, { useState, useCallback } from 'react';
import { View, TextInput, FlatList, StyleSheet, Text, Alert } from 'react-native';
import React, { useState, useCallback, useEffect } from 'react';
import { View, TextInput, FlatList, StyleSheet, Text, Alert, TouchableOpacity } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import FoodRow from '../components/FoodRow';
import Button from '../components/Button';
import PortionSlider from '../components/PortionSlider';
import { FoodItem, searchFoods, createMeal } from '../services/api';
import { FoodItem, searchFoods, createMeal, getFavourites, toggleFavourite } from '../services/api';
import { Colors } from '../theme/colors';
import { Spacing } from '../theme/spacing';
/**
* Manual food search screen.
* REQ-MOB-006, REQ-FOOD-001
* Shows starred favourites at top when search is empty.
* REQ-MOB-006, REQ-FOOD-001, REQ-UX-002
*/
export default function SearchScreen() {
const navigation = useNavigation<any>();
const [query, setQuery] = useState('');
const [results, setResults] = useState<FoodItem[]>([]);
const [favourites, setFavourites] = useState<FoodItem[]>([]);
const [selected, setSelected] = useState<FoodItem | null>(null);
const [grams, setGrams] = useState(100);
const [loading, setLoading] = useState(false);
useEffect(() => {
getFavourites().then(({ data }) => setFavourites(data)).catch(() => {});
}, []);
const search = useCallback(async (text: string) => {
setQuery(text);
if (text.length < 2) { setResults([]); return; }
@@ -30,6 +36,17 @@ export default function SearchScreen() {
} catch { /* silent */ }
}, []);
const handleToggleFavourite = async (item: FoodItem) => {
try {
const { data } = await toggleFavourite(item.id);
if (data.favourite) {
setFavourites(prev => [item, ...prev.filter(f => f.id !== item.id)]);
} else {
setFavourites(prev => prev.filter(f => f.id !== item.id));
}
} catch { /* silent */ }
};
const addToLog = async () => {
if (!selected) return;
setLoading(true);
@@ -53,6 +70,8 @@ export default function SearchScreen() {
? Math.round(selected.caloriesPer100g * grams / 100)
: 0;
const isFav = (item: FoodItem) => favourites.some(f => f.id === item.id);
return (
<View style={styles.container}>
<TextInput
@@ -80,18 +99,41 @@ export default function SearchScreen() {
<Button label="← Back to search" variant="ghost" onPress={() => setSelected(null)} />
</View>
) : (
<FlatList
data={results}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<FoodRow item={item} onSelect={setSelected} />
<>
{/* Favourites section (shown when search is empty) */}
{query.length < 2 && favourites.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}> Favourites</Text>
{favourites.map(item => (
<FoodRow
key={item.id}
item={item}
onSelect={setSelected}
isFavourite={true}
onToggleFavourite={() => handleToggleFavourite(item)}
/>
))}
</View>
)}
ListEmptyComponent={
query.length >= 2
? <Text style={styles.empty}>No results for "{query}"</Text>
: null
}
/>
<FlatList
data={results}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<FoodRow
item={item}
onSelect={setSelected}
isFavourite={isFav(item)}
onToggleFavourite={() => handleToggleFavourite(item)}
/>
)}
ListEmptyComponent={
query.length >= 2
? <Text style={styles.empty}>No results for "{query}"</Text>
: null
}
/>
</>
)}
</View>
);
@@ -109,6 +151,8 @@ const styles = StyleSheet.create({
fontSize: 16,
color: Colors.gray900,
},
section: { paddingHorizontal: Spacing.md },
sectionTitle: { fontSize: 14, fontWeight: '600', color: Colors.gray500, marginBottom: Spacing.xs },
portionView: { padding: Spacing.md },
foodName: { fontSize: 20, fontWeight: '600', color: Colors.gray900, marginBottom: Spacing.md },
kcalDisplay: {

View File

@@ -92,6 +92,12 @@ export const searchFoods = (query: string) =>
export const getFoodByBarcode = (code: string) =>
api.get<FoodItem>(`/foods/barcode/${encodeURIComponent(code)}`);
export const getFavourites = () =>
api.get<FoodItem[]>('/foods/favourites');
export const toggleFavourite = (foodId: string) =>
api.post<{ favourite: boolean }>(`/foods/${encodeURIComponent(foodId)}/favourite`);
// Meals
export const getDailyOverview = (date: string) =>
api.get<DailyOverview>('/meals/daily', { params: { date } });
@@ -105,6 +111,12 @@ export const createMeal = (payload: object) =>
export const deleteMeal = (id: string) =>
api.delete(`/meals/${encodeURIComponent(id)}`);
export const getStreak = () =>
api.get<{ currentStreak: number; longestStreak: number }>('/meals/streak');
export const quickAddCalories = (payload: { date: string; mealType: string; calories: number; label?: string }) =>
api.post<MealEntry>('/meals/quick-add', payload);
// AI
export const analyzeMealPhoto = (imageFormData: FormData) =>
api.post<AiAnalysisResponse>('/ai/analyze-meal', imageFormData, {
@@ -114,4 +126,14 @@ export const analyzeMealPhoto = (imageFormData: FormData) =>
export const saveAiCorrections = (analysisId: string, corrections: { name: string; correctedGrams: number }[]) =>
api.post('/ai/correction', { analysisId, corrections });
export const exportMeals = (from: string, to: string) =>
api.get('/export/meals', { params: { from, to }, responseType: 'blob' });
// Water
export const getWaterDaily = (date: string) =>
api.get<{ date: string; totalMl: number }>('/water/daily', { params: { date } });
export const logWater = (date: string, amountMl: number) =>
api.post<{ date: string; totalMl: number }>('/water', { date, amountMl });
export default api;