d3ro-voice/apps/mobile-rn/src/screens/DataPortabilityScreen.tsx
2026-08-29 18:33:45 +09:00

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,
},
});
}