Skip to content
Merged
2 changes: 1 addition & 1 deletion SparkyFitnessMobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ npx expo prebuild --clean
- Screens intentionally off the hook (e.g. `FoodSearchScreen`'s bespoke anchored-menu bar) must mirror custom actions with `unstable_header{Left,Right}Items` themselves, hide the screen-owned React header behind `useNativeIOSHeadersActive()` with a guard such as `{!usesNativeHeader && <Header />}`, and gate the `useLayoutEffect` that sets native header items on the same flag; otherwise iOS renders both headers.
- When adding a tab, update `TabParamList`, `NativeTab.Screen`, and `FallbackTab.Screen`; for content tabs also add a tab-local native stack screen using `createIOSNativeHeaderOptions(...)`.
- `__tests__/navigation/nativeHeaderContract.test.ts` enforces this native-header wiring. If it fails, fix the route/type/navigator alignment instead of weakening the test.
- Current stack screens include onboarding/tabs, library/detail/form flows for foods/meals/exercises/presets, food entry view/edit, meal type detail and copy, `EditBarcode`, food search/entry/scan/photo flow, workout/activity add/detail, exercise/preset search, settings subscreens, logs, sync, measurements, fasting, and `WhatsNew`.
- Current stack screens include onboarding/tabs, library/detail/form flows for foods/meals/exercises/presets, food entry view/edit, meal type detail and copy, the family diary flows (`FamilyMembers`, `FamilyDiary`, `FamilyMealDetail`, and `FamilyCopyReview`), `EditBarcode`, food search/entry/scan/photo flow, workout/activity add/detail, exercise/preset search, settings subscreens, logs, sync, measurements, fasting, and `WhatsNew`.
Comment thread
Bl4nk24 marked this conversation as resolved.
- `AddSheet` offers Food, Workout, Activity, Preset, Measurements, Scan Food, Ask Sparky, and Sync Health Data. Keep its present/dismiss refs intact to avoid Android re-present loops.
- `useNavigationActionGuard` locks navigation-triggering actions while a native-stack transition is running (idle-callback unlock on re-focus, 5s safety release) so double-taps cannot queue duplicate screens; Library create actions use it.
- `ActiveWorkoutBar` is mounted outside normal screen trees, uses the root navigation ref, and hides itself on modal/editor routes such as food search/forms/scan/photo, exercise search, workout/activity add, measurements, and barcode edit.
Expand Down
33 changes: 33 additions & 0 deletions SparkyFitnessMobile/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ import {
SafeWhatsNew,
SafeDailyNutritionDetails,
SafeNutrientTrends,
SafeFamilyMembers,
SafeFamilyDiary,
SafeFamilyMealDetail,
SafeFamilyCopyReview,
SafeCycleSettings,
SafeCycleOnboarding,
SafeCycleHub,
Expand Down Expand Up @@ -336,6 +340,35 @@ function AppContent() {
/>
)}
</Stack.Screen>
<Stack.Screen
name="FamilyMembers"
component={SafeFamilyMembers}
options={createStackScreenOptions(t('familyDiary.title', { defaultValue: 'Family Diaries' }), {
headerBackButtonDisplayMode: 'minimal',
})}
/>
<Stack.Screen
name="FamilyDiary"
component={SafeFamilyDiary}
options={({ route }) => createStackScreenOptions(
route.params.familyUser.displayName.trim() || t('familyDiary.unnamedMember', { defaultValue: 'Family member' }),
{ headerBackButtonDisplayMode: 'minimal' },
)}
/>
<Stack.Screen
name="FamilyMealDetail"
component={SafeFamilyMealDetail}
options={({ route }) => createStackScreenOptions(route.params.mealTypeName, {
headerBackButtonDisplayMode: 'minimal',
})}
/>
<Stack.Screen
name="FamilyCopyReview"
component={SafeFamilyCopyReview}
options={createStackScreenOptions(t('familyDiary.copyReview', { defaultValue: 'Review copy' }), {
headerBackButtonDisplayMode: 'minimal',
})}
/>
<Stack.Screen
name="FoodsLibrary"
component={SafeFoodsLibrary}
Expand Down
156 changes: 156 additions & 0 deletions SparkyFitnessMobile/__tests__/components/DateNavigator.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import DateNavigator from '../../src/components/DateNavigator';
import i18n, { initializeI18n } from '../../src/localization/i18n';

describe('DateNavigator action', () => {
beforeAll(async () => {
await initializeI18n('en');
});

afterAll(async () => {
await i18n.changeLanguage('en');
});

test('renders an accessible 44 by 44 header action', () => {
const onPress = jest.fn();
const { getByRole } = render(
<SafeAreaProvider
initialMetrics={{
frame: { x: 0, y: 0, width: 390, height: 844 },
insets: { top: 0, bottom: 0, left: 0, right: 0 },
}}
>
<DateNavigator
title="Diary"
selectedDate="2025-01-15"
onPreviousDay={jest.fn()}
onNextDay={jest.fn()}
onToday={jest.fn()}
action={{
icon: 'people',
accessibilityLabel: 'Open family diaries',
onPress,
}}
/>
</SafeAreaProvider>,
);

const action = getByRole('button', { name: 'Open family diaries' });
expect(action.props.style).toEqual(
expect.objectContaining({ width: 44, height: 44 }),
);
fireEvent.press(action);
expect(onPress).toHaveBeenCalledTimes(1);
});

test('exposes every date control as a named 44 point button', () => {
const onPreviousDay = jest.fn();
const onNextDay = jest.fn();
const onDatePress = jest.fn();
const { getByRole } = render(
<SafeAreaProvider
initialMetrics={{
frame: { x: 0, y: 0, width: 390, height: 844 },
insets: { top: 0, bottom: 0, left: 0, right: 0 },
}}
>
<DateNavigator
title="Family diary"
selectedDate="2025-01-15"
onPreviousDay={onPreviousDay}
onNextDay={onNextDay}
onToday={jest.fn()}
onDatePress={onDatePress}
/>
</SafeAreaProvider>,
);

const previous = getByRole('button', { name: 'Previous day' });
const picker = getByRole('button', { name: 'Choose date' });
const next = getByRole('button', { name: 'Next day' });

for (const control of [previous, picker, next]) {
expect(control.props.style).toEqual(
expect.objectContaining({ minHeight: 44, minWidth: 44 }),
);
}
expect(previous.props.accessibilityHint).toBe('Shows the previous day');
expect(picker.props.accessibilityHint).toBe('Opens the date picker');
expect(next.props.accessibilityHint).toBe('Shows the next day');

fireEvent.press(previous);
fireEvent.press(picker);
fireEvent.press(next);
expect(onPreviousDay).toHaveBeenCalledTimes(1);
expect(onDatePress).toHaveBeenCalledTimes(1);
expect(onNextDay).toHaveBeenCalledTimes(1);
});

test('renders the global relative date and translated accessible controls', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date(2025, 0, 15, 12));
const screen = render(
<SafeAreaProvider
initialMetrics={{
frame: { x: 0, y: 0, width: 390, height: 844 },
insets: { top: 0, bottom: 0, left: 0, right: 0 },
}}
>
<DateNavigator
title="Dziennik rodzinny"
selectedDate="2025-01-15"
onPreviousDay={jest.fn()}
onNextDay={jest.fn()}
onToday={jest.fn()}
dateControls={{
previousDayLabel: 'Poprzedni dzień',
previousDayHint: 'Pokazuje poprzedni dzień',
nextDayLabel: 'Następny dzień',
nextDayHint: 'Pokazuje następny dzień',
chooseDateLabel: 'Wybierz datę',
chooseDateHint: 'Otwiera wybór daty',
goToTodayLabel: 'Przejdź do dzisiaj',
goToTodayHint: 'Wraca do dzisiaj',
}}
/>
</SafeAreaProvider>,
);

expect(screen.getByText('Today')).toBeTruthy();
expect(
screen.getByRole('button', { name: 'Poprzedni dzień' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: 'Następny dzień' })).toBeTruthy();
jest.useRealTimers();
});

test('localizes default accessible controls for existing callers', async () => {
await i18n.changeLanguage('pl');

const screen = render(
<SafeAreaProvider
initialMetrics={{
frame: { x: 0, y: 0, width: 390, height: 844 },
insets: { top: 0, bottom: 0, left: 0, right: 0 },
}}
>
<DateNavigator
title="Dziennik"
selectedDate="2025-01-15"
onPreviousDay={jest.fn()}
onNextDay={jest.fn()}
onToday={jest.fn()}
onDatePress={jest.fn()}
/>
</SafeAreaProvider>,
);

expect(
screen.getByRole('button', { name: 'Poprzedni dzień' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: 'Wybierz datę' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Następny dzień' })).toBeTruthy();
});
});
Loading
Loading