diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx
index d1850e1..a9ac9a1 100644
--- a/src/navigation/AppNavigator.tsx
+++ b/src/navigation/AppNavigator.tsx
@@ -21,6 +21,8 @@ import SettingsScreen from '../screens/SettingsScreen';
import AccountingExportScreen from '../screens/AccountingExportScreen';
import WebhookSettingsScreen from '../screens/WebhookSettingsScreen';
import ErrorDashboardScreen from '../screens/ErrorDashboardScreen';
+import ImportScreen from '../screens/ImportScreen';
+import ExportScreen from '../screens/ExportScreen';
import AdminDashboardScreen from '../screens/AdminDashboardScreen';
import InvoiceListScreen from '../screens/InvoiceListScreen';
import InvoiceDetailScreen from '../screens/InvoiceDetailScreen';
@@ -160,6 +162,16 @@ const SettingsStack = () => (
component={ErrorDashboardScreen}
options={{ title: 'Error Dashboard', headerShown: true }}
/>
+
+
);
diff --git a/src/navigation/types.ts b/src/navigation/types.ts
index 194cc44..a75efed 100644
--- a/src/navigation/types.ts
+++ b/src/navigation/types.ts
@@ -20,6 +20,8 @@ export type RootStackParamList = {
LanguageSettings: undefined;
SessionManagement: undefined;
ErrorDashboard: undefined;
+ Import: undefined;
+ Export: undefined;
SegmentManagement: undefined;
SegmentDetail: { segmentId: string };
Gamification: undefined;
diff --git a/src/screens/ExportScreen.tsx b/src/screens/ExportScreen.tsx
new file mode 100644
index 0000000..df35be6
--- /dev/null
+++ b/src/screens/ExportScreen.tsx
@@ -0,0 +1,511 @@
+import React, { useState, useCallback } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ SafeAreaView,
+ ScrollView,
+ TouchableOpacity,
+ Alert,
+ Share,
+ ActivityIndicator,
+ Clipboard,
+ Platform,
+} from 'react-native';
+import { useNavigation } from '@react-navigation/native';
+import { NativeStackNavigationProp } from '@react-navigation/native-stack';
+import { RootStackParamList } from '../navigation/types';
+import { colors, spacing, typography, borderRadius } from '../utils/constants';
+import { Button } from '../components/common/Button';
+import { Card } from '../components/common/Card';
+import {
+ generateCSV,
+ exportToJSON,
+ ExportData,
+ Subscription,
+} from '../utils/importExport';
+import { useSubscriptionStore } from '../store';
+
+type ExportScreenNavigationProp = NativeStackNavigationProp;
+
+type ExportFormat = 'json' | 'csv';
+
+const ExportScreen: React.FC = () => {
+ const navigation = useNavigation();
+ const { subscriptions } = useSubscriptionStore();
+
+ const [exportFormat, setExportFormat] = useState('json');
+ const [isExporting, setIsExporting] = useState(false);
+ const [exportedData, setExportedData] = useState(null);
+ const [showPreview, setShowPreview] = useState(false);
+
+ const handleExport = useCallback(async () => {
+ if (subscriptions.length === 0) {
+ Alert.alert('No Data', 'There are no subscriptions to export.');
+ return;
+ }
+
+ setIsExporting(true);
+
+ try {
+ let data: string;
+ let preview: string;
+
+ if (exportFormat === 'json') {
+ data = exportToJSON(subscriptions);
+ preview = JSON.stringify(JSON.parse(data), null, 2);
+ } else {
+ data = generateCSV(subscriptions);
+ preview = data;
+ }
+
+ setExportedData(data);
+ setShowPreview(true);
+
+ Alert.alert(
+ 'Export Ready',
+ `Exported ${subscriptions.length} subscription(s) as ${exportFormat.toUpperCase()}.`,
+ [
+ {
+ text: 'Cancel',
+ style: 'cancel',
+ },
+ {
+ text: 'Share',
+ onPress: () => shareData(data),
+ },
+ {
+ text: 'Copy to Clipboard',
+ onPress: () => copyToClipboard(data),
+ },
+ ]
+ );
+ } catch (error) {
+ Alert.alert(
+ 'Error',
+ error instanceof Error ? error.message : 'Failed to export data'
+ );
+ } finally {
+ setIsExporting(false);
+ }
+ }, [subscriptions, exportFormat]);
+
+ const shareData = async (data: string) => {
+ try {
+ await Share.share({
+ message: data,
+ title: `SubTrackr Export (${exportFormat.toUpperCase()})`,
+ });
+ } catch (error) {
+ Alert.alert('Error', 'Failed to share data');
+ }
+ };
+
+ const copyToClipboard = (data: string) => {
+ Clipboard.setString(data);
+ Alert.alert('Copied', `${exportFormat.toUpperCase()} data copied to clipboard`);
+ };
+
+ const downloadFile = () => {
+ if (!exportedData) return;
+
+ // In a real implementation, this would use a file system library
+ // like expo-file-system to save the file
+ Alert.alert(
+ 'Download',
+ 'In a production app, this would save the file to the device storage.',
+ [
+ { text: 'OK' },
+ ]
+ );
+ };
+
+ const renderFormatSelector = () => (
+
+ Export Format
+
+ setExportFormat('json')}
+ >
+
+ JSON
+
+
+ Full data with metadata
+
+
+ setExportFormat('csv')}
+ >
+
+ CSV
+
+
+ Spreadsheet compatible
+
+
+
+
+ );
+
+ const renderSubscriptionStats = () => (
+
+ Export Summary
+
+
+ {subscriptions.length}
+ Total Subscriptions
+
+
+
+ {subscriptions.filter((s) => s.isActive).length}
+
+ Active
+
+
+
+ {subscriptions.filter((s) => !s.isActive).length}
+
+ Paused
+
+
+
+ By Category
+ {getCategoryStats().map((cat) => (
+
+ {cat.name}
+ {cat.count}
+
+ ))}
+
+
+ );
+
+ const getCategoryStats = () => {
+ const categoryMap = new Map();
+ subscriptions.forEach((sub) => {
+ const count = categoryMap.get(sub.category) || 0;
+ categoryMap.set(sub.category, count + 1);
+ });
+
+ return Array.from(categoryMap.entries())
+ .map(([name, count]) => ({ name: name.charAt(0).toUpperCase() + name.slice(1), count }))
+ .sort((a, b) => b.count - a.count);
+ };
+
+ const renderPreview = () => {
+ if (!showPreview || !exportedData) return null;
+
+ const previewText = exportedData.length > 500
+ ? exportedData.substring(0, 500) + '...'
+ : exportedData;
+
+ return (
+
+
+ Preview
+ setShowPreview(false)}>
+ Hide
+
+
+
+ {previewText}
+
+
+ );
+ };
+
+ const renderActions = () => (
+
+
+
+ {exportedData && (
+
+ shareData(exportedData)}
+ >
+ Share
+
+ copyToClipboard(exportedData)}
+ >
+ Copy
+
+
+ Download
+
+
+ )}
+
+ );
+
+ const renderInfo = () => (
+
+ Export Information
+
+ JSON Format:
+
+ Full export with version, timestamp, and all subscription data
+
+
+
+ CSV Format:
+
+ Tabular format compatible with Excel, Google Sheets, etc.
+
+
+
+ Data Included:
+
+ Name, description, category, price, currency, billing cycle, dates, and settings
+
+
+
+ );
+
+ return (
+
+
+
+ Export Subscriptions
+
+ Export your subscription data for backup or migration
+
+
+
+ {renderSubscriptionStats()}
+ {renderFormatSelector()}
+ {renderInfo()}
+ {renderPreview()}
+ {renderActions()}
+
+ {subscriptions.length === 0 && (
+
+ No Subscriptions
+
+ Add some subscriptions first before exporting.
+
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ header: {
+ padding: spacing.lg,
+ paddingTop: spacing.xl,
+ },
+ title: {
+ ...typography.h1,
+ color: colors.text,
+ },
+ subtitle: {
+ ...typography.body,
+ color: colors.textSecondary,
+ marginTop: spacing.xs,
+ },
+ statsCard: {
+ margin: spacing.lg,
+ marginTop: 0,
+ },
+ sectionTitle: {
+ ...typography.h3,
+ color: colors.text,
+ marginBottom: spacing.md,
+ },
+ statsGrid: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ marginBottom: spacing.lg,
+ },
+ statItem: {
+ alignItems: 'center',
+ },
+ statValue: {
+ ...typography.h1,
+ color: colors.primary,
+ },
+ statLabel: {
+ ...typography.caption,
+ color: colors.textSecondary,
+ marginTop: spacing.xs,
+ },
+ categoryBreakdown: {
+ borderTopWidth: 1,
+ borderTopColor: colors.border,
+ paddingTop: spacing.md,
+ },
+ categoryTitle: {
+ ...typography.body,
+ color: colors.textSecondary,
+ marginBottom: spacing.sm,
+ },
+ categoryRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ paddingVertical: spacing.xs,
+ },
+ categoryName: {
+ ...typography.body,
+ color: colors.text,
+ },
+ categoryCount: {
+ ...typography.body,
+ color: colors.textSecondary,
+ },
+ formatContainer: {
+ padding: spacing.lg,
+ paddingTop: 0,
+ },
+ formatButtons: {
+ flexDirection: 'row',
+ marginTop: spacing.md,
+ gap: spacing.md,
+ },
+ formatButton: {
+ flex: 1,
+ padding: spacing.lg,
+ borderRadius: borderRadius.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ backgroundColor: colors.surface,
+ },
+ formatButtonActive: {
+ borderColor: colors.primary,
+ backgroundColor: colors.primary + '20',
+ },
+ formatButtonText: {
+ ...typography.h3,
+ color: colors.text,
+ },
+ formatButtonTextActive: {
+ color: colors.primary,
+ },
+ formatButtonSubtext: {
+ ...typography.caption,
+ color: colors.textSecondary,
+ marginTop: spacing.xs,
+ },
+ infoCard: {
+ margin: spacing.lg,
+ marginTop: 0,
+ },
+ infoTitle: {
+ ...typography.h3,
+ color: colors.text,
+ marginBottom: spacing.md,
+ },
+ infoRow: {
+ marginBottom: spacing.md,
+ },
+ infoLabel: {
+ ...typography.body,
+ color: colors.text,
+ fontWeight: '600',
+ marginBottom: spacing.xs,
+ },
+ infoValue: {
+ ...typography.caption,
+ color: colors.textSecondary,
+ },
+ previewCard: {
+ margin: spacing.lg,
+ marginTop: 0,
+ },
+ previewHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ hidePreview: {
+ ...typography.body,
+ color: colors.primary,
+ },
+ previewContent: {
+ backgroundColor: colors.background,
+ borderRadius: borderRadius.md,
+ padding: spacing.md,
+ maxHeight: 200,
+ },
+ previewText: {
+ ...typography.caption,
+ color: colors.textSecondary,
+ fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
+ },
+ actionsContainer: {
+ padding: spacing.lg,
+ paddingTop: 0,
+ },
+ actionButtons: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ marginTop: spacing.lg,
+ },
+ actionButton: {
+ padding: spacing.md,
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ minWidth: 100,
+ alignItems: 'center',
+ },
+ actionButtonText: {
+ ...typography.body,
+ color: colors.primary,
+ fontWeight: '600',
+ },
+ emptyState: {
+ alignItems: 'center',
+ padding: spacing.xl,
+ },
+ emptyTitle: {
+ ...typography.h2,
+ color: colors.text,
+ },
+ emptyText: {
+ ...typography.body,
+ color: colors.textSecondary,
+ marginTop: spacing.md,
+ textAlign: 'center',
+ },
+});
+
+export default ExportScreen;
\ No newline at end of file
diff --git a/src/screens/ImportScreen.tsx b/src/screens/ImportScreen.tsx
new file mode 100644
index 0000000..6a53c54
--- /dev/null
+++ b/src/screens/ImportScreen.tsx
@@ -0,0 +1,701 @@
+import React, { useState, useCallback } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ SafeAreaView,
+ ScrollView,
+ TouchableOpacity,
+ Alert,
+ TextInput,
+ ActivityIndicator,
+ FlatList,
+ Modal,
+} from 'react-native';
+import { useNavigation } from '@react-navigation/native';
+import { NativeStackNavigationProp } from '@react-navigation/native-stack';
+import { RootStackParamList } from '../navigation/types';
+import { colors, spacing, typography, borderRadius } from '../utils/constants';
+import { Button } from '../components/common/Button';
+import { Card } from '../components/common/Card';
+import {
+ parseCSV,
+ parseJSON,
+ validateImport,
+ processImport,
+ recordImport,
+ getImportHistory,
+ getCSVTemplate,
+ getJSONTemplate,
+ detectFormat,
+ ImportMode,
+ ImportResult,
+ ValidationResult,
+ ImportHistoryEntry,
+ SubscriptionInput,
+} from '../utils/importExport';
+import { useSubscriptionStore } from '../store';
+
+type ImportScreenNavigationProp = NativeStackNavigationProp;
+
+const ImportScreen: React.FC = () => {
+ const navigation = useNavigation();
+ const { subscriptions, addSubscription, updateSubscription } = useSubscriptionStore();
+
+ const [importMode, setImportMode] = useState('upsert');
+ const [importText, setImportText] = useState('');
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [validationResult, setValidationResult] = useState(null);
+ const [importResult, setImportResult] = useState(null);
+ const [showHistory, setShowHistory] = useState(false);
+ const [importHistory, setImportHistory] = useState([]);
+ const [showTemplateModal, setShowTemplateModal] = useState(false);
+
+ const handleImport = useCallback(async () => {
+ if (!importText.trim()) {
+ Alert.alert('Error', 'Please enter data to import');
+ return;
+ }
+
+ setIsProcessing(true);
+ setValidationResult(null);
+ setImportResult(null);
+
+ try {
+ const format = detectFormat(importText);
+ let parsedData: SubscriptionInput[];
+
+ if (format === 'csv') {
+ parsedData = parseCSV(importText);
+ } else if (format === 'json') {
+ parsedData = parseJSON(importText);
+ } else {
+ Alert.alert('Error', 'Unable to detect file format. Please use CSV or JSON format.');
+ setIsProcessing(false);
+ return;
+ }
+
+ // Validate the data
+ const validation = validateImport({ subscriptions: parsedData, mode: importMode });
+ setValidationResult(validation);
+
+ if (validation.validRows.length === 0) {
+ Alert.alert(
+ 'Validation Failed',
+ `Found ${validation.errors.length} error(s). Please fix them and try again.`
+ );
+ setIsProcessing(false);
+ return;
+ }
+
+ // Show preview and ask for confirmation
+ Alert.alert(
+ 'Import Preview',
+ `Found ${validation.validRows.length} valid subscription(s).\n\n${
+ validation.warnings.length > 0
+ ? `Warnings: ${validation.warnings.length}\n`
+ : ''
+ }Do you want to proceed with the import?`,
+ [
+ {
+ text: 'Cancel',
+ style: 'cancel',
+ onPress: () => setIsProcessing(false),
+ },
+ {
+ text: 'Import',
+ onPress: async () => {
+ await executeImport(parsedData);
+ },
+ },
+ ]
+ );
+ } catch (error) {
+ Alert.alert(
+ 'Error',
+ error instanceof Error ? error.message : 'Failed to parse import data'
+ );
+ } finally {
+ setIsProcessing(false);
+ }
+ }, [importText, importMode]);
+
+ const executeImport = async (parsedData: SubscriptionInput[]) => {
+ setIsProcessing(true);
+
+ try {
+ const result = processImport(
+ { subscriptions: parsedData, mode: importMode },
+ subscriptions
+ );
+
+ setImportResult(result);
+
+ // Apply the import
+ if (result.imported > 0 || result.updated > 0) {
+ for (const sub of parsedData) {
+ const existing = subscriptions.find(
+ (s) => s.name.toLowerCase() === sub.name.toLowerCase()
+ );
+
+ if (existing) {
+ // Update existing
+ await updateSubscription(existing.id, {
+ name: sub.name,
+ description: sub.description,
+ category: sub.category as any,
+ price: sub.price,
+ currency: sub.currency,
+ billingCycle: sub.billingCycle as any,
+ nextBillingDate: new Date(sub.nextBillingDate),
+ isActive: sub.isActive ?? true,
+ notificationsEnabled: sub.notificationsEnabled ?? true,
+ isCryptoEnabled: sub.isCryptoEnabled ?? false,
+ cryptoToken: sub.cryptoToken,
+ cryptoAmount: sub.cryptoAmount,
+ });
+ } else {
+ // Add new
+ await addSubscription({
+ name: sub.name,
+ description: sub.description,
+ category: sub.category as any,
+ price: sub.price,
+ currency: sub.currency,
+ billingCycle: sub.billingCycle as any,
+ nextBillingDate: new Date(sub.nextBillingDate),
+ notificationsEnabled: sub.notificationsEnabled ?? true,
+ isCryptoEnabled: sub.isCryptoEnabled ?? false,
+ cryptoToken: sub.cryptoToken,
+ cryptoAmount: sub.cryptoAmount,
+ });
+ }
+ }
+ }
+
+ // Record in history
+ await recordImport('Manual Import', importMode, parsedData.length, result);
+
+ Alert.alert(
+ 'Import Complete',
+ `Imported: ${result.imported}\nUpdated: ${result.updated}\nFailed: ${result.failed}`
+ );
+ } catch (error) {
+ Alert.alert(
+ 'Error',
+ error instanceof Error ? error.message : 'Failed to complete import'
+ );
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ const loadHistory = useCallback(async () => {
+ const history = await getImportHistory();
+ setImportHistory(history);
+ setShowHistory(true);
+ }, []);
+
+ const clearData = useCallback(() => {
+ setImportText('');
+ setValidationResult(null);
+ setImportResult(null);
+ }, []);
+
+ const loadTemplate = useCallback((type: 'csv' | 'json') => {
+ setImportText(type === 'csv' ? getCSVTemplate() : getJSONTemplate());
+ setShowTemplateModal(false);
+ }, []);
+
+ const renderModeSelector = () => (
+
+ Import Mode
+
+ {(['create', 'upsert', 'replace'] as ImportMode[]).map((mode) => (
+ setImportMode(mode)}
+ >
+
+ {mode.charAt(0).toUpperCase() + mode.slice(1)}
+
+
+ ))}
+
+
+ {importMode === 'create'
+ ? 'Add new subscriptions only (skip duplicates)'
+ : importMode === 'upsert'
+ ? 'Update existing or add new subscriptions'
+ : 'Replace all existing subscriptions'}
+
+
+ );
+
+ const renderValidationResults = () => {
+ if (!validationResult) return null;
+
+ return (
+
+ Validation Results
+
+ Valid Rows:
+ {validationResult.validRows.length}
+
+ {validationResult.errors.length > 0 && (
+
+ Errors:
+ {validationResult.errors.slice(0, 5).map((error, index) => (
+
+ Row {error.row}: {error.message}
+
+ ))}
+ {validationResult.errors.length > 5 && (
+
+ ...and {validationResult.errors.length - 5} more
+
+ )}
+
+ )}
+ {validationResult.warnings.length > 0 && (
+
+ Warnings:
+ {validationResult.warnings.slice(0, 3).map((warning, index) => (
+
+ Row {warning.row}: {warning.message}
+
+ ))}
+
+ )}
+
+ );
+ };
+
+ const renderImportResults = () => {
+ if (!importResult) return null;
+
+ return (
+
+ Import Results
+
+ Imported:
+
+ {importResult.imported}
+
+
+
+ Updated:
+
+ {importResult.updated}
+
+
+
+ Failed:
+ 0 ? styles.errorText : styles.successText]}>
+ {importResult.failed}
+
+
+
+ );
+ };
+
+ const renderHistoryModal = () => (
+ setShowHistory(false)}
+ >
+
+
+ Import History
+ setShowHistory(false)}>
+ Close
+
+
+ item.id}
+ renderItem={({ item }) => (
+
+
+ {item.fileName}
+
+ {item.status}
+
+
+
+ {new Date(item.timestamp).toLocaleString()}
+
+
+ Imported: {item.imported} | Updated: {item.updated} | Failed: {item.failed}
+
+
+ )}
+ ListEmptyComponent={
+ No import history
+ }
+ />
+
+
+ );
+
+ const renderTemplateModal = () => (
+ setShowTemplateModal(false)}
+ >
+
+
+ Load Template
+ setShowTemplateModal(false)}>
+ Close
+
+
+
+ loadTemplate('csv')}
+ >
+ CSV Template
+
+ Sample CSV with column headers
+
+
+ loadTemplate('json')}
+ >
+ JSON Template
+
+ Sample JSON export format
+
+
+
+
+
+ );
+
+ return (
+
+
+
+ Import Subscriptions
+
+ Import subscription data from CSV or JSON
+
+
+
+ {renderModeSelector()}
+
+
+
+ Import Data
+ setShowTemplateModal(true)}>
+ Load Template
+
+
+
+
+
+ {renderValidationResults()}
+ {renderImportResults()}
+
+
+
+
+
+
+
+ View Import History
+
+
+
+ {renderHistoryModal()}
+ {renderTemplateModal()}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ header: {
+ padding: spacing.lg,
+ paddingTop: spacing.xl,
+ },
+ title: {
+ ...typography.h1,
+ color: colors.text,
+ },
+ subtitle: {
+ ...typography.body,
+ color: colors.textSecondary,
+ marginTop: spacing.xs,
+ },
+ modeContainer: {
+ padding: spacing.lg,
+ paddingTop: 0,
+ },
+ modeButtons: {
+ flexDirection: 'row',
+ marginTop: spacing.md,
+ gap: spacing.sm,
+ },
+ modeButton: {
+ flex: 1,
+ padding: spacing.md,
+ borderRadius: borderRadius.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ alignItems: 'center',
+ },
+ modeButtonActive: {
+ backgroundColor: colors.primary,
+ borderColor: colors.primary,
+ },
+ modeButtonText: {
+ ...typography.body,
+ color: colors.text,
+ fontWeight: '600',
+ },
+ modeButtonTextActive: {
+ color: colors.textInverted,
+ },
+ modeDescription: {
+ ...typography.caption,
+ color: colors.textSecondary,
+ marginTop: spacing.sm,
+ },
+ sectionTitle: {
+ ...typography.h3,
+ color: colors.text,
+ },
+ inputCard: {
+ margin: spacing.lg,
+ marginTop: 0,
+ },
+ inputHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: spacing.md,
+ },
+ templateLink: {
+ ...typography.body,
+ color: colors.primary,
+ fontWeight: '600',
+ },
+ textArea: {
+ ...typography.body,
+ minHeight: 200,
+ borderWidth: 1,
+ borderColor: colors.border,
+ borderRadius: borderRadius.md,
+ padding: spacing.md,
+ color: colors.text,
+ backgroundColor: colors.surface,
+ },
+ buttonContainer: {
+ padding: spacing.lg,
+ paddingTop: 0,
+ gap: spacing.md,
+ },
+ resultCard: {
+ margin: spacing.lg,
+ marginTop: spacing.md,
+ },
+ resultTitle: {
+ ...typography.h3,
+ color: colors.text,
+ marginBottom: spacing.md,
+ },
+ resultRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: spacing.sm,
+ },
+ resultLabel: {
+ ...typography.body,
+ color: colors.textSecondary,
+ },
+ resultValue: {
+ ...typography.body,
+ color: colors.text,
+ fontWeight: '600',
+ },
+ successText: {
+ color: colors.success,
+ },
+ errorText: {
+ color: colors.error,
+ },
+ warningText: {
+ color: colors.warning,
+ },
+ errorContainer: {
+ marginTop: spacing.md,
+ padding: spacing.md,
+ backgroundColor: colors.error + '20',
+ borderRadius: borderRadius.md,
+ },
+ errorTitle: {
+ ...typography.body,
+ color: colors.error,
+ fontWeight: '600',
+ marginBottom: spacing.sm,
+ },
+ warningContainer: {
+ marginTop: spacing.md,
+ padding: spacing.md,
+ backgroundColor: colors.warning + '20',
+ borderRadius: borderRadius.md,
+ },
+ warningTitle: {
+ ...typography.body,
+ color: colors.warning,
+ fontWeight: '600',
+ marginBottom: spacing.sm,
+ },
+ errorText: {
+ ...typography.caption,
+ color: colors.error,
+ },
+ warningText: {
+ ...typography.caption,
+ color: colors.warning,
+ },
+ moreText: {
+ ...typography.caption,
+ color: colors.textSecondary,
+ fontStyle: 'italic',
+ marginTop: spacing.sm,
+ },
+ historyLink: {
+ alignItems: 'center',
+ padding: spacing.lg,
+ },
+ historyLinkText: {
+ ...typography.body,
+ color: colors.primary,
+ },
+ modalContainer: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ modalHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: spacing.lg,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.border,
+ },
+ modalTitle: {
+ ...typography.h2,
+ color: colors.text,
+ },
+ closeButton: {
+ ...typography.body,
+ color: colors.primary,
+ fontWeight: '600',
+ },
+ historyCard: {
+ margin: spacing.md,
+ },
+ historyRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ historyFile: {
+ ...typography.body,
+ color: colors.text,
+ fontWeight: '600',
+ },
+ historyStatus: {
+ ...typography.caption,
+ fontWeight: '600',
+ textTransform: 'capitalize',
+ },
+ historyDate: {
+ ...typography.caption,
+ color: colors.textSecondary,
+ marginTop: spacing.xs,
+ },
+ historyStats: {
+ ...typography.caption,
+ color: colors.textSecondary,
+ marginTop: spacing.xs,
+ },
+ emptyText: {
+ ...typography.body,
+ color: colors.textSecondary,
+ textAlign: 'center',
+ marginTop: spacing.xl,
+ },
+ templateButtons: {
+ padding: spacing.lg,
+ },
+ templateButton: {
+ padding: spacing.lg,
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.md,
+ marginBottom: spacing.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ },
+ templateButtonText: {
+ ...typography.h3,
+ color: colors.text,
+ },
+ templateButtonSubtext: {
+ ...typography.caption,
+ color: colors.textSecondary,
+ marginTop: spacing.xs,
+ },
+});
+
+export default ImportScreen;
\ No newline at end of file
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx
index f51dc82..a82c1e1 100644
--- a/src/screens/SettingsScreen.tsx
+++ b/src/screens/SettingsScreen.tsx
@@ -191,6 +191,28 @@ const SettingsScreen: React.FC = () => {
+ Data Management
+ navigation.navigate('Import')}
+ accessibilityRole="button"
+ accessibilityLabel="Import subscriptions"
+ accessibilityHint="Opens import screen">
+ Import Subscriptions
+ →
+
+ navigation.navigate('Export')}
+ accessibilityRole="button"
+ accessibilityLabel="Export subscriptions"
+ accessibilityHint="Opens export screen">
+ Export Subscriptions
+ →
+
+
+
+ About
{t('settings.sections.about')}
diff --git a/src/utils/__tests__/importExport.test.ts b/src/utils/__tests__/importExport.test.ts
new file mode 100644
index 0000000..63502bc
--- /dev/null
+++ b/src/utils/__tests__/importExport.test.ts
@@ -0,0 +1,391 @@
+/**
+ * Import/Export Tests
+ */
+
+import {
+ parseCSV,
+ parseJSON,
+ generateCSV,
+ exportToJSON,
+ validateImport,
+ processImport,
+ detectFormat,
+ getCSVTemplate,
+ getJSONTemplate,
+ ImportMode,
+ Subscription,
+ SubscriptionCategory,
+ BillingCycle,
+} from '../utils/importExport';
+
+describe('Import/Export Utilities', () => {
+ describe('CSV Parsing', () => {
+ it('should parse valid CSV data', () => {
+ const csv = `name,description,category,price,currency,billingCycle,nextBillingDate
+Netflix,Streaming service,streaming,15.99,USD,monthly,2026-05-01
+Spotify,Music streaming,streaming,9.99,USD,monthly,2026-05-15`;
+
+ const result = parseCSV(csv);
+ expect(result).toHaveLength(2);
+ expect(result[0].name).toBe('Netflix');
+ expect(result[0].price).toBe(15.99);
+ expect(result[1].name).toBe('Spotify');
+ });
+
+ it('should handle quoted values with commas', () => {
+ const csv = `name,description,category,price,currency,billingCycle,nextBillingDate
+Netflix,"Premium, 4K",streaming,15.99,USD,monthly,2026-05-01`;
+
+ const result = parseCSV(csv);
+ expect(result).toHaveLength(1);
+ expect(result[0].description).toBe('Premium, 4K');
+ });
+
+ it('should throw error for empty CSV', () => {
+ expect(() => parseCSV('')).toThrow('CSV must contain at least a header row and one data row');
+ });
+
+ it('should skip empty rows', () => {
+ const csv = `name,description,category,price,currency,billingCycle,nextBillingDate
+Netflix,Streaming service,streaming,15.99,USD,monthly,2026-05-01
+
+Spotify,Music streaming,streaming,9.99,USD,monthly,2026-05-15`;
+
+ const result = parseCSV(csv);
+ expect(result).toHaveLength(2);
+ });
+ });
+
+ describe('JSON Parsing', () => {
+ it('should parse JSON array', () => {
+ const json = `[
+ {
+ "name": "Netflix",
+ "category": "streaming",
+ "price": 15.99,
+ "currency": "USD",
+ "billingCycle": "monthly",
+ "nextBillingDate": "2026-05-01"
+ }
+ ]`;
+
+ const result = parseJSON(json);
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('Netflix');
+ });
+
+ it('should parse export format with metadata', () => {
+ const json = `{
+ "version": "1.0.0",
+ "exportedAt": "2026-04-26T00:00:00.000Z",
+ "subscriptionCount": 1,
+ "subscriptions": [
+ {
+ "name": "Netflix",
+ "category": "streaming",
+ "price": 15.99,
+ "currency": "USD",
+ "billingCycle": "monthly",
+ "nextBillingDate": "2026-05-01"
+ }
+ ]
+ }`;
+
+ const result = parseJSON(json);
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('Netflix');
+ });
+
+ it('should throw error for invalid JSON', () => {
+ expect(() => parseJSON('not valid json')).toThrow();
+ });
+ });
+
+ describe('CSV Generation', () => {
+ it('should generate valid CSV from subscriptions', () => {
+ const subscriptions: Subscription[] = [
+ {
+ id: '1',
+ name: 'Netflix',
+ description: 'Streaming service',
+ category: SubscriptionCategory.STREAMING,
+ price: 15.99,
+ currency: 'USD',
+ billingCycle: BillingCycle.MONTHLY,
+ nextBillingDate: new Date('2026-05-01'),
+ isActive: true,
+ notificationsEnabled: true,
+ isCryptoEnabled: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ];
+
+ const csv = generateCSV(subscriptions);
+ expect(csv).toContain('Netflix');
+ expect(csv).toContain('15.99');
+ expect(csv).toContain('USD');
+ });
+
+ it('should handle special characters in CSV', () => {
+ const subscriptions: Subscription[] = [
+ {
+ id: '1',
+ name: 'Test "quoted" subscription',
+ description: 'Description with, comma',
+ category: SubscriptionCategory.OTHER,
+ price: 10,
+ currency: 'USD',
+ billingCycle: BillingCycle.MONTHLY,
+ nextBillingDate: new Date('2026-05-01'),
+ isActive: true,
+ notificationsEnabled: true,
+ isCryptoEnabled: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ];
+
+ const csv = generateCSV(subscriptions);
+ expect(csv).toContain('"Test ""quoted"" subscription"');
+ });
+ });
+
+ describe('JSON Export', () => {
+ it('should export subscriptions to JSON', () => {
+ const subscriptions: Subscription[] = [
+ {
+ id: '1',
+ name: 'Netflix',
+ category: SubscriptionCategory.STREAMING,
+ price: 15.99,
+ currency: 'USD',
+ billingCycle: BillingCycle.MONTHLY,
+ nextBillingDate: new Date('2026-05-01'),
+ isActive: true,
+ notificationsEnabled: true,
+ isCryptoEnabled: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ];
+
+ const json = exportToJSON(subscriptions);
+ const parsed = JSON.parse(json);
+
+ expect(parsed.version).toBe('1.0.0');
+ expect(parsed.subscriptionCount).toBe(1);
+ expect(parsed.subscriptions[0].name).toBe('Netflix');
+ });
+ });
+
+ describe('Validation', () => {
+ it('should validate correct data', () => {
+ const data = {
+ subscriptions: [
+ {
+ name: 'Netflix',
+ category: 'streaming',
+ price: 15.99,
+ currency: 'USD',
+ billingCycle: 'monthly',
+ nextBillingDate: '2026-05-01',
+ },
+ ],
+ mode: 'create' as ImportMode,
+ };
+
+ const result = validateImport(data);
+ expect(result.isValid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ it('should catch missing required fields', () => {
+ const data = {
+ subscriptions: [
+ {
+ name: '',
+ category: 'streaming',
+ price: 15.99,
+ currency: 'USD',
+ billingCycle: 'monthly',
+ nextBillingDate: '2026-05-01',
+ },
+ ],
+ mode: 'create' as ImportMode,
+ };
+
+ const result = validateImport(data);
+ expect(result.isValid).toBe(false);
+ expect(result.errors.length).toBeGreaterThan(0);
+ });
+
+ it('should catch invalid price', () => {
+ const data = {
+ subscriptions: [
+ {
+ name: 'Netflix',
+ category: 'streaming',
+ price: -10,
+ currency: 'USD',
+ billingCycle: 'monthly',
+ nextBillingDate: '2026-05-01',
+ },
+ ],
+ mode: 'create' as ImportMode,
+ };
+
+ const result = validateImport(data);
+ expect(result.isValid).toBe(false);
+ expect(result.errors.some(e => e.field === 'price')).toBe(true);
+ });
+
+ it('should add warnings for invalid category', () => {
+ const data = {
+ subscriptions: [
+ {
+ name: 'Netflix',
+ category: 'invalid-category',
+ price: 15.99,
+ currency: 'USD',
+ billingCycle: 'monthly',
+ nextBillingDate: '2026-05-01',
+ },
+ ],
+ mode: 'create' as ImportMode,
+ };
+
+ const result = validateImport(data);
+ expect(result.warnings.some(w => w.field === 'category')).toBe(true);
+ });
+ });
+
+ describe('Import Processing', () => {
+ it('should create new subscriptions in create mode', () => {
+ const data = {
+ subscriptions: [
+ {
+ name: 'Netflix',
+ category: 'streaming',
+ price: 15.99,
+ currency: 'USD',
+ billingCycle: 'monthly',
+ nextBillingDate: '2026-05-01',
+ },
+ ],
+ mode: 'create' as ImportMode,
+ };
+
+ const result = processImport(data, []);
+ expect(result.success).toBe(true);
+ expect(result.imported).toBe(1);
+ expect(result.updated).toBe(0);
+ });
+
+ it('should update existing subscriptions in upsert mode', () => {
+ const existing: Subscription[] = [
+ {
+ id: '1',
+ name: 'Netflix',
+ category: SubscriptionCategory.STREAMING,
+ price: 10,
+ currency: 'USD',
+ billingCycle: BillingCycle.MONTHLY,
+ nextBillingDate: new Date('2026-05-01'),
+ isActive: true,
+ notificationsEnabled: true,
+ isCryptoEnabled: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ];
+
+ const data = {
+ subscriptions: [
+ {
+ name: 'Netflix',
+ category: 'streaming',
+ price: 15.99,
+ currency: 'USD',
+ billingCycle: 'monthly',
+ nextBillingDate: '2026-05-01',
+ },
+ ],
+ mode: 'upsert' as ImportMode,
+ };
+
+ const result = processImport(data, existing);
+ expect(result.success).toBe(true);
+ expect(result.imported).toBe(0);
+ expect(result.updated).toBe(1);
+ });
+
+ it('should handle duplicate detection', () => {
+ const existing: Subscription[] = [
+ {
+ id: '1',
+ name: 'Netflix',
+ category: SubscriptionCategory.STREAMING,
+ price: 10,
+ currency: 'USD',
+ billingCycle: BillingCycle.MONTHLY,
+ nextBillingDate: new Date('2026-05-01'),
+ isActive: true,
+ notificationsEnabled: true,
+ isCryptoEnabled: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ];
+
+ const data = {
+ subscriptions: [
+ {
+ name: 'Netflix',
+ category: 'streaming',
+ price: 15.99,
+ currency: 'USD',
+ billingCycle: 'monthly',
+ nextBillingDate: '2026-05-01',
+ },
+ ],
+ mode: 'create' as ImportMode,
+ };
+
+ const result = processImport(data, existing);
+ expect(result.imported).toBe(0);
+ expect(result.failed).toBe(1);
+ });
+ });
+
+ describe('Format Detection', () => {
+ it('should detect JSON format', () => {
+ expect(detectFormat('[{"name": "test"}]')).toBe('json');
+ expect(detectFormat('{"name": "test"}')).toBe('json');
+ });
+
+ it('should detect CSV format', () => {
+ expect(detectFormat('name,description,price\nTest,Test desc,10')).toBe('csv');
+ });
+
+ it('should return unknown for invalid format', () => {
+ expect(detectFormat('invalid data')).toBe('unknown');
+ });
+ });
+
+ describe('Templates', () => {
+ it('should generate valid CSV template', () => {
+ const template = getCSVTemplate();
+ const result = parseCSV(template);
+ expect(result.length).toBeGreaterThan(0);
+ expect(result[0].name).toBe('Netflix');
+ });
+
+ it('should generate valid JSON template', () => {
+ const template = getJSONTemplate();
+ const result = parseJSON(template);
+ expect(result.length).toBeGreaterThan(0);
+ expect(result[0].name).toBe('Netflix');
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/utils/importExport.ts b/src/utils/importExport.ts
new file mode 100644
index 0000000..dac8d62
--- /dev/null
+++ b/src/utils/importExport.ts
@@ -0,0 +1,761 @@
+/**
+ * Import/Export Utilities for Subscriptions
+ * Supports CSV import with column mapping and JSON export
+ */
+
+import { Subscription, SubscriptionCategory, BillingCycle } from '../types/subscription';
+
+// ============================================
+// Types
+// ============================================
+
+export interface ImportData {
+ subscriptions: SubscriptionInput[];
+ mode: ImportMode;
+}
+
+export interface SubscriptionInput {
+ id?: string;
+ name: string;
+ description?: string;
+ category: string;
+ price: number;
+ currency: string;
+ billingCycle: string;
+ nextBillingDate: string;
+ isActive?: boolean;
+ notificationsEnabled?: boolean;
+ isCryptoEnabled?: boolean;
+ cryptoToken?: string;
+ cryptoAmount?: number;
+}
+
+export type ImportMode = 'create' | 'upsert' | 'replace';
+
+export interface ImportResult {
+ success: boolean;
+ imported: number;
+ updated: number;
+ failed: number;
+ errors: ImportError[];
+ warnings: ImportWarning[];
+}
+
+export interface ImportError {
+ row: number;
+ field: string;
+ message: string;
+ value?: string;
+}
+
+export interface ImportWarning {
+ row: number;
+ field: string;
+ message: string;
+ value?: string;
+}
+
+export interface ValidationResult {
+ isValid: boolean;
+ errors: ImportError[];
+ warnings: ImportWarning[];
+ validRows: SubscriptionInput[];
+}
+
+export interface ExportData {
+ version: string;
+ exportedAt: string;
+ subscriptionCount: number;
+ subscriptions: Subscription[];
+}
+
+export interface ColumnMapping {
+ csvColumn: string;
+ fieldName: keyof SubscriptionInput;
+ required: boolean;
+ transform?: (value: string) => unknown;
+}
+
+export interface ImportHistoryEntry {
+ id: string;
+ timestamp: string;
+ fileName: string;
+ mode: ImportMode;
+ totalRows: number;
+ imported: number;
+ updated: number;
+ failed: number;
+ status: 'success' | 'partial' | 'failed';
+}
+
+// ============================================
+// Constants
+// ============================================
+
+export const CSV_COLUMN_MAPPING: ColumnMapping[] = [
+ { csvColumn: 'name', fieldName: 'name', required: true },
+ { csvColumn: 'description', fieldName: 'description', required: false },
+ { csvColumn: 'category', fieldName: 'category', required: true },
+ { csvColumn: 'price', fieldName: 'price', required: true, transform: parseFloat },
+ { csvColumn: 'currency', fieldName: 'currency', required: false },
+ { csvColumn: 'billingCycle', fieldName: 'billingCycle', required: true },
+ { csvColumn: 'nextBillingDate', fieldName: 'nextBillingDate', required: true },
+ { csvColumn: 'isActive', fieldName: 'isActive', required: false, transform: parseBoolean },
+ { csvColumn: 'notificationsEnabled', fieldName: 'notificationsEnabled', required: false, transform: parseBoolean },
+ { csvColumn: 'isCryptoEnabled', fieldName: 'isCryptoEnabled', required: false, transform: parseBoolean },
+ { csvColumn: 'cryptoToken', fieldName: 'cryptoToken', required: false },
+ { csvColumn: 'cryptoAmount', fieldName: 'cryptoAmount', required: false, transform: parseFloat },
+];
+
+export const VALID_CATEGORIES = Object.values(SubscriptionCategory);
+export const VALID_BILLING_CYCLES = Object.values(BillingCycle);
+export const VALID_CURRENCIES = ['USD', 'EUR', 'GBP', 'JPY', 'CAD', 'AUD', 'CHF', 'XLM'];
+
+const EXPORT_VERSION = '1.0.0';
+const HISTORY_KEY = 'subtrackr-import-history';
+const MAX_HISTORY_ENTRIES = 50;
+
+// ============================================
+// Helper Functions
+// ============================================
+
+function parseBoolean(value: string): boolean {
+ const lower = value.toLowerCase().trim();
+ return lower === 'true' || lower === '1' || lower === 'yes';
+}
+
+function generateUniqueId(): string {
+ const timestamp = Date.now().toString(36);
+ const randomComponent = Math.random().toString(36).substring(2, 8);
+ return `${timestamp}-${randomComponent}`;
+}
+
+function generateHistoryId(): string {
+ return `import-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
+}
+
+function normalizeCategory(value: string): SubscriptionCategory {
+ const normalized = value.toLowerCase().trim() as SubscriptionCategory;
+ if (VALID_CATEGORIES.includes(normalized)) {
+ return normalized;
+ }
+ // Try to match by partial string
+ for (const cat of VALID_CATEGORIES) {
+ if (cat.includes(normalized) || normalized.includes(cat)) {
+ return cat;
+ }
+ }
+ return SubscriptionCategory.OTHER;
+}
+
+function normalizeBillingCycle(value: string): BillingCycle {
+ const normalized = value.toLowerCase().trim() as BillingCycle;
+ if (VALID_BILLING_CYCLES.includes(normalized)) {
+ return normalized;
+ }
+ // Try common variations
+ const cycleMap: Record = {
+ 'month': BillingCycle.MONTHLY,
+ 'year': BillingCycle.YEARLY,
+ 'week': BillingCycle.WEEKLY,
+ 'custom': BillingCycle.CUSTOM,
+ };
+ for (const [key, cycle] of Object.entries(cycleMap)) {
+ if (normalized.includes(key)) {
+ return cycle;
+ }
+ }
+ return BillingCycle.MONTHLY;
+}
+
+function parseDate(value: string): Date {
+ const parsed = new Date(value);
+ if (!Number.isNaN(parsed.getTime())) {
+ return parsed;
+ }
+ // Try common formats
+ const formats = [
+ /^(\d{4})-(\d{2})-(\d{2})$/, // YYYY-MM-DD
+ /^(\d{2})\/(\d{2})\/(\d{4})$/, // MM/DD/YYYY
+ /^(\d{2})-(\d{2})-(\d{4})$/, // DD-MM-YYYY
+ ];
+
+ for (const format of formats) {
+ const match = value.match(format);
+ if (match) {
+ const date = new Date(value);
+ if (!Number.isNaN(date.getTime())) {
+ return date;
+ }
+ }
+ }
+
+ return new Date(); // Default to current date
+}
+
+// ============================================
+// CSV Parsing
+// ============================================
+
+/**
+ * Parse CSV string into array of subscription objects
+ */
+export function parseCSV(csvContent: string): SubscriptionInput[] {
+ const lines = csvContent.split(/\r?\n/).filter(line => line.trim());
+
+ if (lines.length < 2) {
+ throw new Error('CSV must contain at least a header row and one data row');
+ }
+
+ const headerLine = lines[0];
+ const headers = parseCSVLine(headerLine);
+
+ // Create header to field mapping
+ const headerMap = new Map();
+ headers.forEach((header, index) => {
+ headerMap.set(header.toLowerCase().trim(), index);
+ });
+
+ const subscriptions: SubscriptionInput[] = [];
+
+ for (let i = 1; i < lines.length; i++) {
+ const values = parseCSVLine(lines[i]);
+ if (values.length === 0 || values.every(v => !v.trim())) {
+ continue; // Skip empty rows
+ }
+
+ const subscription: Partial = {};
+
+ for (const mapping of CSV_COLUMN_MAPPING) {
+ const columnIndex = headerMap.get(mapping.csvColumn.toLowerCase());
+ if (columnIndex !== undefined && values[columnIndex]) {
+ const rawValue = values[columnIndex];
+ const value = mapping.transform ?
+ String(mapping.transform(rawValue)) :
+ rawValue;
+
+ (subscription as Record)[mapping.fieldName] = value;
+ }
+ }
+
+ if (subscription.name) {
+ subscriptions.push(subscription as SubscriptionInput);
+ }
+ }
+
+ return subscriptions;
+}
+
+function parseCSVLine(line: string): string[] {
+ const result: string[] = [];
+ let current = '';
+ let inQuotes = false;
+
+ for (let i = 0; i < line.length; i++) {
+ const char = line[i];
+
+ if (char === '"') {
+ if (inQuotes && line[i + 1] === '"') {
+ current += '"';
+ i++;
+ } else {
+ inQuotes = !inQuotes;
+ }
+ } else if (char === ',' && !inQuotes) {
+ result.push(current.trim());
+ current = '';
+ } else {
+ current += char;
+ }
+ }
+
+ result.push(current.trim());
+ return result;
+}
+
+/**
+ * Generate CSV from subscriptions
+ */
+export function generateCSV(subscriptions: Subscription[]): string {
+ const headers = CSV_COLUMN_MAPPING.map(m => m.csvColumn);
+ const rows = subscriptions.map(sub => {
+ return CSV_COLUMN_MAPPING.map(mapping => {
+ const value = sub[mapping.fieldName as keyof Subscription];
+ if (value === undefined || value === null) {
+ return '';
+ }
+ if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
+ return `"${value.replace(/"/g, '""')}"`;
+ }
+ if (value instanceof Date) {
+ return value.toISOString().split('T')[0];
+ }
+ return String(value);
+ }).join(',');
+ });
+
+ return [headers.join(','), ...rows].join('\n');
+}
+
+// ============================================
+// JSON Export
+// ============================================
+
+/**
+ * Export subscriptions to JSON format
+ */
+export function exportToJSON(subscriptions: Subscription[]): string {
+ const exportData: ExportData = {
+ version: EXPORT_VERSION,
+ exportedAt: new Date().toISOString(),
+ subscriptionCount: subscriptions.length,
+ subscriptions: subscriptions.map(sub => ({
+ ...sub,
+ nextBillingDate: sub.nextBillingDate instanceof Date ?
+ sub.nextBillingDate :
+ new Date(sub.nextBillingDate as unknown as string),
+ createdAt: sub.createdAt instanceof Date ?
+ sub.createdAt :
+ new Date(sub.createdAt as unknown as string),
+ updatedAt: sub.updatedAt instanceof Date ?
+ sub.updatedAt :
+ new Date(sub.updatedAt as unknown as string),
+ })),
+ };
+
+ return JSON.stringify(exportData, null, 2);
+}
+
+/**
+ * Parse JSON import data
+ */
+export function parseJSON(jsonContent: string): SubscriptionInput[] {
+ const data = JSON.parse(jsonContent);
+
+ // Handle both direct array and wrapped export format
+ let subscriptions: Subscription[] | SubscriptionInput[];
+
+ if (Array.isArray(data)) {
+ subscriptions = data;
+ } else if (data.subscriptions && Array.isArray(data.subscriptions)) {
+ subscriptions = data.subscriptions;
+ } else {
+ throw new Error('Invalid JSON format: expected array or export object');
+ }
+
+ return subscriptions.map(sub => ({
+ id: sub.id,
+ name: sub.name,
+ description: sub.description,
+ category: typeof sub.category === 'string' ? sub.category : SubscriptionCategory.OTHER,
+ price: Number(sub.price) || 0,
+ currency: sub.currency || 'USD',
+ billingCycle: typeof sub.billingCycle === 'string' ? sub.billingCycle : BillingCycle.MONTHLY,
+ nextBillingDate: sub.nextBillingDate ? new Date(sub.nextBillingDate).toISOString() : new Date().toISOString(),
+ isActive: sub.isActive,
+ notificationsEnabled: sub.notificationsEnabled,
+ isCryptoEnabled: sub.isCryptoEnabled,
+ cryptoToken: sub.cryptoToken,
+ cryptoAmount: sub.cryptoAmount,
+ }));
+}
+
+// ============================================
+// Validation
+// ============================================
+
+/**
+ * Validate import data
+ */
+export function validateImport(data: ImportData): ValidationResult {
+ const errors: ImportError[] = [];
+ const warnings: ImportWarning[] = [];
+ const validRows: SubscriptionInput[] = [];
+
+ data.subscriptions.forEach((subscription, index) => {
+ const rowNum = index + 1;
+
+ // Required field validation
+ if (!subscription.name || subscription.name.trim() === '') {
+ errors.push({
+ row: rowNum,
+ field: 'name',
+ message: 'Name is required',
+ value: subscription.name,
+ });
+ return;
+ }
+
+ // Category validation
+ if (!subscription.category) {
+ errors.push({
+ row: rowNum,
+ field: 'category',
+ message: 'Category is required',
+ value: subscription.category,
+ });
+ } else if (!VALID_CATEGORIES.includes(subscription.category.toLowerCase() as SubscriptionCategory)) {
+ warnings.push({
+ row: rowNum,
+ field: 'category',
+ message: `Invalid category "${subscription.category}", defaulting to "other"`,
+ value: subscription.category,
+ });
+ }
+
+ // Price validation
+ if (subscription.price === undefined || subscription.price === null) {
+ errors.push({
+ row: rowNum,
+ field: 'price',
+ message: 'Price is required',
+ value: String(subscription.price),
+ });
+ return;
+ }
+
+ if (isNaN(subscription.price) || subscription.price < 0) {
+ errors.push({
+ row: rowNum,
+ field: 'price',
+ message: 'Price must be a valid positive number',
+ value: String(subscription.price),
+ });
+ return;
+ }
+
+ // Billing cycle validation
+ if (!subscription.billingCycle) {
+ errors.push({
+ row: rowNum,
+ field: 'billingCycle',
+ message: 'Billing cycle is required',
+ value: subscription.billingCycle,
+ });
+ } else if (!VALID_BILLING_CYCLES.includes(subscription.billingCycle.toLowerCase() as BillingCycle)) {
+ warnings.push({
+ row: rowNum,
+ field: 'billingCycle',
+ message: `Invalid billing cycle "${subscription.billingCycle}", defaulting to "monthly"`,
+ value: subscription.billingCycle,
+ });
+ }
+
+ // Currency validation
+ if (subscription.currency && !VALID_CURRENCIES.includes(subscription.currency.toUpperCase())) {
+ warnings.push({
+ row: rowNum,
+ field: 'currency',
+ message: `Non-standard currency "${subscription.currency}"`,
+ value: subscription.currency,
+ });
+ }
+
+ // Date validation
+ if (subscription.nextBillingDate) {
+ const date = new Date(subscription.nextBillingDate);
+ if (Number.isNaN(date.getTime())) {
+ errors.push({
+ row: rowNum,
+ field: 'nextBillingDate',
+ message: 'Invalid date format',
+ value: subscription.nextBillingDate,
+ });
+ return;
+ }
+ }
+
+ // Crypto validation
+ if (subscription.isCryptoEnabled && !subscription.cryptoToken) {
+ warnings.push({
+ row: rowNum,
+ field: 'cryptoToken',
+ message: 'Crypto enabled but no token specified',
+ value: subscription.cryptoToken,
+ });
+ }
+
+ validRows.push(subscription);
+ });
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ warnings,
+ validRows,
+ };
+}
+
+// ============================================
+// Import Processing
+// ============================================
+
+/**
+ * Process import with validation
+ */
+export function processImport(
+ data: ImportData,
+ existingSubscriptions: Subscription[]
+): ImportResult {
+ const validation = validateImport(data);
+
+ if (validation.validRows.length === 0 && validation.errors.length > 0) {
+ return {
+ success: false,
+ imported: 0,
+ updated: 0,
+ failed: data.subscriptions.length,
+ errors: validation.errors,
+ warnings: validation.warnings,
+ };
+ }
+
+ let imported = 0;
+ let updated = 0;
+ const errors: ImportError[] = [...validation.errors];
+ const warnings: ImportWarning[] = [...validation.warnings];
+
+ // Create lookup for existing subscriptions
+ const existingByName = new Map();
+ const existingById = new Map();
+
+ existingSubscriptions.forEach(sub => {
+ existingByName.set(sub.name.toLowerCase(), sub);
+ if (sub.id) {
+ existingById.set(sub.id, sub);
+ }
+ });
+
+ const processedSubscriptions: Subscription[] = [];
+
+ validation.validRows.forEach((input, index) => {
+ const rowNum = index + 1;
+ const now = new Date();
+
+ try {
+ // Check for duplicates
+ const existingByNameMatch = existingByName.get(input.name.toLowerCase());
+ const existingByIdMatch = input.id ? existingById.get(input.id) : null;
+ const isUpdate = (existingByNameMatch || existingByIdMatch) && data.mode === 'upsert';
+ const isReplace = data.mode === 'replace';
+
+ if (isUpdate || isReplace) {
+ // Update existing
+ const existing = existingByIdMatch || existingByNameMatch;
+ if (existing) {
+ const updated: Subscription = {
+ ...existing,
+ name: input.name,
+ description: input.description,
+ category: normalizeCategory(input.category),
+ price: input.price,
+ currency: input.currency?.toUpperCase() || 'USD',
+ billingCycle: normalizeBillingCycle(input.billingCycle),
+ nextBillingDate: parseDate(input.nextBillingDate),
+ isActive: input.isActive ?? existing.isActive,
+ notificationsEnabled: input.notificationsEnabled ?? existing.notificationsEnabled,
+ isCryptoEnabled: input.isCryptoEnabled ?? existing.isCryptoEnabled,
+ cryptoToken: input.cryptoToken ?? existing.cryptoToken,
+ cryptoAmount: input.cryptoAmount ?? existing.cryptoAmount,
+ updatedAt: now,
+ };
+ processedSubscriptions.push(updated);
+ updated++;
+ }
+ } else {
+ // Create new
+ const newSubscription: Subscription = {
+ id: input.id || generateUniqueId(),
+ name: input.name,
+ description: input.description,
+ category: normalizeCategory(input.category),
+ price: input.price,
+ currency: input.currency?.toUpperCase() || 'USD',
+ billingCycle: normalizeBillingCycle(input.billingCycle),
+ nextBillingDate: parseDate(input.nextBillingDate),
+ isActive: input.isActive ?? true,
+ notificationsEnabled: input.notificationsEnabled ?? true,
+ isCryptoEnabled: input.isCryptoEnabled ?? false,
+ cryptoToken: input.cryptoToken,
+ cryptoAmount: input.cryptoAmount,
+ createdAt: now,
+ updatedAt: now,
+ };
+ processedSubscriptions.push(newSubscription);
+ imported++;
+ }
+ } catch (err) {
+ errors.push({
+ row: rowNum,
+ field: 'general',
+ message: err instanceof Error ? err.message : 'Unknown error',
+ });
+ }
+ });
+
+ return {
+ success: errors.length === 0,
+ imported,
+ updated,
+ failed: data.subscriptions.length - imported - updated,
+ errors,
+ warnings,
+ };
+}
+
+// ============================================
+// Import History
+// ============================================
+
+/**
+ * Get import history from storage
+ */
+export async function getImportHistory(): Promise {
+ try {
+ const AsyncStorage = require('@react-native-async-storage/async-storage').default;
+ const historyJson = await AsyncStorage.getItem(HISTORY_KEY);
+ if (historyJson) {
+ return JSON.parse(historyJson);
+ }
+ } catch (error) {
+ console.error('Failed to get import history:', error);
+ }
+ return [];
+}
+
+/**
+ * Save import history entry
+ */
+export async function saveImportHistory(entry: ImportHistoryEntry): Promise {
+ try {
+ const AsyncStorage = require('@react-native-async-storage/async-storage').default;
+ const history = await getImportHistory();
+
+ history.unshift(entry);
+
+ // Keep only last N entries
+ const trimmedHistory = history.slice(0, MAX_HISTORY_ENTRIES);
+
+ await AsyncStorage.setItem(HISTORY_KEY, JSON.stringify(trimmedHistory));
+ } catch (error) {
+ console.error('Failed to save import history:', error);
+ }
+}
+
+/**
+ * Create and save import history entry
+ */
+export async function recordImport(
+ fileName: string,
+ mode: ImportMode,
+ totalRows: number,
+ result: ImportResult
+): Promise {
+ const entry: ImportHistoryEntry = {
+ id: generateHistoryId(),
+ timestamp: new Date().toISOString(),
+ fileName,
+ mode,
+ totalRows,
+ imported: result.imported,
+ updated: result.updated,
+ failed: result.failed,
+ status: result.success ? 'success' : result.failed === totalRows ? 'failed' : 'partial',
+ };
+
+ await saveImportHistory(entry);
+}
+
+/**
+ * Clear import history
+ */
+export async function clearImportHistory(): Promise {
+ try {
+ const AsyncStorage = require('@react-native-async-storage/async-storage').default;
+ await AsyncStorage.removeItem(HISTORY_KEY);
+ } catch (error) {
+ console.error('Failed to clear import history:', error);
+ }
+}
+
+// ============================================
+// Utility Functions
+// ============================================
+
+/**
+ * Detect file format from content
+ */
+export function detectFormat(content: string): 'csv' | 'json' | 'unknown' {
+ const trimmed = content.trim();
+
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
+ try {
+ JSON.parse(trimmed);
+ return 'json';
+ } catch {
+ // Not valid JSON
+ }
+ }
+
+ // Check for CSV indicators
+ if (trimmed.includes(',') && trimmed.split('\n')[0].split(',').length > 1) {
+ return 'csv';
+ }
+
+ return 'unknown';
+}
+
+/**
+ * Get sample CSV template
+ */
+export function getCSVTemplate(): string {
+ return `name,description,category,price,currency,billingCycle,nextBillingDate,isActive,notificationsEnabled,isCryptoEnabled,cryptoToken,cryptoAmount
+Netflix,Streaming service,streaming,15.99,USD,monthly,2026-05-01,true,true,false,,
+Spotify,Music streaming,streaming,9.99,USD,monthly,2026-05-15,true,true,false,,
+Adobe Creative Cloud,Design software,software,54.99,USD,monthly,2026-05-20,true,true,true,XLM,0.5`;
+}
+
+/**
+ * Get sample JSON template
+ */
+export function getJSONTemplate(): string {
+ const template: ExportData = {
+ version: EXPORT_VERSION,
+ exportedAt: new Date().toISOString(),
+ subscriptionCount: 2,
+ subscriptions: [
+ {
+ id: 'sample-1',
+ name: 'Netflix',
+ description: 'Streaming service',
+ category: SubscriptionCategory.STREAMING,
+ price: 15.99,
+ currency: 'USD',
+ billingCycle: BillingCycle.MONTHLY,
+ nextBillingDate: new Date('2026-05-01'),
+ isActive: true,
+ notificationsEnabled: true,
+ isCryptoEnabled: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ {
+ id: 'sample-2',
+ name: 'Spotify',
+ description: 'Music streaming',
+ category: SubscriptionCategory.STREAMING,
+ price: 9.99,
+ currency: 'USD',
+ billingCycle: BillingCycle.MONTHLY,
+ nextBillingDate: new Date('2026-05-15'),
+ isActive: true,
+ notificationsEnabled: true,
+ isCryptoEnabled: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ };
+
+ return JSON.stringify(template, null, 2);
+}
\ No newline at end of file