feat: Phase 4 — 9 new features (v1.1)
Some checks failed
CI / Build & test backend (push) Failing after 14m56s
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:
@@ -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 },
|
||||
});
|
||||
|
||||
90
mobile/src/components/GoalBanner.tsx
Normal file
90
mobile/src/components/GoalBanner.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
209
mobile/src/components/WeeklyCalorieChart.tsx
Normal file
209
mobile/src/components/WeeklyCalorieChart.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
177
mobile/src/screens/BarcodeScreen.tsx
Normal file
177
mobile/src/screens/BarcodeScreen.tsx
Normal 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 },
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
179
mobile/src/screens/QuickAddScreen.tsx
Normal file
179
mobile/src/screens/QuickAddScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user