diff --git a/App.tsx b/App.tsx index 175c27a..97d1e52 100644 --- a/App.tsx +++ b/App.tsx @@ -12,8 +12,8 @@ import { } from '@pagopa/io-app-design-system'; import {BottomSheetModalProvider} from '@gorhom/bottom-sheet'; import {persistor, store} from './ts/store'; -import {RootStackNavigator} from './ts/navigation/RootStacknavigator'; import IdentificationModal from './ts/screens/IdentificationModal'; +import RootContainer from './ts/screens/RootContainer'; function App(): React.JSX.Element { return ( @@ -25,7 +25,7 @@ function App(): React.JSX.Element { - + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 7ddb6c8..e153bfc 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1578,6 +1578,8 @@ PODS: - React-Core - RNCAsyncStorage (2.1.0): - React-Core + - RNCClipboard (1.15.0): + - React-Core - RNDeviceInfo (14.0.1): - React-Core - RNGestureHandler (2.21.2): @@ -1810,6 +1812,7 @@ DEPENDENCIES: - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - RNBootSplash (from `../node_modules/react-native-bootsplash`) - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" + - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" - RNDeviceInfo (from `../node_modules/react-native-device-info`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`) @@ -1967,6 +1970,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-bootsplash" RNCAsyncStorage: :path: "../node_modules/@react-native-async-storage/async-storage" + RNCClipboard: + :path: "../node_modules/@react-native-clipboard/clipboard" RNDeviceInfo: :path: "../node_modules/react-native-device-info" RNGestureHandler: @@ -2055,6 +2060,7 @@ SPEC CHECKSUMS: ReactCommon: 36d48f542b4010786d6b2bcee615fe5f906b7105 RNBootSplash: 74d11cdbe6bfafa66014fc54a7105b79350333bd RNCAsyncStorage: c91d753ede6dc21862c4922cd13f98f7cfde578e + RNCClipboard: dbcf25b8f666b4685c02eeb65be981d30198e505 RNDeviceInfo: afc27b3f24bd0e97181bf3e9f23cfa4c9040dd32 RNGestureHandler: 5b24d10761754ad271b714e536c457fd89b17c54 RNReactNativeHapticFeedback: 00ba111b82aa266bb3ee1aa576831c2ea9a9dfad diff --git a/locales/en/global.json b/locales/en/global.json index ba6f43f..11f2c7f 100644 --- a/locales/en/global.json +++ b/locales/en/global.json @@ -10,13 +10,13 @@ }, "settings": { "title": "Settings", - "listHeaders": { - "test": { - "title": "Test", - "walletReset": "Reset wallet", - "onboardingReset": "Reset onboarding" - } - } + "reset": { + "title": "Reset App", + "walletReset": "Reset wallet", + "onboardingReset": "Reset onboarding" + }, + "debug": "Enable debug mode", + "version": "Version" }, "buttons": { "next": "Next", @@ -44,6 +44,9 @@ "hint": "Wait for the content load" } }, + "clipboard": { + "copyFeedback": "Copied to clipboard" + }, "identification": { "title": { "validation": "Authorise the operation.", diff --git a/package.json b/package.json index c441d62..a5dbc0a 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@pagopa/io-react-native-wallet": "^0.27.0", "@pagopa/react-native-nodelibs": "^0.1.0", "@react-native-async-storage/async-storage": "^2.0.0", + "@react-native-clipboard/clipboard": "^1.15.0", "@react-navigation/bottom-tabs": "^7.0.5", "@react-navigation/native": "^7.0.3", "@react-navigation/native-stack": "^7.0.4", diff --git a/ts/components/AppVersion.tsx b/ts/components/AppVersion.tsx new file mode 100644 index 0000000..69954ea --- /dev/null +++ b/ts/components/AppVersion.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import {GestureResponderEvent, StyleSheet, View} from 'react-native'; +import {BodySmall, IOStyles, WithTestID} from '@pagopa/io-app-design-system'; +import {useTranslation} from 'react-i18next'; +import {getAppVersion} from '../utils/device'; + +export type AppVersion = WithTestID<{ + onPress: (event: GestureResponderEvent) => void; +}>; + +const styles = StyleSheet.create({ + versionButton: { + paddingVertical: 20, + alignSelf: 'flex-start' + } +}); + +/** + * This component renders a text with the current app version, to be shown in the settings screen. + */ +const AppVersion = () => { + const appVersion = getAppVersion(); + const {t} = useTranslation('global'); + const appVersionText = `${t('settings.version')} ${appVersion}`; + + return ( + + + {appVersionText} + + + ); +}; + +export default AppVersion; diff --git a/ts/components/debug/DebugDataIndicator.tsx b/ts/components/debug/DebugDataIndicator.tsx new file mode 100644 index 0000000..aad0355 --- /dev/null +++ b/ts/components/debug/DebugDataIndicator.tsx @@ -0,0 +1,68 @@ +import { + HStack, + IOColors, + IOText, + Icon, + hexToRgba +} from '@pagopa/io-app-design-system'; +import _ from 'lodash'; +import * as React from 'react'; +import {Pressable, StyleSheet} from 'react-native'; +import {selectDebugData} from '../../store/reducers/debug'; +import {useAppSelector} from '../../store'; + +type DebugDataIndicatorProps = { + onPress: () => void; +}; + +/** + * This component renders an icon with a ladybug which opens the debug info overlay when pressed. + * Used in {@link DebugInfoOverlay} + */ +export const DebugDataIndicator = (props: DebugDataIndicatorProps) => { + const data = useAppSelector(selectDebugData); + const dataSize = _.size(data); + + if (dataSize === 0) { + return null; + } + + return ( + + + + + {dataSize} + + + + ); +}; + +const debugItemBgColor = hexToRgba(IOColors['warning-500'], 0.4); +const debugItemBorderColor = hexToRgba(IOColors['warning-850'], 0.1); + +const styles = StyleSheet.create({ + wrapper: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderColor: debugItemBorderColor, + borderWidth: 1, + paddingHorizontal: 6, + borderRadius: 8, + backgroundColor: debugItemBgColor + } +}); diff --git a/ts/components/debug/DebugDataOverlay.tsx b/ts/components/debug/DebugDataOverlay.tsx new file mode 100644 index 0000000..61faaff --- /dev/null +++ b/ts/components/debug/DebugDataOverlay.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { + ScrollView, + StyleSheet, + TouchableWithoutFeedback, + View +} from 'react-native'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import {useAppSelector} from '../../store'; +import {selectDebugData} from '../../store/reducers/debug'; +import {DebugPrettyPrint} from './DebugPrettyPrint'; + +type DebugDataOverlayProps = { + onDismissed?: () => void; +}; + +/** + * Debug overlay to show all the debug data in a list for each entry in the debug state via {@link DebugPrettyPrint}. + * Used in {@link DebugInfoOverlay} + */ +export const DebugDataOverlay = ({onDismissed}: DebugDataOverlayProps) => { + const debugData = useAppSelector(selectDebugData); + + return ( + + + + + + {Object.entries(debugData).map(([key, value]) => ( + + ))} + + + ); +}; + +const overlayColor = '#000000B0'; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + zIndex: 999, + paddingTop: 60 + }, + overlay: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + backgroundColor: overlayColor + }, + scroll: { + flexGrow: 0 + }, + scrollContainer: { + paddingHorizontal: 16 + } +}); diff --git a/ts/components/debug/DebugInfoOverlay.tsx b/ts/components/debug/DebugInfoOverlay.tsx new file mode 100644 index 0000000..c269b34 --- /dev/null +++ b/ts/components/debug/DebugInfoOverlay.tsx @@ -0,0 +1,75 @@ +import { + IOColors, + IOText, + VStack, + hexToRgba, + useIOTheme +} from '@pagopa/io-app-design-system'; +import * as React from 'react'; +import {useState} from 'react'; +import {Platform, SafeAreaView, StyleSheet, View} from 'react-native'; +import {getAppVersion} from '../../utils/device'; +import {DebugDataIndicator} from './DebugDataIndicator'; +import {DebugDataOverlay} from './DebugDataOverlay'; + +const debugItemBgColor = hexToRgba(IOColors.white, 0.4); +const debugItemBorderColor = hexToRgba(IOColors.black, 0.1); + +/** + * Overlay which shows the debug data stored in the debug state. + */ +const DebugInfoOverlay = () => { + const theme = useIOTheme(); + const appVersion = getAppVersion(); + const [isDebugDataVisibile, showDebugData] = useState(false); + + const appVersionText = `DEBUG ENABLED: v${appVersion}`; + + return ( + <> + + + + + {appVersionText} + + + showDebugData(prevState => !prevState)} + /> + + + {isDebugDataVisibile && ( + showDebugData(false)} /> + )} + + ); +}; + +const styles = StyleSheet.create({ + versionContainer: { + ...StyleSheet.absoluteFillObject, + top: Platform.OS === 'android' ? 0 : -8, + justifyContent: 'flex-start', + alignItems: 'center', + zIndex: 1000 + }, + versionTextWrapper: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderColor: debugItemBorderColor, + borderWidth: 1, + paddingHorizontal: 4, + borderRadius: 8, + backgroundColor: debugItemBgColor + } +}); + +export default DebugInfoOverlay; diff --git a/ts/components/debug/DebugPrettyPrint.tsx b/ts/components/debug/DebugPrettyPrint.tsx new file mode 100644 index 0000000..0349398 --- /dev/null +++ b/ts/components/debug/DebugPrettyPrint.tsx @@ -0,0 +1,122 @@ +import { + BodySmall, + HStack, + IOColors, + IOText, + IconButton, + useIOToast +} from '@pagopa/io-app-design-system'; +import React from 'react'; +import {StyleSheet, View} from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; +import {useTranslation} from 'react-i18next'; +import {truncateObjectStrings} from '../../utils/debug'; +import {Prettify} from '../../utils/types'; +import {withDebugEnabled} from './withDebugEnabled'; + +type ExpandableProps = + | { + expandable: true; + isExpanded?: boolean; + } + | { + expandable?: false; + isExpanded?: undefined; + }; + +type Props = Prettify< + { + title: string; + data: any; + } & ExpandableProps +>; + +/** + * This component allows to print the content of an object in an elegant and readable way. + * and to copy its content to the clipboard by pressing on the copy button. + * The component it is rendered only if debug mode is enabled + */ +export const DebugPrettyPrint = withDebugEnabled( + ({title, data, expandable = true, isExpanded = false}: Props) => { + const toast = useIOToast(); + const [expanded, setExpanded] = React.useState(isExpanded); + const {t} = useTranslation('global'); + + const content = React.useMemo(() => { + if ((expandable && !expanded) || !expandable) { + return null; + } + + return ( + + + {JSON.stringify(truncateObjectStrings(data), null, 2)} + + + ); + }, [data, expandable, expanded]); + + /** + * Copy a text to the device clipboard and give a feedback. + */ + const clipboardSetStringWithFeedback = (text: string) => { + Clipboard.setString(text); + toast.success(t('clipboard.copyFeedback')); + }; + + return ( + + + + {title} + + + + clipboardSetStringWithFeedback(JSON.stringify(data, null, 2)) + } + color="contrast" + /> + {expandable && ( + setExpanded(_ => !_)} + color="contrast" + /> + )} + + + {content} + + ); + } +); + +const styles = StyleSheet.create({ + container: { + borderRadius: 4, + overflow: 'hidden', + marginVertical: 4 + }, + header: { + backgroundColor: IOColors['error-600'], + padding: 12, + flex: 1, + flexDirection: 'row', + justifyContent: 'space-between' + }, + content: { + backgroundColor: IOColors['grey-50'], + padding: 8 + } +}); diff --git a/ts/components/debug/withDebugEnabled.tsx b/ts/components/debug/withDebugEnabled.tsx new file mode 100644 index 0000000..092a28b --- /dev/null +++ b/ts/components/debug/withDebugEnabled.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import {selectIsDebugModeEnabled} from '../../store/reducers/debug'; +import {useAppSelector} from '../../store'; + +/** + * This HOC allows to render the wrapped component only if the debug mode is enabled, otherwise returns null (nothing) + */ +export const withDebugEnabled = +

>( + WrappedComponent: React.ComponentType

+ ) => + (props: P) => { + const isDebug = useAppSelector(selectIsDebugModeEnabled); + if (!isDebug) { + return null; + } + return ; + }; diff --git a/ts/features/wallet/screens/pidIssuance/PidIssuanceFailure.tsx b/ts/features/wallet/screens/pidIssuance/PidIssuanceFailure.tsx index fb461e1..6070e02 100644 --- a/ts/features/wallet/screens/pidIssuance/PidIssuanceFailure.tsx +++ b/ts/features/wallet/screens/pidIssuance/PidIssuanceFailure.tsx @@ -1,10 +1,15 @@ import {useNavigation} from '@react-navigation/native'; import {useTranslation} from 'react-i18next'; import React from 'react'; -import {useAppDispatch} from '../../../../store'; +import {useAppDispatch, useAppSelector} from '../../../../store'; import {OperationResultScreenContent} from '../../../../components/screens/OperationResultScreenContent'; -import {resetInstanceCreation, resetPidIssuance} from '../../store/pidIssuance'; +import { + resetInstanceCreation, + resetPidIssuance, + selectPidIssuanceError +} from '../../store/pidIssuance'; import {useHardwareBackButton} from '../../../../hooks/useHardwareBackButton'; +import {useDebugInfo} from '../../../../hooks/useDebugInfo'; /** * Filure screen of the pid issuance flow. @@ -14,9 +19,12 @@ const PidIssuanceFailure = () => { const {t} = useTranslation(['global', 'wallet']); const navigation = useNavigation(); const dispatch = useAppDispatch(); + const error = useAppSelector(selectPidIssuanceError); useHardwareBackButton(() => true); + useDebugInfo({error}); + const onPress = () => { dispatch(resetInstanceCreation()); dispatch(resetPidIssuance()); diff --git a/ts/features/wallet/store/pidIssuance.ts b/ts/features/wallet/store/pidIssuance.ts index c798653..af3309d 100644 --- a/ts/features/wallet/store/pidIssuance.ts +++ b/ts/features/wallet/store/pidIssuance.ts @@ -110,3 +110,11 @@ export const selectPidIssuanceData = (state: RootState) => state.wallet.pidIssuanceStatus.issuance.success.status === true ? state.wallet.pidIssuanceStatus.issuance.success.data : undefined; + +/** + * Selects the error occurred during the issuance flow. + * @param state - The root state + * @returns The error occurred during the issuance flow + */ +export const selectPidIssuanceError = (state: RootState) => + state.wallet.pidIssuanceStatus.issuance.error.error; diff --git a/ts/hooks/useDebugInfo.ts b/ts/hooks/useDebugInfo.ts new file mode 100644 index 0000000..c2524b6 --- /dev/null +++ b/ts/hooks/useDebugInfo.ts @@ -0,0 +1,32 @@ +import {useFocusEffect} from '@react-navigation/native'; +import React from 'react'; +import { + resetDebugData, + selectIsDebugModeEnabled, + setDebugData +} from '../store/reducers/debug'; +import {useAppDispatch, useAppSelector} from '../store'; + +/** + * Sets debug data for the mounted component. Removes it when the component is unmounted + * @param data Data to be displayes in debug mode + */ +export const useDebugInfo = (data: Record) => { + const dispatch = useAppDispatch(); + const isDebug = useAppSelector(selectIsDebugModeEnabled); + + useFocusEffect( + React.useCallback(() => { + // Avoids storing debug data if debug is disabled + if (!isDebug) { + return undefined; + } + + dispatch(setDebugData(data)); + + return () => { + dispatch(resetDebugData(Object.keys(data))); + }; + }, [dispatch, isDebug, data]) + ); +}; diff --git a/ts/i18n/types/resources.d.ts b/ts/i18n/types/resources.d.ts index f200eca..cfe2d5e 100644 --- a/ts/i18n/types/resources.d.ts +++ b/ts/i18n/types/resources.d.ts @@ -11,13 +11,13 @@ interface Resources { }; settings: { title: 'Settings'; - listHeaders: { - test: { - title: 'Test'; - walletReset: 'Reset wallet'; - onboardingReset: 'Reset onboarding'; - }; + reset: { + title: 'Reset App'; + walletReset: 'Reset wallet'; + onboardingReset: 'Reset onboarding'; }; + debug: 'Enable debug mode'; + version: 'Version'; }; buttons: { next: 'Next'; @@ -45,6 +45,9 @@ interface Resources { hint: 'Wait for the content load'; }; }; + clipboard: { + copyFeedback: 'Copied to clipboard'; + }; identification: { title: { validation: 'Authorise the operation.'; diff --git a/ts/screens/RootContainer.tsx b/ts/screens/RootContainer.tsx new file mode 100644 index 0000000..a85e9b3 --- /dev/null +++ b/ts/screens/RootContainer.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import {RootStackNavigator} from '../navigation/RootStacknavigator'; +import {useAppSelector} from '../store'; +import DebugInfoOverlay from '../components/debug/DebugInfoOverlay'; +import {selectIsDebugModeEnabled} from '../store/reducers/debug'; + +/** + * This is the root container of the app. It contains the main navigation stack and the debug overlay. + * It must be rendered in the root of the app after the store provider. + * @returns + */ +const RootContainer = () => { + const isDebugModeEnabled = useAppSelector(selectIsDebugModeEnabled); + + return ( + <> + {isDebugModeEnabled && } + + + ); +}; + +export default RootContainer; diff --git a/ts/screens/Settings.tsx b/ts/screens/Settings.tsx index 24241cf..388a6dc 100644 --- a/ts/screens/Settings.tsx +++ b/ts/screens/Settings.tsx @@ -2,6 +2,7 @@ import { ButtonSolid, IOStyles, ListItemHeader, + ListItemSwitch, useIOToast, VSpacer } from '@pagopa/io-app-design-system'; @@ -10,9 +11,14 @@ import {FlatList, View} from 'react-native'; import {useTranslation} from 'react-i18next'; import {useHeaderSecondLevel} from '../hooks/useHeaderSecondLevel'; import {IOScrollViewWithLargeHeader} from '../components/IOScrollViewWithLargeHeader'; -import {useAppDispatch} from '../store'; +import {useAppDispatch, useAppSelector} from '../store'; import {Lifecycle, setLifecycle} from '../features/wallet/store/lifecycle'; import {preferencesReset} from '../store/reducers/preferences'; +import { + selectIsDebugModeEnabled, + setDebugModeEnabled +} from '../store/reducers/debug'; +import AppVersion from '../components/AppVersion'; type TestButtonsListItem = Pick< ComponentProps, @@ -27,23 +33,34 @@ const Settings = () => { const toast = useIOToast(); const {t} = useTranslation('global'); const dispatch = useAppDispatch(); + const isDebugModeEnabled = useAppSelector(selectIsDebugModeEnabled); const testButtonListItems: ReadonlyArray = [ { - label: t('settings.listHeaders.test.walletReset'), + label: t('settings.reset.walletReset'), onPress: () => { dispatch(setLifecycle({lifecycle: Lifecycle.LIFECYCLE_OPERATIONAL})); toast.success(t('generics.success')); } }, { - label: t('settings.listHeaders.test.onboardingReset'), + label: t('settings.reset.onboardingReset'), onPress: () => { dispatch(preferencesReset()); } } ]; + const DebugSwitch = () => ( + { + dispatch(setDebugModeEnabled({state})); + }} + /> + ); + useHeaderSecondLevel({ title: '' }); @@ -55,17 +72,25 @@ const Settings = () => { accessibilityLabel: t('settings.title') }}> - ( - - )} - ListHeaderComponent={ - - } - ItemSeparatorComponent={() => } - /> + + {isDebugModeEnabled && ( + ( + + )} + ListHeaderComponent={ + + } + ItemSeparatorComponent={() => } + /> + )} + ); diff --git a/ts/store/index.ts b/ts/store/index.ts index 6be219d..b07509b 100644 --- a/ts/store/index.ts +++ b/ts/store/index.ts @@ -18,6 +18,7 @@ import {AppDispatch, RootState} from './types'; import {startupSlice} from './reducers/startup'; import {pinReducer} from './reducers/pin'; import {preferencesReducer} from './reducers/preferences'; +import {debugReducer} from './reducers/debug'; import {identificationReducer} from './reducers/identification'; // Create the saga middleware @@ -32,8 +33,9 @@ export const store = configureStore({ startup: startupSlice.reducer, preferences: preferencesReducer, pin: pinReducer, + wallet: walletReducer, identification: identificationReducer, - wallet: walletReducer + debug: debugReducer }, middleware: getDefaultMiddleware => getDefaultMiddleware({ diff --git a/ts/store/reducers/debug.ts b/ts/store/reducers/debug.ts new file mode 100644 index 0000000..0069b1c --- /dev/null +++ b/ts/store/reducers/debug.ts @@ -0,0 +1,79 @@ +/* eslint-disable functional/immutable-data */ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import _ from 'lodash'; +import {PersistConfig, persistReducer} from 'redux-persist'; +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +import {RootState} from '../types'; + +/* + * State type definition for the debug slice + * isDebugModeEnabled - Indicates if the debug mode is enabled or not + * debugData - Data that is used for debugging purposes + */ +type DebugState = Readonly<{ + isDebugModeEnabled: boolean; + debugData: Record; +}>; + +// Initial state for the debug slice +const initialState: DebugState = { + isDebugModeEnabled: false, + debugData: {} +}; + +/** + * Redux slice for the debug state. It allows to enable and disable the debug mode and set debug data. + */ +const debugSlice = createSlice({ + name: 'debug', + initialState, + reducers: { + setDebugModeEnabled: (state, action: PayloadAction<{state: boolean}>) => { + state.isDebugModeEnabled = action.payload.state; + state.debugData = {}; + }, + setDebugData: (state, action: PayloadAction>) => { + state.debugData = _.merge(state.debugData, action.payload); + }, + resetDebugData(state, action: PayloadAction>) { + state.debugData = Object.fromEntries( + Object.entries(state.debugData).filter( + ([key]) => !action.payload.includes(key) + ) + ); + } + } +}); + +/** + * Exports the actions for the debug slice. + */ +export const {setDebugModeEnabled, setDebugData, resetDebugData} = + debugSlice.actions; + +const debugPersist: PersistConfig = { + key: 'debug', + storage: AsyncStorage, + whitelist: ['isDebugModeEnabled'] +}; + +/** + * Persisted reducer for the debug slice. + */ +export const debugReducer = persistReducer(debugPersist, debugSlice.reducer); + +/** + * Selects the debug mode state. + * @param state - The root state of the Redux store + * @returns a boolean indicating if the debug mode is enabled + */ +export const selectIsDebugModeEnabled = (state: RootState) => + state.debug.isDebugModeEnabled; + +/** + * Selects the debug data. + * @param state - The root state of the Redux store + * @returns a record with the debug data + */ +export const selectDebugData = (state: RootState) => state.debug.debugData; diff --git a/ts/store/reducers/pin.ts b/ts/store/reducers/pin.ts index 76f0864..8552c2b 100644 --- a/ts/store/reducers/pin.ts +++ b/ts/store/reducers/pin.ts @@ -6,7 +6,8 @@ import {PinString} from '../../features/onboarding/types/PinString'; import secureStoragePersistor from '../persistors/secureStorage'; import {preferencesReset} from './preferences'; -/* State type definition for the pin slice +/* + * State type definition for the pin slice * pin - Application PIN set by the user */ export type PreferencesState = Readonly<{ diff --git a/ts/utils/debug.ts b/ts/utils/debug.ts new file mode 100644 index 0000000..94f7308 --- /dev/null +++ b/ts/utils/debug.ts @@ -0,0 +1,63 @@ +type Primitive = string | number | boolean | null | undefined; + +type TruncatableValue = + | Primitive + | TruncatableObject + | TruncatableArray + | TruncatableSet; + +interface TruncatableObject { + [key: string]: TruncatableValue; +} + +type TruncatableArray = Array; +type TruncatableSet = Set; + +/** + * Truncates all string values in an object or array structure to a specified maximum length. + * This function creates a deep copy of the input, ensuring the original is not modified. + * + * @template T - The type of the input value, must extend TruncatableValue + * @param {T} value - The value to process. Can be a string, number, boolean, null, undefined, array, or object + * @param {number} [maxLength=250] - The maximum length for string values. Defaults to 250 + * @returns {T} A new value of the same type as the input, with all strings truncated to the specified length + * + * @example + * const obj = { name: "Very long name...", age: 30, details: { bio: "Long bio..." } }; + * const truncated = truncateObjectStrings(obj, 10); + * // Result: { name: "Very long...", age: 30, details: { bio: "Long bio..." } } + */ +export const truncateObjectStrings = ( + value: T, + maxLength: number = 250 +): T => { + if (typeof value === 'string') { + return ( + value.length > maxLength ? value.slice(0, maxLength) + '...' : value + ) as T; + } + + if (Array.isArray(value)) { + return value.map(item => truncateObjectStrings(item, maxLength)) as T; + } + + if (typeof value === 'object' && value !== null) { + if (value instanceof Set) { + // Set could not be serialized to JSON because values are not stored as properties + // For display purposes, we convert it to an array + return Array.from(value).map(item => + truncateObjectStrings(item, maxLength) + ) as T; + } + + return Object.entries(value).reduce( + (acc, [key, val]) => ({ + ...acc, + [key]: truncateObjectStrings(val, maxLength) + }), + {} + ) as T; + } + + return value; +}; diff --git a/ts/utils/device.ts b/ts/utils/device.ts new file mode 100644 index 0000000..3bc4ee1 --- /dev/null +++ b/ts/utils/device.ts @@ -0,0 +1,12 @@ +import {Platform} from 'react-native'; +import DeviceInfo from 'react-native-device-info'; + +/** + * Returns the application version. + * @returns a string representing the application version + */ +export const getAppVersion = () => + Platform.select({ + ios: DeviceInfo.getReadableVersion(), + default: DeviceInfo.getVersion() + }); diff --git a/ts/utils/types.ts b/ts/utils/types.ts index fee4b28..6435e7b 100644 --- a/ts/utils/types.ts +++ b/ts/utils/types.ts @@ -3,3 +3,12 @@ export type NonEmptyArray = [T, ...Array]; export type TestID = {testID?: string}; export type WithTestID = T & TestID; + +/** + * A TypeScript type alias called `Prettify`. + * It takes a type as its argument and returns a new type that has the same properties as the original type, + * but the properties are not intersected. This means that the new type is easier to read and understand. + */ +export type Prettify = { + [K in keyof T]: T[K]; +} & object; diff --git a/yarn.lock b/yarn.lock index 3eb1b33..40dde35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2364,6 +2364,23 @@ __metadata: languageName: node linkType: hard +"@react-native-clipboard/clipboard@npm:^1.15.0": + version: 1.15.0 + resolution: "@react-native-clipboard/clipboard@npm:1.15.0" + peerDependencies: + react: ">= 16.9.0" + react-native: ">= 0.61.5" + react-native-macos: ">= 0.61.0" + react-native-windows: ">= 0.61.0" + peerDependenciesMeta: + react-native-macos: + optional: true + react-native-windows: + optional: true + checksum: b0c071368e46ce0676bbe68de2812c31ba67c477a85163b3f6392d04d13e4134c42040dc485361bfa059a7713baceb25afd4e15b77bfcaabe136fa499d9d5e19 + languageName: node + linkType: hard + "@react-native-community/cli-clean@npm:14.1.0": version: 14.1.0 resolution: "@react-native-community/cli-clean@npm:14.1.0" @@ -7368,6 +7385,7 @@ __metadata: "@pagopa/io-react-native-wallet": ^0.27.0 "@pagopa/react-native-nodelibs": ^0.1.0 "@react-native-async-storage/async-storage": ^2.0.0 + "@react-native-clipboard/clipboard": ^1.15.0 "@react-native-community/cli": 15.0.0 "@react-native-community/cli-platform-android": 15.0.0 "@react-native-community/cli-platform-ios": 15.0.0