Skip to content

Commit e5a9cf5

Browse files
authored
Merge pull request #800 from apedley/app-barcode-scan
App Barcode Scan and Dash Charts
2 parents 14f12ff + 65042e2 commit e5a9cf5

33 files changed

Lines changed: 2422 additions & 128 deletions

SparkyFitnessMobile/App.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import FoodSearchScreen from './src/screens/FoodSearchScreen';
2424
import FoodEntryAddScreen from './src/screens/FoodEntryAddScreen';
2525
import FoodEntryViewScreen from './src/screens/FoodEntryViewScreen';
2626
import ManualFoodEntryScreen from './src/screens/ManualFoodEntryScreen';
27+
import FoodScanScreen from './src/screens/FoodScanScreen';
2728
import { configureBackgroundSync } from './src/services/backgroundSyncService';
2829
import { initializeTheme } from './src/services/themeService';
2930
import { initLogService } from './src/services/LogService';
@@ -200,6 +201,8 @@ function AppContent() {
200201
options={{
201202
presentation: 'modal',
202203
headerShown: false,
204+
gestureEnabled: true,
205+
gestureDirection: 'horizontal',
203206
}}
204207
/>
205208
<Stack.Screen
@@ -220,6 +223,15 @@ function AppContent() {
220223
gestureDirection: 'horizontal',
221224
}}
222225
/>
226+
<Stack.Screen
227+
name="FoodScan"
228+
component={FoodScanScreen}
229+
options={{
230+
headerShown: false,
231+
gestureEnabled: true,
232+
gestureDirection: 'horizontal',
233+
}}
234+
/>
223235
<Stack.Screen
224236
name="FoodEntryView"
225237
component={FoodEntryViewScreen}
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { renderHook, waitFor } from '@testing-library/react-native';
2+
import React from 'react';
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4+
import { useMeasurementsRange } from '../../src/hooks/useMeasurementsRange';
5+
import { measurementsRangeQueryKey } from '../../src/hooks/queryKeys';
6+
import { fetchMeasurementsRange } from '../../src/services/api/measurementsApi';
7+
import { getTodayDate, addDays } from '../../src/utils/dateUtils';
8+
import type { CheckInMeasurementRange } from '../../src/types/measurements';
9+
10+
jest.mock('../../src/services/api/measurementsApi', () => ({
11+
fetchMeasurementsRange: jest.fn(),
12+
}));
13+
14+
jest.mock('@react-navigation/native', () => ({
15+
useFocusEffect: jest.fn((callback) => {
16+
callback();
17+
}),
18+
}));
19+
20+
const mockFetchMeasurementsRange = fetchMeasurementsRange as jest.MockedFunction<typeof fetchMeasurementsRange>;
21+
22+
const makeMeasurement = (entry_date: string, steps?: number): CheckInMeasurementRange => ({
23+
id: `id-${entry_date}`,
24+
user_id: 'user-1',
25+
entry_date,
26+
steps,
27+
updated_at: `${entry_date}T12:00:00Z`,
28+
});
29+
30+
describe('useMeasurementsRange', () => {
31+
let queryClient: QueryClient;
32+
33+
const createWrapper = () => {
34+
const Wrapper = ({ children }: { children: React.ReactNode }) =>
35+
React.createElement(QueryClientProvider, { client: queryClient }, children);
36+
Wrapper.displayName = 'QueryClientProviderWrapper';
37+
return Wrapper;
38+
};
39+
40+
beforeEach(() => {
41+
jest.clearAllMocks();
42+
queryClient = new QueryClient({
43+
defaultOptions: {
44+
queries: {
45+
retry: false,
46+
staleTime: 0,
47+
},
48+
},
49+
});
50+
});
51+
52+
afterEach(() => {
53+
queryClient.clear();
54+
});
55+
56+
describe('data transformation', () => {
57+
test('returns correct number of data points for 7d range', async () => {
58+
mockFetchMeasurementsRange.mockResolvedValue([]);
59+
60+
const { result } = renderHook(
61+
() => useMeasurementsRange({ range: '7d' }),
62+
{ wrapper: createWrapper() },
63+
);
64+
65+
await waitFor(() => {
66+
expect(result.current.isLoading).toBe(false);
67+
});
68+
69+
expect(result.current.stepsData).toHaveLength(7);
70+
});
71+
72+
test('returns correct number of data points for 30d range', async () => {
73+
mockFetchMeasurementsRange.mockResolvedValue([]);
74+
75+
const { result } = renderHook(
76+
() => useMeasurementsRange({ range: '30d' }),
77+
{ wrapper: createWrapper() },
78+
);
79+
80+
await waitFor(() => {
81+
expect(result.current.isLoading).toBe(false);
82+
});
83+
84+
expect(result.current.stepsData).toHaveLength(30);
85+
});
86+
87+
test('returns correct number of data points for 90d range', async () => {
88+
mockFetchMeasurementsRange.mockResolvedValue([]);
89+
90+
const { result } = renderHook(
91+
() => useMeasurementsRange({ range: '90d' }),
92+
{ wrapper: createWrapper() },
93+
);
94+
95+
await waitFor(() => {
96+
expect(result.current.isLoading).toBe(false);
97+
});
98+
99+
expect(result.current.stepsData).toHaveLength(90);
100+
});
101+
102+
test('fills missing days with 0 steps', async () => {
103+
const today = getTodayDate();
104+
mockFetchMeasurementsRange.mockResolvedValue([
105+
makeMeasurement(today, 5000),
106+
]);
107+
108+
const { result } = renderHook(
109+
() => useMeasurementsRange({ range: '7d' }),
110+
{ wrapper: createWrapper() },
111+
);
112+
113+
await waitFor(() => {
114+
expect(result.current.isLoading).toBe(false);
115+
});
116+
117+
// Only today should have steps, rest should be 0
118+
const nonZero = result.current.stepsData.filter((d) => d.steps > 0);
119+
expect(nonZero).toHaveLength(1);
120+
expect(nonZero[0].day).toBe(today);
121+
expect(nonZero[0].steps).toBe(5000);
122+
123+
const zeros = result.current.stepsData.filter((d) => d.steps === 0);
124+
expect(zeros).toHaveLength(6);
125+
});
126+
127+
test('deduplicates entries per date keeping the first (most recent)', async () => {
128+
const today = getTodayDate();
129+
mockFetchMeasurementsRange.mockResolvedValue([
130+
{ ...makeMeasurement(today, 8000), updated_at: `${today}T14:00:00Z` },
131+
{ ...makeMeasurement(today, 5000), updated_at: `${today}T10:00:00Z` },
132+
]);
133+
134+
const { result } = renderHook(
135+
() => useMeasurementsRange({ range: '7d' }),
136+
{ wrapper: createWrapper() },
137+
);
138+
139+
await waitFor(() => {
140+
expect(result.current.isLoading).toBe(false);
141+
});
142+
143+
const todayPoint = result.current.stepsData.find((d) => d.day === today);
144+
expect(todayPoint?.steps).toBe(8000);
145+
});
146+
147+
test('returns data in chronological order (ascending)', async () => {
148+
const today = getTodayDate();
149+
const yesterday = addDays(today, -1);
150+
mockFetchMeasurementsRange.mockResolvedValue([
151+
makeMeasurement(today, 3000),
152+
makeMeasurement(yesterday, 7000),
153+
]);
154+
155+
const { result } = renderHook(
156+
() => useMeasurementsRange({ range: '7d' }),
157+
{ wrapper: createWrapper() },
158+
);
159+
160+
await waitFor(() => {
161+
expect(result.current.isLoading).toBe(false);
162+
});
163+
164+
const data = result.current.stepsData;
165+
// Last item should be today
166+
expect(data[data.length - 1].day).toBe(today);
167+
expect(data[data.length - 2].day).toBe(yesterday);
168+
169+
// Verify ascending order
170+
for (let i = 1; i < data.length; i++) {
171+
expect(data[i].day > data[i - 1].day).toBe(true);
172+
}
173+
});
174+
175+
test('treats undefined steps as 0', async () => {
176+
const today = getTodayDate();
177+
mockFetchMeasurementsRange.mockResolvedValue([
178+
makeMeasurement(today, undefined),
179+
]);
180+
181+
const { result } = renderHook(
182+
() => useMeasurementsRange({ range: '7d' }),
183+
{ wrapper: createWrapper() },
184+
);
185+
186+
await waitFor(() => {
187+
expect(result.current.isLoading).toBe(false);
188+
});
189+
190+
const todayPoint = result.current.stepsData.find((d) => d.day === today);
191+
expect(todayPoint?.steps).toBe(0);
192+
});
193+
});
194+
195+
describe('API calls', () => {
196+
test('calls fetchMeasurementsRange with correct date range for 7d', async () => {
197+
mockFetchMeasurementsRange.mockResolvedValue([]);
198+
const today = getTodayDate();
199+
const startDate = addDays(today, -6);
200+
201+
renderHook(
202+
() => useMeasurementsRange({ range: '7d' }),
203+
{ wrapper: createWrapper() },
204+
);
205+
206+
await waitFor(() => {
207+
expect(mockFetchMeasurementsRange).toHaveBeenCalledWith(startDate, today);
208+
});
209+
});
210+
211+
test('calls fetchMeasurementsRange with correct date range for 30d', async () => {
212+
mockFetchMeasurementsRange.mockResolvedValue([]);
213+
const today = getTodayDate();
214+
const startDate = addDays(today, -29);
215+
216+
renderHook(
217+
() => useMeasurementsRange({ range: '30d' }),
218+
{ wrapper: createWrapper() },
219+
);
220+
221+
await waitFor(() => {
222+
expect(mockFetchMeasurementsRange).toHaveBeenCalledWith(startDate, today);
223+
});
224+
});
225+
});
226+
227+
describe('options', () => {
228+
test('respects enabled=false', async () => {
229+
mockFetchMeasurementsRange.mockResolvedValue([]);
230+
231+
renderHook(
232+
() => useMeasurementsRange({ range: '7d', enabled: false }),
233+
{ wrapper: createWrapper() },
234+
);
235+
236+
await new Promise((resolve) => setTimeout(resolve, 50));
237+
238+
expect(mockFetchMeasurementsRange).not.toHaveBeenCalled();
239+
});
240+
241+
test('enabled defaults to true', async () => {
242+
mockFetchMeasurementsRange.mockResolvedValue([]);
243+
244+
renderHook(
245+
() => useMeasurementsRange({ range: '7d' }),
246+
{ wrapper: createWrapper() },
247+
);
248+
249+
await waitFor(() => {
250+
expect(mockFetchMeasurementsRange).toHaveBeenCalled();
251+
});
252+
});
253+
});
254+
255+
describe('query key', () => {
256+
test('exports correct query key function', () => {
257+
expect(measurementsRangeQueryKey('2024-06-01', '2024-06-07')).toEqual([
258+
'measurementsRange',
259+
'2024-06-01',
260+
'2024-06-07',
261+
]);
262+
});
263+
264+
test('query key changes with dates', () => {
265+
expect(measurementsRangeQueryKey('2024-06-01', '2024-06-07')).not.toEqual(
266+
measurementsRangeQueryKey('2024-06-01', '2024-06-08'),
267+
);
268+
});
269+
});
270+
});

SparkyFitnessMobile/app.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,14 @@
3838
"expo-font",
3939
"expo-asset",
4040
"expo-background-task",
41-
"expo-secure-store"
41+
"expo-secure-store",
42+
[
43+
"expo-camera",
44+
{
45+
"cameraPermission": "SparkyFitness needs access to your camera to scan food barcodes.",
46+
"recordAudioAndroid": false
47+
}
48+
]
4249
],
4350
"experiments": {
4451
"reactCompiler": true

SparkyFitnessMobile/docs/external_providers.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Most providers require an `x-provider-id` header — that's the ID of the user's
1414

1515

1616
## Open Food Facts
17+
18+
### Search
1719
```
1820
curl -X GET 'http://10.0.0.75:8080/api/foods/openfoodfacts/search?query=pepper' \
1921
--header 'Accept: */*' \
@@ -39,6 +41,26 @@ interface OpenFoodFactsProduct {
3941
}
4042
```
4143

44+
### Barcode Search
45+
```
46+
curl -X GET 'http://10.0.0.75:8080/api/foods/openfoodfacts/barcode/0851953005136' \
47+
--header 'Accept: */*' \
48+
--header 'Authorization: Bearer {token}'
49+
```
50+
51+
52+
#### Response
53+
54+
```json
55+
{
56+
"code": "0851953005136",
57+
"product": {
58+
"_id": "0851953005136",
59+
...
60+
}
61+
}
62+
```
63+
4264
## FatSecret
4365

4466
### Search

SparkyFitnessMobile/docs/food_api.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,42 @@ Searches for foods based on searchTerm. foodFilter is unused.
104104
"custom_nutrients": {},
105105
"meal_type_id": "d7bbfb6c-a5fb-48e0-9444-718bd20e7885"
106106
}
107+
```
108+
109+
110+
## POST /api/foods
111+
112+
```json
113+
{
114+
"name": "Protein Shake",
115+
"brand": "Homemade",
116+
"user_id": "some-uuid-here",
117+
"is_custom": true,
118+
"is_quick_food": true,
119+
"barcode": null,
120+
"provider_external_id": null,
121+
"provider_type": null,
122+
"serving_size": 1,
123+
"serving_unit": "serving",
124+
"calories": 250,
125+
"protein": 30,
126+
"carbs": 15,
127+
"fat": 8,
128+
"saturated_fat": 2,
129+
"polyunsaturated_fat": null,
130+
"monounsaturated_fat": null,
131+
"trans_fat": 0,
132+
"cholesterol": null,
133+
"sodium": null,
134+
"potassium": null,
135+
"dietary_fiber": null,
136+
"sugars": 5,
137+
"vitamin_a": null,
138+
"vitamin_c": null,
139+
"calcium": null,
140+
"iron": null,
141+
"is_default": true,
142+
"glycemic_index": null,
143+
"custom_nutrients": {}
144+
}
107145
```

0 commit comments

Comments
 (0)