632 lines
23 KiB
TypeScript
632 lines
23 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
Keyboard,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
ScrollView,
|
|
StatusBar,
|
|
StyleSheet,
|
|
Switch,
|
|
Text,
|
|
View,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import LinearGradient from 'react-native-linear-gradient';
|
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
|
import Animated, { FadeIn, FadeInUp } from 'react-native-reanimated';
|
|
import Ionicons from '@react-native-vector-icons/ionicons';
|
|
|
|
import { Button, InfoBanner, Input, Loader } from '../../../components';
|
|
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
|
import { useTheme } from '../../../theme';
|
|
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
|
|
import { useToast } from '../../../hooks/useToast';
|
|
import { haptics } from '../../../services/haptics/haptics';
|
|
import { biometrics } from '../../../services/security/biometrics';
|
|
import { authActions } from '../store/authSlice';
|
|
import {
|
|
selectBiometricReady,
|
|
selectLastProfile,
|
|
selectMpinLocked,
|
|
selectSignInContextLoaded,
|
|
selectSignInError,
|
|
selectSignInStatus,
|
|
} from '../store/authSelectors';
|
|
import {
|
|
loadSignInContext,
|
|
signIn,
|
|
signInAsAgent,
|
|
signInWithBiometrics,
|
|
} from '../store/authThunk';
|
|
import { MPIN_LENGTH, isValidMpin, isValidPhone } from '../utils/authValidation';
|
|
import { LoginHero } from '../components/LoginHero';
|
|
import { BiometricNode } from '../components/BiometricNode';
|
|
import { CodeInput } from '../components/CodeInput';
|
|
import { PreLoginActions } from '../components/PreLoginActions';
|
|
import { LoginFooter } from '../components/LoginFooter';
|
|
import { useBiometricPrompt } from '../hooks/useBiometricPrompt';
|
|
import type { AuthStackParamList } from '../../../navigation/types';
|
|
import type { LoginRole } from '../../../types/app';
|
|
import { LoginRoleTabs } from '../components/LoginRoleTabs';
|
|
import { FeatureKey } from '../../../constants/featureKeys';
|
|
import { FeatureGate, useFeature } from '../../../features';
|
|
|
|
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'Login'>>;
|
|
|
|
export default function LoginScreen({ navigation }: Props) {
|
|
const dispatch = useAppDispatch();
|
|
const theme = useTheme();
|
|
const toast = useToast();
|
|
const showApiError = useApiErrorToast();
|
|
const promptBiometric = useBiometricPrompt();
|
|
|
|
const lastProfile = useAppSelector(selectLastProfile);
|
|
const contextLoaded = useAppSelector(selectSignInContextLoaded);
|
|
const biometricReady = useAppSelector(selectBiometricReady);
|
|
const signInStatus = useAppSelector(selectSignInStatus);
|
|
const signInError = useAppSelector(selectSignInError);
|
|
const mpinLocked = useAppSelector(selectMpinLocked);
|
|
|
|
const [mobile, setMobile] = useState('');
|
|
const [mpin, setMpin] = useState('');
|
|
const [activeRole, setActiveRole] = useState<LoginRole>('User');
|
|
const [rememberForBiometrics, setRememberForBiometrics] = useState(false);
|
|
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
|
|
/** Set once the customer chooses the MPIN path over the biometric one. */
|
|
const [mpinRequested, setMpinRequested] = useState(false);
|
|
const [keyboardUp, setKeyboardUp] = useState(false);
|
|
/**
|
|
* The last failure, held for display on the form itself. The toast that
|
|
* `showApiError` raises is gone in a few seconds, and the effect below clears
|
|
* the MPIN boxes at the same time — so without this, a wrong MPIN looks like
|
|
* the app simply did nothing.
|
|
*/
|
|
const [formError, setFormError] = useState<string | undefined>();
|
|
|
|
const userRoleEnabled = useFeature(FeatureKey.AUTH_LOGIN_USER_ROLE);
|
|
const agentRoleEnabled = useFeature(FeatureKey.AUTH_LOGIN_AGENT_ROLE);
|
|
|
|
const isLoading = signInStatus === 'loading';
|
|
|
|
/**
|
|
* A profile stored on this device means registration already completed here,
|
|
* so the customer only owes us an MPIN — the number is not theirs to retype.
|
|
* The agent app has no stored profile, so it keeps the full form.
|
|
*/
|
|
const isReturningCustomer = activeRole === 'User' && Boolean(lastProfile);
|
|
|
|
useEffect(() => {
|
|
void biometrics.isAvailable().then(setBiometricsAvailable);
|
|
}, []);
|
|
|
|
// The public action row is pinned to the foot of the screen, which is exactly
|
|
// where the keyboard lands. Hiding it while the keyboard is up keeps it from
|
|
// being squeezed into a sliver above the keys.
|
|
useEffect(() => {
|
|
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
|
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
|
const shown = Keyboard.addListener(showEvent, () => setKeyboardUp(true));
|
|
const hidden = Keyboard.addListener(hideEvent, () => setKeyboardUp(false));
|
|
|
|
return () => {
|
|
shown.remove();
|
|
hidden.remove();
|
|
};
|
|
}, []);
|
|
|
|
// Several routes reach this screen after the store was wiped — an ordinary
|
|
// logout, or a stored session found dead at launch. Re-reading the remembered
|
|
// profile here covers all of them rather than one at a time.
|
|
useEffect(() => {
|
|
if (!contextLoaded) {
|
|
void dispatch(loadSignInContext());
|
|
}
|
|
// Mount-only: once loaded, re-running would loop.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [dispatch]);
|
|
|
|
// First launch is routed straight to registration by AuthNavigator, so this
|
|
// only catches the late case: a logout wiped the store and the re-read found
|
|
// no profile (app data cleared, or the device was unbound). By then the
|
|
// navigator is long since ready, so the action is safe to dispatch here.
|
|
useEffect(() => {
|
|
if (contextLoaded && !lastProfile && activeRole === 'User') {
|
|
navigation.replace('Register');
|
|
}
|
|
}, [activeRole, contextLoaded, lastProfile, navigation]);
|
|
|
|
useEffect(() => {
|
|
if (lastProfile) {
|
|
setMobile((current) => current || lastProfile.mobileNumber);
|
|
}
|
|
}, [lastProfile]);
|
|
|
|
// Keep the selected role on something that is actually offered — otherwise a
|
|
// disabled role could still be submitted via the default state.
|
|
useEffect(() => {
|
|
if (activeRole === 'User' && !userRoleEnabled && agentRoleEnabled) {
|
|
setActiveRole('Agent');
|
|
} else if (activeRole === 'Agent' && !agentRoleEnabled && userRoleEnabled) {
|
|
setActiveRole('User');
|
|
}
|
|
}, [activeRole, agentRoleEnabled, userRoleEnabled]);
|
|
|
|
// A locked profile has no in-app path at all, so it gets its own screen
|
|
// rather than a toast the user can dismiss and keep hammering the form.
|
|
useEffect(() => {
|
|
if (mpinLocked) {
|
|
navigation.navigate('MpinLocked');
|
|
}
|
|
}, [mpinLocked, navigation]);
|
|
|
|
useEffect(() => {
|
|
if (signInStatus !== 'failed' || !signInError) {
|
|
return;
|
|
}
|
|
|
|
// MPIN_LOCKED gets the dedicated screen above; a cancelled biometric
|
|
// prompt is a user decision, not a failure worth shouting about.
|
|
if (signInError.code === 'MPIN_LOCKED') {
|
|
return;
|
|
}
|
|
|
|
if (signInError.code === 'BIOMETRIC_CANCELLED') {
|
|
toast('Biometric sign-in cancelled', 'info', 'You can sign in with your MPIN instead.');
|
|
dispatch(authActions.clearSignInError());
|
|
return;
|
|
}
|
|
|
|
showApiError(signInError);
|
|
|
|
// A device that was never bound, or a profile with no MPIN yet, cannot sign
|
|
// in whatever the MPIN — registration is the only way forward.
|
|
if (signInError.code === 'DEVICE_NOT_BOUND' || signInError.code === 'MPIN_NOT_SET') {
|
|
navigation.navigate('Register');
|
|
} else {
|
|
// Every other failure leaves the customer on this screen, so the reason
|
|
// has to stay on this screen with them — and the MPIN field comes out,
|
|
// since a biometric failure needs a way through that is not biometrics.
|
|
setFormError(signInError.message);
|
|
setMpinRequested(true);
|
|
}
|
|
|
|
setMpin('');
|
|
dispatch(authActions.clearSignInError());
|
|
}, [dispatch, navigation, showApiError, signInError, signInStatus, toast]);
|
|
|
|
const mobileError = useMemo(() => {
|
|
if (mobile.length === 0 || isValidPhone(mobile)) {
|
|
return undefined;
|
|
}
|
|
|
|
return 'Enter the 10-digit number registered with your bank';
|
|
}, [mobile]);
|
|
|
|
const canSubmit = isValidPhone(mobile) && isValidMpin(mpin) && !isLoading;
|
|
|
|
// Only worth offering once a profile on this device has actually stored an
|
|
// MPIN behind the device prompt — otherwise the button can only ever fail.
|
|
const canUseBiometrics = activeRole === 'Agent' || biometricReady;
|
|
const biometricsGranted = useFeature(FeatureKey.AUTH_LOGIN_BIOMETRIC);
|
|
|
|
/**
|
|
* Biometrics lead only for a returning customer who has them ready: the
|
|
* device knows who they are, so one tap is the whole sign-in. Everyone else —
|
|
* an agent, or a customer with nothing enrolled — needs the form anyway, and
|
|
* hiding it behind a button would just add a tap to the only way in.
|
|
*/
|
|
const biometricsLead = canUseBiometrics && biometricsGranted && isReturningCustomer;
|
|
const formVisible = !biometricsLead || mpinRequested;
|
|
|
|
// Switching role changes which of the two paths applies, so the disclosure
|
|
// resets with it rather than leaving the form open from the previous role.
|
|
useEffect(() => {
|
|
setMpinRequested(false);
|
|
setFormError(undefined);
|
|
setMpin('');
|
|
}, [activeRole]);
|
|
|
|
const handleMobileChange = (value: string) => {
|
|
setFormError(undefined);
|
|
setMobile(value.replace(/\D/g, ''));
|
|
};
|
|
|
|
const handleMpinChange = (value: string) => {
|
|
// Retyping is the customer acting on the error, so the notice goes away as
|
|
// soon as they start — leaving it up would read as a second failure.
|
|
setFormError(undefined);
|
|
setMpin(value);
|
|
};
|
|
|
|
const handleSignIn = () => {
|
|
if (!canSubmit) {
|
|
return;
|
|
}
|
|
|
|
if (activeRole === 'Agent') {
|
|
// The field-agent app has no CBS backend behind it yet.
|
|
void dispatch(signInAsAgent({ mobileNumber: mobile }));
|
|
return;
|
|
}
|
|
|
|
void dispatch(
|
|
signIn({
|
|
mobileNumber: mobile,
|
|
mpin,
|
|
orgId: lastProfile?.orgId,
|
|
// This sign-in is the only place the MPIN is verified, so it is the
|
|
// only safe moment to store it for biometric use.
|
|
rememberForBiometrics: rememberForBiometrics && biometricsAvailable,
|
|
}),
|
|
);
|
|
};
|
|
|
|
const handleBiometricLogin = async () => {
|
|
haptics.selection();
|
|
|
|
if (activeRole === 'Agent') {
|
|
const confirmed = await promptBiometric();
|
|
|
|
if (!confirmed) {
|
|
toast('Biometric sign-in cancelled', 'info', 'You can sign in with your MPIN instead.');
|
|
return;
|
|
}
|
|
|
|
void dispatch(signInAsAgent({ mobileNumber: mobile || '0000000000' }));
|
|
return;
|
|
}
|
|
|
|
// Reading the stored MPIN raises the platform's own prompt, so there is no
|
|
// separate biometric check to run first.
|
|
void dispatch(signInWithBiometrics());
|
|
};
|
|
|
|
const handleRegisterAgain = () => {
|
|
haptics.selection();
|
|
dispatch(authActions.resetRegistration());
|
|
navigation.navigate('Register');
|
|
};
|
|
|
|
const handleUseMpin = () => {
|
|
haptics.selection();
|
|
setMpinRequested(true);
|
|
};
|
|
|
|
/**
|
|
* Indian mobile grouping is 5 + 5. Masking the first six digits and grouping
|
|
* what is left the same way keeps the number recognisable in the shape the
|
|
* customer knows it in, without printing it in full on a locked screen.
|
|
*/
|
|
const maskedMobile = lastProfile?.mobileNumber
|
|
? `+91 ••••• •${lastProfile.mobileNumber.slice(-4)}`
|
|
: undefined;
|
|
|
|
const gradient = (
|
|
<>
|
|
<LinearGradient
|
|
colors={theme.colors.brandGradient}
|
|
style={StyleSheet.absoluteFill}
|
|
// A vertical wash. Two of the three stops carry the brand navy itself,
|
|
// so the screen still reads as one colour rather than as a fade.
|
|
start={{ x: 0.5, y: 0 }}
|
|
end={{ x: 0.5, y: 1 }}
|
|
/>
|
|
{/* The wash is dark, so the status bar icons have to be light — the
|
|
opposite of every other screen in the app. */}
|
|
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
|
</>
|
|
);
|
|
|
|
// Deciding between the MPIN screen and registration needs the stored profile.
|
|
// Rendering either one first would flash the wrong screen.
|
|
if (!contextLoaded) {
|
|
return (
|
|
<View style={styles.flex}>
|
|
{gradient}
|
|
<SafeAreaView edges={['top']} style={[styles.flex, styles.centre]}>
|
|
<Loader />
|
|
</SafeAreaView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.flex}>
|
|
{gradient}
|
|
|
|
<SafeAreaView edges={['top', 'bottom']} style={styles.flex}>
|
|
<KeyboardAvoidingView
|
|
style={styles.flex}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
>
|
|
<ScrollView
|
|
contentContainerStyle={styles.scroll}
|
|
showsVerticalScrollIndicator={false}
|
|
keyboardShouldPersistTaps="handled"
|
|
keyboardDismissMode="interactive"
|
|
>
|
|
{/* ── brand lock-up ─────────────────────────────────────────── */}
|
|
<LoginHero />
|
|
|
|
{/* ── who is signing in ─────────────────────────────────────── */}
|
|
<LoginRoleTabs
|
|
activeRole={activeRole}
|
|
onRoleChange={setActiveRole}
|
|
disabled={isLoading}
|
|
/>
|
|
|
|
{/* ── greeting ──────────────────────────────────────────────── */}
|
|
<View style={styles.greeting}>
|
|
{isReturningCustomer && lastProfile?.fullName ? (
|
|
<>
|
|
<Text style={[theme.text.bodySmall, { color: theme.colors.onBrandSecondary }]}>
|
|
Welcome back
|
|
</Text>
|
|
<Text
|
|
numberOfLines={2}
|
|
style={[theme.text.h1, styles.centreText, { color: theme.colors.onBrandPrimary }]}
|
|
>
|
|
{lastProfile.fullName}
|
|
</Text>
|
|
{maskedMobile ? (
|
|
<Text style={[theme.text.code, { color: theme.colors.onBrandMuted }]}>
|
|
{maskedMobile}
|
|
</Text>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Text
|
|
style={[
|
|
theme.text.h1,
|
|
styles.centreText,
|
|
{ color: theme.colors.onBrandPrimary },
|
|
]}
|
|
>
|
|
{activeRole === 'Agent' ? 'Agent sign-in' : 'Welcome'}
|
|
</Text>
|
|
{/*
|
|
A returning customer whose name we do not hold yet still
|
|
gets told which profile they are signing into. That is the
|
|
device-rebind path: verify-otp stores the mobile number but
|
|
no name, because it does not return one — so without this
|
|
the screen asks for an MPIN and names nothing at all.
|
|
*/}
|
|
{maskedMobile ? (
|
|
<Text style={[theme.text.code, { color: theme.colors.onBrandMuted }]}>
|
|
{maskedMobile}
|
|
</Text>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</View>
|
|
|
|
{/* ── the way in ────────────────────────────────────────────── */}
|
|
{biometricsLead ? (
|
|
<Animated.View entering={FadeIn.duration(320)} style={styles.heroBlock}>
|
|
<BiometricNode
|
|
variant="hero"
|
|
onPress={handleBiometricLogin}
|
|
disabled={isLoading}
|
|
caption="Tap to sign in"
|
|
/>
|
|
</Animated.View>
|
|
) : null}
|
|
|
|
{formVisible ? (
|
|
<Animated.View entering={FadeInUp.duration(280)} style={styles.form}>
|
|
{!isReturningCustomer ? (
|
|
<Input
|
|
label="Registered mobile number"
|
|
prefix="+91"
|
|
keyboardType="number-pad"
|
|
maxLength={10}
|
|
value={mobile}
|
|
onChangeText={handleMobileChange}
|
|
placeholder="98765 43210"
|
|
error={mobileError}
|
|
editable={!isLoading}
|
|
/>
|
|
) : null}
|
|
|
|
<CodeInput
|
|
label="Enter MPIN"
|
|
value={mpin}
|
|
onChangeText={handleMpinChange}
|
|
length={MPIN_LENGTH}
|
|
secure
|
|
autoFocus={mpinRequested || isReturningCustomer}
|
|
editable={!isLoading}
|
|
onSubmit={handleSignIn}
|
|
/>
|
|
|
|
{formError ? <InfoBanner tone="danger" message={formError} /> : null}
|
|
|
|
{/* Offered only where it can actually be turned on: biometrics
|
|
enrolled, a customer sign-in, and nothing stored yet. */}
|
|
{activeRole === 'User' && biometricsAvailable && !biometricReady ? (
|
|
<View style={styles.rememberRow}>
|
|
<Text
|
|
style={[
|
|
theme.text.bodySmall,
|
|
styles.rememberLabel,
|
|
{ color: theme.colors.onBrandSecondary },
|
|
]}
|
|
>
|
|
Enable biometric sign-in next time
|
|
</Text>
|
|
<Switch
|
|
value={rememberForBiometrics}
|
|
onValueChange={setRememberForBiometrics}
|
|
disabled={isLoading}
|
|
accessibilityLabel="Enable biometric sign-in next time"
|
|
// The thumb stays white in both states. A navy thumb on
|
|
// the translucent-white track composites to roughly
|
|
// 1.7:1 against it and effectively disappears on the
|
|
// navy wash.
|
|
trackColor={{
|
|
false: theme.colors.onBrandSurfaceStrong,
|
|
true: theme.colors.success,
|
|
}}
|
|
thumbColor={theme.colors.onBrandPrimary}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
|
|
{/* White fill, navy label — `primary` here would be navy on
|
|
navy and disappear into the wash. */}
|
|
<Button
|
|
title="Sign in"
|
|
variant="inverse"
|
|
loading={isLoading}
|
|
disabled={!canSubmit}
|
|
onPress={handleSignIn}
|
|
/>
|
|
|
|
{/* An agent, and a customer whose biometrics are enrolled but
|
|
not yet bound to a stored MPIN, never see the hero target
|
|
above — so their biometric path lives here instead, or they
|
|
would have none at all. */}
|
|
{canUseBiometrics && biometricsGranted && !biometricsLead ? (
|
|
<>
|
|
<View style={styles.dividerRow}>
|
|
<View
|
|
style={[styles.divider, { backgroundColor: theme.colors.onBrandBorder }]}
|
|
/>
|
|
<Text style={[theme.text.eyebrow, { color: theme.colors.onBrandMuted }]}>
|
|
or sign in with
|
|
</Text>
|
|
<View
|
|
style={[styles.divider, { backgroundColor: theme.colors.onBrandBorder }]}
|
|
/>
|
|
</View>
|
|
|
|
<BiometricNode onPress={handleBiometricLogin} disabled={isLoading} />
|
|
</>
|
|
) : null}
|
|
</Animated.View>
|
|
) : (
|
|
<Button
|
|
title="Sign in with MPIN"
|
|
variant="onBrand"
|
|
onPress={handleUseMpin}
|
|
disabled={isLoading}
|
|
rightAccessory={
|
|
<Ionicons
|
|
name="chevron-forward"
|
|
size={16}
|
|
color={theme.colors.onBrandPrimary}
|
|
/>
|
|
}
|
|
style={styles.mpinSwitch}
|
|
/>
|
|
)}
|
|
|
|
{/* ── footnotes ─────────────────────────────────────────────── */}
|
|
<View style={styles.footnotes}>
|
|
<FeatureGate feature={FeatureKey.AUTH_REGISTER}>
|
|
<Text style={[theme.text.bodySmall, { color: theme.colors.onBrandSecondary }]}>
|
|
{isReturningCustomer ? 'Not you? ' : 'New to BankCBS? '}
|
|
<Text
|
|
accessibilityRole="link"
|
|
style={{ color: theme.colors.onBrandPrimary }}
|
|
onPress={handleRegisterAgain}
|
|
>
|
|
Register
|
|
</Text>
|
|
</Text>
|
|
</FeatureGate>
|
|
|
|
{/* There is no forgot-MPIN or reset flow in the API — by policy,
|
|
not by omission — so the app says so instead of offering a
|
|
dead link. */}
|
|
<Text
|
|
style={[theme.text.caption, styles.centreText, { color: theme.colors.onBrandMuted }]}
|
|
>
|
|
Forgot your MPIN? Visit your branch to have it reset.
|
|
</Text>
|
|
</View>
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
|
|
{/*
|
|
Both of these are hidden while the keyboard is up. They are the
|
|
least urgent things on the screen, and the MPIN field needs the
|
|
height more than a legal line does.
|
|
*/}
|
|
{!keyboardUp ? (
|
|
<View style={styles.actions}>
|
|
<PreLoginActions onOpenAccount={handleRegisterAgain} />
|
|
<LoginFooter />
|
|
</View>
|
|
) : null}
|
|
</SafeAreaView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
flex: {
|
|
flex: 1,
|
|
},
|
|
centre: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
centreText: {
|
|
textAlign: 'center',
|
|
},
|
|
scroll: {
|
|
flexGrow: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 26,
|
|
paddingHorizontal: 24,
|
|
paddingVertical: 28,
|
|
},
|
|
greeting: {
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
},
|
|
heroBlock: {
|
|
alignItems: 'center',
|
|
paddingVertical: 4,
|
|
},
|
|
form: {
|
|
width: '100%',
|
|
gap: 16,
|
|
},
|
|
mpinSwitch: {
|
|
// Narrower than the full gutter width, so the pill reads as one option
|
|
// among several rather than as the screen's committed action.
|
|
width: '88%',
|
|
},
|
|
rememberRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 12,
|
|
},
|
|
rememberLabel: {
|
|
flex: 1,
|
|
},
|
|
dividerRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 12,
|
|
},
|
|
divider: {
|
|
flex: 1,
|
|
height: 1,
|
|
},
|
|
footnotes: {
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
},
|
|
actions: {
|
|
gap: 14,
|
|
paddingBottom: 8,
|
|
},
|
|
});
|