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:
2026-05-18 21:56:13 +03:00
commit 91cd18aec6
106 changed files with 13886 additions and 0 deletions

View 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,
},
});

View 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 },
});

View 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 },
});

View 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,
},
});

View 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 },
});

View 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 },
});

View 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,
},
});

View 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 },
});

View 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,
},
});

View 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 },
});