328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { ActivityIndicator, ScrollView, StyleSheet, View } from 'react-native';
|
|
import { useNavigation } from '@react-navigation/native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useI18n, type TranslationKey } from '@d3ro/i18n';
|
|
import { useAuth } from '../lib/auth-context';
|
|
import { useMobilePreferences } from '../lib/preferences-context';
|
|
import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components';
|
|
import {
|
|
exportAccountArchive,
|
|
importAccountArchive,
|
|
pickPortabilityFile,
|
|
PortabilityError,
|
|
prepareAccountJsonFile,
|
|
prepareAccountSummaryFile,
|
|
prepareDictionaryFile,
|
|
prepareHistoryCsvFile,
|
|
sharePreparedPortableFile,
|
|
type PreparedPortableFile,
|
|
} from '../features/data-portability';
|
|
|
|
type ExportKind =
|
|
| 'account-json'
|
|
| 'dictionary-csv'
|
|
| 'dictionary-txt'
|
|
| 'history-csv'
|
|
| 'summary-txt';
|
|
type BusyAction = ExportKind | 'import' | null;
|
|
|
|
const ERROR_KEYS: Record<PortabilityError['code'], TranslationKey> = {
|
|
auth: 'mobile.portability.error.auth',
|
|
cancelled: 'mobile.portability.error.cancelled',
|
|
checksum: 'mobile.portability.error.checksum',
|
|
conflict: 'mobile.portability.error.conflict',
|
|
duplicate: 'mobile.portability.importDuplicate',
|
|
'file-read': 'mobile.portability.error.file',
|
|
'file-share': 'mobile.portability.error.file',
|
|
format: 'mobile.portability.error.format',
|
|
network: 'mobile.portability.error.network',
|
|
owner: 'mobile.portability.error.owner',
|
|
server: 'mobile.portability.error.server',
|
|
size: 'mobile.portability.error.size',
|
|
validation: 'mobile.portability.error.format',
|
|
};
|
|
|
|
export default function DataPortabilityScreen(): React.ReactElement {
|
|
const insets = useSafeAreaInsets();
|
|
const navigation = useNavigation<any>();
|
|
const { t } = useI18n();
|
|
const { user } = useAuth();
|
|
const { palette } = useMobilePreferences();
|
|
const styles = useMemo(() => createStyles(palette), [palette]);
|
|
const [busy, setBusy] = useState<BusyAction>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [result, setResult] = useState<string | null>(null);
|
|
|
|
async function runExport(kind: ExportKind): Promise<void> {
|
|
if (user === null || busy !== null) return;
|
|
setBusy(kind);
|
|
setError(null);
|
|
setResult(null);
|
|
let file: PreparedPortableFile | null = null;
|
|
try {
|
|
const exported = await exportAccountArchive(user.id);
|
|
if (kind === 'account-json')
|
|
file = await prepareAccountJsonFile(exported.serialized);
|
|
else if (kind === 'dictionary-csv')
|
|
file = await prepareDictionaryFile(exported.payload, 'csv');
|
|
else if (kind === 'dictionary-txt')
|
|
file = await prepareDictionaryFile(exported.payload, 'txt');
|
|
else if (kind === 'history-csv')
|
|
file = await prepareHistoryCsvFile(exported.payload);
|
|
else file = await prepareAccountSummaryFile(exported.payload);
|
|
|
|
await sharePreparedPortableFile(
|
|
file,
|
|
t('mobile.portability.exportTitle'),
|
|
);
|
|
setResult(
|
|
t('mobile.portability.exportSuccess', { count: exported.rowCount }),
|
|
);
|
|
// The OS may read a content URI after the chooser Promise resolves. The
|
|
// export remains in the app cache for OS-managed cleanup instead of being
|
|
// deleted while a receiving app is still opening it.
|
|
file = null;
|
|
} catch (caught) {
|
|
setError(portabilityErrorMessage(caught, t));
|
|
} finally {
|
|
if (file !== null) await file.dispose().catch(() => undefined);
|
|
setBusy(null);
|
|
}
|
|
}
|
|
|
|
async function runImport(): Promise<void> {
|
|
if (user === null || busy !== null) return;
|
|
setBusy('import');
|
|
setError(null);
|
|
setResult(null);
|
|
let selected: Awaited<ReturnType<typeof pickPortabilityFile>> | null = null;
|
|
try {
|
|
selected = await pickPortabilityFile();
|
|
const imported = await importAccountArchive(
|
|
user.id,
|
|
selected.content,
|
|
selected.fileName,
|
|
selected.mimeType,
|
|
);
|
|
setResult(
|
|
imported.result.status === 'duplicate'
|
|
? t('mobile.portability.importDuplicate')
|
|
: t('mobile.portability.importSuccess', {
|
|
imported: imported.result.imported_rows,
|
|
skipped: imported.result.skipped_rows,
|
|
}),
|
|
);
|
|
} catch (caught) {
|
|
if (
|
|
!(caught instanceof PortabilityError && caught.code === 'cancelled')
|
|
) {
|
|
setError(portabilityErrorMessage(caught, t));
|
|
}
|
|
} finally {
|
|
if (selected !== null) await selected.dispose().catch(() => undefined);
|
|
setBusy(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<View style={styles.container} testID="data-portability-screen">
|
|
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
|
|
<ThemeButton
|
|
label="←"
|
|
variant="quiet"
|
|
onPress={() => navigation.goBack()}
|
|
accessibilityLabel={t('common.back')}
|
|
style={styles.backButton}
|
|
testID="data-portability-back"
|
|
/>
|
|
<View style={styles.headerCopy}>
|
|
<ThemeText variant="title" accessibilityRole="header">
|
|
{t('mobile.portability.title')}
|
|
</ThemeText>
|
|
<ThemeText color="muted" style={styles.headerDescription}>
|
|
{t('mobile.portability.description')}
|
|
</ThemeText>
|
|
</View>
|
|
</View>
|
|
|
|
<ScrollView
|
|
contentContainerStyle={[
|
|
styles.content,
|
|
{ paddingBottom: insets.bottom + 28 },
|
|
]}
|
|
keyboardShouldPersistTaps="handled"
|
|
>
|
|
<ThemeCard
|
|
style={styles.securityCard}
|
|
testID="data-portability-security"
|
|
>
|
|
<ThemeText variant="label" color="accent">
|
|
{t('mobile.portability.securityTitle')}
|
|
</ThemeText>
|
|
<ThemeText color="secondary" style={styles.readingText}>
|
|
{t('mobile.portability.securityBody')}
|
|
</ThemeText>
|
|
<ThemeText variant="small" color="muted">
|
|
{t('mobile.portability.exclusions')}
|
|
</ThemeText>
|
|
</ThemeCard>
|
|
|
|
{user === null ? (
|
|
<ThemeCard testID="data-portability-login-required">
|
|
<ThemeText color="danger">
|
|
{t('mobile.portability.loginRequired')}
|
|
</ThemeText>
|
|
</ThemeCard>
|
|
) : (
|
|
<>
|
|
<ThemeCard
|
|
style={styles.section}
|
|
testID="data-portability-export-section"
|
|
>
|
|
<ThemeText variant="label" color="muted">
|
|
{t('mobile.portability.exportTitle')}
|
|
</ThemeText>
|
|
<ThemeButton
|
|
label={t('mobile.portability.exportJson')}
|
|
disabled={busy !== null}
|
|
onPress={() => {
|
|
void runExport('account-json');
|
|
}}
|
|
testID="data-portability-export-json"
|
|
/>
|
|
<View style={styles.buttonGrid}>
|
|
<ThemeButton
|
|
label={t('mobile.portability.exportDictionaryCsv')}
|
|
variant="secondary"
|
|
disabled={busy !== null}
|
|
onPress={() => {
|
|
void runExport('dictionary-csv');
|
|
}}
|
|
style={styles.gridButton}
|
|
testID="data-portability-export-dictionary-csv"
|
|
/>
|
|
<ThemeButton
|
|
label={t('mobile.portability.exportDictionaryTxt')}
|
|
variant="secondary"
|
|
disabled={busy !== null}
|
|
onPress={() => {
|
|
void runExport('dictionary-txt');
|
|
}}
|
|
style={styles.gridButton}
|
|
testID="data-portability-export-dictionary-txt"
|
|
/>
|
|
</View>
|
|
<View style={styles.buttonGrid}>
|
|
<ThemeButton
|
|
label={t('mobile.portability.exportHistoryCsv')}
|
|
variant="secondary"
|
|
disabled={busy !== null}
|
|
onPress={() => {
|
|
void runExport('history-csv');
|
|
}}
|
|
style={styles.gridButton}
|
|
testID="data-portability-export-history-csv"
|
|
/>
|
|
<ThemeButton
|
|
label={t('mobile.portability.exportSummaryTxt')}
|
|
variant="secondary"
|
|
disabled={busy !== null}
|
|
onPress={() => {
|
|
void runExport('summary-txt');
|
|
}}
|
|
style={styles.gridButton}
|
|
testID="data-portability-export-summary-txt"
|
|
/>
|
|
</View>
|
|
</ThemeCard>
|
|
|
|
<ThemeCard
|
|
style={styles.section}
|
|
testID="data-portability-import-section"
|
|
>
|
|
<ThemeText variant="label" color="muted">
|
|
{t('mobile.portability.importTitle')}
|
|
</ThemeText>
|
|
<ThemeText color="secondary" style={styles.readingText}>
|
|
{t('mobile.portability.importBody')}
|
|
</ThemeText>
|
|
<ThemeButton
|
|
label={t('mobile.portability.importFile')}
|
|
disabled={busy !== null}
|
|
onPress={() => {
|
|
void runImport();
|
|
}}
|
|
testID="data-portability-import-file"
|
|
/>
|
|
</ThemeCard>
|
|
</>
|
|
)}
|
|
|
|
{busy !== null && (
|
|
<ThemeCard
|
|
inset
|
|
style={styles.statusRow}
|
|
testID="data-portability-busy"
|
|
>
|
|
<ActivityIndicator color={palette.accent.main} />
|
|
<ThemeText color="muted">
|
|
{t('mobile.portability.working')}
|
|
</ThemeText>
|
|
</ThemeCard>
|
|
)}
|
|
{result !== null && (
|
|
<ThemeCard inset testID="data-portability-result">
|
|
<ThemeText color="success">{result}</ThemeText>
|
|
</ThemeCard>
|
|
)}
|
|
{error !== null && (
|
|
<ThemeCard inset testID="data-portability-error">
|
|
<ThemeText color="danger">{error}</ThemeText>
|
|
</ThemeCard>
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
type Translate = ReturnType<typeof useI18n>['t'];
|
|
|
|
function portabilityErrorMessage(error: unknown, t: Translate): string {
|
|
if (!(error instanceof PortabilityError))
|
|
return t('mobile.portability.error.server');
|
|
return t(ERROR_KEYS[error.code]);
|
|
}
|
|
|
|
type Palette = ReturnType<typeof useMobilePreferences>['palette'];
|
|
|
|
function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|
return StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: palette.bg.app },
|
|
header: {
|
|
minHeight: 112,
|
|
paddingHorizontal: 14,
|
|
paddingBottom: 14,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: palette.border.subtle,
|
|
},
|
|
backButton: { width: 48, paddingHorizontal: 0 },
|
|
headerCopy: { flex: 1, gap: 5 },
|
|
headerDescription: { lineHeight: 20 },
|
|
content: { padding: 18, gap: 14 },
|
|
securityCard: { gap: 10, borderColor: palette.accent.dim },
|
|
section: { gap: 12 },
|
|
readingText: { lineHeight: 22 },
|
|
buttonGrid: { flexDirection: 'row', gap: 10 },
|
|
gridButton: { flex: 1 },
|
|
statusRow: {
|
|
minHeight: 56,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
},
|
|
});
|
|
}
|