From 7a49b0487632ef69534723601351f8afdd9f49a1 Mon Sep 17 00:00:00 2001 From: Senmalong Date: Mon, 1 Jun 2026 07:41:57 +0100 Subject: [PATCH 1/6] fix(#72): replace autoFocus with delayed focus via useRef autoFocus opens the keyboard immediately on mount, which can block UI elements before the screen finishes rendering. Replace it with a ref-based focus triggered after a 300 ms delay, giving the navigation transition time to complete on both iOS and Android. - Add nameInputRef (useRef) to the name field - Remove autoFocus prop from the name TextInput - Focus the input programmatically after 300 ms in a useEffect - Import useRef from React --- src/screens/AddSubscriptionScreen.tsx | 47 ++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/src/screens/AddSubscriptionScreen.tsx b/src/screens/AddSubscriptionScreen.tsx index bd09d0d..c2d4788 100644 --- a/src/screens/AddSubscriptionScreen.tsx +++ b/src/screens/AddSubscriptionScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { View, Text, @@ -10,6 +10,7 @@ import { Alert, KeyboardAvoidingView, Platform, + Keyboard, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -33,6 +34,11 @@ const AddSubscriptionScreen: React.FC = () => { const { addSubscription, isLoading, error } = useSubscriptionStore(); const { preferredCurrency } = useSettingsStore(); + // 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: '', @@ -56,6 +62,32 @@ const AddSubscriptionScreen: React.FC = () => { } }, [error]); + // Delay focus so the screen finishes rendering before the keyboard opens. + // A 300 ms delay gives the navigation transition time to complete on both + // iOS and Android, preventing the keyboard from obscuring UI elements. + useEffect(() => { + const focusTimer = setTimeout(() => { + nameInputRef.current?.focus(); + }, 300); + + return () => clearTimeout(focusTimer); + }, []); + + // Track keyboard visibility so the ScrollView can adjust its content + // inset and keep form fields accessible when the keyboard is open. + useEffect(() => { + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + + const showSub = Keyboard.addListener(showEvent, () => setIsKeyboardVisible(true)); + const hideSub = Keyboard.addListener(hideEvent, () => setIsKeyboardVisible(false)); + + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + // Date Picker States const [showPicker, setShowPicker] = useState(false); const [pickerMode, setPickerMode] = useState<'date' | 'time'>('date'); @@ -186,8 +218,12 @@ const AddSubscriptionScreen: React.FC = () => { - + behavior={Platform.OS === 'ios' ? 'padding' : undefined} + keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}> + { Name * handleInputChange('name', text)} placeholder="Enter subscription name" placeholderTextColor={colors.textSecondary} - autoFocus accessibilityLabel="Subscription name, required" accessibilityHint="Enter the name of the subscription service" returnKeyType="next" @@ -480,6 +516,9 @@ const styles = StyleSheet.create({ scrollView: { flex: 1, }, + scrollContentKeyboardOpen: { + paddingBottom: 120, + }, header: { padding: spacing.lg, paddingBottom: spacing.md, From fe35c61cd3b646db5d1fbf5f3a87910284281f3f Mon Sep 17 00:00:00 2001 From: Senmalong Date: Mon, 1 Jun 2026 07:42:06 +0100 Subject: [PATCH 2/6] fix(#72): improve KeyboardAvoidingView and keyboard visibility tracking - Switch KeyboardAvoidingView behavior to 'padding' on iOS and undefined on Android (Android handles scroll natively) - Track keyboard show/hide events with Keyboard listeners to set isKeyboardVisible state - Apply extra bottom padding to ScrollView content when keyboard is open so all form fields remain reachable - Import Keyboard from react-native From 5949ec23194b59b2664c659fa1155ed9c8926248 Mon Sep 17 00:00:00 2001 From: Senmalong Date: Mon, 1 Jun 2026 07:48:20 +0100 Subject: [PATCH 3/6] fix(#59): add crash reporter service and recovery modal - Add src/services/crashReporter.ts: persists crash records to AsyncStorage, installs a global ErrorUtils handler for uncaught JS errors, detects previous crash on next launch, and supports optional remote endpoint reporting - Add src/components/CrashRecoveryModal.tsx: modal shown on launch when a previous crash is detected; lets user trigger data recovery or continue without recovering - Update ErrorBoundary to call crashReporter.recordCrash() in componentDidCatch so React render errors are also persisted --- src/components/CrashRecoveryModal.tsx | 158 ++++++++++++ src/components/ErrorBoundary.tsx | 7 + src/services/crashReporter.ts | 358 ++++++++++++++++++++++++++ 3 files changed, 523 insertions(+) create mode 100644 src/components/CrashRecoveryModal.tsx create mode 100644 src/services/crashReporter.ts diff --git a/src/components/CrashRecoveryModal.tsx b/src/components/CrashRecoveryModal.tsx new file mode 100644 index 0000000..687b8be --- /dev/null +++ b/src/components/CrashRecoveryModal.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + Modal, + TouchableOpacity, + SafeAreaView, + ActivityIndicator, +} from 'react-native'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import type { CrashRecord } from '../services/crashReporter'; + +interface Props { + visible: boolean; + crash: CrashRecord | null; + onRecover: () => Promise; + onDismiss: () => void; +} + +const CrashRecoveryModal: React.FC = ({ visible, crash, onRecover, onDismiss }) => { + const [recovering, setRecovering] = React.useState(false); + + const handleRecover = async () => { + setRecovering(true); + await onRecover(); + setRecovering(false); + }; + + return ( + + + + + ⚠️ + App Recovered from a Crash + + The app crashed during your last session. Your data may be in an inconsistent state. + We can attempt to restore it now. + + + {__DEV__ && crash && ( + + Debug info + + {crash.message} + + {crash.timestamp} + + )} + + + {recovering ? ( + + ) : ( + Recover Data + )} + + + + Continue Without Recovering + + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.6)', + justifyContent: 'center', + padding: spacing.lg, + }, + safeArea: { + flex: 0, + }, + card: { + backgroundColor: colors.surface, + borderRadius: borderRadius.lg, + padding: spacing.xl, + borderWidth: 1, + borderColor: colors.border, + alignItems: 'center', + }, + icon: { + fontSize: 40, + marginBottom: spacing.md, + }, + title: { + ...typography.h2, + color: colors.text, + textAlign: 'center', + marginBottom: spacing.md, + }, + body: { + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', + lineHeight: 22, + marginBottom: spacing.lg, + }, + devBox: { + width: '100%', + backgroundColor: colors.background, + borderRadius: borderRadius.md, + padding: spacing.md, + marginBottom: spacing.lg, + }, + devLabel: { + ...typography.caption, + color: colors.warning, + fontWeight: '600', + marginBottom: spacing.xs, + }, + devText: { + ...typography.caption, + color: colors.textSecondary, + fontSize: 11, + }, + primaryBtn: { + width: '100%', + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + paddingVertical: spacing.md, + alignItems: 'center', + marginBottom: spacing.sm, + minHeight: 48, + justifyContent: 'center', + }, + primaryBtnText: { + ...typography.body, + color: colors.background, + fontWeight: '700', + }, + secondaryBtn: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + }, + secondaryBtnText: { + ...typography.body, + color: colors.textSecondary, + }, +}); + +export default CrashRecoveryModal; diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 310d254..0dd4615 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,6 +1,7 @@ import React, { Component, ErrorInfo, ReactNode } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ScrollView, SafeAreaView } from 'react-native'; import { errorHandler, AppError, ErrorSeverity } from '../services/errorHandler'; +import { crashReporter } from '../services/crashReporter'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { Button } from '../components/common/Button'; @@ -49,6 +50,12 @@ class ErrorBoundary extends Component { }; } + // Persist crash record so the next launch can detect and recover from it + void crashReporter.recordCrash(error, { + component: this.state.error?.context.component ?? 'ErrorBoundary', + metadata: { componentStack: errorInfo.componentStack ?? '' }, + }); + // Call onError callback if provided if (this.props.onError && this.state.error) { this.props.onError(this.state.error); diff --git a/src/services/crashReporter.ts b/src/services/crashReporter.ts new file mode 100644 index 0000000..3bb5839 --- /dev/null +++ b/src/services/crashReporter.ts @@ -0,0 +1,358 @@ +/** + * CrashReporter — lightweight crash detection and recovery service. + * + * No third-party SDK required. Uses AsyncStorage to persist crash records + * across app launches so the next session can detect, report, and recover + * from the previous crash. + * + * Integration points: + * - Call `crashReporter.initialize()` early in App.tsx (before rendering UI). + * - Call `crashReporter.recordCrash(error, context)` from ErrorBoundary and + * global JS error handlers. + * - Call `crashReporter.attemptDataRecovery()` when the user chooses to recover. + * - Subscribe to `crashReporter.onCrashDetected` to show recovery UI. + * + * Privacy: crash records are stored locally only. No data is sent to any + * external server unless you add a `reportingEndpoint` in the config. + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Platform } from 'react-native'; +import { ErrorSeverity, ErrorType } from './errorHandler'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface CrashRecord { + /** Unique crash identifier */ + id: string; + /** ISO timestamp of the crash */ + timestamp: string; + /** JS error message */ + message: string; + /** JS stack trace (may be minified in production) */ + stackTrace?: string; + /** Error type classification */ + errorType: ErrorType; + /** Severity at time of crash */ + severity: ErrorSeverity; + /** Component or screen where the crash originated */ + component?: string; + /** Additional key/value metadata */ + metadata?: Record; + /** Platform info captured at crash time */ + platform: { + os: string; + version: string | number; + }; + /** Whether the user has already been notified about this crash */ + notified: boolean; + /** Whether a recovery attempt has been made */ + recoveryAttempted: boolean; +} + +export interface CrashReporterConfig { + /** + * Maximum number of crash records to keep in storage. + * Oldest records are pruned when the limit is exceeded. + * @default 20 + */ + maxRecords?: number; + /** + * Optional HTTPS endpoint to POST crash records to. + * When omitted, crashes are stored locally only. + */ + reportingEndpoint?: string; + /** + * Keys in AsyncStorage that should be preserved during data recovery. + * Any key NOT in this list will be cleared on a recovery wipe. + */ + preservedStorageKeys?: string[]; + /** + * Whether to install a global `ErrorUtils` handler for uncaught JS errors. + * Disable this in test environments to avoid interfering with Jest. + * @default true + */ + installGlobalHandler?: boolean; +} + +type CrashDetectedListener = (crash: CrashRecord) => void; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const STORAGE_KEY = '@subtrackr/crash_records'; +const SESSION_FLAG_KEY = '@subtrackr/session_clean'; + +// ─── Service ────────────────────────────────────────────────────────────────── + +class CrashReporterService { + private config: Required = { + maxRecords: 20, + reportingEndpoint: '', + preservedStorageKeys: [], + installGlobalHandler: true, + }; + + private listeners: CrashDetectedListener[] = []; + private initialized = false; + + // ── Public API ────────────────────────────────────────────────────────────── + + /** + * Initialize the crash reporter. Call this once, early in App startup. + * Returns the most recent unnotified crash record if one exists, so the + * caller can decide whether to show a recovery UI. + */ + async initialize(config?: CrashReporterConfig): Promise { + if (this.initialized) return null; + this.initialized = true; + + if (config) { + this.config = { ...this.config, ...config }; + } + + // Install global uncaught-error handler + if (this.config.installGlobalHandler) { + this._installGlobalHandler(); + } + + // Check whether the previous session ended cleanly + const previousCrash = await this._detectPreviousCrash(); + + // Mark this session as clean (will be cleared if a crash occurs) + await AsyncStorage.setItem(SESSION_FLAG_KEY, 'clean'); + + return previousCrash; + } + + /** + * Record a crash. Persists the record to AsyncStorage and optionally + * forwards it to a remote endpoint. + */ + async recordCrash( + error: Error, + context?: { + errorType?: ErrorType; + severity?: ErrorSeverity; + component?: string; + metadata?: Record; + } + ): Promise { + // Mark the session as dirty so the next launch detects the crash + await AsyncStorage.setItem(SESSION_FLAG_KEY, 'crashed'); + + const record: CrashRecord = { + id: `crash_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, + timestamp: new Date().toISOString(), + message: error.message || 'Unknown error', + stackTrace: error.stack, + errorType: context?.errorType ?? ErrorType.UNKNOWN, + severity: context?.severity ?? ErrorSeverity.CRITICAL, + component: context?.component, + metadata: context?.metadata, + platform: { + os: Platform.OS, + version: Platform.Version, + }, + notified: false, + recoveryAttempted: false, + }; + + await this._persistRecord(record); + + if (this.config.reportingEndpoint) { + // Fire-and-forget — do not let a network failure block the crash path + this._sendToEndpoint(record).catch(() => { + // Silently swallow — crash reporting must never throw + }); + } + + if (__DEV__) { + console.warn('[CrashReporter] Crash recorded:', record.id, record.message); + } + + return record; + } + + /** + * Attempt to recover user data after a crash. + * + * Strategy: + * 1. Read all AsyncStorage keys. + * 2. Keep keys listed in `preservedStorageKeys`. + * 3. Remove all other keys (clears corrupted state). + * 4. Mark the crash record as recovery-attempted. + * + * Returns `true` if recovery completed without errors. + */ + async attemptDataRecovery(crashId?: string): Promise { + try { + const allKeys = await AsyncStorage.getAllKeys(); + const keysToRemove = allKeys.filter( + (key) => + key !== STORAGE_KEY && + key !== SESSION_FLAG_KEY && + !this.config.preservedStorageKeys.includes(key) + ); + + if (keysToRemove.length > 0) { + await AsyncStorage.multiRemove(keysToRemove); + } + + if (crashId) { + await this._markRecoveryAttempted(crashId); + } + + if (__DEV__) { + console.info( + '[CrashReporter] Data recovery complete. Removed keys:', + keysToRemove.length + ); + } + + return true; + } catch { + return false; + } + } + + /** + * Mark a crash record as notified (user has seen the recovery UI). + */ + async markNotified(crashId: string): Promise { + const records = await this._loadRecords(); + const updated = records.map((r) => (r.id === crashId ? { ...r, notified: true } : r)); + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } + + /** + * Return all stored crash records, newest first. + */ + async getCrashHistory(): Promise { + const records = await this._loadRecords(); + return [...records].reverse(); + } + + /** + * Clear all stored crash records. + */ + async clearHistory(): Promise { + await AsyncStorage.removeItem(STORAGE_KEY); + } + + /** + * Subscribe to crash-detected events. The listener is called during + * `initialize()` if a previous crash is found. + */ + onCrashDetected(listener: CrashDetectedListener): () => void { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter((l) => l !== listener); + }; + } + + // ── Private helpers ───────────────────────────────────────────────────────── + + private async _detectPreviousCrash(): Promise { + try { + const sessionFlag = await AsyncStorage.getItem(SESSION_FLAG_KEY); + + if (sessionFlag !== 'crashed') return null; + + const records = await this._loadRecords(); + // Find the most recent unnotified crash + const crash = [...records].reverse().find((r) => !r.notified) ?? null; + + if (crash) { + // Notify all listeners + this.listeners.forEach((l) => { + try { + l(crash); + } catch { + // Listener errors must not propagate + } + }); + } + + return crash; + } catch { + return null; + } + } + + private async _persistRecord(record: CrashRecord): Promise { + const records = await this._loadRecords(); + records.push(record); + + // Prune oldest records beyond the limit + const pruned = + records.length > this.config.maxRecords + ? records.slice(records.length - this.config.maxRecords) + : records; + + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(pruned)); + } + + private async _loadRecords(): Promise { + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + private async _markRecoveryAttempted(crashId: string): Promise { + const records = await this._loadRecords(); + const updated = records.map((r) => + r.id === crashId ? { ...r, recoveryAttempted: true } : r + ); + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } + + private async _sendToEndpoint(record: CrashRecord): Promise { + if (!this.config.reportingEndpoint) return; + + await fetch(this.config.reportingEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + // Strip the full stack trace from the payload to limit PII exposure + body: JSON.stringify({ + ...record, + stackTrace: record.stackTrace ? '[redacted in remote report]' : undefined, + }), + }); + } + + /** + * Install a global handler for uncaught JS errors via React Native's + * ErrorUtils. This catches errors that escape all React error boundaries + * (e.g. errors in native event callbacks or promise rejections that are + * not caught anywhere). + */ + private _installGlobalHandler(): void { + // ErrorUtils is a React Native global — not available in Node/Jest + if (typeof ErrorUtils === 'undefined') return; + + const previousHandler = ErrorUtils.getGlobalHandler(); + + ErrorUtils.setGlobalHandler(async (error: Error, isFatal?: boolean) => { + try { + await this.recordCrash(error, { + severity: isFatal ? ErrorSeverity.CRITICAL : ErrorSeverity.HIGH, + metadata: { isFatal: isFatal ?? false, source: 'globalHandler' }, + }); + } catch { + // Never let the crash reporter itself crash + } finally { + // Always delegate to the previous handler so React Native's default + // red-screen / crash behaviour is preserved in development. + previousHandler?.(error, isFatal); + } + }); + } +} + +// Singleton — import and use directly throughout the app +export const crashReporter = new CrashReporterService(); From d3e1ec8357a2f9ae4215dbe0cdcb134f0bfaaad3 Mon Sep 17 00:00:00 2001 From: Senmalong Date: Mon, 1 Jun 2026 07:48:36 +0100 Subject: [PATCH 4/6] fix(#59): wire crash reporting into App.tsx startup - Initialize crashReporter early in App startup (alongside i18n) - On launch, if a previous crash is detected, show CrashRecoveryModal - handleRecover calls crashReporter.attemptDataRecovery() which clears corrupted AsyncStorage state while preserving settings and auth keys - handleDismissRecovery marks the crash as notified so the modal does not reappear on subsequent launches - Import Alert for recovery-failure feedback --- App.tsx | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/App.tsx b/App.tsx index f08cd6b..5bf96ac 100644 --- a/App.tsx +++ b/App.tsx @@ -1,14 +1,16 @@ import React from 'react'; -import { View } from 'react-native'; +import { View, Alert } from 'react-native'; import { StatusBar } from 'expo-status-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { AppNavigator } from './src/navigation/AppNavigator'; import { useNotifications } from './src/hooks/useNotifications'; import { useTransactionQueue } from './src/hooks/useTransactionQueue'; import ErrorBoundary from './src/components/ErrorBoundary'; +import CrashRecoveryModal from './src/components/CrashRecoveryModal'; import { initI18n } from './src/i18n/config'; import i18n from './src/i18n/config'; import { I18nextProvider } from 'react-i18next'; +import { crashReporter, CrashRecord } from './src/services/crashReporter'; // Import WalletConnect compatibility layer import '@walletconnect/react-native-compat'; @@ -91,12 +93,30 @@ function NotificationBootstrap() { export default function App() { const [i18nReady, setI18nReady] = React.useState(false); + const [pendingCrash, setPendingCrash] = React.useState(null); + const [showRecoveryModal, setShowRecoveryModal] = React.useState(false); React.useEffect(() => { let cancelled = false; const run = async () => { try { await initI18n(); + + // Initialize crash reporter — returns the previous crash if one exists + const previousCrash = await crashReporter.initialize({ + // Preserve user settings and auth tokens across a recovery wipe + preservedStorageKeys: [ + '@subtrackr/settings', + '@subtrackr/auth_token', + '@subtrackr/preferred_currency', + ], + installGlobalHandler: true, + }); + + if (previousCrash && !cancelled) { + setPendingCrash(previousCrash); + setShowRecoveryModal(true); + } } finally { if (!cancelled) setI18nReady(true); } @@ -107,6 +127,29 @@ export default function App() { }; }, []); + const handleRecover = async () => { + if (pendingCrash) { + const success = await crashReporter.attemptDataRecovery(pendingCrash.id); + await crashReporter.markNotified(pendingCrash.id); + setShowRecoveryModal(false); + setPendingCrash(null); + if (!success) { + Alert.alert( + 'Recovery Incomplete', + 'Some data could not be restored. The app will continue with a fresh state.' + ); + } + } + }; + + const handleDismissRecovery = async () => { + if (pendingCrash) { + await crashReporter.markNotified(pendingCrash.id); + } + setShowRecoveryModal(false); + setPendingCrash(null); + }; + if (!i18nReady) return null; return ( @@ -120,6 +163,12 @@ export default function App() { + ); From 4edc56edac646cb9592704f064a8142d80796b4e Mon Sep 17 00:00:00 2001 From: Senmalong Date: Mon, 1 Jun 2026 07:58:44 +0100 Subject: [PATCH 5/6] fix(#50): add Transaction types and transactionStore - Add src/types/transaction.ts: Transaction interface with id, subscriptionId, subscriptionName, amount, currency, status, type, date, txHash, chainId, explorerUrl, failureReason, notes - Add TransactionStatus (pending/confirmed/failed/cancelled) and TransactionType (fiat/crypto/refund) enums - Add src/store/transactionStore.ts: Zustand persisted store with addTransaction, updateTransactionStatus, getBySubscription, getByStatus, clearHistory; capped at 500 records via AsyncStorage - Export useTransactionStore from src/store/index.ts - Add TransactionHistory route to RootStackParamList --- src/navigation/types.ts | 1 + src/store/index.ts | 1 + src/store/transactionStore.ts | 66 +++++++++++++++++++++++++++++++++++ src/types/transaction.ts | 33 ++++++++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 src/store/transactionStore.ts create mode 100644 src/types/transaction.ts diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 4beeb89..6030747 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -43,6 +43,7 @@ export type RootStackParamList = { LoyaltyDashboard: undefined; CampaignManagement: undefined; PerformanceDashboard: undefined; + TransactionHistory: undefined; }; export type TabParamList = { diff --git a/src/store/index.ts b/src/store/index.ts index 6cf489e..46b4fa6 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 0000000..008cd1e --- /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 0000000..111850d --- /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; +} From 349883fdce4689fdaa367c3a27346429206a0d05 Mon Sep 17 00:00:00 2001 From: Senmalong Date: Mon, 1 Jun 2026 07:59:03 +0100 Subject: [PATCH 6/6] fix(#50): add TransactionHistoryScreen with detail view and explorer link - Add src/screens/TransactionHistoryScreen.tsx: - FlatList of all transactions, newest first - Filter chips: All / Confirmed / Pending / Failed - Each row shows subscription name, date, type, amount, status badge - Crypto transactions show shortened tx hash - Tap a row to open a bottom-sheet detail panel with full info - Detail panel shows txHash, chainId, failureReason, notes - 'View on Block Explorer' button opens explorerUrl/tx/ via Linking.openURL (satisfies 'links to explorer' acceptance criterion) - Register TransactionHistory screen in AppNavigator (SettingsStack) --- src/navigation/AppNavigator.tsx | 6 + src/screens/TransactionHistoryScreen.tsx | 384 +++++++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 src/screens/TransactionHistoryScreen.tsx diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 6ef9207..b44f1f0 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -48,6 +48,7 @@ import ApiKeyManagementScreen from '../screens/ApiKeyManagementScreen'; import DocumentationPortalScreen from '../screens/DocumentationPortalScreen'; import IntegrationGuidesScreen from '../screens/IntegrationGuidesScreen'; import PerformanceDashboardScreen from '../screens/PerformanceDashboardScreen'; +import TransactionHistoryScreen from '../screens/TransactionHistoryScreen'; import { colors } from '../utils/constants'; import { RootStackParamList, TabParamList } from './types'; @@ -304,6 +305,11 @@ const SettingsStack = () => ( component={PerformanceDashboardScreen} options={{ title: 'Performance', headerShown: true }} /> + ); diff --git a/src/screens/TransactionHistoryScreen.tsx b/src/screens/TransactionHistoryScreen.tsx new file mode 100644 index 0000000..66510ff --- /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;