From ebf8a67c336dd0b580abb96af8d066629412bb46 Mon Sep 17 00:00:00 2001 From: Laurentiu ONCESCU Date: Fri, 13 Nov 2026 18:05:52 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(mobile):=20patch-package=20aplic=C4=83?= =?UTF-8?q?=20din=20nou=20patch-ul=20pentru=20Unistyles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fișierul se numea unistyles.patch, așa că patch-package îl ignora și aplicația se închidea la pornire după o instalare curată. Redenumit în react-native-unistyles+3.8.1.patch. Co-Authored-By: Claude Opus 5.5 --- .../{unistyles.patch => react-native-unistyles+3.8.1.patch} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mobile/patches/{unistyles.patch => react-native-unistyles+3.8.1.patch} (100%) 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 From 79f95e3ab56e857023311555b7c2de5ae51283e8 Mon Sep 17 00:00:00 2001 From: Laurentiu ONCESCU Date: Fri, 13 Nov 2026 18:05:52 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(backend):=20ordine=20stabil=C4=83=20pen?= =?UTF-8?q?tru=20lista=20de=20ore=20(dat=C4=83,=20apoi=20createdOn,=20apoi?= =?UTF-8?q?=20id)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orele cu aceeași dată veneau în ordine arbitrară: în aplicație intrarea abia trimisă nu apărea prima în ziua ei (iar evidențierea ei se juca în afara ecranului), iar paginarea putea dubla sau sări rânduri. findMany adaugă acum createdOn și id în aceeași direcție ca sortarea principală. Co-Authored-By: Claude Opus 5.5 --- .../activity-log.repository.spec.ts | 95 +++++++++++++++++++ .../repositories/activity-log.repository.ts | 19 ++-- 2 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 backend/src/modules/activity-log/repositories/activity-log.repository.spec.ts 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( From 9796a76c24938ed844bd3d325f9b4734970542cd Mon Sep 17 00:00:00 2001 From: Laurentiu ONCESCU Date: Fri, 13 Nov 2026 18:06:03 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(mobile):=20=C3=8Endr=C4=83zne=C8=9B=20?= =?UTF-8?q?=E2=80=94=20repara=C8=9Bii=20dup=C4=83=20testarea=20pe=20dispoz?= =?UTF-8?q?itive=20reale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Găsite pe Galaxy Note9 (release), iPhone 8 (release) și simulatorul iPhone SE. Tastatură și formulare - foile (email la login, „Adaugă ore”) nu mai stau sub tastatură pe Android (android_keyboardInputMode="adjustPan"; footerul „Trimite” rămâne vizibil) - eroare vizibilă când „Trimite codul” eșuează - telefonul acceptă „07…” (0 inițial eliminat; API-ul primește tot +40…) - prima atingere pe Județ/Oraș deschide lista și cu tastatura deschisă (keyboardShouldPersistTaps); nu mai lovește „Continuă” și nu mai apar erori - listele de selecție (Eveniment, Județ…) nu mai intră sub bara de navigare (Sheet `scrollable`: un singur BottomSheetScrollView măsurat corect) Tab bar și cromatică - tab bar, pastila și „Adaugă ore” deasupra barei de navigare Android cu 3 butoane - etichetele tab-urilor nu se mai micșorează la font mare pe Android - lista de ore are spațiu sub butonul „Adaugă ore” - bara compactă de pe Acasă e opacă pe Android (fără BlurView) - Documente: titlul nu mai trece sub status bar Text la Dynamic Type mare - fără „…” la numele organizației, chipul „în așteptare”, anunțuri, Profil - „1 h” cu spațiu între cifră și unitate; „evenimente” nu se mai rupe - segmentele au lățimi egale și nu se mai împing la schimbare Comportament - hexagonul de pe Ore nu se mai golește și nu mai reia numărătoarea după trimitere - titlul de pe Evenimente nu mai sare la schimbarea segmentului - descărcarea unui contract inexistent (404) arată o eroare în loc de un PDF gol Co-Authored-By: Claude Opus 5.5 --- .../common/utils/download-contract.test.ts | 64 +++++++++ mobile/src/common/utils/download-contract.ts | 4 + mobile/src/common/utils/phone.test.ts | 15 ++ mobile/src/common/utils/phone.ts | 2 + mobile/src/components/FormInput.tsx | 4 +- mobile/src/components/FormSelect.tsx | 23 ++-- mobile/src/components/ProfileIntro.tsx | 2 +- mobile/src/components/forms.test.tsx | 129 +++++++++++++++++- mobile/src/components/hours/AddHoursSheet.tsx | 11 +- mobile/src/components/hours/HoursSummary.tsx | 15 +- mobile/src/components/hours/LogRow.tsx | 2 + .../navigation/GlassTabBar.test.tsx | 79 ++++++++++- .../src/components/navigation/GlassTabBar.tsx | 31 +++-- mobile/src/components/ui/Chip.tsx | 2 +- mobile/src/components/ui/ListRow.tsx | 5 +- mobile/src/components/ui/ScreenChrome.tsx | 5 +- mobile/src/components/ui/Sheet.tsx | 2 + mobile/src/design/components/Chip.tsx | 12 +- mobile/src/design/components/ListRow.test.tsx | 56 ++++++++ mobile/src/design/components/ListRow.tsx | 19 ++- .../design/components/SegmentedControl.tsx | 30 ++-- mobile/src/design/components/Select.tsx | 4 +- mobile/src/design/components/Sheet.test.tsx | 78 +++++++++++ mobile/src/design/components/Sheet.tsx | 30 +++- mobile/src/design/components/groupB.test.tsx | 95 ++++++++++++- mobile/src/design/tokens.ts | 9 +- mobile/src/layouts/FormLayout.tsx | 1 + mobile/src/screens/AccountData.test.tsx | 28 ++++ mobile/src/screens/AccountData.tsx | 3 + mobile/src/screens/ActivityLogs.tsx | 29 ++-- mobile/src/screens/CreateUser.test.tsx | 60 ++++++++ mobile/src/screens/CreateUser.tsx | 3 + mobile/src/screens/Documents.tsx | 24 ++-- mobile/src/screens/Events.tsx | 26 +++- mobile/src/screens/Home.tsx | 7 +- mobile/src/screens/Landing.tsx | 7 +- mobile/src/screens/Login.tsx | 10 +- mobile/src/screens/Organizations.tsx | 5 +- mobile/src/screens/Settings.tsx | 8 +- mobile/src/screens/documents.test.tsx | 8 ++ mobile/src/screens/entry.test.tsx | 30 ++++ mobile/src/screens/events.test.tsx | 30 +++- mobile/src/screens/home.test.tsx | 18 ++- mobile/src/screens/home/HomeParts.tsx | 27 ++-- mobile/src/screens/hours.test.tsx | 102 +++++++++++--- mobile/src/services/event/event.service.ts | 2 + 46 files changed, 992 insertions(+), 164 deletions(-) create mode 100644 mobile/src/common/utils/download-contract.test.ts create mode 100644 mobile/src/common/utils/phone.test.ts create mode 100644 mobile/src/common/utils/phone.ts create mode 100644 mobile/src/design/components/ListRow.test.tsx create mode 100644 mobile/src/design/components/Sheet.test.tsx create mode 100644 mobile/src/screens/CreateUser.test.tsx 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