Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -91,12 +93,30 @@ function NotificationBootstrap() {

export default function App() {
const [i18nReady, setI18nReady] = React.useState(false);
const [pendingCrash, setPendingCrash] = React.useState<CrashRecord | null>(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);
}
Expand All @@ -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 (
Expand All @@ -120,6 +163,12 @@ export default function App() {
</I18nextProvider>
</ErrorBoundary>
<AppKit />
<CrashRecoveryModal
visible={showRecoveryModal}
crash={pendingCrash}
onRecover={handleRecover}
onDismiss={handleDismissRecovery}
/>
</View>
</GestureHandlerRootView>
);
Expand Down
158 changes: 158 additions & 0 deletions src/components/CrashRecoveryModal.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
onDismiss: () => void;
}

const CrashRecoveryModal: React.FC<Props> = ({ visible, crash, onRecover, onDismiss }) => {
const [recovering, setRecovering] = React.useState(false);

const handleRecover = async () => {
setRecovering(true);
await onRecover();
setRecovering(false);
};

return (
<Modal visible={visible} transparent animationType="fade" statusBarTranslucent>
<View style={styles.overlay}>
<SafeAreaView style={styles.safeArea}>
<View style={styles.card}>
<Text style={styles.icon}>⚠️</Text>
<Text style={styles.title}>App Recovered from a Crash</Text>
<Text style={styles.body}>
The app crashed during your last session. Your data may be in an inconsistent state.
We can attempt to restore it now.
</Text>

{__DEV__ && crash && (
<View style={styles.devBox}>
<Text style={styles.devLabel}>Debug info</Text>
<Text style={styles.devText} numberOfLines={3}>
{crash.message}
</Text>
<Text style={styles.devText}>{crash.timestamp}</Text>
</View>
)}

<TouchableOpacity
style={styles.primaryBtn}
onPress={handleRecover}
disabled={recovering}
accessibilityRole="button"
accessibilityLabel="Attempt data recovery">
{recovering ? (
<ActivityIndicator color={colors.background} />
) : (
<Text style={styles.primaryBtnText}>Recover Data</Text>
)}
</TouchableOpacity>

<TouchableOpacity
style={styles.secondaryBtn}
onPress={onDismiss}
disabled={recovering}
accessibilityRole="button"
accessibilityLabel="Continue without recovering">
<Text style={styles.secondaryBtnText}>Continue Without Recovering</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
</Modal>
);
};

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;
7 changes: 7 additions & 0 deletions src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -49,6 +50,12 @@ class ErrorBoundary extends Component<Props, State> {
};
}

// 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);
Expand Down
6 changes: 6 additions & 0 deletions src/navigation/AppNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -304,6 +305,11 @@ const SettingsStack = () => (
component={PerformanceDashboardScreen}
options={{ title: 'Performance', headerShown: true }}
/>
<Stack.Screen
name="TransactionHistory"
component={TransactionHistoryScreen}
options={{ headerShown: false }}
/>
</Stack.Navigator>
);

Expand Down
1 change: 1 addition & 0 deletions src/navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type RootStackParamList = {
LoyaltyDashboard: undefined;
CampaignManagement: undefined;
PerformanceDashboard: undefined;
TransactionHistory: undefined;
};

export type TabParamList = {
Expand Down
47 changes: 43 additions & 4 deletions src/screens/AddSubscriptionScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
Expand All @@ -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';
Expand All @@ -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<TextInput>(null);
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);

const [formData, setFormData] = useState<AddSubscriptionFormData>({
name: '',
description: '',
Expand All @@ -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');
Expand Down Expand Up @@ -186,8 +218,12 @@ const AddSubscriptionScreen: React.FC = () => {
<SafeAreaView style={styles.container} testID="add-subscription-screen">
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<ScrollView style={styles.scrollView} keyboardShouldPersistTaps="handled">
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}>
<ScrollView
style={styles.scrollView}
keyboardShouldPersistTaps="handled"
contentContainerStyle={isKeyboardVisible ? styles.scrollContentKeyboardOpen : undefined}>
<View style={styles.header}>
<View style={styles.headerContent}>
<TouchableOpacity
Expand All @@ -210,12 +246,12 @@ const AddSubscriptionScreen: React.FC = () => {
<View style={styles.inputGroup}>
<Text style={styles.label}>Name *</Text>
<TextInput
ref={nameInputRef}
style={styles.textInput}
value={formData.name}
onChangeText={(text) => 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"
Expand Down Expand Up @@ -480,6 +516,9 @@ const styles = StyleSheet.create({
scrollView: {
flex: 1,
},
scrollContentKeyboardOpen: {
paddingBottom: 120,
},
header: {
padding: spacing.lg,
paddingBottom: spacing.md,
Expand Down
Loading
Loading