diff --git a/app/src/components/Mnemonic.tsx b/app/src/components/Mnemonic.tsx index ad77306ee..a2ba717ef 100644 --- a/app/src/components/Mnemonic.tsx +++ b/app/src/components/Mnemonic.tsx @@ -2,11 +2,12 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Button, Text, XStack, YStack } from 'tamagui'; import Clipboard from '@react-native-clipboard/clipboard'; import { useSettingStore } from '@/stores/settingStore'; +import useCompactLayout from '@/hooks/useCompactLayout'; import { black, slate50, @@ -25,8 +26,9 @@ interface MnemonicProps { interface WordPill { index: number; word: string; + compact: boolean; } -const WordPill = ({ index, word }: WordPill) => { +const WordPill = ({ index, word, compact }: WordPill) => { return ( { backgroundColor={white} borderWidth="$0.5" borderRadius="$2" - padding={4} - minWidth={26} - gap={4} + padding={compact ? 3 : 4} + minWidth={compact ? 22 : 26} + gap={compact ? 3 : 4} > - + {index} - + {word} @@ -54,6 +56,34 @@ const Mnemonic = ({ words = REDACTED, onRevealWords }: MnemonicProps) => { const [revealWords, setRevealWords] = useState(false); const [copied, setCopied] = useState(false); const { setHasViewedRecoveryPhrase } = useSettingStore(); + const { isCompactWidth, selectResponsiveValues } = useCompactLayout(); + const { + containerPaddingHorizontal, + containerPaddingVertical, + containerGap, + buttonPadding, + } = selectResponsiveValues({ + containerPaddingHorizontal: { + compact: 18, + regular: 26, + dimension: 'width', + }, + containerPaddingVertical: { + compact: 22, + regular: 28, + dimension: 'width', + }, + containerGap: { compact: 10, regular: 12, dimension: 'width' }, + buttonPadding: { compact: 12, regular: 16, dimension: 'width' }, + }); + const containerSpacing = useMemo( + () => ({ + paddingHorizontal: containerPaddingHorizontal, + paddingVertical: containerPaddingVertical, + gap: containerGap, + }), + [containerGap, containerPaddingHorizontal, containerPaddingVertical], + ); const copyToClipboardOrReveal = useCallback(async () => { confirmTap(); if (!revealWords) { @@ -76,13 +106,18 @@ const Mnemonic = ({ words = REDACTED, onRevealWords }: MnemonicProps) => { borderBottomWidth={0} borderTopLeftRadius="$5" borderTopRightRadius="$5" - gap={12} - paddingHorizontal={26} - paddingVertical={28} + gap={containerSpacing.gap} + paddingHorizontal={containerSpacing.paddingHorizontal} + paddingVertical={containerSpacing.paddingVertical} flexWrap="wrap" > {(revealWords ? words : REDACTED).map((word, i) => ( - + ))} { borderTopWidth={0} borderBottomLeftRadius="$5" borderBottomRightRadius="$5" - paddingVertical={16} + paddingVertical={buttonPadding} onPress={copyToClipboardOrReveal} width="100%" textAlign="center" diff --git a/app/src/hooks/useCompactLayout.ts b/app/src/hooks/useCompactLayout.ts new file mode 100644 index 000000000..13431ed1d --- /dev/null +++ b/app/src/hooks/useCompactLayout.ts @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { useCallback } from 'react'; +import { useWindowDimensions } from 'react-native'; + +export const DEFAULT_COMPACT_WIDTH = 360; +export const DEFAULT_COMPACT_HEIGHT = 720; + +interface UseCompactLayoutOptions { + compactWidth?: number; + compactHeight?: number; +} + +type ResponsiveDimension = 'width' | 'height' | 'any'; + +interface ResponsivePaddingOptions { + min?: number; + max?: number; + percent?: number; +} + +type ResponsiveValueConfig = { + compact: T; + regular: T; + dimension?: ResponsiveDimension; +}; + +const useCompactLayout = ( + options: UseCompactLayoutOptions = {}, +): { + width: number; + height: number; + isCompactWidth: boolean; + isCompactHeight: boolean; + isCompact: boolean; + selectResponsiveValue: ( + compactValue: T, + regularValue: T, + dimension?: ResponsiveDimension, + ) => T; + selectResponsiveValues: >>( + config: TConfig, + ) => { + [K in keyof TConfig]: TConfig[K] extends ResponsiveValueConfig + ? TValue + : never; + }; + getResponsiveHorizontalPadding: (options?: ResponsivePaddingOptions) => number; +} => { + const { width, height } = useWindowDimensions(); + const compactWidth = options.compactWidth ?? DEFAULT_COMPACT_WIDTH; + const compactHeight = options.compactHeight ?? DEFAULT_COMPACT_HEIGHT; + + const isCompactWidth = width < compactWidth; + const isCompactHeight = height < compactHeight; + const selectResponsiveValue = useCallback( + ( + compactValue: T, + regularValue: T, + dimension: ResponsiveDimension = 'any', + ): T => { + if (dimension === 'width') { + return isCompactWidth ? compactValue : regularValue; + } + + if (dimension === 'height') { + return isCompactHeight ? compactValue : regularValue; + } + + return isCompactWidth || isCompactHeight ? compactValue : regularValue; + }, + [isCompactHeight, isCompactWidth], + ); + + const selectResponsiveValues = useCallback( + >>( + config: TConfig, + ) => { + const entries = Object.entries(config) as Array<[ + keyof TConfig, + ResponsiveValueConfig, + ]>; + const result = {} as { + [K in keyof TConfig]: TConfig[K] extends ResponsiveValueConfig + ? TValue + : never; + }; + + entries.forEach(([key, { compact, regular, dimension }]) => { + result[key] = selectResponsiveValue(compact, regular, dimension); + }); + + return result; + }, + [selectResponsiveValue], + ); + + const getResponsiveHorizontalPadding = useCallback( + (paddingOptions: ResponsivePaddingOptions = {}): number => { + const { min = 16, max, percent = 0.06 } = paddingOptions; + const computed = width * percent; + const withMin = Math.max(min, computed); + return typeof max === 'number' ? Math.min(max, withMin) : withMin; + }, + [width], + ); + + return { + width, + height, + isCompactWidth, + isCompactHeight, + isCompact: isCompactWidth || isCompactHeight, + selectResponsiveValue, + selectResponsiveValues, + getResponsiveHorizontalPadding, + }; +}; + +export default useCompactLayout; diff --git a/app/src/layouts/ExpandableBottomLayout.tsx b/app/src/layouts/ExpandableBottomLayout.tsx index 2b46f6800..2b0506ce0 100644 --- a/app/src/layouts/ExpandableBottomLayout.tsx +++ b/app/src/layouts/ExpandableBottomLayout.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import React from 'react'; +import React, { useMemo } from 'react'; import { Dimensions, PixelRatio, @@ -17,6 +17,7 @@ import { View } from 'tamagui'; import { black, white } from '@/utils/colors'; import { extraYPadding } from '@/utils/constants'; +import useCompactLayout from '@/hooks/useCompactLayout'; // Get the current font scale factor const fontScale = PixelRatio.getFontScale(); @@ -57,7 +58,17 @@ const TopSection: React.FC = ({ ...props }) => { const { top } = useSafeAreaInsets(); - const { roundTop, ...restProps } = props; + const { roundTop, style: incomingStyle, ...restProps } = props; + const { selectResponsiveValue } = useCompactLayout({ + compactHeight: 760, + }); + const spacingStyle = useMemo( + () => ({ + paddingHorizontal: selectResponsiveValue(16, 20, 'width'), + paddingVertical: selectResponsiveValue(16, 20, 'height'), + }), + [selectResponsiveValue], + ); return ( = ({ styles.topSection, roundTop && styles.roundTop, roundTop ? { marginTop: top } : { paddingTop: top }, + spacingStyle, { backgroundColor }, + incomingStyle, ]} > {children} @@ -108,6 +121,16 @@ const BottomSection: React.FC = ({ const minBottom = safeAreaBottom + extraYPadding; const totalBottom = typeof incomingBottom === 'number' ? minBottom + incomingBottom : minBottom; + const { selectResponsiveValue } = useCompactLayout({ + compactHeight: 760, + }); + const spacingStyle = useMemo( + () => ({ + paddingHorizontal: selectResponsiveValue(16, 20, 'width'), + paddingTop: selectResponsiveValue(18, 30, 'height'), + }), + [selectResponsiveValue], + ); let panelHeight: number | 'auto' = 'auto'; // set bottom section height to 38% of screen height @@ -129,7 +152,7 @@ const BottomSection: React.FC = ({ {children} diff --git a/app/src/screens/account/recovery/AccountRecoveryScreen.tsx b/app/src/screens/account/recovery/AccountRecoveryScreen.tsx index 2e2c31e41..cc37069b0 100644 --- a/app/src/screens/account/recovery/AccountRecoveryScreen.tsx +++ b/app/src/screens/account/recovery/AccountRecoveryScreen.tsx @@ -12,10 +12,12 @@ import { Title, } from '@selfxyz/mobile-sdk-alpha/components'; import { BackupEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import useHapticNavigation from '@/hooks/useHapticNavigation'; import RestoreAccountSvg from '@/images/icons/restore_account.svg'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; +import useCompactLayout from '@/hooks/useCompactLayout'; import { black, slate600, white } from '@/utils/colors'; const AccountRecoveryScreen: React.FC = () => { @@ -25,6 +27,30 @@ const AccountRecoveryScreen: React.FC = () => { nextScreen: 'SaveRecoveryPhrase', }, }); + const { selectResponsiveValues, getResponsiveHorizontalPadding } = useCompactLayout(); + const { bottom } = useSafeAreaInsets(); + + const { + iconSize, + iconPadding, + contentGap, + descriptionSize, + titleSize, + buttonStackGap, + buttonPaddingTop, + extraBottomPadding, + } = selectResponsiveValues({ + iconSize: { compact: 64, regular: 80 }, + iconPadding: { compact: '$4', regular: '$5' }, + contentGap: { compact: '$2', regular: '$2.5' }, + descriptionSize: { compact: 15, regular: 16 }, + titleSize: { compact: 26, regular: 32 }, + buttonStackGap: { compact: '$2', regular: '$2.5' }, + buttonPaddingTop: { compact: '$4', regular: '$6' }, + extraBottomPadding: { compact: 16, regular: 24 }, + }); + const horizontalPadding = getResponsiveHorizontalPadding({ percent: 0.06 }); + const bottomPadding = bottom + extraBottomPadding; return ( @@ -33,20 +59,28 @@ const AccountRecoveryScreen: React.FC = () => { borderColor={slate600} borderWidth="$1" borderRadius="$10" - padding="$5" + padding={iconPadding} > - + - - - Restore your Self account - + + + + Restore your Self account + + By continuing, you certify that this passport belongs to you and is not stolen or forged. - + { const { trackEvent } = useSelfClient(); const [mnemonic, setMnemonic] = useState(); const [restoring, setRestoring] = useState(false); + const { bottom } = useSafeAreaInsets(); + const { height: screenHeight } = useCompactLayout(); + const textAreaMinHeight = Math.max(160, screenHeight * 0.3); const onPaste = useCallback(async () => { const clipboard = (await Clipboard.getString()).trim(); if (ethers.Mnemonic.isValidMnemonic(clipboard)) { @@ -111,67 +122,79 @@ const RecoverWithPhraseScreen: React.FC = () => { ]); return ( - - - Your recovery phrase has 24 words. Enter the words in the correct order, - separated by spaces. - - -