add new home screen (#1019)

* add new home screen

* fix typing issue

* yarn nice
This commit is contained in:
turnoffthiscomputer
2025-09-09 01:42:33 +02:00
committed by GitHub
parent ff678b359a
commit 78b2341091
22 changed files with 1979 additions and 126 deletions

View File

@@ -13,9 +13,10 @@ import type { SelfApp } from '@selfxyz/common/utils/appType';
import { NavBar } from '@/components/NavBar/BaseNavBar';
import ActivityIcon from '@/images/icons/activity.svg';
import ScanIcon from '@/images/icons/qr_scan.svg';
import SettingsIcon from '@/images/icons/settings.svg';
import { useSelfAppStore } from '@/stores/selfAppStore';
import { black, neutral400, white } from '@/utils/colors';
import { black, charcoal, neutral400, slate50, white } from '@/utils/colors';
import { extraYPadding } from '@/utils/constants';
import { buttonTap } from '@/utils/haptic';
@@ -70,44 +71,56 @@ export const HomeNavBar = (props: NativeStackHeaderProps) => {
};
return (
<NavBar.Container
backgroundColor={black}
backgroundColor={slate50}
barStyle={'light'}
padding={16}
padding={8}
justifyContent="space-between"
paddingTop={Math.max(insets.top, 15) + extraYPadding}
>
<NavBar.LeftAction
component={
<Button
size={'$3'}
unstyled
icon={
<ActivityIcon width={'24'} height={'100%'} color={neutral400} />
}
/>
<XStack alignItems="center">
<Button
size={'$3'}
unstyled
icon={<ScanIcon width={'24'} height={'100%'} color={charcoal} />}
onPress={() => {
buttonTap();
props.navigation.navigate('QRCodeViewFinder');
}}
/>
<Button
size={'$3'}
unstyled
marginTop={10}
icon={<ClipboardIcon size={24} color={charcoal} />}
onPress={handleConsumeToken}
/>
</XStack>
}
// disable icon click for now
onPress={() => {
buttonTap();
props.navigation.navigate('ProofHistory');
}}
/>
<NavBar.Title size="large" color={white}>
<NavBar.Title size="large" color={black}>
{props.options.title}
</NavBar.Title>
<NavBar.RightAction
component={
<XStack alignItems="center" gap={10}>
<ClipboardIcon
size={24}
color={neutral400}
onPress={handleConsumeToken}
<XStack alignItems="center">
<Button
size={'$3'}
unstyled
icon={
<ActivityIcon width={'24'} height={'100%'} color={charcoal} />
}
onPress={() => {
buttonTap();
props.navigation.navigate('ProofHistory');
}}
/>
<Button
size={'$3'}
unstyled
icon={
<SettingsIcon width={'24'} height={'100%'} color={neutral400} />
<SettingsIcon width={'24'} height={'100%'} color={charcoal} />
}
onPress={() => {
buttonTap();

View File

@@ -0,0 +1,56 @@
// 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 React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Button, Text, View, XStack } from 'tamagui';
import type { NativeStackHeaderProps } from '@react-navigation/native-stack';
import { ChevronLeft } from '@tamagui/lucide-icons';
import { NavBar } from '@/components/NavBar/BaseNavBar';
import { black, charcoal, slate50 } from '@/utils/colors';
import { extraYPadding } from '@/utils/constants';
import { buttonTap } from '@/utils/haptic';
export const IdDetailsNavBar = (props: NativeStackHeaderProps) => {
const insets = useSafeAreaInsets();
const backButtonWidth = 50; // Adjusted for text
return (
<NavBar.Container
backgroundColor={slate50}
barStyle={'light'}
justifyContent="space-between"
paddingTop={Math.max(insets.top, 15) + extraYPadding}
>
<NavBar.LeftAction
component={
<Button
unstyled
marginLeft={'$3.5'}
padding={'$3'}
width={'$10'}
onPress={() => {
buttonTap();
props.navigation.goBack();
}}
>
<Text color={charcoal} fontSize={17} fontWeight="bold">
Done
</Text>
</Button>
}
/>
<NavBar.Title size="large" color={black}>
{props.options.title}
</NavBar.Title>
<NavBar.RightAction
component={
// Spacer to balance the back button and center the title
<View style={{ width: backButtonWidth }} />
}
/>
</NavBar.Container>
);
};

View File

@@ -4,4 +4,5 @@
export { DefaultNavBar } from '@/components/NavBar/DefaultNavBar';
export { HomeNavBar } from '@/components/NavBar/HomeNavBar';
export { IdDetailsNavBar } from '@/components/NavBar/IdDetailsNavBar';
export { ProgressNavBar } from '@/components/NavBar/ProgressNavBar';

View File

@@ -0,0 +1,578 @@
// 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 React, { useCallback, useState } from 'react';
import { Dimensions, Pressable } from 'react-native';
import { SvgXml } from 'react-native-svg';
import { Button, Image, Separator, Text, XStack, YStack } from 'tamagui';
import { useFocusEffect } from '@react-navigation/native';
import {
attributeToPosition,
attributeToPosition_ID,
formatMrz,
PassportData,
} from '@selfxyz/common/dist/esm';
import { pad } from '@selfxyz/common/dist/esm/src/utils/passports/passport';
import EPassport from '@/images/icons/epassport.svg';
import LogoGray from '@/images/logo_gray.svg';
import LogoInversed from '@/images/logo_inversed.svg';
import { usePassport } from '@/providers/passportDataProvider';
import {
black,
blue600,
green500,
red500,
slate50,
slate100,
slate300,
slate400,
slate500,
white,
} from '@/utils/colors';
import { dinot, plexMono } from '@/utils/fonts';
// Import the logo SVG as a string
const logoSvg = `<svg width="47" height="46" viewBox="0 0 47 46" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.7814 13.2168C12.7814 12.7057 13.1992 12.2969 13.7214 12.2969H30.0017L42.5676 0H11.2408L0 11.0001V29.0973H12.7814V13.2104V13.2168Z" fill="white"/>
<path d="M34.2186 16.8515V32.3552C34.2186 32.8663 33.8008 33.2751 33.2786 33.2751H17.4357L4.43236 46H35.7592L47 34.9999V16.8579H34.2186V16.8515Z" fill="white"/>
<path d="M28.9703 17.6525H18.0362V28.3539H28.9703V17.6525Z" fill="#00FFB6"/>
</svg>`;
interface IdCardLayoutAttributes {
idDocument: PassportData;
selected: boolean;
hidden: boolean;
}
// This layout should be fully adaptative. I should perfectly fit in any screen size.
// the font size should adapt according to the size available to fit perfectly.
// only svg are allowed.
// each element size should be determined as % of the screen or the parent element
// the border radius should be adaptative too, as well as the padding
// this is the v0 of this component so we should only use placholders for now, no need to pass the real passport data as parameters.
const IdCardLayout: React.FC<IdCardLayoutAttributes> = ({
idDocument,
selected,
hidden,
}) => {
// Function to mask MRZ characters except '<' and spaces
const maskMrzValue = (text: string): string => {
return text.replace(/./g, 'X');
};
// Get screen dimensions for adaptive sizing
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
// Calculate adaptive sizes based on screen dimensions
// Reduce width slightly to account for horizontal margins (8px each side = 16px total)
const cardWidth = screenWidth * 0.95 - 16; // 90% of screen width minus margin space
const cardHeight = selected ? cardWidth * 0.645 : cardWidth * 0.645 * 0.3; // ID card aspect ratio (roughly 1.6:1)
const borderRadius = cardWidth * 0.04; // 4% of card width
const padding = cardWidth * 0.035; // 4% of card width
const fontSize = {
large: cardWidth * 0.045,
medium: cardWidth * 0.032,
small: cardWidth * 0.028,
xsmall: cardWidth * 0.022,
};
// Image dimensions (standard ID photo ratio)
const imageSize = {
width: cardWidth * 0.2, // 25% of card width
height: cardWidth * 0.29, // ID photo aspect ratio
};
// Shared left offset for content that should align with the start of the attributes block
const contentLeftOffset = imageSize.width + padding;
return (
// Container wrapper to handle shadow space properly
<YStack
width="100%" // Add space for horizontal margins
alignItems="center"
justifyContent="center"
>
<YStack
width={cardWidth}
height={cardHeight}
backgroundColor={white}
borderRadius={borderRadius}
borderWidth={0.75}
borderColor={'#E0E0E0'}
padding={padding}
// Improved shadow configuration for better visibility and containment
shadowColor={black}
shadowOffset={{ width: 0, height: 2 }}
shadowOpacity={0.1}
shadowRadius={4}
elevation={4}
// Add margins to provide space for shadow bleeding
marginBottom={8}
justifyContent="center"
>
{/* Header Section */}
<XStack>
<XStack alignItems="center">
<EPassport
width={fontSize.large * 3}
height={fontSize.large * 3 * 0.617}
/>
<YStack marginLeft={imageSize.width - fontSize.large * 3}>
<Text
fontWeight="bold"
fontFamily={dinot}
fontSize={fontSize.large * 1.4}
color="black"
>
{idDocument.documentCategory === 'passport'
? 'Passport'
: 'ID Card'}
</Text>
<Text
fontSize={fontSize.small}
color={'#9193A2'}
fontFamily={dinot}
>
Verified{' '}
{idDocument.documentCategory === 'passport'
? 'Biometric Passport'
: ' Biometric ID Card'}
</Text>
</YStack>
</XStack>
<XStack flex={1} justifyContent="flex-end">
{idDocument.mock && (
<YStack
marginTop={padding / 4}
borderWidth={1}
borderColor={slate300}
borderRadius={100}
paddingHorizontal={padding / 2}
alignSelf="flex-start"
backgroundColor={slate100}
paddingVertical={padding / 8}
>
<Text
fontSize={fontSize.xsmall}
color={slate400}
fontFamily={dinot}
letterSpacing={fontSize.xsmall * 0.15}
>
DEVELOPER
</Text>
</YStack>
)}
</XStack>
</XStack>
{selected && (
<Separator
backgroundColor={'#E0E0E0'}
height={1}
width={cardWidth - 1}
marginLeft={-padding}
marginTop={padding}
/>
)}
{/* Main Content Section */}
{selected && (
<XStack height="60%" paddingVertical={padding}>
{/* Person Image */}
<YStack
width={imageSize.width}
height={imageSize.height}
backgroundColor="#F5F5F5"
borderRadius={borderRadius * 0.5}
justifyContent="center"
alignItems="center"
marginRight={padding}
opacity={hidden ? 0.3 : 1}
>
<SvgXml
xml={logoSvg}
width={imageSize.width * 0.6}
height={imageSize.height * 0.6}
/>
</YStack>
{/* ID Attributes */}
<YStack
flex={1}
justifyContent="space-between"
height={imageSize.height}
>
<XStack flex={1} gap={padding * 0.3}>
<YStack flex={1}>
<IdAttribute
name="TYPE"
value={
idDocument.documentCategory === 'passport'
? 'PASSPORT'
: 'ID CARD'
}
maskValue={
idDocument.documentCategory === 'passport'
? 'PASSPORT'
: 'ID CARD'
}
hidden={hidden}
/>
</YStack>
<YStack flex={1}>
<IdAttribute
name="CODE"
value={idDocument.mock ? 'SELF DEV' : 'SELF ID'}
maskValue={idDocument.mock ? 'SELF DEV' : 'SELF ID'}
hidden={hidden}
/>
</YStack>
<YStack flex={1}>
<IdAttribute
name="DOC NO."
value={
getPassportAttributes(
idDocument.mrz,
idDocument.documentCategory,
).passNoSlice
}
maskValue="XX-XXXXXXX"
hidden={hidden}
/>
</YStack>
</XStack>
<XStack flex={1} gap={padding * 0.3}>
<YStack flex={1}>
<IdAttribute
name="SURNAME"
value={getNameAndSurname(
getPassportAttributes(
idDocument.mrz,
idDocument.documentCategory,
).nameSlice,
).surname.join(' ')}
maskValue="XXXXXXXX"
hidden={hidden}
/>
</YStack>
<YStack flex={1}>
<IdAttribute
name="NAME"
value={getNameAndSurname(
getPassportAttributes(
idDocument.mrz,
idDocument.documentCategory,
).nameSlice,
).names.join(' ')}
maskValue="XXXXX"
hidden={hidden}
/>
</YStack>
<YStack flex={1}>
<IdAttribute
name="SEX"
value={
getPassportAttributes(
idDocument.mrz,
idDocument.documentCategory,
).sexSlice
}
maskValue="X"
hidden={hidden}
/>
</YStack>
</XStack>
<XStack flex={1} gap={padding * 0.3}>
<YStack flex={1}>
<IdAttribute
name="NATIONALITY"
value={
getPassportAttributes(
idDocument.mrz,
idDocument.documentCategory,
).nationalitySlice
}
maskValue="XXX"
hidden={hidden}
/>
</YStack>
<YStack flex={1}>
<IdAttribute
name="DOB"
value={formatDateFromYYMMDD(
getPassportAttributes(
idDocument.mrz,
idDocument.documentCategory,
).dobSlice,
)}
maskValue="XX/XX/XXXX"
hidden={hidden}
/>
</YStack>
<YStack flex={1}>
<IdAttribute
name="EXPIRY DATE"
value={formatDateFromYYMMDD(
getPassportAttributes(
idDocument.mrz,
idDocument.documentCategory,
).expiryDateSlice,
true,
)}
maskValue="XX/XX/XXXX"
hidden={hidden}
/>
</YStack>
</XStack>
<XStack flex={1} gap={padding * 0.3}>
<YStack flex={1}>
<IdAttribute
name="AUTHORITY"
value={
getPassportAttributes(
idDocument.mrz,
idDocument.documentCategory,
).issuingStateSlice
}
maskValue="XXX"
hidden={hidden}
/>
</YStack>
<YStack flex={1} />
<YStack flex={1} />
</XStack>
</YStack>
</XStack>
)}
{/* Footer Section - MRZ */}
{selected && (
<XStack
alignItems="center"
backgroundColor={slate100}
borderRadius={borderRadius / 3}
paddingHorizontal={padding / 2}
paddingVertical={padding / 4}
>
{/* Fixed-width spacer to align MRZ content with the attributes block */}
<XStack width={contentLeftOffset} alignItems="center">
<LogoGray width={fontSize.large} height={fontSize.large} />
</XStack>
<YStack marginLeft={-padding / 2}>
{idDocument.documentCategory === 'passport' ? (
// Passport: 2 lines, 88 chars total (44 chars each)
<>
<Text
fontSize={fontSize.xsmall}
letterSpacing={fontSize.xsmall * 0.1}
fontFamily={plexMono}
color={slate400}
>
{hidden
? maskMrzValue(idDocument.mrz.slice(0, 44))
: idDocument.mrz.slice(0, 44)}
</Text>
<Text
fontSize={fontSize.xsmall}
letterSpacing={fontSize.xsmall * 0.1}
fontFamily={plexMono}
color={slate400}
>
{hidden
? maskMrzValue(idDocument.mrz.slice(44, 88))
: idDocument.mrz.slice(44, 88)}
</Text>
</>
) : (
// ID Card: 3 lines, 90 chars total (30 chars each)
<>
<Text
fontSize={fontSize.xsmall}
letterSpacing={fontSize.xsmall * 0.44}
fontFamily={plexMono}
color={slate400}
>
{hidden
? maskMrzValue(idDocument.mrz.slice(0, 30))
: idDocument.mrz.slice(0, 30)}
</Text>
<Text
fontSize={fontSize.xsmall}
letterSpacing={fontSize.xsmall * 0.44}
fontFamily={plexMono}
color={slate400}
>
{hidden
? maskMrzValue(idDocument.mrz.slice(30, 60))
: idDocument.mrz.slice(30, 60)}
</Text>
<Text
fontSize={fontSize.xsmall}
letterSpacing={fontSize.xsmall * 0.44}
fontFamily={plexMono}
color={slate400}
>
{hidden
? maskMrzValue(idDocument.mrz.slice(60, 90))
: idDocument.mrz.slice(60, 90)}
</Text>
</>
)}
</YStack>
</XStack>
)}
</YStack>
</YStack>
);
};
// Interface for IdAttribute props
interface IdAttributeProps {
name: string;
value: string;
maskValue: string;
hidden?: boolean;
}
// This layout should be fully adaptative. I should perfectly fit in any screen size.
// the font size should adapt according to the size available to fit perfectly.
// only svg are allowed.
// each element size should be determined as % of the screen or the parent element
const IdAttribute: React.FC<IdAttributeProps> = ({
name,
value,
maskValue,
hidden = false,
}) => {
const { width: screenWidth } = Dimensions.get('window');
const fontSize = {
label: screenWidth * 0.024,
value: screenWidth * 0.02,
};
const displayValue = hidden ? maskValue : value;
return (
<YStack>
<Text
fontWeight="bold"
fontSize={fontSize.label}
color={slate500}
fontFamily={dinot}
>
{name}
</Text>
<Text fontSize={fontSize.value} color={slate400} fontFamily={dinot}>
{displayValue}
</Text>
</YStack>
);
};
export default IdCardLayout;
function getPassportAttributes(mrz: string, documentCategory: string) {
const isPassportType = documentCategory === 'passport';
const attributePositions = isPassportType
? attributeToPosition
: attributeToPosition_ID;
const nameSlice = mrz.slice(
attributePositions.name[0],
attributePositions.name[1],
);
const dobSlice = mrz.slice(
attributePositions.date_of_birth[0],
attributePositions.date_of_birth[1] + 1,
);
const yobSlice = mrz.slice(
attributePositions.date_of_birth[0],
attributePositions.date_of_birth[0] + 1,
);
const issuingStateSlice = mrz.slice(
attributePositions.issuing_state[0],
attributePositions.issuing_state[1] + 1,
);
const nationalitySlice = mrz.slice(
attributePositions.nationality[0],
attributePositions.nationality[1] + 1,
);
const passNoSlice = mrz.slice(
attributePositions.passport_number[0],
attributePositions.passport_number[1] + 1,
);
const sexSlice = mrz.slice(
attributePositions.gender[0],
attributePositions.gender[1] + 1,
);
const expiryDateSlice = mrz.slice(
attributePositions.expiry_date[0],
attributePositions.expiry_date[1] + 1,
);
return {
nameSlice,
dobSlice,
yobSlice,
issuingStateSlice,
nationalitySlice,
passNoSlice,
sexSlice,
expiryDateSlice,
isPassportType,
};
}
function getNameAndSurname(nameSlice: string) {
// Split by double << to separate surname from names
const parts = nameSlice.split('<<');
if (parts.length < 2) {
return { surname: [], names: [] };
}
// First part is surname, second part contains names separated by single <
const surname = parts[0].replace(/</g, '').trim();
const namesString = parts[1];
// Split names by single < and filter out empty strings
const names = namesString.split('<').filter(name => name.length > 0);
return {
surname: surname ? [surname] : [],
names: names[0] ? [names[0]] : [],
};
}
function formatDateFromYYMMDD(
dateString: string,
isExpiry: boolean = false,
): string {
if (dateString.length !== 6) {
return dateString;
}
const yy = parseInt(dateString.substring(0, 2), 10);
const mm = dateString.substring(2, 4);
const dd = dateString.substring(4, 6);
const currentYear = new Date().getFullYear();
const century = Math.floor(currentYear / 100) * 100;
let year = century + yy;
if (isExpiry) {
// For expiry: if year is in the past, assume next century
if (year < currentYear) {
year += 100;
}
} else {
// For birth: if year is in the future, assume previous century
if (year > currentYear) {
year -= 100;
}
}
return `${dd}/${mm}/${year}`;
}