diff --git a/backend/src/modules/activity-log/repositories/activity-log.repository.spec.ts b/backend/src/modules/activity-log/repositories/activity-log.repository.spec.ts new file mode 100644 index 00000000..f035a892 --- /dev/null +++ b/backend/src/modules/activity-log/repositories/activity-log.repository.spec.ts @@ -0,0 +1,95 @@ +import { Repository } from 'typeorm'; +import { OrderDirection } from 'src/common/enums/order-direction.enum'; +import { ActivityLogEntity } from '../entities/activity-log.entity'; +import { FindManyActivityLogsOptions } from '../models/activity-log.model'; +import { ActivityLogRepositoryService } from './activity-log.repository'; + +describe('ActivityLogRepositoryService.findMany ordering', () => { + it.each([ + { + name: 'date descending', + orderBy: 'date', + orderDirection: OrderDirection.DESC, + primary: 'activityLog.date', + direction: OrderDirection.DESC, + secondary: ['activityLog.createdOn', 'activityLog.id'], + }, + { + name: 'date ascending', + orderBy: 'date', + orderDirection: OrderDirection.ASC, + primary: 'activityLog.date', + direction: OrderDirection.ASC, + secondary: ['activityLog.createdOn', 'activityLog.id'], + }, + { + name: 'default sort', + orderBy: undefined, + orderDirection: undefined, + primary: 'activityLog.createdOn', + direction: OrderDirection.ASC, + secondary: ['activityLog.id'], + }, + { + name: 'explicit createdOn descending', + orderBy: 'createdOn', + orderDirection: OrderDirection.DESC, + primary: 'activityLog.createdOn', + direction: OrderDirection.DESC, + secondary: ['activityLog.id'], + }, + { + name: 'prefixed createdOn descending', + orderBy: 'activityLog.createdOn', + orderDirection: OrderDirection.DESC, + primary: 'activityLog.createdOn', + direction: OrderDirection.DESC, + secondary: ['activityLog.id'], + }, + { + name: 'relation column descending', + orderBy: 'user.name', + orderDirection: OrderDirection.DESC, + primary: 'user.name', + direction: OrderDirection.DESC, + secondary: ['activityLog.createdOn', 'activityLog.id'], + }, + ])( + '$name', + async ({ orderBy, orderDirection, primary, direction, secondary }) => { + const query = { + leftJoinAndMapOne: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + const repository = new ActivityLogRepositoryService({ + createQueryBuilder: jest.fn().mockReturnValue(query), + } as unknown as Repository); + const options: FindManyActivityLogsOptions = { + organizationId: 'organization-id', + limit: 10, + page: 1, + orderBy, + orderDirection, + }; + + await repository.findMany(options); + + expect(query.orderBy.mock.calls).toEqual([[primary, direction]]); + expect(query.addOrderBy.mock.calls).toEqual( + secondary.map((column) => [column, direction]), + ); + expect(query.orderBy.mock.invocationCallOrder[0]).toBeLessThan( + query.addOrderBy.mock.invocationCallOrder[0], + ); + expect( + query.addOrderBy.mock.invocationCallOrder.slice(-1)[0], + ).toBeLessThan(query.take.mock.invocationCallOrder[0]); + }, + ); +}); diff --git a/backend/src/modules/activity-log/repositories/activity-log.repository.ts b/backend/src/modules/activity-log/repositories/activity-log.repository.ts index 411c5a6f..cca6dcac 100644 --- a/backend/src/modules/activity-log/repositories/activity-log.repository.ts +++ b/backend/src/modules/activity-log/repositories/activity-log.repository.ts @@ -42,6 +42,11 @@ export class ActivityLogRepositoryService findMany( findOptions: FindManyActivityLogsOptions, ): Promise> { + const direction = findOptions.orderDirection || OrderDirection.ASC; + const orderBy = this.buildOrderByQuery( + findOptions.orderBy || 'createdOn', + 'activityLog', + ); let query = this.activityLogRepo // TODO: strong type queries to use only the resulting select .createQueryBuilder('activityLog') .leftJoinAndMapOne('activityLog.event', 'activityLog.event', 'event') @@ -94,13 +99,13 @@ export class ActivityLogRepositoryService .where('activityLog.organizationId = :organizationId', { organizationId: findOptions.organizationId, }) - .orderBy( - this.buildOrderByQuery( - findOptions.orderBy || 'createdOn', - 'activityLog', - ), - findOptions.orderDirection || OrderDirection.ASC, - ); + .orderBy(orderBy, direction); + + // Break ties consistently so logs with the same date have stable pagination. + if (orderBy !== 'activityLog.createdOn') { + query.addOrderBy('activityLog.createdOn', direction); + } + query.addOrderBy('activityLog.id', direction); if (findOptions.search) { query.andWhere( diff --git a/mobile/patches/unistyles.patch b/mobile/patches/react-native-unistyles+3.8.1.patch similarity index 100% rename from mobile/patches/unistyles.patch rename to mobile/patches/react-native-unistyles+3.8.1.patch diff --git a/mobile/src/common/utils/download-contract.test.ts b/mobile/src/common/utils/download-contract.test.ts new file mode 100644 index 00000000..68994616 --- /dev/null +++ b/mobile/src/common/utils/download-contract.test.ts @@ -0,0 +1,64 @@ +import { Platform } from 'react-native'; +import * as FileSystem from 'expo-file-system/legacy'; +import { shareAsync } from 'expo-sharing'; +import { IContractListItem } from '../interfaces/contract-list-item.interface'; +import { downloadContract } from './download-contract'; + +jest.mock('expo-file-system/legacy', () => ({ + documentDirectory: 'file:///documents/', + downloadAsync: jest.fn(), + deleteAsync: jest.fn(async () => undefined), +})); +jest.mock('expo-sharing', () => ({ shareAsync: jest.fn(async () => undefined) })); + +const contract = { + path: 'https://example.com/contract.pdf', + contractFileName: 'Contract_12/2026.pdf', +} as IContractListItem; +const uri = 'file:///documents/Contract_12-2026.pdf'; + +beforeEach(() => { + jest.clearAllMocks(); + jest.replaceProperty(Platform, 'OS', 'ios'); +}); +afterEach(() => jest.restoreAllMocks()); + +describe('downloadContract', () => { + it.each([199, 300, 404, 500])( + 'rejects HTTP %i and deletes the file without sharing', + async (status) => { + jest + .mocked(FileSystem.downloadAsync) + .mockResolvedValue({ uri, status } as FileSystem.FileSystemDownloadResult); + + await expect(downloadContract(contract)).rejects.toThrow( + `Contract download failed (${status})`, + ); + + expect(FileSystem.deleteAsync).toHaveBeenCalledWith(uri, { idempotent: true }); + expect(shareAsync).not.toHaveBeenCalled(); + }, + ); + + it('still rejects the HTTP error when deleting the file fails', async () => { + jest + .mocked(FileSystem.downloadAsync) + .mockResolvedValue({ uri, status: 404 } as FileSystem.FileSystemDownloadResult); + jest.mocked(FileSystem.deleteAsync).mockRejectedValueOnce(new Error('Delete failed')); + + await expect(downloadContract(contract)).rejects.toThrow('Contract download failed (404)'); + expect(shareAsync).not.toHaveBeenCalled(); + }); + + it.each([200, 299])('shares a successful HTTP %i download', async (status) => { + jest + .mocked(FileSystem.downloadAsync) + .mockResolvedValue({ uri, status } as FileSystem.FileSystemDownloadResult); + + await downloadContract(contract); + + expect(FileSystem.downloadAsync).toHaveBeenCalledWith(contract.path, uri); + expect(shareAsync).toHaveBeenCalledWith(uri); + expect(FileSystem.deleteAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/mobile/src/common/utils/download-contract.ts b/mobile/src/common/utils/download-contract.ts index 3c26980c..6f929055 100644 --- a/mobile/src/common/utils/download-contract.ts +++ b/mobile/src/common/utils/download-contract.ts @@ -32,6 +32,10 @@ export const downloadContract = async (contract: IContractListItem) => { const fileName = sanitizeFileName(contract.contractFileName); const localPath = `${FileSystem.documentDirectory}${fileName}`; const file = await FileSystem.downloadAsync(contract.path, localPath); + if (file.status < 200 || file.status >= 300) { + await FileSystem.deleteAsync(file.uri, { idempotent: true }).catch(() => undefined); + throw new Error(`Contract download failed (${file.status})`); + } const extension = fileName.split('.').pop() as string; await save(file.uri, fileName, MIME_TYPES[extension] as string); }; diff --git a/mobile/src/common/utils/phone.test.ts b/mobile/src/common/utils/phone.test.ts new file mode 100644 index 00000000..f0be833c --- /dev/null +++ b/mobile/src/common/utils/phone.test.ts @@ -0,0 +1,15 @@ +import { normalizePhoneNumber } from './phone'; + +describe('normalizePhoneNumber', () => { + it.each([ + ['0799111333', '799111333'], + ['799111333', '799111333'], + ['0', ''], + ['', ''], + ['0700111333', '700111333'], + ['7991113334', '7991113334'], + ['invalid', 'invalid'], + ])('normalizes %j to %j without hiding validation errors', (value, expected) => { + expect(normalizePhoneNumber(value)).toBe(expected); + }); +}); diff --git a/mobile/src/common/utils/phone.ts b/mobile/src/common/utils/phone.ts new file mode 100644 index 00000000..8318b0ea --- /dev/null +++ b/mobile/src/common/utils/phone.ts @@ -0,0 +1,2 @@ +// The +40 prefix is rendered separately; forms and validation hold the nine national digits. +export const normalizePhoneNumber = (value: string): string => value.replace(/^0/, ''); diff --git a/mobile/src/components/FormInput.tsx b/mobile/src/components/FormInput.tsx index 9b6200bb..a01d28b2 100644 --- a/mobile/src/components/FormInput.tsx +++ b/mobile/src/components/FormInput.tsx @@ -15,6 +15,7 @@ interface FormInputProps extends Omit { disabled?: boolean; /** element or render function (gets the text style), e.g. the +40 phone prefix */ accessoryLeft?: React.ReactNode | ((props: any) => React.ReactNode); + normalizeText?: (text: string) => string; [key: string]: any; } @@ -30,6 +31,7 @@ const FormInput: React.FC = ({ helper, disabled, accessoryLeft, + normalizeText, backdropComponent: _backdropComponent, onBlur: onBlurProp, ...rest @@ -47,7 +49,7 @@ const FormInput: React.FC = ({ onBlur(); onBlurProp?.(event); }} - onChangeText={onChange} + onChangeText={(text) => onChange(normalizeText ? normalizeText(text) : text)} value={value === undefined || value === null ? '' : `${value}`} error={error?.message} helper={helper} diff --git a/mobile/src/components/FormSelect.tsx b/mobile/src/components/FormSelect.tsx index dc02abff..b37268bb 100644 --- a/mobile/src/components/FormSelect.tsx +++ b/mobile/src/components/FormSelect.tsx @@ -1,14 +1,10 @@ import React, { useMemo, useRef, useState } from 'react'; -import { StyleSheet, View } from 'react-native'; -import { - BottomSheetFlatList, - BottomSheetModal, - BottomSheetScrollView, - BottomSheetTextInput, -} from '@gorhom/bottom-sheet'; +import { Keyboard, StyleSheet, View } from 'react-native'; +import { BottomSheetFlatList, BottomSheetModal, BottomSheetTextInput } from '@gorhom/bottom-sheet'; import { useTranslation } from 'react-i18next'; import { Control, Controller } from 'react-hook-form'; import Svg, { Path } from 'react-native-svg'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { colors } from '../common/theme/tokens'; import { PressableScale, Sheet, VText, VTextField } from './ui'; import { INLINE_MAX, Select } from '../design/components/Select'; @@ -108,6 +104,7 @@ const FormSelect: React.FC = ({ disabled, }: FormSelectProps) => { const { t } = useTranslation('general'); + const insets = useSafeAreaInsets(); const sheet = useRef(null); const long = options.length > LONG_LIST; const [query, setQuery] = useState(''); @@ -144,6 +141,7 @@ const FormSelect: React.FC = ({ title={label} testID={`select-${name}-sheet`} fullHeight={long} + scrollable={!long} closeLabel={`${t('close')}`} onDismiss={() => setQuery('')} > @@ -166,7 +164,7 @@ const FormSelect: React.FC = ({ keyExtractor={(option: ISelectItem) => `${option.key}`} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" - contentContainerStyle={styles.list} + contentContainerStyle={[styles.list, { paddingBottom: 24 + insets.bottom }]} initialNumToRender={20} ListEmptyComponent={ @@ -178,6 +176,9 @@ const FormSelect: React.FC = ({ option={option} active={option.key === value} onPress={() => { + // Blur the search before closing/unmounting it, so focus cannot fall + // back to an input in the underlying form. + Keyboard.dismiss(); onChange(option.key); sheet.current?.dismiss(); }} @@ -187,20 +188,21 @@ const FormSelect: React.FC = ({ /> ) : ( - + {options.map((option) => ( + )} ) : null @@ -215,7 +217,6 @@ export default FormSelect; const styles = StyleSheet.create({ text: { flex: 1 }, - options: { maxHeight: 420 }, search: { paddingHorizontal: 20 }, list: { paddingHorizontal: 20, paddingBottom: 24 }, empty: { paddingVertical: 24, textAlign: 'center' }, diff --git a/mobile/src/components/ProfileIntro.tsx b/mobile/src/components/ProfileIntro.tsx index 373d0082..a83d4749 100644 --- a/mobile/src/components/ProfileIntro.tsx +++ b/mobile/src/components/ProfileIntro.tsx @@ -20,7 +20,7 @@ const ProfileIntro = ({ uri, name, description, children, testID }: ProfileIntro - + {name} {!!description && {description}} diff --git a/mobile/src/components/forms.test.tsx b/mobile/src/components/forms.test.tsx index 6e090f1c..c3cfa0f4 100644 --- a/mobile/src/components/forms.test.tsx +++ b/mobile/src/components/forms.test.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import { Text } from 'react-native'; +import { Dimensions, Keyboard, StyleSheet, Text } from 'react-native'; import { useForm } from 'react-hook-form'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { fireEvent, render, screen, within } from '@testing-library/react-native'; import { NavigationContainer } from '@react-navigation/native'; +import { BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet'; import '../common/config/i18n'; import FormInput from './FormInput'; import FormSelect from './FormSelect'; @@ -11,7 +12,20 @@ import PageLayout from '../layouts/PageLayout'; import { renderPhoneNumberPrefix } from './InputPrefixes'; import { ButtonType } from '../common/enums/button-type.enum'; -jest.mock('@gorhom/bottom-sheet', () => require('@gorhom/bottom-sheet/mock')); +jest.mock('@gorhom/bottom-sheet', () => { + const mock = require('@gorhom/bottom-sheet/mock'); + const React = require('react'); + const { View } = require('react-native'); + return { + ...mock, + BottomSheetView: View, + BottomSheetModal: class extends mock.BottomSheetModal { + render() { + return React.createElement(View, { ...this.props, testID: 'mock-sheet-modal' }); + } + }, + }; +}); let mockValues: Record = {}; @@ -71,6 +85,117 @@ describe('VIC 2.0 form fields', () => { expect(screen.getByTestId('field-phone').props.editable).toBe(false); }); + it('blurs the form input when opening a picker', async () => { + const dismiss = jest.spyOn(Keyboard, 'dismiss'); + await render(withSafeArea(
)); + await fireEvent.press(screen.getByTestId('select-county')); + expect(dismiss).toHaveBeenCalledTimes(1); + dismiss.mockRestore(); + }); + + it('blurs a searchable picker before selecting and dismissing its sheet', async () => { + const dismiss = jest.spyOn(Keyboard, 'dismiss'); + const Picker = () => { + const { control, watch } = useForm({ defaultValues: { county: 1 } }); + mockValues = watch(); + return ( + ({ + key: index + 1, + label: `Județ ${index + 1}`, + }))} + /> + ); + }; + await render(withSafeArea()); + await fireEvent.press(screen.getByTestId('select-county')); + await fireEvent.changeText(screen.getByTestId('select-county-search'), '13'); + dismiss.mockClear(); + await fireEvent.press(screen.getByTestId('select-county-option-13')); + expect(dismiss).toHaveBeenCalledTimes(1); + expect(mockValues.county).toBe(13); + dismiss.mockRestore(); + }); + + it.each([ + [7, 0], + [8, 24], + [9, 48], + [12, 34], + [13, 0], + [13, 24], + [13, 34], + [13, 48], + ])( + 'pads a %s-option picker with bottom inset %s inside its scroll content', + async (count, bottom) => { + const present = jest.spyOn(BottomSheetModal.prototype, 'present'); + const Picker = () => { + const { control } = useForm({ defaultValues: { event: 1 } }); + return ( + ({ + key: index + 1, + label: `Eveniment ${index + 1}`, + }))} + /> + ); + }; + await render( + + + , + ); + await fireEvent.press(screen.getByTestId('select-event')); + expect(present).toHaveBeenCalledTimes(1); + present.mockRestore(); + const scrollables = screen.container.queryAll( + (node) => node.type.includes('ScrollView') && !!node.props.contentContainerStyle, + ); + expect(scrollables.length).toBeGreaterThan(0); + for (const scroll of scrollables) { + expect(scroll.props.keyboardShouldPersistTaps).toBe('handled'); + expect(StyleSheet.flatten(scroll.props.contentContainerStyle).paddingBottom).toBe( + count > 12 ? 24 + bottom : Math.max(bottom, 14) + 10, + ); + } + const modal = screen.getByTestId('mock-sheet-modal'); + if (count <= 12) { + expect(modal.props.enableDynamicSizing).toBe(true); + expect(modal.props.maxDynamicContentSize).toBe(Dimensions.get('window').height - 24 - 28); + // Inspect the modal's direct React child: a nested scrollable in BottomSheetView fails this. + expect(modal.props.children.type).toBe(BottomSheetScrollView); + expect(scrollables).toHaveLength(1); + expect(StyleSheet.flatten(modal.props.children.props.style)?.maxHeight).toBeUndefined(); + const content = within(screen.getByTestId('select-event-sheet')); + expect(content.getByRole('header', { name: 'Eveniment' })).toBeTruthy(); + for (let index = 1; index <= count; index++) { + expect(content.getByTestId(`select-event-option-${index}`)).toBeTruthy(); + } + } else { + expect(modal.props.enableDynamicSizing).toBe(false); + expect(screen.getByTestId('select-event-sheet')).toHaveStyle({ paddingBottom: 0 }); + expect(screen.getByTestId('select-event-search')).toBeTruthy(); + } + await fireEvent.press(screen.getByTestId(`select-event-option-${count}`)); + expect( + within(screen.getByTestId('select-event')).getByText(`Eveniment ${count}`), + ).toBeTruthy(); + }, + ); + it('a select shows the chosen option and changes it from the sheet', async () => { await render(withSafeArea()); expect(within(screen.getByTestId('select-county')).getByText('București')).toBeTruthy(); diff --git a/mobile/src/components/hours/AddHoursSheet.tsx b/mobile/src/components/hours/AddHoursSheet.tsx index c1966a6c..0d52f856 100644 --- a/mobile/src/components/hours/AddHoursSheet.tsx +++ b/mobile/src/components/hours/AddHoursSheet.tsx @@ -14,7 +14,6 @@ import { BottomSheetScrollView, BottomSheetScrollViewMethods, } from '@gorhom/bottom-sheet'; -import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'; import Animated, { SharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'; import { StyleSheet } from 'react-native-unistyles'; import { useForm } from 'react-hook-form'; @@ -70,14 +69,10 @@ export const AddHoursSheet = forwardRef const timer = useRef | null>(null); const { mutate: logHours, isLoading: isLoggingHours } = useCreateActivityLogMutation(); - // Keyboard up: the sheet only rises to its top inset, so its lower part (and the scroll view in - // it) stays behind the keyboard while the sticky footer rides above it. A spacer as tall as the - // keyboard lets the last field scroll above the footer, and a focused „Mențiuni” is scrolled - // there once the keyboard is up (and again as it grows line by line). + // Sheet resizes the scroll area above the keyboard and reserves the sticky footer's height. + // Reveal a focused „Mențiuni” once the keyboard is up and as it grows line by line. const scrollRef = useRef(null); const mentionsFocused = useRef(false); - const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); - const keyboardSpacer = useAnimatedStyle(() => ({ height: Math.abs(keyboardHeight.value) })); const revealMentions = useCallback(() => { if (mentionsFocused.current) { scrollRef.current?.scrollToEnd({ animated: true }); @@ -269,6 +264,7 @@ export const AddHoursSheet = forwardRef keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} contentContainerStyle={styles.body} + onLayout={revealMentions} onContentSizeChange={revealMentions} > onMentionsFocus={onMentionsFocus} onMentionsBlur={onMentionsBlur} /> - {!!sent && ( { +export const HoursSummary = ({ approved, pending, rejected, replay }: HoursSummaryProps) => { const { t } = useTranslation('activity_logs'); const reduced = useReducedMotion(); - // prototype `counts()`: approved counts from 0 with the refill, pending from the number shown + // prototype `counts()`: approved counts from 0 on each focus, pending from the number shown const approvedShown = useCountTo(approved || 0, { reduced, replay }); const pendingShown = useCountTo(pending || 0, { reduced, fromZero: false }); const items: { key: LegendKey; value: number; shown: number }[] = [ @@ -52,7 +44,6 @@ export const HoursSummary = ({ {`${log.hours}h`} diff --git a/mobile/src/components/navigation/GlassTabBar.test.tsx b/mobile/src/components/navigation/GlassTabBar.test.tsx index 3e752b21..f0859771 100644 --- a/mobile/src/components/navigation/GlassTabBar.test.tsx +++ b/mobile/src/components/navigation/GlassTabBar.test.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { Text } from 'react-native'; +import { Platform, Text } from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import { BottomTabBarProps, createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -import { SafeAreaProvider } from 'react-native-safe-area-context'; -import { act, fireEvent, render, screen } from '@testing-library/react-native'; -import GlassTabBar, { GlassTabBarItem } from './GlassTabBar'; -import { nextCollapse, TabBarScrollProvider } from './TabBarScroll'; +import { SafeAreaInsetsContext, SafeAreaProvider } from 'react-native-safe-area-context'; +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react-native'; +import GlassTabBar, { GlassTabBarItem, useGlassTabBarInset } from './GlassTabBar'; +import { nextCollapse, TabBarScrollProvider, useTabBarAccessory } from './TabBarScroll'; import { resetTabBarOverlays, useHideTabBarWhile } from './tab-bar-overlay'; jest.mock('expo-blur', () => ({ BlurView: () => null })); @@ -26,6 +26,12 @@ const { Navigator, Screen } = createBottomTabNavigator(); const page = (name: string) => () => {`page:${name}`}; const renderTabBar = (props: BottomTabBarProps) => ; +const HoursScreen = () => { + const accessory = React.useMemo(() => ({ label: 'Adaugă ore', onPress: jest.fn() }), []); + useTabBarAccessory(accessory); + return page:hours; +}; + // a screen with a sheet that opens and closes, like Profil > Log out const SheetScreen = () => { const [open, setOpen] = React.useState(false); @@ -52,7 +58,9 @@ const renderTabs = () => ))} @@ -62,6 +70,30 @@ const renderTabs = () => ); describe('GlassTabBar', () => { + afterEach(() => jest.restoreAllMocks()); + + it.each(['android', 'ios'] as const)('keeps %s label fitting platform-specific', async (os) => { + jest.replaceProperty(Platform, 'OS', os); + jest.spyOn(require('react-native'), 'useWindowDimensions').mockReturnValue({ + width: 320, + height: 568, + scale: 2, + fontScale: 1.3, + }); + // Reanimated's mock leaves interpolate empty; use the expanded bar's first keyframe. + jest + .spyOn(require('react-native-reanimated'), 'interpolate') + .mockImplementation((_value, _input, output) => (output as number[])[0]); + await renderTabs(); + await fireEvent.press(screen.getByTestId('tab-hours')); + for (const label of ['Evenimente', 'Adaugă ore']) { + const text = screen.getByText(label); + expect(text.props.adjustsFontSizeToFit).toBe(os === 'ios'); + expect(text.props.allowFontScaling).toBe(os === 'ios'); + } + expect(screen.getByText('Evenimente')).toHaveStyle({ height: os === 'android' ? 13 : 15 }); + }); + it('shows the five VIC 2.0 tabs and no button for hidden routes', async () => { await renderTabs(); for (const label of ['Acasă', 'Evenimente', 'Ore', 'Documente', 'Profil']) { @@ -90,6 +122,38 @@ describe('GlassTabBar', () => { }); }); +describe('floating bar content clearance', () => { + afterEach(() => jest.restoreAllMocks()); + + it.each([ + ['ios', 0, 16], + ['ios', 34, 24], + ['android', 0, 16], + ['android', 16, 24], + ['android', 24, 24], + ['android', 48, 56], + ] as const)('%s with a %dpt bottom inset', async (os, bottom, expectedBottom) => { + jest.replaceProperty(Platform, 'OS', os); + const { result } = await renderHook( + () => ({ + bar: useGlassTabBarInset(), + accessory: useGlassTabBarInset({ accessory: true }), + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + expect(result.current.bar).toBe(expectedBottom + 68 + 16); + // The last row clears the expanded action as well as the bar, including its 4pt alignment. + expect(result.current.accessory).toBe(expectedBottom + 68 + 56 + 10 + 16); + expect(result.current.accessory).toBeGreaterThan(expectedBottom + 68 + 10 + 56 + 4); + }); +}); + describe('tab bar and bottom sheets', () => { afterEach(() => act(() => resetTabBarOverlays())); @@ -102,6 +166,9 @@ describe('tab bar and bottom sheets', () => { expect( screen.getByTestId('glass-tab-bar', { includeHiddenElements: true }).props.pointerEvents, ).toBe('none'); + expect(screen.getByTestId('glass-tab-bar', { includeHiddenElements: true })).toHaveStyle({ + transform: [{ translateY: 182 }], + }); await fireEvent.press(screen.getByTestId('toggle-sheet')); expect(screen.getByTestId('glass-tab-bar').props.pointerEvents).toBe('box-none'); }); diff --git a/mobile/src/components/navigation/GlassTabBar.tsx b/mobile/src/components/navigation/GlassTabBar.tsx index f260cdaa..ee252623 100644 --- a/mobile/src/components/navigation/GlassTabBar.tsx +++ b/mobile/src/components/navigation/GlassTabBar.tsx @@ -54,17 +54,22 @@ export interface GlassTabBarProps extends BottomTabBarProps { /** Distance of the bar from the bottom edge of the screen. */ const useBarBottom = () => { const insets = useSafeAreaInsets(); + // Keep the gesture-nav spacing; three-button navigation needs room for its full inset. + if (Platform.OS === 'android' && insets.bottom > tabBar.bottom) { + return insets.bottom + 8; + } return insets.bottom > 0 ? tabBar.bottom : 16; }; -/** Height the floating bar takes over the content, for bottom padding on tab screens. */ -export const useGlassTabBarInset = () => useBarBottom() + tabBar.height + 16; - // The minimised pill: a glass ring around the yellow lens with the active icon. const PILL_RING = 4; const PILL = tabBar.pill + PILL_RING * 2; const ACCESSORY_HEIGHT = tabBar.pill; const ACCESSORY_GAP = 10; +/** Bottom padding that clears the full bar and, optionally, the action floating above it. */ +export const useGlassTabBarInset = ({ accessory = false }: { accessory?: boolean } = {}) => + useBarBottom() + tabBar.height + 16 + (accessory ? ACCESSORY_HEIGHT + ACCESSORY_GAP : 0); + const EXPAND_HINT = 'Arată toate tab-urile'; const LABEL_LINE = 13; // how far a label may reach into the neighbouring slots („Evenimente” is 63pt, a slot at 320pt @@ -215,7 +220,9 @@ export const GlassTabBar = ({ state, navigation, items }: GlassTabBarProps) => { ); const wrapperStyle = useAnimatedStyle(() => ({ opacity: 1 - hideProgress.value, - transform: [{ translateY: hideProgress.value * (tabBar.height + bottom + 60) }], + transform: [ + { translateY: hideProgress.value * (bottom + tabBar.height + ACCESSORY_GAP + PILL + 16) }, + ], })); // the screen action: floats above the bar on the right, sits beside the pill when minimised @@ -227,7 +234,7 @@ export const GlassTabBar = ({ state, navigation, items }: GlassTabBarProps) => { opacity: accessoryWidth ? 1 : 0, transform: [ { translateX: interpolate(p, [0, 1], [right, left]) }, - { translateY: interpolate(p, [0, 1], [-(tabBar.height + 10), 0]) }, + { translateY: interpolate(p, [0, 1], [-(tabBar.height + ACCESSORY_GAP), 0]) }, ], }; }); @@ -267,7 +274,8 @@ export const GlassTabBar = ({ state, navigation, items }: GlassTabBarProps) => { @@ -377,9 +385,11 @@ const TabButton = ({ const Icon = ICONS[item.icon]; const color = focused ? palette.navy : palette.ink2; const { fontScale } = useWindowDimensions(); - // the label row is as tall as one line at the capped scale, so it never clips + // Android auto-fit can shrink scaled labels below their base size; keep its labels at 1×. + // The label row is as tall as one line at the capped scale, so it never clips. const labelHeight = Math.ceil( - LABEL_LINE * Math.min(Math.max(fontScale, 1), fontScaleCap.tabLabel), + LABEL_LINE * + (Platform.OS === 'android' ? 1 : Math.min(Math.max(fontScale, 1), fontScaleCap.tabLabel)), ); // prototype `.tab.on svg{transform:translateY(-1px) scale(1.06)}` and `.tab:active svg{scale(.86)}`: @@ -453,11 +463,12 @@ const TabButton = ({ - {/* never ellipsized: the label may use 5pt of each neighbour's slot and shrinks to fit */} + {/* the label may use 5pt of each neighbour's slot; on iOS it also shrinks to fit */} ( diff --git a/mobile/src/components/ui/ListRow.tsx b/mobile/src/components/ui/ListRow.tsx index bdb3ea5f..dcb5337f 100644 --- a/mobile/src/components/ui/ListRow.tsx +++ b/mobile/src/components/ui/ListRow.tsx @@ -7,6 +7,7 @@ import { useGroupInset } from './Group'; export interface ListRowProps { title: string; subtitle?: string; + preview?: string; leading?: ReactNode; trailing?: ReactNode; onPress?: () => void; @@ -19,7 +20,7 @@ export interface ListRowProps { testID?: string; } -export const Chevron = ({ color }: { color?: string }) => ; +export const Chevron = RowChevron; /** * Settings-style row, legacy API over the „Îndrăzneț” ListRow (60pt, Manrope 700 15.5 title, @@ -28,6 +29,7 @@ export const Chevron = ({ color }: { color?: string }) => ({ justifyContent: 'center', paddingHorizontal: 60, }, - // Android has no live blur outside a BlurTargetView: a denser paper keeps the title readable + // Android has no live blur outside a BlurTargetView: opaque paper keeps the title readable compactTint: { - backgroundColor: Platform.OS === 'ios' ? theme.colors.topBarTint : 'rgba(244,241,233,0.96)', + backgroundColor: + Platform.OS === 'ios' ? theme.colors.topBarTint : theme.colors.androidTopBarTint, }, compactTitle: { fontFamily: theme.fontFamily.display, diff --git a/mobile/src/components/ui/Sheet.tsx b/mobile/src/components/ui/Sheet.tsx index 24737c21..2f6590b8 100644 --- a/mobile/src/components/ui/Sheet.tsx +++ b/mobile/src/components/ui/Sheet.tsx @@ -14,6 +14,8 @@ export interface SheetProps extends Omit { fullHeight?: boolean; /** accessibility label of the close button shown by `fullHeight` sheets */ closeLabel?: string; + /** Scroll the complete auto-sized content, including its header and bottom safe area. */ + scrollable?: boolean; } /** diff --git a/mobile/src/design/components/Chip.tsx b/mobile/src/design/components/Chip.tsx index 68a3bbd2..a1c986a2 100644 --- a/mobile/src/design/components/Chip.tsx +++ b/mobile/src/design/components/Chip.tsx @@ -97,7 +97,7 @@ export const StatusChip = ({ label, kind = 'plain', icon, style, testID }: Statu ]} > {mark} - + {label} @@ -155,12 +155,7 @@ export const OptionChip = ({ > {icon} - + {label} @@ -173,6 +168,8 @@ export default StatusChip; const styles = StyleSheet.create((theme) => ({ stripes: { borderRadius: 4, overflow: 'hidden' }, status: { + maxWidth: '100%', + paddingVertical: 4, minHeight: 28, borderRadius: theme.radius.chip, paddingLeft: theme.space.xs, @@ -194,6 +191,7 @@ const styles = StyleSheet.create((theme) => ({ optionDisabled: { opacity: 0.45 }, option: { minHeight: OPTION_HEIGHT, + paddingVertical: 6, paddingHorizontal: theme.space.m, borderRadius: 12, backgroundColor: theme.colors.surface, diff --git a/mobile/src/design/components/ListRow.test.tsx b/mobile/src/design/components/ListRow.test.tsx new file mode 100644 index 00000000..2b115899 --- /dev/null +++ b/mobile/src/design/components/ListRow.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Text, useWindowDimensions } from 'react-native'; +import { render, screen } from '@testing-library/react-native'; +import { ListRow, RowChevron } from './ListRow'; +import { Chevron, ListRow as LegacyListRow } from '../../components/ui/ListRow'; + +jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ + __esModule: true, + default: jest.fn(), +})); + +it.each([1, 1.2, 3.2])( + 'keeps default and explicit chevrons beside the text at font scale %s', + async (fontScale) => { + jest.mocked(useWindowDimensions).mockReturnValue({ + width: 390, + height: 844, + scale: 3, + fontScale, + }); + await render( + <> + + + } /> + } /> + , + ); + // Only the title/subtitle belong to the text column; chevrons remain its siblings. + expect(screen.getByText('Anunț').parent?.children).toHaveLength(2); + expect(screen.getByText('Acasă').parent?.children).toHaveLength(1); + expect(screen.getByText('Explicit').parent?.children).toHaveLength(1); + expect(screen.getByText('Legacy').parent?.children).toHaveLength(1); + }, +); + +it.each([1, 1.2, 3.2])('stacks a status pill only at large font scales (%s)', async (fontScale) => { + jest.mocked(useWindowDimensions).mockReturnValue({ + width: 390, + height: 844, + scale: 3, + fontScale, + }); + await render(Activa} />); + const textColumn = screen.getByText('Organizație').parent; + if (fontScale >= 1.2) { + expect(screen.getByText('Activa').parent).toHaveStyle({ + alignSelf: 'flex-start', + marginTop: 6, + }); + expect(textColumn?.children).toHaveLength(2); + } else { + expect(screen.getByText('Activa').parent).toHaveStyle({ flexDirection: 'row' }); + expect(textColumn?.children).toHaveLength(1); + } +}); diff --git a/mobile/src/design/components/ListRow.tsx b/mobile/src/design/components/ListRow.tsx index e5b002da..258ec729 100644 --- a/mobile/src/design/components/ListRow.tsx +++ b/mobile/src/design/components/ListRow.tsx @@ -2,7 +2,7 @@ // leading tile (38pt, radius 13, paper) or a hexagon avatar, a Manrope 700 15.5 title, an optional // subtitle and a trailing chevron / chip / custom node. Inside a Group a hairline separates rows. import React, { ReactNode } from 'react'; -import { Pressable, StyleProp, Text, View, ViewStyle } from 'react-native'; +import { Pressable, StyleProp, Text, useWindowDimensions, View, ViewStyle } from 'react-native'; import Svg, { Path } from 'react-native-svg'; import { StyleSheet } from 'react-native-unistyles'; import { fontScaleCap, palette } from '../tokens'; @@ -71,6 +71,8 @@ export const HexAvatar = ({ name, size = 'md', tone = 'navy', testID }: HexAvata export interface ListRowProps { title: string; subtitle?: string; + /** Free-text preview; only this part may be clamped. */ + preview?: string; /** a lucide icon drawn in the 38pt paper tile */ icon?: LucideIcon; /** a hexagon avatar with the initials of this name */ @@ -103,6 +105,7 @@ export const RowChevron = ({ color = palette.navy }: { color?: string }) => ( export const ListRow = ({ title, subtitle, + preview, icon: Icon, avatar, leading, @@ -119,6 +122,11 @@ export const ListRow = ({ style, testID, }: ListRowProps) => { + const { fontScale } = useWindowDimensions(); + const stackTail = + fontScale >= 1.2 && + trailing != null && + !(React.isValidElement(trailing) && trailing.type === RowChevron); const lead = leading ?? (Icon ? ( @@ -157,8 +165,14 @@ export const ListRow = ({ {subtitle} )} + {!!preview && ( + + {preview} + + )} + {stackTail && tail ? {tail} : null} - {tail} + {!stackTail && tail} ); @@ -215,6 +229,7 @@ const styles = StyleSheet.create((theme) => ({ }, tileDanger: { backgroundColor: 'rgba(200,50,31,0.1)' }, text: { flex: 1, minWidth: 0, gap: 2 }, + stackedTail: { alignSelf: 'flex-start', marginTop: 6 }, title: { fontFamily: theme.fontFamily.textBold, fontSize: 15.5, diff --git a/mobile/src/design/components/SegmentedControl.tsx b/mobile/src/design/components/SegmentedControl.tsx index bbcc48e4..c6bc7412 100644 --- a/mobile/src/design/components/SegmentedControl.tsx +++ b/mobile/src/design/components/SegmentedControl.tsx @@ -1,7 +1,8 @@ -// Segmented control „Îndrăzneț”: a navy track with a yellow thumb that slides and resizes to the -// selected segment (spring snappy). Segments size to their labels (flex 1 1 auto). +// Segmented control „Îndrăzneț”: a fixed navy track with equal-width segments and a yellow thumb +// measured from their layout (spring snappy). Single-line labels shrink to fit beside count badges; +// iOS Large Content Viewer exposes the full label at larger accessibility sizes. import React, { ReactNode, useCallback, useState } from 'react'; -import { LayoutChangeEvent, Pressable, Text, View } from 'react-native'; +import { LayoutChangeEvent, Platform, Pressable, Text, View } from 'react-native'; import Animated, { useAnimatedStyle, useReducedMotion } from 'react-native-reanimated'; import { StyleSheet } from 'react-native-unistyles'; import { fontScaleCap, palette } from '../tokens'; @@ -98,6 +99,8 @@ export function SegmentedControl({ option.accessibilityLabel ?? (hasCount ? `${option.label}, ${option.count}` : option.label) } + accessibilityShowsLargeContentViewer={Platform.OS === 'ios'} + accessibilityLargeContentTitle={Platform.OS === 'ios' ? option.label : undefined} onLayout={onLayoutFor(option.key)} // 42pt segment + the track padding: a 52pt hit target hitSlop={{ top: TRACK_PADDING, bottom: TRACK_PADDING }} @@ -107,14 +110,15 @@ export function SegmentedControl({ } onChange(option.key); }} - // Dynamic Type: a label of several words wraps to a second line instead of being cut; - // one-word labels keep their width, so they never break mid-word - style={[styles.segment, option.label.includes(' ') ? null : styles.segmentOneWord]} + style={styles.segment} > {option.icon?.(selected)} {option.label} @@ -122,7 +126,7 @@ export function SegmentedControl({ {hasCount && ( {option.count} @@ -154,9 +158,10 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.yellow, }, segment: { + flex: 1, flexGrow: 1, flexShrink: 1, - flexBasis: 'auto', + flexBasis: 0, minHeight: 42, minWidth: 0, borderRadius: 17, @@ -167,7 +172,6 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: 'center', gap: 6, }, - segmentOneWord: { flexShrink: 0 }, label: { flexShrink: 1, fontFamily: theme.fontFamily.textHeavy, @@ -176,8 +180,10 @@ const styles = StyleSheet.create((theme) => ({ textAlign: 'center', }, count: { + flexShrink: 0, minWidth: 20, - height: 20, + minHeight: 20, + paddingVertical: 3, borderRadius: 10, paddingHorizontal: 5, alignItems: 'center', diff --git a/mobile/src/design/components/Select.tsx b/mobile/src/design/components/Select.tsx index 7d3b629a..183ba7a8 100644 --- a/mobile/src/design/components/Select.tsx +++ b/mobile/src/design/components/Select.tsx @@ -3,7 +3,7 @@ // the snappy spring). With more options it calls `onOpenSheet`; the sheet itself lives with the // caller, which can mount it next to the field through `renderSheet`. import React, { ReactNode, useEffect, useState } from 'react'; -import { LayoutChangeEvent, Pressable, StyleProp, View, ViewStyle } from 'react-native'; +import { Keyboard, LayoutChangeEvent, Pressable, StyleProp, View, ViewStyle } from 'react-native'; import Animated, { useAnimatedStyle, useReducedMotion, @@ -114,6 +114,8 @@ export function Select({ })); const toggle = () => { + // A picker takes over from the form input; don't leave that input focused behind its sheet. + Keyboard.dismiss(); if (sheetMode) { onOpenSheet?.(); return; diff --git a/mobile/src/design/components/Sheet.test.tsx b/mobile/src/design/components/Sheet.test.tsx new file mode 100644 index 00000000..e84e6c51 --- /dev/null +++ b/mobile/src/design/components/Sheet.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { StyleSheet, Text } from 'react-native'; +import { render, screen, within } from '@testing-library/react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { BottomSheetScrollView, BottomSheetView } from '@gorhom/bottom-sheet'; +import { Sheet } from './Sheet'; + +jest.mock('@gorhom/bottom-sheet', () => { + const mock = require('@gorhom/bottom-sheet/mock'); + const React = require('react'); + const { View } = require('react-native'); + return { + ...mock, + BottomSheetView: View, + BottomSheetModal: class extends mock.BottomSheetModal { + render() { + return React.createElement(View, { ...this.props, testID: 'mock-sheet-modal' }); + } + }, + }; +}); + +describe('auto Sheet content sizing', () => { + it.each([0, 34, 48])( + 'keeps plain content and bottom inset %s in BottomSheetView', + async (bottom) => { + await render( + + Confirm action}> + Confirm body + + , + ); + expect(screen.getByTestId('mock-sheet-modal').props.children.type).toBe(BottomSheetView); + expect(screen.getByTestId('plain-sheet')).toHaveStyle({ + paddingBottom: Math.max(bottom, 14) + 10, + }); + expect(screen.container.queryAll((node) => node.type.includes('ScrollView'))).toHaveLength(0); + }, + ); + + it('includes the header, body and inline footer in the single scrollable measurement', async () => { + await render( + + Footer} + > + Last row + + , + ); + const modal = screen.getByTestId('mock-sheet-modal'); + expect(modal.props.children.type).toBe(BottomSheetScrollView); + expect(modal.props.footerComponent).toBeUndefined(); + expect(modal.props.children.props.keyboardShouldPersistTaps).toBe('handled'); + expect(StyleSheet.flatten(modal.props.children.props.contentContainerStyle).paddingBottom).toBe( + 58, + ); + const content = within(screen.getByTestId('scroll-sheet')); + for (const text of ['Picker', 'Choose one', 'Last row', 'Footer']) { + expect(content.getByText(text)).toBeTruthy(); + } + }); +}); diff --git a/mobile/src/design/components/Sheet.tsx b/mobile/src/design/components/Sheet.tsx index 1bae9366..e57ee029 100644 --- a/mobile/src/design/components/Sheet.tsx +++ b/mobile/src/design/components/Sheet.tsx @@ -70,6 +70,8 @@ export interface SheetProps extends Omit { closeLabel?: string; /** form / filters: wrap the children in a BottomSheetScrollView (default true) */ scroll?: boolean; + /** auto: measure and scroll the header, body and footer as a single dynamic-sized scrollable. */ + scrollable?: boolean; /** style of the body around the children */ bodyStyle?: StyleProp; testID?: string; @@ -87,6 +89,7 @@ export const Sheet = forwardRef( showClose = true, closeLabel = 'Închide', scroll = true, + scrollable = false, bodyStyle, testID, onChange, @@ -210,18 +213,35 @@ export const Sheet = forwardRef( let content: ReactNode; if (variant === 'auto') { - content = ( - + const autoContent = ( + <> {header} {children} {!!footer && {footer}} + + ); + content = scrollable ? ( + + {autoContent} + + ) : ( + + {autoContent} ); } else if (variant === 'full' || !scroll) { content = ( {header} {children} @@ -256,7 +276,9 @@ export const Sheet = forwardRef( handleIndicatorStyle={styles.grabber} keyboardBehavior="interactive" keyboardBlurBehavior="restore" - android_keyboardInputMode="adjustResize" + // KeyboardProvider keeps the Android window full height. Gorhom's adjustResize path + // assumes the OS resized it and skips both the interactive lift and footer offset. + android_keyboardInputMode="adjustPan" onChange={(index, position, type) => { const nowOpen = index >= 0; if (nowOpen && !openRef.current) { diff --git a/mobile/src/design/components/groupB.test.tsx b/mobile/src/design/components/groupB.test.tsx index c99a7cc8..f7b7ea6e 100644 --- a/mobile/src/design/components/groupB.test.tsx +++ b/mobile/src/design/components/groupB.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { AccessibilityInfo, Text } from 'react-native'; +import { AccessibilityInfo, Platform, StyleSheet, Text } from 'react-native'; import { fireEvent, render, screen } from '@testing-library/react-native'; import * as Haptics from 'expo-haptics'; import { @@ -11,7 +11,7 @@ import { SegmentedControl, toastConfig, } from './groupB'; -import { palette } from '../tokens'; +import { fontScaleCap, palette } from '../tokens'; const options = [ { key: 'pending', label: 'În așteptare', count: 3 }, @@ -47,6 +47,83 @@ describe('SegmentedControl', () => { expect(screen.getByTestId('seg-pending').props.accessibilityState).toEqual({ selected: false }); }); + it('fits single-line labels into equal segments and reserves space for badges', async () => { + await render( + , + ); + expect(screen.container.queryAll((node) => node.type.includes('ScrollView'))).toHaveLength(0); + expect(fontScaleCap.segment).toBe(1.15); + for (const label of ['În așteptare', 'Aprobate', 'Organizațiile mele']) { + expect(screen.getByText(label).props).toMatchObject({ + numberOfLines: 1, + adjustsFontSizeToFit: true, + minimumFontScale: 0.7, + maxFontSizeMultiplier: fontScaleCap.segment, + ellipsizeMode: 'clip', + }); + expect(screen.getByText(label)).toHaveStyle({ flexShrink: 1 }); + } + for (const key of ['pending', 'approved', 'mine']) { + expect(StyleSheet.flatten(screen.getByTestId(`seg-${key}`).props.style)).toMatchObject({ + flex: 1, + flexGrow: 1, + flexShrink: 1, + flexBasis: 0, + minWidth: 0, + }); + } + for (const count of ['18', '0']) { + expect(screen.getByText(count).parent).toHaveStyle({ + flexShrink: 0, + minWidth: 20, + minHeight: 20, + }); + expect(screen.getByText(count).props.maxFontSizeMultiplier).toBe(fontScaleCap.segment); + } + expect(screen.getByTestId('seg-mine').props).toMatchObject({ + accessibilityShowsLargeContentViewer: Platform.OS === 'ios', + accessibilityLargeContentTitle: Platform.OS === 'ios' ? 'Organizațiile mele' : undefined, + }); + }); + + it('keeps segment styles identical when selection and the active-only badge move', async () => { + const keys = ['pending', 'approved', 'rejected'] as const; + const renderControl = (value: (typeof keys)[number]) => ( + ({ + key: option.key, + label: option.label, + count: option.key === value ? 18 : undefined, + }))} + /> + ); + await render(renderControl('pending')); + const stylesBefore = keys.map((key) => + StyleSheet.flatten(screen.getByTestId(`seg-${key}`).props.style), + ); + expect(stylesBefore[1]).toEqual(stylesBefore[0]); + expect(stylesBefore[2]).toEqual(stylesBefore[0]); + + await screen.rerender(renderControl('approved')); + keys.forEach((key, index) => { + expect(StyleSheet.flatten(screen.getByTestId(`seg-${key}`).props.style)).toEqual( + stylesBefore[index], + ); + }); + }); + it('shows the thumb once segments are measured', async () => { await render( { expect(screen.getByTestId('grp')).toHaveStyle({ borderRadius: 24, overflow: 'hidden' }); }); + it('shows the full organization separately from the clamped free-text preview', async () => { + const organization = 'Asociația Exemplu pentru Comunitate'; + await render( + , + ); + expect(screen.getByText('Anunț important').props.numberOfLines).toBeUndefined(); + expect(screen.getByText(organization).props.numberOfLines).toBeUndefined(); + expect(screen.getByText('Text lung de previzualizare').props.numberOfLines).toBe(2); + }); + it('hexagon avatar shows the initials', async () => { expect(hexInitials('Asociația Exemplu Verde')).toBe('AE'); await render(); diff --git a/mobile/src/design/tokens.ts b/mobile/src/design/tokens.ts index 1537d727..e1a2a0ec 100644 --- a/mobile/src/design/tokens.ts +++ b/mobile/src/design/tokens.ts @@ -42,6 +42,7 @@ export const lightColors = { glassTintStrong: 'rgba(255,255,255,0.62)', glassStroke: 'rgba(255,255,255,0.75)', topBarTint: 'rgba(244,241,233,0.72)', + androidTopBarTint: palette.paper, } as const; export type AppColors = typeof lightColors; @@ -154,10 +155,12 @@ export type TypeVariant = keyof typeof type; /** * Dynamic Type: text grows to 1.35×, display and large titles stop at 1.15×. Tab bar labels stop at - * 1.1× (they also shrink to fit their slot and never ellipsize; the iOS Large Content Viewer shows - * them big on long press, like the system tab bar). + * 1.1× on iOS (they also shrink to fit their slot; the Large Content Viewer shows them big on long + * press, like the system tab bar). Android navigation labels stay at 1× to avoid auto-fit shrinking. + * Segmented control labels stop at 1.15× and shrink to fit their equal-width segment, never + * ellipsized or broken mid-word. */ -export const fontScaleCap = { text: 1.35, display: 1.15, tabLabel: 1.1 } as const; +export const fontScaleCap = { text: 1.35, display: 1.15, tabLabel: 1.1, segment: 1.15 } as const; export const glass = { tint: lightColors.glassTint, diff --git a/mobile/src/layouts/FormLayout.tsx b/mobile/src/layouts/FormLayout.tsx index 8c849783..5cf6f486 100644 --- a/mobile/src/layouts/FormLayout.tsx +++ b/mobile/src/layouts/FormLayout.tsx @@ -65,6 +65,7 @@ const FormLayout = ({ children, scrollRef }: FormLayoutProps) => { alwaysBounceHorizontal={false} alwaysBounceVertical={false} showsVerticalScrollIndicator={false} + keyboardShouldPersistTaps="handled" contentContainerStyle={styles.layout} > {children} diff --git a/mobile/src/screens/AccountData.test.tsx b/mobile/src/screens/AccountData.test.tsx index 19df3f84..e627179f 100644 --- a/mobile/src/screens/AccountData.test.tsx +++ b/mobile/src/screens/AccountData.test.tsx @@ -111,3 +111,31 @@ describe('Date personale discard guard', () => { }, ); }); + +it('normalizes a local phone number before validating and sending the existing API format', async () => { + const { client } = await setup(); + await fireEvent.changeText(screen.getByTestId('field-phone'), '0799111333'); + expect(screen.getByTestId('field-phone').props.value).toBe('799111333'); + await fireEvent.press(screen.getByTestId('page-action')); + await waitFor(() => + expect(updateUserProfile).toHaveBeenCalledWith( + expect.objectContaining({ phone: '+40799111333' }), + undefined, + ), + ); + client.clear(); +}); + +it('keeps an untouched missing birthday error hidden while editing other fields', async () => { + const { client } = await setup(); + await act(() => useStore.setState({ userProfile: { ...profile, birthday: undefined } as any })); + await fireEvent.changeText(screen.getByTestId('field-phone'), '0799111333'); + await fireEvent(screen.getByTestId('field-phone'), 'blur'); + await fireEvent.press(screen.getByTestId('select-countyId')); + await fireEvent.press(screen.getByTestId('select-countyId-option-1')); + expect(screen.queryByRole('alert')).toBeNull(); + await fireEvent.press(screen.getByTestId('page-action')); + expect(await screen.findByRole('alert')).toBeTruthy(); + expect(updateUserProfile).not.toHaveBeenCalled(); + client.clear(); +}); diff --git a/mobile/src/screens/AccountData.tsx b/mobile/src/screens/AccountData.tsx index 88b4c3ad..7b7f25aa 100644 --- a/mobile/src/screens/AccountData.tsx +++ b/mobile/src/screens/AccountData.tsx @@ -24,6 +24,7 @@ import { Avatar, VButton } from '../components/ui'; import { spacing } from '../common/theme/tokens'; import { CONSTANTS, REGEX } from '../common/constants/constants'; import { renderPhoneNumberPrefix } from '../components/InputPrefixes'; +import { normalizePhoneNumber } from '../common/utils/phone'; import { useUserProfile } from '../store/profile/profile.selector'; import { usePaddingTop } from '../hooks/usePaddingTop'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; @@ -252,6 +253,8 @@ const AccountData = ({ navigation }: any) => { error={errors.phone} disabled={isUpdatingProfile} keyboardType="phone-pad" + maxLength={10} + normalizeText={normalizePhoneNumber} accessoryLeft={renderPhoneNumberPrefix} /> { const { t } = useTranslation('activity_logs'); const insets = useSafeAreaInsets(); - const bottomInset = useGlassTabBarInset(); const reduced = useReducedMotion(); useFocusedStatusBarStyle('dark'); const scrollY = useSharedValue(0); @@ -74,11 +73,11 @@ const ActivityLogs = ({ navigation, route }: any) => { const [status, setStatus] = useState(ActivityLogStatus.PENDING); const [pendingCount, setPendingCount] = useState(undefined); - // the entry just sent (highlighted once) and a counter that refills the hexagon + // the entry just sent (highlighted once) const [highlightId, setHighlightId] = useState(null); - const [refillKey, setRefillKey] = useState(0); // each focus counts the approved hours again from 0, with the hexagon refill const [focusKey, setFocusKey] = useState(0); + const [refreshing, setRefreshing] = useState(false); const sentAt = useRef(0); const toastTimer = useRef | null>(null); // the first list enters as one block (stagger step 4); after a segment change rows stagger from 0 @@ -106,7 +105,7 @@ const ActivityLogs = ({ navigation, route }: any) => { fetchNextPage, hasNextPage, refetch: reloadActivityLogs, - isRefetching, + isLoading, } = useActivityLogsInfiniteQuery(organization?.id, ORDER, '', status); // adding, editing or deleting an entry invalidates the list and these totals @@ -117,6 +116,15 @@ const ActivityLogs = ({ navigation, route }: any) => { reloadCounters(); }, [reloadActivityLogs, reloadCounters]); + const onRefresh = async () => { + setRefreshing(true); + try { + await Promise.all([reloadActivityLogs(), reloadCounters()]); + } finally { + setRefreshing(false); + } + }; + useFocusEffect( useCallback(() => { // Mark every segment stale; reload only the current list and totals. @@ -170,13 +178,12 @@ const ActivityLogs = ({ navigation, route }: any) => { addSheet.current?.present(); }, [hasProfile]); - // after „Trimis spre aprobare”: back to the pending entries, the new one glows, the hexagon refills + // after „Trimis spre aprobare”: back to the pending entries, the new one glows const onSent = useCallback( (log: IActivityLog) => { sentAt.current = Date.now(); setStatus(ActivityLogStatus.PENDING); setHighlightId(log?.id ?? null); - setRefillKey((key) => key + 1); reload(); if (toastTimer.current) { clearTimeout(toastTimer.current); @@ -202,6 +209,7 @@ const ActivityLogs = ({ navigation, route }: any) => { [asTab, canAdd, onAdd, t], ); useTabBarAccessory(accessory); + const bottomInset = useGlassTabBarInset({ accessory: !!accessory }); const onOpen = (activityLogId: string) => navigation.navigate('activity-log', { activityLogId }); @@ -274,8 +282,7 @@ const ActivityLogs = ({ navigation, route }: any) => { approved={toHours(approvedHours)} pending={toHours(pendingHours)} rejected={toHours(rejectedHours)} - refillKey={refillKey} - replay={`${focusKey}-${refillKey}`} + replay={focusKey} /> )} @@ -315,7 +322,7 @@ const ActivityLogs = ({ navigation, route }: any) => { } // on the tab the add action already sits in the tab bar; the empty state repeats it only off-tab const emptyAdd = status === ActivityLogStatus.PENDING && canAdd && !asTab; - if (isFetching && !isRefetching && !items.length) { + if (isLoading && !data) { return ; } if (error) { @@ -369,8 +376,8 @@ const ActivityLogs = ({ navigation, route }: any) => { onEndReachedThreshold={0.4} refreshControl={ } diff --git a/mobile/src/screens/CreateUser.test.tsx b/mobile/src/screens/CreateUser.test.tsx new file mode 100644 index 00000000..713fbc9c --- /dev/null +++ b/mobile/src/screens/CreateUser.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { Keyboard } from 'react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import i18n from '../common/config/i18n'; +import CreateUser from './CreateUser'; + +jest.mock('@gorhom/bottom-sheet', () => require('@gorhom/bottom-sheet/mock')); +jest.mock('../services/user/user.service', () => ({ + useCreateUserProfileMutation: () => ({ mutate: jest.fn(), isLoading: false }), +})); +jest.mock('../services/location/location.api', () => ({ + getCounties: jest.fn(async () => [{ id: 1, name: 'Alba' }]), + getCities: jest.fn(async () => [{ id: 1, name: 'Alba Iulia' }]), +})); + +it('keeps untouched birthday and consent errors hidden until Continue, then revalidates changes', async () => { + await render( + + + + + , + ); + const birthdayError = i18n.t('register:create_user.form.birthday.required'); + const termsError = i18n.t('register:create_account.form.terms.required'); + const expectUntouched = () => { + expect(screen.queryByText(birthdayError)).toBeNull(); + expect(screen.queryByText(termsError)).toBeNull(); + }; + expectUntouched(); + // Android can hide the IME while the input is still focused. + await fireEvent(screen.getByTestId('field-phone'), 'focus'); + await fireEvent.changeText(screen.getByTestId('field-phone'), '0799111333'); + Keyboard.dismiss(); + await fireEvent(screen.getByTestId('field-phone'), 'blur'); + const scroll = screen.container.queryAll((node) => node.type.includes('ScrollView')); + expect(scroll.some((node) => node.props.keyboardShouldPersistTaps === 'handled')).toBe(true); + await fireEvent.press(screen.getByTestId('select-countyId')); + expect(screen.getByTestId('select-countyId').props.accessibilityState.expanded).toBe(true); + await fireEvent.press(screen.getByTestId('select-countyId-option-1')); + expectUntouched(); + await fireEvent.press(screen.getByTestId('select-cityId')); + await fireEvent.press(screen.getByTestId('select-cityId-option-1')); + await fireEvent.press(screen.getByTestId('select-sex')); + expectUntouched(); + + await fireEvent.press(screen.getByTestId('page-action')); + expect(await screen.findByText(birthdayError)).toBeTruthy(); + expect(await screen.findByText(termsError)).toBeTruthy(); + await fireEvent.press(screen.getByTestId('create-user-terms')); + expect(screen.queryByText(termsError)).toBeNull(); + expect(screen.getByText(birthdayError)).toBeTruthy(); +}); diff --git a/mobile/src/screens/CreateUser.tsx b/mobile/src/screens/CreateUser.tsx index 66fb9718..081fe440 100644 --- a/mobile/src/screens/CreateUser.tsx +++ b/mobile/src/screens/CreateUser.tsx @@ -33,6 +33,7 @@ import { Toast } from 'react-native-toast-message/lib/src/Toast'; import { InternalErrors } from '../common/errors/internal-errors.class'; import { CONSTANTS, REGEX } from '../common/constants/constants'; import { renderPhoneNumberPrefix } from '../components/InputPrefixes'; +import { normalizePhoneNumber } from '../common/utils/phone'; import { usePaddingTop } from '../hooks/usePaddingTop'; import { displayTitle } from '../layouts/displayTitle'; @@ -185,6 +186,8 @@ const CreateUser = ({ navigation }: any) => { error={errors.phone} disabled={isLoading} keyboardType="phone-pad" + maxLength={10} + normalizeText={normalizePhoneNumber} accessoryLeft={renderPhoneNumberPrefix} /> { useFocusedStatusBarStyle('dark'); const scrollY = useSharedValue(0); const onScroll = useTabBarScroll(scrollY); + const titleStyle = useLargeTitleCollapse(scrollY); const { userProfile } = useUserProfile(); const organization = userProfile?.activeOrganization; const volunteerId: string | undefined = route.params?.volunteerId || organization?.volunteerId; @@ -116,14 +118,16 @@ const Documents = ({ navigation, route }: any) => { )} {/* Two lines like the handbook („Contractele
tale”): break before the last word. */} - - {`${t('title_large')}`.replace(/ (?=\S+$)/, '\n')} - + + + {`${t('title_large')}`.replace(/ (?=\S+$)/, '\n')} + + {!!organization && ( {t('activity_logs:at', { name: organization.name })} @@ -185,6 +189,7 @@ const Documents = ({ navigation, route }: any) => { showsVerticalScrollIndicator={false} testID="documents-list" /> + ({ back: { marginBottom: theme.space.m }, // handbook .lt: Bricolage 44, line height .96 (42), 14pt above; no font padding on Android title: { lineHeight: 42, marginTop: 14, includeFontPadding: false }, + titleWrap: { transformOrigin: 'left bottom' }, featured: { marginBottom: theme.space.s }, item: { marginBottom: theme.space.s }, more: { paddingVertical: 20, alignItems: 'center', justifyContent: 'center' }, diff --git a/mobile/src/screens/Events.tsx b/mobile/src/screens/Events.tsx index 2493638b..e431524d 100644 --- a/mobile/src/screens/Events.tsx +++ b/mobile/src/screens/Events.tsx @@ -91,6 +91,7 @@ const Events = ({ navigation, route }: any) => { const [eventFilter, setEventFilter] = useState(EventType.OPEN); const [filters, setFilters] = useState(EMPTY_EVENT_FILTERS); const [searchOpen, setSearchOpen] = useState(false); + const [refreshing, setRefreshing] = useState(false); const filtersRef = useRef(null); const listOut = useSharedValue(0); @@ -163,13 +164,24 @@ const Events = ({ navigation, route }: any) => { fetchNextPage, hasNextPage, refetch, - isRefetching, - } = useEventsInfiniteQuery(OrderDirection.ASC, filters.search, eventFilter, queryFilters); + isLoading, + isPreviousData, + } = useEventsInfiniteQuery(OrderDirection.ASC, filters.search, eventFilter, queryFilters, { + keepPreviousData: true, + }); // an RSVP from the event page changes what the tabs show const reload = useCallback(() => { refetch(); }, [refetch]); + const onRefresh = async () => { + setRefreshing(true); + try { + await refetch(); + } finally { + setRefreshing(false); + } + }; useEffect(() => { reload(); }, [event, reload]); @@ -178,7 +190,9 @@ const Events = ({ navigation, route }: any) => { () => (events?.pages ?? []).flatMap((page) => page.items) as EventCardItem[], [events], ); - const total: number | undefined = events?.pages?.[0]?.meta?.totalItems; + const total: number | undefined = isPreviousData + ? undefined + : events?.pages?.[0]?.meta?.totalItems; const activeFilters = countActiveFilters(filters); const organizations = useMyOrganizations(); const summary = useMemo(() => { @@ -202,7 +216,7 @@ const Events = ({ navigation, route }: any) => { } // on the first screen the cards follow the header's steps; after a swap they start at once const itemOffset = initialListKey.current ? HEADER_STEPS : 0; - const isInitialLoading = isFetching && !isFetchingNextPage && !isRefetching && !items.length; + const isInitialLoading = isLoading && !events; // the count of the list on screen (the API gives no count for the other segments) const countFor = (key: EventType) => key === eventFilter && key === selectedFilter && !isInitialLoading ? total : undefined; @@ -346,13 +360,13 @@ const Events = ({ navigation, route }: any) => { ListHeaderComponent={header} onScroll={onScroll} scrollEventThrottle={16} - onEndReached={() => hasNextPage && !isFetching && fetchNextPage()} + onEndReached={() => hasNextPage && !isPreviousData && !isFetching && fetchNextPage()} onEndReachedThreshold={0.4} ListEmptyComponent={renderEmpty} ListFooterComponent={ isFetchingNextPage ? : null } - refreshControl={} + refreshControl={} contentContainerStyle={[styles.list, { paddingBottom: bottomInset }]} showsVerticalScrollIndicator={false} testID="events-list" diff --git a/mobile/src/screens/Home.tsx b/mobile/src/screens/Home.tsx index f43a1f85..d4bade87 100644 --- a/mobile/src/screens/Home.tsx +++ b/mobile/src/screens/Home.tsx @@ -358,11 +358,8 @@ const Home = ({ navigation }: any) => { /> } title={item.title} - subtitle={[item.organization?.name, item.description] - .filter(Boolean) - .join(' · ')} - titleLines={2} - subtitleLines={2} + subtitle={item.organization?.name} + preview={item.description} onPress={() => navigation.navigate('announcements')} divider={index < all.length - 1} testID={`home-anouncement-${item.id}`} diff --git a/mobile/src/screens/Landing.tsx b/mobile/src/screens/Landing.tsx index 6e2aa113..d3f629be 100644 --- a/mobile/src/screens/Landing.tsx +++ b/mobile/src/screens/Landing.tsx @@ -22,6 +22,8 @@ import { palette } from '../design/tokens'; import { hasSeenOnboarding } from '../common/utils/onboarding'; import { isMigrationNoticeActive } from '../common/utils/migration-notice'; import { displayTitle } from '../layouts/displayTitle'; +import { getAuthErrorCode } from '../common/utils/auth-error'; +import { InternalErrors } from '../common/errors/internal-errors.class'; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -78,8 +80,9 @@ const Landing = ({ navigation }: any) => { const maskedEmail = await requestLoginCode(value); sheetRef.current?.dismiss(); navigation.navigate('login', { email: value, maskedEmail }); - } catch { - // the auth context shows the error + } catch (error) { + const code = getAuthErrorCode(error); + setEmailError(code ? InternalErrors.AUTH_ERRORS.getError(code) : `${t('auth:errors.login')}`); } finally { setIsSending(false); } diff --git a/mobile/src/screens/Login.tsx b/mobile/src/screens/Login.tsx index 2f4e06da..aa05c1dc 100644 --- a/mobile/src/screens/Login.tsx +++ b/mobile/src/screens/Login.tsx @@ -22,6 +22,8 @@ import { MailIcon } from '../design/icons'; import { palette } from '../design/tokens'; import { isMigrationNoticeActive } from '../common/utils/migration-notice'; import { displayTitle } from '../layouts/displayTitle'; +import { getAuthErrorCode } from '../common/utils/auth-error'; +import { InternalErrors } from '../common/errors/internal-errors.class'; // A new code can be asked for once a minute (the backend answers AUTH_010 sooner). export const RESEND_AFTER_SECONDS = 60; @@ -111,14 +113,18 @@ const Login = ({ navigation, route }: any) => { return () => clearTimeout(timer); }, [secondsLeft]); - // errors are shown by the auth context + // Keep request errors visible at the email field as well as the auth context's toast. const sendCode = async (value: string): Promise => { try { setIsLoading(true); setMaskedEmail(await requestLoginCode(value)); setSecondsLeft(RESEND_AFTER_SECONDS); return true; - } catch { + } catch (error) { + const errorCode = getAuthErrorCode(error); + setEmailError( + errorCode ? InternalErrors.AUTH_ERRORS.getError(errorCode) : `${t('auth:errors.login')}`, + ); return false; } finally { setIsLoading(false); diff --git a/mobile/src/screens/Organizations.tsx b/mobile/src/screens/Organizations.tsx index 16260c94..e1c87dfd 100644 --- a/mobile/src/screens/Organizations.tsx +++ b/mobile/src/screens/Organizations.tsx @@ -117,9 +117,7 @@ const Organizations = ({ navigation }: any) => { > - - {item.name} - + {item.name} {/* the "Membru" badge goes under the name so it doesn't cut it on small screens */} @@ -242,7 +240,6 @@ const Organizations = ({ navigation }: any) => { diff --git a/mobile/src/screens/Settings.tsx b/mobile/src/screens/Settings.tsx index 737400ca..22f38cbd 100644 --- a/mobile/src/screens/Settings.tsx +++ b/mobile/src/screens/Settings.tsx @@ -63,8 +63,7 @@ const JoinHexagon = () => ( ); -// From this text size up a third of the width no longer holds „evenimente” on one line: the -// tiles go two per row (the third takes a row of its own) instead of breaking words. +// Narrow screens and large text use two tiles per row so labels keep whole words. const STATS_WRAP_SCALE = 1.2; const Stat = ({ @@ -105,8 +104,8 @@ const Settings = ({ navigation }: any) => { const insets = useSafeAreaInsets(); const bottomInset = useGlassTabBarInset(); const reduced = useReducedMotion(); - const { fontScale } = useWindowDimensions(); - const wideStats = fontScale >= STATS_WRAP_SCALE; + const { fontScale, width } = useWindowDimensions(); + const wideStats = width < 430 || fontScale >= STATS_WRAP_SCALE; useFocusedStatusBarStyle('dark'); const scrollY = useSharedValue(0); const onScroll = useTabBarScroll(scrollY); @@ -246,7 +245,6 @@ const Settings = ({ navigation }: any) => { key={organization.id} avatar={{ name: organization.name }} title={organization.name} - titleLines={2} subtitle={ isCandidateVolunteer(organization) || organization.volunteerProfileId ? undefined diff --git a/mobile/src/screens/documents.test.tsx b/mobile/src/screens/documents.test.tsx index 3f957c05..4d02b3fc 100644 --- a/mobile/src/screens/documents.test.tsx +++ b/mobile/src/screens/documents.test.tsx @@ -119,6 +119,14 @@ describe('Documente', () => { ).toBeTruthy(); const bar = screen.getByTestId('documents-compact-bar', { includeHiddenElements: true }); expect(within(bar).getByText('Documente', { includeHiddenElements: true })).toBeTruthy(); + // The status area stays covered even before the compact bar fades in at 48pt. + expect(screen.getByTestId('status-bar-scrim')).toHaveStyle({ + height: 47, + backgroundColor: '#F4F1E9', + }); + expect( + within(screen.getByTestId('documents-list')).queryByTestId('status-bar-scrim'), + ).toBeNull(); const signed = screen.getByTestId('document-row-3'); expect(within(signed).getByText('Contract 3/2026')).toBeTruthy(); expect(within(signed).getByText('15 ian 2026 – 15 ian 2027')).toBeTruthy(); diff --git a/mobile/src/screens/entry.test.tsx b/mobile/src/screens/entry.test.tsx index be8127cb..c96dd421 100644 --- a/mobile/src/screens/entry.test.tsx +++ b/mobile/src/screens/entry.test.tsx @@ -138,6 +138,22 @@ describe('Login, code step', () => { }); describe('Login, email step', () => { + it.each([ + [new Error('Network Error'), 'Eroare la autentificare, vă rugăm încercați din nou'], + [ + { response: { status: 429, data: {} } }, + 'Prea multe cereri. Încearcă din nou peste un minut.', + ], + ])('keeps a failed send visible at the email field', async (error, message) => { + mockAuth.requestLoginCode.mockRejectedValueOnce(error); + await renderStack('login'); + await fireEvent.changeText(await screen.findByTestId('login-email-input'), 'ana@example.ro'); + await fireEvent.press(screen.getByTestId('login-send')); + expect(await screen.findByText(message)).toBeTruthy(); + expect(screen.queryByTestId('button-spinner')).toBeNull(); + expect(screen.queryByTestId('login-code')).toBeNull(); + }); + it('validates the address before sending a code', async () => { await renderStack('login'); await fireEvent.changeText(await screen.findByTestId('login-email-input'), 'nu-e-email'); @@ -169,6 +185,20 @@ describe('Login, email step', () => { }); describe('Landing', () => { + it('keeps a failed send visible in the email sheet and allows retrying', async () => { + await AsyncStorage.setItem('vic.onboarding.seen', '1'); + mockAuth.requestLoginCode.mockRejectedValueOnce(new Error('Network Error')); + await renderStack('landing'); + await fireEvent.changeText(await screen.findByTestId('email-input'), 'ana@example.ro'); + await fireEvent.press(screen.getByTestId('email-send')); + expect( + await screen.findByText('Eroare la autentificare, vă rugăm încercați din nou'), + ).toBeTruthy(); + expect(screen.queryByTestId('button-spinner')).toBeNull(); + await fireEvent.press(screen.getByTestId('email-send')); + expect(await screen.findByText('Verifică-ți emailul')).toBeTruthy(); + }); + it('opens the intro on the first start only', async () => { await renderStack('landing'); expect(await screen.findByTestId('onboarding')).toBeTruthy(); diff --git a/mobile/src/screens/events.test.tsx b/mobile/src/screens/events.test.tsx index cec3b98a..a3571a7b 100644 --- a/mobile/src/screens/events.test.tsx +++ b/mobile/src/screens/events.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { SafeAreaProvider } from 'react-native-safe-area-context'; -import { act, fireEvent, render, screen, within } from '@testing-library/react-native'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react-native'; import '../common/config/i18n'; import Events from './Events'; import Event from './Event'; @@ -26,6 +26,7 @@ const mockCalls: any[][] = []; const mockSetRsvp = jest.fn(); const mockRefetchEvent = jest.fn(); let mockEventError: any = null; +let mockPreviousEvents = false; const event = (id: string, extra: Record = {}) => ({ id, @@ -56,12 +57,17 @@ jest.mock('../services/event/event.service', () => ({ if (search === 'nimic' || filters?.organizationId === 'org-2') { items = []; } + const previous = mockPreviousEvents && eventFilter === 'GOING'; + if (previous) { + items = [event('1'), event('2', { isPublic: false }), event('3')]; + } return { data: { pages: [{ items, meta: { totalItems: items.length } }] }, error: null, - isFetching: false, + isFetching: previous, isFetchingNextPage: false, - isRefetching: false, + isRefetching: previous, + isPreviousData: previous, hasNextPage: false, fetchNextPage: jest.fn(), refetch: jest.fn(), @@ -92,6 +98,7 @@ const renderApp = (initialRouteName = 'events', params?: Record ); beforeEach(() => { + mockPreviousEvents = false; mockEventError = null; mockCalls.length = 0; jest.clearAllMocks(); @@ -116,6 +123,23 @@ beforeEach(() => { const lastCall = () => mockCalls[mockCalls.length - 1]; describe('Evenimente', () => { + it('keeps the header and rows during a segment fetch without pulling down the list', async () => { + await renderApp(); + const header = screen.getByRole('header', { name: 'Evenimente' }); + const list = screen.getByTestId('events-list'); + mockPreviousEvents = true; + + await fireEvent.press(screen.getByText('Particip')); + await screen.findByTestId('event-card-1'); + // Wait for the swap-out timer to select the new query. + await waitFor(() => expect(lastCall()[2]).toBe(EventType.GOING)); + + expect(lastCall()[4]).toEqual({ keepPreviousData: true }); + expect(screen.getByRole('header', { name: 'Evenimente' })).toBe(header); + expect(screen.getByTestId('events-list')).toBe(list); + expect(screen.getByTestId('events-list').props.refreshControl.props.refreshing).toBe(false); + expect(within(screen.getByTestId('events-tabs-GOING')).queryByText('3')).toBeNull(); + }); it('shows only the search title in the pill when no filters are active', async () => { await renderApp(); const pill = await screen.findByTestId('events-search-pill'); diff --git a/mobile/src/screens/home.test.tsx b/mobile/src/screens/home.test.tsx index c08caccd..d4988777 100644 --- a/mobile/src/screens/home.test.tsx +++ b/mobile/src/screens/home.test.tsx @@ -57,6 +57,7 @@ jest.mock('../services/anouncement/anouncement.service', () => ({ title: 'Căutăm 50 de voluntari', description: 'Pentru Ziua Pământului', organizationLogo: '', + organization: { name: 'Asociația Exemplu pentru Comunitate' }, }, ], }), @@ -164,6 +165,19 @@ describe('Acasă', () => { ).toBeTruthy(); }); + it('leaves organization names and announcement titles unclamped, with only a body preview', async () => { + await renderHome(); + expect( + within(screen.getByTestId('home-org')).getByText('Asociația Exemplu').props.numberOfLines, + ).toBeUndefined(); + const announcement = within(screen.getByTestId('home-anouncement-a1')); + expect(announcement.getByText('Căutăm 50 de voluntari').props.numberOfLines).toBeUndefined(); + expect( + announcement.getByText('Asociația Exemplu pentru Comunitate').props.numberOfLines, + ).toBeUndefined(); + expect(announcement.getByText('Pentru Ziua Pământului').props.numberOfLines).toBe(2); + }); + it('the bell has no badge when everything is read', async () => { mockUnread = 0; await renderHome(); @@ -215,7 +229,9 @@ describe('Acasă', () => { it('the impact number counts up to the approved hours', async () => { await renderHome(); - await waitFor(() => expect(screen.getByTestId('home-impact-count')).toHaveTextContent('42h')); + await waitFor(() => + expect(screen.getByTestId('home-impact-count')).toHaveTextContent('42\u2009h'), + ); }); it('hides the pending and rejected chips when there are none', async () => { diff --git a/mobile/src/screens/home/HomeParts.tsx b/mobile/src/screens/home/HomeParts.tsx index 850bc056..5af07a8c 100644 --- a/mobile/src/screens/home/HomeParts.tsx +++ b/mobile/src/screens/home/HomeParts.tsx @@ -2,7 +2,7 @@ // organization row, the navy impact card with the liquid hexagon, the yellow to-do cards, the // „Urmează” carousel and the „Luna aceasta” bento. import React from 'react'; -import { Pressable, ScrollView, Text, View, useWindowDimensions } from 'react-native'; +import { Platform, Pressable, ScrollView, Text, View, useWindowDimensions } from 'react-native'; import Animated, { Extrapolation, interpolate, @@ -76,7 +76,9 @@ export const CompactBar = ({ title, scrollY }: { title: string; scrollY: SharedV style={[styles.compact, { paddingTop: insets.top, height: insets.top + 44 }, barStyle]} testID="home-compact-bar" > - + {Platform.OS === 'ios' && ( + + )} @@ -99,12 +101,7 @@ export const LargeTitle = ({ title, scrollY }: { title: string; scrollY: SharedV }); return ( - + {title} @@ -152,7 +149,7 @@ export const OrgRow = ({ {t('org.overline')} - + {name} {!!onSwitch && ( @@ -254,7 +251,7 @@ export const ImpactCard = ({ {`${shown}`} {/* the small „h” (36pt) inherits the line of the number; AppText trims the line */} - h + {'\u2009h'} @@ -319,7 +316,7 @@ export const TodoCard = ({ {title} {!!subtitle && ( - + {subtitle} )} @@ -579,7 +576,9 @@ const styles = StyleSheet.create((theme) => ({ alignItems: 'center', justifyContent: 'center', }, - compactTint: { backgroundColor: lightColors.topBarTint }, + compactTint: { + backgroundColor: Platform.OS === 'ios' ? lightColors.topBarTint : lightColors.androidTopBarTint, + }, compactTitle: { fontFamily: theme.fontFamily.display, fontSize: 17, @@ -597,7 +596,7 @@ const styles = StyleSheet.create((theme) => ({ }, orgOuter: { flex: 1 }, org: { flexDirection: 'row', alignItems: 'center', gap: 10, minHeight: theme.hit.min }, - orgText: { flex: 1 }, + orgText: { flex: 1, minWidth: 0 }, orgNameRow: { flexDirection: 'row', alignItems: 'center', gap: 2 }, orgName: { fontFamily: theme.fontFamily.textHeavy, flexShrink: 1 }, bell: { @@ -665,7 +664,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: 'center', justifyContent: 'center', }, - todoText: { flex: 1, gap: 1 }, + todoText: { flex: 1, minWidth: 0, gap: 1 }, todoTitle: { fontFamily: theme.fontFamily.textHeavy, fontSize: 15.5, diff --git a/mobile/src/screens/hours.test.tsx b/mobile/src/screens/hours.test.tsx index 2cc89591..612d39d0 100644 --- a/mobile/src/screens/hours.test.tsx +++ b/mobile/src/screens/hours.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Alert } from 'react-native'; +import { Alert, StyleSheet } from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { BottomTabBarProps, createBottomTabNavigator } from '@react-navigation/bottom-tabs'; @@ -68,6 +68,7 @@ const mockRefetchCounters = jest.fn(); const mockSent: any[] = []; let queryClient: QueryClient; let mockLogQuery: any = { isFetching: false }; +let mockRefetching = false; const entry = (id: string, extra: Record = {}) => ({ id, @@ -80,26 +81,34 @@ const entry = (id: string, extra: Record = {}) => ({ jest.mock('../services/activity-log/activity-log.service', () => ({ useActivityLogsInfiniteQuery: (...args: any[]) => { + const { useMemo } = require('react'); mockListCalls.push(args); + const organizationId = args[0]; const status = args[3]; - // disabled without an organization: no data - const items = !args[0] - ? [] - : status === 'pending' - ? [ - ...mockSent, - entry('1'), - entry('2', { event: undefined, activityType: undefined, hours: 3 }), - ] - : status === 'approved' - ? [entry('3', { hours: 4 })] - : []; + const sentCount = mockSent.length; + // React Query keeps data stable between refreshes, including when onSent changes local state. + const data = useMemo(() => { + // disabled without an organization: no data + const items = !organizationId + ? [] + : status === 'pending' + ? [ + ...mockSent, + entry('1'), + entry('2', { event: undefined, activityType: undefined, hours: 3 }), + ] + : status === 'approved' + ? [entry('3', { hours: 4 })] + : []; + return { pages: [{ items, meta: { totalItems: items.length } }] }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- mockSent is read through sentCount + }, [organizationId, status, sentCount]); return { - data: { pages: [{ items, meta: { totalItems: items.length } }] }, + data, error: null, - isFetching: false, + isFetching: mockRefetching, isFetchingNextPage: false, - isRefetching: false, + isRefetching: mockRefetching, hasNextPage: false, fetchNextPage: jest.fn(), refetch: mockRefetchList, @@ -189,6 +198,7 @@ const detailParams = { }; beforeEach(() => { + mockRefetching = false; queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); mockLogQuery = { isFetching: false, error: null, refetch: jest.fn() }; mockListCalls.length = 0; @@ -232,6 +242,10 @@ describe('Ore', () => { const add = bar.getByTestId('hours-add'); expect(add.props.accessibilityLabel).toBe('Adaugă ore'); expect(bar.getByText('Adaugă ore')).toBeTruthy(); + expect( + StyleSheet.flatten(screen.getByTestId('hours-list').props.contentContainerStyle) + .paddingBottom, + ).toBe(174); }); it('scrolls the organization, title, totals and status with the entries, under a compact bar', async () => { @@ -284,6 +298,28 @@ describe('Ore', () => { expect(mockRefetchCounters).toHaveBeenCalledTimes(1); }); + it('keeps the summary mounted during a background refetch with existing totals', async () => { + await renderApp(); + const summary = screen.getByTestId('hours-summary'); + const hexagon = screen.getByTestId('impact-hexagon', { includeHiddenElements: true }); + await waitFor(() => + expect(within(screen.getByTestId('hours-total-approved')).getByText('145h')).toBeTruthy(), + ); + + mockRefetching = true; + await act(async () => { + useStore.setState({ counters: { approved: 145, pending: 12, rejected: 5 } }); + }); + + expect(screen.getByTestId('hours-summary')).toBe(summary); + expect(screen.getByTestId('impact-hexagon', { includeHiddenElements: true })).toBe(hexagon); + expect(within(screen.getByTestId('hours-total-approved')).getByText('145h')).toBeTruthy(); + expect(screen.getByTestId('hours-list').props.refreshControl.props.refreshing).toBe(false); + await waitFor(() => + expect(within(screen.getByTestId('hours-total-pending')).getByText('12h')).toBeTruthy(), + ); + }); + it('marks cached segments stale on focus without fetching them', async () => { const fetchList = jest.fn(async () => ({ pages: [] })); const keys = Object.values(ActivityLogStatus).map((status) => @@ -470,12 +506,44 @@ describe('Ore', () => { expect(mockCreate).not.toHaveBeenCalled(); }); + it('highlights an entry already refreshed into the pending list before the sheet calls onSent', async () => { + mockCreate.mockImplementation((_payload: any, options: any) => { + const log = entry('new', { hours: 1 }); + mockSent.push(log); + // refreshHours updates the list and counters before the sheet's success dwell ends. + useStore.setState({ counters: { approved: 145, pending: 10, rejected: 5 } }); + options.onSuccess({ ...log, status: 'pending' }); + }); + await renderApp(); + await waitFor(() => + expect(within(screen.getByTestId('hours-total-approved')).getByText('145h')).toBeTruthy(), + ); + const hexagon = screen.getByTestId('impact-hexagon', { includeHiddenElements: true }); + await openAddSheet(); + await pickActivity(); + await fireEvent.press(screen.getByTestId('hours-submit')); + + expect(await screen.findByTestId('hours-sent')).toBeTruthy(); + const row = await screen.findByTestId('log-row-new'); + expect(screen.queryByTestId('log-row-new-highlight')).toBeNull(); + // Keep the refreshed query result unchanged while onSent sets only highlightId. + expect( + await screen.findByTestId('log-row-new-highlight', undefined, { + timeout: SUCCESS_DWELL_MS + 1000, + }), + ).toBeTruthy(); + expect(screen.getByTestId('log-row-new')).toBe(row); + expect(screen.getByTestId('impact-hexagon', { includeHiddenElements: true })).toBe(hexagon); + expect(within(screen.getByTestId('hours-total-approved')).getByText('145h')).toBeTruthy(); + }); + it('sends the hours, shows „Trimis spre aprobare”, then highlights the new entry', async () => { mockCreate.mockImplementation((_payload: any, options: any) => { mockSent.push(entry('new', { hours: 3 })); options.onSuccess({ ...entry('new', { hours: 3 }), status: 'pending' }); }); await renderApp(); + const hexagon = screen.getByTestId('impact-hexagon', { includeHiddenElements: true }); await fireEvent.press(await screen.findByText('Aprobate')); const sheet = await openAddSheet(); await pickActivity(); @@ -511,6 +579,8 @@ describe('Ore', () => { await waitFor(() => expect(mockListCalls[mockListCalls.length - 1][3]).toBe(ActivityLogStatus.PENDING), ); + expect(screen.getByTestId('impact-hexagon', { includeHiddenElements: true })).toBe(hexagon); + expect(within(screen.getByTestId('hours-total-approved')).getByText('145h')).toBeTruthy(); // then the toast confirms it (prototype: „4h trimise spre aprobare”) await waitFor(() => expect(Toast.show).toHaveBeenCalledWith({ diff --git a/mobile/src/services/event/event.service.ts b/mobile/src/services/event/event.service.ts index 7ba8e895..cad06606 100644 --- a/mobile/src/services/event/event.service.ts +++ b/mobile/src/services/event/event.service.ts @@ -15,11 +15,13 @@ export const useEventsInfiniteQuery = ( search: string, eventFilter: EventType, filters: EventsFilters = {}, + { keepPreviousData = false }: { keepPreviousData?: boolean } = {}, ) => { return useInfiniteQuery( ['events', orderDirection, search, eventFilter, filters], ({ pageParam }) => getEvents({ pageParam, orderDirection, search, eventFilter, ...filters }), { + keepPreviousData, getNextPageParam: (lastPage) => { return lastPage?.meta.totalPages > lastPage?.meta.currentPage ? lastPage?.meta.currentPage + 1