Add TPIN creation, TPIN-authorised transfers, MPIN reset and transfer history
- Create TPIN: current MPIN -> OTP -> encrypted TPIN via /tpin/create/*, from Services and from the transfer screen on TPIN_NOT_SET - IMPS initiate now sends encryptedTpin instead of encryptedMpin; handles INVALID_TPIN and TPIN_NOT_SET - Share one RSA-OAEP encryption and key-retry path for MPIN and TPIN - Forgot MPIN on sign-in and Reset MPIN in the Security Center - Fund transfer history list and detail, reachable from Home - Brand logo on sign-in and palette updates Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
abb14d5768
commit
f1def81eb7
@@ -0,0 +1,236 @@
|
||||
import React, { useEffect } from 'react';
|
||||
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 { Button, Card, EmptyState, InfoBanner, Loader, ScreenShell } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate, formatTime } from '../../../utils/formatters';
|
||||
import type { HomeStackParamList, MainTabParamList } from '../../../navigation/types';
|
||||
import type { ApiError } from '../../../types/api';
|
||||
import { describeError } from '../../../services/api/cbsErrors';
|
||||
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
|
||||
import { useGetFundTransferDetailQuery } from '../../payments/services/paymentsApi';
|
||||
import { MODE_LABEL, humaniseReason, statusCopy, toneColors } from '../utils/transferStatus';
|
||||
|
||||
type Props = CompositeScreenProps<
|
||||
NativeStackScreenProps<HomeStackParamList, 'TransferDetail'>,
|
||||
BottomTabScreenProps<MainTabParamList>
|
||||
>;
|
||||
|
||||
const when = (iso: string | null) => (iso ? `${formatDate(iso)}, ${formatTime(iso)}` : null);
|
||||
|
||||
export default function TransferDetailScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const showError = useApiErrorToast();
|
||||
const { transferId } = route.params;
|
||||
|
||||
// Always fetched fresh on open: reopening the screen is one of the two ways
|
||||
// the guide says a pending transfer should re-check its state.
|
||||
const { data, isLoading, isFetching, error, refetch } = useGetFundTransferDetailQuery(transferId, {
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
const apiError = error as ApiError | undefined;
|
||||
|
||||
// A transfer that is not on this profile: close the detail and say why.
|
||||
useEffect(() => {
|
||||
if (apiError?.code === 'TRANSACTION_NOT_FOUND') {
|
||||
showError(apiError);
|
||||
navigation.goBack();
|
||||
}
|
||||
}, [apiError, navigation, showError]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<ScreenShell title="Transfer details">
|
||||
<Loader />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
const copy = describeError(apiError?.code);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Transfer details">
|
||||
<EmptyState
|
||||
title={copy.title}
|
||||
description={apiError?.message || copy.description}
|
||||
actionLabel="Try again"
|
||||
onAction={refetch}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const status = statusCopy(data.status);
|
||||
const colors = toneColors(status.tone, theme.colors);
|
||||
const showReason =
|
||||
Boolean(data.failureReason) && (data.status === 'FAILED' || data.status === 'MANUAL_REVIEW');
|
||||
|
||||
const rows: Array<[string, string | null | undefined]> = [
|
||||
['Paid to', data.payeeName],
|
||||
[data.mode === 'P2P' ? 'Mobile' : 'Account', data.payeeDisplay],
|
||||
['IFSC', data.beneficiaryIfsc],
|
||||
['MMID', data.beneficiaryMmid],
|
||||
['Type', `IMPS · ${MODE_LABEL[data.mode] ?? data.mode}`],
|
||||
['Remark', data.remark],
|
||||
['Initiated', when(data.createdAt)],
|
||||
['Last updated', when(data.updatedAt)],
|
||||
];
|
||||
|
||||
return (
|
||||
<ScreenShell title="Transfer details" scrollable={false}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isFetching}
|
||||
onRefresh={refetch}
|
||||
tintColor={theme.colors.primary}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* ── the outcome ───────────────────────────────────────────────── */}
|
||||
<Card style={styles.hero}>
|
||||
<View style={[styles.statusIcon, { backgroundColor: colors.surface }]}>
|
||||
<Ionicons name={status.icon} size={30} color={colors.tone} />
|
||||
</View>
|
||||
<Text style={[theme.text.balance, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(data.amount)}
|
||||
</Text>
|
||||
<View style={[styles.badge, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
{/* `simple` break strategy: Android's default `highQuality` measures
|
||||
a custom font as fitting on one line, then wraps it on draw, so
|
||||
the last word ("pending") was clipped outside the pill. */}
|
||||
<Text
|
||||
textBreakStrategy="simple"
|
||||
style={[theme.text.bodyStrong, styles.badgeText, { color: colors.tone }]}
|
||||
>
|
||||
{status.label}
|
||||
</Text>
|
||||
</View>
|
||||
{showReason ? (
|
||||
<Text style={[theme.text.body, styles.centre, { color: theme.colors.danger }]}>
|
||||
{humaniseReason(data.failureReason as string)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{data.status === 'PENDING_RECONCILIATION' || data.status === 'PROCESSING' ? (
|
||||
<InfoBanner
|
||||
tone="warning"
|
||||
message="Your bank is still confirming this transfer. Don't send it again — refresh in a little while to see the final status."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data.status === 'PENDING_OTP' ? (
|
||||
<InfoBanner
|
||||
tone="info"
|
||||
message="This transfer was never confirmed with an OTP, so no money has left your account."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data.status === 'MANUAL_REVIEW' ? (
|
||||
<InfoBanner
|
||||
tone="warning"
|
||||
message="Your bank is reviewing this transfer. Contact support and quote the reference below if you need help."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* ── the reference the customer quotes ─────────────────────────── */}
|
||||
<Card style={styles.section}>
|
||||
<Text style={[theme.text.eyebrow, { color: theme.colors.textMuted }]}>Reference</Text>
|
||||
<Text selectable style={[theme.text.code, { color: theme.colors.textPrimary }]}>
|
||||
{data.referenceId}
|
||||
</Text>
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
|
||||
Press and hold to copy
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.section}>
|
||||
{rows
|
||||
.filter(([, value]) => Boolean(value))
|
||||
.map(([label, value]) => (
|
||||
<View key={label} style={styles.detailRow}>
|
||||
<Text style={[theme.text.body, { color: theme.colors.textSecondary }]}>{label}</Text>
|
||||
<Text
|
||||
style={[theme.text.bodyStrong, styles.detailValue, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{status.refreshable ? (
|
||||
<Button
|
||||
title={isFetching ? 'Checking…' : 'Refresh status'}
|
||||
variant="secondary"
|
||||
onPress={refetch}
|
||||
disabled={isFetching}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data.status === 'MANUAL_REVIEW' || data.status === 'FAILED' ? (
|
||||
<Button
|
||||
title="Get help"
|
||||
variant="ghost"
|
||||
onPress={() => navigation.navigate('Profile', { screen: 'HelpSupport' })}
|
||||
/>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {
|
||||
gap: 14,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
hero: {
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingVertical: 24,
|
||||
},
|
||||
statusIcon: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
badgeText: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
badge: {
|
||||
maxWidth: '100%',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
},
|
||||
centre: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
section: {
|
||||
gap: 6,
|
||||
padding: 16,
|
||||
},
|
||||
detailRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
detailValue: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Pressable,
|
||||
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 DatePicker from 'react-native-date-picker';
|
||||
|
||||
import {
|
||||
AppModal as Modal,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
Loader,
|
||||
ScreenShell,
|
||||
SegmentedControl,
|
||||
} from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate, formatTime } from '../../../utils/formatters';
|
||||
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 { useFundTransferHistory, type HistoryFilters } from '../hooks/useFundTransferHistory';
|
||||
import { humaniseReason, statusCopy, toneColors } from '../utils/transferStatus';
|
||||
|
||||
type Props = NativeStackScreenProps<HomeStackParamList, 'TransferHistory'>;
|
||||
|
||||
const STATUS_FILTERS: ReadonlyArray<{ value: FundTransferStatus | 'ALL'; label: string }> = [
|
||||
{ value: 'ALL', label: 'All' },
|
||||
{ value: 'SUCCESS', label: 'Successful' },
|
||||
{ value: 'FAILED', label: 'Failed' },
|
||||
{ value: 'PROCESSING', label: 'In progress' },
|
||||
{ value: 'PENDING_RECONCILIATION', label: 'Pending' },
|
||||
{ value: 'MANUAL_REVIEW', label: 'Under review' },
|
||||
];
|
||||
|
||||
const MODE_FILTERS: ReadonlyArray<{ value: BeneficiaryMode | 'ALL'; label: string }> = [
|
||||
{ value: 'ALL', label: 'All' },
|
||||
{ value: 'P2A', label: 'Account' },
|
||||
{ value: 'P2P', label: 'Mobile' },
|
||||
];
|
||||
|
||||
function TransferRow({
|
||||
item,
|
||||
onPress,
|
||||
}: {
|
||||
item: FundTransferHistoryItem;
|
||||
onPress: (item: FundTransferHistoryItem) => void;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const copy = statusCopy(item.status);
|
||||
const colors = toneColors(copy.tone, theme.colors);
|
||||
// Shown only for failed and review states, and only when there is one.
|
||||
const showReason =
|
||||
Boolean(item.failureReason) && (item.status === 'FAILED' || item.status === 'MANUAL_REVIEW');
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${item.payeeName ?? 'Transfer'}, ${formatCurrency(item.amount)}, ${copy.label}`}
|
||||
onPress={() => onPress(item)}
|
||||
>
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.row}>
|
||||
<View style={[styles.iconWrap, { backgroundColor: colors.surface }]}>
|
||||
<Ionicons name={copy.icon} size={20} color={colors.tone} />
|
||||
</View>
|
||||
|
||||
<View style={styles.copy}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
{item.payeeName || 'Payee'}
|
||||
</Text>
|
||||
{item.payeeDisplay ? (
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
|
||||
{item.payeeDisplay}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
|
||||
{`${formatDate(item.createdAt)} · ${formatTime(item.createdAt)}`}
|
||||
</Text>
|
||||
{showReason ? (
|
||||
<Text style={[theme.text.caption, { color: theme.colors.danger }]}>
|
||||
{humaniseReason(item.failureReason as string)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.trailing}>
|
||||
<Text style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(item.amount)}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
{ backgroundColor: colors.surface, borderColor: colors.border },
|
||||
]}
|
||||
>
|
||||
{/* See TransferDetailScreen: `simple` stops Android clipping the
|
||||
last word of a label drawn in a custom font. */}
|
||||
<Text
|
||||
textBreakStrategy="simple"
|
||||
style={[theme.text.caption, styles.badgeText, { color: colors.tone }]}
|
||||
>
|
||||
{copy.label}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TransferHistoryScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const [filters, setFilters] = useState<HistoryFilters>({ status: 'ALL', mode: 'ALL' });
|
||||
|
||||
const [rangeOpen, setRangeOpen] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState<'from' | 'to' | null>(null);
|
||||
const [tempFrom, setTempFrom] = useState(() => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 30);
|
||||
return date;
|
||||
});
|
||||
const [tempTo, setTempTo] = useState(() => new Date());
|
||||
const [rangeError, setRangeError] = useState<string | null>(null);
|
||||
|
||||
const { transfers, totalElements, hasMore, isLoading, isLoadingMore, isRefreshing, error, loadMore, reload } =
|
||||
useFundTransferHistory(filters);
|
||||
|
||||
const apiError = error as ApiError | undefined;
|
||||
|
||||
const hasRange = Boolean(filters.fromDate && filters.toDate);
|
||||
|
||||
const openDetail = useCallback(
|
||||
(item: FundTransferHistoryItem) => navigation.navigate('TransferDetail', { transferId: item.transferId }),
|
||||
[navigation],
|
||||
);
|
||||
|
||||
const applyRange = useCallback(() => {
|
||||
if (tempFrom.getTime() > tempTo.getTime()) {
|
||||
setRangeError('The start date cannot be after the end date.');
|
||||
return;
|
||||
}
|
||||
|
||||
setRangeError(null);
|
||||
setRangeOpen(false);
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
fromDate: toStatementDate(tempFrom),
|
||||
toDate: toStatementDate(tempTo),
|
||||
}));
|
||||
}, [tempFrom, tempTo]);
|
||||
|
||||
const clearRange = useCallback(() => {
|
||||
setRangeOpen(false);
|
||||
setRangeError(null);
|
||||
setFilters((current) => ({ ...current, fromDate: undefined, toDate: undefined }));
|
||||
}, []);
|
||||
|
||||
const subtitle = useMemo(() => {
|
||||
if (isLoading) return 'Loading your transfers';
|
||||
return `${totalElements} ${totalElements === 1 ? 'transfer' : 'transfers'}`;
|
||||
}, [isLoading, totalElements]);
|
||||
|
||||
const listHeader = (
|
||||
<View style={styles.filters}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
|
||||
{STATUS_FILTERS.map((option) => {
|
||||
const active = filters.status === option.value;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: active }}
|
||||
onPress={() => setFilters((current) => ({ ...current, status: option.value }))}
|
||||
style={[
|
||||
styles.chip,
|
||||
{
|
||||
borderRadius: theme.radius.pill,
|
||||
backgroundColor: active ? theme.colors.primary : theme.colors.surface,
|
||||
borderColor: active ? theme.colors.primary : theme.colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
theme.text.bodySmall,
|
||||
{ color: active ? theme.colors.textOnBrand : theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
<SegmentedControl
|
||||
options={MODE_FILTERS}
|
||||
value={filters.mode}
|
||||
onChange={(mode) => setFilters((current) => ({ ...current, mode }))}
|
||||
accessibilityLabel="Transfer type"
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => setRangeOpen(true)}
|
||||
style={[
|
||||
styles.rangeButton,
|
||||
{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderColor: hasRange ? theme.colors.primary : theme.colors.cardBorder,
|
||||
borderRadius: theme.radius.md,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons 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} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderEmpty = () => {
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (apiError) {
|
||||
const copy = describeError(apiError.code);
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
title={copy.title}
|
||||
description={apiError.message || copy.description}
|
||||
actionLabel="Try again"
|
||||
onAction={reload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
title="No transfers found"
|
||||
description={
|
||||
filters.status === 'ALL' && filters.mode === 'ALL' && !hasRange
|
||||
? 'Transfers you make from the app will appear here.'
|
||||
: 'Nothing matches these filters. Try widening them.'
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="History" subtitle={subtitle} scrollable={false}>
|
||||
<FlatList
|
||||
data={transfers}
|
||||
keyExtractor={(item) => item.transferId}
|
||||
renderItem={({ item }) => <TransferRow item={item} onPress={openDetail} />}
|
||||
ListHeaderComponent={listHeader}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={
|
||||
isLoadingMore ? (
|
||||
<ActivityIndicator style={styles.footer} color={theme.colors.primary} />
|
||||
) : null
|
||||
}
|
||||
ItemSeparatorComponent={Separator}
|
||||
onEndReached={hasMore ? loadMore : undefined}
|
||||
onEndReachedThreshold={0.4}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={reload}
|
||||
tintColor={theme.colors.primary}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
|
||||
<Modal visible={rangeOpen} onClose={() => setRangeOpen(false)} title="Filter by date">
|
||||
<View style={styles.modalBody}>
|
||||
{(['from', 'to'] as const).map((which) => (
|
||||
<View key={which} style={styles.dateField}>
|
||||
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>
|
||||
{which === 'from' ? 'From' : 'To'}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => setPickerOpen(which)}
|
||||
style={[
|
||||
styles.rangeButton,
|
||||
{
|
||||
backgroundColor: theme.colors.surfaceMuted,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
borderRadius: theme.radius.md,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons 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>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{rangeError ? (
|
||||
<Text style={[theme.text.caption, { color: theme.colors.danger }]}>{rangeError}</Text>
|
||||
) : null}
|
||||
|
||||
<View style={styles.modalActions}>
|
||||
<Button title="Apply" onPress={applyRange} style={styles.modalButton} />
|
||||
<Button
|
||||
title={hasRange ? 'Clear dates' : 'Cancel'}
|
||||
variant="secondary"
|
||||
onPress={hasRange ? clearRange : () => setRangeOpen(false)}
|
||||
style={styles.modalButton}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<DatePicker
|
||||
modal
|
||||
open={pickerOpen !== null}
|
||||
date={pickerOpen === 'to' ? tempTo : tempFrom}
|
||||
maximumDate={new Date()}
|
||||
mode="date"
|
||||
title={pickerOpen === 'to' ? 'Select end date' : 'Select start date'}
|
||||
onConfirm={(date) => {
|
||||
if (pickerOpen === 'to') setTempTo(date);
|
||||
else setTempFrom(date);
|
||||
setPickerOpen(null);
|
||||
setRangeError(null);
|
||||
}}
|
||||
onCancel={() => setPickerOpen(null)}
|
||||
/>
|
||||
</Modal>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const Separator = () => <View style={styles.separator} />;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
paddingBottom: 24,
|
||||
flexGrow: 1,
|
||||
},
|
||||
filters: {
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
chips: {
|
||||
gap: 8,
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderWidth: 1,
|
||||
},
|
||||
rangeButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
borderWidth: 1,
|
||||
},
|
||||
rangeText: {
|
||||
flex: 1,
|
||||
},
|
||||
separator: {
|
||||
height: 10,
|
||||
},
|
||||
card: {
|
||||
padding: 14,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
trailing: {
|
||||
alignItems: 'flex-end',
|
||||
gap: 6,
|
||||
},
|
||||
badgeText: {
|
||||
textAlign: 'right',
|
||||
},
|
||||
badge: {
|
||||
maxWidth: 150,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
},
|
||||
footer: {
|
||||
marginVertical: 16,
|
||||
},
|
||||
modalBody: {
|
||||
gap: 14,
|
||||
},
|
||||
dateField: {
|
||||
gap: 6,
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
marginTop: 6,
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user