Add Positive Pay, statement guide v1.0, onboarding, and switch every feature on

- Positive Pay on the 12 Oct /pps contract (account number, not account ID):
  OTP enrollment, cheque submit, saved cheques, detail and stop payment.
  Full flow passed on the phone against mobile-banking-backend.lincpay.
- Statement & Mini Statement guide v1.0 (14 Oct): mini statement JSON on
  Home and the Statement screen; dated PDF export with the password rule.
- Onboarding slides and splash screen, Inter font, shared Icon and
  TypewriterText components, nearby branches via OpenStreetMap.
- Every feature switched on; the 14 Oct switch set is kept in
  featureConfig.stage-2026-10-14.ts. Loans run on demo data until the
  backend has /loans routes.
- npm run android wraps run-android for shells spawned from VS Code.
- OpenProject work packages updated to 14-10-2026 status, with screenshots
  of Positive Pay, statement, loans and cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Abin Toppo
2026-10-14 18:18:40 +05:30
co-authored by Claude Opus 5.5
parent 5fbd13ba12
commit f960a119e8
510 changed files with 20268 additions and 6483 deletions
+2
View File
@@ -7,6 +7,7 @@ import { transferReducer } from '../modules/transfer/store/transferSlice';
import { paymentsReducer } from '../modules/payments/store/paymentsSlice';
import { depositReducer } from '../modules/deposit/store/depositSlice';
import { serviceReducer } from '../modules/services/store/serviceSlice';
import { ppsReducer } from '../modules/services/store/ppsSlice';
import { loanReducer } from '../modules/loan/store/loanSlice';
import { insuranceReducer } from '../modules/insurance/store/insuranceSlice';
import { cardsReducer } from '../modules/cards/store/cardsSlice';
@@ -26,6 +27,7 @@ const appReducer = combineReducers({
payments: paymentsReducer,
deposit: depositReducer,
service: serviceReducer,
pps: ppsReducer,
loan: loanReducer,
insurance: insuranceReducer,
cards: cardsReducer,
+41 -33
View File
@@ -1,7 +1,7 @@
import React, { memo, useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../Icon';
import type { CbsAccount } from '../../types/cbs';
import { useTheme, textStyles } from '../../theme';
@@ -74,30 +74,33 @@ const AccountCardComponent = ({
const showFull = revealed && canRevealNumber;
const displayNumber = showFull
? account.accountNumber
: `XXXX ${account.accountNumber.slice(-4)}`;
: `•••• ${account.accountNumber.slice(-4)}`;
return (
<LinearGradient
colors={theme.colors.brandGradient}
colors={theme.colors.heroGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.card, styles.gradientCard, { borderRadius: theme.radius.xl }]}
>
{/* Two overlapping translucent discs — the card's only ornament. Kept at
the on-brand surface tier so the text above them stays legible. */}
<View style={[styles.brandAccent, { backgroundColor: theme.colors.onBrandSurface }]} />
<View style={[styles.brandAccentLow, { 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} />
<Icon 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 }]}>
{accountTypeLabel(account.accountType).toUpperCase()}
{accountTypeLabel(account.accountType)}
</Text>
</View>
</View>
@@ -116,7 +119,7 @@ const AccountCardComponent = ({
<View style={styles.mainContent}>
<Text style={[styles.numberLabel, { color: theme.colors.onBrandSecondary }]}>
ACCOUNT NUMBER
Account number
</Text>
<View style={styles.numberRow}>
<Text
@@ -133,7 +136,7 @@ const AccountCardComponent = ({
accessibilityRole="button"
accessibilityLabel={showFull ? 'Hide account number' : 'Show account number'}
>
<Ionicons
<Icon
name={showFull ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={theme.colors.onBrandPrimary}
@@ -165,7 +168,7 @@ const AccountCardComponent = ({
</View>
) : balanceError ? (
<View style={styles.balanceRow}>
<Ionicons
<Icon
name="alert-circle-outline"
size={20}
color={theme.colors.onBrandPrimary}
@@ -185,7 +188,7 @@ const AccountCardComponent = ({
<View style={styles.balanceRow}>
<View style={styles.balanceCopy}>
<Text style={[styles.balanceLabel, { color: theme.colors.onBrandMuted }]}>
AVAILABLE BALANCE
Available balance
</Text>
<Text style={[styles.balanceValue, { color: theme.colors.onBrandPrimary }]}>
{formatCurrency(balance)}
@@ -193,15 +196,15 @@ const AccountCardComponent = ({
</View>
{/* A balance is only true at the instant it was fetched, so the
way to get a fresh one stays on screen next to the figure. */}
<Ionicons name="refresh-outline" size={18} color={theme.colors.onBrandSecondary} />
<Icon name="refresh-outline" size={18} color={theme.colors.onBrandSecondary} />
</View>
) : (
<View style={styles.balanceRow}>
<Ionicons name="eye-outline" size={20} color={theme.colors.onBrandPrimary} />
<Icon name="eye-outline" size={20} color={theme.colors.onBrandPrimary} />
<Text style={[styles.balanceAction, { color: theme.colors.onBrandPrimary }]}>
Check balance
</Text>
<Ionicons
<Icon
name="chevron-forward"
size={18}
color={theme.colors.onBrandSecondary}
@@ -214,7 +217,7 @@ const AccountCardComponent = ({
<View style={styles.footerRow}>
<View style={styles.footerItem}>
<Text style={[styles.footerLabel, { color: theme.colors.onBrandMuted }]}>
{accountHolder ? 'ACCOUNT HOLDER' : 'IFSC'}
{accountHolder ? 'Account holder' : 'IFSC'}
</Text>
<Text style={[styles.footerValue, { color: theme.colors.onBrandPrimary }]}>
{accountHolder ?? account.ifsc}
@@ -235,22 +238,30 @@ const AccountCardComponent = ({
const styles = StyleSheet.create({
card: {
padding: 24,
gap: 32,
padding: 22,
gap: 26,
position: 'relative',
minHeight: 260,
minHeight: 220,
},
gradientCard: {
overflow: 'hidden',
},
brandAccent: {
position: 'absolute',
top: -50,
right: -50,
top: -60,
right: -40,
width: 200,
height: 200,
borderRadius: 100,
},
brandAccentLow: {
position: 'absolute',
bottom: -90,
right: 40,
width: 180,
height: 180,
borderRadius: 90,
},
topRow: {
flexDirection: 'row',
justifyContent: 'space-between',
@@ -273,7 +284,6 @@ const styles = StyleSheet.create({
},
cardType: {
...textStyles.eyebrow,
fontSize: 10,
},
statusBadge: {
paddingHorizontal: 12,
@@ -282,27 +292,27 @@ const styles = StyleSheet.create({
},
statusBadgeText: {
...textStyles.eyebrow,
fontSize: 10,
letterSpacing: 0.5,
fontSize: 11,
},
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.
// An account number, so it is set with tabular digits — a customer reads
// these back a group at a time.
numberValue: {
...textStyles.code,
fontFamily: textStyles.h2.fontFamily,
flex: 1,
fontSize: 22,
letterSpacing: 1.5,
lineHeight: 28,
letterSpacing: 2,
},
revealButton: {
marginLeft: 12,
@@ -330,14 +340,13 @@ const styles = StyleSheet.create({
},
balanceLabel: {
...textStyles.eyebrow,
fontSize: 9,
letterSpacing: 1,
},
// Mono, like every other figure in the app — the theme's own reason is that
// proportional digits reflow as a number changes.
// Tabular, like every other figure in the app — proportional digits reflow
// as a number changes.
balanceValue: {
...textStyles.moneyLarge,
fontSize: 22,
...textStyles.balance,
fontSize: 26,
lineHeight: 32,
},
balanceAction: {
...textStyles.bodyStrong,
@@ -363,8 +372,7 @@ const styles = StyleSheet.create({
},
footerLabel: {
...textStyles.eyebrow,
fontSize: 9,
letterSpacing: 1,
fontSize: 11,
},
footerValue: {
...textStyles.bodyStrong,
+21 -14
View File
@@ -6,7 +6,7 @@ import {
View,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../Icon';
import { useTheme, fontFamily } from '../../theme';
@@ -30,23 +30,29 @@ const AppHeaderComponent = ({
return (
<View style={styles.wrapper}>
<View style={styles.leading}>
{/* A bare arrow, not a bordered chip: the header is a single quiet
line — arrow, then title — and the press state is the only fill. */}
{shouldShowBackButton ? (
<Pressable
onPress={() => navigation.goBack()}
style={[
accessibilityRole="button"
accessibilityLabel="Go back"
hitSlop={6}
style={({ pressed }) => [
styles.backButton,
{
backgroundColor: theme.colors.cardGlass,
borderColor: theme.colors.cardBorder,
backgroundColor: pressed ? theme.colors.surfaceMuted : 'transparent',
borderRadius: theme.radius.pill,
},
]}
>
<Ionicons name="chevron-back" size={20} color={theme.colors.textPrimary} />
<Icon name="arrow-back" size={22} color={theme.colors.textPrimary} />
</Pressable>
) : null}
<View style={styles.copy}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
<Text numberOfLines={1} style={[styles.title, { color: theme.colors.textPrimary }]}>
{title}
</Text>
{/* {subtitle ? (
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>{subtitle}</Text>
) : null} */}
@@ -74,21 +80,22 @@ const styles = StyleSheet.create({
gap: 4,
flex: 1,
},
// Sentence case, as the screen names it. The uppercase tracked title this
// replaced shouted every screen name at the same volume.
title: {
fontSize: 20,
fontFamily: fontFamily.bold,
textTransform: "uppercase",
letterSpacing: 0.5,
lineHeight: 20,
fontSize: 18,
fontFamily: fontFamily.semibold,
letterSpacing: -0.3,
lineHeight: 24,
},
subtitle: {
fontSize: 13,
lineHeight: 18,
},
backButton: {
width: 42,
height: 42,
borderWidth: 1,
width: 40,
height: 40,
marginLeft: -6,
alignItems: 'center',
justifyContent: 'center',
},
+2 -2
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../Icon';
import { SegmentedControl, type SegmentedControlOption } from '../SegmentedControl';
import { textStyles, useTheme, useThemeMode, type ThemeMode } from '../../theme';
@@ -46,7 +46,7 @@ export const AppearanceSetting = () => {
*/}
{mode === 'system' ? (
<View style={styles.hintRow}>
<Ionicons
<Icon
name={resolved === 'dark' ? 'moon-outline' : 'sunny-outline'}
size={14}
color={theme.colors.textMuted}
+18 -8
View File
@@ -4,8 +4,6 @@ import {
Keyboard,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
@@ -40,12 +38,24 @@ export const BottomSheet = ({ visible, title, onClose, children }: BottomSheetPr
};
return (
<Modal animationType="fade" transparent visible={visible} onRequestClose={onClose}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.flex}
keyboardVerticalOffset={Platform.OS === 'ios' ? 10 : 0}
>
<Modal
animationType="fade"
transparent
statusBarTranslucent
visible={visible}
onRequestClose={onClose}
>
{/*
Self-correcting on both platforms. The keyboard is measured in screen
coordinates and this view's frame is relative to the modal's window,
which `statusBarTranslucent` starts at the very top of the screen — so
the two line up and no offset is needed. If the window was resized for
the keyboard (Android's adjustResize), the frame already ends at the
keyboard and the padding comes out as zero; if it was not (iOS, or an
edge-to-edge Android 15 window), it pads by exactly the overlap.
`height` on Android shrank a window that had often already shrunk.
*/}
<KeyboardAvoidingView behavior="padding" style={styles.flex}>
<TouchableWithoutFeedback onPress={handleBackdropPress}>
<View style={[styles.backdrop, { backgroundColor: theme.colors.overlay }]}>
<TouchableWithoutFeedback onPress={() => {}}>
+16 -16
View File
@@ -9,7 +9,7 @@ import {
ScrollView,
PanResponder,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../Icon';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { useTheme, fontFamily } from '../../theme';
@@ -208,23 +208,23 @@ export const DatePicker = ({
<>
{/* Header: tappable month & year */}
<View style={styles.calendarHeader}>
<Pressable onPress={prevMonth} hitSlop={12} style={[styles.navBtn, { backgroundColor: `${theme.colors.primary}10` }]}>
<Ionicons name="chevron-back" size={20} color={theme.colors.primary} />
<Pressable onPress={prevMonth} hitSlop={12} style={[styles.navBtn, { backgroundColor: theme.tint(theme.colors.primary, 0.06) }]}>
<Icon name="chevron-back" size={20} color={theme.colors.primary} />
</Pressable>
<View style={styles.monthYearWrap}>
<Pressable onPress={() => setPickerMode('months')} style={styles.headerTappable}>
<Text style={[styles.monthText, { color: theme.colors.primary }]}>{MONTHS[viewMonth]}</Text>
<Ionicons name="chevron-down" size={14} color={theme.colors.primary} style={{ marginLeft: 4 }} />
<Icon name="chevron-down" size={14} color={theme.colors.primary} style={{ marginLeft: 4 }} />
</Pressable>
<Pressable onPress={handleOpenYearPicker} style={styles.headerTappable}>
<Text style={[styles.yearText, { color: theme.colors.textMuted }]}>{viewYear}</Text>
<Ionicons name="chevron-down" size={12} color={theme.colors.textMuted} style={{ marginLeft: 3 }} />
<Icon name="chevron-down" size={12} color={theme.colors.textMuted} style={{ marginLeft: 3 }} />
</Pressable>
</View>
<Pressable onPress={nextMonth} hitSlop={12} style={[styles.navBtn, { backgroundColor: `${theme.colors.primary}10` }]}>
<Ionicons name="chevron-forward" size={20} color={theme.colors.primary} />
<Pressable onPress={nextMonth} hitSlop={12} style={[styles.navBtn, { backgroundColor: theme.tint(theme.colors.primary, 0.06) }]}>
<Icon name="chevron-forward" size={20} color={theme.colors.primary} />
</Pressable>
</View>
@@ -297,7 +297,7 @@ export const DatePicker = ({
setViewYear(today.getFullYear());
handleSelect(today.getDate());
}}
style={[styles.footerBtn, styles.todayBtn, { backgroundColor: `${theme.colors.primary}12` }]}
style={[styles.footerBtn, styles.todayBtn, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}
>
<Text style={[styles.footerBtnText, { color: theme.colors.primary, fontFamily: fontFamily.bold }]}>Today</Text>
</Pressable>
@@ -310,7 +310,7 @@ export const DatePicker = ({
<>
<View style={styles.pickerHeader}>
<Pressable onPress={() => setPickerMode('days')} hitSlop={12}>
<Ionicons name="arrow-back" size={22} color={theme.colors.textPrimary} />
<Icon name="arrow-back" size={22} color={theme.colors.textPrimary} />
</Pressable>
<Text style={[styles.pickerTitle, { color: theme.colors.textPrimary }]}>Select Month</Text>
<View style={{ width: 22 }} />
@@ -326,7 +326,7 @@ export const DatePicker = ({
style={[
styles.monthCell,
{
backgroundColor: isActive ? theme.colors.primary : `${theme.colors.primary}08`,
backgroundColor: isActive ? theme.colors.primary : theme.tint(theme.colors.primary, 0.03),
borderColor: isActive ? theme.colors.primary : theme.colors.cardBorder,
},
]}
@@ -351,7 +351,7 @@ export const DatePicker = ({
<>
<View style={styles.pickerHeader}>
<Pressable onPress={() => setPickerMode('days')} hitSlop={12}>
<Ionicons name="arrow-back" size={22} color={theme.colors.textPrimary} />
<Icon name="arrow-back" size={22} color={theme.colors.textPrimary} />
</Pressable>
<Text style={[styles.pickerTitle, { color: theme.colors.textPrimary }]}>Select Year</Text>
<View style={{ width: 22 }} />
@@ -372,7 +372,7 @@ export const DatePicker = ({
style={[
styles.yearRow,
{
backgroundColor: isActive ? `${theme.colors.primary}12` : 'transparent',
backgroundColor: isActive ? theme.tint(theme.colors.primary, 0.07) : 'transparent',
borderColor: isActive ? theme.colors.primary : 'transparent',
},
]}
@@ -389,7 +389,7 @@ export const DatePicker = ({
{year}
</Text>
{isActive && (
<Ionicons name="checkmark" size={20} color={theme.colors.primary} />
<Icon name="checkmark" size={20} color={theme.colors.primary} />
)}
</Pressable>
);
@@ -413,11 +413,11 @@ export const DatePicker = ({
styles.inputWrapper,
{
borderColor: error ? theme.colors.error : theme.colors.cardBorder,
backgroundColor: 'rgba(0, 0, 0, 0.02)',
backgroundColor: theme.colors.surface,
},
]}
>
<Ionicons name="calendar-outline" size={18} color={theme.colors.primary} style={{ marginRight: 10 }} />
<Icon name="calendar-outline" size={18} color={theme.colors.primary} style={{ marginRight: 10 }} />
<Text
style={[
styles.inputText,
@@ -426,7 +426,7 @@ export const DatePicker = ({
>
{value || placeholder}
</Text>
<Ionicons name="chevron-down" size={16} color={theme.colors.textMuted} />
<Icon name="chevron-down" size={16} color={theme.colors.textMuted} />
</Pressable>
{error && <Text style={[styles.errorText, { color: theme.colors.error }]}>{error}</Text>}
+397
View File
@@ -0,0 +1,397 @@
import React, { memo } from 'react';
import type { StyleProp, ViewStyle } from 'react-native';
import { HugeiconsIcon } from '@hugeicons/react-native';
import AccessibilityIcon from '@hugeicons/core-free-icons/AccessibilityIcon';
import Add01Icon from '@hugeicons/core-free-icons/Add01Icon';
import AddCircleIcon from '@hugeicons/core-free-icons/AddCircleIcon';
import Airplane01Icon from '@hugeicons/core-free-icons/Airplane01Icon';
import AlarmClockIcon from '@hugeicons/core-free-icons/AlarmClockIcon';
import Album01Icon from '@hugeicons/core-free-icons/Album01Icon';
import Alert02Icon from '@hugeicons/core-free-icons/Alert02Icon';
import AlertCircleIcon from '@hugeicons/core-free-icons/AlertCircleIcon';
import Analytics01Icon from '@hugeicons/core-free-icons/Analytics01Icon';
import ArrowDataTransferHorizontalIcon from '@hugeicons/core-free-icons/ArrowDataTransferHorizontalIcon';
import ArrowDown01Icon from '@hugeicons/core-free-icons/ArrowDown01Icon';
import ArrowDown02Icon from '@hugeicons/core-free-icons/ArrowDown02Icon';
import ArrowLeft01Icon from '@hugeicons/core-free-icons/ArrowLeft01Icon';
import ArrowLeft02Icon from '@hugeicons/core-free-icons/ArrowLeft02Icon';
import ArrowRight01Icon from '@hugeicons/core-free-icons/ArrowRight01Icon';
import ArrowRight02Icon from '@hugeicons/core-free-icons/ArrowRight02Icon';
import ArrowUp01Icon from '@hugeicons/core-free-icons/ArrowUp01Icon';
import ArrowUp02Icon from '@hugeicons/core-free-icons/ArrowUp02Icon';
import ArrowUpRight01Icon from '@hugeicons/core-free-icons/ArrowUpRight01Icon';
import Attachment01Icon from '@hugeicons/core-free-icons/Attachment01Icon';
import Award01Icon from '@hugeicons/core-free-icons/Award01Icon';
import BankIcon from '@hugeicons/core-free-icons/BankIcon';
import BarcodeIcon from '@hugeicons/core-free-icons/BarcodeIcon';
import Book02Icon from '@hugeicons/core-free-icons/Book02Icon';
import Bookmark02Icon from '@hugeicons/core-free-icons/Bookmark02Icon';
import Briefcase01Icon from '@hugeicons/core-free-icons/Briefcase01Icon';
import BubbleChatIcon from '@hugeicons/core-free-icons/BubbleChatIcon';
import Calculator01Icon from '@hugeicons/core-free-icons/Calculator01Icon';
import Calendar03Icon from '@hugeicons/core-free-icons/Calendar03Icon';
import Call02Icon from '@hugeicons/core-free-icons/Call02Icon';
import Camera01Icon from '@hugeicons/core-free-icons/Camera01Icon';
import Cancel01Icon from '@hugeicons/core-free-icons/Cancel01Icon';
import CancelCircleIcon from '@hugeicons/core-free-icons/CancelCircleIcon';
import Car01Icon from '@hugeicons/core-free-icons/Car01Icon';
import Car02Icon from '@hugeicons/core-free-icons/Car02Icon';
import ChartDecreaseIcon from '@hugeicons/core-free-icons/ChartDecreaseIcon';
import ChartHistogramIcon from '@hugeicons/core-free-icons/ChartHistogramIcon';
import ChartIncreaseIcon from '@hugeicons/core-free-icons/ChartIncreaseIcon';
import Chatting01Icon from '@hugeicons/core-free-icons/Chatting01Icon';
import CheckmarkCircle02Icon from '@hugeicons/core-free-icons/CheckmarkCircle02Icon';
import CheckmarkSquare02Icon from '@hugeicons/core-free-icons/CheckmarkSquare02Icon';
import CircleIcon from '@hugeicons/core-free-icons/CircleIcon';
import Clock01Icon from '@hugeicons/core-free-icons/Clock01Icon';
import CloudDownloadIcon from '@hugeicons/core-free-icons/CloudDownloadIcon';
import CloudSlowWindIcon from '@hugeicons/core-free-icons/CloudSlowWindIcon';
import CloudUploadIcon from '@hugeicons/core-free-icons/CloudUploadIcon';
import CreditCardIcon from '@hugeicons/core-free-icons/CreditCardIcon';
import CubeIcon from '@hugeicons/core-free-icons/CubeIcon';
import CustomerSupportIcon from '@hugeicons/core-free-icons/CustomerSupportIcon';
import DashboardSquare01Icon from '@hugeicons/core-free-icons/DashboardSquare01Icon';
import Delete02Icon from '@hugeicons/core-free-icons/Delete02Icon';
import DiamondIcon from '@hugeicons/core-free-icons/DiamondIcon';
import Download04Icon from '@hugeicons/core-free-icons/Download04Icon';
import FavouriteIcon from '@hugeicons/core-free-icons/FavouriteIcon';
import File01Icon from '@hugeicons/core-free-icons/File01Icon';
import File02Icon from '@hugeicons/core-free-icons/File02Icon';
import Files02Icon from '@hugeicons/core-free-icons/Files02Icon';
import FilterHorizontalIcon from '@hugeicons/core-free-icons/FilterHorizontalIcon';
import FingerPrintIcon from '@hugeicons/core-free-icons/FingerPrintIcon';
import FirstAidKitIcon from '@hugeicons/core-free-icons/FirstAidKitIcon';
import FlashIcon from '@hugeicons/core-free-icons/FlashIcon';
import FloppyDiskIcon from '@hugeicons/core-free-icons/FloppyDiskIcon';
import FolderOpenIcon from '@hugeicons/core-free-icons/FolderOpenIcon';
import GiftIcon from '@hugeicons/core-free-icons/GiftIcon';
import GitCompareIcon from '@hugeicons/core-free-icons/GitCompareIcon';
import GitPullRequestIcon from '@hugeicons/core-free-icons/GitPullRequestIcon';
import Globe02Icon from '@hugeicons/core-free-icons/Globe02Icon';
import GpsSignal01Icon from '@hugeicons/core-free-icons/GpsSignal01Icon';
import GraduationCapIcon from '@hugeicons/core-free-icons/GraduationCapIcon';
import GridIcon from '@hugeicons/core-free-icons/GridIcon';
import HandPrayerIcon from '@hugeicons/core-free-icons/HandPrayerIcon';
import HealthIcon from '@hugeicons/core-free-icons/HealthIcon';
import HelpCircleIcon from '@hugeicons/core-free-icons/HelpCircleIcon';
import HierarchyIcon from '@hugeicons/core-free-icons/HierarchyIcon';
import Home01Icon from '@hugeicons/core-free-icons/Home01Icon';
import HourglassIcon from '@hugeicons/core-free-icons/HourglassIcon';
import IdIcon from '@hugeicons/core-free-icons/IdIcon';
import Image01Icon from '@hugeicons/core-free-icons/Image01Icon';
import InformationCircleIcon from '@hugeicons/core-free-icons/InformationCircleIcon';
import Invoice01Icon from '@hugeicons/core-free-icons/Invoice01Icon';
import Key01Icon from '@hugeicons/core-free-icons/Key01Icon';
import Layers01Icon from '@hugeicons/core-free-icons/Layers01Icon';
import Leaf01Icon from '@hugeicons/core-free-icons/Leaf01Icon';
import Link01Icon from '@hugeicons/core-free-icons/Link01Icon';
import Location01Icon from '@hugeicons/core-free-icons/Location01Icon';
import Logout03Icon from '@hugeicons/core-free-icons/Logout03Icon';
import Mail01Icon from '@hugeicons/core-free-icons/Mail01Icon';
import MapsIcon from '@hugeicons/core-free-icons/MapsIcon';
import Megaphone01Icon from '@hugeicons/core-free-icons/Megaphone01Icon';
import Menu01Icon from '@hugeicons/core-free-icons/Menu01Icon';
import Message01Icon from '@hugeicons/core-free-icons/Message01Icon';
import MinusSignIcon from '@hugeicons/core-free-icons/MinusSignIcon';
import Money03Icon from '@hugeicons/core-free-icons/Money03Icon';
import Moon02Icon from '@hugeicons/core-free-icons/Moon02Icon';
import Navigation03Icon from '@hugeicons/core-free-icons/Navigation03Icon';
import News01Icon from '@hugeicons/core-free-icons/News01Icon';
import Notification03Icon from '@hugeicons/core-free-icons/Notification03Icon';
import PencilEdit02Icon from '@hugeicons/core-free-icons/PencilEdit02Icon';
import PieChartIcon from '@hugeicons/core-free-icons/PieChartIcon';
import Pin02Icon from '@hugeicons/core-free-icons/Pin02Icon';
import PlayCircleIcon from '@hugeicons/core-free-icons/PlayCircleIcon';
import PrinterIcon from '@hugeicons/core-free-icons/PrinterIcon';
import PulseRectangle01Icon from '@hugeicons/core-free-icons/PulseRectangle01Icon';
import QrCodeIcon from '@hugeicons/core-free-icons/QrCodeIcon';
import Radio01Icon from '@hugeicons/core-free-icons/Radio01Icon';
import RecordIcon from '@hugeicons/core-free-icons/RecordIcon';
import RefreshIcon from '@hugeicons/core-free-icons/RefreshIcon';
import RepeatIcon from '@hugeicons/core-free-icons/RepeatIcon';
import Sad01Icon from '@hugeicons/core-free-icons/Sad01Icon';
import ScanIcon from '@hugeicons/core-free-icons/ScanIcon';
import Search01Icon from '@hugeicons/core-free-icons/Search01Icon';
import SecurityCheckIcon from '@hugeicons/core-free-icons/SecurityCheckIcon';
import SentIcon from '@hugeicons/core-free-icons/SentIcon';
import Settings01Icon from '@hugeicons/core-free-icons/Settings01Icon';
import Share01Icon from '@hugeicons/core-free-icons/Share01Icon';
import Share08Icon from '@hugeicons/core-free-icons/Share08Icon';
import Shield01Icon from '@hugeicons/core-free-icons/Shield01Icon';
import SmartPhone01Icon from '@hugeicons/core-free-icons/SmartPhone01Icon';
import SnowIcon from '@hugeicons/core-free-icons/SnowIcon';
import SourceCodeIcon from '@hugeicons/core-free-icons/SourceCodeIcon';
import SparklesIcon from '@hugeicons/core-free-icons/SparklesIcon';
import SquareIcon from '@hugeicons/core-free-icons/SquareIcon';
import SquareLock02Icon from '@hugeicons/core-free-icons/SquareLock02Icon';
import SquareUnlock02Icon from '@hugeicons/core-free-icons/SquareUnlock02Icon';
import StarIcon from '@hugeicons/core-free-icons/StarIcon';
import StopCircleIcon from '@hugeicons/core-free-icons/StopCircleIcon';
import StopIcon from '@hugeicons/core-free-icons/StopIcon';
import Sun03Icon from '@hugeicons/core-free-icons/Sun03Icon';
import Tag01Icon from '@hugeicons/core-free-icons/Tag01Icon';
import TextFontIcon from '@hugeicons/core-free-icons/TextFontIcon';
import Tick02Icon from '@hugeicons/core-free-icons/Tick02Icon';
import TickDouble02Icon from '@hugeicons/core-free-icons/TickDouble02Icon';
import Ticket01Icon from '@hugeicons/core-free-icons/Ticket01Icon';
import ToggleOnIcon from '@hugeicons/core-free-icons/ToggleOnIcon';
import TranslateIcon from '@hugeicons/core-free-icons/TranslateIcon';
import UmbrellaIcon from '@hugeicons/core-free-icons/UmbrellaIcon';
import UserAdd01Icon from '@hugeicons/core-free-icons/UserAdd01Icon';
import UserCircleIcon from '@hugeicons/core-free-icons/UserCircleIcon';
import UserGroupIcon from '@hugeicons/core-free-icons/UserGroupIcon';
import UserIcon from '@hugeicons/core-free-icons/UserIcon';
import Video01Icon from '@hugeicons/core-free-icons/Video01Icon';
import ViewIcon from '@hugeicons/core-free-icons/ViewIcon';
import ViewOffSlashIcon from '@hugeicons/core-free-icons/ViewOffSlashIcon';
import Wallet01Icon from '@hugeicons/core-free-icons/Wallet01Icon';
import Wifi01Icon from '@hugeicons/core-free-icons/Wifi01Icon';
/**
* The app's one icon component — Hugeicons, "stroke rounded" style.
*
* ── THE LOOK ────────────────────────────────────────────────────────────────
* Single-weight line icons on a 24pt grid with fully rounded caps, joins and
* corners: a soft, friendly outline that sits well next to Inter. No fills,
* no second tone — the clean, light look of a modern banking app.
*
* Free and MIT-licensed (@hugeicons/core-free-icons), drawn with
* react-native-svg, which the app already ships. Each glyph is imported on its
* own, so only the ones listed here are bundled — not all 4,000+.
*
* ── NAMES ───────────────────────────────────────────────────────────────────
* The component takes the Ionicons-style names the app already used, so call
* sites did not change. The name only tunes the stroke now:
*
* 'wallet-outline' 1.5 the default line
* 'wallet' 2.0 Ionicons' solid variant meant "emphasis" — the
* active tab, a status mark — so it draws bolder
* 'chevron-forward' 1.8 bare lines (arrows, carets, ticks, crosses) a
* touch heavier so they hold their own
*
* Below 18pt every stroke is nudged up, since a 1.5 line at 14pt turns faint.
* Pass `strokeWidth` to override.
*/
const GLYPHS = {
'accessibility': AccessibilityIcon,
'add': Add01Icon,
'add-circle': AddCircleIcon,
'airplane': Airplane01Icon,
'alarm': AlarmClockIcon,
'albums': Album01Icon,
'alert': AlertCircleIcon,
'alert-circle': AlertCircleIcon,
'analytics': Analytics01Icon,
'arrow-back': ArrowLeft02Icon,
'arrow-down': ArrowDown02Icon,
'arrow-forward': ArrowRight02Icon,
'arrow-up': ArrowUp02Icon,
'arrow-up-circle': ArrowUpRight01Icon,
'barcode': BarcodeIcon,
'book': Book02Icon,
'bookmark': Bookmark02Icon,
'briefcase': Briefcase01Icon,
'business': BankIcon,
'calculator': Calculator01Icon,
'calendar': Calendar03Icon,
'call': Call02Icon,
'camera': Camera01Icon,
'car': Car01Icon,
'car-sport': Car02Icon,
'card': CreditCardIcon,
'cash': Money03Icon,
'chatbox': Message01Icon,
'chatbubble': BubbleChatIcon,
'chatbubble-ellipses': BubbleChatIcon,
'chatbubbles': Chatting01Icon,
'checkbox': CheckmarkSquare02Icon,
'checkmark': Tick02Icon,
'checkmark-circle': CheckmarkCircle02Icon,
'checkmark-done': TickDouble02Icon,
'chevron-back': ArrowLeft01Icon,
'chevron-down': ArrowDown01Icon,
'chevron-forward': ArrowRight01Icon,
'chevron-up': ArrowUp01Icon,
'close': Cancel01Icon,
'close-circle': CancelCircleIcon,
'cloud-download': CloudDownloadIcon,
'cloud-offline': CloudSlowWindIcon,
'cloud-upload': CloudUploadIcon,
'code': SourceCodeIcon,
'create': PencilEdit02Icon,
'cube': CubeIcon,
'diamond': DiamondIcon,
'document': File01Icon,
'document-attach': Attachment01Icon,
'document-text': File02Icon,
'documents': Files02Icon,
'download': Download04Icon,
'ellipse': CircleIcon,
'eye': ViewIcon,
'eye-off': ViewOffSlashIcon,
'finger-print': FingerPrintIcon,
'flash': FlashIcon,
'folder-open': FolderOpenIcon,
'gift': GiftIcon,
'git-compare': GitCompareIcon,
'git-network': HierarchyIcon,
'git-pull-request': GitPullRequestIcon,
'globe': Globe02Icon,
'grid': DashboardSquare01Icon,
'hand-left': HandPrayerIcon,
'heart': FavouriteIcon,
'heart-circle': HealthIcon,
'help': HelpCircleIcon,
'help-buoy': CustomerSupportIcon,
'help-circle': HelpCircleIcon,
'home': Home01Icon,
'hourglass': HourglassIcon,
'id-card': IdIcon,
'image': Image01Icon,
'information-circle': InformationCircleIcon,
'key': Key01Icon,
'keypad': GridIcon,
'language': TranslateIcon,
'layers': Layers01Icon,
'leaf': Leaf01Icon,
'link': Link01Icon,
'list': Menu01Icon,
'locate': GpsSignal01Icon,
'location': Location01Icon,
'lock-closed': SquareLock02Icon,
'lock-open': SquareUnlock02Icon,
'log-out': Logout03Icon,
'mail': Mail01Icon,
'map': MapsIcon,
'medkit': FirstAidKitIcon,
'megaphone': Megaphone01Icon,
'moon': Moon02Icon,
'navigate': Navigation03Icon,
'notifications': Notification03Icon,
'options': FilterHorizontalIcon,
'paper-plane': SentIcon,
'people': UserGroupIcon,
'person': UserIcon,
'person-add': UserAdd01Icon,
'person-circle': UserCircleIcon,
'phone-portrait': SmartPhone01Icon,
'pie-chart': PieChartIcon,
'pin': Pin02Icon,
'play-circle': PlayCircleIcon,
'pricetag': Tag01Icon,
'print': PrinterIcon,
'pulse': PulseRectangle01Icon,
'qr-code': QrCodeIcon,
'radio': Radio01Icon,
'radio-button-off': CircleIcon,
'radio-button-on': RecordIcon,
'reader': News01Icon,
'receipt': Invoice01Icon,
'refresh': RefreshIcon,
'refresh-circle': RefreshIcon,
'remove': MinusSignIcon,
'repeat': RepeatIcon,
'ribbon': Award01Icon,
'sad': Sad01Icon,
'save': FloppyDiskIcon,
'scan': ScanIcon,
'school': GraduationCapIcon,
'search': Search01Icon,
'send': SentIcon,
'settings': Settings01Icon,
'share': Share08Icon,
'share-social': Share01Icon,
'shield': Shield01Icon,
'shield-checkmark': SecurityCheckIcon,
'shield-half': Shield01Icon,
'snow': SnowIcon,
'sparkles': SparklesIcon,
'square': SquareIcon,
'star': StarIcon,
'stats-chart': ChartHistogramIcon,
'stop': StopIcon,
'stop-circle': StopCircleIcon,
'sunny': Sun03Icon,
'swap-horizontal': ArrowDataTransferHorizontalIcon,
'sync': RepeatIcon,
'text': TextFontIcon,
'ticket': Ticket01Icon,
'time': Clock01Icon,
'toggle': ToggleOnIcon,
'trash': Delete02Icon,
'trending-down': ChartDecreaseIcon,
'trending-up': ChartIncreaseIcon,
'umbrella': UmbrellaIcon,
'videocam': Video01Icon,
'wallet': Wallet01Icon,
'warning': Alert02Icon,
'wifi': Wifi01Icon,
} as const;
type BaseName = keyof typeof GLYPHS;
/** Every name the app may pass: each glyph, and its `-outline` variant. */
export type IconName = BaseName | `${BaseName}-outline`;
const LINE_GLYPHS: ReadonlySet<string> = new Set([
'add',
'remove',
'arrow-back',
'arrow-down',
'arrow-forward',
'arrow-up',
'chevron-back',
'chevron-down',
'chevron-forward',
'chevron-up',
'checkmark',
'checkmark-done',
'close',
'list',
'link',
'swap-horizontal',
]);
interface AppIconProps {
name: IconName;
size?: number;
color?: string;
/** Overrides the stroke the name would choose. */
strokeWidth?: number;
style?: StyleProp<ViewStyle>;
testID?: string;
}
const strokeFor = (name: string, base: string, size: number) => {
const stroke = LINE_GLYPHS.has(base) ? 1.8 : name.endsWith('-outline') ? 1.5 : 2;
return size < 18 ? stroke + 0.3 : stroke;
};
const IconComponent = ({ name, size = 24, color, strokeWidth, style, testID }: AppIconProps) => {
const base = name.replace(/-outline$/, '') as BaseName;
const glyph = GLYPHS[base];
if (!glyph) {
if (__DEV__) {
console.warn(`Icon: no glyph mapped for "${name}"`);
}
return null;
}
return (
<HugeiconsIcon
icon={glyph}
size={size}
color={color}
strokeWidth={strokeWidth ?? strokeFor(name, base, size)}
style={style}
testID={testID}
/>
);
};
export const Icon = memo(IconComponent);
export default Icon;
+4 -4
View File
@@ -1,12 +1,12 @@
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 { Icon } from '../Icon';
import { useTheme } from '../../theme';
type InfoBannerTone = 'info' | 'warning' | 'danger' | 'success' | 'brand';
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
type IoniconName = React.ComponentProps<typeof Icon>['name'];
interface InfoBannerProps {
/** Optional — a one-line banner reads better without one. */
@@ -102,7 +102,7 @@ const InfoBannerComponent = ({
style,
]}
>
<Ionicons
<Icon
name={icon ?? DEFAULT_ICON[tone]}
size={18}
color={palette.fg}
@@ -130,7 +130,7 @@ const InfoBannerComponent = ({
style={styles.action}
>
<Text style={[theme.text.button, { color: palette.fg }]}>{actionLabel}</Text>
<Ionicons name="chevron-forward" size={15} color={palette.fg} />
<Icon name="chevron-forward" size={15} color={palette.fg} />
</Pressable>
) : null}
</View>
+2 -2
View File
@@ -6,7 +6,7 @@ import {
View,
type TextInputProps,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../Icon';
import { useTheme } from '../../theme';
@@ -154,7 +154,7 @@ export const Input = ({
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} />
<Icon name="alert-circle" size={14} color={theme.colors.danger} />
<Text style={[theme.text.caption, styles.messageText, { color: theme.colors.danger }]}>
{error}
</Text>
@@ -5,16 +5,7 @@ import {
StyleSheet,
type StyleProp,
type ViewStyle,
type NativeScrollEvent,
type NativeSyntheticEvent,
type TextInput,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
Easing,
} from 'react-native-reanimated';
import { useKeyboardHandler } from '../../hooks/useKeyboardHandler';
interface KeyboardAwareScrollViewProps {
@@ -46,8 +37,14 @@ export const KeyboardAwareScrollView = ({
const scrollRef = useRef<ScrollView>(null);
const { keyboardHeight, isVisible } = useKeyboardHandler();
// Calculate the dynamic padding: keyboard height + extra buffer
const bottomPad = isVisible ? keyboardHeight + extraPadding : 0;
/**
* iOS only. Android's window already ends at the top of the keyboard (see
* `MainActivity.restoreAdjustResize`), so adding the keyboard's height there
* as well left a keyboard-sized empty gap to scroll through.
*/
const bottomPad = isVisible && Platform.OS === 'ios' ? keyboardHeight + extraPadding : 0;
const basePadding =
(StyleSheet.flatten(contentContainerStyle)?.paddingBottom as number | undefined) ?? 40;
return (
<ScrollView
@@ -55,7 +52,10 @@ export const KeyboardAwareScrollView = ({
style={[styles.flex, style]}
contentContainerStyle={[
contentContainerStyle,
{ paddingBottom: (contentContainerStyle as any)?.paddingBottom || 40 + bottomPad },
// The keyboard padding is ADDED to the caller's own. It used to be
// `callerPadding || 40 + keyboard`, so any screen that set its own
// bottom padding silently lost the keyboard padding altogether.
{ paddingBottom: basePadding + bottomPad },
]}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
keyboardShouldPersistTaps="handled"
+4 -3
View File
@@ -91,9 +91,10 @@ export const KeyboardSafeView = ({
showsVerticalScrollIndicator = false,
bounces = true,
}: KeyboardSafeViewProps) => {
// iOS: 'padding' works best, adds padding at the bottom
// Android: 'height' or undefined works best (Android handles it natively in most cases)
const behavior = Platform.OS === 'ios' ? 'padding' as const : 'height' as const;
// iOS: 'padding'. Android: nothing — the window itself ends at the top of
// the keyboard (adjustResize, restored on 15+ by MainActivity), and a
// 'height' KeyboardAvoidingView on top of that shrank the content twice.
const behavior = Platform.OS === 'ios' ? ('padding' as const) : undefined;
// Base offset: accounts for navigation header height on iOS
// Android's windowSoftInputMode handles most of the offset natively
+18 -8
View File
@@ -3,8 +3,6 @@ import {
Keyboard,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
@@ -37,12 +35,24 @@ export const AppModal = ({
};
return (
<Modal transparent visible={visible} animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.flex}
keyboardVerticalOffset={Platform.OS === 'ios' ? 10 : 0}
>
<Modal
transparent
statusBarTranslucent
visible={visible}
animationType="fade"
onRequestClose={onClose}
>
{/*
Self-correcting on both platforms. The keyboard is measured in screen
coordinates and this view's frame is relative to the modal's window,
which `statusBarTranslucent` starts at the very top of the screen — so
the two line up and no offset is needed. If the window was resized for
the keyboard (Android's adjustResize), the frame already ends at the
keyboard and the padding comes out as zero; if it was not (iOS, or an
edge-to-edge Android 15 window), it pads by exactly the overlap.
`height` on Android shrank a window that had often already shrunk.
*/}
<KeyboardAvoidingView behavior="padding" style={styles.flex}>
<TouchableWithoutFeedback onPress={handleOverlayPress}>
<View style={[styles.overlay, { backgroundColor: theme.colors.overlay }]}>
<TouchableWithoutFeedback onPress={() => {}}>
+30 -9
View File
@@ -9,12 +9,13 @@ import {
type StyleProp,
type ViewStyle,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import LinearGradient from 'react-native-linear-gradient';
import { useTheme } from '../../theme';
import { AppHeader } from '../AppHeader';
import { TAB_BAR_HEIGHT } from '../../utils/layoutConstants';
import { useKeyboardHandler } from '../../hooks/useKeyboardHandler';
interface ScreenShellProps {
title?: string;
@@ -59,27 +60,47 @@ export const ScreenShell = ({
fixedFooter,
}: ScreenShellProps) => {
const theme = useTheme();
const insets = useSafeAreaInsets();
/**
* The tab bar hides while the keyboard is up (`tabBarHideOnKeyboard`), so
* the room reserved for it has to go too — otherwise a 90pt band of nothing
* sits between the last field, or the Pay button, and the keyboard, and on a
* short phone that band is exactly where the field you are typing in was.
*/
const { isVisible: keyboardUp } = useKeyboardHandler();
const reserveTabBar = withTabBar && !keyboardUp;
/**
* Android is handled by the manifest, not by React.
* Android is handled by the window, not by React.
*
* `windowSoftInputMode="adjustResize"` already shrinks the window when the
* keyboard opens. Running a KeyboardAvoidingView on top of that shrinks the
* keyboard opens — natively on Android 14 and earlier, and on 15+ through
* the IME-inset padding in `MainActivity.restoreAdjustResize`, since an
* edge-to-edge window is no longer resized by the system. Either way the
* React root ends at the top of the keyboard.
*
* 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;
/**
* KeyboardAvoidingView compares the keyboard's SCREEN position with its own
* layout frame, which is relative to its parent — here the frame view, which
* starts below the top safe area. The offset is therefore exactly that
* inset. It was a fixed 90, which over-subtracted on every notched iPhone
* and left a fixed footer's lower edge — the Pay or Continue button —
* tucked under the keyboard.
*/
const totalOffset = insets.top + keyboardExtraOffset;
// Bottom spacing: if inside tab navigator, add TAB_BAR_HEIGHT + breathing room
// If there's a fixedFooter, we don't necessarily need tab bar padding on the scroll view,
// but let's leave it for safety or adjust based on layout.
// A fixed footer already clears the tab bar (see below), so the scroll above
// it only needs breathing room, not the bar's full height again.
const tabBarPadding = withTabBar && !fixedFooter ? TAB_BAR_HEIGHT + 16 : 20;
const tabBarPadding = reserveTabBar && !fixedFooter ? TAB_BAR_HEIGHT + 16 : 20;
const scrollContent = (
<ScrollView
@@ -114,7 +135,7 @@ export const ScreenShell = ({
style={[
styles.content,
styles.staticContent,
{ paddingBottom: withTabBar ? TAB_BAR_HEIGHT : 0 },
{ paddingBottom: reserveTabBar ? TAB_BAR_HEIGHT : 0 },
contentContainerStyle,
]}
>
@@ -156,7 +177,7 @@ export const ScreenShell = ({
Continue buttons were rendering behind the bar. Lifting it by the
bar's height keeps it above. */}
{fixedFooter ? (
<View style={withTabBar ? { paddingBottom: TAB_BAR_HEIGHT } : undefined}>
<View style={reserveTabBar ? { paddingBottom: TAB_BAR_HEIGHT } : undefined}>
{fixedFooter}
</View>
) : null}
+2 -2
View File
@@ -1,6 +1,6 @@
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 { Icon } from '../Icon';
import { useTheme } from '../../theme';
@@ -103,7 +103,7 @@ const StepperComponent = ({ steps, current, onBrand = false, style }: StepperPro
]}
>
{done ? (
<Ionicons name="checkmark" size={13} color={markerInk} />
<Icon name="checkmark" size={13} color={markerInk} />
) : (
<Text style={[theme.text.eyebrow, styles.markerText, { color: markerInk }]}>
{index + 1}
+3 -3
View File
@@ -4,7 +4,7 @@ import {
Text,
View,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../Icon';
import type { TransactionRecord } from '../../types/app';
import { useTheme, textStyles } from '../../theme';
@@ -46,7 +46,7 @@ const TransactionItemComponent = ({ item }: TransactionItemProps) => {
color: theme.colors.danger,
surface: theme.colors.dangerSurface,
border: theme.colors.dangerBorder,
label: 'HIGH RISK',
label: 'High risk',
icon: 'alert-circle' as const,
},
}[riskLevel];
@@ -65,7 +65,7 @@ const TransactionItemComponent = ({ item }: TransactionItemProps) => {
},
]}
>
<Ionicons
<Icon
name={isCredit ? 'chevron-down' : 'chevron-up'}
size={18}
color={isCredit ? theme.colors.success : theme.colors.textSecondary}
+245
View File
@@ -0,0 +1,245 @@
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import {
AccessibilityInfo,
StyleSheet,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from 'react-native';
import { useTheme } from '../../theme';
interface TypewriterTextProps {
/** One line, typed once. */
text?: string;
/**
* Several lines, typed in turn: each is typed, held, deleted, and the next
* one typed, round and round. Takes precedence over `text`.
*/
texts?: readonly string[];
/**
* Types only while true; switching it back on restarts from the first
* character. A paging carousel passes "is this the slide on screen", so each
* slide types out as it arrives rather than all of them at mount.
*/
active?: boolean;
/** Milliseconds per character typed. */
speed?: number;
/** Milliseconds per character deleted — quicker than typing, as a person is. */
deleteSpeed?: number;
/** How long a finished line stays up before it is deleted (`texts` only). */
holdMs?: number;
/** Pause before the first character, so the slide has settled. */
startDelay?: number;
style?: StyleProp<TextStyle>;
containerStyle?: StyleProp<ViewStyle>;
/** Caret colour. Defaults to the brand blue. */
caretColor?: string;
/** Keep the caret blinking after a single line has finished. */
keepCaret?: boolean;
/** Fired once a single `text` has finished typing. Not fired for `texts`. */
onDone?: () => void;
}
type Phase = 'waiting' | 'typing' | 'holding' | 'deleting' | 'done';
interface Step {
/** Which line, in `texts`. */
line: number;
/** Characters of it on screen. */
count: number;
phase: Phase;
}
const START: Step = { line: 0, count: 0, phase: 'waiting' };
/** The gap between deleting one line and starting the next. */
const BETWEEN_LINES_MS = 350;
/**
* Text that types itself out, with a blinking caret — one line once, or
* several in rotation.
*
* Three things it is careful about:
* - LAYOUT. The longest line is laid out invisibly underneath and the typed
* part drawn over it, so the block holds its final size from the first
* frame. Nothing around it jumps as characters land or lines change.
* - SCREEN READERS. The block announces every line once; the half-typed
* frames are hidden from accessibility, so nobody hears it spelled out.
* - REDUCED MOTION. With the system setting on, the first line simply
* appears and stays.
*
* Driven one step at a time by a single timeout, rather than by intervals:
* every transition — next character, hold, delete, next line — is the same
* kind of event, and there is never more than one timer to clean up.
*/
const TypewriterTextComponent = ({
text,
texts,
active = true,
speed = 45,
deleteSpeed = 22,
holdMs = 1800,
startDelay = 250,
style,
containerStyle,
caretColor,
keepCaret = false,
onDone,
}: TypewriterTextProps) => {
const theme = useTheme();
const [step, setStep] = useState<Step>(START);
const [reduceMotion, setReduceMotion] = useState(false);
const [caretOn, setCaretOn] = useState(true);
const onDoneRef = useRef(onDone);
onDoneRef.current = onDone;
const lines = useMemo(
() => (texts && texts.length > 0 ? [...texts] : [text ?? '']),
// Joined so a new array with the same lines is not a restart.
// eslint-disable-next-line react-hooks/exhaustive-deps
[texts?.join('\u0000'), text],
);
const rotating = lines.length > 1;
const longest = useMemo(
() => lines.reduce((a, b) => (b.length > a.length ? b : a), ''),
[lines],
);
const current = lines[step.line % lines.length] ?? '';
useEffect(() => {
let mounted = true;
AccessibilityInfo.isReduceMotionEnabled()
.then((enabled) => mounted && setReduceMotion(enabled))
.catch(() => {});
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);
return () => {
mounted = false;
sub.remove();
};
}, []);
// Back to the start whenever it is (re)activated or the lines change.
useEffect(() => {
setStep(START);
}, [active, lines]);
// One step at a time.
useEffect(() => {
if (!active || step.phase === 'done') {
return;
}
if (reduceMotion) {
setStep({ line: 0, count: lines[0]?.length ?? 0, phase: 'done' });
return;
}
const advance = (): { next: Step; delay: number } => {
switch (step.phase) {
case 'waiting':
return { next: { ...step, phase: 'typing' }, delay: startDelay };
case 'typing':
if (step.count < current.length) {
return { next: { ...step, count: step.count + 1 }, delay: speed };
}
return rotating
? { next: { ...step, phase: 'holding' }, delay: 0 }
: { next: { ...step, phase: 'done' }, delay: 0 };
case 'holding':
return { next: { ...step, phase: 'deleting' }, delay: holdMs };
case 'deleting':
if (step.count > 0) {
return { next: { ...step, count: step.count - 1 }, delay: deleteSpeed };
}
return {
next: { line: (step.line + 1) % lines.length, count: 0, phase: 'typing' },
delay: BETWEEN_LINES_MS,
};
default:
return { next: step, delay: 0 };
}
};
const { next, delay } = advance();
const timer = setTimeout(() => setStep(next), delay);
return () => clearTimeout(timer);
}, [active, current.length, deleteSpeed, holdMs, lines, reduceMotion, rotating, speed, startDelay, step]);
// `onDone` once, when a single line lands.
useEffect(() => {
if (step.phase === 'done' && !rotating) {
onDoneRef.current?.();
}
}, [rotating, step.phase]);
const moving = step.phase === 'typing' || step.phase === 'deleting';
const showCaret =
!reduceMotion && active && (step.phase !== 'done' || keepCaret) && step.phase !== 'waiting';
/**
* Solid while characters are moving, blinking while it waits. Blinked by
* colour, not opacity: the caret is a span nested inside the text, and
* Android ignores opacity on a nested span. Transparent keeps its width, so
* nothing shifts as it blinks.
*/
useEffect(() => {
setCaretOn(true);
if (!showCaret || moving) {
return;
}
const blink = setInterval(() => setCaretOn((on) => !on), 500);
return () => clearInterval(blink);
}, [moving, showCaret]);
return (
<View
style={containerStyle}
accessible
accessibilityRole="text"
accessibilityLabel={lines.join('. ')}
>
{/* Holds the size of the longest line. Invisible, and not read out. */}
<Text style={[style, styles.ghost]} importantForAccessibility="no-hide-descendants">
{longest}
</Text>
<Text
style={[style, styles.overlay]}
importantForAccessibility="no-hide-descendants"
accessibilityElementsHidden
>
{current.slice(0, step.count)}
{showCaret ? (
<Text style={{ color: caretOn ? caretColor ?? theme.colors.primary : 'transparent' }}>
|
</Text>
) : null}
</Text>
</View>
);
};
const styles = StyleSheet.create({
ghost: {
opacity: 0,
},
overlay: {
...StyleSheet.absoluteFillObject,
},
});
export const TypewriterText = memo(TypewriterTextComponent);
+1
View File
@@ -19,3 +19,4 @@ export * from './Stepper';
export * from './SegmentedControl';
export * from './ToastHost';
export * from './TransactionItem';
export * from './TypewriterText';
+17 -7
View File
@@ -7,12 +7,15 @@
* to the handset, and that failure reads like the app being broken. `localhost`
* is wrong for the same reason — on a device it means the device itself.
*
* This is a named host on an internal domain, not an IP, so it resolves only
* where the DNS that serves `.lincpay` is reachable — the office network or
* the VPN. A handset on mobile data, or on a guest network without that
* resolver, fails to resolve it at all and the app looks like the backend is
* down. Note there is no port here: this host answers on the default 80, unlike
* the `:8089` LAN addresses this build was aimed at before.
* This is a name only the office network's DNS knows (it resolves to
* 192.168.6.1), so the handset has to be on the same network (office Wi-Fi or
* the VPN). On mobile data or a guest network it does not resolve and the app
* looks like the backend is down.
*
* Positive Pay follows the 12 Oct 2026 guide, which names accounts by
* `accountNumber`; the 13 Oct revision keeps that contract and names this host
* as the deployment base URL. A backend still on the earlier build (account
* ids) refuses those calls — check `/v3/api-docs` here if Positive Pay fails.
*
* Plain HTTP, so it depends on cleartext traffic being allowed for this host
* specifically. Both platforms gate that by host and neither tells the app why
@@ -38,7 +41,14 @@ export const env = {
* the dashboard and the agent app — still resolve from fixtures. Registration,
* sign-in, accounts/MMID and payees always call the live API regardless.
*/
useMockServer: true,
useMockServer: false,
/**
* Debug builds only: answers sign-in, registration, accounts, payees and
* transfer history from `services/api/devMockAdapter.ts` instead of the bank,
* accepting any mobile number, OTP and MPIN. For running the app off the
* office network. Set false to talk to the real backend again.
*/
devMockBackend: false,
/** Server-enforced idle window; any authenticated call refreshes it. */
sessionTimeoutMs: 300_000,
/** How long before the idle cut-off the user is warned. */
+4
View File
@@ -25,6 +25,7 @@ export enum FeatureKey {
AUTH_LOGIN_BIOMETRIC = 'auth.login.biometric',
AUTH_LOGIN_FORGOT_PASSWORD = 'auth.login.forgotPassword',
AUTH_REGISTER = 'auth.register',
AUTH_ONBOARDING = 'auth.onboarding',
AUTH_LOGIN_CREATE_ACCOUNT = 'auth.login.createAccount',
AUTH_LOGOUT = 'auth.logout',
@@ -136,6 +137,9 @@ export enum FeatureKey {
SERVICES_CHEQUE_STOP_PAYMENT = 'services.cheque.stopPayment',
SERVICES_CHEQUE_TRACKING = 'services.cheque.tracking',
SERVICES_CHEQUE_POSITIVE_PAY = 'services.cheque.positivePay',
SERVICES_CHEQUE_POSITIVE_PAY_HISTORY = 'services.cheque.positivePay.history',
/** One saved Positive Pay cheque. Reached from the history and from Stop Payment alike. */
SERVICES_CHEQUE_DETAIL = 'services.cheque.detail',
SERVICES_CERTIFICATES = 'services.certificates',
SERVICES_CERTIFICATES_HUB = 'services.certificates.hub',
+1
View File
@@ -9,6 +9,7 @@ export const QUERY_TAGS = [
'Deposits',
'Services',
'ChequeBooks',
'PositivePay',
'Loans',
'LoanActive',
'LoanApplications',
@@ -0,0 +1,306 @@
import { FeatureKey } from '../constants/featureKeys';
import type { FeatureDefinition } from './types';
/**
* Snapshot of DEFAULT_FEATURE_CONFIG as it stood on 14 Oct 2026, the release
* stage before every feature was switched on. Not imported anywhere — it is
* the record of which features that stage shipped off, and why.
*
* To return to this stage, make DEFAULT_FEATURE_CONFIG in `featureConfig.ts`
* equal to this map (or copy its `enabled: false` entries back).
*/
export const STAGE_2026_10_14_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
// ---------------------------------------------------------------- Auth ----
[FeatureKey.AUTH]: { enabled: true, description: 'Authentication module' },
[FeatureKey.AUTH_LOGIN]: { enabled: true },
[FeatureKey.AUTH_LOGIN_USER_ROLE]: { enabled: true, description: 'Customer login tab' },
[FeatureKey.AUTH_LOGIN_AGENT_ROLE]: {
enabled: false,
description: 'Agent login tab',
reason:
'Off with the agent app itself. This key is not under `agent`, so it does not ' +
'inherit that kill — left on, the tab would offer a role whose app no longer ' +
'exists, and RootNavigator would drop the agent into the customer app instead.',
},
[FeatureKey.AUTH_LOGIN_BIOMETRIC]: { enabled: true },
[FeatureKey.AUTH_LOGIN_FORGOT_PASSWORD]: { enabled: true },
[FeatureKey.AUTH_REGISTER]: {
enabled: true,
description: 'Device registration: lookup, OTP, MPIN setup',
},
[FeatureKey.AUTH_ONBOARDING]: {
enabled: true,
description: 'First-launch introduction slides, shown once before registration',
},
[FeatureKey.AUTH_LOGIN_CREATE_ACCOUNT]: { enabled: true },
[FeatureKey.AUTH_LOGOUT]: { enabled: true },
// ---------------------------------------------------------------- Home ----
[FeatureKey.HOME]: { enabled: true, description: 'Home tab' },
[FeatureKey.HOME_ACCOUNTS]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_CAROUSEL]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_BALANCE_TOGGLE]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_MINI_STATEMENT]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_DETAILS]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_EPASSBOOK]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_REKYC]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_VIDEO_KYC]: { enabled: true },
[FeatureKey.HOME_ACCOUNTS_MMID]: {
enabled: true,
description: 'Accounts and MMID, for receiving money by IMPS',
},
[FeatureKey.HOME_QUICK_ACTIONS]: { enabled: true },
[FeatureKey.HOME_QUICK_ACTIONS_TRANSFER]: { enabled: true },
[FeatureKey.HOME_QUICK_ACTIONS_BILL_PAY]: { enabled: true },
[FeatureKey.HOME_QUICK_ACTIONS_AIRTIME]: { enabled: true },
[FeatureKey.HOME_QUICK_ACTIONS_LOANS]: { enabled: false, reason: 'Destination module is off' },
[FeatureKey.HOME_QUICK_ACTIONS_CARDS]: { enabled: false, reason: 'Destination module is off' },
[FeatureKey.HOME_QUICK_ACTIONS_STATS]: { enabled: true },
[FeatureKey.HOME_SERVICES]: { enabled: true },
[FeatureKey.HOME_SERVICES_TRANSFER]: { enabled: true },
[FeatureKey.HOME_SERVICES_SELF_TRANSFER]: { enabled: true },
[FeatureKey.HOME_SERVICES_BILL_PAY]: { enabled: true },
[FeatureKey.HOME_SERVICES_COLLECT_REQUEST]: { enabled: true },
[FeatureKey.HOME_SERVICES_MANAGE_PAYEE]: { enabled: true },
[FeatureKey.HOME_SERVICES_SUPPORT]: { enabled: true },
[FeatureKey.HOME_SERVICES_INSURANCE]: { enabled: false, reason: 'Destination module is off' },
[FeatureKey.HOME_SERVICES_DEPOSITS]: { enabled: true },
[FeatureKey.HOME_SERVICES_GOLD_LOAN]: { enabled: false, reason: 'Destination module is off' },
[FeatureKey.HOME_INSIGHTS]: { enabled: true },
[FeatureKey.HOME_PROMO_BANNER]: { enabled: true },
// -------------------------------------------------------------- Search ----
[FeatureKey.SEARCH]: { enabled: true },
[FeatureKey.SEARCH_GLOBAL]: { enabled: true },
// ------------------------------------------------------- Notifications ----
[FeatureKey.NOTIFICATIONS]: { enabled: true },
[FeatureKey.NOTIFICATIONS_LIST]: { enabled: true },
[FeatureKey.NOTIFICATIONS_MARK_READ]: { enabled: true },
// ------------------------------------------------------------ Payments ----
[FeatureKey.PAYMENTS]: { enabled: true, description: 'Payments tab' },
[FeatureKey.PAYMENTS_HUB]: { enabled: true },
[FeatureKey.PAYMENTS_BENEFICIARIES]: { enabled: true },
[FeatureKey.PAYMENTS_BENEFICIARIES_LIST]: { enabled: true },
[FeatureKey.PAYMENTS_BENEFICIARIES_ADD]: { enabled: true },
[FeatureKey.PAYMENTS_BENEFICIARIES_DELETE]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_IMPS]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_NEFT]: { enabled: false, reason: 'Target transfer form offers IMPS only' },
[FeatureKey.PAYMENTS_TRANSFER_RTGS]: { enabled: false, reason: 'Target transfer form offers IMPS only' },
[FeatureKey.PAYMENTS_TRANSFER_INTERNAL]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_SUBMIT]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_MOBILE]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_ACCOUNT]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_OWN]: { enabled: true },
[FeatureKey.PAYMENTS_MMID]: {
enabled: true,
description: 'The MMIDs on this profile, for receiving over IMPS',
},
[FeatureKey.PAYMENTS_MMID_CANCEL]: { enabled: true },
[FeatureKey.PAYMENTS_MMID_GENERATE]: { enabled: true },
[FeatureKey.PAYMENTS_UPI]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.PAYMENTS_UPI_SCAN]: { enabled: false, reason: 'Not in the target screen set' },
// --------------------------------------------------------------- Cards ----
[FeatureKey.CARDS]: { enabled: false, description: 'Cards tab', reason: 'Not in the target screen set' },
[FeatureKey.CARDS_OVERVIEW]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_DETAILS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_TRANSACTIONS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_FREEZE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_ONLINE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_INTERNATIONAL]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_CONTACTLESS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_ATM]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_LIMITS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_BLOCK]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_APPLY_CREDIT]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_REQUEST_PHYSICAL]: { enabled: false, reason: 'Not in the target screen set' },
// ------------------------------------------------------------ Services ----
[FeatureKey.SERVICES]: { enabled: true, description: 'Services / hub tab' },
[FeatureKey.SERVICES_HUB]: { enabled: true },
[FeatureKey.SERVICES_DEPOSITS]: { enabled: true },
[FeatureKey.SERVICES_DEPOSITS_OVERVIEW]: { enabled: true },
[FeatureKey.SERVICES_DEPOSITS_DETAILS]: { enabled: true },
// A customer may look at the deposits they hold, but cannot start a new
// one from the app. Both the route and every control that reaches it are
// gated on these, so switching them on is all it takes to offer it.
[FeatureKey.SERVICES_DEPOSITS_OPEN_FD]: { enabled: false, reason: 'Viewing deposits is offered; opening one is not' },
[FeatureKey.SERVICES_DEPOSITS_OPEN_RD]: { enabled: false, reason: 'Viewing deposits is offered; opening one is not' },
[FeatureKey.SERVICES_DEPOSITS_CALCULATOR]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE_HUB]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE_BOOK_REQUEST]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE_TRACKING]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE_POSITIVE_PAY]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE_POSITIVE_PAY_HISTORY]: {
enabled: true,
description: 'The saved Positive Pay cheques and their status',
},
[FeatureKey.SERVICES_CHEQUE_DETAIL]: {
enabled: true,
description: 'One saved Positive Pay cheque, with stop payment when it is eligible',
},
[FeatureKey.SERVICES_CERTIFICATES]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_HUB]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_LIST]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_BALANCE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_INTEREST]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_SHARE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_HYPOTHECATION]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_INSURANCE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_TAX]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_LOAN]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_REQUESTS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_REQUESTS_HUB]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_STANDING_INSTRUCTIONS]: { enabled: false, reason: 'Only entry point was the service-requests hub' },
[FeatureKey.SERVICES_FORM_15G15H]: { enabled: false, reason: 'Only entry point was the service-requests hub' },
[FeatureKey.SERVICES_NOMINATION]: { enabled: false, reason: 'Only entry point was the service-requests hub' },
[FeatureKey.SERVICES_BRANCH_LOCATOR]: { enabled: true },
[FeatureKey.SERVICES_AWARENESS]: { enabled: true },
[FeatureKey.SERVICES_AWARENESS_LIST]: { enabled: true },
[FeatureKey.SERVICES_AWARENESS_DETAIL]: { enabled: true },
[FeatureKey.SERVICES_PROTECTION]: { enabled: true },
[FeatureKey.SERVICES_PROTECTION_HUB]: { enabled: true },
[FeatureKey.SERVICES_PROTECTION_SCHEME_DETAIL]: { enabled: true },
[FeatureKey.SERVICES_PROTECTION_ENROLLMENT]: { enabled: true },
[FeatureKey.SERVICES_PROTECTION_DEAF]: { enabled: true },
[FeatureKey.SERVICES_PROTECTION_DEAF_CLAIM]: { enabled: true },
[FeatureKey.SERVICES_PROTECTION_DICGC]: { enabled: true },
// Not built yet — these two only raise a "coming soon" toast today, so they
// are off by default rather than advertising something that does not exist.
[FeatureKey.SERVICES_SUPPORT_CHAT]: { enabled: true },
[FeatureKey.SERVICES_HELP_CENTER]: { enabled: true },
[FeatureKey.SERVICES_BALANCE_ENQUIRY]: {
enabled: false,
reason: 'No CBS endpoint returns a balance',
},
[FeatureKey.SERVICES_FATE_OF_CHEQUE]: { enabled: true },
[FeatureKey.SERVICES_TPIN_CHANGE]: { enabled: true },
[FeatureKey.SERVICES_TPIN_RESET]: { enabled: true, description: 'Reset a forgotten TPIN with an OTP' },
// Service-desk entry points to screens the home and profile stacks already
// own. Switching one off takes the tile away, not the screen.
[FeatureKey.SERVICES_MINI_STATEMENT]: { enabled: true },
[FeatureKey.SERVICES_MPIN_CHANGE]: { enabled: true },
// --------------------------------------------------------------- Loans ----
[FeatureKey.LOANS]: { enabled: false, description: 'Loans module', reason: 'Not in the target screen set' },
[FeatureKey.LOANS_DASHBOARD]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_TYPES]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_ELIGIBILITY]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_CALCULATOR]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_APPLY]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_TRACKING]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_ACTIVE_DETAILS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_EMI_PAYMENT]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_FORECLOSURE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_HISTORY]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_DOCUMENTS]: { enabled: false, reason: 'Not in the target screen set' },
// ----------------------------------------------------------- Insurance ----
[FeatureKey.INSURANCE]: { enabled: false, description: 'Insurance module', reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_DASHBOARD]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_BUY]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_POLICIES]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_POLICY_DETAILS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_RENEWALS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_CLAIMS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_DOCUMENTS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_CALCULATOR]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_COMPARISON]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_NOTIFICATIONS]: { enabled: false, reason: 'Not in the target screen set' },
// ------------------------------------------------------------- Profile ----
[FeatureKey.PROFILE]: { enabled: true, description: 'Profile tab' },
[FeatureKey.PROFILE_MAIN]: { enabled: true },
[FeatureKey.PROFILE_EDIT]: {
enabled: false,
reason: 'No backend endpoint: GET /profile is read-only and editing is outside its scope',
},
[FeatureKey.PROFILE_ACCOUNTS]: { enabled: true },
[FeatureKey.PROFILE_ACCOUNTS_ADD]: { enabled: true },
[FeatureKey.PROFILE_BENEFICIARIES_SHORTCUT]: { enabled: true },
[FeatureKey.PROFILE_STATEMENTS_SHORTCUT]: { enabled: true },
[FeatureKey.PROFILE_SETTINGS]: { enabled: true },
[FeatureKey.PROFILE_SETTINGS_LANGUAGE]: { enabled: false, reason: 'Removed from the profile screen' },
[FeatureKey.PROFILE_SETTINGS_NOTIFICATIONS]: { enabled: true },
[FeatureKey.PROFILE_SETTINGS_PRIVACY]: { enabled: true },
[FeatureKey.PROFILE_SECURITY]: { enabled: true },
[FeatureKey.PROFILE_SECURITY_CHANGE_PIN]: { enabled: true },
[FeatureKey.PROFILE_SECURITY_RESET_MPIN]: { enabled: true },
[FeatureKey.PROFILE_SECURITY_CHANGE_PASSWORD]: { enabled: true },
[FeatureKey.PROFILE_SECURITY_BIOMETRIC]: { enabled: true },
[FeatureKey.PROFILE_SECURITY_TWO_FACTOR]: { enabled: true },
[FeatureKey.PROFILE_SECURITY_DEVICES]: { enabled: true },
[FeatureKey.PROFILE_SECURITY_LOGOUT_ALL]: { enabled: true },
[FeatureKey.PROFILE_SECURITY_HIDE_BALANCE]: { enabled: true },
[FeatureKey.PROFILE_SUPPORT]: { enabled: true },
[FeatureKey.PROFILE_LEGAL]: { enabled: true },
[FeatureKey.PROFILE_ABOUT]: { enabled: true },
[FeatureKey.PROFILE_FEATURE_FLAGS]: {
// Off by default, on purpose. Enable it through remote config for the
// tenants whose staff need it; a switchboard shipped on by default would
// be reachable by every customer.
enabled: false,
description: 'Operational feature switchboard (admin)',
},
// --------------------------------------------------------------- Agent ----
[FeatureKey.AGENT]: {
enabled: false,
description: 'Field-agent application',
reason:
'Parked for now. Disabling this root retires the whole agent app — the 24 keys ' +
'below inherit it, so they are left as they are and the module comes back in ' +
'one edit. Pair with `auth.login.agentRole`, which sits outside this subtree.',
},
[FeatureKey.AGENT_DASHBOARD]: { enabled: true },
[FeatureKey.AGENT_DASHBOARD_MAIN]: { enabled: true },
[FeatureKey.AGENT_DASHBOARD_ROUTE]: { enabled: true },
[FeatureKey.AGENT_CUSTOMERS]: { enabled: true },
[FeatureKey.AGENT_CUSTOMERS_LIST]: { enabled: true },
[FeatureKey.AGENT_CUSTOMERS_DETAIL]: { enabled: true },
[FeatureKey.AGENT_CUSTOMERS_CREATE]: { enabled: true },
[FeatureKey.AGENT_CUSTOMERS_EDIT]: { enabled: true },
[FeatureKey.AGENT_CUSTOMERS_DELETE]: { enabled: true },
[FeatureKey.AGENT_CUSTOMERS_INSTALLMENT_HISTORY]: { enabled: true },
[FeatureKey.AGENT_COLLECTIONS]: { enabled: true },
[FeatureKey.AGENT_COLLECTIONS_DAILY]: { enabled: true },
[FeatureKey.AGENT_COLLECTIONS_HISTORY]: { enabled: true },
[FeatureKey.AGENT_COLLECTIONS_RECEIPT]: { enabled: true },
[FeatureKey.AGENT_LOANS]: { enabled: true },
[FeatureKey.AGENT_LOANS_LIST]: { enabled: true },
[FeatureKey.AGENT_LOANS_CREATE_REQUEST]: { enabled: true },
[FeatureKey.AGENT_LOANS_ACCOUNT_OPENING]: { enabled: true },
[FeatureKey.AGENT_LOANS_COMMISSION]: { enabled: true },
[FeatureKey.AGENT_PROFILE]: { enabled: true },
[FeatureKey.AGENT_PROFILE_MAIN]: { enabled: true },
[FeatureKey.AGENT_PROFILE_SMS_HISTORY]: { enabled: true },
[FeatureKey.AGENT_PROFILE_CHANGE_PASSWORD]: { enabled: true },
};
+86 -83
View File
@@ -16,6 +16,10 @@ import type { FeatureDefinition, FeatureOverrides } from './types';
* e.g. `services.cheque = false` hides the cheque hub, book request, stop
* payment, tracking and positive pay in one edit.
* - Entries are grouped and indented to read like the tree they represent.
*
* Every feature is on as of 14 Oct 2026. The previous stage, with the features
* it shipped off and the reason for each, is kept in
* `featureConfig.stage-2026-10-14.ts`.
*/
export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
// ---------------------------------------------------------------- Auth ----
@@ -23,12 +27,8 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
[FeatureKey.AUTH_LOGIN]: { enabled: true },
[FeatureKey.AUTH_LOGIN_USER_ROLE]: { enabled: true, description: 'Customer login tab' },
[FeatureKey.AUTH_LOGIN_AGENT_ROLE]: {
enabled: false,
enabled: true,
description: 'Agent login tab',
reason:
'Off with the agent app itself. This key is not under `agent`, so it does not ' +
'inherit that kill — left on, the tab would offer a role whose app no longer ' +
'exists, and RootNavigator would drop the agent into the customer app instead.',
},
[FeatureKey.AUTH_LOGIN_BIOMETRIC]: { enabled: true },
[FeatureKey.AUTH_LOGIN_FORGOT_PASSWORD]: { enabled: true },
@@ -36,6 +36,10 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
enabled: true,
description: 'Device registration: lookup, OTP, MPIN setup',
},
[FeatureKey.AUTH_ONBOARDING]: {
enabled: true,
description: 'First-launch introduction slides, shown once before registration',
},
[FeatureKey.AUTH_LOGIN_CREATE_ACCOUNT]: { enabled: true },
[FeatureKey.AUTH_LOGOUT]: { enabled: true },
@@ -58,8 +62,8 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
[FeatureKey.HOME_QUICK_ACTIONS_TRANSFER]: { enabled: true },
[FeatureKey.HOME_QUICK_ACTIONS_BILL_PAY]: { enabled: true },
[FeatureKey.HOME_QUICK_ACTIONS_AIRTIME]: { enabled: true },
[FeatureKey.HOME_QUICK_ACTIONS_LOANS]: { enabled: false, reason: 'Destination module is off' },
[FeatureKey.HOME_QUICK_ACTIONS_CARDS]: { enabled: false, reason: 'Destination module is off' },
[FeatureKey.HOME_QUICK_ACTIONS_LOANS]: { enabled: true },
[FeatureKey.HOME_QUICK_ACTIONS_CARDS]: { enabled: true },
[FeatureKey.HOME_QUICK_ACTIONS_STATS]: { enabled: true },
[FeatureKey.HOME_SERVICES]: { enabled: true },
@@ -69,9 +73,9 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
[FeatureKey.HOME_SERVICES_COLLECT_REQUEST]: { enabled: true },
[FeatureKey.HOME_SERVICES_MANAGE_PAYEE]: { enabled: true },
[FeatureKey.HOME_SERVICES_SUPPORT]: { enabled: true },
[FeatureKey.HOME_SERVICES_INSURANCE]: { enabled: false, reason: 'Destination module is off' },
[FeatureKey.HOME_SERVICES_INSURANCE]: { enabled: true },
[FeatureKey.HOME_SERVICES_DEPOSITS]: { enabled: true },
[FeatureKey.HOME_SERVICES_GOLD_LOAN]: { enabled: false, reason: 'Destination module is off' },
[FeatureKey.HOME_SERVICES_GOLD_LOAN]: { enabled: true },
[FeatureKey.HOME_INSIGHTS]: { enabled: true },
[FeatureKey.HOME_PROMO_BANNER]: { enabled: true },
@@ -94,8 +98,8 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
[FeatureKey.PAYMENTS_BENEFICIARIES_DELETE]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_IMPS]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_NEFT]: { enabled: false, reason: 'Target transfer form offers IMPS only' },
[FeatureKey.PAYMENTS_TRANSFER_RTGS]: { enabled: false, reason: 'Target transfer form offers IMPS only' },
[FeatureKey.PAYMENTS_TRANSFER_NEFT]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_RTGS]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_INTERNAL]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_SUBMIT]: { enabled: true },
[FeatureKey.PAYMENTS_TRANSFER_MOBILE]: { enabled: true },
@@ -109,24 +113,24 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
[FeatureKey.PAYMENTS_MMID_CANCEL]: { enabled: true },
[FeatureKey.PAYMENTS_MMID_GENERATE]: { enabled: true },
[FeatureKey.PAYMENTS_UPI]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.PAYMENTS_UPI_SCAN]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.PAYMENTS_UPI]: { enabled: true },
[FeatureKey.PAYMENTS_UPI_SCAN]: { enabled: true },
// --------------------------------------------------------------- Cards ----
[FeatureKey.CARDS]: { enabled: false, description: 'Cards tab', reason: 'Not in the target screen set' },
[FeatureKey.CARDS_OVERVIEW]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_DETAILS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_TRANSACTIONS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_FREEZE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_ONLINE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_INTERNATIONAL]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_CONTACTLESS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_ATM]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_LIMITS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_CONTROLS_BLOCK]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_APPLY_CREDIT]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS_REQUEST_PHYSICAL]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.CARDS]: { enabled: true, description: 'Cards tab' },
[FeatureKey.CARDS_OVERVIEW]: { enabled: true },
[FeatureKey.CARDS_DETAILS]: { enabled: true },
[FeatureKey.CARDS_TRANSACTIONS]: { enabled: true },
[FeatureKey.CARDS_CONTROLS]: { enabled: true },
[FeatureKey.CARDS_CONTROLS_FREEZE]: { enabled: true },
[FeatureKey.CARDS_CONTROLS_ONLINE]: { enabled: true },
[FeatureKey.CARDS_CONTROLS_INTERNATIONAL]: { enabled: true },
[FeatureKey.CARDS_CONTROLS_CONTACTLESS]: { enabled: true },
[FeatureKey.CARDS_CONTROLS_ATM]: { enabled: true },
[FeatureKey.CARDS_CONTROLS_LIMITS]: { enabled: true },
[FeatureKey.CARDS_CONTROLS_BLOCK]: { enabled: true },
[FeatureKey.CARDS_APPLY_CREDIT]: { enabled: true },
[FeatureKey.CARDS_REQUEST_PHYSICAL]: { enabled: true },
// ------------------------------------------------------------ Services ----
[FeatureKey.SERVICES]: { enabled: true, description: 'Services / hub tab' },
@@ -135,11 +139,10 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
[FeatureKey.SERVICES_DEPOSITS]: { enabled: true },
[FeatureKey.SERVICES_DEPOSITS_OVERVIEW]: { enabled: true },
[FeatureKey.SERVICES_DEPOSITS_DETAILS]: { enabled: true },
// A customer may look at the deposits they hold, but cannot start a new
// one from the app. Both the route and every control that reaches it are
// gated on these, so switching them on is all it takes to offer it.
[FeatureKey.SERVICES_DEPOSITS_OPEN_FD]: { enabled: false, reason: 'Viewing deposits is offered; opening one is not' },
[FeatureKey.SERVICES_DEPOSITS_OPEN_RD]: { enabled: false, reason: 'Viewing deposits is offered; opening one is not' },
// Opening a deposit: the route and every control that reaches it are gated
// on these two keys.
[FeatureKey.SERVICES_DEPOSITS_OPEN_FD]: { enabled: true },
[FeatureKey.SERVICES_DEPOSITS_OPEN_RD]: { enabled: true },
[FeatureKey.SERVICES_DEPOSITS_CALCULATOR]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE]: { enabled: true },
@@ -148,24 +151,32 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
[FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE_TRACKING]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE_POSITIVE_PAY]: { enabled: true },
[FeatureKey.SERVICES_CHEQUE_POSITIVE_PAY_HISTORY]: {
enabled: true,
description: 'The saved Positive Pay cheques and their status',
},
[FeatureKey.SERVICES_CHEQUE_DETAIL]: {
enabled: true,
description: 'One saved Positive Pay cheque, with stop payment when it is eligible',
},
[FeatureKey.SERVICES_CERTIFICATES]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_HUB]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_LIST]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_BALANCE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_INTEREST]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_SHARE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_HYPOTHECATION]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_INSURANCE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_TAX]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES_LOAN]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_CERTIFICATES]: { enabled: true },
[FeatureKey.SERVICES_CERTIFICATES_HUB]: { enabled: true },
[FeatureKey.SERVICES_CERTIFICATES_LIST]: { enabled: true },
[FeatureKey.SERVICES_CERTIFICATES_BALANCE]: { enabled: true },
[FeatureKey.SERVICES_CERTIFICATES_INTEREST]: { enabled: true },
[FeatureKey.SERVICES_CERTIFICATES_SHARE]: { enabled: true },
[FeatureKey.SERVICES_CERTIFICATES_HYPOTHECATION]: { enabled: true },
[FeatureKey.SERVICES_CERTIFICATES_INSURANCE]: { enabled: true },
[FeatureKey.SERVICES_CERTIFICATES_TAX]: { enabled: true },
[FeatureKey.SERVICES_CERTIFICATES_LOAN]: { enabled: true },
[FeatureKey.SERVICES_REQUESTS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_REQUESTS_HUB]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.SERVICES_REQUESTS]: { enabled: true },
[FeatureKey.SERVICES_REQUESTS_HUB]: { enabled: true },
[FeatureKey.SERVICES_STANDING_INSTRUCTIONS]: { enabled: false, reason: 'Only entry point was the service-requests hub' },
[FeatureKey.SERVICES_FORM_15G15H]: { enabled: false, reason: 'Only entry point was the service-requests hub' },
[FeatureKey.SERVICES_NOMINATION]: { enabled: false, reason: 'Only entry point was the service-requests hub' },
[FeatureKey.SERVICES_STANDING_INSTRUCTIONS]: { enabled: true },
[FeatureKey.SERVICES_FORM_15G15H]: { enabled: true },
[FeatureKey.SERVICES_NOMINATION]: { enabled: true },
[FeatureKey.SERVICES_BRANCH_LOCATOR]: { enabled: true },
[FeatureKey.SERVICES_AWARENESS]: { enabled: true },
@@ -185,10 +196,7 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
[FeatureKey.SERVICES_SUPPORT_CHAT]: { enabled: true },
[FeatureKey.SERVICES_HELP_CENTER]: { enabled: true },
[FeatureKey.SERVICES_BALANCE_ENQUIRY]: {
enabled: false,
reason: 'No CBS endpoint returns a balance',
},
[FeatureKey.SERVICES_BALANCE_ENQUIRY]: { enabled: true },
[FeatureKey.SERVICES_FATE_OF_CHEQUE]: { enabled: true },
[FeatureKey.SERVICES_TPIN_CHANGE]: { enabled: true },
[FeatureKey.SERVICES_TPIN_RESET]: { enabled: true, description: 'Reset a forgotten TPIN with an OTP' },
@@ -198,31 +206,31 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
[FeatureKey.SERVICES_MPIN_CHANGE]: { enabled: true },
// --------------------------------------------------------------- Loans ----
[FeatureKey.LOANS]: { enabled: false, description: 'Loans module', reason: 'Not in the target screen set' },
[FeatureKey.LOANS_DASHBOARD]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_TYPES]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_ELIGIBILITY]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_CALCULATOR]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_APPLY]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_TRACKING]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_ACTIVE_DETAILS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_EMI_PAYMENT]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_FORECLOSURE]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_HISTORY]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS_DOCUMENTS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.LOANS]: { enabled: true, description: 'Loans module' },
[FeatureKey.LOANS_DASHBOARD]: { enabled: true },
[FeatureKey.LOANS_TYPES]: { enabled: true },
[FeatureKey.LOANS_ELIGIBILITY]: { enabled: true },
[FeatureKey.LOANS_CALCULATOR]: { enabled: true },
[FeatureKey.LOANS_APPLY]: { enabled: true },
[FeatureKey.LOANS_TRACKING]: { enabled: true },
[FeatureKey.LOANS_ACTIVE_DETAILS]: { enabled: true },
[FeatureKey.LOANS_EMI_PAYMENT]: { enabled: true },
[FeatureKey.LOANS_FORECLOSURE]: { enabled: true },
[FeatureKey.LOANS_HISTORY]: { enabled: true },
[FeatureKey.LOANS_DOCUMENTS]: { enabled: true },
// ----------------------------------------------------------- Insurance ----
[FeatureKey.INSURANCE]: { enabled: false, description: 'Insurance module', reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_DASHBOARD]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_BUY]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_POLICIES]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_POLICY_DETAILS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_RENEWALS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_CLAIMS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_DOCUMENTS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_CALCULATOR]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_COMPARISON]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE_NOTIFICATIONS]: { enabled: false, reason: 'Not in the target screen set' },
[FeatureKey.INSURANCE]: { enabled: true, description: 'Insurance module' },
[FeatureKey.INSURANCE_DASHBOARD]: { enabled: true },
[FeatureKey.INSURANCE_BUY]: { enabled: true },
[FeatureKey.INSURANCE_POLICIES]: { enabled: true },
[FeatureKey.INSURANCE_POLICY_DETAILS]: { enabled: true },
[FeatureKey.INSURANCE_RENEWALS]: { enabled: true },
[FeatureKey.INSURANCE_CLAIMS]: { enabled: true },
[FeatureKey.INSURANCE_DOCUMENTS]: { enabled: true },
[FeatureKey.INSURANCE_CALCULATOR]: { enabled: true },
[FeatureKey.INSURANCE_COMPARISON]: { enabled: true },
[FeatureKey.INSURANCE_NOTIFICATIONS]: { enabled: true },
// ------------------------------------------------------------- Profile ----
[FeatureKey.PROFILE]: { enabled: true, description: 'Profile tab' },
@@ -252,21 +260,16 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
[FeatureKey.PROFILE_LEGAL]: { enabled: true },
[FeatureKey.PROFILE_ABOUT]: { enabled: true },
[FeatureKey.PROFILE_FEATURE_FLAGS]: {
// Off by default, on purpose. Enable it through remote config for the
// tenants whose staff need it; a switchboard shipped on by default would
// be reachable by every customer.
enabled: false,
// On with everything else. Shipped on by default, this switchboard is
// reachable by every customer — turn it off before a customer release.
enabled: true,
description: 'Operational feature switchboard (admin)',
},
// --------------------------------------------------------------- Agent ----
[FeatureKey.AGENT]: {
enabled: false,
enabled: true,
description: 'Field-agent application',
reason:
'Parked for now. Disabling this root retires the whole agent app — the 24 keys ' +
'below inherit it, so they are left as they are and the module comes back in ' +
'one edit. Pair with `auth.login.agentRole`, which sits outside this subtree.',
},
[FeatureKey.AGENT_DASHBOARD]: { enabled: true },
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useTheme, fontFamily } from '../../../theme';
interface AgentEmptyStateProps {
@@ -22,8 +22,8 @@ export const AgentEmptyState = ({
return (
<View style={styles.container}>
<View style={[styles.iconContainer, { backgroundColor: `${theme.colors.primary}10` }]}>
<Ionicons name={icon as any} size={48} color={theme.colors.primary} />
<View style={[styles.iconContainer, { backgroundColor: theme.tint(theme.colors.primary, 0.06) }]}>
<Icon name={icon as any} size={48} color={theme.colors.primary} />
</View>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
<Text style={[styles.description, { color: theme.colors.textMuted }]}>{description}</Text>
@@ -60,7 +60,7 @@ export const AgentProgressRing = ({
<Svg width={size} height={size}>
{/* Background Circle */}
<Circle
stroke={`${ringColor}20`}
stroke={theme.tint(ringColor, 0.13)}
fill="none"
cx={size / 2}
cy={size / 2}
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, { FadeInUp } from 'react-native-reanimated';
import LinearGradient from 'react-native-linear-gradient';
@@ -38,12 +38,12 @@ export const AgentStatCard = ({
const content = (
<View style={styles.content}>
<View style={styles.header}>
<View style={[styles.iconFrame, { backgroundColor: `${baseColor}20` }]}>
<Ionicons name={icon as any} size={18} color={baseColor} />
<View style={[styles.iconFrame, { backgroundColor: theme.tint(baseColor, 0.13) }]}>
<Icon name={icon as any} size={18} color={baseColor} />
</View>
{trend !== undefined && (
<View style={[styles.trendBadge, { backgroundColor: isPositive ? `${theme.colors.success}20` : isNegative ? `${theme.colors.danger}20` : `${theme.colors.textMuted}20` }]}>
<Ionicons
<View style={[styles.trendBadge, { backgroundColor: isPositive ? theme.tint(theme.colors.success, 0.13) : isNegative ? theme.tint(theme.colors.danger, 0.13) : theme.tint(theme.colors.textMuted, 0.13) }]}>
<Icon
name={isPositive ? 'trending-up' : isNegative ? 'trending-down' : 'remove'}
size={12}
color={isPositive ? theme.colors.success : isNegative ? theme.colors.danger : theme.colors.textMuted}
@@ -66,7 +66,7 @@ export const AgentStatCard = ({
{/* Tap indicator arrow */}
{onPress && (
<View style={styles.tapIndicator}>
<Ionicons name="chevron-forward" size={14} color={gradient ? 'rgba(255,255,255,0.4)' : theme.colors.textMuted} />
<Icon name="chevron-forward" size={14} color={gradient ? 'rgba(255,255,255,0.4)' : theme.colors.textMuted} />
</View>
)}
</View>
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useTheme, fontFamily } from '../../../theme';
import type { CollectionEntry } from '../types/agentTypes';
import { formatAgentCurrency, formatTime } from '../utils/agentFormatters';
@@ -36,8 +36,8 @@ export const CollectionEntryCard = ({ entry, onPress }: CollectionEntryCardProps
onPress={onPress}
>
<View style={styles.leftContent}>
<View style={[styles.iconContainer, { backgroundColor: `${statusColor}15` }]}>
<Ionicons
<View style={[styles.iconContainer, { backgroundColor: theme.tint(statusColor, 0.08) }]}>
<Icon
name={isCollected ? 'checkmark-circle' : isPartial ? 'pie-chart' : 'time'}
size={20}
color={statusColor}
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useTheme, fontFamily } from '../../../theme';
import type { CommissionSummary } from '../types/agentTypes';
import { formatAgentCurrency } from '../utils/agentFormatters';
@@ -53,8 +53,8 @@ export const CommissionSummaryCard = ({ summary }: CommissionSummaryCardProps) =
</View>
</View>
<View style={[styles.rateBadge, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name="information-circle" size={14} color={theme.colors.primary} />
<View style={[styles.rateBadge, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
<Icon name="information-circle" size={14} color={theme.colors.primary} />
<Text style={[styles.rateText, { color: theme.colors.primary }]}>
Calculated at {summary.commissionRate}% commission rate
</Text>
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useTheme, fontFamily } from '../../../theme';
import type { AgentCustomer } from '../types/agentTypes';
import { formatAgentCurrency, maskPhone } from '../utils/agentFormatters';
@@ -46,7 +46,7 @@ export const CustomerCard = ({
>
<View style={styles.header}>
<View style={styles.profileInfo}>
<View style={[styles.avatar, { backgroundColor: `${theme.colors.primary}15` }]}>
<View style={[styles.avatar, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>
{customer.name.charAt(0).toUpperCase()}
</Text>
@@ -54,7 +54,7 @@ export const CustomerCard = ({
<View>
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{customer.name}</Text>
<View style={styles.phoneRow}>
<Ionicons name="call-outline" size={12} color={theme.colors.textMuted} />
<Icon name="call-outline" size={12} color={theme.colors.textMuted} />
<Text style={[styles.phone, { color: theme.colors.textMuted }]}>
{maskPhone(customer.phone)}
</Text>
@@ -62,7 +62,7 @@ export const CustomerCard = ({
</View>
</View>
<View style={[styles.statusBadge, { backgroundColor: `${statusColor}15` }]}>
<View style={[styles.statusBadge, { backgroundColor: theme.tint(statusColor, 0.08) }]}>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
<Text style={[styles.statusText, { color: statusColor }]}>
{isPaid ? 'Paid' : isOverdue ? 'Overdue' : 'Pending'}
@@ -91,10 +91,10 @@ export const CustomerCard = ({
{onAction && (
<Pressable
style={[styles.actionBtn, { backgroundColor: `${actionColor || theme.colors.primary}15` }]}
style={[styles.actionBtn, { backgroundColor: theme.tint(actionColor || theme.colors.primary, 0.08) }]}
onPress={onAction}
>
<Ionicons name={actionIcon || 'chevron-forward'} size={18} color={actionColor || theme.colors.primary} />
<Icon name={actionIcon || 'chevron-forward'} size={18} color={actionColor || theme.colors.primary} />
</Pressable>
)}
</View>
@@ -162,9 +162,8 @@ const styles = StyleSheet.create({
borderRadius: 3,
},
statusText: {
fontSize: 10,
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
},
divider: {
height: 1,
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Alert, Platform, KeyboardAvoidingView } from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, { FadeInRight, FadeOutLeft } from 'react-native-reanimated';
import { Input, Button, DatePicker, KeyboardAwareScrollView } from '../../../components';
@@ -31,7 +31,7 @@ const StepIndicator = ({ steps, currentStepIndex }: { steps: any[], currentStepI
}
]}>
{i < currentStepIndex ? (
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
<Icon name="checkmark" size={14} color={theme.colors.white} />
) : (
<Text style={{ color: isPassed ? theme.colors.white : theme.colors.textMuted, fontSize: 12, fontFamily: fontFamily.bold }}>{i + 1}</Text>
)}
@@ -94,9 +94,9 @@ export default function AccountOpeningScreen({ navigation }: { navigation: Nativ
{/* Local header */}
<View style={styles.localHeader}>
<Pressable onPress={() => navigation.goBack()} hitSlop={12} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={theme.colors.textPrimary} />
<Icon name="chevron-back" size={24} color={theme.colors.textPrimary} />
</Pressable>
<Text style={[styles.localHeaderTitle, { color: theme.colors.textPrimary }]}>NEW ACCOUNT</Text>
<Text style={[styles.localHeaderTitle, { color: theme.colors.textPrimary }]}>New account</Text>
</View>
<StepIndicator steps={steps} currentStepIndex={currentStepIndex} />
@@ -121,8 +121,8 @@ export default function AccountOpeningScreen({ navigation }: { navigation: Nativ
]}
onPress={() => setAccountType(type.id)}
>
<View style={[styles.typeIcon, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name={type.icon as any} size={28} color={theme.colors.primary} />
<View style={[styles.typeIcon, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
<Icon name={type.icon as any} size={28} color={theme.colors.primary} />
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.typeLabel, { color: theme.colors.textPrimary }]}>{type.label}</Text>
@@ -245,8 +245,8 @@ export default function AccountOpeningScreen({ navigation }: { navigation: Nativ
)}
</View>
<View style={[styles.warningBox, { backgroundColor: `${theme.colors.info}10`, borderColor: `${theme.colors.info}30` }]}>
<Ionicons name="cash-outline" size={20} color={theme.colors.info} />
<View style={[styles.warningBox, { backgroundColor: theme.tint(theme.colors.info, 0.06), borderColor: theme.tint(theme.colors.info, 0.19) }]}>
<Icon name="cash-outline" size={20} color={theme.colors.info} />
<Text style={[styles.warningText, { color: theme.colors.info }]}>
Please collect the initial deposit amount from the customer in cash before proceeding.
</Text>
@@ -400,9 +400,8 @@ const styles = StyleSheet.create({
marginTop: 8,
},
stepLabel: {
fontSize: 10,
fontSize: 11,
fontFamily: fontFamily.semibold,
textTransform: 'uppercase',
letterSpacing: 0.3,
width: 54,
textAlign: 'center',
@@ -478,8 +477,7 @@ const styles = StyleSheet.create({
sectionDividerText: {
fontSize: 12,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.5,
letterSpacing: 0.1,
paddingHorizontal: 12,
marginTop: -8,
},
@@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Input, Button, KeyboardAwareScrollView } from '../../../components';
@@ -21,8 +21,8 @@ const FormSection = ({ title, icon, children }: { title: string, icon: string, c
return (
<Animated.View entering={FadeInUp} style={[styles.section, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={[styles.sectionHeader, { borderBottomColor: theme.colors.cardBorder }]}>
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name={icon as any} size={18} color={theme.colors.primary} />
<View style={[styles.sectionIcon, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
<Icon name={icon as any} size={18} color={theme.colors.primary} />
</View>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
</View>
@@ -103,7 +103,7 @@ export default function AddEditCustomerScreen({ route, navigation }: Props) {
variant="secondary"
onPress={handleFetchClient}
loading={isLookingUp}
leftAccessory={<Ionicons name="search-outline" size={18} color={theme.colors.textPrimary} />}
leftAccessory={<Icon name="search-outline" size={18} color={theme.colors.textPrimary} />}
/>
</FormSection>
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet, ScrollView, RefreshControl, Pressable } from 'r
import { CompositeNavigationProp } from '@react-navigation/native';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, { FadeIn } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
@@ -99,8 +99,8 @@ export default function AgentDashboardScreen({ navigation }: { navigation: Agent
<Text style={[styles.greeting, { color: theme.colors.textSecondary }]}>{getGreeting()},</Text>
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{profile?.firstName || 'Agent'}</Text>
</View>
<View style={[styles.avatarFrame, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name="person" size={20} color={theme.colors.primary} />
<View style={[styles.avatarFrame, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
<Icon name="person" size={20} color={theme.colors.primary} />
</View>
</View>
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { View, StyleSheet, Text, Pressable, ScrollView } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, { FadeIn, FadeInRight, FadeInLeft } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
@@ -47,7 +47,7 @@ export default function AgentLoansScreen({ route, navigation }: Props) {
]}
onPress={() => setActiveTab(tab.key)}
>
<Ionicons
<Icon
name={tab.icon as any}
size={16}
color={isActive ? theme.colors.white : theme.colors.textMuted}
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet, ScrollView, Pressable, Alert } from 'react-nati
import { CompositeNavigationProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
@@ -46,12 +46,12 @@ export default function AgentProfileScreen({ navigation }: { navigation: Navigat
onPress={onPress}
>
<View style={styles.settingsRowLeft}>
<View style={[styles.settingsIconFrame, { backgroundColor: destructive ? `${theme.colors.danger}15` : `${theme.colors.primary}15` }]}>
<Ionicons name={icon as any} size={20} color={destructive ? theme.colors.danger : theme.colors.primary} />
<View style={[styles.settingsIconFrame, { backgroundColor: destructive ? theme.tint(theme.colors.danger, 0.08) : theme.tint(theme.colors.primary, 0.08) }]}>
<Icon name={icon as any} size={20} color={destructive ? theme.colors.danger : theme.colors.primary} />
</View>
<Text style={[styles.settingsLabel, { color: destructive ? theme.colors.danger : theme.colors.textPrimary }]}>{label}</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={theme.colors.textMuted} />
<Icon name="chevron-forward" size={20} color={theme.colors.textMuted} />
</Pressable>
);
@@ -60,12 +60,12 @@ export default function AgentProfileScreen({ navigation }: { navigation: Navigat
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<Animated.View entering={FadeInUp} style={[styles.profileCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={[styles.avatarBig, { backgroundColor: `${theme.colors.primary}15` }]}>
<View style={[styles.avatarBig, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>{profile?.firstName?.charAt(0) || 'A'}</Text>
</View>
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{profile?.firstName} {profile?.lastName}</Text>
<Text style={[styles.role, { color: theme.colors.textMuted }]}>Field Agent • {profile?.email}</Text>
<View style={[styles.badge, { backgroundColor: `${theme.colors.success}15` }]}>
<View style={[styles.badge, { backgroundColor: theme.tint(theme.colors.success, 0.08) }]}>
<View style={[styles.statusDot, { backgroundColor: theme.colors.success }]} />
<Text style={[styles.badgeText, { color: theme.colors.success }]}>Active Agent</Text>
</View>
@@ -184,8 +184,7 @@ const styles = StyleSheet.create({
sectionTitle: {
fontSize: 13,
fontFamily: fontFamily.semibold,
textTransform: 'uppercase',
letterSpacing: 0.5,
letterSpacing: 0.1,
marginBottom: 8,
marginLeft: 8,
},
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Share } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { ScreenShell, Button } from '../../../components';
import { useToast } from '../../../hooks/useToast';
@@ -49,7 +49,7 @@ export default function AgentRouteScreen(_props: Props) {
<ScreenShell title="My Route" subtitle="Today's collection route" contentContainerStyle={styles.content}>
{/* Map preview */}
<View style={[styles.mapPlaceholder, { borderColor: theme.colors.cardBorder, backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons name="map-outline" size={48} color={theme.colors.primary} />
<Icon name="map-outline" size={48} color={theme.colors.primary} />
<Text style={[styles.mapText, { color: theme.colors.textSecondary }]}>Route overview · 5 stops · 8.4 km</Text>
<View style={styles.pinRow}>
{STOPS.map((stop) => (
@@ -81,8 +81,8 @@ export default function AgentRouteScreen(_props: Props) {
<View style={[styles.stopsCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
{STOPS.map((stop, index) => (
<View key={stop.id} style={[styles.stopRow, index !== STOPS.length - 1 && { borderBottomWidth: 1, borderBottomColor: theme.colors.cardBorder }]}>
<View style={[styles.stopDot, { backgroundColor: `${statusColor(stop.status)}20` }]}>
<Ionicons
<View style={[styles.stopDot, { backgroundColor: theme.tint(statusColor(stop.status), 0.13) }]}>
<Icon
name={stop.status === 'done' ? 'checkmark' : 'location'}
size={14}
color={statusColor(stop.status)}
@@ -104,7 +104,7 @@ export default function AgentRouteScreen(_props: Props) {
fullWidth={false}
style={styles.actionBtn}
onPress={() => toast('Opening maps', 'info', 'Launching turn-by-turn navigation for your route.')}
leftAccessory={<Ionicons name="navigate-outline" size={18} color={theme.colors.white} />}
leftAccessory={<Icon name="navigate-outline" size={18} color={theme.colors.white} />}
/>
<Button
title="Share Route"
@@ -112,7 +112,7 @@ export default function AgentRouteScreen(_props: Props) {
fullWidth={false}
style={styles.actionBtn}
onPress={handleShare}
leftAccessory={<Ionicons name="share-social-outline" size={18} color={theme.colors.textPrimary} />}
leftAccessory={<Icon name="share-social-outline" size={18} color={theme.colors.textPrimary} />}
/>
</View>
<View style={{ height: 32 }} />
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, { FadeIn, SlideInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
@@ -51,8 +51,8 @@ export default function CollectionReceiptScreen({ route, navigation }: Props) {
<Animated.View entering={SlideInUp.springify()} style={[styles.receiptCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.successHeader}>
<View style={[styles.successIcon, { backgroundColor: `${theme.colors.success}15` }]}>
<Ionicons name="checkmark" size={32} color={theme.colors.success} />
<View style={[styles.successIcon, { backgroundColor: theme.tint(theme.colors.success, 0.08) }]}>
<Icon name="checkmark" size={32} color={theme.colors.success} />
</View>
<Text style={[styles.successTitle, { color: theme.colors.success }]}>Payment Successful</Text>
<Text style={[styles.successAmount, { color: theme.colors.textPrimary }]}>{formatFullCurrency(receipt.amount)}</Text>
@@ -81,12 +81,12 @@ export default function CollectionReceiptScreen({ route, navigation }: Props) {
<Animated.View entering={FadeIn.delay(300)} style={styles.actionsRow}>
<Pressable style={[styles.actionBtn, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<Ionicons name="share-outline" size={20} color={theme.colors.primary} />
<Icon name="share-outline" size={20} color={theme.colors.primary} />
<Text style={[styles.actionBtnText, { color: theme.colors.primary }]}>Share PDF</Text>
</Pressable>
<Pressable style={[styles.actionBtn, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<Ionicons name="print-outline" size={20} color={theme.colors.primary} />
<Icon name="print-outline" size={20} color={theme.colors.primary} />
<Text style={[styles.actionBtnText, { color: theme.colors.primary }]}>Print</Text>
</Pressable>
</Animated.View>
@@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import { View, StyleSheet, FlatList, RefreshControl, Text } from 'react-native';
import Animated, { FadeInUp } from 'react-native-reanimated';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useTheme, fontFamily } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
@@ -51,7 +51,7 @@ export default function CommissionDashboardScreen() {
<Text style={styles.totalValue}>{formatAgentCurrency(summary.totalEarned)}</Text>
</View>
<View style={styles.iconCircle}>
<Ionicons name="star" size={24} color={theme.colors.primary} />
<Icon name="star" size={24} color={theme.colors.primary} />
</View>
</View>
<View style={styles.totalDivider} />
@@ -72,7 +72,7 @@ export default function CommissionDashboardScreen() {
<Animated.View entering={FadeInUp.delay(index * 50)} style={[styles.historyCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.historyHeader}>
<Text style={[styles.historyMonth, { color: theme.colors.textPrimary }]}>{item.month} {item.year}</Text>
<View style={[styles.statusBadge, { backgroundColor: item.status === 'paid' ? `${theme.colors.success}15` : `${theme.colors.warning}15` }]}>
<View style={[styles.statusBadge, { backgroundColor: item.status === 'paid' ? theme.tint(theme.colors.success, 0.08) : theme.tint(theme.colors.warning, 0.08) }]}>
<Text style={[styles.statusText, { color: item.status === 'paid' ? theme.colors.success : theme.colors.warning }]}>
{item.status.toUpperCase()}
</Text>
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable, Platform, KeyboardAvoidingView } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, { FadeInRight, FadeOutLeft, FadeIn } from 'react-native-reanimated';
import { ScreenShell, Input, Button, KeyboardAwareScrollView } from '../../../components';
@@ -41,7 +41,7 @@ const StepIndicator = ({ step }: { step: number }) => {
},
]}>
{isCompleted ? (
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
<Icon name="checkmark" size={14} color={theme.colors.white} />
) : (
<Text style={{
color: isActive ? theme.colors.white : theme.colors.textMuted,
@@ -210,7 +210,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
onPress={() => updateField('category', cat.id as any)}
>
<View style={[styles.catIcon, { backgroundColor: surface }]}>
<Ionicons name={cat.icon as any} size={22} color={tone} />
<Icon name={cat.icon as any} size={22} color={tone} />
</View>
<Text style={[
styles.catLabel,
@@ -220,7 +220,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
<View style={[styles.catCheck, { backgroundColor: tone }]}>
{/* On a saturated fill, so this takes the on-fill
tone — white in light mode, dark ink in dark. */}
<Ionicons
<Icon
name="checkmark"
size={12}
color={theme.colors.textOnAccentFill}
@@ -269,7 +269,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
]}
>
<View style={[styles.bannerIcon, { backgroundColor: selectedTone.surface }]}>
<Ionicons name={selectedCategory.icon as any} size={20} color={selectedTone.tone} />
<Icon name={selectedCategory.icon as any} size={20} color={selectedTone.tone} />
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.bannerTitle, { color: selectedTone.tone }]}>{selectedCategory.label}</Text>
@@ -335,7 +335,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
{/* Header badge */}
{selectedCategory && (
<View style={[styles.reviewBadge, { backgroundColor: selectedTone.surface }]}>
<Ionicons name={selectedCategory.icon as any} size={18} color={selectedTone.tone} />
<Icon name={selectedCategory.icon as any} size={18} color={selectedTone.tone} />
<Text style={[styles.reviewBadgeText, { color: selectedTone.tone }]}>
{selectedCategory.label}
</Text>
@@ -359,8 +359,8 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
</View>
{/* Documents info */}
<View style={[styles.docsCard, { backgroundColor: `${theme.colors.info}08`, borderColor: `${theme.colors.info}25` }]}>
<Ionicons name="document-attach-outline" size={20} color={theme.colors.info} />
<View style={[styles.docsCard, { backgroundColor: theme.tint(theme.colors.info, 0.03), borderColor: theme.tint(theme.colors.info, 0.15) }]}>
<Icon name="document-attach-outline" size={20} color={theme.colors.info} />
<View style={{ flex: 1 }}>
<Text style={[styles.docsTitle, { color: theme.colors.info }]}>Documents can be added later</Text>
<Text style={[styles.docsText, { color: theme.colors.textMuted }]}>
@@ -371,7 +371,7 @@ export default function CreateLoanRequestScreen({ route, navigation }: Props) {
{/* Consent */}
<View style={[styles.consentBox, { backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons name="shield-checkmark-outline" size={18} color={theme.colors.success} />
<Icon name="shield-checkmark-outline" size={18} color={theme.colors.success} />
<Text style={[styles.consentText, { color: theme.colors.textMuted }]}>
By submitting, you confirm the above details are accurate and the customer has given consent for this application.
</Text>
@@ -414,9 +414,8 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
stepLabel: {
fontSize: 10,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontSize: 11,
letterSpacing: 0.1,
},
stepLine: {
flex: 1,
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, Pressable, Alert } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
@@ -68,7 +68,7 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
const InfoRow = ({ label, value, icon, masked = false }: { label: string, value: string, icon?: string, masked?: boolean }) => (
<View style={styles.infoRow}>
<View style={styles.infoLabelArea}>
{icon && <Ionicons name={icon as any} size={16} color={theme.colors.textMuted} style={{ marginRight: 6 }} />}
{icon && <Icon name={icon as any} size={16} color={theme.colors.textMuted} style={{ marginRight: 6 }} />}
<Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>{label}</Text>
</View>
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>
@@ -84,7 +84,7 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
{/* Profile Header */}
<Animated.View entering={FadeInUp.delay(100)} style={styles.headerCard}>
<View style={styles.profileTopRow}>
<View style={[styles.avatarBig, { backgroundColor: `${theme.colors.primary}15` }]}>
<View style={[styles.avatarBig, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
<Text style={[styles.avatarBigText, { color: theme.colors.primary }]}>
{customer.name.charAt(0).toUpperCase()}
</Text>
@@ -92,7 +92,7 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
<View style={styles.headerInfo}>
<Text style={[styles.headerName, { color: theme.colors.textPrimary }]}>{customer.name}</Text>
<Text style={[styles.headerPhone, { color: theme.colors.textSecondary }]}>{maskPhone(customer.phone)}</Text>
<View style={[styles.statusChip, { backgroundColor: customer.status === 'active' ? `${theme.colors.success}15` : `${theme.colors.danger}15` }]}>
<View style={[styles.statusChip, { backgroundColor: customer.status === 'active' ? theme.tint(theme.colors.success, 0.08) : theme.tint(theme.colors.danger, 0.08) }]}>
<Text style={[styles.statusText, { color: customer.status === 'active' ? theme.colors.success : theme.colors.danger }]}>
{customer.status.toUpperCase()}
</Text>
@@ -102,23 +102,23 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
<View style={styles.actionButtonsRow}>
<Pressable style={styles.iconBtn} onPress={() => {/* Implement call */}}>
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.info}15` }]}>
<Ionicons name="call" size={20} color={theme.colors.info} />
<View style={[styles.iconFrame, { backgroundColor: theme.tint(theme.colors.info, 0.08) }]}>
<Icon name="call" size={20} color={theme.colors.info} />
</View>
<Text style={[styles.iconLabel, { color: theme.colors.info }]}>Call</Text>
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => {/* Implement SMS */}}>
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.success}15` }]}>
<Ionicons name="chatbubble" size={20} color={theme.colors.success} />
<View style={[styles.iconFrame, { backgroundColor: theme.tint(theme.colors.success, 0.08) }]}>
<Icon name="chatbubble" size={20} color={theme.colors.success} />
</View>
<Text style={[styles.iconLabel, { color: theme.colors.success }]}>SMS</Text>
</Pressable>
<FeatureGate feature={FeatureKey.AGENT_CUSTOMERS_EDIT}>
<Pressable style={styles.iconBtn} onPress={() => navigation.navigate('AddEditCustomer', { customerId: customer.id })}>
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name="create" size={20} color={theme.colors.primary} />
<View style={[styles.iconFrame, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
<Icon name="create" size={20} color={theme.colors.primary} />
</View>
<Text style={[styles.iconLabel, { color: theme.colors.primary }]}>Edit</Text>
</Pressable>
@@ -189,7 +189,7 @@ export default function CustomerDetailScreen({ route, navigation }: Props) {
variant="ghost"
loading={isDeleting}
onPress={handleDeleteCustomer}
leftAccessory={<Ionicons name="trash-outline" size={18} color={theme.colors.danger} />}
leftAccessory={<Icon name="trash-outline" size={18} color={theme.colors.danger} />}
style={{ borderColor: theme.colors.danger, borderWidth: 1 }}
/>
</View>
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, FlatList } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
@@ -46,8 +46,8 @@ export default function CustomerInstallmentHistoryScreen({ route, navigation }:
const renderItem = ({ item, index }: { item: any, index: number }) => (
<Animated.View entering={FadeInUp.delay(index * 50)} style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.leftSide}>
<View style={[styles.iconFrame, { backgroundColor: item.status === 'paid' ? `${theme.colors.success}15` : `${theme.colors.warning}15` }]}>
<Ionicons
<View style={[styles.iconFrame, { backgroundColor: item.status === 'paid' ? theme.tint(theme.colors.success, 0.08) : theme.tint(theme.colors.warning, 0.08) }]}>
<Icon
name={item.status === 'paid' ? 'checkmark-circle' : 'time'}
size={20}
color={item.status === 'paid' ? theme.colors.success : theme.colors.warning}
@@ -3,7 +3,7 @@ import { View, StyleSheet, FlatList, RefreshControl, TextInput, Pressable, Text
import { CompositeNavigationProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, { FadeIn } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
@@ -67,7 +67,7 @@ export default function CustomerListScreen({ navigation }: { navigation: Navigat
{/* Search Bar */}
<View style={styles.headerArea}>
<View style={[styles.searchBar, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<Ionicons name="search-outline" size={20} color={theme.colors.textMuted} />
<Icon name="search-outline" size={20} color={theme.colors.textMuted} />
<TextInput
style={[styles.searchInput, { color: theme.colors.textPrimary }]}
placeholder="Search by name or phone..."
@@ -77,7 +77,7 @@ export default function CustomerListScreen({ navigation }: { navigation: Navigat
/>
{searchQuery.length > 0 && (
<Pressable onPress={() => handleSearch('')} style={styles.clearBtn}>
<Ionicons name="close-circle" size={16} color={theme.colors.textMuted} />
<Icon name="close-circle" size={16} color={theme.colors.textMuted} />
</Pressable>
)}
</View>
@@ -167,7 +167,7 @@ export default function CustomerListScreen({ navigation }: { navigation: Navigat
style={[styles.fab, { backgroundColor: theme.colors.primary }]}
onPress={() => navigation.navigate('AddEditCustomer', {})}
>
<Ionicons name="add" size={24} color={theme.colors.white} />
<Icon name="add" size={24} color={theme.colors.white} />
</Pressable>
</FeatureGate>
</ScreenShell>
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet, FlatList, RefreshControl, Pressable } from 'rea
import { CompositeNavigationProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, { FadeIn, SlideInDown } from 'react-native-reanimated';
import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet';
@@ -75,13 +75,13 @@ const CollectionForm = ({ selectedEntry, navigation, bottomSheetRef }: { selecte
style={[
styles.modeChip,
{
backgroundColor: mode === m.id ? `${theme.colors.primary}15` : theme.colors.surface,
backgroundColor: mode === m.id ? theme.tint(theme.colors.primary, 0.08) : theme.colors.surface,
borderColor: mode === m.id ? theme.colors.primary : theme.colors.cardBorder,
}
]}
onPress={() => setMode(m.id)}
>
<Ionicons name={m.icon as any} size={16} color={mode === m.id ? theme.colors.primary : theme.colors.textMuted} />
<Icon name={m.icon as any} size={16} color={mode === m.id ? theme.colors.primary : theme.colors.textMuted} />
<Text style={[styles.modeText, { color: mode === m.id ? theme.colors.primary : theme.colors.textMuted }]}>{m.label}</Text>
</Pressable>
))}
@@ -164,7 +164,7 @@ export default function DailyCollectionScreen({ navigation }: { navigation: Navi
{offlineQueue.length > 0 && (
<View style={[styles.offlineBanner, { backgroundColor: theme.colors.warning }]}>
<Ionicons name="cloud-offline" size={16} color="#fff" />
<Icon name="cloud-offline" size={16} color="#fff" />
<Text style={styles.offlineText}>{offlineQueue.length} entries waiting to sync</Text>
</View>
)}
@@ -244,7 +244,7 @@ export default function DailyCollectionScreen({ navigation }: { navigation: Navi
onPress={() => handleEntryPress(item)}
>
<View style={styles.entryLeft}>
<View style={[styles.avatarBox, { backgroundColor: `${theme.colors.primary}15` }]}>
<View style={[styles.avatarBox, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}>
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>{item.customerName.charAt(0)}</Text>
</View>
<View>
@@ -257,7 +257,7 @@ export default function DailyCollectionScreen({ navigation }: { navigation: Navi
{isCollected || isPartial ? (
<>
<Text style={[styles.entryAmount, { color: theme.colors.success }]}>{formatAgentCurrency(item.collectedAmount)}</Text>
<View style={[styles.statusBadge, { backgroundColor: `${theme.colors.success}15` }]}>
<View style={[styles.statusBadge, { backgroundColor: theme.tint(theme.colors.success, 0.08) }]}>
<Text style={[styles.statusText, { color: theme.colors.success }]}>Collected</Text>
</View>
</>
@@ -280,6 +280,13 @@ export default function DailyCollectionScreen({ navigation }: { navigation: Navi
index={-1}
snapPoints={['50%', '65%']}
enablePanDownToClose
// The amount field sits in this sheet. Android's window shrinks for
// the keyboard (adjustResize, restored on 15+ by MainActivity), and the
// sheet has to be told so — its default assumes it must lift itself,
// which on a resized window lifts it twice and off the top.
android_keyboardInputMode="adjustResize"
keyboardBehavior="interactive"
keyboardBlurBehavior="restore"
backgroundStyle={{ backgroundColor: theme.colors.surface }}
handleIndicatorStyle={{ backgroundColor: theme.colors.cardBorder }}
>
@@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, FlatList, Pressable, RefreshControl } from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, { SlideInRight } from 'react-native-reanimated';
import { useTheme, fontFamily } from '../../../theme';
@@ -32,10 +32,10 @@ export default function LoanRequestListScreen({ navigation }: { navigation: Nati
<View style={styles.headerRow}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Recent Requests</Text>
<Pressable
style={[styles.newBtn, { backgroundColor: `${theme.colors.primary}15` }]}
style={[styles.newBtn, { backgroundColor: theme.tint(theme.colors.primary, 0.08) }]}
onPress={() => navigation.navigate('CreateLoanRequest', {})}
>
<Ionicons name="add" size={16} color={theme.colors.primary} />
<Icon name="add" size={16} color={theme.colors.primary} />
<Text style={[styles.newBtnText, { color: theme.colors.primary }]}>New Request</Text>
</Pressable>
</View>
@@ -70,7 +70,7 @@ export default function LoanRequestListScreen({ navigation }: { navigation: Nati
<Text style={[styles.customerName, { color: theme.colors.textPrimary }]}>{item.customerName}</Text>
<Text style={[styles.category, { color: theme.colors.textMuted }]}>{item.category} Loan</Text>
</View>
<View style={[styles.statusBadge, { backgroundColor: `${statusColor}15` }]}>
<View style={[styles.statusBadge, { backgroundColor: theme.tint(statusColor, 0.08) }]}>
<Text style={[styles.statusText, { color: statusColor }]}>{item.status.replace('_', ' ').toUpperCase()}</Text>
</View>
</View>
@@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import { View, StyleSheet, FlatList, RefreshControl, Text } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
@@ -59,11 +59,11 @@ export default function SmsHistoryScreen({ navigation }: Props) {
<Animated.View entering={FadeInUp.delay(index * 50)} style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.cardHeader}>
<View style={styles.headerLeft}>
<Ionicons name="chatbubble-ellipses" size={16} color={theme.colors.primary} />
<Icon name="chatbubble-ellipses" size={16} color={theme.colors.primary} />
<Text style={[styles.phone, { color: theme.colors.textPrimary }]}>{item.customerPhone}</Text>
</View>
<View style={styles.headerRight}>
<Ionicons name={statusIcon} size={14} color={statusColor} />
<Icon name={statusIcon} size={14} color={statusColor} />
<Text style={[styles.statusText, { color: statusColor }]}>{item.status.toUpperCase()}</Text>
</View>
</View>
@@ -1,6 +1,6 @@
import React, { memo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useTheme } from '../../../theme';
@@ -58,13 +58,13 @@ const BiometricNodeComponent = ({
{/* Pressing brightens rather than dims. Fading an element on a
navy wash reduces its contrast against the wash, which is the
wrong direction for feedback on a sign-in control. */}
<Ionicons
<Icon
name="scan-outline"
size={148}
color={pressed ? theme.colors.onBrandPrimary : theme.colors.onBrandSecondary}
/>
<View style={styles.frameCentre} pointerEvents="none">
<Ionicons name="finger-print" size={52} color={theme.colors.onBrandPrimary} />
<Icon name="finger-print" size={52} color={theme.colors.onBrandPrimary} />
</View>
</View>
@@ -114,14 +114,14 @@ const BiometricNodeComponent = ({
},
]}
>
<Ionicons name="finger-print" size={20} color={theme.colors.primary} />
<Icon name="finger-print" size={20} color={theme.colors.primary} />
</View>
<Text style={[theme.text.button, styles.tileLabel, { color: theme.colors.primary }]}>
{label}
</Text>
<Ionicons name="chevron-forward" size={18} color={theme.colors.textMuted} />
<Icon name="chevron-forward" size={18} color={theme.colors.textMuted} />
</Pressable>
);
};
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useTheme } from '../../../theme';
@@ -152,7 +152,7 @@ export const CodeInput = ({
{error ? (
<View style={styles.message}>
<Ionicons
<Icon
name="alert-circle"
size={14}
color={onBrand ? theme.colors.onBrandPrimary : theme.colors.danger}
@@ -0,0 +1,214 @@
import React, { memo } from 'react';
import Svg, {
Circle,
Defs,
G,
LinearGradient,
Path,
Rect,
Stop,
} from 'react-native-svg';
import { useTheme } from '../../../theme';
/**
* The three onboarding illustrations, drawn in SVG from theme roles.
*
* Vector rather than bitmap art so they follow the live theme — the same
* drawing is correct in light and dark mode — cost a few kilobytes instead of
* three large PNGs, and stay sharp at any density. Every fill is a theme role;
* no hex is written here.
*
* All three share one 280 × 260 canvas and a soft tint disc behind the
* subject, so the slides read as a set.
*/
const VIEW_BOX = '0 0 280 260';
interface IllustrationProps {
/** Rendered width. Height follows the shared aspect ratio. */
width: number;
}
const heightFor = (width: number) => (width * 260) / 280;
/** The tint disc and a scatter of small sparkles every slide sits on. */
const Backdrop = () => {
const theme = useTheme();
return (
<G>
<Circle cx={140} cy={132} r={112} fill={theme.colors.primarySurface} />
<Circle cx={140} cy={132} r={84} fill={theme.colors.surface} opacity={0.55} />
<Circle cx={42} cy={64} r={4} fill={theme.colors.primaryLight} opacity={0.6} />
<Circle cx={244} cy={48} r={3} fill={theme.colors.accentFill} />
<Circle cx={252} cy={196} r={5} fill={theme.colors.primaryLight} opacity={0.45} />
<Circle cx={30} cy={200} r={3} fill={theme.colors.accentFill} opacity={0.8} />
</G>
);
};
/** Slide 1 — "Your goals. Our support.": two cards and a phone with a chart. */
const GoalsIllustrationComponent = ({ width }: IllustrationProps) => {
const theme = useTheme();
const [heroA, heroB, heroC] = theme.colors.heroGradient;
return (
<Svg width={width} height={heightFor(width)} viewBox={VIEW_BOX}>
<Defs>
<LinearGradient id="cardFront" x1="0" y1="0" x2="1" y2="1">
<Stop offset="0" stopColor={heroA} />
<Stop offset="1" stopColor={heroC} />
</LinearGradient>
<LinearGradient id="cardBack" x1="0" y1="0" x2="1" y2="1">
<Stop offset="0" stopColor={theme.colors.primaryLight} />
<Stop offset="1" stopColor={heroB} />
</LinearGradient>
</Defs>
<Backdrop />
{/* back card, tilted */}
<G transform="rotate(-14 98 112)">
<Rect x={30} y={70} width={150} height={94} rx={14} fill="url(#cardBack)" />
</G>
{/* front card */}
<G transform="rotate(-4 104 150)">
<Rect x={34} y={104} width={150} height={94} rx={14} fill="url(#cardFront)" />
<Rect x={50} y={124} width={26} height={20} rx={4} fill={theme.colors.accentFill} />
<Rect x={50} y={160} width={70} height={7} rx={3.5} fill={theme.colors.onBrandSurfaceStrong} />
<Rect x={50} y={174} width={44} height={7} rx={3.5} fill={theme.colors.onBrandSurface} />
<Circle cx={158} cy={176} r={9} fill={theme.colors.onBrandSurfaceStrong} />
<Circle cx={146} cy={176} r={9} fill={theme.colors.onBrandSurface} />
</G>
{/* phone */}
<Rect x={164} y={58} width={84} height={156} rx={16} fill={theme.colors.surface} stroke={theme.colors.cardBorderStrong} strokeWidth={2} />
<Rect x={172} y={70} width={68} height={132} rx={10} fill={theme.colors.primarySurface} />
<Rect x={198} y={63} width={16} height={3} rx={1.5} fill={theme.colors.cardBorderStrong} />
{/* chart bars */}
<Rect x={182} y={150} width={10} height={36} rx={3} fill={theme.colors.primaryLight} />
<Rect x={198} y={134} width={10} height={52} rx={3} fill={theme.colors.primary} />
<Rect x={214} y={118} width={10} height={68} rx={3} fill={theme.colors.primaryDark} />
{/* trend line */}
<Path d="M182 120 L200 106 L214 112 L230 88" stroke={theme.colors.primary} strokeWidth={3} fill="none" strokeLinecap="round" strokeLinejoin="round" />
<Circle cx={230} cy={88} r={5} fill={theme.colors.accentFill} />
{/* check badge */}
<Circle cx={240} cy={204} r={16} fill={theme.colors.success} />
<Path d="M232 204 L238 210 L249 198" stroke={theme.colors.textOnBrand} strokeWidth={3} fill="none" strokeLinecap="round" strokeLinejoin="round" />
</Svg>
);
};
/** Slide 2 — "Secure & convenient": a phone carrying a shield. */
const SecureIllustrationComponent = ({ width }: IllustrationProps) => {
const theme = useTheme();
const [heroA, , heroC] = theme.colors.heroGradient;
return (
<Svg width={width} height={heightFor(width)} viewBox={VIEW_BOX}>
<Defs>
<LinearGradient id="phoneBody" x1="0" y1="0" x2="1" y2="1">
<Stop offset="0" stopColor={heroA} />
<Stop offset="1" stopColor={heroC} />
</LinearGradient>
</Defs>
<Backdrop />
{/* phone */}
<Rect x={92} y={34} width={104} height={196} rx={20} fill="url(#phoneBody)" />
<Rect x={101} y={46} width={86} height={172} rx={13} fill={theme.colors.onBrandSurface} />
<Rect x={132} y={39} width={24} height={4} rx={2} fill={theme.colors.onBrandSurfaceStrong} />
{/* shield on the screen */}
<Path
d="M144 84 L174 96 V120 C174 142 161 156 144 164 C127 156 114 142 114 120 V96 Z"
fill={theme.colors.surface}
/>
<Path
d="M131 123 L141 133 L159 113"
stroke={theme.colors.primary}
strokeWidth={6}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
<Rect x={118} y={184} width={52} height={7} rx={3.5} fill={theme.colors.onBrandSurfaceStrong} />
<Rect x={128} y={198} width={32} height={7} rx={3.5} fill={theme.colors.onBrandSurface} />
{/* small shield, right */}
<Path
d="M222 110 L242 118 V134 C242 149 233 158 222 163 C211 158 202 149 202 134 V118 Z"
fill={theme.colors.surface}
stroke={theme.colors.primary}
strokeWidth={3}
/>
<Path d="M214 136 L220 142 L231 130" stroke={theme.colors.primary} strokeWidth={3.5} fill="none" strokeLinecap="round" strokeLinejoin="round" />
{/* a person, left — the customer the shield is for */}
<Circle cx={62} cy={168} r={9} fill={theme.colors.primaryDark} />
<Path d="M48 214 C48 194 54 182 62 182 C70 182 76 194 76 214 Z" fill={theme.colors.primary} />
{/* lock, top left */}
<Rect x={50} y={88} width={28} height={22} rx={5} fill={theme.colors.accentFill} />
<Path d="M56 88 V82 C56 74 72 74 72 82 V88" stroke={theme.colors.accentFill} strokeWidth={4} fill="none" />
<Circle cx={64} cy={99} r={3} fill={theme.colors.textOnAccentFill} />
</Svg>
);
};
/** Slide 3 — "Build a brighter financial future": a gift box. */
const FutureIllustrationComponent = ({ width }: IllustrationProps) => {
const theme = useTheme();
const [heroA, heroB, heroC] = theme.colors.heroGradient;
const ribbon = theme.colors.accentFill;
return (
<Svg width={width} height={heightFor(width)} viewBox={VIEW_BOX}>
<Defs>
<LinearGradient id="boxBody" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor={heroB} />
<Stop offset="1" stopColor={heroC} />
</LinearGradient>
<LinearGradient id="boxLid" x1="0" y1="0" x2="1" y2="0">
<Stop offset="0" stopColor={theme.colors.primaryLight} />
<Stop offset="1" stopColor={heroA} />
</LinearGradient>
</Defs>
<Backdrop />
{/* shadow */}
<Rect x={74} y={214} width={132} height={10} rx={5} fill={theme.colors.primaryBorder} opacity={0.7} />
{/* box */}
<Rect x={82} y={122} width={116} height={96} rx={8} fill="url(#boxBody)" />
<Rect x={130} y={122} width={20} height={96} fill={ribbon} />
{/* lid */}
<Rect x={72} y={98} width={136} height={32} rx={8} fill="url(#boxLid)" />
<Rect x={130} y={98} width={20} height={32} fill={ribbon} />
{/* bow */}
<Path d="M140 98 C120 70 92 78 104 94 C110 102 126 100 140 98 Z" fill={ribbon} />
<Path d="M140 98 C160 70 188 78 176 94 C170 102 154 100 140 98 Z" fill={ribbon} />
<Circle cx={140} cy={97} r={8} fill={theme.colors.accent} />
{/* floating coins */}
<Circle cx={60} cy={110} r={13} fill={ribbon} />
<Circle cx={60} cy={110} r={8} fill="none" stroke={theme.colors.textOnAccentFill} strokeWidth={2} opacity={0.5} />
<Circle cx={226} cy={86} r={10} fill={ribbon} />
<Circle cx={226} cy={86} r={6} fill="none" stroke={theme.colors.textOnAccentFill} strokeWidth={2} opacity={0.5} />
{/* rising arrow */}
<Path d="M214 178 L238 150" stroke={theme.colors.success} strokeWidth={4} strokeLinecap="round" />
<Path d="M226 148 L240 148 L240 162" stroke={theme.colors.success} strokeWidth={4} fill="none" strokeLinecap="round" strokeLinejoin="round" />
</Svg>
);
};
export const GoalsIllustration = memo(GoalsIllustrationComponent);
export const SecureIllustration = memo(SecureIllustrationComponent);
export const FutureIllustration = memo(FutureIllustrationComponent);
@@ -1,6 +1,6 @@
import React, { useCallback, useMemo } from 'react';
import { Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useTheme } from '../../../theme';
import { haptics } from '../../../services/haptics/haptics';
@@ -8,7 +8,7 @@ import { appConfig } from '../../../config/appConfig';
import { FeatureKey } from '../../../constants/featureKeys';
import { useEnabledItems } from '../../../features';
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
type IoniconName = React.ComponentProps<typeof Icon>['name'];
interface PreLoginAction {
id: string;
@@ -96,7 +96,7 @@ export const PreLoginActions = ({ onOpenAccount }: PreLoginActionsProps) => {
},
]}
>
<Ionicons name={action.icon} size={20} color={theme.colors.onBrandPrimary} />
<Icon name={action.icon} size={20} color={theme.colors.onBrandPrimary} />
{/* Solid white, not `onBrandSecondary`. The tile's own translucent
fill lightens the wash underneath it, so a translucent label on
top of it lands at 4.37:1 at the top of the gradient — under AA.
+2 -2
View File
@@ -8,7 +8,7 @@ import {
type DimensionValue,
type LayoutChangeEvent,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Svg, { Path, Rect } from 'react-native-svg';
import { fontFamily, useTheme } from '../../../theme';
@@ -257,7 +257,7 @@ const SimCard = ({
{busy ? (
<ActivityIndicator size="small" color={theme.colors.primary} />
) : (
<Ionicons
<Icon
name={number ? 'arrow-forward' : 'close-circle-outline'}
size={18}
color={accent}
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Button, Card, ScreenShell } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
@@ -149,7 +149,7 @@ export default function CreateMpinScreen({ navigation }: Props) {
{biometricsAvailable && (
<Card style={styles.biometricCard}>
<View style={styles.biometricRow}>
<Ionicons name="finger-print-outline" size={22} color={theme.colors.primary} />
<Icon name="finger-print-outline" size={22} color={theme.colors.primary} />
<View style={styles.biometricCopy}>
<Text style={[styles.biometricTitle, { color: theme.colors.textPrimary }]}>
Sign in with biometrics
@@ -171,8 +171,8 @@ export default function CreateMpinScreen({ navigation }: Props) {
</Card>
)}
<View style={[styles.noteCard, { backgroundColor: `${theme.colors.warning}14` }]}>
<Ionicons name="alert-circle-outline" size={18} color={theme.colors.warning} />
<View style={[styles.noteCard, { backgroundColor: theme.tint(theme.colors.warning, 0.08) }]}>
<Icon name="alert-circle-outline" size={18} color={theme.colors.warning} />
<Text style={[styles.noteText, { color: theme.colors.textSecondary }]}>
Three wrong MPINs lock your profile, and only your branch can unlock it. Pick
something you will remember.
+6 -5
View File
@@ -14,7 +14,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import LinearGradient from 'react-native-linear-gradient';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Animated, { FadeIn, FadeInUp } from 'react-native-reanimated';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Button, InfoBanner, Input, Loader } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
@@ -479,11 +479,12 @@ export default function LoginScreen({ navigation }: Props) {
</View>
) : null}
{/* White fill, navy label — `primary` here would be navy on
navy and disappear into the wash. */}
{/* Solid brand blue on the navy wash. The wash is kept deep
precisely so the vivid blue reads against it, and the
label is the fill's own `textOnBrand` in both themes. */}
<Button
title="Sign in"
variant="inverse"
variant="primary"
loading={isLoading}
disabled={!canSubmit}
onPress={handleSignIn}
@@ -518,7 +519,7 @@ export default function LoginScreen({ navigation }: Props) {
onPress={handleUseMpin}
disabled={isLoading}
rightAccessory={
<Ionicons
<Icon
name="chevron-forward"
size={16}
color={theme.colors.onBrandPrimary}
@@ -1,7 +1,7 @@
import React from 'react';
import { Linking, StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Button, Card, ScreenShell } from '../../../components';
import { useAppDispatch } from '../../../app/hooks';
@@ -38,8 +38,8 @@ export default function MpinLockedScreen({ navigation }: Props) {
withTabBar={false}
contentContainerStyle={styles.container}
>
<View style={[styles.iconWrap, { backgroundColor: `${theme.colors.error}14` }]}>
<Ionicons name="lock-closed-outline" size={40} color={theme.colors.error} />
<View style={[styles.iconWrap, { backgroundColor: theme.tint(theme.colors.error, 0.08) }]}>
<Icon name="lock-closed-outline" size={40} color={theme.colors.error} />
</View>
<Card style={styles.card}>
@@ -0,0 +1,641 @@
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import {
Animated as RNAnimated,
Easing as RNEasing,
FlatList,
Pressable,
StatusBar,
StyleSheet,
Text,
useWindowDimensions,
View,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { Icon } from '../../../components/Icon';
import LinearGradient from 'react-native-linear-gradient';
import Animated, {
Extrapolation,
interpolate,
useAnimatedScrollHandler,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withRepeat,
withSequence,
withTiming,
cancelAnimation,
Easing,
FadeInDown,
type SharedValue,
} from 'react-native-reanimated';
import { Button } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
import { FeatureKey } from '../../../constants/featureKeys';
import { useFeature } from '../../../features';
import { haptics } from '../../../services/haptics/haptics';
import { useTheme, fontFamily } from '../../../theme';
import type { AuthStackParamList } from '../../../navigation/types';
import { selectLastProfile } from '../store/authSelectors';
import { markOnboardingSeen } from '../utils/onboardingFlag';
import {
FutureIllustration,
GoalsIllustration,
SecureIllustration,
} from '../components/OnboardingIllustrations';
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'Onboarding'>>;
type IconName = React.ComponentProps<typeof Icon>['name'];
const GUTTER = 24;
/** The illustrations are drawn on a 280 × 260 canvas. */
const ART_RATIO = 280 / 260;
/**
* The slides. Every line describes something the app actually does — accounts
* and transfers, the security around them, and the products behind Explore —
* rather than generic marketing. Nothing here claims a feature the app lacks.
*
* `highlights` are kept to a few words each so the chips sit in one or two
* rows on a 360pt phone. `rolling` is a short lead and a few words that roll
* into place after it, each kept short enough for the line to fit one row.
*/
const SLIDES: ReadonlyArray<{
key: string;
kicker: string;
title: string;
body: string;
highlights: ReadonlyArray<{ icon: IconName; label: string }>;
rolling: { lead: string; words: readonly string[] };
Illustration: React.ComponentType<{ width: number }>;
}> = [
{
key: 'goals',
kicker: 'Everyday banking',
title: 'Your goals.\nOur support.',
body: 'Check balances, read your mini statement and move money between banks — all from one app.',
highlights: [
{ icon: 'wallet-outline', label: 'Live balances' },
{ icon: 'receipt-outline', label: 'Statements' },
{ icon: 'paper-plane-outline', label: 'IMPS transfers' },
],
rolling: { lead: 'Banking made', words: ['simple', 'instant', 'personal'] },
Illustration: GoalsIllustration,
},
{
key: 'secure',
kicker: 'Security first',
title: 'Secure\n& convenient',
body: 'Your MPIN, biometrics and a phone registered to you. Every transfer is confirmed with a TPIN and an OTP.',
highlights: [
{ icon: 'finger-print-outline', label: 'Biometric login' },
{ icon: 'phone-portrait-outline', label: 'Device bound' },
{ icon: 'key-outline', label: 'TPIN + OTP' },
],
rolling: { lead: 'Protected by', words: ['your MPIN', 'biometrics', 'TPIN + OTP'] },
Illustration: SecureIllustration,
},
{
key: 'future',
kicker: 'Grow with us',
title: 'Build a brighter\nfinancial future',
body: 'Open fixed and recurring deposits, plan a loan with the EMI calculator and explore insurance.',
highlights: [
{ icon: 'trending-up-outline', label: 'FD & RD' },
{ icon: 'calculator-outline', label: 'EMI planner' },
{ icon: 'shield-checkmark-outline', label: 'Insurance' },
],
rolling: { lead: 'Your future, made', words: ['secure', 'simple', 'brighter'] },
Illustration: FutureIllustration,
},
];
/**
* First-launch introduction: three swipeable slides, shown once per handset.
*
* It only ever leads somewhere — there is nothing to fill in — so both
* "Get started" and "Skip" end in the same place: the flag is recorded and the
* stack is replaced with registration (or sign-in, when registration is off or
* a profile is already stored), so the back gesture cannot return here.
*
* Motion is tied to the swipe itself: the copy drifts and fades, the
* illustration scales, and the progress dots stretch, all driven on the UI
* thread from the scroll position. With "reduce motion" on, only the swipe
* itself moves.
*/
export default function OnboardingScreen({ navigation }: Props) {
const theme = useTheme();
const { width } = useWindowDimensions();
const listRef = useRef<FlatList<(typeof SLIDES)[number]>>(null);
const [index, setIndex] = useState(0);
const scrollX = useSharedValue(0);
const registrationEnabled = useFeature(FeatureKey.AUTH_REGISTER);
const lastProfile = useAppSelector(selectLastProfile);
const isLast = index === SLIDES.length - 1;
const finish = useCallback(() => {
void markOnboardingSeen();
navigation.replace(registrationEnabled && !lastProfile ? 'Register' : 'Login');
}, [lastProfile, navigation, registrationEnabled]);
const handleNext = useCallback(() => {
haptics.selection();
if (isLast) {
finish();
return;
}
listRef.current?.scrollToIndex({ index: index + 1, animated: true });
}, [finish, index, isLast]);
const handleScroll = useAnimatedScrollHandler((event) => {
scrollX.value = event.contentOffset.x;
});
const handleScrollEnd = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const next = Math.round(event.nativeEvent.contentOffset.x / width);
setIndex(Math.min(Math.max(next, 0), SLIDES.length - 1));
},
[width],
);
return (
<View style={styles.flex}>
{/* A soft tint rising from the foot of the screen, so the white slides
sit on something rather than floating in a blank page. */}
<LinearGradient
colors={[theme.colors.surface, theme.colors.surface, theme.colors.primarySurface]}
locations={[0, 0.55, 1]}
style={StyleSheet.absoluteFill}
/>
<SafeAreaView edges={['top', 'bottom']} style={styles.flex}>
<StatusBar barStyle={theme.statusBarStyle} backgroundColor="transparent" translucent />
{/* ── Top bar: the way out ────────────────────────────────────────── */}
<View style={styles.topBar}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Skip introduction"
disabled={isLast}
onPress={finish}
hitSlop={10}
style={[styles.skip, isLast && styles.hidden]}
>
<Text style={[theme.text.button, { color: theme.colors.primary }]}>Skip</Text>
</Pressable>
</View>
<Animated.FlatList
ref={listRef}
data={SLIDES}
keyExtractor={(item) => item.key}
horizontal
pagingEnabled
bounces={false}
showsHorizontalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
onMomentumScrollEnd={handleScrollEnd}
getItemLayout={(_, i) => ({ length: width, offset: width * i, index: i })}
renderItem={({ item, index: slideIndex }) => (
<Slide
item={item}
slideIndex={slideIndex}
width={width}
scrollX={scrollX}
active={slideIndex === index}
/>
)}
/>
{/* ── Footer: progress, and the way on ──────────────────────────────── */}
<View style={styles.footer}>
<View
style={styles.dots}
accessible
accessibilityLabel={`Slide ${index + 1} of ${SLIDES.length}`}
>
{SLIDES.map((entry, i) => (
<Dot key={entry.key} i={i} width={width} scrollX={scrollX} />
))}
</View>
<Button
title={isLast ? 'Get started' : 'Next'}
onPress={handleNext}
rightAccessory={
<Icon
name={isLast ? 'checkmark' : 'arrow-forward'}
size={18}
color={theme.colors.textOnBrand}
/>
}
/>
</View>
</SafeAreaView>
</View>
);
}
/* ── One slide ─────────────────────────────────────────────────────────────── */
interface SlideProps {
item: (typeof SLIDES)[number];
slideIndex: number;
width: number;
scrollX: SharedValue<number>;
active: boolean;
}
const SlideComponent = ({ item, slideIndex, width, scrollX, active }: SlideProps) => {
const theme = useTheme();
const reduceMotion = useReducedMotion();
const [artBox, setArtBox] = useState({ width: 0, height: 0 });
const float = useSharedValue(0);
/** Bumped each time this slide arrives, to replay the copy's entrance. */
const [run, setRun] = useState(0);
useEffect(() => {
if (active) {
setRun((n) => n + 1);
}
}, [active]);
/** Rise-and-fade, staggered by position. Nothing at all with reduce motion. */
const enter = (position: number) =>
reduceMotion ? undefined : FadeInDown.delay(80 + position * 90).duration(420);
// A slow bob on the slide that is showing. Stopped when it is not, and never
// started with reduce motion on.
useEffect(() => {
if (!active || reduceMotion) {
cancelAnimation(float);
float.value = withTiming(0, { duration: 200 });
return;
}
float.value = withRepeat(
withSequence(
withTiming(-8, { duration: 1800, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 1800, easing: Easing.inOut(Easing.sin) }),
),
-1,
);
return () => cancelAnimation(float);
}, [active, float, reduceMotion]);
const range = [(slideIndex - 1) * width, slideIndex * width, (slideIndex + 1) * width];
// The copy drifts a little against the swipe and fades, so it reads as a
// layer above the page rather than glued to it.
const copyStyle = useAnimatedStyle(() => {
if (reduceMotion) {
return {};
}
return {
opacity: interpolate(scrollX.value, range, [0, 1, 0], Extrapolation.CLAMP),
transform: [
{
translateX: interpolate(
scrollX.value,
range,
[width * 0.3, 0, -width * 0.3],
Extrapolation.CLAMP,
),
},
],
};
});
const artStyle = useAnimatedStyle(() => {
const scale = reduceMotion
? 1
: interpolate(scrollX.value, range, [0.82, 1, 0.82], Extrapolation.CLAMP);
return {
opacity: reduceMotion
? 1
: interpolate(scrollX.value, range, [0.3, 1, 0.3], Extrapolation.CLAMP),
transform: [{ translateY: float.value }, { scale }],
};
});
const handleArtLayout = (event: LayoutChangeEvent) => {
const { width: boxWidth, height: boxHeight } = event.nativeEvent.layout;
setArtBox({ width: boxWidth, height: boxHeight });
};
// Fits whatever room the copy leaves: by width on a tall phone, by height
// on a short one, and never larger than the art was drawn to look good at.
const artWidth = Math.max(
0,
Math.min(artBox.width, (artBox.height - 16) * ART_RATIO, 330),
);
return (
<View style={[styles.slide, { width }]}>
<Animated.View style={[styles.copy, copyStyle]}>
{/* Re-keyed on every arrival, so each element replays its entrance —
label, headline, rolling line, description, chips — one after
another. */}
<Animated.View key={`kicker-${run}`} entering={enter(0)} style={styles.kickerRow}>
<Text style={[theme.text.eyebrow, styles.kickerText, { color: theme.colors.primary }]}>
{item.kicker}
</Text>
</Animated.View>
<Animated.View key={`title-${run}`} entering={enter(1)}>
<Text style={[theme.text.display, { color: theme.colors.textPrimary }]}>
{item.title}
</Text>
</Animated.View>
<Animated.View key={`roll-${run}`} entering={enter(2)}>
<RollingWord lead={item.rolling.lead} words={item.rolling.words} active={active} />
</Animated.View>
<Animated.View key={`body-${run}`} entering={enter(3)}>
<Text style={[theme.text.body, { color: theme.colors.textSecondary }]}>{item.body}</Text>
</Animated.View>
<Animated.View key={`chips-${run}`} entering={enter(4)} style={styles.chips}>
{item.highlights.map((highlight) => (
<View
key={highlight.label}
style={[
styles.chip,
{
backgroundColor: theme.colors.primarySurface,
borderColor: theme.colors.primaryBorder,
},
]}
>
<Icon name={highlight.icon} size={14} color={theme.colors.primary} />
<Text style={[styles.chipText, { color: theme.colors.textPrimary }]}>
{highlight.label}
</Text>
</View>
))}
</Animated.View>
</Animated.View>
<View style={styles.art} onLayout={handleArtLayout}>
{artWidth > 0 ? (
<Animated.View style={artStyle}>
<item.Illustration width={artWidth} />
</Animated.View>
) : null}
</View>
</View>
);
};
const Slide = memo(SlideComponent);
/* ── A lead with a word that rolls into place ──────────────────────────────── */
/**
* "Banking made simple" → "instant" → "personal": the lead stays put and the
* last word rolls — the old one lifts away as the new one rises in.
*
* The word sits in a clipped window sized to the longest of them, so the line
* never changes width. Rolls only on the slide that is showing; screen
* readers hear the lead and every word once. With reduce motion on, the first
* word simply stays.
*
* Driven by React Native's own Animated on a View, NOT by Reanimated's
* entering/exiting layout animations. Those, put on a Text, hit a Reanimated
* Android bug (software-mansion/react-native-reanimated#10734, fixed by
* #10732): the layout-animation proxy reads the Text's props as if they were
* a View's, past the end of the allocation, and the app dies with SIGSEGV in
* MountingCoordinator::pullTransaction — intermittently, depending on the
* device's allocator. Swapping a keyed Animated.Text every 2.2 seconds made
* this screen hit it constantly. Do not move this back to layout animations.
*/
const RollingWord = ({
lead,
words,
active,
}: {
lead: string;
words: readonly string[];
active: boolean;
}) => {
const theme = useTheme();
const reduceMotion = useReducedMotion();
const [wordIndex, setWordIndex] = useState(0);
const longest = words.reduce((a, b) => (b.length > a.length ? b : a), '');
/** -1 lifted away above, 0 in place, 1 waiting below. */
const roll = useRef(new RNAnimated.Value(0)).current;
useEffect(() => {
if (!active) {
roll.stopAnimation();
roll.setValue(0);
setWordIndex(0);
return;
}
if (reduceMotion || words.length < 2) {
return;
}
let alive = true;
const timer = setInterval(() => {
RNAnimated.timing(roll, {
toValue: -1,
duration: 220,
easing: RNEasing.in(RNEasing.quad),
useNativeDriver: true,
}).start(({ finished }) => {
if (!alive || !finished) {
return;
}
// Swap the word while it is out of sight, then bring it up from below.
roll.setValue(1);
setWordIndex((n) => (n + 1) % words.length);
RNAnimated.timing(roll, {
toValue: 0,
duration: 320,
easing: RNEasing.out(RNEasing.cubic),
useNativeDriver: true,
}).start();
});
}, 2200);
return () => {
alive = false;
clearInterval(timer);
roll.stopAnimation();
roll.setValue(0);
};
}, [active, reduceMotion, roll, words.length]);
const rollStyle = {
opacity: roll.interpolate({ inputRange: [-1, 0, 1], outputRange: [0, 1, 0] }),
transform: [
{ translateY: roll.interpolate({ inputRange: [-1, 0, 1], outputRange: [-16, 0, 16] }) },
],
};
const wordStyle = [theme.text.h2, { color: theme.colors.primary }];
return (
<View style={styles.rollRow} accessible accessibilityLabel={`${lead} ${words.join(', ')}`}>
<Text style={[theme.text.h2, { color: theme.colors.textPrimary }]}>{`${lead} `}</Text>
<View style={styles.rollWindow}>
{/* Holds the width and height of the longest word. */}
<Text style={[wordStyle, styles.ghost]}>{longest}</Text>
<RNAnimated.View style={[styles.rollWord, rollStyle]}>
<Text style={wordStyle}>{words[wordIndex]}</Text>
</RNAnimated.View>
<View style={[styles.rollUnderline, { backgroundColor: theme.colors.accentFill }]} />
</View>
</View>
);
};
/* ── One progress dot ──────────────────────────────────────────────────────── */
const Dot = ({
i,
width,
scrollX,
}: {
i: number;
width: number;
scrollX: SharedValue<number>;
}) => {
const theme = useTheme();
// Stretches into a bar as its slide arrives, and is the brand blue at full
// strength only there — shape and strength both carry it, not colour alone.
const style = useAnimatedStyle(() => {
const range = [(i - 1) * width, i * width, (i + 1) * width];
return {
width: interpolate(scrollX.value, range, [8, 26, 8], Extrapolation.CLAMP),
opacity: interpolate(scrollX.value, range, [0.3, 1, 0.3], Extrapolation.CLAMP),
};
});
return <Animated.View style={[styles.dot, { backgroundColor: theme.colors.primary }, style]} />;
};
const styles = StyleSheet.create({
flex: {
flex: 1,
},
topBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
paddingHorizontal: GUTTER - 6,
paddingTop: 8,
paddingBottom: 4,
},
skip: {
paddingVertical: 6,
paddingHorizontal: 10,
},
hidden: {
opacity: 0,
},
slide: {
flex: 1,
paddingHorizontal: GUTTER,
paddingTop: 20,
},
copy: {
gap: 12,
},
kickerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
kickerText: {
fontSize: 13,
},
chips: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 2,
},
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
borderWidth: StyleSheet.hairlineWidth,
},
chipText: {
fontSize: 13,
lineHeight: 18,
fontFamily: fontFamily.medium,
},
rollRow: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'flex-end',
},
rollWindow: {
overflow: 'hidden',
paddingBottom: 6,
},
ghost: {
opacity: 0,
},
rollWord: {
position: 'absolute',
left: 0,
top: 0,
},
rollUnderline: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 3,
borderRadius: 2,
},
art: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
footer: {
paddingHorizontal: GUTTER,
paddingBottom: 12,
paddingTop: 8,
gap: 20,
alignItems: 'center',
},
dots: {
flexDirection: 'row',
gap: 6,
},
dot: {
height: 8,
borderRadius: 4,
},
});
+4 -4
View File
@@ -14,7 +14,7 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import LinearGradient from 'react-native-linear-gradient';
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
@@ -417,7 +417,7 @@ export default function RegisterScreen({ navigation }: Props) {
accessibilityLabel="Go back"
style={[styles.back, { backgroundColor: theme.colors.onBrandSurfaceStrong }]}
>
<Ionicons name="chevron-back" size={20} color={theme.colors.onBrandPrimary} />
<Icon name="chevron-back" size={20} color={theme.colors.onBrandPrimary} />
</Pressable>
) : null}
@@ -451,7 +451,7 @@ export default function RegisterScreen({ navigation }: Props) {
},
]}
>
<Ionicons name={item.icon} size={13} color={theme.colors.onBrandPrimary} />
<Icon name={item.icon} size={13} color={theme.colors.onBrandPrimary} />
{/* Solid white: the chip's translucent fill lightens the wash,
so a translucent label on it drops to 4.37:1 up here where
the gradient is lightest. */}
@@ -516,7 +516,7 @@ export default function RegisterScreen({ navigation }: Props) {
{ backgroundColor: theme.colors.onBrandSurfaceStrong },
]}
>
<Ionicons
<Icon
name={needsSettings ? 'settings-outline' : blocker.icon}
size={20}
color={theme.colors.onBrandPrimary}
+155
View File
@@ -0,0 +1,155 @@
import React, { memo, useMemo } from 'react';
import { StatusBar, StyleSheet, Text, useWindowDimensions, View } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import Svg, { G, Path, Rect } from 'react-native-svg';
import Animated, { FadeIn, FadeInUp } from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import { BrandLogo } from '../components/BrandLogo';
/**
* The skyline, on a 400 × 220 canvas anchored to the bottom edge.
*
* Two layers of towers: a far row drawn in a translucent white, so it reads as
* haze against whatever navy the theme supplies, and a near row in the wash's
* darkest stop, with lit windows. Fixed geometry rather than random, so the
* splash is the same picture on every launch.
*/
const FAR: ReadonlyArray<[x: number, w: number, h: number]> = [
[0, 34, 92], [30, 26, 120], [52, 40, 84], [88, 22, 140], [106, 36, 104],
[140, 30, 156], [196, 24, 128], [224, 42, 96], [262, 28, 150], [286, 38, 110],
[320, 24, 132], [340, 36, 88], [372, 28, 118],
];
const NEAR: ReadonlyArray<[x: number, w: number, h: number]> = [
[-4, 40, 64], [32, 30, 96], [60, 46, 72], [104, 28, 112], [150, 32, 128],
[210, 30, 104], [238, 44, 70], [280, 32, 118], [310, 40, 80], [348, 30, 100],
[376, 30, 66],
];
const CANVAS_W = 400;
const CANVAS_H = 220;
/** Lit windows for one near tower — a deterministic sprinkle, not a grid. */
const windowsFor = (x: number, w: number, h: number, seed: number) => {
const lit: Array<{ x: number; y: number }> = [];
const columns = Math.max(1, Math.floor((w - 8) / 8));
for (let row = 0; row * 10 + 14 < h - 6; row += 1) {
for (let col = 0; col < columns; col += 1) {
if ((row * 5 + col * 3 + seed) % 4 === 0) {
lit.push({ x: x + 6 + col * 8, y: CANVAS_H - h + 10 + row * 10 });
}
}
}
return lit;
};
const Skyline = memo(({ width }: { width: number }) => {
const theme = useTheme();
const nearColor = theme.colors.brandGradient[2];
const windows = useMemo(
() => NEAR.flatMap(([x, w, h], i) => windowsFor(x, w, h, i)),
[],
);
return (
<Svg
width={width}
height={(width * CANVAS_H) / CANVAS_W}
viewBox={`0 0 ${CANVAS_W} ${CANVAS_H}`}
preserveAspectRatio="xMidYMax slice"
>
{/* far row */}
<G fill={theme.colors.onBrandSurface}>
{FAR.map(([x, w, h]) => (
<Rect key={`f${x}`} x={x} y={CANVAS_H - h} width={w} height={h} />
))}
{/* the landmark tower, with its spire */}
<Rect x={170} y={CANVAS_H - 188} width={26} height={188} />
<Path d={`M176 ${CANVAS_H - 188} L183 ${CANVAS_H - 214} L190 ${CANVAS_H - 188} Z`} />
</G>
{/* near row */}
<G fill={nearColor}>
{NEAR.map(([x, w, h]) => (
<Rect key={`n${x}`} x={x} y={CANVAS_H - h} width={w} height={h} />
))}
</G>
{/* windows */}
<G fill={theme.colors.accentFill} opacity={0.75}>
{windows.map((pane) => (
<Rect key={`${pane.x}-${pane.y}`} x={pane.x} y={pane.y} width={3} height={4} />
))}
</G>
</Svg>
);
});
/**
* The launch screen, shown while the app restores its session and resolves
* its feature flags.
*
* The navy wash with the bank's logo on its white plate, a one-line promise,
* and the skyline along the foot. Light status-bar icons in both themes, since
* the wash is dark in both.
*/
const SplashScreenComponent = () => {
const theme = useTheme();
const { width } = useWindowDimensions();
return (
<View style={styles.flex}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<LinearGradient
colors={theme.colors.brandGradient}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.skyline} pointerEvents="none">
<Skyline width={width} />
</View>
<View style={styles.centre}>
<Animated.View entering={FadeIn.duration(500)}>
<BrandLogo width={210} />
</Animated.View>
<Animated.View entering={FadeInUp.delay(250).duration(500)}>
<Text style={[theme.text.body, styles.tagline, { color: theme.colors.onBrandSecondary }]}>
Banking for a brighter tomorrow
</Text>
</Animated.View>
</View>
</View>
);
};
const styles = StyleSheet.create({
flex: {
flex: 1,
},
centre: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 18,
// Sits the lock-up a little above centre, clear of the skyline.
paddingBottom: '30%',
},
tagline: {
textAlign: 'center',
},
skyline: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
},
});
export const SplashScreen = memo(SplashScreenComponent);
+3 -3
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Button, Card, ScreenShell } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
@@ -227,8 +227,8 @@ export default function VerifyOtpScreen({ navigation }: Props) {
</Card>
{registration.accountsLinked > 0 && (
<View style={[styles.noteCard, { backgroundColor: `${theme.colors.success}0D` }]}>
<Ionicons name="checkmark-circle-outline" size={18} color={theme.colors.success} />
<View style={[styles.noteCard, { backgroundColor: theme.tint(theme.colors.success, 0.05) }]}>
<Icon name="checkmark-circle-outline" size={18} color={theme.colors.success} />
<Text style={[styles.noteText, { color: theme.colors.textSecondary }]}>
{registration.accountsLinked === 1
? '1 account found at your bank and ready to link.'
+32
View File
@@ -0,0 +1,32 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
/**
* Whether this handset has already shown the first-launch introduction.
*
* A device fact, not a customer one, so it is one of the keys sign-out keeps —
* see `KEPT_KEYS` in `appDataPurge.ts`. Renaming it here means renaming it
* there, or every sign-out replays the slides.
*/
export const ONBOARDING_SEEN_KEY = 'bankcbs.onboarding.seen';
/**
* Reads the flag. A storage failure answers "seen": the slides are a welcome,
* not a gate, and a handset whose storage is misbehaving is better off going
* straight to sign-in than being shown them on every launch.
*/
export const readOnboardingSeen = async (): Promise<boolean> => {
try {
return (await AsyncStorage.getItem(ONBOARDING_SEEN_KEY)) === '1';
} catch {
return true;
}
};
/** Best effort. Failing to record it costs one more viewing, nothing else. */
export const markOnboardingSeen = async () => {
try {
await AsyncStorage.setItem(ONBOARDING_SEEN_KEY, '1');
} catch {
// Deliberately ignored — see above.
}
};
@@ -1,6 +1,6 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Card } from '../../../components';
import { useTheme, fontFamily } from '../../../theme';
import { AwarenessArticle } from '../types';
@@ -24,8 +24,8 @@ export const AwarenessCard = ({
<Pressable onPress={onPress}>
<Card style={styles.card}>
<View style={styles.header}>
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.primary}12` }]}>
<Ionicons name={article.icon as any} size={22} color={theme.colors.primary} />
<View style={[styles.iconFrame, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
<Icon name={article.icon as any} size={22} color={theme.colors.primary} />
</View>
<View style={styles.headerInfo}>
<View style={styles.titleRow}>
@@ -33,7 +33,7 @@ export const AwarenessCard = ({
{article.category}
</Text>
{article.isUrgent && (
<View style={[styles.urgentBadge, { backgroundColor: `${theme.colors.error}15` }]}>
<View style={[styles.urgentBadge, { backgroundColor: theme.tint(theme.colors.error, 0.08) }]}>
<Text style={[styles.urgentText, { color: theme.colors.error }]}>URGENT</Text>
</View>
)}
@@ -43,7 +43,7 @@ export const AwarenessCard = ({
</Text>
</View>
<Pressable hitSlop={10} onPress={onToggleBookmark} style={styles.bookmarkBtn}>
<Ionicons
<Icon
name={isBookmarked ? 'bookmark' : 'bookmark-outline'}
size={22}
color={isBookmarked ? theme.colors.primary : theme.colors.textMuted}
@@ -65,7 +65,7 @@ export const AwarenessCard = ({
</View>
<View style={styles.readMore}>
<Text style={[styles.readMoreText, { color: theme.colors.primary }]}>Read More</Text>
<Ionicons name="chevron-forward" size={14} color={theme.colors.primary} />
<Icon name="chevron-forward" size={14} color={theme.colors.primary} />
</View>
</View>
</Card>
@@ -103,8 +103,7 @@ const styles = StyleSheet.create({
category: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.5,
letterSpacing: 0.1,
},
urgentBadge: {
paddingHorizontal: 6,
@@ -1,7 +1,7 @@
import React from 'react';
import { Share, StyleSheet, Text, View, Pressable } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Card, ScreenShell } from '../../../components';
import { ServicesStackParamList } from '../../../navigation/types';
@@ -44,13 +44,13 @@ export default function AwarenessDetailScreen({ route, navigation }: Props) {
const rightContent = (
<View style={styles.headerActions}>
<Pressable onPress={handleShare} style={[styles.actionBtn, { backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons name="share-social-outline" size={20} color={theme.colors.textPrimary} />
<Icon name="share-social-outline" size={20} color={theme.colors.textPrimary} />
</Pressable>
<Pressable
onPress={() => toggleBookmark(articleId)}
style={[styles.actionBtn, { backgroundColor: isBookmarked ? `${theme.colors.primary}15` : theme.colors.surfaceMuted }]}
style={[styles.actionBtn, { backgroundColor: isBookmarked ? theme.tint(theme.colors.primary, 0.08) : theme.colors.surfaceMuted }]}
>
<Ionicons
<Icon
name={isBookmarked ? 'bookmark' : 'bookmark-outline'}
size={20}
color={isBookmarked ? theme.colors.primary : theme.colors.textPrimary}
@@ -67,7 +67,7 @@ export default function AwarenessDetailScreen({ route, navigation }: Props) {
>
<View style={styles.container}>
<View style={styles.metaRow}>
<View style={[styles.categoryBadge, { backgroundColor: `${theme.colors.primary}12` }]}>
<View style={[styles.categoryBadge, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
<Text style={[styles.categoryText, { color: theme.colors.primary }]}>{article.category}</Text>
</View>
<Text style={[styles.dateText, { color: theme.colors.textMuted }]}>{article.publishDate}</Text>
@@ -95,9 +95,9 @@ export default function AwarenessDetailScreen({ route, navigation }: Props) {
</Text>
</View>
<View style={styles.safetyCta}>
<View style={[styles.ctaIcon, { backgroundColor: `${theme.colors.success}15` }]}>
<Ionicons name="shield-checkmark-outline" size={24} color={theme.colors.success} />
<View style={[styles.safetyCta, { backgroundColor: theme.colors.successSurface }]}>
<View style={[styles.ctaIcon, { backgroundColor: theme.tint(theme.colors.success, 0.08) }]}>
<Icon name="shield-checkmark-outline" size={24} color={theme.colors.success} />
</View>
<View style={styles.ctaContent}>
<Text style={[styles.ctaTitle, { color: theme.colors.textPrimary }]}>Stay Protected</Text>
@@ -115,7 +115,7 @@ export default function AwarenessDetailScreen({ route, navigation }: Props) {
onPress={() => navigation.navigate('ServiceRequestsHub')}
style={[styles.reportBtn, { borderColor: theme.colors.cardBorder }]}
>
<Ionicons name="megaphone-outline" size={18} color={theme.colors.error} />
<Icon name="megaphone-outline" size={18} color={theme.colors.error} />
<Text style={[styles.reportText, { color: theme.colors.error }]}>Report Fraudulent Activity</Text>
</Pressable>
</View>
@@ -156,8 +156,7 @@ const styles = StyleSheet.create({
categoryText: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.5,
letterSpacing: 0.1,
},
dateText: {
fontSize: 12,
@@ -203,7 +202,6 @@ const styles = StyleSheet.create({
},
safetyCta: {
flexDirection: 'row',
backgroundColor: 'rgba(52, 211, 153, 0.05)',
padding: 20,
borderRadius: 20,
gap: 16,
@@ -1,7 +1,7 @@
import React from 'react';
import { FlatList, ScrollView, StyleSheet, Text, View, Pressable } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Input, ScreenShell } from '../../../components';
import { ServicesStackParamList } from '../../../navigation/types';
@@ -36,7 +36,7 @@ export default function AwarenessListScreen({ navigation }: Props) {
placeholder="Search safety tips, fraud alerts..."
value={filter.searchQuery}
onChangeText={handleSearch}
leftAccessory={<Ionicons name="search-outline" size={20} color={theme.colors.textMuted} />}
leftAccessory={<Icon name="search-outline" size={20} color={theme.colors.textMuted} />}
/>
</View>
@@ -86,7 +86,7 @@ export default function AwarenessListScreen({ navigation }: Props) {
{urgentArticles.length > 0 && filter.category === 'All' && filter.searchQuery === '' && (
<View style={styles.urgentSection}>
<View style={styles.sectionHeader}>
<Ionicons name="alert-circle" size={18} color={theme.colors.error} />
<Icon name="alert-circle" size={18} color={theme.colors.error} />
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Critical Security Alerts
</Text>
@@ -96,10 +96,10 @@ export default function AwarenessListScreen({ navigation }: Props) {
<Pressable
key={article.id}
onPress={() => navigation.navigate('AwarenessDetail', { articleId: article.id })}
style={[styles.urgentCard, { backgroundColor: `${theme.colors.error}08`, borderColor: `${theme.colors.error}20` }]}
style={[styles.urgentCard, { backgroundColor: theme.tint(theme.colors.error, 0.03), borderColor: theme.tint(theme.colors.error, 0.13) }]}
>
<View style={[styles.urgentIcon, { backgroundColor: `${theme.colors.error}15` }]}>
<Ionicons name={article.icon as any} size={20} color={theme.colors.error} />
<View style={[styles.urgentIcon, { backgroundColor: theme.tint(theme.colors.error, 0.08) }]}>
<Icon name={article.icon as any} size={20} color={theme.colors.error} />
</View>
<Text style={[styles.urgentCardTitle, { color: theme.colors.textPrimary }]} numberOfLines={1}>
{article.title}
@@ -122,7 +122,7 @@ export default function AwarenessListScreen({ navigation }: Props) {
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons name="search-outline" size={48} color={theme.colors.textMuted} />
<Icon name="search-outline" size={48} color={theme.colors.textMuted} />
<Text style={[styles.emptyTitle, { color: theme.colors.textPrimary }]}>No results found</Text>
<Text style={[styles.emptySubtitle, { color: theme.colors.textSecondary }]}>
Try adjusting your search or filters to find what you're looking for.
@@ -1,6 +1,6 @@
import React, { memo, useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useTheme, fontFamily } from '../../../theme';
import type { CardPhysicalDelivery } from '../../../types/app';
@@ -54,10 +54,10 @@ const CardDeliveryStatusComponent = ({
: theme.colors.primary;
const accentBackground =
delivery.status === 'delivered'
? `${theme.colors.accent}12`
? theme.tint(theme.colors.accent, 0.07)
: delivery.status === 'ready-for-pickup'
? `${theme.colors.warning}14`
: `${theme.colors.primary}12`;
? theme.tint(theme.colors.warning, 0.08)
: theme.tint(theme.colors.primary, 0.07);
const modeLabel = delivery.mode === 'home' ? 'Home delivery' : 'Branch pickup';
return (
@@ -75,7 +75,7 @@ const CardDeliveryStatusComponent = ({
</Text>
</View>
<View style={[styles.badge, { backgroundColor: accentBackground }]}>
<Ionicons name="cube-outline" size={14} color={accentColor} />
<Icon name="cube-outline" size={14} color={accentColor} />
<Text style={[styles.badgeText, { color: accentColor }]}>{modeLabel}</Text>
</View>
</View>
@@ -120,7 +120,7 @@ const CardDeliveryStatusComponent = ({
<View style={styles.timelineTopRow}>
<View style={[styles.timelineDot, timelineDotStyle]}>
{isCompleted ? (
<Ionicons name="checkmark" size={10} color={theme.colors.white} />
<Icon name="checkmark" size={10} color={theme.colors.white} />
) : null}
</View>
{index !== steps.length - 1 ? (
@@ -186,8 +186,7 @@ const styles = StyleSheet.create({
eyebrow: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
title: {
fontSize: 17,
@@ -223,8 +222,7 @@ const styles = StyleSheet.create({
metricLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
letterSpacing: 0.1,
},
metricValue: {
fontSize: 14,
+3 -4
View File
@@ -5,7 +5,7 @@ import {
Text,
View,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import LinearGradient from 'react-native-linear-gradient';
import Animated, {
Extrapolation,
@@ -112,7 +112,7 @@ const CardItemComponent = ({
<Text style={styles.footerValue}>{card.network}</Text>
</View>
<View style={styles.flipHint}>
<Ionicons name="sync-outline" size={14} color="rgba(255,255,255,0.85)" />
<Icon name="sync-outline" size={14} color="rgba(255,255,255,0.85)" />
<Text style={styles.flipText}>Flip</Text>
</View>
</View>
@@ -216,8 +216,7 @@ const styles = StyleSheet.create({
color: 'rgba(255,255,255,0.82)',
fontSize: 12,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 1,
letterSpacing: 0.1,
},
cardNumber: {
color: 'white',
@@ -5,7 +5,7 @@ import {
Text,
View,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, {
FadeInDown,
FadeOutUp,
@@ -18,7 +18,7 @@ import { useTheme, fontFamily } from '../../../theme';
interface ExpandableSectionProps {
title: string;
subtitle?: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
icon: React.ComponentProps<typeof Icon>['name'];
defaultExpanded?: boolean;
badge?: string;
children: React.ReactNode;
@@ -42,10 +42,10 @@ const ExpandableSectionComponent = ({
<View
style={[
styles.iconWrap,
{ backgroundColor: `${theme.colors.primary}12` },
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
]}
>
<Ionicons name={icon as any} size={18} color={theme.colors.primary} />
<Icon name={icon as any} size={18} color={theme.colors.primary} />
</View>
<View style={styles.copy}>
<View style={styles.titleRow}>
@@ -69,7 +69,7 @@ const ExpandableSectionComponent = ({
</Text>
) : null}
</View>
<Ionicons
<Icon
name={expanded ? 'chevron-up-outline' : 'chevron-down-outline'}
size={18}
color={theme.colors.textSecondary}
+6 -6
View File
@@ -7,7 +7,7 @@ import {
Text,
View,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import Animated, {
useAnimatedStyle,
useSharedValue,
@@ -147,11 +147,11 @@ const LimitSliderComponent = ({
const decreaseDisabled = value <= min;
const increaseDisabled = value >= max;
const decreaseButtonStyle = {
backgroundColor: decreaseDisabled ? theme.colors.surfaceMuted : `${theme.colors.primary}12`,
backgroundColor: decreaseDisabled ? theme.colors.surfaceMuted : theme.tint(theme.colors.primary, 0.07),
opacity: decreaseDisabled ? 0.6 : 1,
};
const increaseButtonStyle = {
backgroundColor: increaseDisabled ? theme.colors.surfaceMuted : `${theme.colors.primary}12`,
backgroundColor: increaseDisabled ? theme.colors.surfaceMuted : theme.tint(theme.colors.primary, 0.07),
opacity: increaseDisabled ? 0.6 : 1,
};
@@ -191,7 +191,7 @@ const LimitSliderComponent = ({
disabled={decreaseDisabled}
style={[styles.stepButton, decreaseButtonStyle]}
>
<Ionicons name="remove-outline" size={18} color={theme.colors.primary} />
<Icon name="remove-outline" size={18} color={theme.colors.primary} />
</Pressable>
<View style={styles.trackColumn}>
@@ -221,7 +221,7 @@ const LimitSliderComponent = ({
styles.thumb,
thumbStyle,
{
borderColor: `${theme.colors.primary}30`,
borderColor: theme.tint(theme.colors.primary, 0.19),
backgroundColor: theme.colors.white,
},
]}
@@ -246,7 +246,7 @@ const LimitSliderComponent = ({
disabled={increaseDisabled}
style={[styles.stepButton, increaseButtonStyle]}
>
<Ionicons name="add-outline" size={18} color={theme.colors.primary} />
<Icon name="add-outline" size={18} color={theme.colors.primary} />
</Pressable>
</View>
</View>
@@ -4,7 +4,7 @@ import {
Text,
View,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useTheme, fontFamily } from '../../../theme';
import type { CardTransaction } from '../../../types/app';
@@ -33,18 +33,18 @@ const TransactionItemComponent = ({
<View
style={[
styles.row,
showDivider && styles.rowDivider,
showDivider && [styles.rowDivider, { borderBottomColor: theme.colors.cardBorder }],
]}
>
<View
style={[
styles.iconFrame,
{
backgroundColor: isCredit ? `${theme.colors.success}12` : `${theme.colors.primary}12`,
backgroundColor: isCredit ? theme.tint(theme.colors.success, 0.07) : theme.tint(theme.colors.primary, 0.07),
},
]}
>
<Ionicons
<Icon
name={isCredit ? 'arrow-down-outline' : 'arrow-up-outline'}
size={18}
color={isCredit ? theme.colors.success : theme.colors.primary}
@@ -78,7 +78,7 @@ const TransactionItemComponent = ({
</Text>
</View>
<View style={[styles.statusPill, { backgroundColor: `${statusTone}12` }]}>
<View style={[styles.statusPill, { backgroundColor: theme.tint(statusTone, 0.07) }]}>
<Text style={[styles.statusText, { color: statusTone }]}>
{item.status.toUpperCase()}
</Text>
@@ -96,7 +96,6 @@ const styles = StyleSheet.create({
},
rowDivider: {
borderBottomWidth: 1,
borderBottomColor: 'rgba(15, 23, 42, 0.08)',
},
iconFrame: {
width: 44,
@@ -7,7 +7,7 @@ import {
View,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
@@ -142,10 +142,10 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
<View
style={[
styles.heroIcon,
{ backgroundColor: `${theme.colors.warning}14` },
{ backgroundColor: theme.tint(theme.colors.warning, 0.08) },
]}
>
<Ionicons name="sparkles-outline" size={22} color={theme.colors.warning} />
<Icon name="sparkles-outline" size={22} color={theme.colors.warning} />
</View>
<View style={styles.heroCopy}>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
@@ -208,8 +208,8 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
style={[
styles.productCard,
{
backgroundColor: selected ? `${theme.colors.primary}10` : theme.colors.surfaceMuted,
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
backgroundColor: selected ? theme.tint(theme.colors.primary, 0.06) : theme.colors.surfaceMuted,
borderColor: selected ? theme.tint(theme.colors.primary, 0.25) : theme.colors.cardBorder,
},
]}
>
@@ -223,7 +223,7 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
</Text>
</View>
{selected ? (
<Ionicons name="checkmark-circle" size={20} color={theme.colors.primary} />
<Icon name="checkmark-circle" size={20} color={theme.colors.primary} />
) : null}
</View>
<View style={styles.productMetaRow}>
@@ -237,7 +237,7 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
<View style={styles.benefitList}>
{product.benefits.map((benefit) => (
<View key={benefit} style={styles.benefitItem}>
<Ionicons name="checkmark-outline" size={16} color={theme.colors.accentStrong} />
<Icon name="checkmark-outline" size={16} color={theme.colors.accentStrong} />
<Text style={[styles.benefitText, { color: theme.colors.textSecondary }]}>
{benefit}
</Text>
@@ -271,8 +271,8 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
style={[
styles.segmentButton,
{
backgroundColor: selected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
backgroundColor: selected ? theme.tint(theme.colors.primary, 0.07) : theme.colors.surfaceMuted,
borderColor: selected ? theme.tint(theme.colors.primary, 0.25) : theme.colors.cardBorder,
},
]}
>
@@ -331,8 +331,8 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
style={[
styles.accountCard,
{
backgroundColor: selected ? `${theme.colors.accent}10` : theme.colors.surfaceMuted,
borderColor: selected ? `${theme.colors.accent}40` : theme.colors.cardBorder,
backgroundColor: selected ? theme.tint(theme.colors.accent, 0.06) : theme.colors.surfaceMuted,
borderColor: selected ? theme.tint(theme.colors.accent, 0.25) : theme.colors.cardBorder,
},
]}
>
@@ -346,7 +346,7 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
</Text>
</View>
{selected ? (
<Ionicons name="checkmark-circle" size={20} color={theme.colors.accentStrong} />
<Icon name="checkmark-circle" size={20} color={theme.colors.accentStrong} />
) : null}
</View>
<Text style={[styles.productMeta, { color: theme.colors.textPrimary }]}>
@@ -385,7 +385,7 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
]}
>
{consentAccepted ? (
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
<Icon name="checkmark" size={14} color={theme.colors.white} />
) : null}
</View>
<Text style={[styles.consentText, { color: theme.colors.textPrimary }]}>
@@ -444,7 +444,7 @@ export default function ApplyCreditCardScreen({ navigation }: Props) {
<Button
title="Submit credit card application"
onPress={handleApply}
leftAccessory={<Ionicons name="sparkles-outline" size={18} color={theme.colors.white} />}
leftAccessory={<Icon name="sparkles-outline" size={18} color={theme.colors.white} />}
/>
<View style={{ height: 60 }} />
</ScrollView>
@@ -498,8 +498,7 @@ const styles = StyleSheet.create({
heroStatLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
heroStatValue: {
fontSize: 15,
+32 -41
View File
@@ -8,7 +8,7 @@ import {
View,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useSharedValue } from 'react-native-reanimated';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
@@ -95,8 +95,8 @@ const FilterChip = ({ label, active, onPress }: FilterChipProps) => {
style={[
styles.filterChip,
{
backgroundColor: active ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
borderColor: active ? `${theme.colors.primary}40` : theme.colors.cardBorder,
backgroundColor: active ? theme.tint(theme.colors.primary, 0.07) : theme.colors.surfaceMuted,
borderColor: active ? theme.tint(theme.colors.primary, 0.25) : theme.colors.cardBorder,
},
]}
>
@@ -117,7 +117,7 @@ const StatementAction = ({
label,
onPress,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
icon: React.ComponentProps<typeof Icon>['name'];
label: string;
onPress: () => void;
}) => {
@@ -131,7 +131,7 @@ const StatementAction = ({
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Ionicons name={icon as any} size={14} color={theme.colors.primary} />
<Icon name={icon as any} size={14} color={theme.colors.primary} />
<Text style={[styles.statementActionText, { color: theme.colors.textPrimary }]}>
{label}
</Text>
@@ -353,6 +353,7 @@ export default function CardDetailsScreen({ route }: Props) {
>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
scrollEnabled={!sliderInteractionActive}
contentContainerStyle={styles.scrollContent}
>
@@ -422,7 +423,7 @@ export default function CardDetailsScreen({ route }: Props) {
style={styles.primaryActionButton}
onPress={handleToggleBlock}
leftAccessory={
<Ionicons
<Icon
name={card.status === 'blocked' ? 'lock-open-outline' : 'lock-closed-outline'}
size={18}
color={card.status === 'blocked' ? theme.colors.textPrimary : theme.colors.white}
@@ -438,7 +439,7 @@ export default function CardDetailsScreen({ route }: Props) {
style={styles.primaryActionButton}
onPress={handleToggleFreeze}
leftAccessory={
<Ionicons
<Icon
name={card.controls.frozen ? 'play-circle-outline' : 'snow-outline'}
size={18}
color={theme.colors.textPrimary}
@@ -620,7 +621,7 @@ export default function CardDetailsScreen({ route }: Props) {
variant={sensitiveVisible ? 'secondary' : 'primary'}
onPress={handleRevealSensitive}
leftAccessory={
<Ionicons
<Icon
name={sensitiveVisible ? 'eye-off-outline' : 'finger-print-outline'}
size={18}
color={sensitiveVisible ? theme.colors.textPrimary : theme.colors.white}
@@ -637,7 +638,7 @@ export default function CardDetailsScreen({ route }: Props) {
{
backgroundColor:
account.accountNumber === card.primaryLinkedAccountId
? `${theme.colors.primary}12`
? theme.tint(theme.colors.primary, 0.07)
: theme.colors.surfaceMuted,
},
]}
@@ -655,7 +656,7 @@ export default function CardDetailsScreen({ route }: Props) {
))}
</View>
<View style={styles.billingCard}>
<View style={[styles.billingCard, { backgroundColor: theme.colors.surfaceMuted }]}>
<View>
<Text style={[styles.billingLabel, { color: theme.colors.textSecondary }]}>
Billing cycle
@@ -683,7 +684,7 @@ export default function CardDetailsScreen({ route }: Props) {
>
{card.virtualCard ? (
<>
<View style={styles.virtualCardTile}>
<View style={[styles.virtualCardTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.virtualLabel, { color: theme.colors.textSecondary }]}>
Virtual card number
</Text>
@@ -714,7 +715,7 @@ export default function CardDetailsScreen({ route }: Props) {
title="Delete virtual card"
variant="danger"
onPress={handleDeleteVirtualCard}
leftAccessory={<Ionicons name="trash-outline" size={18} color={theme.colors.white} />}
leftAccessory={<Icon name="trash-outline" size={18} color={theme.colors.white} />}
/>
</>
) : (
@@ -738,7 +739,7 @@ export default function CardDetailsScreen({ route }: Props) {
<Button
title="Generate virtual card"
onPress={handleGenerateVirtualCard}
leftAccessory={<Ionicons name="add-circle-outline" size={18} color={theme.colors.white} />}
leftAccessory={<Icon name="add-circle-outline" size={18} color={theme.colors.white} />}
/>
</>
)}
@@ -896,7 +897,7 @@ export default function CardDetailsScreen({ route }: Props) {
fullWidth={false}
style={styles.primaryActionButton}
onPress={() => handlePinAction('Change PIN')}
leftAccessory={<Ionicons name="create-outline" size={18} color={theme.colors.white} />}
leftAccessory={<Icon name="create-outline" size={18} color={theme.colors.white} />}
/>
<Button
title="Reset PIN"
@@ -904,12 +905,12 @@ export default function CardDetailsScreen({ route }: Props) {
fullWidth={false}
style={styles.primaryActionButton}
onPress={() => handlePinAction('Reset PIN')}
leftAccessory={<Ionicons name="refresh-outline" size={18} color={theme.colors.textPrimary} />}
leftAccessory={<Icon name="refresh-outline" size={18} color={theme.colors.textPrimary} />}
/>
</View>
<View style={[styles.securityNote, { backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons name="finger-print-outline" size={18} color={theme.colors.primary} />
<Icon name="finger-print-outline" size={18} color={theme.colors.primary} />
<Text style={[styles.securityNoteText, { color: theme.colors.textSecondary }]}>
Biometric authentication is required before viewing CVV, changing PIN, or resetting PIN.
</Text>
@@ -938,7 +939,7 @@ export default function CardDetailsScreen({ route }: Props) {
title="Redeem"
fullWidth={false}
onPress={handleRedeemRewards}
leftAccessory={<Ionicons name="cash-outline" size={18} color={theme.colors.white} />}
leftAccessory={<Icon name="cash-outline" size={18} color={theme.colors.white} />}
/>
</View>
@@ -983,18 +984,18 @@ export default function CardDetailsScreen({ route }: Props) {
title="Save card name"
variant="secondary"
onPress={handleRenameCard}
leftAccessory={<Ionicons name="save-outline" size={18} color={theme.colors.textPrimary} />}
leftAccessory={<Icon name="save-outline" size={18} color={theme.colors.textPrimary} />}
/>
{!card.isDefault ? (
<Button
title="Set as default card"
onPress={handleSetDefaultCard}
leftAccessory={<Ionicons name="star-outline" size={18} color={theme.colors.white} />}
leftAccessory={<Icon name="star-outline" size={18} color={theme.colors.white} />}
/>
) : (
<View style={[styles.defaultCardBanner, { backgroundColor: `${theme.colors.primary}12` }]}>
<Ionicons name="star" size={16} color={theme.colors.primary} />
<View style={[styles.defaultCardBanner, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
<Icon name="star" size={16} color={theme.colors.primary} />
<Text style={[styles.defaultCardText, { color: theme.colors.primary }]}>
This is your default card.
</Text>
@@ -1018,11 +1019,11 @@ export default function CardDetailsScreen({ route }: Props) {
{
backgroundColor:
account.accountNumber === card.primaryLinkedAccountId
? `${theme.colors.primary}12`
? theme.tint(theme.colors.primary, 0.07)
: theme.colors.surfaceMuted,
borderColor:
account.accountNumber === card.primaryLinkedAccountId
? `${theme.colors.primary}35`
? theme.tint(theme.colors.primary, 0.21)
: theme.colors.cardBorder,
},
]}
@@ -1078,8 +1079,7 @@ const styles = StyleSheet.create({
heroMetricLabel: {
fontSize: 12,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
heroMetricValue: {
fontSize: 21,
@@ -1100,8 +1100,7 @@ const styles = StyleSheet.create({
filterLabel: {
fontSize: 12,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
letterSpacing: 0.1,
},
filterWrap: {
flexDirection: 'row',
@@ -1131,8 +1130,7 @@ const styles = StyleSheet.create({
secureLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
secureValue: {
fontSize: 16,
@@ -1150,8 +1148,7 @@ const styles = StyleSheet.create({
linkedAccountLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
linkedAccountValue: {
fontSize: 14,
@@ -1163,13 +1160,11 @@ const styles = StyleSheet.create({
gap: 12,
borderRadius: 18,
padding: 16,
backgroundColor: '#F8FAFC',
},
billingLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
billingValue: {
marginTop: 6,
@@ -1181,13 +1176,11 @@ const styles = StyleSheet.create({
borderRadius: 18,
padding: 16,
gap: 8,
backgroundColor: '#F8FAFC',
},
virtualLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
virtualValue: {
fontSize: 18,
@@ -1330,8 +1323,7 @@ const styles = StyleSheet.create({
rewardEyebrow: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
rewardPoints: {
fontSize: 30,
@@ -1355,8 +1347,7 @@ const styles = StyleSheet.create({
offerMerchant: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
offerTitle: {
fontSize: 15,
@@ -6,7 +6,7 @@ import {
View,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { Button, Card, EmptyState, ScreenShell } from '../../../components';
@@ -65,10 +65,10 @@ const CardServiceHub = ({
<View
style={[
styles.serviceIconWrap,
{ backgroundColor: `${theme.colors.primary}12` },
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
]}
>
<Ionicons name="card-outline" size={18} color={theme.colors.primary} />
<Icon name="card-outline" size={18} color={theme.colors.primary} />
</View>
<View style={styles.serviceCopy}>
<Text style={[styles.serviceEyebrow, { color: theme.colors.textSecondary }]}>
@@ -86,7 +86,7 @@ const CardServiceHub = ({
<View
style={[
styles.serviceTag,
{ backgroundColor: `${theme.colors.accent}12` },
{ backgroundColor: theme.tint(theme.colors.accent, 0.07) },
]}
>
<Text style={[styles.serviceTagText, { color: theme.colors.accentStrong }]}>
@@ -96,7 +96,7 @@ const CardServiceHub = ({
<View
style={[
styles.serviceTag,
{ backgroundColor: `${theme.colors.primary}12` },
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
]}
>
<Text style={[styles.serviceTagText, { color: theme.colors.primary }]}>Visa</Text>
@@ -104,7 +104,7 @@ const CardServiceHub = ({
<View
style={[
styles.serviceTag,
{ backgroundColor: `${theme.colors.warning}16` },
{ backgroundColor: theme.tint(theme.colors.warning, 0.09) },
]}
>
<Text style={[styles.serviceTagText, { color: theme.colors.warning }]}>
@@ -117,7 +117,7 @@ const CardServiceHub = ({
title="Request debit card"
variant="secondary"
onPress={onRequestDebitCard}
leftAccessory={<Ionicons name="paper-plane-outline" size={18} color={theme.colors.textPrimary} />}
leftAccessory={<Icon name="paper-plane-outline" size={18} color={theme.colors.textPrimary} />}
/>
</FeatureGate>
</View>
@@ -125,17 +125,17 @@ const CardServiceHub = ({
<View
style={[
styles.servicePanel,
{ backgroundColor: `${theme.colors.primary}08` },
{ backgroundColor: theme.tint(theme.colors.primary, 0.03) },
]}
>
<View style={styles.servicePanelHeader}>
<View
style={[
styles.serviceIconWrap,
{ backgroundColor: `${theme.colors.warning}16` },
{ backgroundColor: theme.tint(theme.colors.warning, 0.09) },
]}
>
<Ionicons name="sparkles-outline" size={18} color={theme.colors.warning} />
<Icon name="sparkles-outline" size={18} color={theme.colors.warning} />
</View>
<View style={styles.serviceCopy}>
<Text style={[styles.serviceEyebrow, { color: theme.colors.textSecondary }]}>
@@ -153,7 +153,7 @@ const CardServiceHub = ({
<View
style={[
styles.serviceTag,
{ backgroundColor: `${theme.colors.primary}12` },
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
]}
>
<Text style={[styles.serviceTagText, { color: theme.colors.primary }]}>
@@ -163,7 +163,7 @@ const CardServiceHub = ({
<View
style={[
styles.serviceTag,
{ backgroundColor: `${theme.colors.accent}12` },
{ backgroundColor: theme.tint(theme.colors.accent, 0.07) },
]}
>
<Text style={[styles.serviceTagText, { color: theme.colors.accentStrong }]}>
@@ -173,7 +173,7 @@ const CardServiceHub = ({
<View
style={[
styles.serviceTag,
{ backgroundColor: `${theme.colors.warning}16` },
{ backgroundColor: theme.tint(theme.colors.warning, 0.09) },
]}
>
<Text style={[styles.serviceTagText, { color: theme.colors.warning }]}>
@@ -185,7 +185,7 @@ const CardServiceHub = ({
<Button
title="Apply credit card"
onPress={onApplyCreditCard}
leftAccessory={<Ionicons name="sparkles-outline" size={18} color={theme.colors.white} />}
leftAccessory={<Icon name="sparkles-outline" size={18} color={theme.colors.white} />}
/>
</FeatureGate>
</View>
@@ -311,7 +311,7 @@ export default function CardsOverviewScreen({ navigation }: Props) {
onPress={handleRequestDebitCard}
style={[styles.fab, { backgroundColor: theme.colors.primary }]}
>
<Ionicons name="add-outline" size={24} color={theme.colors.white} />
<Icon name="add-outline" size={24} color={theme.colors.white} />
<Text style={styles.fabLabel}>Request Card</Text>
</Pressable>
}
@@ -328,10 +328,10 @@ export default function CardsOverviewScreen({ navigation }: Props) {
<View
style={[
styles.heroBadge,
{ backgroundColor: `${theme.colors.primary}12` },
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
]}
>
<Ionicons name="sparkles-outline" size={14} color={theme.colors.primary} />
<Icon name="sparkles-outline" size={14} color={theme.colors.primary} />
<Text style={[styles.heroBadgeText, { color: theme.colors.primary }]}>
{selectedCard.isDefault ? 'Default card' : 'Secondary card'}
</Text>
@@ -413,7 +413,7 @@ export default function CardsOverviewScreen({ navigation }: Props) {
style={styles.actionButton}
onPress={() => navigation.navigate('CardDetails', { cardId: selectedCard.id })}
leftAccessory={
<Ionicons name="card-outline" size={18} color={theme.colors.white} />
<Icon name="card-outline" size={18} color={theme.colors.white} />
}
/>
<Button
@@ -423,7 +423,7 @@ export default function CardsOverviewScreen({ navigation }: Props) {
style={styles.actionButton}
onPress={handleToggleFreeze}
leftAccessory={
<Ionicons
<Icon
name={selectedCard.controls.frozen ? 'play-circle-outline' : 'snow-outline'}
size={18}
color={theme.colors.textPrimary}
@@ -437,7 +437,7 @@ export default function CardsOverviewScreen({ navigation }: Props) {
style={styles.actionButton}
onPress={handleToggleBlock}
leftAccessory={
<Ionicons
<Icon
name={selectedCard.status === 'blocked' ? 'lock-open-outline' : 'lock-closed-outline'}
size={18}
color={
@@ -595,8 +595,7 @@ const styles = StyleSheet.create({
heroEyebrow: {
fontSize: 12,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 1,
letterSpacing: 0.1,
},
heroTitle: {
fontSize: 24,
@@ -634,8 +633,7 @@ const styles = StyleSheet.create({
metricLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.9,
letterSpacing: 0.1,
},
metricValue: {
fontSize: 22,
@@ -699,8 +697,7 @@ const styles = StyleSheet.create({
serviceEyebrow: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
serviceTitle: {
fontSize: 16,
@@ -788,8 +785,7 @@ const styles = StyleSheet.create({
offerMerchant: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.9,
letterSpacing: 0.1,
},
offerTitle: {
fontSize: 15,
@@ -7,7 +7,7 @@ import {
View,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { Button, Card, Input, ScreenShell } from '../../../components';
@@ -27,7 +27,7 @@ const NETWORK_OPTIONS: Array<{
id: CardNetwork;
title: string;
subtitle: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
icon: React.ComponentProps<typeof Icon>['name'];
}> = [
{
id: 'RuPay',
@@ -152,10 +152,10 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
<View
style={[
styles.heroIcon,
{ backgroundColor: `${theme.colors.primary}12` },
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
]}
>
<Ionicons name="card-outline" size={22} color={theme.colors.primary} />
<Icon name="card-outline" size={22} color={theme.colors.primary} />
</View>
<View style={styles.heroCopy}>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
@@ -218,8 +218,8 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
style={[
styles.choiceCard,
{
backgroundColor: selected ? `${theme.colors.primary}10` : theme.colors.surfaceMuted,
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
backgroundColor: selected ? theme.tint(theme.colors.primary, 0.06) : theme.colors.surfaceMuted,
borderColor: selected ? theme.tint(theme.colors.primary, 0.25) : theme.colors.cardBorder,
},
]}
>
@@ -233,7 +233,7 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
</Text>
</View>
{selected ? (
<Ionicons
<Icon
name="checkmark-circle"
size={20}
color={theme.colors.primary}
@@ -270,20 +270,20 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
style={[
styles.choiceCard,
{
backgroundColor: selected ? `${theme.colors.accent}10` : theme.colors.surfaceMuted,
borderColor: selected ? `${theme.colors.accent}40` : theme.colors.cardBorder,
backgroundColor: selected ? theme.tint(theme.colors.accent, 0.06) : theme.colors.surfaceMuted,
borderColor: selected ? theme.tint(theme.colors.accent, 0.25) : theme.colors.cardBorder,
},
]}
>
<View style={styles.choiceHeader}>
<View style={styles.networkTitleRow}>
<Ionicons name={option.icon as any} size={18} color={theme.colors.primary} />
<Icon name={option.icon as any} size={18} color={theme.colors.primary} />
<Text style={[styles.choiceTitle, { color: theme.colors.textPrimary }]}>
{option.title}
</Text>
</View>
{selected ? (
<Ionicons
<Icon
name="checkmark-circle"
size={20}
color={theme.colors.accentStrong}
@@ -320,20 +320,20 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
style={[
styles.choiceCard,
{
backgroundColor: selected ? `${theme.colors.primary}10` : theme.colors.surfaceMuted,
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
backgroundColor: selected ? theme.tint(theme.colors.primary, 0.06) : theme.colors.surfaceMuted,
borderColor: selected ? theme.tint(theme.colors.primary, 0.25) : theme.colors.cardBorder,
},
]}
>
<View style={styles.choiceHeader}>
<View style={styles.networkTitleRow}>
<Ionicons name={option.icon as any} size={18} color={theme.colors.primary} />
<Icon name={option.icon as any} size={18} color={theme.colors.primary} />
<Text style={[styles.choiceTitle, { color: theme.colors.textPrimary }]}>
{option.title}
</Text>
</View>
{selected ? (
<Ionicons
<Icon
name="checkmark-circle"
size={20}
color={theme.colors.primary}
@@ -376,7 +376,7 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Ionicons name="business-outline" size={18} color={theme.colors.primary} />
<Icon name="business-outline" size={18} color={theme.colors.primary} />
<View style={styles.pickupCopy}>
<Text style={[styles.pickupTitle, { color: theme.colors.textPrimary }]}>
Pickup branch
@@ -454,7 +454,7 @@ export default function RequestPhysicalCardScreen({ navigation }: Props) {
<Button
title="Request physical debit card"
onPress={handleSubmit}
leftAccessory={<Ionicons name="paper-plane-outline" size={18} color={theme.colors.white} />}
leftAccessory={<Icon name="paper-plane-outline" size={18} color={theme.colors.white} />}
/>
<View style={styles.bottomInset} />
</ScrollView>
@@ -508,8 +508,7 @@ const styles = StyleSheet.create({
heroStatLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
heroStatValue: {
fontSize: 15,
@@ -1,6 +1,6 @@
import React, { memo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Card } from '../../../components';
import { useTheme, fontFamily } from '../../../theme';
@@ -69,10 +69,10 @@ const DepositAccountCardComponent = ({
<View
style={[
styles.ratePill,
{ backgroundColor: `${theme.colors.accent}12` },
{ backgroundColor: theme.tint(theme.colors.accent, 0.07) },
]}
>
<Ionicons name="trending-up-outline" size={14} color={theme.colors.accentStrong} />
<Icon name="trending-up-outline" size={14} color={theme.colors.accentStrong} />
<Text style={[styles.rateText, { color: theme.colors.accentStrong }]}>
{deposit.interestRate}% p.a.
</Text>
@@ -153,8 +153,7 @@ const styles = StyleSheet.create({
eyebrow: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
number: {
fontSize: 16,
@@ -202,8 +201,7 @@ const styles = StyleSheet.create({
metricLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
letterSpacing: 0.1,
},
metricValue: {
fontSize: 14,
@@ -22,8 +22,8 @@ const DepositOptionChipComponent = ({
style={({ pressed }) => [
styles.chip,
{
backgroundColor: selected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
backgroundColor: selected ? theme.tint(theme.colors.primary, 0.07) : theme.colors.surfaceMuted,
borderColor: selected ? theme.tint(theme.colors.primary, 0.25) : theme.colors.cardBorder,
opacity: pressed ? 0.9 : 1,
transform: [{ scale: pressed ? 0.98 : 1 }],
},
@@ -19,8 +19,8 @@ const DepositRatePillComponent = ({ rate }: DepositRatePillProps) => {
style={[
styles.pill,
{
backgroundColor: 'rgba(53, 214, 163, 0.12)',
borderColor: theme.colors.cardBorder,
backgroundColor: theme.colors.accentSurface,
borderColor: theme.colors.accentBorder,
borderRadius: theme.radius.pill,
},
]}
@@ -17,24 +17,24 @@ const DepositStatusPillComponent = ({
const tone = nearMaturity
? {
backgroundColor: `${theme.colors.warning}16`,
backgroundColor: theme.tint(theme.colors.warning, 0.09),
color: theme.colors.warning,
label: 'MATURING',
}
: status === 'closed'
? {
backgroundColor: `${theme.colors.textMuted}18`,
backgroundColor: theme.tint(theme.colors.textMuted, 0.09),
color: theme.colors.textSecondary,
label: 'CLOSED',
}
: status === 'matured'
? {
backgroundColor: `${theme.colors.accent}16`,
backgroundColor: theme.tint(theme.colors.accent, 0.09),
color: theme.colors.accentStrong,
label: 'MATURED',
}
: {
backgroundColor: `${theme.colors.primary}12`,
backgroundColor: theme.tint(theme.colors.primary, 0.07),
color: theme.colors.primary,
label: 'ACTIVE',
};
@@ -8,7 +8,7 @@ import {
import type { CompositeScreenProps } from '@react-navigation/native';
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useAppSelector } from '../../../app/hooks';
import { Card, Input, ScreenShell } from '../../../components';
@@ -171,8 +171,8 @@ export default function DepositCalculatorScreen({ route }: Props) {
<Card style={styles.resultCard}>
<View style={styles.resultHeader}>
<View style={[styles.resultIcon, { backgroundColor: `${theme.colors.primary}12` }]}>
<Ionicons name="calculator-outline" size={20} color={theme.colors.primary} />
<View style={[styles.resultIcon, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
<Icon name="calculator-outline" size={20} color={theme.colors.primary} />
</View>
<View style={styles.resultCopy}>
<Text style={[styles.resultTitle, { color: theme.colors.textPrimary }]}>
@@ -229,8 +229,7 @@ const styles = StyleSheet.create({
fieldLabel: {
fontSize: 12,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
letterSpacing: 0.1,
},
optionRow: {
flexDirection: 'row',
@@ -283,8 +282,7 @@ const styles = StyleSheet.create({
resultLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
letterSpacing: 0.1,
},
resultTileValue: {
fontSize: 15,
@@ -8,7 +8,7 @@ import {
import type { CompositeScreenProps } from '@react-navigation/native';
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { AppModal, Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
@@ -375,7 +375,7 @@ export default function DepositDetailsScreen({ navigation, route }: Props) {
onPress={() =>
navigation.navigate('DepositCalculator', { initialKind: deposit.kind })
}
leftAccessory={<Ionicons name="calculator-outline" size={18} color={theme.colors.textPrimary} />}
leftAccessory={<Icon name="calculator-outline" size={18} color={theme.colors.textPrimary} />}
/>
</FeatureGate>
@@ -386,7 +386,7 @@ export default function DepositDetailsScreen({ navigation, route }: Props) {
loading={closeState.isLoading}
onPress={() => setConfirmVisible(true)}
leftAccessory={
<Ionicons
<Icon
name={deposit.status === 'matured' ? 'cash-outline' : 'close-circle-outline'}
size={18}
color={theme.colors.white}
@@ -476,8 +476,7 @@ const styles = StyleSheet.create({
heroEyebrow: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
heroTitle: {
fontSize: 24,
@@ -498,8 +497,7 @@ const styles = StyleSheet.create({
heroMetricLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
heroMetricValue: {
fontSize: 16,
@@ -539,8 +537,7 @@ const styles = StyleSheet.create({
detailLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
detailValue: {
fontSize: 14,
@@ -9,7 +9,7 @@ import {
import type { CompositeScreenProps } from '@react-navigation/native';
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
import { FeatureKey } from '../../../constants/featureKeys';
@@ -121,10 +121,10 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
<View
style={[
styles.heroIcon,
{ backgroundColor: `${theme.colors.primary}12` },
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
]}
>
<Ionicons name="wallet-outline" size={20} color={theme.colors.primary} />
<Icon name="wallet-outline" size={20} color={theme.colors.primary} />
</View>
<View style={styles.heroCopy}>
<Text style={[styles.heroEyebrow, { color: theme.colors.primary }]}>
@@ -177,7 +177,7 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
<View
style={[
styles.heroBadge,
{ backgroundColor: `${theme.colors.warning}14` },
{ backgroundColor: theme.tint(theme.colors.warning, 0.08) },
]}
>
<Text style={[styles.heroBadgeValue, { color: theme.colors.warning }]}>
@@ -198,7 +198,7 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
<View
style={[
styles.heroMetricCard,
{ backgroundColor: `${theme.colors.primary}08` },
{ backgroundColor: theme.tint(theme.colors.primary, 0.03) },
]}
>
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
@@ -254,7 +254,7 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
style={styles.actionButton}
onPress={handleOpenFixedDeposit}
leftAccessory={
<Ionicons
<Icon
name="trending-up-outline"
size={18}
color={theme.colors.white}
@@ -271,7 +271,7 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
style={styles.actionButton}
onPress={handleOpenRecurringDeposit}
leftAccessory={
<Ionicons
<Icon
name="repeat-outline"
size={18}
color={theme.colors.textPrimary}
@@ -288,7 +288,7 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
style={styles.actionButton}
onPress={handleOpenCalculator}
leftAccessory={
<Ionicons
<Icon
name="calculator-outline"
size={18}
color={theme.colors.textPrimary}
@@ -303,8 +303,8 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
{summary.nearMaturityDeposits.length ? (
<Card style={styles.watchCard}>
<View style={styles.watchHeader}>
<View style={[styles.watchIcon, { backgroundColor: `${theme.colors.warning}16` }]}>
<Ionicons name="time-outline" size={18} color={theme.colors.warning} />
<View style={[styles.watchIcon, { backgroundColor: theme.tint(theme.colors.warning, 0.09) }]}>
<Icon name="time-outline" size={18} color={theme.colors.warning} />
</View>
<View style={styles.watchCopy}>
<Text style={[styles.watchTitle, { color: theme.colors.textPrimary }]}>
@@ -334,7 +334,7 @@ export default function DepositsOverviewScreen({ navigation, route }: Props) {
styles.watchDays,
{
color: theme.colors.warning,
backgroundColor: `${theme.colors.warning}12`,
backgroundColor: theme.tint(theme.colors.warning, 0.07),
},
]}
>
@@ -462,8 +462,7 @@ const styles = StyleSheet.create({
heroEyebrow: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
heroTitle: {
fontSize: 20,
@@ -489,8 +488,7 @@ const styles = StyleSheet.create({
heroHighlightLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
heroHighlightValue: {
fontSize: 28,
@@ -519,8 +517,7 @@ const styles = StyleSheet.create({
fontSize: 11,
fontFamily: fontFamily.bold,
textAlign: 'center',
textTransform: 'uppercase',
letterSpacing: 0.5,
letterSpacing: 0.1,
},
heroMetrics: {
flexDirection: 'row',
@@ -537,8 +534,7 @@ const styles = StyleSheet.create({
heroMetricLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
heroMetricValue: {
fontSize: 20,
@@ -660,8 +656,7 @@ const styles = StyleSheet.create({
portfolioCountLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
filterWrap: {
flexDirection: 'row',
@@ -8,7 +8,7 @@ import {
import type { CompositeScreenProps } from '@react-navigation/native';
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useAppSelector } from '../../../app/hooks';
import { Button, Card, Input, ScreenShell } from '../../../components';
@@ -153,8 +153,8 @@ export default function OpenFixedDepositScreen({ navigation }: Props) {
{product.rate}% p.a.
</Text>
</View>
<View style={[styles.heroPill, { backgroundColor: `${theme.colors.accent}12` }]}>
<Ionicons
<View style={[styles.heroPill, { backgroundColor: theme.tint(theme.colors.accent, 0.07) }]}>
<Icon
name="shield-checkmark-outline"
size={16}
color={theme.colors.accentStrong}
@@ -266,7 +266,7 @@ export default function OpenFixedDepositScreen({ navigation }: Props) {
onPress={handleSubmit}
loading={createState.isLoading}
leftAccessory={
<Ionicons name="add-circle-outline" size={18} color={theme.colors.white} />
<Icon name="add-circle-outline" size={18} color={theme.colors.white} />
}
/>
</ScrollView>
@@ -294,8 +294,7 @@ const styles = StyleSheet.create({
heroEyebrow: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
heroTitle: {
marginTop: 4,
@@ -329,8 +328,7 @@ const styles = StyleSheet.create({
fieldLabel: {
fontSize: 12,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
letterSpacing: 0.1,
},
optionRow: {
flexDirection: 'row',
@@ -365,8 +363,7 @@ const styles = StyleSheet.create({
previewLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
letterSpacing: 0.1,
},
previewValue: {
fontSize: 16,
@@ -8,7 +8,7 @@ import {
import type { CompositeScreenProps } from '@react-navigation/native';
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useAppSelector } from '../../../app/hooks';
import { Button, Card, Input, ScreenShell } from '../../../components';
@@ -141,8 +141,8 @@ export default function OpenRecurringDepositScreen({ navigation }: Props) {
{product.rate}% p.a.
</Text>
</View>
<View style={[styles.heroPill, { backgroundColor: `${theme.colors.primary}12` }]}>
<Ionicons name="repeat-outline" size={16} color={theme.colors.primary} />
<View style={[styles.heroPill, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
<Icon name="repeat-outline" size={16} color={theme.colors.primary} />
<Text style={[styles.heroPillText, { color: theme.colors.primary }]}>
Min {formatCurrency(product.minimumInstallment ?? 0)}
</Text>
@@ -229,7 +229,7 @@ export default function OpenRecurringDepositScreen({ navigation }: Props) {
onPress={handleSubmit}
loading={createState.isLoading}
leftAccessory={
<Ionicons name="add-circle-outline" size={18} color={theme.colors.white} />
<Icon name="add-circle-outline" size={18} color={theme.colors.white} />
}
/>
</ScrollView>
@@ -257,8 +257,7 @@ const styles = StyleSheet.create({
heroEyebrow: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 0.1,
},
heroTitle: {
marginTop: 4,
@@ -292,8 +291,7 @@ const styles = StyleSheet.create({
fieldLabel: {
fontSize: 12,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
letterSpacing: 0.1,
},
optionRow: {
flexDirection: 'row',
@@ -324,8 +322,7 @@ const styles = StyleSheet.create({
previewLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.7,
letterSpacing: 0.1,
},
previewValue: {
fontSize: 16,
@@ -19,7 +19,7 @@ export interface HistoryFilters {
/**
* The transfer history, accumulated page by page — the same shape as
* `useStatement`, for the same reason: RTK Query caches one entry per page, and
* the old paged statement hook, for the same reason: RTK Query caches one entry per page, and
* the list needs every page walked so far.
*
* Any change of filter starts again from page 0, as the guide requires; page 2
@@ -3,7 +3,7 @@ import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native
import type { CompositeScreenProps } from '@react-navigation/native';
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Button, Card, EmptyState, InfoBanner, Loader, ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
@@ -98,7 +98,7 @@ export default function TransferDetailScreen({ navigation, route }: Props) {
{/* ── the outcome ───────────────────────────────────────────────── */}
<Card style={styles.hero}>
<View style={[styles.statusIcon, { backgroundColor: colors.surface }]}>
<Ionicons name={status.icon} size={30} color={colors.tone} />
<Icon name={status.icon} size={30} color={colors.tone} />
</View>
<Text style={[theme.text.balance, { color: theme.colors.textPrimary }]}>
{formatCurrency(data.amount)}
@@ -10,7 +10,7 @@ import {
View,
} from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import DatePicker from 'react-native-date-picker';
import {
@@ -28,7 +28,7 @@ import type { HomeStackParamList } from '../../../navigation/types';
import type { ApiError } from '../../../types/api';
import type { BeneficiaryMode, FundTransferHistoryItem, FundTransferStatus } from '../../../types/cbs';
import { describeError } from '../../../services/api/cbsErrors';
import { toStatementDate } from '../../home/hooks/useStatement';
import { toStatementDate } from '../../home/utils/statement';
import { useFundTransferHistory, type HistoryFilters } from '../hooks/useFundTransferHistory';
import { humaniseReason, statusCopy, toneColors } from '../utils/transferStatus';
@@ -72,7 +72,7 @@ function TransferRow({
<Card style={styles.card}>
<View style={styles.row}>
<View style={[styles.iconWrap, { backgroundColor: colors.surface }]}>
<Ionicons name={copy.icon} size={20} color={colors.tone} />
<Icon name={copy.icon} size={20} color={colors.tone} />
</View>
<View style={styles.copy}>
@@ -228,13 +228,13 @@ export default function TransferHistoryScreen({ navigation }: Props) {
},
]}
>
<Ionicons name="calendar-outline" size={18} color={theme.colors.primary} />
<Icon name="calendar-outline" size={18} color={theme.colors.primary} />
<Text style={[theme.text.body, styles.rangeText, { color: theme.colors.textPrimary }]}>
{hasRange
? `${formatDate(filters.fromDate as string)} – ${formatDate(filters.toDate as string)}`
: 'Any date'}
</Text>
<Ionicons name="chevron-down" size={16} color={theme.colors.textMuted} />
<Icon name="chevron-down" size={16} color={theme.colors.textMuted} />
</Pressable>
</View>
);
@@ -316,7 +316,7 @@ export default function TransferHistoryScreen({ navigation }: Props) {
},
]}
>
<Ionicons name="calendar-outline" size={18} color={theme.colors.primary} />
<Icon name="calendar-outline" size={18} color={theme.colors.primary} />
<Text style={[theme.text.body, styles.rangeText, { color: theme.colors.textPrimary }]}>
{formatDate(toStatementDate(which === 'from' ? tempFrom : tempTo))}
</Text>
+2 -2
View File
@@ -1,10 +1,10 @@
import type Ionicons from '@react-native-vector-icons/ionicons';
import type { Icon } from '../../../components/Icon';
import type React from 'react';
import type { FundTransferStatus } from '../../../types/cbs';
import type { useTheme } from '../../../theme';
type IconName = React.ComponentProps<typeof Ionicons>['name'];
type IconName = React.ComponentProps<typeof Icon>['name'];
type Colors = ReturnType<typeof useTheme>['colors'];
export type StatusTone = 'success' | 'danger' | 'warning' | 'info';
@@ -14,8 +14,8 @@ export const HomeSectionHeader = ({ title, onSeeAll }: HomeSectionHeaderProps) =
<View style={styles.container}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
{onSeeAll && (
<Pressable onPress={onSeeAll}>
<Text style={[styles.seeAll, { color: theme.colors.primary }]}>See All</Text>
<Pressable onPress={onSeeAll} hitSlop={8} accessibilityRole="button">
<Text style={[styles.seeAll, { color: theme.colors.primary }]}>See all</Text>
</Pressable>
)}
</View>
@@ -27,16 +27,16 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
marginBottom: 12,
paddingHorizontal: 4,
},
title: {
fontSize: 18,
fontSize: 17,
fontFamily: fontFamily.bold,
letterSpacing: -0.5,
letterSpacing: -0.3,
},
seeAll: {
fontSize: 14,
fontFamily: fontFamily.bold,
fontSize: 13,
fontFamily: fontFamily.semibold,
},
});
@@ -1,6 +1,6 @@
import React from 'react';
import { StyleSheet, Text, View, ScrollView, Pressable } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { StyleSheet, Text, View, Pressable } from 'react-native';
import { Icon } from '../../../components/Icon';
import { FeatureKey } from '../../../constants/featureKeys';
import { useEnabledItems } from '../../../features';
import { useTheme, fontFamily } from '../../../theme';
@@ -26,6 +26,7 @@ const CATEGORIES: Category[] = [
{ id: 'deposits', name: 'Deposits', icon: 'wallet-outline', feature: FeatureKey.SERVICES_DEPOSITS_OVERVIEW },
{ id: 'loans', name: 'Loans', icon: 'business-outline', feature: FeatureKey.LOANS },
{ id: 'insurance', name: 'Insurance', icon: 'shield-outline', feature: FeatureKey.INSURANCE },
{ id: 'mmid', name: 'MMID', icon: 'qr-code-outline', feature: FeatureKey.HOME_ACCOUNTS_MMID },
];
export const HorizontalCategories = ({ onCategoryPress }: HorizontalCategoriesProps) => {
@@ -38,91 +39,71 @@ export const HorizontalCategories = ({ onCategoryPress }: HorizontalCategoriesPr
return null;
}
/*
* A row of icon tiles: a tinted rounded square with the glyph in brand blue
* and the label underneath. Four to a row, equal width, so the grid lines up
* with the account actions above it rather than scrolling off the edge.
*/
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.container}
>
{categories.map((cat) => {
return (
<Pressable
key={cat.id}
onPress={() => onCategoryPress?.(cat.id)}
style={({ pressed }) => [
styles.pill,
{
backgroundColor: pressed ? theme.colors.surfaceMuted : theme.colors.surface,
borderColor: theme.colors.cardBorder,
borderRadius: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: pressed ? 0.08 : 0.05,
shadowRadius: pressed ? 10 : 8,
elevation: pressed ? 4 : 2,
opacity: pressed ? 0.96 : 1,
transform: [{ scale: pressed ? 0.97 : 1 }],
},
]}
>
{({ pressed }) => (
<>
<View
style={[
styles.iconBox,
{
backgroundColor: pressed
? 'rgba(0, 0, 0, 0.08)'
: 'rgba(0, 0, 0, 0.05)',
},
]}
>
<Ionicons
name={cat.icon as any}
size={18}
color={theme.colors.textSecondary}
/>
</View>
<Text
style={[
styles.name,
{ color: theme.colors.textPrimary },
]}
>
{cat.name}
</Text>
</>
)}
</Pressable>
);
})}
</ScrollView>
<View style={styles.grid}>
{categories.map((cat) => (
<Pressable
key={cat.id}
accessibilityRole="button"
accessibilityLabel={cat.name}
onPress={() => onCategoryPress?.(cat.id)}
style={styles.tile}
>
{({ pressed }) => (
<>
<View
style={[
styles.iconBox,
{
borderRadius: theme.radius.lg,
backgroundColor: pressed
? theme.colors.primaryBorder
: theme.colors.primarySurface,
},
]}
>
<Icon
name={cat.icon as React.ComponentProps<typeof Icon>['name']}
size={24}
color={theme.colors.primary}
/>
</View>
<Text numberOfLines={1} style={[styles.name, { color: theme.colors.textPrimary }]}>
{cat.name}
</Text>
</>
)}
</Pressable>
))}
</View>
);
};
const styles = StyleSheet.create({
container: {
paddingHorizontal: 6,
gap: 12,
paddingVertical: 12,
},
pill: {
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
rowGap: 16,
},
tile: {
width: '25%',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 10,
gap: 10,
borderWidth: 1,
gap: 8,
},
iconBox: {
width: 32,
height: 32,
borderRadius: 8,
width: 56,
height: 56,
alignItems: 'center',
justifyContent: 'center',
},
name: {
fontSize: 14,
fontFamily: fontFamily.bold,
fontSize: 12,
lineHeight: 16,
fontFamily: fontFamily.medium,
},
});
+3 -4
View File
@@ -6,7 +6,7 @@ import {
View,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useTheme, fontFamily, type CategoryHue } from '../../../theme';
import { Card } from '../../../components';
import { FeatureKey } from '../../../constants/featureKeys';
@@ -66,7 +66,7 @@ const QuickActionsComponent = ({ onPressRoute }: QuickActionsProps) => {
{ backgroundColor: theme.colors.category[action.hue].surface },
]}
>
<Ionicons
<Icon
name={action.icon as any}
size={22}
color={theme.colors.category[action.hue].tone}
@@ -116,12 +116,11 @@ const styles = StyleSheet.create({
letterSpacing: -0.2,
},
subtitle: {
fontSize: 9,
fontSize: 11,
marginTop: 2,
fontFamily: fontFamily.semibold,
textAlign: 'center',
opacity: 0.6,
textTransform: 'uppercase',
},
});
@@ -1,6 +1,6 @@
import React, { memo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useTheme, fontFamily } from '../../../theme';
interface ServiceGridItemProps {
@@ -45,12 +45,12 @@ const ServiceGridItemComponent = ({
style={[
styles.iconFrame,
{
backgroundColor: `${iconColor || theme.colors.primary}12`,
borderColor: `${iconColor || theme.colors.primary}18`,
backgroundColor: theme.tint(iconColor || theme.colors.primary, 0.07),
borderColor: theme.tint(iconColor || theme.colors.primary, 0.09),
},
]}
>
<Ionicons name={icon as any} size={22} color={iconColor || theme.colors.primary} />
<Icon name={icon as any} size={22} color={iconColor || theme.colors.primary} />
</View>
<Text style={[styles.title, { color: theme.colors.textPrimary }]} numberOfLines={2}>
{title}
+5 -6
View File
@@ -1,6 +1,6 @@
import React, { memo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useTheme, fontFamily } from '../../../theme';
import { Card } from '../../../components';
@@ -21,8 +21,8 @@ const SummaryCardComponent = ({ branch, user, tenant }: SummaryCardProps) => {
return (
<Card style={styles.container}>
<View style={styles.header}>
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.primary}10` }]}>
<Ionicons name="information-circle" size={18} color={theme.colors.primary} />
<View style={[styles.iconFrame, { backgroundColor: theme.tint(theme.colors.primary, 0.06) }]}>
<Icon name="information-circle" size={18} color={theme.colors.primary} />
</View>
<View>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Account Details</Text>
@@ -93,10 +93,9 @@ const styles = StyleSheet.create({
gap: 4,
},
label: {
fontSize: 10,
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 1,
letterSpacing: 0.1,
},
value: {
fontSize: 13,
@@ -0,0 +1,33 @@
import { useGetMiniStatementQuery } from '../services/accountsApi';
import type { CbsMiniStatementTransaction } from '../../../types/cbs';
const NO_ROWS: CbsMiniStatementTransaction[] = [];
/**
* The latest (up to ten) entries on one account, newest first.
*
* Refetched whenever the account changes or a screen mounts, so what is shown
* was fetched for that screen. Reads `currentData` rather than `data`: RTK
* Query keeps the *previous* account's result in `data` while the new one
* loads, and the guide asks that the prior account's view be cleared the
* moment the selection changes — one account's entries must never sit under
* another account's name, even for a frame.
*/
export const useMiniStatement = (accountNumber?: string, options: { skip?: boolean } = {}) => {
const { currentData, isFetching, error, refetch } = useGetMiniStatementQuery(
accountNumber ?? '',
{ skip: !accountNumber || options.skip, refetchOnMountOrArgChange: true },
);
return {
transactions: currentData?.transactions ?? NO_ROWS,
accountNumberMasked: currentData?.accountNumberMasked ?? null,
// `currentData` is undefined while a request for this account is in
// flight, which is exactly when a spinner belongs on screen.
isLoading: isFetching && !currentData,
isFetching,
// An error is only meaningful once nothing for this account has loaded.
error: currentData ? undefined : error,
reload: refetch,
};
};
-170
View File
@@ -1,170 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useGetStatementQuery } from '../services/accountsApi';
import type { CbsStatementTransaction } from '../../../types/cbs';
/**
* Rows per request. Below the bank's cap of 100, and high enough that the
* common case — a customer glancing at a month — is one round trip.
*/
export const STATEMENT_PAGE_SIZE = 25;
/**
* A date in the `yyyy-MM-dd` the statement endpoint wants, read off the local
* calendar.
*
* Deliberately not `toISOString().slice(0, 10)`. That converts to UTC first,
* so for anyone east of Greenwich — every customer of this bank, at +05:30 —
* a date picked as the 17th is sent as the 16th for most of the day, and a
* range quietly loses its first day and gains a day at the other end.
*/
export const toStatementDate = (date: Date) => {
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return `${date.getFullYear()}-${month}-${day}`;
};
interface UseStatementArgs {
accountNumber?: string;
/** Sent only as a pair — see `CbsStatementQuery`. */
fromDate?: string;
toDate?: string;
}
/**
* An account's ledger, accumulated page by page.
*
* RTK Query holds one cache entry per page, which is what makes paging back
* free, but a list needs every page it has walked through. The pages are kept
* here keyed by the page number the *response* reports rather than by the one
* requested, so a bank that clamps or re-numbers a page cannot leave a row
* filed under the wrong index.
*
* The whole accumulation is thrown away when the account or the date range
* changes: those are different ledgers, and page 2 of the old one has no
* meaning in the new one.
*/
export const useStatement = ({ accountNumber, fromDate, toDate }: UseStatementArgs) => {
const queryKey = `${accountNumber ?? ''}|${fromDate ?? ''}|${toDate ?? ''}`;
const [page, setPage] = useState(0);
const [pages, setPages] = useState<Record<number, CbsStatementTransaction[]>>({});
const loadedKey = useRef(queryKey);
const { data, isLoading, isFetching, error, refetch } = useGetStatementQuery(
{
accountNumber: accountNumber ?? '',
fromDate,
toDate,
page,
size: STATEMENT_PAGE_SIZE,
},
{ skip: !accountNumber, refetchOnMountOrArgChange: true },
);
// Reset before the new key's first response lands, not after, so the list is
// never briefly the old account's rows under the new account's header.
if (loadedKey.current !== queryKey) {
loadedKey.current = queryKey;
if (page !== 0) {
setPage(0);
}
if (Object.keys(pages).length > 0) {
setPages({});
}
}
useEffect(() => {
if (!data) {
return;
}
setPages((current) =>
current[data.page] === data.content
? current
: { ...current, [data.page]: data.content },
);
}, [data]);
/**
* Flattened in page order, with repeats dropped.
*
* The endpoint warns that a page can repeat a row when its server-side cache
* expires mid-browse, and `referenceNumber` is the only stable key these
* rows carry — so the same entry arriving twice becomes one row here rather
* than a duplicate in the list and a duplicate in the totals.
*/
const transactions = useMemo(() => {
const seen = new Set<string>();
const ordered: CbsStatementTransaction[] = [];
Object.keys(pages)
.map(Number)
.sort((left, right) => left - right)
.forEach((index) => {
(pages[index] ?? []).forEach((item) => {
if (seen.has(item.referenceNumber)) {
return;
}
seen.add(item.referenceNumber);
ordered.push(item);
});
});
return ordered;
}, [pages]);
const hasMore = Boolean(data?.hasMore);
// True only while another page is on its way, never while the first one is.
const isLoadingMore = isFetching && page > 0;
const loadMore = useCallback(() => {
if (!hasMore || isFetching) {
return;
}
setPage((current) => current + 1);
}, [hasMore, isFetching]);
/**
* Start again from page 0.
*
* On page 0 the fresh rows are written from the refetch's own result rather
* than left to the `[data]` effect: an identical response comes back as the
* SAME `data` object under RTK Query's structural sharing, the effect never
* fires, and a list cleared first would stay empty. Off page 0, moving back
* to it is a different cache entry, so the effect refills the list — and
* refetching here would only re-request the page being left.
*/
const reload = useCallback(() => {
if (page !== 0) {
setPages({});
setPage(0);
return;
}
refetch()
.unwrap()
.then((fresh) => setPages({ [fresh.page]: fresh.content }))
.catch(() => {
// Surfaced through `error`; whatever is on screen is kept.
});
}, [page, refetch]);
return {
transactions,
totalElements: data?.totalElements ?? 0,
hasMore,
// The first page still being fetched, with nothing to show behind it.
isLoading: isLoading || (isFetching && transactions.length === 0),
isLoadingMore,
isFetching,
error,
loadMore,
reload,
};
};
@@ -1,7 +1,7 @@
import React, { useCallback, useMemo, useState, useRef } from 'react';
import { StyleSheet, Text, View, Dimensions, Animated } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import LinearGradient from 'react-native-linear-gradient';
import { AccountCard, Button, Card, ScreenShell } from '../../../components';
@@ -27,7 +27,7 @@ const SPACING = 16;
const CARD_WIDTH = SCREEN_WIDTH - 32;
type Props = NativeStackScreenProps<HomeStackParamList, 'AccountDetails'>;
type IconName = React.ComponentProps<typeof Ionicons>['name'];
type IconName = React.ComponentProps<typeof Icon>['name'];
type DetailItem = {
id: string;
@@ -128,10 +128,10 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
<View
style={[
styles.emptyIcon,
{ backgroundColor: `${theme.colors.danger}12` },
{ backgroundColor: theme.tint(theme.colors.danger, 0.07) },
]}
>
<Ionicons name="alert-circle-outline" size={28} color={theme.colors.danger} />
<Icon name="alert-circle-outline" size={28} color={theme.colors.danger} />
</View>
<Text style={[styles.emptyTitle, { color: theme.colors.textPrimary }]}>
Account not found
@@ -331,7 +331,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
<View
style={[
styles.statusBadge,
{ backgroundColor: `${theme.colors.success}14` },
{ backgroundColor: theme.tint(theme.colors.success, 0.08) },
]}
>
<View
@@ -369,10 +369,10 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
<View
style={[
styles.sectionIcon,
{ backgroundColor: `${theme.colors.primary}12` },
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
]}
>
<Ionicons name="document-text-outline" size={18} color={theme.colors.primary} />
<Icon name="document-text-outline" size={18} color={theme.colors.primary} />
</View>
<View style={styles.sectionCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
@@ -399,7 +399,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
{ backgroundColor: theme.colors.surface },
]}
>
<Ionicons name={item.icon as any} size={18} color={theme.colors.primary} />
<Icon name={item.icon as any} size={18} color={theme.colors.primary} />
</View>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
{item.label}
@@ -417,10 +417,10 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
<View
style={[
styles.reKycIcon,
{ backgroundColor: `${theme.colors.primary}12` },
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
]}
>
<Ionicons name="shield-checkmark-outline" size={18} color={theme.colors.primary} />
<Icon name="shield-checkmark-outline" size={18} color={theme.colors.primary} />
</View>
<View style={styles.reKycCopy}>
<Text style={[styles.reKycTitle, { color: theme.colors.textPrimary }]}>
@@ -436,7 +436,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
title="Re-KYC via Aadhaar"
onPress={() => navigation.navigate('ReKyc', { accountNumber: account.accountNumber })}
leftAccessory={
<Ionicons name="finger-print-outline" size={18} color={theme.colors.white} />
<Icon name="finger-print-outline" size={18} color={theme.colors.white} />
}
/>
</Card>
@@ -446,10 +446,10 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
<View
style={[
styles.sectionIcon,
{ backgroundColor: `${theme.colors.info}12` },
{ backgroundColor: theme.tint(theme.colors.info, 0.07) },
]}
>
<Ionicons name="person-circle-outline" size={18} color={theme.colors.info} />
<Icon name="person-circle-outline" size={18} color={theme.colors.info} />
</View>
<View style={styles.sectionCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
@@ -467,17 +467,20 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
key={item.id}
style={[
styles.profileRow,
index !== profileItems.length - 1 && styles.profileRowDivider,
index !== profileItems.length - 1 && [
styles.profileRowDivider,
{ borderBottomColor: theme.colors.cardBorder },
],
]}
>
<View style={styles.profileLeft}>
<View
style={[
styles.profileIcon,
{ backgroundColor: `${theme.colors.primary}10` },
{ backgroundColor: theme.tint(theme.colors.primary, 0.06) },
]}
>
<Ionicons name={item.icon as any} size={16} color={theme.colors.primary} />
<Icon name={item.icon as any} size={16} color={theme.colors.primary} />
</View>
<View style={styles.profileCopy}>
<Text style={[styles.profileLabel, { color: theme.colors.textSecondary }]}>
@@ -498,10 +501,10 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
<View
style={[
styles.sectionIcon,
{ backgroundColor: `${theme.colors.warning}12` },
{ backgroundColor: theme.tint(theme.colors.warning, 0.07) },
]}
>
<Ionicons name="wallet-outline" size={18} color={theme.colors.warning} />
<Icon name="wallet-outline" size={18} color={theme.colors.warning} />
</View>
<View style={styles.sectionCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
@@ -540,7 +543,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
fullWidth={false}
style={styles.actionButton}
leftAccessory={
<Ionicons name="download-outline" size={18} color={theme.colors.white} />
<Icon name="download-outline" size={18} color={theme.colors.white} />
}
/>
<Button
@@ -550,7 +553,7 @@ export default function AccountDetailsScreen({ route, navigation }: Props) {
fullWidth={false}
style={styles.actionButton}
leftAccessory={
<Ionicons name="book-outline" size={18} color={theme.colors.textPrimary} />
<Icon name="book-outline" size={18} color={theme.colors.textPrimary} />
}
/>
</View>
@@ -595,8 +598,7 @@ const styles = StyleSheet.create({
highlightLabel: {
fontSize: 12,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 1,
letterSpacing: 0.1,
},
highlightTitle: {
fontSize: 22,
@@ -719,8 +721,7 @@ const styles = StyleSheet.create({
detailLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.9,
letterSpacing: 0.1,
},
detailValue: {
fontSize: 15,
@@ -735,7 +736,6 @@ const styles = StyleSheet.create({
},
profileRowDivider: {
borderBottomWidth: 1,
borderBottomColor: 'rgba(15, 23, 42, 0.08)',
},
profileLeft: {
flexDirection: 'row',
@@ -1,7 +1,7 @@
import React from 'react';
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Card, EmptyState, Loader, ScreenShell } from '../../../components';
import { useTheme, fontFamily } from '../../../theme';
@@ -78,7 +78,7 @@ export default function AccountsMmidScreen(_props: Props) {
</Text>
</View>
<View
style={[styles.statusPill, { backgroundColor: `${toneColor(account.status)}1A` }]}
style={[styles.statusPill, { backgroundColor: theme.tint(toneColor(account.status), 0.1) }]}
>
<Text style={[styles.statusText, { color: toneColor(account.status) }]}>
{titleCase(account.status)}
@@ -137,11 +137,11 @@ export default function AccountsMmidScreen(_props: Props) {
/>
}
>
<View style={[styles.mobileCard, { backgroundColor: `${theme.colors.primary}0D` }]}>
<Ionicons name="call-outline" size={18} color={theme.colors.primary} />
<View style={[styles.mobileCard, { backgroundColor: theme.tint(theme.colors.primary, 0.05) }]}>
<Icon name="call-outline" size={18} color={theme.colors.primary} />
<View style={styles.mobileCopy}>
<Text style={[styles.mobileLabel, { color: theme.colors.textSecondary }]}>
REGISTERED MOBILE
Registered mobile
</Text>
<Text style={[styles.mobileValue, { color: theme.colors.textPrimary }]}>
{mobileNumber ?? '—'}
+11 -11
View File
@@ -1,7 +1,7 @@
import React, { useMemo, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Button, Card, ScreenShell } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
@@ -66,7 +66,7 @@ export default function EPassbookScreen({ route }: Props) {
return (
<ScreenShell title="E-Passbook" subtitle="Account ledger">
<Card style={styles.emptyCard}>
<Ionicons name="book-outline" size={28} color={theme.colors.textMuted} />
<Icon name="book-outline" size={28} color={theme.colors.textMuted} />
<Text style={[styles.emptyTitle, { color: theme.colors.textPrimary }]}>No account found</Text>
</Card>
</ScreenShell>
@@ -87,8 +87,8 @@ export default function EPassbookScreen({ route }: Props) {
{/* Passbook cover */}
<Card style={styles.coverCard}>
<View style={styles.coverHeader}>
<View style={[styles.coverIcon, { backgroundColor: `${theme.colors.primary}12` }]}>
<Ionicons name="book-outline" size={20} color={theme.colors.primary} />
<View style={[styles.coverIcon, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
<Icon name="book-outline" size={20} color={theme.colors.primary} />
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.coverName, { color: theme.colors.textPrimary }]}>{customerName || "Account holder"}</Text>
@@ -155,7 +155,7 @@ export default function EPassbookScreen({ route }: Props) {
fullWidth={false}
style={styles.actionBtn}
onPress={() => toast('Download started', 'success', 'Your e-passbook is being saved to your device.')}
leftAccessory={<Ionicons name="cloud-download-outline" size={18} color={theme.colors.white} />}
leftAccessory={<Icon name="cloud-download-outline" size={18} color={theme.colors.white} />}
/>
<Button
title="Share"
@@ -163,7 +163,7 @@ export default function EPassbookScreen({ route }: Props) {
fullWidth={false}
style={styles.actionBtn}
onPress={() => toast('Share sheet', 'info', 'Choose an app to share your e-passbook.')}
leftAccessory={<Ionicons name="share-social-outline" size={18} color={theme.colors.textPrimary} />}
leftAccessory={<Icon name="share-social-outline" size={18} color={theme.colors.textPrimary} />}
/>
</View>
<View style={{ height: 40 }} />
@@ -175,7 +175,7 @@ const styles = StyleSheet.create({
content: { gap: 16 },
selector: { gap: 10, paddingRight: 12 },
accountChip: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 12, borderWidth: 1.5, gap: 2, minWidth: 120 },
chipType: { fontSize: 12, fontFamily: fontFamily.bold, textTransform: 'uppercase' },
chipType: { fontSize: 12, fontFamily: fontFamily.bold },
chipMask: { fontSize: 12, fontFamily: fontFamily.medium },
coverCard: { padding: 18, gap: 16 },
coverHeader: { flexDirection: 'row', alignItems: 'center', gap: 12 },
@@ -184,13 +184,13 @@ const styles = StyleSheet.create({
coverMeta: { fontSize: 12, fontFamily: fontFamily.medium },
coverGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 12 },
coverItem: { width: '47%', gap: 4 },
coverLabel: { fontSize: 11, fontFamily: fontFamily.bold, textTransform: 'uppercase', letterSpacing: 0.6 },
coverLabel: { fontSize: 11, fontFamily: fontFamily.bold, letterSpacing: 0.1 },
coverValue: { fontSize: 14, fontFamily: fontFamily.bold },
ledgerCard: { padding: 16 },
ledgerHead: { flexDirection: 'row', paddingBottom: 10, borderBottomWidth: 1 },
thDate: { width: 64, fontSize: 11, fontFamily: fontFamily.bold, textTransform: 'uppercase' },
thParticulars: { flex: 1, fontSize: 11, fontFamily: fontFamily.bold, textTransform: 'uppercase', paddingHorizontal: 8 },
thAmount: { width: 84, fontSize: 11, fontFamily: fontFamily.bold, textTransform: 'uppercase', textAlign: 'right' },
thDate: { width: 64, fontSize: 11, fontFamily: fontFamily.bold },
thParticulars: { flex: 1, fontSize: 11, fontFamily: fontFamily.bold, paddingHorizontal: 8 },
thAmount: { width: 84, fontSize: 11, fontFamily: fontFamily.bold, textAlign: 'right' },
ledgerRow: { flexDirection: 'row', paddingVertical: 12, borderBottomWidth: 1, alignItems: 'center' },
tdDate: { width: 64, fontSize: 11, fontFamily: fontFamily.semibold },
tdParticulars: { flex: 1, paddingHorizontal: 8, gap: 2 },
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+36 -36
View File
@@ -7,7 +7,7 @@ import {
View,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import LinearGradient from 'react-native-linear-gradient';
import { BottomSheet, Button, Card, Input, ScreenShell } from '../../../components';
@@ -16,7 +16,7 @@ import type { HomeStackParamList } from '../../../navigation/types';
import { useTheme, fontFamily } from '../../../theme';
type Props = NativeStackScreenProps<HomeStackParamList, 'ReKyc'>;
type IconName = React.ComponentProps<typeof Ionicons>['name'];
type IconName = React.ComponentProps<typeof Icon>['name'];
type ChangeField = 'name' | 'mobile' | 'email' | 'address';
type ChangeOption = {
@@ -102,10 +102,10 @@ export default function ReKycScreen({ route, navigation }: Props) {
<View
style={[
styles.emptyIcon,
{ backgroundColor: `${theme.colors.danger}12` },
{ backgroundColor: theme.tint(theme.colors.danger, 0.07) },
]}
>
<Ionicons name="alert-circle-outline" size={28} color={theme.colors.danger} />
<Icon name="alert-circle-outline" size={28} color={theme.colors.danger} />
</View>
<Text style={[styles.emptyTitle, { color: theme.colors.textPrimary }]}>
Account not found
@@ -212,7 +212,7 @@ export default function ReKycScreen({ route, navigation }: Props) {
contentContainerStyle={styles.contentContainer}
>
<LinearGradient
colors={['#F4F9FF', '#E8F2FE', '#F8FBFF']}
colors={[theme.colors.surface, theme.colors.primarySurface, theme.colors.surface]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.heroCard, { borderColor: theme.colors.cardBorder }]}
@@ -221,10 +221,10 @@ export default function ReKycScreen({ route, navigation }: Props) {
<View
style={[
styles.heroIcon,
{ backgroundColor: `${theme.colors.primary}12` },
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
]}
>
<Ionicons name="shield-checkmark-outline" size={20} color={theme.colors.primary} />
<Icon name="shield-checkmark-outline" size={20} color={theme.colors.primary} />
</View>
<View style={styles.heroCopy}>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
@@ -242,7 +242,7 @@ export default function ReKycScreen({ route, navigation }: Props) {
<View
style={[
styles.stepIndex,
{ backgroundColor: `${theme.colors.primary}12` },
{ backgroundColor: theme.tint(theme.colors.primary, 0.07) },
]}
>
<Text style={[styles.stepIndexText, { color: theme.colors.primary }]}>
@@ -260,10 +260,10 @@ export default function ReKycScreen({ route, navigation }: Props) {
<View
style={[
styles.sectionIcon,
{ backgroundColor: `${theme.colors.info}12` },
{ backgroundColor: theme.tint(theme.colors.info, 0.07) },
]}
>
<Ionicons name="card-outline" size={18} color={theme.colors.info} />
<Icon name="card-outline" size={18} color={theme.colors.info} />
</View>
<View style={styles.sectionCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
@@ -282,7 +282,7 @@ export default function ReKycScreen({ route, navigation }: Props) {
keyboardType="number-pad"
maxLength={14}
placeholder="1234 5678 9012"
leftAccessory={<Ionicons name="finger-print-outline" size={18} color={theme.colors.textMuted} />}
leftAccessory={<Icon name="finger-print-outline" size={18} color={theme.colors.textMuted} />}
/>
</Card>
@@ -291,10 +291,10 @@ export default function ReKycScreen({ route, navigation }: Props) {
<View
style={[
styles.sectionIcon,
{ backgroundColor: `${theme.colors.warning}12` },
{ backgroundColor: theme.tint(theme.colors.warning, 0.07) },
]}
>
<Ionicons name="create-outline" size={18} color={theme.colors.warning} />
<Icon name="create-outline" size={18} color={theme.colors.warning} />
</View>
<View style={styles.sectionCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
@@ -317,12 +317,12 @@ export default function ReKycScreen({ route, navigation }: Props) {
style={[
styles.changeChip,
{
backgroundColor: isSelected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
borderColor: isSelected ? `${theme.colors.primary}45` : theme.colors.cardBorder,
backgroundColor: isSelected ? theme.tint(theme.colors.primary, 0.07) : theme.colors.surfaceMuted,
borderColor: isSelected ? theme.tint(theme.colors.primary, 0.27) : theme.colors.cardBorder,
},
]}
>
<Ionicons
<Icon
name={option.icon as any}
size={16}
color={isSelected ? theme.colors.primary : theme.colors.textSecondary}
@@ -348,7 +348,7 @@ export default function ReKycScreen({ route, navigation }: Props) {
{ backgroundColor: theme.colors.surfaceMuted, borderColor: theme.colors.cardBorder },
]}
>
<Ionicons
<Icon
name={consentGiven ? 'checkmark-circle' : 'ellipse-outline'}
size={22}
color={consentGiven ? theme.colors.success : theme.colors.textMuted}
@@ -404,8 +404,8 @@ export default function ReKycScreen({ route, navigation }: Props) {
{/* Document upload */}
<Card style={styles.sectionCard}>
<View style={styles.sectionHeader}>
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.success}12` }]}>
<Ionicons name="cloud-upload-outline" size={18} color={theme.colors.success} />
<View style={[styles.sectionIcon, { backgroundColor: theme.tint(theme.colors.success, 0.07) }]}>
<Icon name="cloud-upload-outline" size={18} color={theme.colors.success} />
</View>
<View style={styles.sectionCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Upload documents</Text>
@@ -424,13 +424,13 @@ export default function ReKycScreen({ route, navigation }: Props) {
style={[
styles.uploadRow,
{
backgroundColor: isUploaded ? `${theme.colors.success}10` : theme.colors.surfaceMuted,
borderColor: isUploaded ? `${theme.colors.success}45` : theme.colors.cardBorder,
backgroundColor: isUploaded ? theme.tint(theme.colors.success, 0.06) : theme.colors.surfaceMuted,
borderColor: isUploaded ? theme.tint(theme.colors.success, 0.27) : theme.colors.cardBorder,
},
]}
>
<View style={[styles.uploadIcon, { backgroundColor: theme.colors.surface }]}>
<Ionicons name={doc.icon} size={18} color={theme.colors.primary} />
<Icon name={doc.icon} size={18} color={theme.colors.primary} />
</View>
<View style={styles.uploadCopy}>
<Text style={[styles.uploadLabel, { color: theme.colors.textPrimary }]}>{doc.label}</Text>
@@ -438,7 +438,7 @@ export default function ReKycScreen({ route, navigation }: Props) {
{isUploaded ? 'Uploaded · tap to remove' : doc.hint}
</Text>
</View>
<Ionicons
<Icon
name={isUploaded ? 'checkmark-circle' : 'add-circle-outline'}
size={22}
color={isUploaded ? theme.colors.success : theme.colors.primary}
@@ -451,8 +451,8 @@ export default function ReKycScreen({ route, navigation }: Props) {
{/* Latest photograph */}
<Card style={styles.sectionCard}>
<View style={styles.sectionHeader}>
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.info}12` }]}>
<Ionicons name="camera-outline" size={18} color={theme.colors.info} />
<View style={[styles.sectionIcon, { backgroundColor: theme.tint(theme.colors.info, 0.07) }]}>
<Icon name="camera-outline" size={18} color={theme.colors.info} />
</View>
<View style={styles.sectionCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Latest photograph</Text>
@@ -467,12 +467,12 @@ export default function ReKycScreen({ route, navigation }: Props) {
style={[
styles.photoTile,
{
backgroundColor: photoUploaded ? `${theme.colors.success}10` : theme.colors.surfaceMuted,
borderColor: photoUploaded ? `${theme.colors.success}45` : theme.colors.cardBorder,
backgroundColor: photoUploaded ? theme.tint(theme.colors.success, 0.06) : theme.colors.surfaceMuted,
borderColor: photoUploaded ? theme.tint(theme.colors.success, 0.27) : theme.colors.cardBorder,
},
]}
>
<Ionicons
<Icon
name={photoUploaded ? 'checkmark-circle' : 'person-circle-outline'}
size={48}
color={photoUploaded ? theme.colors.success : theme.colors.textMuted}
@@ -486,8 +486,8 @@ export default function ReKycScreen({ route, navigation }: Props) {
{/* Self declaration */}
<Card style={styles.sectionCard}>
<View style={styles.sectionHeader}>
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.warning}12` }]}>
<Ionicons name="document-text-outline" size={18} color={theme.colors.warning} />
<View style={[styles.sectionIcon, { backgroundColor: theme.tint(theme.colors.warning, 0.07) }]}>
<Icon name="document-text-outline" size={18} color={theme.colors.warning} />
</View>
<View style={styles.sectionCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Self declaration</Text>
@@ -508,7 +508,7 @@ export default function ReKycScreen({ route, navigation }: Props) {
{ backgroundColor: theme.colors.surfaceMuted, borderColor: theme.colors.cardBorder },
]}
>
<Ionicons
<Icon
name={accepted ? 'checkmark-circle' : 'ellipse-outline'}
size={22}
color={accepted ? theme.colors.success : theme.colors.textMuted}
@@ -522,8 +522,8 @@ export default function ReKycScreen({ route, navigation }: Props) {
{/* Video KYC option */}
<Card style={styles.sectionCard}>
<View style={styles.sectionHeader}>
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.primary}12` }]}>
<Ionicons name="videocam-outline" size={18} color={theme.colors.primary} />
<View style={[styles.sectionIcon, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
<Icon name="videocam-outline" size={18} color={theme.colors.primary} />
</View>
<View style={styles.sectionCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Prefer a video call?</Text>
@@ -536,13 +536,13 @@ export default function ReKycScreen({ route, navigation }: Props) {
title="Start Video KYC"
variant="secondary"
onPress={() => account && navigation.navigate('VideoKyc', { accountNumber: account.accountNumber })}
leftAccessory={<Ionicons name="videocam-outline" size={18} color={theme.colors.textPrimary} />}
leftAccessory={<Icon name="videocam-outline" size={18} color={theme.colors.textPrimary} />}
/>
</Card>
<Card style={styles.footerCard}>
<View style={styles.footerHeader}>
<Ionicons name="time-outline" size={18} color={theme.colors.primary} />
<Icon name="time-outline" size={18} color={theme.colors.primary} />
<Text style={[styles.footerTitle, { color: theme.colors.textPrimary }]}>
Processing note
</Text>
@@ -553,7 +553,7 @@ export default function ReKycScreen({ route, navigation }: Props) {
<Button
title="Submit re-KYC request"
onPress={handleSubmit}
leftAccessory={<Ionicons name="paper-plane-outline" size={18} color={theme.colors.white} />}
leftAccessory={<Icon name="paper-plane-outline" size={18} color={theme.colors.white} />}
/>
</Card>
<View style={{ height: 60 }} />
+12 -13
View File
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { StyleSheet, Text, View, Pressable } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { Button, Card, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
@@ -9,7 +9,7 @@ import type { HomeStackParamList } from '../../../navigation/types';
import { useTheme, fontFamily } from '../../../theme';
type Props = NativeStackScreenProps<HomeStackParamList, 'VideoKyc'>;
type IconName = React.ComponentProps<typeof Ionicons>['name'];
type IconName = React.ComponentProps<typeof Icon>['name'];
type Slot = { id: string; label: string; sub: string };
@@ -50,15 +50,15 @@ export default function VideoKycScreen({ navigation }: Props) {
<ScreenShell title="Video KYC" subtitle="Verify your identity over a video call" contentContainerStyle={styles.content}>
{/* Hero */}
<Card style={styles.heroCard}>
<View style={[styles.heroIcon, { backgroundColor: `${theme.colors.primary}12` }]}>
<Ionicons name="videocam-outline" size={26} color={theme.colors.primary} />
<View style={[styles.heroIcon, { backgroundColor: theme.tint(theme.colors.primary, 0.07) }]}>
<Icon name="videocam-outline" size={26} color={theme.colors.primary} />
</View>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>Video-based Customer Identification</Text>
<Text style={[styles.heroSub, { color: theme.colors.textSecondary }]}>
A bank officer will verify your identity on a short, secure video call. Keep your PAN ready.
</Text>
<View style={styles.placeholderPreview}>
<Ionicons name="person-circle-outline" size={56} color={theme.colors.textMuted} />
<View style={[styles.placeholderPreview, { borderColor: theme.colors.cardBorderStrong }]}>
<Icon name="person-circle-outline" size={56} color={theme.colors.textMuted} />
<Text style={[styles.previewText, { color: theme.colors.textMuted }]}>Camera preview</Text>
</View>
</Card>
@@ -68,8 +68,8 @@ export default function VideoKycScreen({ navigation }: Props) {
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Before you start</Text>
{CHECKLIST.map((item) => (
<View key={item.id} style={styles.checkRow}>
<View style={[styles.checkIcon, { backgroundColor: `${theme.colors.info}12` }]}>
<Ionicons name={item.icon} size={16} color={theme.colors.info} />
<View style={[styles.checkIcon, { backgroundColor: theme.tint(theme.colors.info, 0.07) }]}>
<Icon name={item.icon} size={16} color={theme.colors.info} />
</View>
<Text style={[styles.checkText, { color: theme.colors.textPrimary }]}>{item.label}</Text>
</View>
@@ -88,12 +88,12 @@ export default function VideoKycScreen({ navigation }: Props) {
style={[
styles.slotRow,
{
backgroundColor: isSelected ? `${theme.colors.primary}10` : theme.colors.surfaceMuted,
backgroundColor: isSelected ? theme.tint(theme.colors.primary, 0.06) : theme.colors.surfaceMuted,
borderColor: isSelected ? theme.colors.primary : theme.colors.cardBorder,
},
]}
>
<Ionicons
<Icon
name={isSelected ? 'radio-button-on' : 'radio-button-off'}
size={20}
color={isSelected ? theme.colors.primary : theme.colors.textMuted}
@@ -112,7 +112,7 @@ export default function VideoKycScreen({ navigation }: Props) {
onPress={() => setAgreed((value) => !value)}
style={[styles.consentRow, { backgroundColor: theme.colors.surfaceMuted, borderColor: theme.colors.cardBorder }]}
>
<Ionicons
<Icon
name={agreed ? 'checkmark-circle' : 'ellipse-outline'}
size={22}
color={agreed ? theme.colors.success : theme.colors.textMuted}
@@ -125,7 +125,7 @@ export default function VideoKycScreen({ navigation }: Props) {
<Button
title={selectedSlot === 'now' ? 'Start video call' : 'Book slot'}
onPress={handleStart}
leftAccessory={<Ionicons name="videocam" size={18} color={theme.colors.white} />}
leftAccessory={<Icon name="videocam" size={18} color={theme.colors.white} />}
/>
<View style={{ height: 40 }} />
</ScreenShell>
@@ -144,7 +144,6 @@ const styles = StyleSheet.create({
borderRadius: 16,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: 'rgba(0,0,0,0.12)',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
+21 -57
View File
@@ -6,10 +6,9 @@ import { downloadPdf } from '../../../services/files/pdfDownload';
import type {
CbsAccount,
CbsAccountBalance,
CbsMiniStatement,
CbsPdfDocument,
CbsStatementExportQuery,
CbsStatementPage,
CbsStatementQuery,
MmidAccountsResult,
} from '../../../types/cbs';
@@ -49,34 +48,19 @@ export const accountsApi = baseApi.injectEndpoints({
}),
/**
* One page of an account's ledger.
* The latest ten entries on an account, newest first.
*
* `fromDate` and `toDate` are dropped unless both are set. The bank
* answers `INVALID_DATE_RANGE` when it gets exactly one, and a screen with
* a half-filled range picker would otherwise send that on every keystroke.
* Sending neither is the supported case — the bank then picks its own
* recent window.
*
* Cached per distinct query, so paging back to a page already fetched does
* not re-hit the bank, but `keepUnusedDataFor: 0` still drops the lot the
* moment the screen unmounts: this response carries balances, and the
* account data rule is that what is on screen was fetched for that screen.
* No date filter exists or may be sent: the bank answers the globally
* latest entries, however old. `keepUnusedDataFor: 0` because this carries
* balances, and the account data rule is that what is on screen was
* fetched for that screen — nothing persists past it.
*/
getStatement: builder.query<CbsStatementPage, CbsStatementQuery>({
queryFn: ({ accountNumber, fromDate, toDate, page, size }) =>
getMiniStatement: builder.query<CbsMiniStatement, string>({
queryFn: (accountNumber) =>
resolveLive(async () => {
const hasRange = Boolean(fromDate && toDate);
const response = await apiClient.get<CbsStatementPage>(
endpoints.accounts.statement,
{
params: {
accountNumber,
...(hasRange ? { fromDate, toDate } : {}),
...(page === undefined ? {} : { page }),
...(size === undefined ? {} : { size }),
},
},
const response = await apiClient.get<CbsMiniStatement>(
endpoints.accounts.miniStatement,
{ params: { accountNumber } },
);
return response.data;
@@ -140,44 +124,25 @@ export const accountsApi = baseApi.injectEndpoints({
}),
/**
* The statement for a range, as a PDF on disk.
* Every entry in an inclusive range, as a password-protected PDF on disk.
*
* A mutation rather than a query even though it is a GET: it writes a file
* and is fired by a button, and caching the resulting path would hand the
* next caller a file that may since have been swept from the cache
* directory. Same date-pair rule as `getStatement` — both or neither.
* directory. All three parameters are required; an empty range still
* returns a PDF saying so.
*
* The bank reuses the cached fetch behind `getStatement`, so exporting a
* range the customer has just been reading costs no extra call upstream.
* The fallback name follows the bank's own pattern —
* `Statement_<last 4>_<from>_<to>.pdf` — for a server that omits
* `Content-Disposition`.
*/
exportStatement: builder.mutation<CbsPdfDocument, CbsStatementExportQuery>({
queryFn: ({ accountNumber, fromDate, toDate }) =>
resolveLive(() => {
const hasRange = Boolean(fromDate && toDate);
return downloadPdf({
path: endpoints.accounts.statementExport,
params: {
accountNumber,
...(hasRange ? { fromDate, toDate } : {}),
},
fallbackFileName: `statement-${accountNumber}.pdf`,
});
}),
}),
/**
* The last 10 entries as a short PDF, led by the current available balance
* — the same figure an ATM mini-statement prints at the top. No date range
* exists on this one; asking for a period is what `exportStatement` is for.
*/
downloadMiniStatement: builder.mutation<CbsPdfDocument, { accountNumber: string }>({
queryFn: ({ accountNumber }) =>
resolveLive(() =>
downloadPdf({
path: endpoints.accounts.miniStatement,
params: { accountNumber },
fallbackFileName: `mini-statement-${accountNumber}.pdf`,
path: endpoints.accounts.statementExport,
params: { accountNumber, fromDate, toDate },
fallbackFileName: `Statement_${accountNumber.slice(-4)}_${fromDate}_${toDate}.pdf`,
}),
),
}),
@@ -188,7 +153,6 @@ export const {
useGetAccountsQuery,
useGetMmidAccountsQuery,
useLazyGetAccountBalanceQuery,
useGetStatementQuery,
useGetMiniStatementQuery,
useExportStatementMutation,
useDownloadMiniStatementMutation,
} = accountsApi;
+88 -90
View File
@@ -1,112 +1,110 @@
import type { CbsStatementTransaction } from '../../../types/cbs';
import type { CbsMiniStatementTransaction } from '../../../types/cbs';
import {
counterpartyDirection,
counterpartyLabel,
hasCounterparty,
isCredit,
statementAmount,
statementBalance,
statementDay,
statementKey,
statementRangeProblem,
statementRemarks,
statementTime,
toStatementDate,
} from './statement';
const row = (overrides: Partial<CbsStatementTransaction> = {}): CbsStatementTransaction => ({
transactionDate: '2026-09-17T10:07:11',
narration: 'Clearing - Outward Cheque',
referenceNumber: 'TXN5123450032',
transactionType: 'CLEARING_OUTWARD',
drCr: 'DR',
amount: 2693,
balanceAfter: 9355,
counterpartyName: 'Priya Nair',
counterpartyAccountNumber: '91449252878981',
counterpartyIfsc: 'HDFC0001234',
counterpartyBankName: 'HDFC Bank',
counterpartyVpa: null,
/** The guide's own example row (§7). */
const row = (overrides: Partial<CbsMiniStatementTransaction> = {}): CbsMiniStatementTransaction => ({
transactionDate: '2026-10-14T10:30:00',
referenceNumber: 'TEST001',
remarks: 'UPI/TEST001/DR/TEST MERCHANT',
transactionType: 'DEBIT',
amount: 500.0,
balanceAfter: 4500.0,
...overrides,
});
/**
* `transactionDate` gained a time component, and five counterparty fields
* arrived alongside it. These pin the two things that broke or became possible
* as a result: day grouping, which used to key on the whole value, and naming
* the other side of an entry without ever printing a full account number.
*/
describe('statementDay', () => {
it('takes the date off a value that now carries a time', () => {
expect(statementDay(row())).toBe('2026-09-17');
describe('toStatementDate', () => {
it('reads the local calendar, with zero padding', () => {
expect(toStatementDate(new Date(2026, 0, 5))).toBe('2026-01-05');
expect(toStatementDate(new Date(2026, 9, 14, 23, 59))).toBe('2026-10-14');
});
});
describe('statementRangeProblem', () => {
const today = new Date(2026, 9, 14, 12);
it('accepts a normal range and a single day (both ends inclusive)', () => {
expect(statementRangeProblem(new Date(2026, 9, 1), new Date(2026, 9, 14), today)).toBeNull();
expect(statementRangeProblem(new Date(2026, 9, 14), new Date(2026, 9, 14), today)).toBeNull();
});
it('buckets two entries booked on the same day together', () => {
// The regression this exists for: keying on the raw `transactionDate` gave
// each of these its own group, and the list drew a day header per row.
expect(statementDay(row({ transactionDate: '2026-09-17T10:07:11' }))).toBe(
statementDay(row({ transactionDate: '2026-09-17T18:44:02' })),
it('refuses a missing date', () => {
expect(statementRangeProblem(null, new Date(2026, 9, 14), today)).toMatch(/both/);
expect(statementRangeProblem(new Date(2026, 9, 1), null, today)).toMatch(/both/);
});
it('refuses an inverted range', () => {
expect(statementRangeProblem(new Date(2026, 9, 14), new Date(2026, 9, 1), today)).toMatch(
/after/,
);
});
it('is unchanged by a bank still sending a bare date', () => {
expect(statementDay(row({ transactionDate: '2026-09-17' }))).toBe('2026-09-17');
});
// Sliced, never parsed: `new Date('2026-09-17')` is read as UTC, which moves
// the row to the 16th for every customer of this bank at +05:30.
it('does not shift the day across a timezone', () => {
expect(statementDay(row({ transactionDate: '2026-01-01T00:30:00' }))).toBe('2026-01-01');
});
});
describe('statementTime', () => {
it('reads the booking time', () => {
expect(statementTime(row())).toBe('10:07');
});
it('is null rather than midnight when the bank sent no time', () => {
expect(statementTime(row({ transactionDate: '2026-09-17' }))).toBeNull();
});
});
describe('counterparty', () => {
it('is absent for the channels that have no other party', () => {
// All five fields are null together for these, by contract — so blank is
// the right answer for them, not missing data.
const cash = row({
transactionType: 'CASH_DEPOSIT',
counterpartyName: null,
counterpartyAccountNumber: null,
counterpartyIfsc: null,
counterpartyBankName: null,
counterpartyVpa: null,
});
expect(hasCounterparty(cash)).toBe(false);
expect(counterpartyLabel(cash)).toBeNull();
});
it('prefers the name, which is what a customer recognises', () => {
expect(counterpartyLabel(row())).toBe('Priya Nair');
});
it('falls back to the VPA for a UPI entry the bank did not name', () => {
it('compares days, not instants, so a later time on the same day is fine', () => {
expect(
counterpartyLabel(
row({
transactionType: 'UPI',
counterpartyName: null,
counterpartyVpa: 'anita.iyer255@okhdfcbank',
}),
),
).toBe('anita.iyer255@okhdfcbank');
statementRangeProblem(new Date(2026, 9, 14, 18), new Date(2026, 9, 14, 9), today),
).toBeNull();
});
it('never prints a counterparty account number in full', () => {
const label = counterpartyLabel(
row({ counterpartyName: null, counterpartyVpa: null }),
it('refuses an end date after today', () => {
expect(statementRangeProblem(new Date(2026, 9, 1), new Date(2026, 9, 15), today)).toMatch(
/future/,
);
});
});
describe('direction', () => {
it('maps CREDIT and DEBIT, case-insensitively', () => {
expect(isCredit(row({ transactionType: 'CREDIT' }))).toBe(true);
expect(isCredit(row({ transactionType: 'credit' }))).toBe(true);
expect(isCredit(row({ transactionType: 'DEBIT' }))).toBe(false);
});
it('reads a missing type as a debit rather than inventing income', () => {
expect(isCredit(row({ transactionType: null }))).toBe(false);
expect(isCredit(row({ transactionType: undefined }))).toBe(false);
});
});
describe('statementDay and statementTime', () => {
it('splits the bank local timestamp without converting it', () => {
// No offset on the wire: 10:30 is shown as 10:30, not shifted from UTC.
expect(statementDay(row())).toBe('2026-10-14');
expect(statementTime(row())).toBe('10:30');
});
it('returns null for a missing or malformed date rather than a fake one', () => {
expect(statementDay(row({ transactionDate: null }))).toBeNull();
expect(statementDay(row({ transactionDate: undefined }))).toBeNull();
expect(statementTime(row({ transactionDate: '2026-10-14' }))).toBeNull();
});
});
describe('optional fields', () => {
it('falls back to the direction when remarks are absent', () => {
expect(statementRemarks(row({ remarks: null }))).toBe('Debit');
expect(statementRemarks(row({ remarks: ' ', transactionType: 'CREDIT' }))).toBe('Credit');
expect(statementRemarks(row())).toBe('UPI/TEST001/DR/TEST MERCHANT');
});
expect(label).toBe('••••8981');
expect(label).not.toContain('91449252878981');
it('keeps amounts positive and tolerates absent numbers', () => {
expect(statementAmount(row())).toBe(500);
expect(statementAmount(row({ amount: -500 }))).toBe(500);
expect(statementAmount(row({ amount: null }))).toBeNull();
expect(statementBalance(row({ balanceAfter: undefined }))).toBeNull();
expect(statementBalance(row({ balanceAfter: 0 }))).toBe(0);
});
it('says which way the money went', () => {
expect(counterpartyDirection(row({ drCr: 'CR' }))).toBe('From');
expect(counterpartyDirection(row({ drCr: 'DR' }))).toBe('To');
it('builds a key with or without a reference number', () => {
expect(statementKey(row(), 0)).toBe('TEST001-0');
expect(statementKey(row({ referenceNumber: null }), 3)).toBe('row-3');
});
});
+99 -60
View File
@@ -1,79 +1,118 @@
import type { CbsStatementTransaction } from '../../../types/cbs';
import type { CbsMiniStatementTransaction } from '../../../types/cbs';
/**
* Rules for reading a statement row, kept free of transport and React imports
* so they can be tested without standing up the API layer or a renderer.
* Rules for reading a mini-statement row and for the statement PDF request,
* kept free of transport and React imports so they can be tested without
* standing up the API layer or a renderer.
*
* Every row field may be null or missing (Statement guide, 14 Oct 2026), so
* nothing outside this file reads one directly.
*/
/**
* The calendar day a row was booked on, as `yyyy-MM-dd`.
* A date in the `yyyy-MM-dd` the statement endpoint wants, read off the local
* calendar.
*
* `transactionDate` used to be exactly this and is now
* `yyyy-MM-ddTHH:mm:ss`, so anything that groups or compares rows by day has
* to take the date part off first — otherwise every entry buckets on its own
* and the list renders one day header per transaction.
*
* Sliced rather than parsed through `Date`: the value is already local time,
* and `new Date(...)` on a bare `yyyy-MM-dd` is read as UTC, which would move
* a row to the previous day for every customer of this bank at +05:30.
* Deliberately not `toISOString().slice(0, 10)`. That converts to UTC first,
* so for anyone east of Greenwich — every customer of this bank, at +05:30 —
* a date picked as the 17th is sent as the 16th for most of the day, and a
* range quietly loses its first day and gains a day at the other end.
*/
export const statementDay = (transaction: CbsStatementTransaction) =>
transaction.transactionDate.slice(0, 10);
export const toStatementDate = (date: Date) => {
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return `${date.getFullYear()}-${month}-${day}`;
};
/**
* The time of day a row was booked, as `HH:mm`, or null for a bank still
* sending a bare date.
* Why a From/To pair cannot be sent, or null when it can.
*
* Nullable on purpose. The time arrived with a contract change, and a row that
* predates it — or comes from a bank that truncates — must render without one
* rather than showing `00:00`, which reads as midnight rather than as unknown.
* Checked here so the customer hears it before a round trip; the bank applies
* the same rule and answers `INVALID_DATE_RANGE`. Both ends are inclusive, so
* a single day (`from` equal to `to`) is a valid range. A future end date is
* refused because there is nothing there to print.
*/
export const statementTime = (transaction: CbsStatementTransaction) => {
const time = transaction.transactionDate.slice(11, 16);
export const statementRangeProblem = (from: Date | null, to: Date | null, today = new Date()) => {
if (!from || !to) {
return 'Choose both a From and a To date.';
}
const fromDay = toStatementDate(from);
const toDay = toStatementDate(to);
if (fromDay > toDay) {
return 'The From date cannot be after the To date.';
}
if (toDay > toStatementDate(today)) {
return 'The To date cannot be in the future.';
}
return null;
};
/** True for money arriving. Anything that is not `CREDIT` is read as a debit. */
export const isCredit = (transaction: CbsMiniStatementTransaction) =>
(transaction.transactionType ?? '').toUpperCase() === 'CREDIT';
/**
* The calendar day a row was booked on, as `yyyy-MM-dd`, or null when the
* bank sent none.
*
* Sliced rather than parsed through `Date`: the value is the bank's local time
* with no offset, and must be shown as given rather than reinterpreted.
*/
export const statementDay = (transaction: CbsMiniStatementTransaction) => {
const day = (transaction.transactionDate ?? '').slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null;
};
/**
* The time of day a row was booked, as `HH:mm`, or null when there is none.
* Never shown as `00:00`, which would read as midnight rather than unknown.
*/
export const statementTime = (transaction: CbsMiniStatementTransaction) => {
const time = (transaction.transactionDate ?? '').slice(11, 16);
return /^\d{2}:\d{2}$/.test(time) ? time : null;
};
/** The narration to show, with a neutral fallback for a row that has none. */
export const statementRemarks = (transaction: CbsMiniStatementTransaction) =>
transaction.remarks?.trim() || (isCredit(transaction) ? 'Credit' : 'Debit');
/**
* True when the bank named the other side of the entry.
* The amount as a positive number. The bank promises a non-negative value;
* `Math.abs` keeps a stray sign from doubling up with the direction prefix.
*/
export const statementAmount = (transaction: CbsMiniStatementTransaction) =>
typeof transaction.amount === 'number' ? Math.abs(transaction.amount) : null;
/** The post-entry balance, or null when the bank omitted it. */
export const statementBalance = (transaction: CbsMiniStatementTransaction) =>
typeof transaction.balanceAfter === 'number' ? transaction.balanceAfter : null;
/**
* A stable list key. The reference number when there is one; otherwise the
* position, which is stable for a list the bank returns in a fixed order.
*/
export const statementKey = (transaction: CbsMiniStatementTransaction, index: number) =>
transaction.referenceNumber ? `${transaction.referenceNumber}-${index}` : `row-${index}`;
/**
* The customer-facing description of the statement PDF password.
*
* All four counterparty fields are null together for `ATM_WDL`, `POS` and
* `CASH_DEPOSIT` — those have no other party's account — so an absent
* counterparty is the right answer for them, not missing data, and the row
* should simply omit the line rather than show a placeholder.
* The rule is the bank's (guide §5): first four letters of the name on record
* in capitals — padded with zeros when shorter — then the birth day and month
* as DDMM. The app shows the rule and examples only; it never asks for the
* date of birth, never works the password out, and never stores it.
*/
export const hasCounterparty = (transaction: CbsStatementTransaction) =>
Boolean(
transaction.counterpartyName ||
transaction.counterpartyAccountNumber ||
transaction.counterpartyVpa,
);
export const STATEMENT_PASSWORD_RULE =
'The PDF is password protected. The password is the first four letters of your name in capitals, followed by your date and month of birth (DDMM).';
/**
* One line naming the other party, or null when there is none to name.
*
* Prefers the name, because that is what a customer recognises. A UPI entry
* falls back to the VPA, which is its only human-readable handle; everything
* else falls back to the masked tail of the account number, never the whole
* thing — a counterparty's full account number has no business on a list row.
*/
export const counterpartyLabel = (transaction: CbsStatementTransaction) => {
if (transaction.counterpartyName) {
return transaction.counterpartyName;
}
if (transaction.counterpartyVpa) {
return transaction.counterpartyVpa;
}
const account = transaction.counterpartyAccountNumber;
return account ? `••••${account.slice(-4)}` : null;
};
/**
* Where the money went, or came from, as a prefix for the counterparty line.
* `CR` means it arrived, so the counterparty is the sender.
*/
export const counterpartyDirection = (transaction: CbsStatementTransaction) =>
transaction.drCr === 'CR' ? 'From' : 'To';
export const STATEMENT_PASSWORD_EXAMPLES = [
'Rahul Sharma, born 15 Aug → RAHU1508',
'Al, born 05 Jan → AL000501',
] as const;
@@ -1,6 +1,6 @@
import React, { memo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useTheme, fontFamily } from '../../../theme';
import type { InsuranceCategory } from '../types';
@@ -27,7 +27,7 @@ const InsuranceCategoryChipComponent = ({
styles.chip,
{
borderColor: selected ? meta.accentColor : theme.colors.cardBorder,
backgroundColor: selected ? `${meta.accentColor}10` : theme.colors.surface,
backgroundColor: selected ? theme.tint(meta.accentColor, 0.06) : theme.colors.surface,
},
]}
>
@@ -35,11 +35,11 @@ const InsuranceCategoryChipComponent = ({
style={[
styles.iconWrap,
{
backgroundColor: selected ? `${meta.accentColor}18` : theme.colors.surfaceMuted,
backgroundColor: selected ? theme.tint(meta.accentColor, 0.09) : theme.colors.surfaceMuted,
},
]}
>
<Ionicons
<Icon
name={meta.icon as any}
size={18}
color={selected ? meta.accentColor : theme.colors.textSecondary}
@@ -1,6 +1,6 @@
import React, { memo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Icon } from '../../../components/Icon';
import { useTheme, fontFamily } from '../../../theme';
import { formatDate } from '../../../utils/formatters';
@@ -31,7 +31,7 @@ const InsuranceDocumentItemComponent = ({
]}
>
<View style={[styles.iconWrap, { backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons name="document-text-outline" size={20} color={theme.colors.primary} />
<Icon name="document-text-outline" size={20} color={theme.colors.primary} />
</View>
<View style={styles.copy}>
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{document.name}</Text>

Some files were not shown because too many files have changed in this diff Show More