Update CBS Mobile App codebase

This commit is contained in:
Abin Toppo
2026-09-21 13:21:25 +05:30
parent 58af541536
commit 2dcb10775c
339 changed files with 26750 additions and 16767 deletions
+240
View File
@@ -0,0 +1,240 @@
import React, { memo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import Ionicons from '@react-native-vector-icons/ionicons';
import type { CbsAccount } from '../../types/cbs';
import { useTheme, textStyles } from '../../theme';
interface AccountCardProps {
account: CbsAccount;
/**
* The customer's name, from the session. Passed in rather than read here:
* this is a shared presentational primitive and none of them reach into app
* state. It is also not account data — `/accounts` returns no holder name.
*/
accountHolder?: string;
/**
* Whether the reveal control for the account number is offered. When false
* the number stays masked with no way to reveal it.
*/
canRevealNumber?: boolean;
}
/**
* The hero card for one account.
*
* This was a balance card until the app moved to `GET /accounts`, which
* returns no balance — and no other CBS endpoint returns one either. Rather
* than print a figure the bank never sent, the card now leads with what it can
* actually vouch for: which account this is, and whether it is usable.
*
* The account number is masked by default. `/accounts` is the only endpoint
* that returns an unmasked one, so revealing it is a deliberate act.
*/
const AccountCardComponent = ({
account,
accountHolder,
canRevealNumber = true,
}: AccountCardProps) => {
const theme = useTheme();
const [revealed, setRevealed] = useState(false);
const showFull = revealed && canRevealNumber;
const displayNumber = showFull
? account.accountNumber
: `XXXX ${account.accountNumber.slice(-4)}`;
return (
<LinearGradient
colors={theme.colors.brandGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.card, styles.gradientCard, { borderRadius: theme.radius.xl }]}
>
<View style={[styles.brandAccent, { backgroundColor: theme.colors.onBrandSurface }]} />
<View style={styles.topRow}>
<View style={styles.bankInfo}>
<View
style={[styles.bankIconFrame, { backgroundColor: theme.colors.onBrandSurfaceStrong }]}
>
<Ionicons name="business" size={20} color={theme.colors.onBrandPrimary} />
</View>
<View>
<Text style={[styles.bankName, { color: theme.colors.onBrandPrimary }]}>
CBS Bank
</Text>
<Text style={[styles.cardType, { color: theme.colors.onBrandSecondary }]}>
{`${account.accountType.toUpperCase()} ACCOUNT`}
</Text>
</View>
</View>
{/* A non-active account has to say so here: this is the card the
customer picks an account from, and dormant is the reason a later
transfer would be refused. */}
{account.status !== 'ACTIVE' ? (
<View style={[styles.statusBadge, { backgroundColor: theme.colors.onBrandPrimary }]}>
<Text style={[styles.statusBadgeText, { color: theme.colors.primary }]}>
{account.status}
</Text>
</View>
) : null}
</View>
<View style={styles.mainContent}>
<Text style={[styles.numberLabel, { color: theme.colors.onBrandSecondary }]}>
ACCOUNT NUMBER
</Text>
<View style={styles.numberRow}>
<Text
style={[styles.numberValue, { color: theme.colors.onBrandPrimary }]}
selectable={showFull}
>
{displayNumber}
</Text>
{canRevealNumber ? (
<Pressable
onPress={() => setRevealed((current) => !current)}
style={[styles.revealButton, { backgroundColor: theme.colors.onBrandSurface }]}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={showFull ? 'Hide account number' : 'Show account number'}
>
<Ionicons
name={showFull ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={theme.colors.onBrandPrimary}
/>
</Pressable>
) : null}
</View>
</View>
<View style={styles.footerRow}>
<View style={styles.footerItem}>
<Text style={[styles.footerLabel, { color: theme.colors.onBrandMuted }]}>
{accountHolder ? 'ACCOUNT HOLDER' : 'IFSC'}
</Text>
<Text style={[styles.footerValue, { color: theme.colors.onBrandPrimary }]}>
{accountHolder ?? account.ifsc}
</Text>
</View>
<View style={[styles.footerItem, styles.footerItemEnd]}>
<Text style={[styles.footerLabel, { color: theme.colors.onBrandMuted }]}>
{account.mmid ? 'MMID' : 'IFSC'}
</Text>
<Text style={[styles.footerValue, { color: theme.colors.onBrandPrimary }]}>
{account.mmid ?? account.ifsc}
</Text>
</View>
</View>
</LinearGradient>
);
};
const styles = StyleSheet.create({
card: {
padding: 24,
gap: 32,
position: 'relative',
minHeight: 260,
},
gradientCard: {
overflow: 'hidden',
},
brandAccent: {
position: 'absolute',
top: -50,
right: -50,
width: 200,
height: 200,
borderRadius: 100,
},
topRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
bankInfo: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
bankIconFrame: {
width: 40,
height: 40,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
bankName: {
...textStyles.h3,
},
cardType: {
...textStyles.eyebrow,
fontSize: 10,
},
statusBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 10,
},
statusBadgeText: {
...textStyles.eyebrow,
fontSize: 10,
letterSpacing: 0.5,
},
mainContent: {
gap: 6,
},
numberLabel: {
...textStyles.eyebrow,
letterSpacing: 1,
},
numberRow: {
flexDirection: 'row',
alignItems: 'center',
},
// An account number, so it is set in mono — a customer reads these back a
// group at a time.
numberValue: {
...textStyles.code,
flex: 1,
fontSize: 22,
letterSpacing: 1.5,
},
revealButton: {
marginLeft: 12,
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
footerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
marginTop: 4,
},
footerItem: {
gap: 4,
flexShrink: 1,
},
footerItemEnd: {
alignItems: 'flex-end',
},
footerLabel: {
...textStyles.eyebrow,
fontSize: 9,
letterSpacing: 1,
},
footerValue: {
...textStyles.bodyStrong,
fontSize: 14,
},
});
export const AccountCard = memo(AccountCardComponent);
+135
View File
@@ -0,0 +1,135 @@
import React, { memo } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useTheme, fontFamily } from '../../theme';
import type { CbsAccount } from '../../types/cbs';
interface AccountSelectorProps {
/** The accounts to offer, as `GET /accounts` returned them. */
accounts: CbsAccount[];
/** The chosen account, addressed by its number — these have no local id. */
selectedAccountNumber?: string;
onSelect: (accountNumber: string) => void;
/** Section label above the row — "Debit from", "Credit to", "Drawn on account". */
label?: string;
/** Greys the row out and stops selection while a request is in flight. */
disabled?: boolean;
}
/**
* The account chooser that heads every account-scoped form.
*
* A row of chips rather than a dropdown: this app's customers hold two or three
* accounts, so every option fits on screen and the choice costs one tap instead
* of two plus a modal.
*
* Selection is drawn with a border and a tinted fill, never with colour alone —
* a chip that differed only in hue would be invisible to a customer who cannot
* separate the two, and this is the control that decides which account the
* money leaves.
*
* Accounts are shown masked. The unmasked number `GET /accounts` returns is
* carried in the value, never painted onto a chip.
*/
const AccountSelectorComponent = ({
accounts,
selectedAccountNumber,
onSelect,
label,
disabled = false,
}: AccountSelectorProps) => {
const theme = useTheme();
if (!accounts.length) {
return null;
}
return (
<View style={styles.container}>
{label ? (
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>{label}</Text>
) : null}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row}
alwaysBounceHorizontal={false}
>
{accounts.map((account) => {
const selected = account.accountNumber === selectedAccountNumber;
return (
<Pressable
key={account.accountNumber}
accessibilityRole="radio"
accessibilityState={{ selected, disabled }}
accessibilityLabel={`${account.accountType} account ending ${account.accountNumber.slice(-4)}`}
disabled={disabled}
onPress={() => onSelect(account.accountNumber)}
style={[
styles.chip,
{
borderRadius: theme.radius.md,
backgroundColor: selected
? theme.colors.primarySurface
: theme.colors.surfaceMuted,
borderColor: selected ? theme.colors.primary : 'transparent',
opacity: disabled ? 0.5 : 1,
},
]}
>
<Text style={[styles.type, { color: theme.colors.primary }]}>
{account.accountType.toUpperCase()}
</Text>
<Text style={[styles.number, { color: theme.colors.primary }]}>
{`XXXX ${account.accountNumber.slice(-4)}`}
</Text>
{/* Only ever shown when it is not ACTIVE — a status line on every
chip is noise, but a dormant account has to say so before it
is picked as the debit side of something. */}
{account.status !== 'ACTIVE' ? (
<Text style={[styles.status, { color: theme.colors.danger }]}>
{account.status}
</Text>
) : null}
</Pressable>
);
})}
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
gap: 10,
},
row: {
flexDirection: 'row',
gap: 12,
paddingRight: 4,
},
chip: {
minWidth: 150,
gap: 4,
borderWidth: 1.5,
paddingVertical: 14,
paddingHorizontal: 16,
},
type: {
fontSize: 14,
fontFamily: fontFamily.bold,
letterSpacing: 0.3,
},
number: {
fontSize: 15,
},
status: {
fontSize: 12,
fontFamily: fontFamily.bold,
letterSpacing: 0.3,
},
});
export const AccountSelector = memo(AccountSelectorComponent);
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { sessionManager } from '../../services/security/sessionManager';
interface ActivityTrackerProps {
children: React.ReactNode;
}
/**
* Records that the user is still there, app-wide.
*
* Needed because the server's idle window only resets on an authenticated call,
* and most of this app reads from fixtures — someone can browse the dashboard
* for five minutes without one, and be signed out while actively using it.
*
* `onStartShouldSetResponderCapture` sees every touch on the way down and
* returning false declines to become the responder, so it never swallows a tap,
* a scroll or a gesture. A wrapper that actually captured touches would break
* every pressable beneath it.
*/
export const ActivityTracker = ({ children }: ActivityTrackerProps) => (
<View
style={styles.fill}
onStartShouldSetResponderCapture={() => {
sessionManager.noteInteraction();
return false;
}}
>
{children}
</View>
);
const styles = StyleSheet.create({
fill: {
flex: 1,
},
});
+2 -2
View File
@@ -8,7 +8,7 @@ import {
import { useNavigation } from '@react-navigation/native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../theme';
import { useTheme, fontFamily } from '../../theme';
interface AppHeaderProps {
title: string;
@@ -76,7 +76,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 20,
fontWeight: '700',
fontFamily: fontFamily.bold,
textTransform: "uppercase",
letterSpacing: 0.5,
lineHeight: 20,
@@ -0,0 +1,77 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { SegmentedControl, type SegmentedControlOption } from '../SegmentedControl';
import { textStyles, useTheme, useThemeMode, type ThemeMode } from '../../theme';
/**
* The light / dark / system control.
*
* Three options rather than a switch, because "follow my phone" is a genuinely
* different answer from "always light" — and it is the one most people want.
* A two-state switch would force everybody to state a preference the OS has
* already been told, and would then ignore it when they changed it there.
*
* `System` is listed last despite being the default. The two explicit choices
* are what someone opening this screen is looking for; `System` is where they
* go back to, and a segmented control reads left to right.
*/
const OPTIONS: ReadonlyArray<SegmentedControlOption<ThemeMode>> = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' },
];
export const AppearanceSetting = () => {
const theme = useTheme();
const { mode, resolved, setMode } = useThemeMode();
return (
<View style={styles.wrapper}>
<SegmentedControl
options={OPTIONS}
value={mode}
onChange={setMode}
accessibilityLabel="App appearance"
/>
{/*
Only shown for `system`, and it names what the phone is currently set
to. Without it the control is ambiguous: `System` is selected, and
nothing on screen says which of the two that actually means — so a
customer whose handset is in light mode cannot tell whether the setting
took effect.
*/}
{mode === 'system' ? (
<View style={styles.hintRow}>
<Ionicons
name={resolved === 'dark' ? 'moon-outline' : 'sunny-outline'}
size={14}
color={theme.colors.textMuted}
/>
<Text style={[styles.hint, { color: theme.colors.textMuted }]}>
{`Following your phone, which is set to ${resolved}.`}
</Text>
</View>
) : null}
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
gap: 10,
paddingVertical: 4,
},
hintRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
hint: {
...textStyles.caption,
flex: 1,
},
});
-230
View File
@@ -1,230 +0,0 @@
import React, { memo, useState } from 'react';
import {
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import Ionicons from '@react-native-vector-icons/ionicons';
import type { AccountSummary } from '../../types/app';
import { useTheme } from '../../theme';
import { formatCurrency } from '../../utils/formatters';
interface BalanceCardProps {
account: AccountSummary;
secondaryLabel?: string;
}
const BalanceCardComponent = ({ account, secondaryLabel = 'SAVINGS ACCOUNT' }: BalanceCardProps) => {
const theme = useTheme();
const [isBalanceVisible, setIsBalanceVisible] = useState(true);
const formattedBalance = formatCurrency(account.availableBalance).replace('₹', '');
return (
<LinearGradient
colors={['#124577', '#124577', '#124577']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.card, styles.gradientCard, { borderRadius: theme.radius.xl }]}
>
{/* Decorative Brand Accent */}
<View style={styles.brandAccent} />
<View style={styles.topRow}>
<View style={styles.bankInfo}>
<View style={styles.bankIconFrame}>
<Ionicons name="business" size={20} color="white" />
</View>
<View>
<Text style={styles.bankName}>CBS Bank</Text>
<Text style={styles.cardType}>{secondaryLabel}</Text>
</View>
</View>
{account.isPrimary && (
<View style={styles.primaryBadge}>
<Text style={styles.primaryBadgeText}>PRIMARY</Text>
</View>
)}
</View>
<View style={styles.mainContent}>
<Text style={styles.maskedNumber}>A/c {account.maskedNumber}</Text>
<View style={styles.balanceContainer}>
<Text style={styles.balanceLabel}>AVAILABLE BALANCE</Text>
<View style={styles.amountRow}>
<Text style={styles.amountValue}>
{isBalanceVisible ? `₹${formattedBalance}` : '₹ ••••••'}
</Text>
<Pressable
onPress={() => setIsBalanceVisible((currentValue) => !currentValue)}
style={styles.visibilityButton}
hitSlop={8}
>
<Ionicons
name={isBalanceVisible ? 'eye-outline' : 'eye-off-outline'}
size={20}
color="rgba(255, 255, 255, 0.7)"
/>
</Pressable>
</View>
</View>
</View>
<View style={styles.footerRow}>
<View style={styles.footerItem}>
<Text style={styles.footerLabel}>ACCOUNT HOLDER</Text>
<Text style={styles.footerValue}>{account.accountHolder}</Text>
</View>
<View style={[styles.footerItem, styles.footerItemEnd]}>
<Text style={styles.footerLabel}>UPI STATUS</Text>
<View style={styles.upiRow}>
<View style={styles.upiDot} />
<Text style={styles.footerValue}>Active</Text>
</View>
</View>
</View>
</LinearGradient>
);
};
const styles = StyleSheet.create({
card: {
padding: 24,
gap: 32,
position: 'relative',
minHeight: 260,
},
gradientCard: {
overflow: 'hidden',
},
brandAccent: {
position: 'absolute',
top: -50,
right: -50,
width: 200,
height: 200,
borderRadius: 100,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
topRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
bankInfo: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
bankIconFrame: {
width: 40,
height: 40,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
},
bankName: {
color: 'white',
fontSize: 18,
fontWeight: '900',
letterSpacing: -0.5,
},
cardType: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 10,
fontWeight: '800',
letterSpacing: 1,
textTransform: 'uppercase',
},
primaryBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 10,
backgroundColor: 'white',
},
primaryBadgeText: {
color: '#124577',
fontSize: 10,
fontWeight: '900',
letterSpacing: 0.5,
},
mainContent: {
gap: 20,
},
maskedNumber: {
color: 'white',
fontSize: 18,
fontWeight: '700',
letterSpacing: 1.5,
},
balanceContainer: {
gap: 4,
},
balanceLabel: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 11,
fontWeight: '800',
letterSpacing: 1,
},
amountRow: {
flexDirection: 'row',
alignItems: 'center',
},
amountValue: {
color: 'white',
fontSize: 36,
fontWeight: '900',
letterSpacing: -1,
},
visibilityButton: {
marginLeft: 12,
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
footerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
marginTop: 4,
},
footerItem: {
gap: 4,
},
footerItemEnd: {
alignItems: 'flex-end',
},
footerLabel: {
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 9,
fontWeight: '800',
letterSpacing: 1,
},
footerValue: {
color: 'white',
fontSize: 14,
fontWeight: '700',
},
upiRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
upiDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#35D6A3',
},
});
export const BalanceCard = memo(BalanceCardComponent);
+2 -2
View File
@@ -13,7 +13,7 @@ import {
View,
} from 'react-native';
import { useTheme } from '../../theme';
import { useTheme, fontFamily } from '../../theme';
interface BottomSheetProps {
visible: boolean;
@@ -107,6 +107,6 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 18,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
});
+165 -98
View File
@@ -1,4 +1,4 @@
import React, { memo } from 'react';
import React, { memo, useCallback, useState } from 'react';
import {
ActivityIndicator,
Pressable,
@@ -10,152 +10,219 @@ import {
type ViewStyle,
type TextStyle,
} from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import { useTheme } from '../../theme';
import { haptics } from '../../services/haptics/haptics';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'cyber';
/**
* The variants the design system defines. `cyber` was removed — it was a
* leftover from an earlier theme, it drew a coloured glow that Android throws
* away, and nothing in the app used it.
*
* `inverse` and `onBrand` exist for the full-bleed navy sign-in screen, where
* `primary` would be navy-on-navy and simply vanish. `inverse` is the primary
* action there (white fill, navy label, 9.80:1); `onBrand` is the secondary
* one (a low-opacity white fill, white label, 4.66:1 at the lightest point of
* the gradient).
*/
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'inverse' | 'onBrand';
/**
* `md` is the default. `lg` is for agent screens, where the person is standing
* up and working one-handed. `sm` is for an inline action inside a row or card.
*/
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps {
title: string;
onPress?: (event: GestureResponderEvent) => void;
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
loading?: boolean;
fullWidth?: boolean;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
leftAccessory?: React.ReactNode;
rightAccessory?: React.ReactNode;
/** Defaults to `title`. Set it when the title alone is not descriptive. */
accessibilityLabel?: string;
}
const ButtonComponent = ({
title,
onPress,
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
fullWidth = true,
style,
textStyle,
leftAccessory,
rightAccessory,
accessibilityLabel,
}: ButtonProps) => {
const theme = useTheme();
const [pressed, setPressed] = useState(false);
const inert = disabled || loading;
const handlePress = (e: GestureResponderEvent) => {
if (!disabled && !loading) {
const handlePress = useCallback(
(event: GestureResponderEvent) => {
if (inert) {
return;
}
haptics.selection();
onPress?.(e);
}
};
const isSolid = variant === 'primary' || variant === 'danger' || variant === 'cyber';
const containerStyle = [
styles.button,
{
borderRadius: theme.radius.md,
borderColor:
variant === 'ghost'
? 'transparent'
: variant === 'secondary'
? theme.colors.cardBorder
: variant === 'cyber'
? theme.colors.cyberCyan + '30'
: 'transparent',
backgroundColor:
variant === 'secondary' ? theme.colors.cardGlass : 'transparent',
width: fullWidth ? ('100%' as const) : undefined,
opacity: disabled || loading ? 0.6 : 1,
onPress?.(event);
},
variant === 'cyber' && {
shadowColor: theme.colors.cyberCyan,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.5,
shadowRadius: 10,
elevation: 8,
},
style,
];
const content = (
<View style={styles.content}>
{loading ? (
<ActivityIndicator color={theme.colors.white} />
) : (
<>
{leftAccessory}
<Text
style={[
styles.label,
{
color:
variant === 'ghost'
? theme.colors.textPrimary
: variant === 'secondary' ? theme.colors.textPrimary : theme.colors.white,
letterSpacing: variant === 'cyber' ? 2 : 0,
textTransform: variant === 'cyber' ? 'uppercase' : 'none',
},
textStyle,
]}
>
{title}
</Text>
</>
)}
</View>
[inert, onPress],
);
const height =
size === 'lg'
? theme.layout.controlHeightAgent
: size === 'sm'
? theme.layout.controlHeightSmall
: theme.layout.controlHeight;
/**
* Pressed feedback is a real colour step, not a blanket opacity fade. Fading
* a navy button to 60% drops its white label under AA, so the fill darkens
* and the label stays at full strength instead.
*/
const surface = ((): { backgroundColor: string; borderColor: string; label: string } => {
if (disabled) {
return {
backgroundColor: theme.colors.surfaceSunk,
borderColor: theme.colors.cardBorderStrong,
// textSecondary is 5.38:1 on surfaceSunk, so a disabled label stays readable.
label: theme.colors.textSecondary,
};
}
switch (variant) {
case 'primary':
return {
backgroundColor: pressed ? theme.colors.primaryDark : theme.colors.primary,
borderColor: pressed ? theme.colors.primaryDark : theme.colors.primary,
label: theme.colors.textOnBrand,
};
case 'danger':
return {
backgroundColor: theme.colors.danger,
borderColor: theme.colors.danger,
label: theme.colors.textOnBrand,
};
case 'secondary':
return {
backgroundColor: pressed ? theme.colors.surfaceMuted : theme.colors.surface,
borderColor: theme.colors.cardBorderStrong,
label: theme.colors.primary,
};
case 'inverse':
return {
backgroundColor: pressed ? theme.colors.surfaceMuted : theme.colors.surface,
borderColor: 'transparent',
label: theme.colors.primary,
};
case 'onBrand':
return {
// Pressing LIGHTENS less rather than more: 0.18 is the ceiling for a
// fill that carries white text, so the feedback has to go the other
// way to stay above AA.
backgroundColor: pressed
? theme.colors.onBrandSurface
: theme.colors.onBrandSurfaceStrong,
borderColor: theme.colors.onBrandBorder,
label: theme.colors.onBrandPrimary,
};
case 'ghost':
default:
return {
backgroundColor: pressed ? theme.colors.primarySurface : 'transparent',
borderColor: 'transparent',
label: theme.colors.primary,
};
}
})();
return (
<Pressable disabled={disabled || loading} onPress={handlePress} style={containerStyle}>
{isSolid ? (
<LinearGradient
colors={
variant === 'danger'
? [theme.colors.danger, '#FF586E']
: variant === 'cyber'
? ['#2563EB', '#3B82F6']
: [theme.colors.primary, theme.colors.primary]
}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? title}
accessibilityState={{ disabled: inert, busy: loading }}
disabled={inert}
onPress={handlePress}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
style={[
styles.button,
{
minHeight: height,
borderRadius: theme.radius.md,
backgroundColor: surface.backgroundColor,
borderColor: surface.borderColor,
width: fullWidth ? '100%' : undefined,
paddingHorizontal: size === 'sm' ? theme.spacing.sm : theme.spacing.lg,
},
// A secondary button is the one variant that reads as a raised object;
// ghost and the solid fills carry their own weight through colour.
variant === 'secondary' && !disabled && theme.elevation.level1,
style,
]}
>
{/* The label stays mounted while loading so the button cannot change
width mid-request and shift the layout under the user's thumb. */}
{loading ? (
<View style={styles.loadingOverlay} pointerEvents="none">
<ActivityIndicator size="small" color={surface.label} />
</View>
) : null}
<View style={[styles.content, loading && styles.contentHidden]}>
{leftAccessory}
<Text
numberOfLines={1}
style={[
styles.gradient,
{
borderRadius: theme.radius.md,
},
theme.text.button,
size === 'sm' && styles.labelSmall,
{ color: surface.label },
textStyle,
]}
>
{content}
</LinearGradient>
) : (
content
)}
{title}
</Text>
{rightAccessory}
</View>
</Pressable>
);
};
const styles = StyleSheet.create({
button: {
borderWidth: 1,
overflow: 'hidden',
},
gradient: {
width: '100%',
},
content: {
minHeight: 54,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
paddingHorizontal: 18,
overflow: 'hidden',
},
label: {
fontSize: 15,
fontWeight: '700',
content: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
contentHidden: {
opacity: 0,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
},
labelSmall: {
fontSize: 13,
lineHeight: 18,
},
});
+52 -13
View File
@@ -1,30 +1,68 @@
import React, { memo, type PropsWithChildren } from 'react';
import {
StyleSheet,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { useTheme } from '../../theme';
/**
* `default` is the resting card and the right answer almost always.
*
* `flat` has a border and no lift — use it for a card that sits inside another
* card, or for a grouped list where a shadow per row would be noise.
*
* `raised` is for the one object on a screen that has to sit above everything
* else, typically the hero balance card. Two raised cards on one screen means
* neither of them is.
*
* `muted` is a well rather than an object — a summary block, a read-only recap.
*
* `glass` is kept only because 13 call sites still pass it. There is no blur
* library in this app, so it never actually rendered as glass; it resolves to
* `default`.
*/
type CardVariant = 'default' | 'flat' | 'raised' | 'muted' | 'glass';
interface CardProps extends PropsWithChildren {
style?: StyleProp<ViewStyle>;
variant?: 'default' | 'glass';
variant?: CardVariant;
/** Drops the built-in padding, for a card that holds an edge-to-edge list. */
noPadding?: boolean;
}
const CardComponent = ({ children, style, variant = 'default' }: CardProps) => {
const CardComponent = ({
children,
style,
variant = 'default',
noPadding = false,
}: CardProps) => {
const theme = useTheme();
const { backgroundColor, elevation } = ((): {
backgroundColor: string;
elevation: ViewStyle;
} => {
switch (variant) {
case 'flat':
return { backgroundColor: theme.colors.surface, elevation: theme.elevation.level0 };
case 'raised':
return { backgroundColor: theme.colors.surface, elevation: theme.elevation.level2 };
case 'muted':
return { backgroundColor: theme.colors.surfaceMuted, elevation: theme.elevation.level0 };
case 'glass':
case 'default':
default:
return { backgroundColor: theme.colors.surface, elevation: theme.elevation.level1 };
}
})();
return (
<View
style={[
styles.card,
theme.shadows.soft,
elevation,
{
backgroundColor: variant === 'glass' ? theme.colors.cardGlass : theme.colors.surface,
borderColor: theme.colors.cardBorder,
backgroundColor,
borderRadius: theme.radius.lg,
padding: noPadding ? 0 : theme.spacing.md,
},
style,
]}
@@ -34,11 +72,12 @@ const CardComponent = ({ children, style, variant = 'default' }: CardProps) => {
);
};
const styles = StyleSheet.create({
card: {
borderWidth: 1,
padding: 18,
// Clipping is what lets a card hold an edge-to-edge list or image without
// the child squaring off the card's own corners.
overflow: 'hidden',
},
});
+16 -17
View File
@@ -12,7 +12,7 @@ import {
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { useTheme } from '../../theme';
import { useTheme, fontFamily } from '../../theme';
const SCREEN_WIDTH = Dimensions.get('window').width;
const DAY_SIZE = Math.floor((SCREEN_WIDTH - 80) / 7);
@@ -273,8 +273,8 @@ export const DatePicker = ({
style={[
styles.dayText,
{ color: theme.colors.textPrimary },
isSelected && { color: theme.colors.white, fontWeight: '800' },
isToday && !isSelected && { color: theme.colors.primary, fontWeight: '700' },
isSelected && { color: theme.colors.white, fontFamily: fontFamily.bold },
isToday && !isSelected && { color: theme.colors.primary, fontFamily: fontFamily.bold },
disabled && { color: theme.colors.textMuted, opacity: 0.4 },
]}
>
@@ -287,7 +287,7 @@ export const DatePicker = ({
</View>
{/* Footer */}
<View style={styles.calendarFooter}>
<View style={[styles.calendarFooter, { borderTopColor: theme.colors.cardBorder }]}>
<Pressable onPress={() => setVisible(false)} style={[styles.footerBtn, { borderColor: theme.colors.cardBorder }]}>
<Text style={[styles.footerBtnText, { color: theme.colors.textMuted }]}>Cancel</Text>
</Pressable>
@@ -299,7 +299,7 @@ export const DatePicker = ({
}}
style={[styles.footerBtn, styles.todayBtn, { backgroundColor: `${theme.colors.primary}12` }]}
>
<Text style={[styles.footerBtnText, { color: theme.colors.primary, fontWeight: '700' }]}>Today</Text>
<Text style={[styles.footerBtnText, { color: theme.colors.primary, fontFamily: fontFamily.bold }]}>Today</Text>
</Pressable>
</View>
</>
@@ -382,7 +382,7 @@ export const DatePicker = ({
styles.yearRowText,
{
color: isActive ? theme.colors.primary : theme.colors.textPrimary,
fontWeight: isActive ? '800' : '500',
fontFamily: isActive ? fontFamily.bold : fontFamily.medium,
},
]}
>
@@ -462,7 +462,7 @@ const styles = StyleSheet.create({
},
label: {
fontSize: 11,
fontWeight: '700',
fontFamily: fontFamily.bold,
letterSpacing: 0.5,
marginLeft: 4,
},
@@ -477,11 +477,11 @@ const styles = StyleSheet.create({
inputText: {
flex: 1,
fontSize: 15,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
errorText: {
fontSize: 12,
fontWeight: '500',
fontFamily: fontFamily.medium,
marginTop: 2,
marginLeft: 4,
},
@@ -528,11 +528,11 @@ const styles = StyleSheet.create({
},
monthText: {
fontSize: 18,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
yearText: {
fontSize: 13,
fontWeight: '600',
fontFamily: fontFamily.semibold,
marginTop: 2,
},
@@ -544,7 +544,7 @@ const styles = StyleSheet.create({
},
weekdayText: {
fontSize: 12,
fontWeight: '700',
fontFamily: fontFamily.bold,
letterSpacing: 0.5,
},
daysGrid: {
@@ -558,7 +558,7 @@ const styles = StyleSheet.create({
},
dayText: {
fontSize: 14,
fontWeight: '500',
fontFamily: fontFamily.medium,
},
// ── Footer ───────────────────────────────────
@@ -569,7 +569,6 @@ const styles = StyleSheet.create({
marginTop: 16,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.05)',
},
footerBtn: {
paddingHorizontal: 20,
@@ -583,7 +582,7 @@ const styles = StyleSheet.create({
},
footerBtnText: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamily.semibold,
},
// ── Month / Year picker shared ───────────────
@@ -595,7 +594,7 @@ const styles = StyleSheet.create({
},
pickerTitle: {
fontSize: 17,
fontWeight: '800',
fontFamily: fontFamily.bold,
},
// ── Month picker grid ────────────────────────
@@ -615,7 +614,7 @@ const styles = StyleSheet.create({
},
monthCellText: {
fontSize: 15,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
// ── Year picker list ─────────────────────────
+2 -2
View File
@@ -5,7 +5,7 @@ import {
View,
} from 'react-native';
import { useTheme } from '../../theme';
import { useTheme, fontFamily } from '../../theme';
import { Button } from '../Button';
interface EmptyStateProps {
@@ -57,7 +57,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 18,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
description: {
fontSize: 14,
+164
View File
@@ -0,0 +1,164 @@
import React, { memo } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../theme';
type InfoBannerTone = 'info' | 'warning' | 'danger' | 'success' | 'brand';
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
interface InfoBannerProps {
/** Optional — a one-line banner reads better without one. */
title?: string;
message: string;
tone?: InfoBannerTone;
/** Overrides the tone's default icon. */
icon?: IoniconName;
/** Renders a text action beneath the message. */
actionLabel?: string;
onActionPress?: () => void;
style?: StyleProp<ViewStyle>;
}
/**
* An inline notice — the thing to reach for instead of a toast when the message
* has to stay on screen while the customer fixes something.
*
* The tone is carried by an icon as well as a colour, because a red border
* alone tells a colour-blind customer nothing, and tells an agent holding the
* phone in direct sunlight even less.
*/
const DEFAULT_ICON: Record<InfoBannerTone, IoniconName> = {
info: 'information-circle',
warning: 'warning',
danger: 'alert-circle',
success: 'checkmark-circle',
brand: 'shield-checkmark',
};
const InfoBannerComponent = ({
title,
message,
tone = 'info',
icon,
actionLabel,
onActionPress,
style,
}: InfoBannerProps) => {
const theme = useTheme();
const palette = ((): { fg: string; bg: string; border: string } => {
switch (tone) {
case 'warning':
return {
fg: theme.colors.warning,
bg: theme.colors.warningSurface,
border: theme.colors.warningBorder,
};
case 'danger':
return {
fg: theme.colors.danger,
bg: theme.colors.dangerSurface,
border: theme.colors.dangerBorder,
};
case 'success':
return {
fg: theme.colors.success,
bg: theme.colors.successSurface,
border: theme.colors.successBorder,
};
case 'brand':
return {
fg: theme.colors.primary,
bg: theme.colors.primarySurface,
border: theme.colors.primaryBorder,
};
case 'info':
default:
return {
fg: theme.colors.info,
bg: theme.colors.infoSurface,
border: theme.colors.infoBorder,
};
}
})();
return (
<View
// Announced when it appears, which is the point of an inline notice that
// replaces a toast.
accessibilityRole="alert"
accessibilityLabel={title ? `${title}. ${message}` : message}
style={[
styles.banner,
{
backgroundColor: palette.bg,
borderColor: palette.border,
borderRadius: theme.radius.md,
padding: theme.spacing.sm,
gap: theme.spacing.sm,
},
style,
]}
>
<Ionicons
name={icon ?? DEFAULT_ICON[tone]}
size={18}
color={palette.fg}
style={styles.icon}
/>
<View style={styles.body}>
{title ? (
<Text style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}>{title}</Text>
) : null}
{/* The message stays in secondary ink rather than the tone colour.
Tinting a whole paragraph is what makes these banners shout — the
icon and the tinted surface already carry the tone. */}
<Text style={[theme.text.bodySmall, { color: theme.colors.textSecondary }]}>{message}</Text>
{actionLabel && onActionPress ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={actionLabel}
onPress={onActionPress}
// A text link inside a banner is the easiest place to ship a 20pt
// target by accident; the hitSlop is what brings it to 44.
hitSlop={12}
style={styles.action}
>
<Text style={[theme.text.button, { color: palette.fg }]}>{actionLabel}</Text>
<Ionicons name="chevron-forward" size={15} color={palette.fg} />
</Pressable>
) : null}
</View>
</View>
);
};
const styles = StyleSheet.create({
banner: {
flexDirection: 'row',
borderWidth: 1,
},
icon: {
// Optically aligns the icon with the first line of text rather than with
// the centre of a paragraph that may run to four lines.
marginTop: 1,
},
body: {
flex: 1,
gap: 2,
},
action: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
marginTop: 6,
minHeight: 24,
},
});
export const InfoBanner = memo(InfoBannerComponent);
+157 -60
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useCallback, useState } from 'react';
import {
StyleSheet,
Text,
@@ -6,75 +6,158 @@ import {
View,
type TextInputProps,
} from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolateColor,
withRepeat,
Easing
} from 'react-native-reanimated';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../theme';
interface InputProps extends TextInputProps {
label?: string;
error?: string;
/** Helper text under the field. Hidden while `error` is showing. */
hint?: string;
/** Adds a marker to the label. Does not itself validate anything. */
required?: boolean;
/**
* A short fixed prefix inside the field, separated by a vertical rule — the
* "+91" in front of a mobile number is the case this exists for.
*/
prefix?: string;
leftAccessory?: React.ReactNode;
rightAccessory?: React.ReactNode;
// Pulled out of the spread so it can be merged *after* the base styles below.
// Spread alone left it silently discarded, because the explicit `style` prop
// on the TextInput overwrote it — which is why SupportScreen's description
// box had been rendering as a single line despite asking for four.
style?: TextInputProps['style'];
}
export const Input = ({
label,
error,
hint,
required = false,
prefix,
leftAccessory,
rightAccessory,
style,
multiline,
editable = true,
onFocus,
onBlur,
...props
}: InputProps) => {
const theme = useTheme();
const focused = useSharedValue(0);
const [focused, setFocused] = useState(false);
const handleFocus = () => {
focused.value = withTiming(1, { duration: 250 });
};
// The parameter type is inferred from React Native's own prop type rather
// than named outright, because the focus/blur event type has been renamed
// across RN versions and hard-coding it breaks on upgrade.
const handleFocus: NonNullable<TextInputProps['onFocus']> = useCallback(
(event) => {
setFocused(true);
onFocus?.(event);
},
[onFocus],
);
const handleBlur = () => {
focused.value = withTiming(0, { duration: 250 });
};
const handleBlur: NonNullable<TextInputProps['onBlur']> = useCallback(
(event) => {
setFocused(false);
onBlur?.(event);
},
[onBlur],
);
const containerAnimatedStyle = useAnimatedStyle(() => ({
borderColor: interpolateColor(
focused.value,
[0, 1],
[theme.colors.cardBorder, theme.colors.primary]
),
backgroundColor: interpolateColor(
focused.value,
[0, 1],
['rgba(0, 0, 0, 0.02)', '#FFFFFF']
),
transform: [{ scale: withTiming(focused.value === 1 ? 1.01 : 1, { duration: 200 }) }],
}));
/**
* Border width is held constant across every state on purpose. Thickening it
* on focus reflows the row and nudges the text under the caret, and the
* previous version also scaled the whole field to 1.01 — a money field that
* jumps as you tap it does not read as a bank. The state change is carried
* by colour and by the surface behind the field instead.
*/
const borderColor = error
? theme.colors.danger
: focused
? theme.colors.primary
: theme.colors.cardBorder;
const backgroundColor = !editable
? theme.colors.surfaceMuted
: error
? theme.colors.dangerSurface
: theme.colors.surface;
return (
<View style={styles.container}>
{label && (
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>
{label.toUpperCase()}
{label ? (
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>
{label}
{required ? <Text style={{ color: theme.colors.danger }}> *</Text> : null}
</Text>
)}
<Animated.View style={[styles.inputWrapper, containerAnimatedStyle]}>
{leftAccessory && <View style={styles.accessory}>{leftAccessory}</View>}
) : null}
<View
style={[
styles.field,
{
minHeight: theme.layout.controlHeight,
borderRadius: theme.radius.md,
borderColor,
backgroundColor,
paddingHorizontal: theme.spacing.md,
},
// A multiline field cannot live inside a fixed 52pt row, so the
// wrapper grows and its content aligns to the top instead of centring.
multiline && styles.fieldMultiline,
]}
>
{leftAccessory ? <View style={styles.accessoryLeft}>{leftAccessory}</View> : null}
{prefix ? (
<View style={styles.prefix}>
<Text style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}>
{prefix}
</Text>
<View style={[styles.prefixRule, { backgroundColor: theme.colors.cardBorder }]} />
</View>
) : null}
<TextInput
{...props}
editable={editable}
multiline={multiline}
onFocus={handleFocus}
onBlur={handleBlur}
placeholderTextColor={theme.colors.textMuted}
style={[styles.input, { color: theme.colors.textPrimary }]}
accessibilityLabel={props.accessibilityLabel ?? label}
style={[
theme.text.body,
styles.input,
multiline && styles.inputMultiline,
{ color: editable ? theme.colors.textPrimary : theme.colors.textSecondary },
style,
]}
/>
{rightAccessory && <View style={styles.accessory}>{rightAccessory}</View>}
</Animated.View>
{error && <Text style={[styles.errorText, { color: theme.colors.error }]}>{error}</Text>}
{rightAccessory ? <View style={styles.accessoryRight}>{rightAccessory}</View> : null}
</View>
{/* An error is not signalled by colour alone — the icon carries it too,
which is what a colour-blind or sunlit user actually reads. */}
{error ? (
<View style={styles.message}>
<Ionicons name="alert-circle" size={14} color={theme.colors.danger} />
<Text style={[theme.text.caption, styles.messageText, { color: theme.colors.danger }]}>
{error}
</Text>
</View>
) : hint ? (
<View style={styles.message}>
<Text style={[theme.text.caption, styles.messageText, { color: theme.colors.textMuted }]}>
{hint}
</Text>
</View>
) : null}
</View>
);
};
@@ -83,35 +166,49 @@ const styles = StyleSheet.create({
container: {
gap: 6,
},
label: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.5,
marginLeft: 4,
},
inputWrapper: {
field: {
flexDirection: 'row',
alignItems: 'center',
height: 52,
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 16,
overflow: 'hidden',
},
fieldMultiline: {
alignItems: 'flex-start',
paddingVertical: 12,
},
input: {
flex: 1,
fontSize: 15,
fontWeight: '500',
height: '100%',
// Android adds its own vertical padding to a TextInput; zeroing it is what
// keeps the text optically centred inside the 52pt row.
paddingVertical: 0,
},
accessory: {
inputMultiline: {
minHeight: 96,
textAlignVertical: 'top',
},
prefix: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
marginRight: 10,
opacity: 0.7,
},
errorText: {
fontSize: 12,
fontWeight: '500',
marginTop: 2,
marginLeft: 4,
prefixRule: {
width: 1,
height: 22,
},
accessoryLeft: {
marginRight: 10,
},
// Mirrored, not shared: a trailing accessory needs its gap on the leading
// side, and the old shared style pushed it away from the edge it sits on.
accessoryRight: {
marginLeft: 10,
},
message: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
},
messageText: {
flex: 1,
},
});
+2 -2
View File
@@ -12,7 +12,7 @@ import {
View,
} from 'react-native';
import { useTheme } from '../../theme';
import { useTheme, fontFamily } from '../../theme';
interface AppModalProps {
visible: boolean;
@@ -100,7 +100,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 20,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
description: {
fontSize: 14,
+35 -26
View File
@@ -1,11 +1,9 @@
import React from 'react';
import {
Keyboard,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
TouchableWithoutFeedback,
View,
StatusBar,
type StyleProp,
@@ -62,9 +60,16 @@ export const ScreenShell = ({
}: ScreenShellProps) => {
const theme = useTheme();
// iOS needs 'padding' behavior, Android handles it natively via windowSoftInputMode
// but we use 'height' as a fallback for Android screens that need it
const kavBehavior = Platform.OS === 'ios' ? 'padding' as const : 'height' as const;
/**
* Android is handled by the manifest, not by React.
*
* `windowSoftInputMode="adjustResize"` already shrinks the window when the
* keyboard opens. Running a KeyboardAvoidingView on top of that shrinks the
* content a second time, so every keyboard event triggered two layout passes
* over the whole screen — which is what made lists stutter while scrolling.
* iOS has no equivalent, so it keeps the component.
*/
const usesKeyboardAvoidingView = keyboardAware && Platform.OS === 'ios';
// Base offset accounts for the navigation header + safe area
const baseOffset = Platform.OS === 'ios' ? 90 : 0;
const totalOffset = baseOffset + keyboardExtraOffset;
@@ -83,35 +88,43 @@ export const ScreenShell = ({
]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
// 'interactive' is iOS-only and degrades to 'none' on Android, which left
// the keyboard up for the whole scroll.
keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'on-drag'}
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
>
{children}
</ScrollView>
);
/**
* Non-scrolling content — in practice, a screen whose child is its own list.
*
* This used to be wrapped in a `TouchableWithoutFeedback` that dismissed the
* keyboard on tap. That wrapper claims the touch responder on every touch
* start, so it spent every screen competing with the list inside it for the
* same gesture: swipes were dropped or arrived late. Keyboard dismissal
* belongs to the scrollable itself — `keyboardDismissMode` — which does not
* fight the gesture it is attached to.
*/
const staticContent = (
<View style={styles.flex}>
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<View
style={[
styles.content,
styles.staticContent,
{ paddingBottom: withTabBar ? TAB_BAR_HEIGHT : 0 },
contentContainerStyle,
]}
>
{children}
</View>
</TouchableWithoutFeedback>
<View
style={[
styles.content,
styles.staticContent,
{ paddingBottom: withTabBar ? TAB_BAR_HEIGHT : 0 },
contentContainerStyle,
]}
>
{children}
</View>
);
const content = scrollable ? scrollContent : staticContent;
const innerContent = keyboardAware ? (
const innerContent = usesKeyboardAvoidingView ? (
<KeyboardAvoidingView
behavior={kavBehavior}
behavior="padding"
style={styles.flex}
keyboardVerticalOffset={totalOffset}
>
@@ -124,11 +137,7 @@ export const ScreenShell = ({
const container = (
<SafeAreaView edges={['top']} style={styles.flex}>
{/* ✅ Status Bar Added */}
<StatusBar
barStyle={'dark-content'}
backgroundColor="transparent"
translucent
/>
<StatusBar barStyle={theme.statusBarStyle} backgroundColor="transparent" translucent />
<View style={[styles.frame, transparent && styles.transparentFrame]}>
{title ? (
+179
View File
@@ -0,0 +1,179 @@
import React, { useCallback, useState } from 'react';
import {
Pressable,
StyleSheet,
Text,
View,
type LayoutChangeEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated';
import { useTheme } from '../../theme';
import { haptics } from '../../services/haptics/haptics';
export interface SegmentedControlOption<T extends string> {
value: T;
label: string;
}
interface SegmentedControlProps<T extends string> {
options: ReadonlyArray<SegmentedControlOption<T>>;
value: T;
onChange: (value: T) => void;
disabled?: boolean;
/** `lg` raises the track to the 48pt agent target. */
size?: 'md' | 'lg';
style?: StyleProp<ViewStyle>;
accessibilityLabel?: string;
}
/**
* Switches between two or three views of the same thing.
*
* The thumb is a real view that slides, so it animates on the UI thread and
* never depends on a pseudo-element or a blur — neither of which exists in
* React Native. Its position comes from a measured track width rather than a
* percentage, because a percentage-positioned thumb drifts by a pixel or two
* per segment and the drift is visible at three segments.
*
* Use this for a view switch, not for navigation between unrelated screens, and
* not for more than three options — past three, the labels stop fitting on a
* 360pt phone and a set of Chips is the right control instead.
*/
export function SegmentedControl<T extends string>({
options,
value,
onChange,
disabled = false,
size = 'md',
style,
accessibilityLabel,
}: SegmentedControlProps<T>) {
const theme = useTheme();
const [trackWidth, setTrackWidth] = useState(0);
const activeIndex = Math.max(
0,
options.findIndex((option) => option.value === value),
);
const handleLayout = useCallback((event: LayoutChangeEvent) => {
setTrackWidth(event.nativeEvent.layout.width);
}, []);
const inset = 3;
const segmentWidth = trackWidth > 0 ? (trackWidth - inset * 2) / options.length : 0;
const height = size === 'lg' ? 48 : 44;
const thumbStyle = useAnimatedStyle(
() => ({
transform: [{ translateX: withTiming(segmentWidth * activeIndex, { duration: 180 }) }],
}),
[segmentWidth, activeIndex],
);
const handlePress = useCallback(
(next: T) => {
if (disabled || next === value) {
return;
}
haptics.selection();
onChange(next);
},
[disabled, onChange, value],
);
return (
<View
accessibilityRole="tablist"
accessibilityLabel={accessibilityLabel}
onLayout={handleLayout}
style={[
styles.track,
{
height,
padding: inset,
borderRadius: theme.radius.pill,
backgroundColor: theme.colors.surfaceMuted,
borderColor: theme.colors.cardBorder,
opacity: disabled ? 0.6 : 1,
},
style,
]}
>
{/* Rendered only once the track has been measured — an unmeasured thumb
would flash at zero width on the first frame. */}
{segmentWidth > 0 ? (
<Animated.View
pointerEvents="none"
style={[
styles.thumb,
theme.elevation.level1,
{
width: segmentWidth,
borderRadius: theme.radius.pill,
backgroundColor: theme.colors.surface,
},
thumbStyle,
]}
/>
) : null}
{options.map((option) => {
const active = option.value === value;
return (
<Pressable
key={option.value}
accessibilityRole="tab"
accessibilityState={{ selected: active, disabled }}
accessibilityLabel={option.label}
disabled={disabled}
onPress={() => handlePress(option.value)}
style={styles.segment}
>
<Text
numberOfLines={1}
style={[
active ? theme.text.bodyStrong : theme.text.body,
styles.label,
{
color: active ? theme.colors.primary : theme.colors.textSecondary,
},
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
);
}
const styles = StyleSheet.create({
track: {
flexDirection: 'row',
borderWidth: 1,
// Clips the thumb to the pill on the final segment, where it otherwise
// overhangs the rounded end by a hair.
overflow: 'hidden',
},
thumb: {
position: 'absolute',
top: 3,
left: 3,
bottom: 3,
borderWidth: 1,
borderColor: 'transparent',
},
segment: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
label: {
fontSize: 14,
},
});
+181
View File
@@ -0,0 +1,181 @@
import React, { memo } from 'react';
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../theme';
interface StepperProps {
/** Two to four short labels. Longer than four and they stop fitting a 360pt phone. */
steps: readonly string[];
/** Zero-based. Steps before it are done, steps after it are still to come. */
current: number;
/**
* Set when this sits on the navy brand wash rather than a light surface.
*/
onBrand?: boolean;
style?: StyleProp<ViewStyle>;
}
/**
* Where the customer is in a flow, and what is still ahead of them.
*
* Registration is three screens — choose a SIM, enter the code, set an MPIN —
* and until this existed the first of those gave no hint that two more were
* coming. Someone deciding whether to hand over their number is entitled to
* know how long the process is before they start it.
*
* A completed step is marked with a tick as well as a fill, so progress is not
* carried by colour alone. The whole thing is inert: it reports position, it is
* not a way to navigate, and steps are not tappable — you cannot jump to an OTP
* screen before an OTP has been sent.
*/
const StepperComponent = ({ steps, current, onBrand = false, style }: StepperProps) => {
const theme = useTheme();
/**
* The connector, and the outline of a step not yet reached.
*
* On brand this is a white at 0.55 rather than the shared `onBrandBorder`
* (0.24). A border token is measured for a panel edge, which sits next to a
* fill; a hairline on the open wash has nothing beside it and 0.24 came to
* 1.75:1 — under the 3:1 that non-text contrast asks for. At 0.55 it is
* 3.34:1 against the lightest point of the gradient and, at one point tall,
* still reads as a rule rather than a bar.
*
* On a light surface it stays a border tone. The rail is reinforcement in
* either case: sequence is carried by the numbers, and completion by the fill
* and the tick, so a reader who cannot make out the line loses nothing.
*/
const railColour = onBrand ? 'rgba(255, 255, 255, 0.55)' : theme.colors.cardBorderStrong;
const doneColour = onBrand ? theme.colors.onBrandPrimary : theme.colors.primary;
return (
<View
accessibilityRole="progressbar"
accessibilityLabel={`Step ${current + 1} of ${steps.length}: ${steps[current] ?? ''}`}
style={[styles.container, style]}
>
{steps.map((label, index) => {
const done = index < current;
const active = index === current;
/**
* A done or active marker is a solid fill, so the number and tick on it
* are the solid tone — the translucent on-brand tiers are measured
* against the bare wash and lose their contrast over a fill.
*/
const markerFill = done || active ? doneColour : 'transparent';
const markerBorder = done || active ? doneColour : railColour;
const markerInk = done || active
? onBrand
? theme.colors.primary
: theme.colors.textOnBrand
: onBrand
? theme.colors.onBrandSecondary
: theme.colors.textMuted;
const labelColour = active
? onBrand
? theme.colors.onBrandPrimary
: theme.colors.textPrimary
: onBrand
? theme.colors.onBrandSecondary
: theme.colors.textMuted;
return (
<View key={label} style={styles.step}>
{/* The rails are halves either side of the marker, each flexing to
fill its own gap. Drawing one line behind the whole row instead
would need an absolute overlay measured against the markers,
which drifts as soon as a label wraps. */}
<View style={styles.railRow}>
<View
style={[
styles.rail,
{ backgroundColor: index === 0 ? 'transparent' : railColour },
]}
/>
<View
style={[
styles.marker,
{ backgroundColor: markerFill, borderColor: markerBorder },
]}
>
{done ? (
<Ionicons name="checkmark" size={13} color={markerInk} />
) : (
<Text style={[theme.text.eyebrow, styles.markerText, { color: markerInk }]}>
{index + 1}
</Text>
)}
</View>
<View
style={[
styles.rail,
{
backgroundColor: index === steps.length - 1 ? 'transparent' : railColour,
},
]}
/>
</View>
<Text
numberOfLines={2}
style={[
active ? theme.text.bodyStrong : theme.text.bodySmall,
styles.label,
{ color: labelColour },
]}
>
{label}
</Text>
</View>
);
})}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-start',
},
step: {
flex: 1,
alignItems: 'center',
gap: 7,
},
railRow: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'stretch',
},
rail: {
flex: 1,
height: 1,
},
marker: {
width: 24,
height: 24,
borderRadius: 12,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
markerText: {
fontSize: 11,
lineHeight: 14,
letterSpacing: 0,
},
label: {
fontSize: 12,
lineHeight: 16,
textAlign: 'center',
paddingHorizontal: 2,
},
});
export const Stepper = memo(StepperComponent);
+2 -2
View File
@@ -10,7 +10,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppDispatch, useAppSelector } from '../../app/hooks';
import { selectToasts } from '../../modules/ui/store/uiSelectors';
import { uiActions } from '../../modules/ui/store/uiSlice';
import { useTheme } from '../../theme';
import { useTheme, fontFamily } from '../../theme';
interface ToastCardProps {
id: string;
@@ -106,7 +106,7 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 14,
fontWeight: '700',
fontFamily: fontFamily.bold,
},
description: {
fontSize: 12,
+51 -32
View File
@@ -7,7 +7,7 @@ import {
import Ionicons from '@react-native-vector-icons/ionicons';
import type { TransactionRecord } from '../../types/app';
import { useTheme } from '../../theme';
import { useTheme, textStyles } from '../../theme';
import { formatCurrency, formatDate } from '../../utils/formatters';
interface TransactionItemProps {
@@ -28,48 +28,59 @@ const TransactionItemComponent = ({ item }: TransactionItemProps) => {
}, [item.id]);
const riskConfig = {
safe: { color: theme.colors.success, label: 'SECURE', icon: 'shield-checkmark' as const },
medium: { color: theme.colors.warning, label: 'FLAGGED', icon: 'warning' as const },
high: { color: theme.colors.danger, label: 'HIGH RISK', icon: 'alert-circle' as const },
safe: {
color: theme.colors.success,
surface: theme.colors.successSurface,
border: theme.colors.successBorder,
label: 'SECURE',
icon: 'shield-checkmark' as const,
},
medium: {
color: theme.colors.warning,
surface: theme.colors.warningSurface,
border: theme.colors.warningBorder,
label: 'FLAGGED',
icon: 'warning' as const,
},
high: {
color: theme.colors.danger,
surface: theme.colors.dangerSurface,
border: theme.colors.dangerBorder,
label: 'HIGH RISK',
icon: 'alert-circle' as const,
},
}[riskLevel];
return (
<View
style={[
styles.wrapper,
{
borderBottomColor: 'rgba(255, 255, 255, 0.05)',
},
]}
>
<View style={[styles.wrapper, { borderBottomColor: theme.colors.cardBorder }]}>
<View style={styles.leading}>
<View
style={[
styles.icon,
{
backgroundColor: isCredit
? `${theme.colors.success}10`
: 'rgba(255, 255, 255, 0.05)',
borderColor: isCredit ? `${theme.colors.success}30` : 'rgba(255, 255, 255, 0.1)',
? theme.colors.successSurface
: theme.colors.surfaceMuted,
borderColor: isCredit ? theme.colors.successBorder : theme.colors.cardBorder,
},
]}
>
<Ionicons
name={isCredit ? 'chevron-down' : 'chevron-up'}
size={18}
color={isCredit ? theme.colors.success : theme.colors.white}
color={isCredit ? theme.colors.success : theme.colors.textSecondary}
/>
</View>
<View style={styles.copy}>
<View style={styles.titleRow}>
<Text style={[styles.title, { color: theme.colors.white }]}>{item.title}</Text>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{item.title}</Text>
</View>
<View style={styles.subtitleRow}>
<Text style={[styles.subtitle, { color: 'rgba(255, 255, 255, 0.5)' }]}>
<Text style={[styles.subtitle, { color: theme.colors.textMuted }]}>
{item.counterparty}
</Text>
<View style={styles.dot} />
<Text style={[styles.subtitle, { color: 'rgba(255, 255, 255, 0.5)' }]}>
<View style={[styles.dot, { backgroundColor: theme.colors.textMuted }]} />
<Text style={[styles.subtitle, { color: theme.colors.textMuted }]}>
{formatDate(item.occurredAt)}
</Text>
</View>
@@ -80,13 +91,18 @@ const TransactionItemComponent = ({ item }: TransactionItemProps) => {
<Text
style={[
styles.amount,
{ color: isCredit ? theme.colors.success : theme.colors.white },
{ color: isCredit ? theme.colors.success : theme.colors.textPrimary },
]}
>
{isCredit ? '+' : '-'}
{formatCurrency(item.amount)}
</Text>
<View style={[styles.riskBadge, { backgroundColor: `${riskConfig.color}10`, borderColor: `${riskConfig.color}20` }]}>
<View
style={[
styles.riskBadge,
{ backgroundColor: riskConfig.surface, borderColor: riskConfig.border },
]}
>
<Text style={[styles.riskText, { color: riskConfig.color }]}>{riskConfig.label}</Text>
</View>
</View>
@@ -125,9 +141,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
title: {
fontSize: 16,
fontWeight: '800',
letterSpacing: -0.3,
...textStyles.h3,
},
subtitleRow: {
flexDirection: 'row',
@@ -138,20 +152,17 @@ const styles = StyleSheet.create({
width: 3,
height: 3,
borderRadius: 1.5,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
backgroundColor: 'transparent', // set from the theme at the call site
},
subtitle: {
fontSize: 12,
fontWeight: '500',
...textStyles.caption,
},
trailing: {
alignItems: 'flex-end',
gap: 4,
},
amount: {
fontSize: 16,
fontWeight: '800',
letterSpacing: -0.2,
...textStyles.money,
},
riskBadge: {
paddingHorizontal: 6,
@@ -159,9 +170,17 @@ const styles = StyleSheet.create({
borderRadius: 6,
borderWidth: 1,
},
// Takes its family and casing from `eyebrow`, then overrides the size.
//
// 8pt is below the scale's floor of 11 and the scale has no entry for it on
// purpose — at this size the label is decoration, not something a customer
// can be expected to read. Left at 8 rather than snapped up to 11 because
// the badge is laid out around it; worth revisiting as a design question,
// since a risk flag nobody can read is not doing its job.
riskText: {
...textStyles.eyebrow,
fontSize: 8,
fontWeight: '900',
lineHeight: 11,
letterSpacing: 0.5,
},
});
+7 -1
View File
@@ -1,15 +1,21 @@
export * from './ActivityTracker';
export * from './AppearanceSetting';
export * from './AccountSelector';
export * from './AppHeader';
export * from './BalanceCard';
export * from './AccountCard';
export * from './BottomSheet';
export * from './Button';
export * from './Card';
export * from './DatePicker';
export * from './EmptyState';
export * from './InfoBanner';
export * from './Input';
export * from './KeyboardAwareScrollView';
export * from './KeyboardSafeView';
export * from './Loader';
export * from './Modal';
export * from './ScreenShell';
export * from './Stepper';
export * from './SegmentedControl';
export * from './ToastHost';
export * from './TransactionItem';