feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
328
apps/mobile-rn/src/screens/DataPortabilityScreen.tsx
Normal file
328
apps/mobile-rn/src/screens/DataPortabilityScreen.tsx
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue