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>
This commit is contained in:
Abin Toppo
2026-10-14 18:18:40 +05:30
co-authored by Claude Opus 5.5
parent 5fbd13ba12
commit f960a119e8
510 changed files with 20268 additions and 6483 deletions
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Button, Card, ScreenShell } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
@@ -149,7 +149,7 @@ export default function CreateMpinScreen({ navigation }: Props) {
{biometricsAvailable && (
<Card style={styles.biometricCard}>
<View style={styles.biometricRow}>
<Ionicons name="finger-print-outline" size={22} color={theme.colors.primary} />
<Icon name="finger-print-outline" size={22} color={theme.colors.primary} />
<View style={styles.biometricCopy}>
<Text style={[styles.biometricTitle, { color: theme.colors.textPrimary }]}>
Sign in with biometrics
@@ -171,8 +171,8 @@ export default function CreateMpinScreen({ navigation }: Props) {
</Card>
)}
<View style={[styles.noteCard, { backgroundColor: `${theme.colors.warning}14` }]}>
<Ionicons name="alert-circle-outline" size={18} color={theme.colors.warning} />
<View style={[styles.noteCard, { backgroundColor: theme.tint(theme.colors.warning, 0.08) }]}>
<Icon name="alert-circle-outline" size={18} color={theme.colors.warning} />
<Text style={[styles.noteText, { color: theme.colors.textSecondary }]}>
Three wrong MPINs lock your profile, and only your branch can unlock it. Pick
something you will remember.
+6 -5
View File
@@ -14,7 +14,7 @@ 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 { Icon } from '../../../components/Icon';
import { Button, InfoBanner, Input, Loader } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
@@ -479,11 +479,12 @@ export default function LoginScreen({ navigation }: Props) {
</View>
) : null}
{/* White fill, navy label — `primary` here would be navy on
navy and disappear into the wash. */}
{/* Solid brand blue on the navy wash. The wash is kept deep
precisely so the vivid blue reads against it, and the
label is the fill's own `textOnBrand` in both themes. */}
<Button
title="Sign in"
variant="inverse"
variant="primary"
loading={isLoading}
disabled={!canSubmit}
onPress={handleSignIn}
@@ -518,7 +519,7 @@ export default function LoginScreen({ navigation }: Props) {
onPress={handleUseMpin}
disabled={isLoading}
rightAccessory={
<Ionicons
<Icon
name="chevron-forward"
size={16}
color={theme.colors.onBrandPrimary}
@@ -1,7 +1,7 @@
import React from 'react';
import { Linking, StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Button, Card, ScreenShell } from '../../../components';
import { useAppDispatch } from '../../../app/hooks';
@@ -38,8 +38,8 @@ export default function MpinLockedScreen({ navigation }: Props) {
withTabBar={false}
contentContainerStyle={styles.container}
>
<View style={[styles.iconWrap, { backgroundColor: `${theme.colors.error}14` }]}>
<Ionicons name="lock-closed-outline" size={40} color={theme.colors.error} />
<View style={[styles.iconWrap, { backgroundColor: theme.tint(theme.colors.error, 0.08) }]}>
<Icon name="lock-closed-outline" size={40} color={theme.colors.error} />
</View>
<Card style={styles.card}>
@@ -0,0 +1,641 @@
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import {
Animated as RNAnimated,
Easing as RNEasing,
FlatList,
Pressable,
StatusBar,
StyleSheet,
Text,
useWindowDimensions,
View,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} 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, {
Extrapolation,
interpolate,
useAnimatedScrollHandler,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withRepeat,
withSequence,
withTiming,
cancelAnimation,
Easing,
FadeInDown,
type SharedValue,
} from 'react-native-reanimated';
import { Button } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
import { FeatureKey } from '../../../constants/featureKeys';
import { useFeature } from '../../../features';
import { haptics } from '../../../services/haptics/haptics';
import { useTheme, fontFamily } from '../../../theme';
import type { AuthStackParamList } from '../../../navigation/types';
import { selectLastProfile } from '../store/authSelectors';
import { markOnboardingSeen } from '../utils/onboardingFlag';
import {
FutureIllustration,
GoalsIllustration,
SecureIllustration,
} from '../components/OnboardingIllustrations';
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'Onboarding'>>;
type IconName = React.ComponentProps<typeof Icon>['name'];
const GUTTER = 24;
/** The illustrations are drawn on a 280 × 260 canvas. */
const ART_RATIO = 280 / 260;
/**
* The slides. Every line describes something the app actually does — accounts
* and transfers, the security around them, and the products behind Explore —
* rather than generic marketing. Nothing here claims a feature the app lacks.
*
* `highlights` are kept to a few words each so the chips sit in one or two
* rows on a 360pt phone. `rolling` is a short lead and a few words that roll
* into place after it, each kept short enough for the line to fit one row.
*/
const SLIDES: ReadonlyArray<{
key: string;
kicker: string;
title: string;
body: string;
highlights: ReadonlyArray<{ icon: IconName; label: string }>;
rolling: { lead: string; words: readonly string[] };
Illustration: React.ComponentType<{ width: number }>;
}> = [
{
key: 'goals',
kicker: 'Everyday banking',
title: 'Your goals.\nOur support.',
body: 'Check balances, read your mini statement and move money between banks — all from one app.',
highlights: [
{ icon: 'wallet-outline', label: 'Live balances' },
{ icon: 'receipt-outline', label: 'Statements' },
{ icon: 'paper-plane-outline', label: 'IMPS transfers' },
],
rolling: { lead: 'Banking made', words: ['simple', 'instant', 'personal'] },
Illustration: GoalsIllustration,
},
{
key: 'secure',
kicker: 'Security first',
title: 'Secure\n& convenient',
body: 'Your MPIN, biometrics and a phone registered to you. Every transfer is confirmed with a TPIN and an OTP.',
highlights: [
{ icon: 'finger-print-outline', label: 'Biometric login' },
{ icon: 'phone-portrait-outline', label: 'Device bound' },
{ icon: 'key-outline', label: 'TPIN + OTP' },
],
rolling: { lead: 'Protected by', words: ['your MPIN', 'biometrics', 'TPIN + OTP'] },
Illustration: SecureIllustration,
},
{
key: 'future',
kicker: 'Grow with us',
title: 'Build a brighter\nfinancial future',
body: 'Open fixed and recurring deposits, plan a loan with the EMI calculator and explore insurance.',
highlights: [
{ icon: 'trending-up-outline', label: 'FD & RD' },
{ icon: 'calculator-outline', label: 'EMI planner' },
{ icon: 'shield-checkmark-outline', label: 'Insurance' },
],
rolling: { lead: 'Your future, made', words: ['secure', 'simple', 'brighter'] },
Illustration: FutureIllustration,
},
];
/**
* First-launch introduction: three swipeable slides, shown once per handset.
*
* It only ever leads somewhere — there is nothing to fill in — so both
* "Get started" and "Skip" end in the same place: the flag is recorded and the
* stack is replaced with registration (or sign-in, when registration is off or
* a profile is already stored), so the back gesture cannot return here.
*
* Motion is tied to the swipe itself: the copy drifts and fades, the
* illustration scales, and the progress dots stretch, all driven on the UI
* thread from the scroll position. With "reduce motion" on, only the swipe
* itself moves.
*/
export default function OnboardingScreen({ navigation }: Props) {
const theme = useTheme();
const { width } = useWindowDimensions();
const listRef = useRef<FlatList<(typeof SLIDES)[number]>>(null);
const [index, setIndex] = useState(0);
const scrollX = useSharedValue(0);
const registrationEnabled = useFeature(FeatureKey.AUTH_REGISTER);
const lastProfile = useAppSelector(selectLastProfile);
const isLast = index === SLIDES.length - 1;
const finish = useCallback(() => {
void markOnboardingSeen();
navigation.replace(registrationEnabled && !lastProfile ? 'Register' : 'Login');
}, [lastProfile, navigation, registrationEnabled]);
const handleNext = useCallback(() => {
haptics.selection();
if (isLast) {
finish();
return;
}
listRef.current?.scrollToIndex({ index: index + 1, animated: true });
}, [finish, index, isLast]);
const handleScroll = useAnimatedScrollHandler((event) => {
scrollX.value = event.contentOffset.x;
});
const handleScrollEnd = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const next = Math.round(event.nativeEvent.contentOffset.x / width);
setIndex(Math.min(Math.max(next, 0), SLIDES.length - 1));
},
[width],
);
return (
<View style={styles.flex}>
{/* A soft tint rising from the foot of the screen, so the white slides
sit on something rather than floating in a blank page. */}
<LinearGradient
colors={[theme.colors.surface, theme.colors.surface, theme.colors.primarySurface]}
locations={[0, 0.55, 1]}
style={StyleSheet.absoluteFill}
/>
<SafeAreaView edges={['top', 'bottom']} style={styles.flex}>
<StatusBar barStyle={theme.statusBarStyle} backgroundColor="transparent" translucent />
{/* ── Top bar: the way out ────────────────────────────────────────── */}
<View style={styles.topBar}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Skip introduction"
disabled={isLast}
onPress={finish}
hitSlop={10}
style={[styles.skip, isLast && styles.hidden]}
>
<Text style={[theme.text.button, { color: theme.colors.primary }]}>Skip</Text>
</Pressable>
</View>
<Animated.FlatList
ref={listRef}
data={SLIDES}
keyExtractor={(item) => item.key}
horizontal
pagingEnabled
bounces={false}
showsHorizontalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
onMomentumScrollEnd={handleScrollEnd}
getItemLayout={(_, i) => ({ length: width, offset: width * i, index: i })}
renderItem={({ item, index: slideIndex }) => (
<Slide
item={item}
slideIndex={slideIndex}
width={width}
scrollX={scrollX}
active={slideIndex === index}
/>
)}
/>
{/* ── Footer: progress, and the way on ──────────────────────────────── */}
<View style={styles.footer}>
<View
style={styles.dots}
accessible
accessibilityLabel={`Slide ${index + 1} of ${SLIDES.length}`}
>
{SLIDES.map((entry, i) => (
<Dot key={entry.key} i={i} width={width} scrollX={scrollX} />
))}
</View>
<Button
title={isLast ? 'Get started' : 'Next'}
onPress={handleNext}
rightAccessory={
<Icon
name={isLast ? 'checkmark' : 'arrow-forward'}
size={18}
color={theme.colors.textOnBrand}
/>
}
/>
</View>
</SafeAreaView>
</View>
);
}
/* ── One slide ─────────────────────────────────────────────────────────────── */
interface SlideProps {
item: (typeof SLIDES)[number];
slideIndex: number;
width: number;
scrollX: SharedValue<number>;
active: boolean;
}
const SlideComponent = ({ item, slideIndex, width, scrollX, active }: SlideProps) => {
const theme = useTheme();
const reduceMotion = useReducedMotion();
const [artBox, setArtBox] = useState({ width: 0, height: 0 });
const float = useSharedValue(0);
/** Bumped each time this slide arrives, to replay the copy's entrance. */
const [run, setRun] = useState(0);
useEffect(() => {
if (active) {
setRun((n) => n + 1);
}
}, [active]);
/** Rise-and-fade, staggered by position. Nothing at all with reduce motion. */
const enter = (position: number) =>
reduceMotion ? undefined : FadeInDown.delay(80 + position * 90).duration(420);
// A slow bob on the slide that is showing. Stopped when it is not, and never
// started with reduce motion on.
useEffect(() => {
if (!active || reduceMotion) {
cancelAnimation(float);
float.value = withTiming(0, { duration: 200 });
return;
}
float.value = withRepeat(
withSequence(
withTiming(-8, { duration: 1800, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 1800, easing: Easing.inOut(Easing.sin) }),
),
-1,
);
return () => cancelAnimation(float);
}, [active, float, reduceMotion]);
const range = [(slideIndex - 1) * width, slideIndex * width, (slideIndex + 1) * width];
// The copy drifts a little against the swipe and fades, so it reads as a
// layer above the page rather than glued to it.
const copyStyle = useAnimatedStyle(() => {
if (reduceMotion) {
return {};
}
return {
opacity: interpolate(scrollX.value, range, [0, 1, 0], Extrapolation.CLAMP),
transform: [
{
translateX: interpolate(
scrollX.value,
range,
[width * 0.3, 0, -width * 0.3],
Extrapolation.CLAMP,
),
},
],
};
});
const artStyle = useAnimatedStyle(() => {
const scale = reduceMotion
? 1
: interpolate(scrollX.value, range, [0.82, 1, 0.82], Extrapolation.CLAMP);
return {
opacity: reduceMotion
? 1
: interpolate(scrollX.value, range, [0.3, 1, 0.3], Extrapolation.CLAMP),
transform: [{ translateY: float.value }, { scale }],
};
});
const handleArtLayout = (event: LayoutChangeEvent) => {
const { width: boxWidth, height: boxHeight } = event.nativeEvent.layout;
setArtBox({ width: boxWidth, height: boxHeight });
};
// Fits whatever room the copy leaves: by width on a tall phone, by height
// on a short one, and never larger than the art was drawn to look good at.
const artWidth = Math.max(
0,
Math.min(artBox.width, (artBox.height - 16) * ART_RATIO, 330),
);
return (
<View style={[styles.slide, { width }]}>
<Animated.View style={[styles.copy, copyStyle]}>
{/* Re-keyed on every arrival, so each element replays its entrance —
label, headline, rolling line, description, chips — one after
another. */}
<Animated.View key={`kicker-${run}`} entering={enter(0)} style={styles.kickerRow}>
<Text style={[theme.text.eyebrow, styles.kickerText, { color: theme.colors.primary }]}>
{item.kicker}
</Text>
</Animated.View>
<Animated.View key={`title-${run}`} entering={enter(1)}>
<Text style={[theme.text.display, { color: theme.colors.textPrimary }]}>
{item.title}
</Text>
</Animated.View>
<Animated.View key={`roll-${run}`} entering={enter(2)}>
<RollingWord lead={item.rolling.lead} words={item.rolling.words} active={active} />
</Animated.View>
<Animated.View key={`body-${run}`} entering={enter(3)}>
<Text style={[theme.text.body, { color: theme.colors.textSecondary }]}>{item.body}</Text>
</Animated.View>
<Animated.View key={`chips-${run}`} entering={enter(4)} style={styles.chips}>
{item.highlights.map((highlight) => (
<View
key={highlight.label}
style={[
styles.chip,
{
backgroundColor: theme.colors.primarySurface,
borderColor: theme.colors.primaryBorder,
},
]}
>
<Icon name={highlight.icon} size={14} color={theme.colors.primary} />
<Text style={[styles.chipText, { color: theme.colors.textPrimary }]}>
{highlight.label}
</Text>
</View>
))}
</Animated.View>
</Animated.View>
<View style={styles.art} onLayout={handleArtLayout}>
{artWidth > 0 ? (
<Animated.View style={artStyle}>
<item.Illustration width={artWidth} />
</Animated.View>
) : null}
</View>
</View>
);
};
const Slide = memo(SlideComponent);
/* ── A lead with a word that rolls into place ──────────────────────────────── */
/**
* "Banking made simple" → "instant" → "personal": the lead stays put and the
* last word rolls — the old one lifts away as the new one rises in.
*
* The word sits in a clipped window sized to the longest of them, so the line
* never changes width. Rolls only on the slide that is showing; screen
* readers hear the lead and every word once. With reduce motion on, the first
* word simply stays.
*
* Driven by React Native's own Animated on a View, NOT by Reanimated's
* entering/exiting layout animations. Those, put on a Text, hit a Reanimated
* Android bug (software-mansion/react-native-reanimated#10734, fixed by
* #10732): the layout-animation proxy reads the Text's props as if they were
* a View's, past the end of the allocation, and the app dies with SIGSEGV in
* MountingCoordinator::pullTransaction — intermittently, depending on the
* device's allocator. Swapping a keyed Animated.Text every 2.2 seconds made
* this screen hit it constantly. Do not move this back to layout animations.
*/
const RollingWord = ({
lead,
words,
active,
}: {
lead: string;
words: readonly string[];
active: boolean;
}) => {
const theme = useTheme();
const reduceMotion = useReducedMotion();
const [wordIndex, setWordIndex] = useState(0);
const longest = words.reduce((a, b) => (b.length > a.length ? b : a), '');
/** -1 lifted away above, 0 in place, 1 waiting below. */
const roll = useRef(new RNAnimated.Value(0)).current;
useEffect(() => {
if (!active) {
roll.stopAnimation();
roll.setValue(0);
setWordIndex(0);
return;
}
if (reduceMotion || words.length < 2) {
return;
}
let alive = true;
const timer = setInterval(() => {
RNAnimated.timing(roll, {
toValue: -1,
duration: 220,
easing: RNEasing.in(RNEasing.quad),
useNativeDriver: true,
}).start(({ finished }) => {
if (!alive || !finished) {
return;
}
// Swap the word while it is out of sight, then bring it up from below.
roll.setValue(1);
setWordIndex((n) => (n + 1) % words.length);
RNAnimated.timing(roll, {
toValue: 0,
duration: 320,
easing: RNEasing.out(RNEasing.cubic),
useNativeDriver: true,
}).start();
});
}, 2200);
return () => {
alive = false;
clearInterval(timer);
roll.stopAnimation();
roll.setValue(0);
};
}, [active, reduceMotion, roll, words.length]);
const rollStyle = {
opacity: roll.interpolate({ inputRange: [-1, 0, 1], outputRange: [0, 1, 0] }),
transform: [
{ translateY: roll.interpolate({ inputRange: [-1, 0, 1], outputRange: [-16, 0, 16] }) },
],
};
const wordStyle = [theme.text.h2, { color: theme.colors.primary }];
return (
<View style={styles.rollRow} accessible accessibilityLabel={`${lead} ${words.join(', ')}`}>
<Text style={[theme.text.h2, { color: theme.colors.textPrimary }]}>{`${lead} `}</Text>
<View style={styles.rollWindow}>
{/* Holds the width and height of the longest word. */}
<Text style={[wordStyle, styles.ghost]}>{longest}</Text>
<RNAnimated.View style={[styles.rollWord, rollStyle]}>
<Text style={wordStyle}>{words[wordIndex]}</Text>
</RNAnimated.View>
<View style={[styles.rollUnderline, { backgroundColor: theme.colors.accentFill }]} />
</View>
</View>
);
};
/* ── One progress dot ──────────────────────────────────────────────────────── */
const Dot = ({
i,
width,
scrollX,
}: {
i: number;
width: number;
scrollX: SharedValue<number>;
}) => {
const theme = useTheme();
// Stretches into a bar as its slide arrives, and is the brand blue at full
// strength only there — shape and strength both carry it, not colour alone.
const style = useAnimatedStyle(() => {
const range = [(i - 1) * width, i * width, (i + 1) * width];
return {
width: interpolate(scrollX.value, range, [8, 26, 8], Extrapolation.CLAMP),
opacity: interpolate(scrollX.value, range, [0.3, 1, 0.3], Extrapolation.CLAMP),
};
});
return <Animated.View style={[styles.dot, { backgroundColor: theme.colors.primary }, style]} />;
};
const styles = StyleSheet.create({
flex: {
flex: 1,
},
topBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
paddingHorizontal: GUTTER - 6,
paddingTop: 8,
paddingBottom: 4,
},
skip: {
paddingVertical: 6,
paddingHorizontal: 10,
},
hidden: {
opacity: 0,
},
slide: {
flex: 1,
paddingHorizontal: GUTTER,
paddingTop: 20,
},
copy: {
gap: 12,
},
kickerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
kickerText: {
fontSize: 13,
},
chips: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 2,
},
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
borderWidth: StyleSheet.hairlineWidth,
},
chipText: {
fontSize: 13,
lineHeight: 18,
fontFamily: fontFamily.medium,
},
rollRow: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'flex-end',
},
rollWindow: {
overflow: 'hidden',
paddingBottom: 6,
},
ghost: {
opacity: 0,
},
rollWord: {
position: 'absolute',
left: 0,
top: 0,
},
rollUnderline: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 3,
borderRadius: 2,
},
art: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
footer: {
paddingHorizontal: GUTTER,
paddingBottom: 12,
paddingTop: 8,
gap: 20,
alignItems: 'center',
},
dots: {
flexDirection: 'row',
gap: 6,
},
dot: {
height: 8,
borderRadius: 4,
},
});
+4 -4
View File
@@ -14,7 +14,7 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import LinearGradient from 'react-native-linear-gradient';
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
@@ -417,7 +417,7 @@ export default function RegisterScreen({ navigation }: Props) {
accessibilityLabel="Go back"
style={[styles.back, { backgroundColor: theme.colors.onBrandSurfaceStrong }]}
>
<Ionicons name="chevron-back" size={20} color={theme.colors.onBrandPrimary} />
<Icon name="chevron-back" size={20} color={theme.colors.onBrandPrimary} />
</Pressable>
) : null}
@@ -451,7 +451,7 @@ export default function RegisterScreen({ navigation }: Props) {
},
]}
>
<Ionicons name={item.icon} size={13} color={theme.colors.onBrandPrimary} />
<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. */}
@@ -516,7 +516,7 @@ export default function RegisterScreen({ navigation }: Props) {
{ backgroundColor: theme.colors.onBrandSurfaceStrong },
]}
>
<Ionicons
<Icon
name={needsSettings ? 'settings-outline' : blocker.icon}
size={20}
color={theme.colors.onBrandPrimary}
+155
View File
@@ -0,0 +1,155 @@
import React, { memo, useMemo } from 'react';
import { StatusBar, StyleSheet, Text, useWindowDimensions, View } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import Svg, { G, Path, Rect } from 'react-native-svg';
import Animated, { FadeIn, FadeInUp } from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import { BrandLogo } from '../components/BrandLogo';
/**
* The skyline, on a 400 × 220 canvas anchored to the bottom edge.
*
* Two layers of towers: a far row drawn in a translucent white, so it reads as
* haze against whatever navy the theme supplies, and a near row in the wash's
* darkest stop, with lit windows. Fixed geometry rather than random, so the
* splash is the same picture on every launch.
*/
const FAR: ReadonlyArray<[x: number, w: number, h: number]> = [
[0, 34, 92], [30, 26, 120], [52, 40, 84], [88, 22, 140], [106, 36, 104],
[140, 30, 156], [196, 24, 128], [224, 42, 96], [262, 28, 150], [286, 38, 110],
[320, 24, 132], [340, 36, 88], [372, 28, 118],
];
const NEAR: ReadonlyArray<[x: number, w: number, h: number]> = [
[-4, 40, 64], [32, 30, 96], [60, 46, 72], [104, 28, 112], [150, 32, 128],
[210, 30, 104], [238, 44, 70], [280, 32, 118], [310, 40, 80], [348, 30, 100],
[376, 30, 66],
];
const CANVAS_W = 400;
const CANVAS_H = 220;
/** Lit windows for one near tower — a deterministic sprinkle, not a grid. */
const windowsFor = (x: number, w: number, h: number, seed: number) => {
const lit: Array<{ x: number; y: number }> = [];
const columns = Math.max(1, Math.floor((w - 8) / 8));
for (let row = 0; row * 10 + 14 < h - 6; row += 1) {
for (let col = 0; col < columns; col += 1) {
if ((row * 5 + col * 3 + seed) % 4 === 0) {
lit.push({ x: x + 6 + col * 8, y: CANVAS_H - h + 10 + row * 10 });
}
}
}
return lit;
};
const Skyline = memo(({ width }: { width: number }) => {
const theme = useTheme();
const nearColor = theme.colors.brandGradient[2];
const windows = useMemo(
() => NEAR.flatMap(([x, w, h], i) => windowsFor(x, w, h, i)),
[],
);
return (
<Svg
width={width}
height={(width * CANVAS_H) / CANVAS_W}
viewBox={`0 0 ${CANVAS_W} ${CANVAS_H}`}
preserveAspectRatio="xMidYMax slice"
>
{/* far row */}
<G fill={theme.colors.onBrandSurface}>
{FAR.map(([x, w, h]) => (
<Rect key={`f${x}`} x={x} y={CANVAS_H - h} width={w} height={h} />
))}
{/* the landmark tower, with its spire */}
<Rect x={170} y={CANVAS_H - 188} width={26} height={188} />
<Path d={`M176 ${CANVAS_H - 188} L183 ${CANVAS_H - 214} L190 ${CANVAS_H - 188} Z`} />
</G>
{/* near row */}
<G fill={nearColor}>
{NEAR.map(([x, w, h]) => (
<Rect key={`n${x}`} x={x} y={CANVAS_H - h} width={w} height={h} />
))}
</G>
{/* windows */}
<G fill={theme.colors.accentFill} opacity={0.75}>
{windows.map((pane) => (
<Rect key={`${pane.x}-${pane.y}`} x={pane.x} y={pane.y} width={3} height={4} />
))}
</G>
</Svg>
);
});
/**
* The launch screen, shown while the app restores its session and resolves
* its feature flags.
*
* The navy wash with the bank's logo on its white plate, a one-line promise,
* and the skyline along the foot. Light status-bar icons in both themes, since
* the wash is dark in both.
*/
const SplashScreenComponent = () => {
const theme = useTheme();
const { width } = useWindowDimensions();
return (
<View style={styles.flex}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<LinearGradient
colors={theme.colors.brandGradient}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.skyline} pointerEvents="none">
<Skyline width={width} />
</View>
<View style={styles.centre}>
<Animated.View entering={FadeIn.duration(500)}>
<BrandLogo width={210} />
</Animated.View>
<Animated.View entering={FadeInUp.delay(250).duration(500)}>
<Text style={[theme.text.body, styles.tagline, { color: theme.colors.onBrandSecondary }]}>
Banking for a brighter tomorrow
</Text>
</Animated.View>
</View>
</View>
);
};
const styles = StyleSheet.create({
flex: {
flex: 1,
},
centre: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 18,
// Sits the lock-up a little above centre, clear of the skyline.
paddingBottom: '30%',
},
tagline: {
textAlign: 'center',
},
skyline: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
},
});
export const SplashScreen = memo(SplashScreenComponent);
+3 -3
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Button, Card, ScreenShell } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
@@ -227,8 +227,8 @@ export default function VerifyOtpScreen({ navigation }: Props) {
</Card>
{registration.accountsLinked > 0 && (
<View style={[styles.noteCard, { backgroundColor: `${theme.colors.success}0D` }]}>
<Ionicons name="checkmark-circle-outline" size={18} color={theme.colors.success} />
<View style={[styles.noteCard, { backgroundColor: theme.tint(theme.colors.success, 0.05) }]}>
<Icon name="checkmark-circle-outline" size={18} color={theme.colors.success} />
<Text style={[styles.noteText, { color: theme.colors.textSecondary }]}>
{registration.accountsLinked === 1
? '1 account found at your bank and ready to link.'