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:
co-authored by
Claude Opus 5.5
parent
5fbd13ba12
commit
f960a119e8
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
@@ -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.'
|
||||
|
||||
Reference in New Issue
Block a user