Files
CBS-Mobile-App/src/modules/auth/screens/RegisterScreen.tsx
T
Abin ToppoandClaude Opus 5.5 f960a119e8 Add Positive Pay, statement guide v1.0, onboarding, and switch every feature on
- 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>
2026-10-14 18:18:40 +05:30

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&apos;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&apos;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&apos;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,
},
});