feat: initial implementation — all 35 requirements across phases 1-3
Backend (Spring Boot 3.2 / Java 21 / PostgreSQL): - JWT auth with BCrypt password hashing - User profile + Mifflin-St Jeor BMR calculator - Food search + barcode via OpenFoodFacts API with local cache - Meal CRUD with user data isolation and ownership checks - AI photo analysis (OpenAI Vision) with confidence intervals - AI correction feedback loop for personalisation - Flyway DB migrations + RFC-7807 error responses Mobile (React Native / TypeScript): - Full navigation stack (Auth → Tabs → Home stack) - Design tokens (WCAG 2.2 AA colours, 8px grid, 48px touch targets) - 10 screens: Login, Register, Home, Search, Camera, AI Result, Edit Meal, Daily Details, History, Profile - Confidence-aware calorie display (kcal ± range) - Repeat last meal shortcut + macro tracking Docs: - docs/PLAN-AND-REQUIREMENTS.md - docs/traceability.csv (35 requirements, all Implemented)
This commit is contained in:
92
mobile/src/screens/AIResultScreen.tsx
Normal file
92
mobile/src/screens/AIResultScreen.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
// Generated by GitHub Copilot
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, ScrollView, StyleSheet, Alert } from 'react-native';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import AISuggestionCard from '../components/AISuggestionCard';
|
||||
import Button from '../components/Button';
|
||||
import { AiSuggestion } from '../services/api';
|
||||
import { Colors } from '../theme/colors';
|
||||
import { Spacing } from '../theme/spacing';
|
||||
|
||||
/**
|
||||
* AI result screen — shows detected items with confidence scores.
|
||||
* NEVER auto-saves. User must confirm or edit first. (REQ-AI-002)
|
||||
* REQ-MOB-004, REQ-INT-001
|
||||
*/
|
||||
export default function AIResultScreen() {
|
||||
const navigation = useNavigation<any>();
|
||||
const route = useRoute<any>();
|
||||
const { analysisId, suggestions: initialSuggestions } = route.params as {
|
||||
analysisId: string;
|
||||
suggestions: AiSuggestion[];
|
||||
};
|
||||
|
||||
const [suggestions, setSuggestions] = useState<AiSuggestion[]>(initialSuggestions);
|
||||
|
||||
const handleGramsChange = (index: number, grams: number) => {
|
||||
setSuggestions(prev => prev.map((s, i) =>
|
||||
i === index
|
||||
? {
|
||||
...s,
|
||||
grams,
|
||||
estimatedCalories: grams * 2,
|
||||
confidenceLow: Math.max(0, grams * 2 * (1 - (1 - s.confidence) * 0.4)),
|
||||
confidenceHigh: grams * 2 * (1 + (1 - s.confidence) * 0.4),
|
||||
}
|
||||
: s
|
||||
));
|
||||
};
|
||||
|
||||
const confirmAndNavigate = () => {
|
||||
// Pass adjusted suggestions to EditMeal for final save
|
||||
navigation.navigate('EditMeal', { items: suggestions, analysisId });
|
||||
};
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyText}>No food items detected. Try Search instead.</Text>
|
||||
<Button label="Search Food" onPress={() => navigation.navigate('Search')} />
|
||||
<Button label="Retake Photo" variant="secondary" onPress={() => navigation.goBack()} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<AISuggestionCard suggestions={suggestions} onGramsChange={handleGramsChange} />
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
label="✅ Confirm Meal"
|
||||
onPress={confirmAndNavigate}
|
||||
accessibilityHint="Proceeds to the edit and save screen"
|
||||
/>
|
||||
<Button
|
||||
label="Edit Items"
|
||||
variant="secondary"
|
||||
onPress={confirmAndNavigate}
|
||||
accessibilityHint="Edit portion sizes before saving"
|
||||
/>
|
||||
<Button
|
||||
label="← Retake Photo"
|
||||
variant="ghost"
|
||||
onPress={() => navigation.goBack()}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: Colors.background },
|
||||
content: { padding: Spacing.md },
|
||||
actions: { gap: Spacing.sm },
|
||||
empty: {
|
||||
flex: 1, padding: Spacing.lg, justifyContent: 'center', alignItems: 'center',
|
||||
backgroundColor: Colors.background,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16, color: Colors.gray700, textAlign: 'center', marginBottom: Spacing.lg,
|
||||
},
|
||||
});
|
||||
119
mobile/src/screens/CameraScreen.tsx
Normal file
119
mobile/src/screens/CameraScreen.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// Generated by GitHub Copilot
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Text, Alert, ActivityIndicator } from 'react-native';
|
||||
import { Camera, useCameraDevices } from 'react-native-camera';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { analyzeMealPhoto } from '../services/api';
|
||||
import { Colors } from '../theme/colors';
|
||||
import { Spacing } from '../theme/spacing';
|
||||
|
||||
/**
|
||||
* Full-screen camera for meal photo capture.
|
||||
* On capture: sends image to POST /ai/analyze-meal and navigates to AIResultScreen.
|
||||
* REQ-MOB-003, REQ-AI-001
|
||||
*/
|
||||
export default function CameraScreen() {
|
||||
const navigation = useNavigation<any>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cameraRef, setCameraRef] = useState<Camera | null>(null);
|
||||
|
||||
const capture = async () => {
|
||||
if (!cameraRef || loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const photo = await cameraRef.takePictureAsync({
|
||||
quality: 0.7,
|
||||
base64: false,
|
||||
fixOrientation: true,
|
||||
});
|
||||
|
||||
// Build multipart form data
|
||||
const formData = new FormData();
|
||||
formData.append('image', {
|
||||
uri: photo.uri,
|
||||
type: 'image/jpeg',
|
||||
name: 'meal.jpg',
|
||||
} as any);
|
||||
|
||||
const { data } = await analyzeMealPhoto(formData);
|
||||
navigation.navigate('AIResult', {
|
||||
analysisId: data.analysisId,
|
||||
suggestions: data.suggestions,
|
||||
});
|
||||
} catch {
|
||||
Alert.alert('Analysis failed', 'Could not analyse the photo. Please try again or use Search instead.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Camera
|
||||
ref={ref => setCameraRef(ref)}
|
||||
style={styles.camera}
|
||||
type={Camera.Constants.Type.back}
|
||||
captureAudio={false}
|
||||
accessibilityLabel="Camera view"
|
||||
/>
|
||||
|
||||
<View style={styles.controls}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="large" color={Colors.white} />
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.captureButton}
|
||||
onPress={capture}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Take photo"
|
||||
>
|
||||
<View style={styles.captureInner} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={() => navigation.goBack()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel"
|
||||
>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#000' },
|
||||
camera: { flex: 1 },
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
bottom: Spacing.xxl,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
captureButton: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
borderWidth: 4,
|
||||
borderColor: Colors.white,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
captureInner: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: Colors.white,
|
||||
},
|
||||
cancelButton: {
|
||||
position: 'absolute',
|
||||
top: Spacing.xxl,
|
||||
left: Spacing.md,
|
||||
minHeight: Spacing.touchTarget,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: Spacing.sm,
|
||||
},
|
||||
cancelText: { color: Colors.white, fontSize: 16 },
|
||||
});
|
||||
97
mobile/src/screens/DailyDetailsScreen.tsx
Normal file
97
mobile/src/screens/DailyDetailsScreen.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
// Generated by GitHub Copilot
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, ScrollView, StyleSheet } from 'react-native';
|
||||
import { useRoute } from '@react-navigation/native';
|
||||
import { getDailyOverview, DailyOverview } from '../services/api';
|
||||
import MealItemRow from '../components/MealItemRow';
|
||||
import ProgressBar from '../components/ProgressBar';
|
||||
import { Colors } from '../theme/colors';
|
||||
import { Spacing } from '../theme/spacing';
|
||||
|
||||
/**
|
||||
* Daily details — calorie total + macro breakdown + full item list.
|
||||
* REQ-MOB-007, REQ-INT-004
|
||||
*/
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
getDailyOverview(date).then(r => setOverview(r.data)).catch(() => {});
|
||||
}, [date]);
|
||||
|
||||
if (!overview) return null;
|
||||
|
||||
const progress = overview.target > 0 ? Math.min(overview.totalCalories / overview.target, 1) : 0;
|
||||
|
||||
// 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>
|
||||
|
||||
<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,
|
||||
},
|
||||
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 },
|
||||
});
|
||||
132
mobile/src/screens/EditMealScreen.tsx
Normal file
132
mobile/src/screens/EditMealScreen.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
// Generated by GitHub Copilot
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, ScrollView, StyleSheet, Alert } from 'react-native';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import PortionSlider from '../components/PortionSlider';
|
||||
import Button from '../components/Button';
|
||||
import { AiSuggestion, createMeal, saveAiCorrections, searchFoods } from '../services/api';
|
||||
import { Colors } from '../theme/colors';
|
||||
import { Spacing } from '../theme/spacing';
|
||||
|
||||
/**
|
||||
* Edit meal screen — per-item portion sliders + real-time calorie total.
|
||||
* Saves both the meal entry and the AI correction record (feedback loop).
|
||||
* REQ-MOB-005, REQ-AI-003, REQ-INT-001
|
||||
*/
|
||||
export default function EditMealScreen() {
|
||||
const navigation = useNavigation<any>();
|
||||
const route = useRoute<any>();
|
||||
const { items: initialItems, analysisId } = route.params as {
|
||||
items: AiSuggestion[];
|
||||
analysisId?: string;
|
||||
};
|
||||
|
||||
const [items, setItems] = useState(initialItems.map(s => ({ ...s })));
|
||||
const [mealType, setMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('lunch');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const updateGrams = (index: number, grams: number) => {
|
||||
setItems(prev => prev.map((item, i) =>
|
||||
i === index
|
||||
? {
|
||||
...item,
|
||||
grams,
|
||||
estimatedCalories: grams * 2,
|
||||
confidenceLow: Math.max(0, grams * 2 * (1 - (1 - item.confidence) * 0.4)),
|
||||
confidenceHigh: grams * 2 * (1 + (1 - item.confidence) * 0.4),
|
||||
}
|
||||
: item
|
||||
));
|
||||
};
|
||||
|
||||
const totalCalories = Math.round(items.reduce((sum, i) => sum + i.estimatedCalories, 0));
|
||||
|
||||
const saveMeal = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Resolve food IDs by searching each item name
|
||||
const resolvedItems = await Promise.all(items.map(async item => {
|
||||
const { data: foods } = await searchFoods(item.name);
|
||||
const food = foods[0];
|
||||
if (!food) throw new Error(`Food not found: ${item.name}`);
|
||||
return { foodItemId: food.id, grams: Math.round(item.grams) };
|
||||
}));
|
||||
|
||||
await createMeal({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
mealType,
|
||||
source: 'photo',
|
||||
items: resolvedItems,
|
||||
});
|
||||
|
||||
// Save AI corrections for feedback loop (REQ-AI-003)
|
||||
if (analysisId) {
|
||||
await saveAiCorrections(analysisId, items.map(i => ({
|
||||
name: i.name,
|
||||
correctedGrams: Math.round(i.grams),
|
||||
})));
|
||||
}
|
||||
|
||||
Alert.alert('Meal saved!');
|
||||
navigation.navigate('Home');
|
||||
} catch (err: any) {
|
||||
Alert.alert('Could not save meal', err.message ?? 'Please try again');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<Text style={styles.heading}>Edit Meal</Text>
|
||||
|
||||
{items.map((item, i) => (
|
||||
<PortionSlider
|
||||
key={i}
|
||||
foodName={item.name}
|
||||
grams={item.grams}
|
||||
onValueChange={v => updateGrams(i, v)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Real-time calorie total updates as sliders move (UX rule) */}
|
||||
<View style={styles.totalRow} accessible accessibilityLabel={`Total: ${totalCalories} calories`}>
|
||||
<Text style={styles.totalLabel}>Total:</Text>
|
||||
<Text style={styles.totalKcal}>{totalCalories} kcal</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Sticky Save button */}
|
||||
<View style={styles.footer}>
|
||||
<Button label="💾 Save Meal" onPress={saveMeal} loading={loading} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: Colors.background },
|
||||
content: { padding: Spacing.md, paddingBottom: 100 },
|
||||
heading: { fontSize: 22, fontWeight: '600', color: Colors.gray900, marginBottom: Spacing.md },
|
||||
totalRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: Colors.gray100,
|
||||
paddingTop: Spacing.md,
|
||||
marginTop: Spacing.md,
|
||||
},
|
||||
totalLabel: { fontSize: 16, color: Colors.gray700 },
|
||||
totalKcal: { fontSize: 20, fontWeight: '700', color: Colors.gray900 },
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: Colors.background,
|
||||
padding: Spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: Colors.gray100,
|
||||
},
|
||||
});
|
||||
71
mobile/src/screens/HistoryScreen.tsx
Normal file
71
mobile/src/screens/HistoryScreen.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// 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 { 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
|
||||
*/
|
||||
export default function HistoryScreen() {
|
||||
const navigation = useNavigation<any>();
|
||||
const [history, setHistory] = useState<{ date: string; totalCalories: number }[]>([]);
|
||||
|
||||
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 }) => {
|
||||
// Aggregate calories per day
|
||||
const byDate: Record<string, number> = {};
|
||||
data.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);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.heading} accessibilityRole="header">History</Text>
|
||||
<FlatList
|
||||
data={history}
|
||||
keyExtractor={item => item.date}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.row}
|
||||
onPress={() => navigation.navigate('HomeTab', { screen: 'DailyDetails', params: { date: item.date } })}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${item.date}, ${Math.round(item.totalCalories)} calories`}
|
||||
>
|
||||
<Text style={styles.date}>{item.date}</Text>
|
||||
<Text style={styles.kcal}>{Math.round(item.totalCalories)} kcal</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
ListEmptyComponent={<Text style={styles.empty}>No history yet</Text>}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: Colors.background, padding: Spacing.md },
|
||||
heading: { fontSize: 24, fontWeight: '600', color: Colors.gray900, marginBottom: Spacing.md },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: Spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: Colors.gray100,
|
||||
minHeight: Spacing.touchTarget,
|
||||
},
|
||||
date: { fontSize: 16, color: Colors.gray900 },
|
||||
kcal: { fontSize: 16, color: Colors.gray500 },
|
||||
empty: { textAlign: 'center', color: Colors.gray500, marginTop: Spacing.xl },
|
||||
});
|
||||
207
mobile/src/screens/HomeScreen.tsx
Normal file
207
mobile/src/screens/HomeScreen.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
// Generated by GitHub Copilot
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, StyleSheet, RefreshControl, Modal,
|
||||
TouchableOpacity, Alert,
|
||||
} from 'react-native';
|
||||
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 { Colors } from '../theme/colors';
|
||||
import { Spacing } from '../theme/spacing';
|
||||
|
||||
/**
|
||||
* Home / Dashboard screen.
|
||||
* REQ-MOB-001: calorie progress card + meal list + Add Meal FAB.
|
||||
* REQ-INT-003: repeat last meal shortcut shown when yesterday's meals exist.
|
||||
*/
|
||||
export default function HomeScreen() {
|
||||
const navigation = useNavigation<any>();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const [overview, setOverview] = useState<DailyOverview | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [addModalVisible, setAddModalVisible] = useState(false);
|
||||
const [yesterdayLunch, setYesterdayLunch] = useState<MealEntry | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await getDailyOverview(today);
|
||||
setOverview(data);
|
||||
// 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);
|
||||
} catch {
|
||||
// Silent fail on network errors — show stale data
|
||||
}
|
||||
}, [today]);
|
||||
|
||||
useFocusEffect(useCallback(() => { load(); }, [load]));
|
||||
|
||||
const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); };
|
||||
|
||||
const repeatYesterdayLunch = async () => {
|
||||
if (!yesterdayLunch) return;
|
||||
try {
|
||||
await createMeal({
|
||||
date: today,
|
||||
mealType: 'lunch',
|
||||
source: 'manual',
|
||||
items: yesterdayLunch.items.map(i => ({
|
||||
foodItemId: i.foodItem.id,
|
||||
grams: i.quantityGrams,
|
||||
})),
|
||||
});
|
||||
await load();
|
||||
Alert.alert('Done!', "Yesterday's lunch has been added.");
|
||||
} catch {
|
||||
Alert.alert('Could not repeat meal');
|
||||
}
|
||||
};
|
||||
|
||||
const grouped = overview?.meals.reduce<Record<string, MealEntry[]>>((acc, m) => {
|
||||
(acc[m.mealType] ??= []).push(m);
|
||||
return acc;
|
||||
}, {}) ?? {};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scroll}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
>
|
||||
{overview && (
|
||||
<CalorieCard
|
||||
consumed={Math.round(overview.totalCalories)}
|
||||
target={overview.target}
|
||||
remaining={Math.round(overview.remaining)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(['breakfast', 'lunch', 'dinner', 'snack'] as const).map(type => (
|
||||
(grouped[type] ?? []).length > 0 && (
|
||||
<View key={type} style={styles.section}>
|
||||
<Text style={styles.mealType}>{type.charAt(0).toUpperCase() + type.slice(1)}</Text>
|
||||
{(grouped[type] ?? []).map(meal => (
|
||||
<TouchableOpacity
|
||||
key={meal.id}
|
||||
style={styles.mealRow}
|
||||
onPress={() => navigation.navigate('DailyDetails', { date: today })}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${type}, ${Math.round(meal.totalCalories)} calories`}
|
||||
>
|
||||
<Text style={styles.mealRowText}>{type.charAt(0).toUpperCase() + type.slice(1)}</Text>
|
||||
<Text style={styles.mealRowKcal}>{Math.round(meal.totalCalories)} kcal</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
))}
|
||||
|
||||
{/* Repeat yesterday's lunch shortcut (REQ-INT-003) */}
|
||||
{yesterdayLunch && (
|
||||
<TouchableOpacity
|
||||
style={styles.repeatCard}
|
||||
onPress={repeatYesterdayLunch}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Repeat yesterday's lunch"
|
||||
>
|
||||
<Text style={styles.repeatText}>⚡ Repeat yesterday's lunch</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* FAB — 1-tap Add Meal (REQ-MOB-001, UX rule) */}
|
||||
<FAB onPress={() => setAddModalVisible(true)} />
|
||||
|
||||
{/* Add Meal bottom sheet (REQ-MOB-002) */}
|
||||
<Modal
|
||||
visible={addModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setAddModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setAddModalVisible(false)}
|
||||
>
|
||||
<View style={styles.bottomSheet}>
|
||||
<Text style={styles.sheetTitle}>Add Meal</Text>
|
||||
{[
|
||||
{ label: '📷 Take Photo', screen: 'Camera' },
|
||||
{ label: '🔍 Search Food', screen: 'Search' },
|
||||
].map(({ label, screen }) => (
|
||||
<TouchableOpacity
|
||||
key={screen}
|
||||
style={styles.sheetOption}
|
||||
onPress={() => { setAddModalVisible(false); navigation.navigate(screen); }}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
<Text style={styles.sheetOptionText}>{label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
style={styles.sheetCancel}
|
||||
onPress={() => setAddModalVisible(false)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel"
|
||||
>
|
||||
<Text style={styles.sheetCancelText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: Colors.backgroundMuted },
|
||||
scroll: { padding: Spacing.md, paddingBottom: 80 },
|
||||
section: { marginBottom: Spacing.md },
|
||||
mealType: { fontSize: 14, fontWeight: '600', color: Colors.gray500, marginBottom: Spacing.xs },
|
||||
mealRow: {
|
||||
backgroundColor: Colors.background,
|
||||
borderRadius: Spacing.borderRadius.md,
|
||||
padding: Spacing.md,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
minHeight: Spacing.touchTarget,
|
||||
},
|
||||
mealRowText: { fontSize: 16, color: Colors.gray900 },
|
||||
mealRowKcal: { fontSize: 14, color: Colors.gray500 },
|
||||
repeatCard: {
|
||||
backgroundColor: Colors.aiSuggestionBg,
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.aiSuggestionBorder,
|
||||
borderRadius: Spacing.borderRadius.md,
|
||||
padding: Spacing.md,
|
||||
minHeight: Spacing.touchTarget,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
repeatText: { fontSize: 15, color: Colors.primaryDark, fontWeight: '500' },
|
||||
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'flex-end' },
|
||||
bottomSheet: {
|
||||
backgroundColor: Colors.background,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
padding: Spacing.lg,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
sheetTitle: { fontSize: 18, fontWeight: '600', color: Colors.gray900, marginBottom: Spacing.md },
|
||||
sheetOption: {
|
||||
paddingVertical: Spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: Colors.gray100,
|
||||
minHeight: Spacing.touchTarget,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sheetOptionText: { fontSize: 16, color: Colors.gray900 },
|
||||
sheetCancel: { paddingVertical: Spacing.md, alignItems: 'center', minHeight: Spacing.touchTarget, justifyContent: 'center' },
|
||||
sheetCancelText: { fontSize: 16, color: Colors.error },
|
||||
});
|
||||
102
mobile/src/screens/LoginScreen.tsx
Normal file
102
mobile/src/screens/LoginScreen.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
// Generated by GitHub Copilot
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View, Text, TextInput, Alert, ScrollView, StyleSheet, KeyboardAvoidingView, Platform,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import Button from '../components/Button';
|
||||
import { login } from '../services/api';
|
||||
import { Colors } from '../theme/colors';
|
||||
import { Spacing } from '../theme/spacing';
|
||||
|
||||
/**
|
||||
* Login screen. REQ-AUTH-002 (mobile side).
|
||||
* Stores JWT in AsyncStorage on success — AsyncStorage is sandboxed per app.
|
||||
*/
|
||||
export default function LoginScreen() {
|
||||
const navigation = useNavigation<any>();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email.trim() || !password) {
|
||||
Alert.alert('Please enter your email and password');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await login(email.trim(), password);
|
||||
await AsyncStorage.setItem('jwt_token', data.token);
|
||||
await AsyncStorage.setItem('user_id', data.userId);
|
||||
// Re-render App.tsx to switch to App navigator
|
||||
navigation.reset({ index: 0, routes: [{ name: 'App' }] });
|
||||
} catch {
|
||||
Alert.alert('Login failed', 'Invalid email or password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.inner}>
|
||||
<Text style={styles.heading} accessibilityRole="header">Sign in</Text>
|
||||
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
accessibilityLabel="Email address"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
accessibilityLabel="Password"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleLogin}
|
||||
/>
|
||||
|
||||
<Button label="Sign in" onPress={handleLogin} loading={loading} />
|
||||
|
||||
<Button
|
||||
label="Create account"
|
||||
variant="ghost"
|
||||
onPress={() => navigation.navigate('Register')}
|
||||
/>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: Colors.background },
|
||||
inner: { padding: Spacing.lg, paddingTop: Spacing.xxl },
|
||||
heading: { fontSize: 28, fontWeight: '700', color: Colors.gray900, marginBottom: Spacing.xl },
|
||||
label: { fontSize: 14, color: Colors.gray700, marginBottom: Spacing.xs, marginTop: Spacing.md },
|
||||
input: {
|
||||
height: 48,
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.gray300,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: Spacing.md,
|
||||
fontSize: 16,
|
||||
color: Colors.gray900,
|
||||
backgroundColor: Colors.background,
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
});
|
||||
136
mobile/src/screens/ProfileScreen.tsx
Normal file
136
mobile/src/screens/ProfileScreen.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
// Generated by GitHub Copilot
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View, Text, TextInput, ScrollView, StyleSheet, Alert,
|
||||
} from 'react-native';
|
||||
import { Picker } from '@react-native-picker/picker';
|
||||
import { getProfile, updateProfile } from '../services/api';
|
||||
import Button from '../components/Button';
|
||||
import { Colors } from '../theme/colors';
|
||||
import { Spacing } from '../theme/spacing';
|
||||
|
||||
/**
|
||||
* Profile screen — edit health stats and goal.
|
||||
* Daily calorie target is auto-calculated by the backend (Mifflin-St Jeor BMR).
|
||||
* REQ-MOB-009, REQ-PRF-001, REQ-PRF-002
|
||||
*/
|
||||
export default function ProfileScreen() {
|
||||
const [age, setAge] = useState('');
|
||||
const [weightKg, setWeightKg] = useState('');
|
||||
const [heightCm, setHeightCm] = useState('');
|
||||
const [goal, setGoal] = useState<'lose' | 'maintain' | 'gain'>('maintain');
|
||||
const [target, setTarget] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getProfile().then(({ data }) => {
|
||||
setAge(data.age?.toString() ?? '');
|
||||
setWeightKg(data.weightKg?.toString() ?? '');
|
||||
setHeightCm(data.heightCm?.toString() ?? '');
|
||||
setGoal(data.goal ?? 'maintain');
|
||||
setTarget(data.dailyCaloriesTarget ?? null);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await updateProfile({
|
||||
age: age ? parseInt(age, 10) : undefined,
|
||||
weightKg: weightKg ? parseFloat(weightKg) : undefined,
|
||||
heightCm: heightCm ? parseFloat(heightCm) : undefined,
|
||||
goal,
|
||||
});
|
||||
setTarget(data.dailyCaloriesTarget);
|
||||
setEditing(false);
|
||||
Alert.alert('Profile saved!');
|
||||
} catch {
|
||||
Alert.alert('Could not save profile');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.heading} accessibilityRole="header">Profile</Text>
|
||||
|
||||
<Field label="Weight (kg)" value={weightKg} onChange={setWeightKg} editable={editing} keyboardType="decimal-pad" />
|
||||
<Field label="Height (cm)" value={heightCm} onChange={setHeightCm} editable={editing} keyboardType="decimal-pad" />
|
||||
<Field label="Age" value={age} onChange={setAge} editable={editing} keyboardType="number-pad" />
|
||||
|
||||
{editing && (
|
||||
<View>
|
||||
<Text style={styles.label}>Goal</Text>
|
||||
<Picker
|
||||
selectedValue={goal}
|
||||
onValueChange={v => setGoal(v)}
|
||||
accessibilityLabel="Goal"
|
||||
>
|
||||
<Picker.Item label="Lose weight" value="lose" />
|
||||
<Picker.Item label="Maintain weight" value="maintain" />
|
||||
<Picker.Item label="Gain weight" value="gain" />
|
||||
</Picker>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{target !== null && (
|
||||
<View style={styles.targetCard} accessible accessibilityLabel={`Daily target: ${target} calories`}>
|
||||
<Text style={styles.targetLabel}>Daily target</Text>
|
||||
<Text style={styles.targetValue}>{target} kcal</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<>
|
||||
<Button label="Save" onPress={save} loading={loading} />
|
||||
<Button label="Cancel" variant="ghost" onPress={() => setEditing(false)} />
|
||||
</>
|
||||
) : (
|
||||
<Button label="Edit Profile" variant="secondary" onPress={() => setEditing(true)} />
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label, value, onChange, editable, keyboardType,
|
||||
}: {
|
||||
label: string; value: string; onChange: (v: string) => void;
|
||||
editable: boolean; keyboardType?: any;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, !editable && styles.inputReadOnly]}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
editable={editable}
|
||||
keyboardType={keyboardType}
|
||||
accessibilityLabel={label}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: Colors.background },
|
||||
content: { padding: Spacing.md },
|
||||
heading: { fontSize: 24, fontWeight: '600', color: Colors.gray900, marginBottom: Spacing.lg },
|
||||
label: { fontSize: 14, color: Colors.gray700, marginBottom: Spacing.xs, marginTop: Spacing.md },
|
||||
input: {
|
||||
height: 48, borderWidth: 1, borderColor: Colors.gray300,
|
||||
borderRadius: 10, paddingHorizontal: Spacing.md, fontSize: 16, color: Colors.gray900,
|
||||
},
|
||||
inputReadOnly: { backgroundColor: Colors.backgroundMuted, color: Colors.gray700 },
|
||||
targetCard: {
|
||||
backgroundColor: Colors.aiSuggestionBg,
|
||||
borderWidth: 1, borderColor: Colors.aiSuggestionBorder,
|
||||
borderRadius: Spacing.borderRadius.md,
|
||||
padding: Spacing.md, marginVertical: Spacing.md, alignItems: 'center',
|
||||
},
|
||||
targetLabel: { fontSize: 13, color: Colors.gray500 },
|
||||
targetValue: { fontSize: 28, fontWeight: '700', color: Colors.primaryDark },
|
||||
});
|
||||
96
mobile/src/screens/RegisterScreen.tsx
Normal file
96
mobile/src/screens/RegisterScreen.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
// Generated by GitHub Copilot
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View, Text, TextInput, Alert, ScrollView, StyleSheet, KeyboardAvoidingView, Platform,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import Button from '../components/Button';
|
||||
import { register } from '../services/api';
|
||||
import { Colors } from '../theme/colors';
|
||||
import { Spacing } from '../theme/spacing';
|
||||
|
||||
/** Register screen. REQ-AUTH-001 (mobile side). */
|
||||
export default function RegisterScreen() {
|
||||
const navigation = useNavigation<any>();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!email.trim() || password.length < 8) {
|
||||
Alert.alert('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await register(email.trim(), password);
|
||||
await AsyncStorage.setItem('jwt_token', data.token);
|
||||
await AsyncStorage.setItem('user_id', data.userId);
|
||||
navigation.reset({ index: 0, routes: [{ name: 'App' }] });
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.status === 409
|
||||
? 'This email is already registered'
|
||||
: 'Registration failed. Please try again.';
|
||||
Alert.alert(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.inner}>
|
||||
<Text style={styles.heading} accessibilityRole="header">Create account</Text>
|
||||
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
accessibilityLabel="Email address"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Password (min 8 characters)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoComplete="new-password"
|
||||
accessibilityLabel="Password"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleRegister}
|
||||
/>
|
||||
|
||||
<Button label="Create account" onPress={handleRegister} loading={loading} />
|
||||
<Button label="Sign in instead" variant="ghost" onPress={() => navigation.goBack()} />
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: Colors.background },
|
||||
inner: { padding: Spacing.lg, paddingTop: Spacing.xxl },
|
||||
heading: { fontSize: 28, fontWeight: '700', color: Colors.gray900, marginBottom: Spacing.xl },
|
||||
label: { fontSize: 14, color: Colors.gray700, marginBottom: Spacing.xs, marginTop: Spacing.md },
|
||||
input: {
|
||||
height: 48,
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.gray300,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: Spacing.md,
|
||||
fontSize: 16,
|
||||
color: Colors.gray900,
|
||||
backgroundColor: Colors.background,
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
});
|
||||
119
mobile/src/screens/SearchScreen.tsx
Normal file
119
mobile/src/screens/SearchScreen.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// Generated by GitHub Copilot
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { View, TextInput, FlatList, StyleSheet, Text, Alert } 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 { Colors } from '../theme/colors';
|
||||
import { Spacing } from '../theme/spacing';
|
||||
|
||||
/**
|
||||
* Manual food search screen.
|
||||
* REQ-MOB-006, REQ-FOOD-001
|
||||
*/
|
||||
export default function SearchScreen() {
|
||||
const navigation = useNavigation<any>();
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<FoodItem[]>([]);
|
||||
const [selected, setSelected] = useState<FoodItem | null>(null);
|
||||
const [grams, setGrams] = useState(100);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const search = useCallback(async (text: string) => {
|
||||
setQuery(text);
|
||||
if (text.length < 2) { setResults([]); return; }
|
||||
try {
|
||||
const { data } = await searchFoods(text);
|
||||
setResults(data);
|
||||
} catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
const addToLog = async () => {
|
||||
if (!selected) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await createMeal({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
mealType: 'snack',
|
||||
source: 'manual',
|
||||
items: [{ foodItemId: selected.id, grams }],
|
||||
});
|
||||
Alert.alert('Added!', `${selected.name} logged.`);
|
||||
navigation.goBack();
|
||||
} catch {
|
||||
Alert.alert('Could not log food');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const estimatedKcal = selected
|
||||
? Math.round(selected.caloriesPer100g * grams / 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search food…"
|
||||
placeholderTextColor={Colors.gray500}
|
||||
value={query}
|
||||
onChangeText={search}
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
accessibilityLabel="Search food"
|
||||
returnKeyType="search"
|
||||
/>
|
||||
|
||||
{selected ? (
|
||||
<View style={styles.portionView}>
|
||||
<Text style={styles.foodName}>{selected.name}</Text>
|
||||
<PortionSlider
|
||||
foodName={selected.name}
|
||||
grams={grams}
|
||||
onValueChange={v => setGrams(Math.round(v))}
|
||||
/>
|
||||
<Text style={styles.kcalDisplay}>{estimatedKcal} kcal</Text>
|
||||
<Button label="✅ Add" onPress={addToLog} loading={loading} />
|
||||
<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} />
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
query.length >= 2
|
||||
? <Text style={styles.empty}>No results for "{query}"</Text>
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: Colors.background },
|
||||
searchInput: {
|
||||
height: 48,
|
||||
margin: Spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.gray300,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: Spacing.md,
|
||||
fontSize: 16,
|
||||
color: Colors.gray900,
|
||||
},
|
||||
portionView: { padding: Spacing.md },
|
||||
foodName: { fontSize: 20, fontWeight: '600', color: Colors.gray900, marginBottom: Spacing.md },
|
||||
kcalDisplay: {
|
||||
fontSize: 24, fontWeight: '700', color: Colors.gray900,
|
||||
textAlign: 'center', marginVertical: Spacing.md,
|
||||
},
|
||||
empty: { padding: Spacing.lg, textAlign: 'center', color: Colors.gray500 },
|
||||
});
|
||||
Reference in New Issue
Block a user