Update CBS Mobile App codebase
This commit is contained in:
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user