users can now choose between what to backup

This commit is contained in:
tapframe 2025-10-28 01:02:33 +05:30
parent f13266b1fc
commit a0a138081d
2 changed files with 465 additions and 63 deletions

View file

@ -0,0 +1,99 @@
import { useState, useEffect, useCallback } from 'react';
import { mmkvStorage } from '../services/mmkvStorage';
import { BackupOptions } from '../services/backupService';
import { logger } from '../utils/logger';
interface BackupPreferences {
includeLibrary: boolean;
includeWatchProgress: boolean;
includeAddons: boolean;
includeSettings: boolean;
includeTraktData: boolean;
includeLocalScrapers: boolean;
includeApiKeys: boolean;
includeCatalogSettings: boolean;
includeUserPreferences: boolean;
}
const DEFAULT_PREFERENCES: BackupPreferences = {
includeLibrary: true,
includeWatchProgress: true,
includeAddons: true,
includeSettings: true,
includeTraktData: true,
includeLocalScrapers: true,
includeApiKeys: true,
includeCatalogSettings: true,
includeUserPreferences: true,
};
const STORAGE_KEY = 'backup_preferences';
export const useBackupOptions = () => {
const [preferences, setPreferences] = useState<BackupPreferences>(DEFAULT_PREFERENCES);
const [isLoading, setIsLoading] = useState(true);
// Load preferences from storage
useEffect(() => {
const loadPreferences = async () => {
try {
const stored = await mmkvStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
// Merge with defaults to handle any missing keys
setPreferences({ ...DEFAULT_PREFERENCES, ...parsed });
}
} catch (error) {
logger.error('[useBackupOptions] Failed to load preferences:', error);
setPreferences(DEFAULT_PREFERENCES);
} finally {
setIsLoading(false);
}
};
loadPreferences();
}, []);
// Save preferences to storage
const savePreferences = useCallback(async (newPreferences: BackupPreferences) => {
try {
await mmkvStorage.setItem(STORAGE_KEY, JSON.stringify(newPreferences));
} catch (error) {
logger.error('[useBackupOptions] Failed to save preferences:', error);
}
}, []);
// Update a single preference
const updatePreference = useCallback(
async (key: keyof BackupPreferences, value: boolean) => {
const newPreferences = { ...preferences, [key]: value };
setPreferences(newPreferences);
await savePreferences(newPreferences);
},
[preferences, savePreferences]
);
// Get backup options in the format expected by backupService
const getBackupOptions = useCallback((): BackupOptions => {
return {
includeLibrary: preferences.includeLibrary,
includeWatchProgress: preferences.includeWatchProgress,
includeDownloads: false, // Downloads are never backed up
includeAddons: preferences.includeAddons,
includeSettings: preferences.includeSettings,
includeTraktData: preferences.includeTraktData,
includeLocalScrapers: preferences.includeLocalScrapers,
includeApiKeys: preferences.includeApiKeys,
includeCatalogSettings: preferences.includeCatalogSettings,
includeUserPreferences: preferences.includeUserPreferences,
};
}, [preferences]);
return {
preferences,
isLoading,
updatePreference,
getBackupOptions,
};
};

View file

@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useRef } from 'react';
import {
View,
Text,
@ -9,21 +9,43 @@ import {
SafeAreaView,
StatusBar,
ScrollView,
Switch,
Animated,
Easing,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker';
import * as Sharing from 'expo-sharing';
import * as Updates from 'expo-updates';
import { useNavigation } from '@react-navigation/native';
import { backupService, BackupOptions } from '../services/backupService';
import { backupService } from '../services/backupService';
import { useTheme } from '../contexts/ThemeContext';
import { logger } from '../utils/logger';
import CustomAlert from '../components/CustomAlert';
import { useBackupOptions } from '../hooks/useBackupOptions';
const BackupScreen: React.FC = () => {
const { currentTheme } = useTheme();
const [isLoading, setIsLoading] = useState(false);
const navigation = useNavigation();
const { preferences, updatePreference, getBackupOptions } = useBackupOptions();
// Collapsible sections state
const [expandedSections, setExpandedSections] = useState({
coreData: false,
addonsIntegrations: false,
settingsPreferences: false,
});
// Animated values for each section
const coreDataAnim = useRef(new Animated.Value(0)).current;
const addonsAnim = useRef(new Animated.Value(0)).current;
const settingsAnim = useRef(new Animated.Value(0)).current;
// Chevron rotation animated values
const coreDataChevron = useRef(new Animated.Value(0)).current;
const addonsChevron = useRef(new Animated.Value(0)).current;
const settingsChevron = useRef(new Animated.Value(0)).current;
// Alert state
const [alertVisible, setAlertVisible] = useState(false);
@ -56,6 +78,43 @@ const BackupScreen: React.FC = () => {
}
};
// Toggle section collapse/expand
const toggleSection = useCallback((section: 'coreData' | 'addonsIntegrations' | 'settingsPreferences') => {
const isExpanded = expandedSections[section];
let heightAnim: Animated.Value;
let chevronAnim: Animated.Value;
if (section === 'coreData') {
heightAnim = coreDataAnim;
chevronAnim = coreDataChevron;
} else if (section === 'addonsIntegrations') {
heightAnim = addonsAnim;
chevronAnim = addonsChevron;
} else {
heightAnim = settingsAnim;
chevronAnim = settingsChevron;
}
// Animate height and chevron rotation
Animated.parallel([
Animated.timing(heightAnim, {
toValue: isExpanded ? 0 : 1,
duration: 300,
useNativeDriver: false, // Required for height
easing: Easing.inOut(Easing.ease),
}),
Animated.timing(chevronAnim, {
toValue: isExpanded ? 0 : 1,
duration: 300,
useNativeDriver: true, // Transforms support native driver
easing: Easing.inOut(Easing.ease),
}),
]).start();
setExpandedSections(prev => ({...prev, [section]: !isExpanded}));
}, [expandedSections, coreDataAnim, addonsAnim, settingsAnim, coreDataChevron, addonsChevron, settingsChevron]);
// Create backup
const handleCreateBackup = useCallback(async () => {
try {
@ -64,64 +123,78 @@ const BackupScreen: React.FC = () => {
const preview = await backupService.getBackupPreview();
setIsLoading(false);
// Calculate total without downloads
const totalWithoutDownloads = preview.library + preview.watchProgress + preview.addons + preview.scrapers;
// Filter based on preferences
const items: string[] = [];
let total = 0;
if (preferences.includeLibrary) {
items.push(`Library: ${preview.library} items`);
total += preview.library;
}
if (preferences.includeWatchProgress) {
items.push(`Watch Progress: ${preview.watchProgress} entries`);
total += preview.watchProgress;
}
if (preferences.includeAddons) {
items.push(`Addons: ${preview.addons} installed`);
total += preview.addons;
}
if (preferences.includeLocalScrapers) {
items.push(`Plugins: ${preview.scrapers} configurations`);
total += preview.scrapers;
}
// Check if no items are selected
const message = items.length > 0
? `Backup Contents:\n\n${items.join('\n')}\n\nTotal: ${total} items\n\nThis backup includes your selected app settings, themes, and integration data.`
: `No content selected for backup.\n\nPlease enable at least one option in the Backup Options section above.`;
openAlert(
'Create Backup',
`Backup Contents:\n\n` +
`Library: ${preview.library} items\n` +
`Watch Progress: ${preview.watchProgress} entries\n` +
`Addons: ${preview.addons} installed\n` +
`Plugins: ${preview.scrapers} configurations\n\n` +
`Total: ${totalWithoutDownloads} items\n\n` +
`This backup includes all your app settings, themes, and integration data.`,
[
{ label: 'Cancel', onPress: () => {} },
{
label: 'Create Backup',
onPress: async () => {
try {
setIsLoading(true);
message,
items.length > 0
? [
{ label: 'Cancel', onPress: () => {} },
{
label: 'Create Backup',
onPress: async () => {
try {
setIsLoading(true);
const backupOptions: BackupOptions = {
includeLibrary: true,
includeWatchProgress: true,
includeDownloads: true,
includeAddons: true,
includeSettings: true,
includeTraktData: true,
includeLocalScrapers: true,
};
const backupOptions = getBackupOptions();
const fileUri = await backupService.createBackup(backupOptions);
const fileUri = await backupService.createBackup(backupOptions);
// Share the backup file
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(fileUri, {
mimeType: 'application/json',
dialogTitle: 'Share Nuvio Backup',
});
// Share the backup file
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(fileUri, {
mimeType: 'application/json',
dialogTitle: 'Share Nuvio Backup',
});
}
openAlert(
'Backup Created',
'Your backup has been created and is ready to share.',
[{ label: 'OK', onPress: () => {} }]
);
} catch (error) {
logger.error('[BackupScreen] Failed to create backup:', error);
openAlert(
'Backup Failed',
`Failed to create backup: ${error instanceof Error ? error.message : String(error)}`,
[{ label: 'OK', onPress: () => {} }]
);
} finally {
setIsLoading(false);
}
}
openAlert(
'Backup Created',
'Your backup has been created and is ready to share.',
[{ label: 'OK', onPress: () => {} }]
);
} catch (error) {
logger.error('[BackupScreen] Failed to create backup:', error);
openAlert(
'Backup Failed',
`Failed to create backup: ${error instanceof Error ? error.message : String(error)}`,
[{ label: 'OK', onPress: () => {} }]
);
} finally {
setIsLoading(false);
}
}
}
]
]
: [{ label: 'OK', onPress: () => {} }]
);
} catch (error) {
logger.error('[BackupScreen] Failed to get backup preview:', error);
@ -132,7 +205,7 @@ const BackupScreen: React.FC = () => {
);
setIsLoading(false);
}
}, [openAlert]);
}, [openAlert, preferences, getBackupOptions]);
// Restore backup
const handleRestoreBackup = useCallback(async () => {
@ -162,15 +235,7 @@ const BackupScreen: React.FC = () => {
try {
setIsLoading(true);
const restoreOptions: BackupOptions = {
includeLibrary: true,
includeWatchProgress: true,
includeDownloads: true,
includeAddons: true,
includeSettings: true,
includeTraktData: true,
includeLocalScrapers: true,
};
const restoreOptions = getBackupOptions();
await backupService.restoreBackup(fileUri, restoreOptions);
@ -248,6 +313,181 @@ const BackupScreen: React.FC = () => {
onClose={() => setAlertVisible(false)}
/>
{/* Backup Options Section */}
<View style={[styles.section, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
Backup Options
</Text>
<Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Choose what to include in your backups
</Text>
{/* Core Data Group */}
<TouchableOpacity
style={styles.sectionHeader}
onPress={() => toggleSection('coreData')}
activeOpacity={0.7}
>
<Text style={[styles.groupLabel, { color: currentTheme.colors.highEmphasis }]}>
Core Data
</Text>
<Animated.View
style={{
transform: [{
rotate: coreDataChevron.interpolate({
inputRange: [0, 1],
outputRange: ['180deg', '0deg']
})
}]
}}
>
<MaterialIcons name="expand-more" size={24} color={currentTheme.colors.highEmphasis} />
</Animated.View>
</TouchableOpacity>
<Animated.View
style={{
maxHeight: coreDataAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, 2000]
}),
overflow: 'hidden',
opacity: coreDataAnim,
}}
>
<OptionToggle
label="Library"
description="Your saved movies and TV shows"
value={preferences.includeLibrary}
onValueChange={(v) => updatePreference('includeLibrary', v)}
theme={currentTheme}
/>
<OptionToggle
label="Watch Progress"
description="Continue watching positions"
value={preferences.includeWatchProgress}
onValueChange={(v) => updatePreference('includeWatchProgress', v)}
theme={currentTheme}
/>
</Animated.View>
{/* Addons & Integrations Group */}
<TouchableOpacity
style={styles.sectionHeader}
onPress={() => toggleSection('addonsIntegrations')}
activeOpacity={0.7}
>
<Text style={[styles.groupLabel, { color: currentTheme.colors.highEmphasis }]}>
Addons & Integrations
</Text>
<Animated.View
style={{
transform: [{
rotate: addonsChevron.interpolate({
inputRange: [0, 1],
outputRange: ['180deg', '0deg']
})
}]
}}
>
<MaterialIcons name="expand-more" size={24} color={currentTheme.colors.highEmphasis} />
</Animated.View>
</TouchableOpacity>
<Animated.View
style={{
maxHeight: addonsAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, 2000]
}),
overflow: 'hidden',
opacity: addonsAnim,
}}
>
<OptionToggle
label="Addons"
description="Installed Stremio addons"
value={preferences.includeAddons}
onValueChange={(v) => updatePreference('includeAddons', v)}
theme={currentTheme}
/>
<OptionToggle
label="Plugins"
description="Custom scraper configurations"
value={preferences.includeLocalScrapers}
onValueChange={(v) => updatePreference('includeLocalScrapers', v)}
theme={currentTheme}
/>
<OptionToggle
label="Trakt Integration"
description="Sync data and authentication tokens"
value={preferences.includeTraktData}
onValueChange={(v) => updatePreference('includeTraktData', v)}
theme={currentTheme}
/>
</Animated.View>
{/* Settings & Preferences Group */}
<TouchableOpacity
style={styles.sectionHeader}
onPress={() => toggleSection('settingsPreferences')}
activeOpacity={0.7}
>
<Text style={[styles.groupLabel, { color: currentTheme.colors.highEmphasis }]}>
Settings & Preferences
</Text>
<Animated.View
style={{
transform: [{
rotate: settingsChevron.interpolate({
inputRange: [0, 1],
outputRange: ['180deg', '0deg']
})
}]
}}
>
<MaterialIcons name="expand-more" size={24} color={currentTheme.colors.highEmphasis} />
</Animated.View>
</TouchableOpacity>
<Animated.View
style={{
maxHeight: settingsAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, 2000]
}),
overflow: 'hidden',
opacity: settingsAnim,
}}
>
<OptionToggle
label="App Settings"
description="Theme, preferences, and configurations"
value={preferences.includeSettings}
onValueChange={(v) => updatePreference('includeSettings', v)}
theme={currentTheme}
/>
<OptionToggle
label="User Preferences"
description="Addon order and UI settings"
value={preferences.includeUserPreferences}
onValueChange={(v) => updatePreference('includeUserPreferences', v)}
theme={currentTheme}
/>
<OptionToggle
label="Catalog Settings"
description="Catalog filters and preferences"
value={preferences.includeCatalogSettings}
onValueChange={(v) => updatePreference('includeCatalogSettings', v)}
theme={currentTheme}
/>
<OptionToggle
label="API Keys"
description="MDBList and OpenRouter keys"
value={preferences.includeApiKeys}
onValueChange={(v) => updatePreference('includeApiKeys', v)}
theme={currentTheme}
/>
</Animated.View>
</View>
{/* Backup Actions */}
<View style={[styles.section, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
@ -297,7 +537,7 @@ const BackupScreen: React.FC = () => {
About Backups
</Text>
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
Backups include all your data: library, watch progress, settings, addons, downloads, and plugins{'\n'}
Customize what gets backed up using the toggles above{'\n'}
Backup files are stored locally on your device{'\n'}
Share your backup to transfer data between devices{'\n'}
Restoring will overwrite your current data
@ -309,6 +549,33 @@ const BackupScreen: React.FC = () => {
);
};
interface OptionToggleProps {
label: string;
description: string;
value: boolean;
onValueChange: (value: boolean) => void;
theme: any;
}
const OptionToggle: React.FC<OptionToggleProps> = ({ label, description, value, onValueChange, theme }) => (
<View style={[styles.optionRow, { borderBottomColor: theme.colors.outline }]}>
<View style={styles.optionLeft}>
<Text style={[styles.optionLabel, { color: theme.colors.highEmphasis }]}>
{label}
</Text>
<Text style={[styles.optionDescription, { color: theme.colors.mediumEmphasis }]}>
{description}
</Text>
</View>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: theme.colors.outline, true: theme.colors.primary }}
thumbColor={value ? '#fff' : '#f4f3f4'}
/>
</View>
);
const styles = StyleSheet.create({
container: {
flex: 1,
@ -356,6 +623,42 @@ const styles = StyleSheet.create({
fontWeight: '600',
marginBottom: 16,
},
sectionDescription: {
fontSize: 14,
marginBottom: 16,
},
groupLabel: {
fontSize: 14,
fontWeight: '600',
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 16,
marginBottom: 8,
paddingVertical: 8,
},
optionRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
},
optionLeft: {
flex: 1,
paddingRight: 16,
},
optionLabel: {
fontSize: 15,
fontWeight: '500',
marginBottom: 4,
},
optionDescription: {
fontSize: 13,
lineHeight: 16,
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',