- Positive Pay on the 12 Oct /pps contract (account number, not account ID): OTP enrollment, cheque submit, saved cheques, detail and stop payment. Full flow passed on the phone against mobile-banking-backend.lincpay. - Statement & Mini Statement guide v1.0 (14 Oct): mini statement JSON on Home and the Statement screen; dated PDF export with the password rule. - Onboarding slides and splash screen, Inter font, shared Icon and TypewriterText components, nearby branches via OpenStreetMap. - Every feature switched on; the 14 Oct switch set is kept in featureConfig.stage-2026-10-14.ts. Loans run on demo data until the backend has /loans routes. - npm run android wraps run-android for shells spawned from VS Code. - OpenProject work packages updated to 14-10-2026 status, with screenshots of Positive Pay, statement, loans and cards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
711 lines
25 KiB
TypeScript
711 lines
25 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
AppState,
|
|
KeyboardAvoidingView,
|
|
Linking,
|
|
Platform,
|
|
Pressable,
|
|
ScrollView,
|
|
StatusBar,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
|
import { Icon } from '../../../components/Icon';
|
|
import LinearGradient from 'react-native-linear-gradient';
|
|
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
|
|
|
import { Button, InfoBanner, Input } from '../../../components';
|
|
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
|
import { useTheme } from '../../../theme';
|
|
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
|
|
import { isValidPhone } from '../../../utils/validation';
|
|
import { simCard, type SimInfo, type SimSubscription } from '../../../services/device/simCard';
|
|
import { BrandLogo } from '../components/BrandLogo';
|
|
import { SimChooser } from '../components/SimChooser';
|
|
import { authActions } from '../store/authSlice';
|
|
import { selectLastProfile, selectRegistration } from '../store/authSelectors';
|
|
import { rememberRegisteredProfile, startRegistration } from '../store/authThunk';
|
|
import type { AuthStackParamList } from '../../../navigation/types';
|
|
|
|
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'Register'>>;
|
|
|
|
/**
|
|
* Three short marks of what this flow is, in the shape a bank's onboarding
|
|
* screen usually carries them.
|
|
*
|
|
* Each one is literally true of this app, which is the only reason it is
|
|
* allowed on the screen: the MPIN really is RSA-OAEP encrypted before it leaves
|
|
* the handset, the number really is read off the SIM when the phone will share
|
|
* it, and the profile really is bound to this device. Nothing here claims a
|
|
* certification, an audit, or a regulator — those would be invented, and an
|
|
* invented security claim on a bank's sign-up screen is a straightforward lie
|
|
* to a customer.
|
|
*/
|
|
const TRUST_MARKS = [
|
|
{ icon: 'shield-checkmark' as const, label: 'Secure' },
|
|
{ icon: 'flash' as const, label: 'Seamless' },
|
|
{ icon: 'phone-portrait' as const, label: 'Trusted device' },
|
|
];
|
|
|
|
export default function RegisterScreen({ navigation }: Props) {
|
|
const dispatch = useAppDispatch();
|
|
const theme = useTheme();
|
|
const showApiError = useApiErrorToast();
|
|
const registration = useAppSelector(selectRegistration);
|
|
const lastProfile = useAppSelector(selectLastProfile);
|
|
|
|
const [sim, setSim] = useState<SimInfo | null>(null);
|
|
/** Which SIM's code request is in flight, so only that row spins. */
|
|
const [sendingId, setSendingId] = useState<number | null>(null);
|
|
const [detecting, setDetecting] = useState(true);
|
|
const [submitted, setSubmitted] = useState(false);
|
|
/** Bumped to re-read the SIMs. Never prompts — see the mount effect. */
|
|
const [attempt, setAttempt] = useState(0);
|
|
/**
|
|
* Android has stopped showing the permission dialog for this app. Asking
|
|
* again would return instantly and change nothing, so the only honest next
|
|
* step is app settings.
|
|
*/
|
|
const [permissionBlocked, setPermissionBlocked] = useState(false);
|
|
/**
|
|
* The typed-number fallback.
|
|
*
|
|
* Reading the SIM covers the common case, but a carrier that never wrote the
|
|
* MSISDN onto the card, a denied permission and a phone with no SIM in it
|
|
* each ended the flow outright — the screen said to contact the bank,
|
|
* because there was nowhere to put a number. It can be typed now, so none of
|
|
* those three is a dead end.
|
|
*/
|
|
const [manualOpen, setManualOpen] = useState(false);
|
|
const [manualNumber, setManualNumber] = useState('');
|
|
|
|
const isLoading = registration.status === 'loading';
|
|
|
|
const sims = sim?.sims ?? [];
|
|
// A line whose number the carrier never provisioned cannot be tapped — there
|
|
// is nothing to send from it. Typing the number is the way past that.
|
|
const tappable = sims.filter((entry) => entry.phoneNumber);
|
|
|
|
/**
|
|
* Reads on mount, and never prompts here.
|
|
*
|
|
* This used to call a `detect()` that raised the system permission dialog the
|
|
* instant the screen appeared — before the customer had read a word of what
|
|
* the screen was for, which is precisely how an app trains people to refuse.
|
|
* The prompt now belongs to a button the customer presses knowing what it
|
|
* will do.
|
|
*/
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
setDetecting(true);
|
|
|
|
void simCard.read().then((info) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
setSim(info);
|
|
setDetecting(false);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [attempt]);
|
|
|
|
// Coming back from app settings is the one case where the answer changes
|
|
// while this screen is mounted but nothing here was touched. Re-reading on
|
|
// foreground is what makes the settings round-trip actually finish.
|
|
useEffect(() => {
|
|
const subscription = AppState.addEventListener('change', (state) => {
|
|
if (state === 'active') {
|
|
setAttempt((value) => value + 1);
|
|
}
|
|
});
|
|
|
|
return () => subscription.remove();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!submitted) {
|
|
return;
|
|
}
|
|
|
|
if (registration.status === 'succeeded' && registration.otpSent) {
|
|
setSubmitted(false);
|
|
setSendingId(null);
|
|
navigation.navigate('VerifyOtp');
|
|
return;
|
|
}
|
|
|
|
if (registration.status === 'failed' && registration.error) {
|
|
setSubmitted(false);
|
|
setSendingId(null);
|
|
showApiError(registration.error);
|
|
|
|
// Registration is already complete for this number — what the customer
|
|
// wants is sign-in, not another OTP.
|
|
if (
|
|
registration.error.code === 'ALREADY_REGISTERED' ||
|
|
registration.error.code === 'MPIN_ALREADY_SET'
|
|
) {
|
|
// Recorded before the reset clears the number, and before the navigate
|
|
// below: sign-in reads the stored profile to tell a device that has
|
|
// completed registration from one that never has, and without it this
|
|
// number would be sent straight back here for another OTP.
|
|
void dispatch(
|
|
rememberRegisteredProfile({
|
|
orgId: registration.orgId,
|
|
mobileNumber: registration.mobileNumber,
|
|
}),
|
|
);
|
|
dispatch(authActions.resetRegistration());
|
|
navigation.navigate('Login');
|
|
}
|
|
}
|
|
}, [dispatch, navigation, registration, showApiError, submitted]);
|
|
|
|
/** One tap sends the code — there is no separate submit step any more. */
|
|
const handleSelectSim = (chosen: SimSubscription) => {
|
|
const number = chosen.phoneNumber;
|
|
|
|
if (!number || isLoading) {
|
|
return;
|
|
}
|
|
|
|
setSendingId(chosen.subscriptionId);
|
|
setSubmitted(true);
|
|
// orgId is fixed for this build and comes from config, so there is no field
|
|
// for it. The chosen SIM is not sent either: the API takes a number alone.
|
|
void dispatch(startRegistration({ mobileNumber: number }));
|
|
};
|
|
|
|
// Ten digits or nothing — the same contract the SIM read normalises to, so a
|
|
// typed number and a read one reach the API in one shape.
|
|
const manualDigits = manualNumber.replace(/D/g, '');
|
|
|
|
/** Silent until there is something to be wrong about, as sign-in is. */
|
|
const manualError = (() => {
|
|
if (manualDigits.length === 0 || isValidPhone(manualDigits)) {
|
|
return undefined;
|
|
}
|
|
|
|
return 'Enter the 10-digit number your bank has on file.';
|
|
})();
|
|
|
|
const handleManualChange = (value: string) => {
|
|
setManualNumber(value.replace(/D/g, ''));
|
|
};
|
|
|
|
const handleManualSubmit = () => {
|
|
if (!isValidPhone(manualDigits) || isLoading) {
|
|
return;
|
|
}
|
|
|
|
// Left null on purpose: no SIM row is sending, so none of them should spin.
|
|
setSendingId(null);
|
|
setSubmitted(true);
|
|
void dispatch(startRegistration({ mobileNumber: manualDigits }));
|
|
};
|
|
|
|
/**
|
|
* Why there is nothing to tap, and what the customer can do about it. Null
|
|
* whenever at least one line carries a readable number.
|
|
*/
|
|
const blocker = (() => {
|
|
if (detecting || !sim || tappable.length > 0) {
|
|
return null;
|
|
}
|
|
|
|
if (!sim.hasPermission) {
|
|
return {
|
|
icon: 'lock-closed-outline' as const,
|
|
title: 'Allow phone access',
|
|
text: 'Your number is read straight from the SIM, so you never have to type it. Nothing else on this phone is read.',
|
|
action: 'Allow phone access',
|
|
kind: 'permission' as const,
|
|
};
|
|
}
|
|
|
|
/*
|
|
* The slots listed but no number came back, and the reason is a permission
|
|
* this app has not been given — not the carrier.
|
|
*
|
|
* This case had no branch at all before, so it fell through to the carrier
|
|
* message below and told the customer to visit a branch. On Android 13 and
|
|
* up it is the likeliest failure of the three, because READ_PHONE_NUMBERS
|
|
* is a separate grant from READ_PHONE_STATE.
|
|
*/
|
|
if (!sim.canReadNumbers) {
|
|
return {
|
|
icon: 'key-outline' as const,
|
|
title: 'One more permission needed',
|
|
text: 'This phone keeps the SIM number behind its own permission. Allow it and your number is filled in for you.',
|
|
action: 'Allow and continue',
|
|
kind: 'permission' as const,
|
|
};
|
|
}
|
|
|
|
if (!sim.hasSim) {
|
|
return {
|
|
icon: 'phone-portrait-outline' as const,
|
|
title: 'No SIM found',
|
|
text: 'Put the SIM whose number your bank has into this phone, then check again.',
|
|
action: 'Check again',
|
|
kind: 'retry' as const,
|
|
};
|
|
}
|
|
|
|
// The ordinary case: a SIM is present, the app may read numbers, and the
|
|
// carrier still never wrote one onto the SIM — so there is nothing to read
|
|
// and nowhere to send a code.
|
|
return {
|
|
icon: 'information-circle-outline' as const,
|
|
title: 'Your carrier does not share this number',
|
|
text:
|
|
sims.length > 1
|
|
? 'Neither line lets apps read its number, so there is nothing to tap. Type the number in and the code still comes through.'
|
|
: 'This SIM does not let apps read its number, so there is nothing to tap. Type the number in and the code still comes through.',
|
|
action: 'Check again',
|
|
kind: 'retry' as const,
|
|
};
|
|
})();
|
|
|
|
/** True once Android has stopped offering the dialog for a permission ask. */
|
|
const needsSettings = blocker?.kind === 'permission' && permissionBlocked;
|
|
|
|
const handleBlockerAction = useCallback(async () => {
|
|
if (blocker?.kind !== 'permission') {
|
|
setAttempt((value) => value + 1);
|
|
return;
|
|
}
|
|
|
|
if (permissionBlocked) {
|
|
// The AppState listener above re-reads when the customer comes back.
|
|
void Linking.openSettings();
|
|
return;
|
|
}
|
|
|
|
const { outcome, info } = await simCard.requestAndRead();
|
|
|
|
setSim(info);
|
|
|
|
if (outcome === 'blocked') {
|
|
setPermissionBlocked(true);
|
|
}
|
|
}, [blocker?.kind, permissionBlocked]);
|
|
|
|
const numberError = registration.error?.fieldErrors?.mobileNumber;
|
|
|
|
const manualLink = (
|
|
<Pressable onPress={() => setManualOpen(true)} hitSlop={6}>
|
|
<Text
|
|
style={[theme.text.bodySmall, styles.manualLink, { color: theme.colors.onBrandSecondary }]}
|
|
>
|
|
Number not showing?{' '}
|
|
<Text style={{ color: theme.colors.onBrandPrimary }}>Type it instead</Text>
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
|
|
/*
|
|
A solid surface, not the translucent `onBrandSurface` the blocker sits on.
|
|
The field inside carries the design system's light-context colours — a
|
|
dark-grey label over half-transparent white over navy is not readable — so
|
|
the card brings its own light surface, exactly as the InfoBanner does.
|
|
*/
|
|
const manualCard = (
|
|
<View
|
|
style={[
|
|
styles.manual,
|
|
{
|
|
backgroundColor: theme.colors.surface,
|
|
borderColor: theme.colors.cardBorder,
|
|
borderRadius: theme.radius.lg,
|
|
},
|
|
]}
|
|
>
|
|
<Text style={[theme.text.h3, { color: theme.colors.textPrimary }]}>
|
|
Enter your mobile number
|
|
</Text>
|
|
<Text style={[theme.text.bodySmall, { color: theme.colors.textSecondary }]}>
|
|
The number your bank holds for you. We'll text a one-time code to it.
|
|
</Text>
|
|
|
|
<Input
|
|
label="Mobile number"
|
|
prefix="+91"
|
|
keyboardType="number-pad"
|
|
maxLength={10}
|
|
value={manualNumber}
|
|
onChangeText={handleManualChange}
|
|
onSubmitEditing={handleManualSubmit}
|
|
returnKeyType="done"
|
|
placeholder="98765 43210"
|
|
autoFocus
|
|
editable={!isLoading}
|
|
// The server's own complaint about the number belongs on the field that
|
|
// holds it, rather than in a banner elsewhere on the screen.
|
|
error={manualError ?? numberError}
|
|
/>
|
|
|
|
<Button
|
|
title="Send code"
|
|
onPress={handleManualSubmit}
|
|
disabled={!isValidPhone(manualDigits)}
|
|
// Only a manual send leaves `sendingId` null, so a SIM row's request
|
|
// cannot put this button into a spin it does not own.
|
|
loading={isLoading && sendingId === null}
|
|
/>
|
|
|
|
<Pressable onPress={() => setManualOpen(false)} hitSlop={6}>
|
|
<Text
|
|
style={[
|
|
theme.text.bodySmall,
|
|
styles.centreText,
|
|
styles.manualDismiss,
|
|
{ color: theme.colors.textSecondary },
|
|
]}
|
|
>
|
|
{tappable.length > 0 ? 'Use a SIM instead' : 'Back'}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
|
|
return (
|
|
<View style={styles.flex}>
|
|
{/*
|
|
The brand wash now fills the screen rather than sitting in a rounded
|
|
banner at the top. It is the same three stops the sign-in screen uses,
|
|
run vertically, so the two auth screens are one surface rather than two
|
|
different navies.
|
|
*/}
|
|
<LinearGradient
|
|
colors={theme.colors.brandGradient}
|
|
style={StyleSheet.absoluteFill}
|
|
start={{ x: 0.5, y: 0 }}
|
|
end={{ x: 0.5, y: 1 }}
|
|
/>
|
|
{/* Dark full-bleed surface, so the status-bar icons have to be light. */}
|
|
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
|
|
|
<SafeAreaView edges={['top', 'bottom']} style={styles.flex}>
|
|
{/* The number can be typed on this screen now, so it needs the same
|
|
keyboard handling sign-in has — without it the field and the send
|
|
button sit under the keyboard on a short handset. */}
|
|
<KeyboardAvoidingView
|
|
style={styles.flex}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
>
|
|
<ScrollView
|
|
contentContainerStyle={styles.scroll}
|
|
showsVerticalScrollIndicator={false}
|
|
keyboardShouldPersistTaps="handled"
|
|
keyboardDismissMode="interactive"
|
|
>
|
|
{navigation.canGoBack() ? (
|
|
<Pressable
|
|
onPress={() => navigation.goBack()}
|
|
hitSlop={10}
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Go back"
|
|
style={[styles.back, { backgroundColor: theme.colors.onBrandSurfaceStrong }]}
|
|
>
|
|
<Icon name="chevron-back" size={20} color={theme.colors.onBrandPrimary} />
|
|
</Pressable>
|
|
) : null}
|
|
|
|
{/* ── Brand and purpose ──────────────────────────────────────────── */}
|
|
<Animated.View entering={FadeInDown.duration(420)} style={styles.heroBody}>
|
|
<BrandLogo width={270} />
|
|
|
|
<Text
|
|
accessibilityRole="header"
|
|
style={[theme.text.display, styles.heroTitle, { color: theme.colors.onBrandPrimary }]}
|
|
>
|
|
Let's set up{'\n'}your banking
|
|
</Text>
|
|
<Text
|
|
style={[theme.text.body, styles.heroSub, { color: theme.colors.onBrandSecondary }]}
|
|
>
|
|
Tap the SIM registered with your bank. We'll read the number straight from it
|
|
and text you a one-time code.
|
|
</Text>
|
|
|
|
<View style={styles.trustRow}>
|
|
{TRUST_MARKS.map((item) => (
|
|
<View
|
|
key={item.label}
|
|
style={[
|
|
styles.trustChip,
|
|
{
|
|
backgroundColor: theme.colors.onBrandSurface,
|
|
borderColor: theme.colors.onBrandBorder,
|
|
borderRadius: theme.radius.pill,
|
|
},
|
|
]}
|
|
>
|
|
<Icon name={item.icon} size={13} color={theme.colors.onBrandPrimary} />
|
|
{/* Solid white: the chip's translucent fill lightens the wash,
|
|
so a translucent label on it drops to 4.37:1 up here where
|
|
the gradient is lightest. */}
|
|
<Text style={[theme.text.caption, { color: theme.colors.onBrandPrimary }]}>
|
|
{item.label}
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</Animated.View>
|
|
|
|
{/*
|
|
The SIM choice, directly on the wash. It used to be wrapped in a
|
|
white card pinned to the foot of the screen; the card was a second
|
|
surface doing nothing the cards inside it were not already doing,
|
|
and pinning it left a tall empty band above.
|
|
*/}
|
|
<Animated.View entering={FadeInUp.delay(120).duration(420)} style={styles.simBlock}>
|
|
{/* Checked ahead of `detecting`: the AppState listener re-reads the
|
|
SIMs every time the app is foregrounded, and a half-typed
|
|
number must not be swapped out for a spinner about something
|
|
the customer has already chosen to go around. */}
|
|
{manualOpen ? (
|
|
manualCard
|
|
) : detecting ? (
|
|
<View
|
|
style={[
|
|
styles.detecting,
|
|
{
|
|
backgroundColor: theme.colors.onBrandSurface,
|
|
borderColor: theme.colors.onBrandBorder,
|
|
borderRadius: theme.radius.md,
|
|
},
|
|
]}
|
|
>
|
|
<ActivityIndicator size="small" color={theme.colors.onBrandPrimary} />
|
|
<Text
|
|
style={[
|
|
theme.text.bodySmall,
|
|
styles.detectingText,
|
|
// On a fill, so solid rather than translucent.
|
|
{ color: theme.colors.onBrandPrimary },
|
|
]}
|
|
>
|
|
Reading the SIMs in this phone…
|
|
</Text>
|
|
</View>
|
|
) : blocker ? (
|
|
<View
|
|
style={[
|
|
styles.blocker,
|
|
{
|
|
backgroundColor: theme.colors.onBrandSurface,
|
|
borderColor: theme.colors.onBrandBorder,
|
|
borderRadius: theme.radius.lg,
|
|
},
|
|
]}
|
|
>
|
|
<View
|
|
style={[
|
|
styles.blockerIcon,
|
|
{ backgroundColor: theme.colors.onBrandSurfaceStrong },
|
|
]}
|
|
>
|
|
<Icon
|
|
name={needsSettings ? 'settings-outline' : blocker.icon}
|
|
size={20}
|
|
color={theme.colors.onBrandPrimary}
|
|
/>
|
|
</View>
|
|
<Text
|
|
style={[
|
|
theme.text.h3,
|
|
styles.centreText,
|
|
{ color: theme.colors.onBrandPrimary },
|
|
]}
|
|
>
|
|
{needsSettings ? 'Grant it in Settings' : blocker.title}
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
theme.text.bodySmall,
|
|
styles.centreText,
|
|
// On a fill, so solid rather than translucent.
|
|
{ color: theme.colors.onBrandPrimary },
|
|
]}
|
|
>
|
|
{needsSettings
|
|
? 'Android will not show the permission prompt again for this app. Open Settings, allow Phone, and come back — we will pick it up from there.'
|
|
: blocker.text}
|
|
</Text>
|
|
{/* White fill on the wash: `secondary` here would be a white
|
|
button on a white card, which is not the situation any more. */}
|
|
<Button
|
|
title={needsSettings ? 'Open Settings' : blocker.action}
|
|
variant="inverse"
|
|
onPress={() => {
|
|
void handleBlockerAction();
|
|
}}
|
|
style={styles.retry}
|
|
/>
|
|
{manualLink}
|
|
</View>
|
|
) : (
|
|
<>
|
|
<SimChooser
|
|
sims={sims}
|
|
busyId={sendingId}
|
|
onSelect={handleSelectSim}
|
|
disabled={isLoading}
|
|
onBrand
|
|
/>
|
|
{/* An InfoBanner rather than bare red text: `danger` is a dark
|
|
red and would sit at roughly 1.5:1 against the navy. The
|
|
banner brings its own light surface, so the message stays
|
|
readable wherever it is put. */}
|
|
{numberError ? <InfoBanner tone="danger" message={numberError} /> : null}
|
|
{/* A line the carrier will not name still has to get a code
|
|
somehow, and a customer registering a number that is not in
|
|
this phone at all has no row to tap either. */}
|
|
{manualLink}
|
|
</>
|
|
)}
|
|
</Animated.View>
|
|
|
|
{/*
|
|
Nothing is pinned to the foot of this screen.
|
|
|
|
The content is centred in the viewport instead, so the space falls
|
|
evenly above and below the SIM cards rather than pooling under them.
|
|
An empty band at the bottom of a screen reads as unfinished; the
|
|
same amount of space, split, reads as breathing room.
|
|
*/}
|
|
<View style={styles.footer}>
|
|
{/* Only worth offering when a profile is actually stored here —
|
|
otherwise it leads to an MPIN screen with nothing to sign in to. */}
|
|
{lastProfile ? (
|
|
<Pressable onPress={() => navigation.navigate('Login')} hitSlop={6}>
|
|
<Text
|
|
style={[
|
|
theme.text.bodySmall,
|
|
styles.footerLink,
|
|
{ color: theme.colors.onBrandSecondary },
|
|
]}
|
|
>
|
|
Already registered?{' '}
|
|
<Text style={{ color: theme.colors.onBrandPrimary }}>Sign in</Text>
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
</SafeAreaView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
flex: {
|
|
flex: 1,
|
|
},
|
|
centreText: {
|
|
textAlign: 'center',
|
|
},
|
|
scroll: {
|
|
flexGrow: 1,
|
|
// Centred, not top-aligned: with nothing pinned to the foot, the leftover
|
|
// height splits evenly above and below the content instead of pooling into
|
|
// one empty band under the SIM cards.
|
|
justifyContent: 'center',
|
|
paddingTop: 14,
|
|
paddingBottom: 28,
|
|
paddingHorizontal: 22,
|
|
gap: 30,
|
|
},
|
|
back: {
|
|
width: 34,
|
|
height: 34,
|
|
borderRadius: 17,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
heroBody: { gap: 10 },
|
|
heroTitle: {
|
|
marginTop: 4,
|
|
},
|
|
heroSub: {
|
|
maxWidth: 340,
|
|
},
|
|
trustRow: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: 8,
|
|
marginTop: 6,
|
|
},
|
|
trustChip: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 5,
|
|
borderWidth: 1,
|
|
paddingVertical: 5,
|
|
paddingHorizontal: 10,
|
|
},
|
|
|
|
simBlock: {
|
|
gap: 12,
|
|
},
|
|
detecting: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
borderWidth: 1,
|
|
paddingVertical: 12,
|
|
paddingHorizontal: 14,
|
|
},
|
|
detectingText: { flex: 1 },
|
|
|
|
blocker: {
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
borderWidth: 1,
|
|
padding: 18,
|
|
},
|
|
blockerIcon: {
|
|
width: 42,
|
|
height: 42,
|
|
borderRadius: 21,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: 2,
|
|
},
|
|
retry: {
|
|
marginTop: 6,
|
|
},
|
|
|
|
manual: {
|
|
gap: 12,
|
|
borderWidth: 1,
|
|
padding: 18,
|
|
},
|
|
manualLink: {
|
|
textAlign: 'center',
|
|
paddingVertical: 10,
|
|
},
|
|
manualDismiss: {
|
|
paddingVertical: 4,
|
|
},
|
|
footer: {
|
|
gap: 4,
|
|
},
|
|
footerLink: {
|
|
textAlign: 'center',
|
|
paddingVertical: 11,
|
|
},
|
|
});
|