diff --git a/src/screens/AddSubscriptionScreen.tsx b/src/screens/AddSubscriptionScreen.tsx index c30c4ca7..546c3a8b 100644 --- a/src/screens/AddSubscriptionScreen.tsx +++ b/src/screens/AddSubscriptionScreen.tsx @@ -48,6 +48,11 @@ const AddSubscriptionScreen: React.FC = () => { const nameInputRef = useRef(null); const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + // Ref for the name input — used for delayed focus instead of autoFocus, + // so the screen has time to fully render before the keyboard opens. + const nameInputRef = useRef(null); + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + const [formData, setFormData] = useState({ name: '', description: '', diff --git a/src/screens/TransactionHistoryScreen.tsx b/src/screens/TransactionHistoryScreen.tsx new file mode 100644 index 00000000..66510ffa --- /dev/null +++ b/src/screens/TransactionHistoryScreen.tsx @@ -0,0 +1,384 @@ +import React, { useMemo, useState } from 'react'; +import { + View, + Text, + StyleSheet, + SafeAreaView, + FlatList, + TouchableOpacity, + Linking, + Alert, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { RootStackParamList } from '../navigation/types'; +import { useTransactionStore } from '../store/transactionStore'; +import { Transaction, TransactionStatus, TransactionType } from '../types/transaction'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const STATUS_COLOR: Record = { + [TransactionStatus.CONFIRMED]: colors.success, + [TransactionStatus.PENDING]: colors.warning, + [TransactionStatus.FAILED]: colors.error, + [TransactionStatus.CANCELLED]: colors.textSecondary, +}; + +const STATUS_LABEL: Record = { + [TransactionStatus.CONFIRMED]: 'Confirmed', + [TransactionStatus.PENDING]: 'Pending', + [TransactionStatus.FAILED]: 'Failed', + [TransactionStatus.CANCELLED]: 'Cancelled', +}; + +const TYPE_LABEL: Record = { + [TransactionType.FIAT]: 'Fiat', + [TransactionType.CRYPTO]: 'Crypto', + [TransactionType.REFUND]: 'Refund', +}; + +const FILTERS: Array = [ + 'all', + TransactionStatus.CONFIRMED, + TransactionStatus.PENDING, + TransactionStatus.FAILED, +]; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +function shortHash(hash: string): string { + return `${hash.slice(0, 6)}…${hash.slice(-4)}`; +} + +// ─── Transaction row ────────────────────────────────────────────────────────── + +interface RowProps { + tx: Transaction; + onPress: (tx: Transaction) => void; +} + +const TransactionRow: React.FC = ({ tx, onPress }) => ( + onPress(tx)} + accessibilityRole="button" + accessibilityLabel={`${tx.subscriptionName} transaction, ${tx.amount} ${tx.currency}, ${STATUS_LABEL[tx.status]}`}> + + + {tx.subscriptionName} + + + {formatDate(tx.date)} · {TYPE_LABEL[tx.type]} + + {tx.txHash ? ( + {shortHash(tx.txHash)} + ) : null} + + + + {tx.type === TransactionType.REFUND ? '+' : '-'} + {tx.amount.toFixed(2)} {tx.currency} + + + + {STATUS_LABEL[tx.status]} + + + + +); + +// ─── Detail modal ───────────────────────────────────────────────────────────── + +interface DetailProps { + tx: Transaction; + onClose: () => void; +} + +const TransactionDetail: React.FC = ({ tx, onClose }) => { + const explorerLink = + tx.txHash && tx.explorerUrl ? `${tx.explorerUrl}/tx/${tx.txHash}` : null; + + const openExplorer = async () => { + if (!explorerLink) return; + const supported = await Linking.canOpenURL(explorerLink); + if (supported) { + await Linking.openURL(explorerLink); + } else { + Alert.alert('Cannot open link', explorerLink); + } + }; + + return ( + + + + Transaction Details + + + + + + + + + + + {tx.txHash ? : null} + {tx.chainId ? : null} + {tx.failureReason ? ( + + ) : null} + {tx.notes ? : null} + + {explorerLink ? ( + + 🔗 View on Block Explorer + + ) : null} + + + ); +}; + +interface DetailRowProps { + label: string; + value: string; + valueColor?: string; + mono?: boolean; +} + +const DetailRow: React.FC = ({ label, value, valueColor, mono }) => ( + + {label} + + {value} + + +); + +// ─── Screen ─────────────────────────────────────────────────────────────────── + +const TransactionHistoryScreen: React.FC = () => { + const navigation = useNavigation>(); + const { transactions } = useTransactionStore(); + const [activeFilter, setActiveFilter] = useState('all'); + const [selectedTx, setSelectedTx] = useState(null); + + const filtered = useMemo( + () => + activeFilter === 'all' + ? transactions + : transactions.filter((tx) => tx.status === activeFilter), + [transactions, activeFilter] + ); + + const renderEmpty = () => ( + + 📋 + No transactions yet + + Your payment history will appear here once you make your first transaction. + + + ); + + return ( + + {/* Header */} + + navigation.goBack()} + style={styles.backBtn} + accessibilityRole="button" + accessibilityLabel="Go back"> + ‹ Back + + Transaction History + + + + {/* Filter chips */} + + {FILTERS.map((f) => ( + setActiveFilter(f)} + accessibilityRole="radio" + accessibilityState={{ checked: activeFilter === f }} + accessibilityLabel={f === 'all' ? 'All transactions' : STATUS_LABEL[f]}> + + {f === 'all' ? 'All' : STATUS_LABEL[f]} + + + ))} + + + {/* Count */} + {filtered.length > 0 && ( + {filtered.length} transaction{filtered.length !== 1 ? 's' : ''} + )} + + {/* List */} + item.id} + renderItem={({ item }) => ( + setSelectedTx(tx)} /> + )} + ListEmptyComponent={renderEmpty} + contentContainerStyle={filtered.length === 0 ? styles.listEmpty : styles.list} + showsVerticalScrollIndicator={false} + /> + + {/* Detail overlay */} + {selectedTx ? ( + setSelectedTx(null)} /> + ) : null} + + ); +}; + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + }, + backBtn: { padding: spacing.sm }, + backText: { ...typography.body, color: colors.primary, fontWeight: '500' }, + title: { ...typography.h2, color: colors.text, flex: 1, textAlign: 'center' }, + headerSpacer: { width: 60 }, + filters: { + flexDirection: 'row', + paddingHorizontal: spacing.lg, + gap: spacing.sm, + marginBottom: spacing.sm, + }, + chip: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: borderRadius.full, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + }, + chipActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + chipText: { ...typography.caption, color: colors.textSecondary }, + chipTextActive: { color: colors.text, fontWeight: '600' }, + count: { + ...typography.caption, + color: colors.textSecondary, + paddingHorizontal: spacing.lg, + marginBottom: spacing.sm, + }, + list: { paddingHorizontal: spacing.lg, paddingBottom: spacing.xl }, + listEmpty: { flex: 1 }, + // Row + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + padding: spacing.md, + marginBottom: spacing.sm, + borderWidth: 1, + borderColor: colors.border, + }, + rowLeft: { flex: 1, marginRight: spacing.md }, + rowName: { ...typography.body, color: colors.text, fontWeight: '600' }, + rowMeta: { ...typography.caption, color: colors.textSecondary, marginTop: 2 }, + rowHash: { ...typography.small, color: colors.accent, marginTop: 2, fontFamily: 'monospace' }, + rowRight: { alignItems: 'flex-end' }, + rowAmount: { ...typography.body, color: colors.text, fontWeight: '700' }, + badge: { + marginTop: spacing.xs, + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: borderRadius.full, + }, + badgeText: { ...typography.small, fontWeight: '600' }, + // Empty + empty: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.xl }, + emptyIcon: { fontSize: 48, marginBottom: spacing.md }, + emptyTitle: { ...typography.h3, color: colors.text, marginBottom: spacing.sm }, + emptyBody: { ...typography.body, color: colors.textSecondary, textAlign: 'center', lineHeight: 22 }, + // Detail overlay + detailOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.6)', + justifyContent: 'flex-end', + }, + detailCard: { + backgroundColor: colors.surface, + borderTopLeftRadius: borderRadius.xl, + borderTopRightRadius: borderRadius.xl, + padding: spacing.lg, + paddingBottom: spacing.xxl, + borderWidth: 1, + borderColor: colors.border, + }, + detailHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.lg, + }, + detailTitle: { ...typography.h3, color: colors.text }, + detailClose: { ...typography.h3, color: colors.textSecondary, padding: spacing.sm }, + detailRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + detailLabel: { ...typography.body, color: colors.textSecondary, flex: 1 }, + detailValue: { ...typography.body, color: colors.text, flex: 2, textAlign: 'right' }, + detailMono: { fontFamily: 'monospace', fontSize: 12 }, + explorerBtn: { + marginTop: spacing.lg, + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + paddingVertical: spacing.md, + alignItems: 'center', + }, + explorerBtnText: { ...typography.body, color: colors.text, fontWeight: '700' }, +}); + +export default TransactionHistoryScreen; diff --git a/src/store/index.ts b/src/store/index.ts index 6cf489e5..46b4fa60 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,6 +1,7 @@ export { useSubscriptionStore } from './subscriptionStore'; export { useInvoiceStore } from './invoiceStore'; export { useTransactionQueueStore } from './transactionQueueStore'; +export { useTransactionStore } from './transactionStore'; export { useWalletStore } from './walletStore'; export { useNetworkStore } from './networkStore'; export { useSettingsStore } from './settingsStore'; diff --git a/src/store/transactionStore.ts b/src/store/transactionStore.ts new file mode 100644 index 00000000..008cd1ed --- /dev/null +++ b/src/store/transactionStore.ts @@ -0,0 +1,66 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Transaction, TransactionStatus, TransactionType } from '../types/transaction'; + +const STORAGE_KEY = 'subtrackr-transaction-history'; +const MAX_RECORDS = 500; + +interface TransactionState { + transactions: Transaction[]; + + // Actions + addTransaction: (tx: Omit) => Transaction; + updateTransactionStatus: ( + id: string, + status: TransactionStatus, + failureReason?: string + ) => void; + getBySubscription: (subscriptionId: string) => Transaction[]; + getByStatus: (status: TransactionStatus) => Transaction[]; + clearHistory: () => void; +} + +export const useTransactionStore = create()( + persist( + (set, get) => ({ + transactions: [], + + addTransaction: (tx) => { + const newTx: Transaction = { + ...tx, + id: `txhist_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + date: new Date().toISOString(), + }; + + set((state) => { + const next = [newTx, ...state.transactions]; + // Prune oldest beyond limit + return { transactions: next.slice(0, MAX_RECORDS) }; + }); + + return newTx; + }, + + updateTransactionStatus: (id, status, failureReason) => { + set((state) => ({ + transactions: state.transactions.map((tx) => + tx.id === id ? { ...tx, status, ...(failureReason ? { failureReason } : {}) } : tx + ), + })); + }, + + getBySubscription: (subscriptionId) => + get().transactions.filter((tx) => tx.subscriptionId === subscriptionId), + + getByStatus: (status) => get().transactions.filter((tx) => tx.status === status), + + clearHistory: () => set({ transactions: [] }), + }), + { + name: STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => AsyncStorage), + } + ) +); diff --git a/src/types/transaction.ts b/src/types/transaction.ts new file mode 100644 index 00000000..111850d2 --- /dev/null +++ b/src/types/transaction.ts @@ -0,0 +1,33 @@ +export enum TransactionStatus { + PENDING = 'pending', + CONFIRMED = 'confirmed', + FAILED = 'failed', + CANCELLED = 'cancelled', +} + +export enum TransactionType { + FIAT = 'fiat', + CRYPTO = 'crypto', + REFUND = 'refund', +} + +export interface Transaction { + id: string; + subscriptionId: string; + subscriptionName: string; + amount: number; + currency: string; + status: TransactionStatus; + type: TransactionType; + date: string; // ISO string + /** On-chain tx hash — present for crypto transactions */ + txHash?: string; + /** Chain ID — present for crypto transactions */ + chainId?: number; + /** Block explorer base URL, e.g. https://etherscan.io */ + explorerUrl?: string; + /** Human-readable failure reason */ + failureReason?: string; + /** Optional notes */ + notes?: string; +}