From 638a6c7b66eb1d0307c740e80f54973eb2aa3496 Mon Sep 17 00:00:00 2001 From: Porfolio1 Date: Sun, 26 Apr 2026 19:07:30 +0100 Subject: [PATCH] feat: implement subscription import/export with CSV and JSON support - Add importExport.ts utility with CSV parsing, JSON export/import - Implement CSV import with column mapping for all subscription fields - Add JSON export for all subscription data with metadata - Implement import validation with error reporting - Add import preview before commit with confirmation dialog - Support incremental import (upsert) mode - Add import history tracking with AsyncStorage persistence - Create ImportScreen.tsx with mode selection and validation UI - Create ExportScreen.tsx with format selection and preview - Add navigation routes for Import and Export screens - Add links in Settings screen for data management - Write comprehensive tests for import/export functionality Implements: subscription import/export feature --- src/navigation/AppNavigator.tsx | 12 + src/navigation/types.ts | 2 + src/screens/ExportScreen.tsx | 511 +++++++++++++++ src/screens/ImportScreen.tsx | 701 +++++++++++++++++++++ src/screens/SettingsScreen.tsx | 21 + src/utils/__tests__/importExport.test.ts | 391 ++++++++++++ src/utils/importExport.ts | 761 +++++++++++++++++++++++ 7 files changed, 2399 insertions(+) create mode 100644 src/screens/ExportScreen.tsx create mode 100644 src/screens/ImportScreen.tsx create mode 100644 src/utils/__tests__/importExport.test.ts create mode 100644 src/utils/importExport.ts diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index b648ac8f..1c377a9d 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -14,6 +14,8 @@ import GDPRSettingsScreen from '../screens/GDPRSettingsScreen'; import LanguageSettingsScreen from '../screens/LanguageSettingsScreen'; import SettingsScreen from '../screens/SettingsScreen'; import ErrorDashboardScreen from '../screens/ErrorDashboardScreen'; +import ImportScreen from '../screens/ImportScreen'; +import ExportScreen from '../screens/ExportScreen'; import { colors } from '../utils/constants'; import { RootStackParamList, TabParamList } from './types'; @@ -64,6 +66,16 @@ const SettingsStack = () => ( component={ErrorDashboardScreen} options={{ title: 'Error Dashboard', headerShown: true }} /> + + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index e56dba5f..06fe50c6 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -10,6 +10,8 @@ export type RootStackParamList = { Settings: undefined; LanguageSettings: undefined; ErrorDashboard: undefined; + Import: undefined; + Export: undefined; }; export type TabParamList = { diff --git a/src/screens/ExportScreen.tsx b/src/screens/ExportScreen.tsx new file mode 100644 index 00000000..df35be6d --- /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 = () => ( + +