@forthtilliath/expo-backup-ui 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -2
  3. package/dist/ActionButton.d.ts +12 -0
  4. package/dist/ActionButton.d.ts.map +1 -0
  5. package/dist/ActionButton.js +22 -0
  6. package/dist/BackupScreen.d.ts +27 -0
  7. package/dist/BackupScreen.d.ts.map +1 -0
  8. package/dist/BackupScreen.js +29 -0
  9. package/dist/CloudSection.d.ts +6 -0
  10. package/dist/CloudSection.d.ts.map +1 -0
  11. package/dist/CloudSection.js +44 -0
  12. package/dist/EncryptionSection.d.ts +9 -0
  13. package/dist/EncryptionSection.d.ts.map +1 -0
  14. package/dist/EncryptionSection.js +32 -0
  15. package/dist/FileSection.d.ts +6 -0
  16. package/dist/FileSection.d.ts.map +1 -0
  17. package/dist/FileSection.js +38 -0
  18. package/dist/RecoveryCodeModals.d.ts +23 -0
  19. package/dist/RecoveryCodeModals.d.ts.map +1 -0
  20. package/dist/RecoveryCodeModals.js +27 -0
  21. package/dist/dialogs.d.ts +9 -0
  22. package/dist/dialogs.d.ts.map +1 -0
  23. package/dist/dialogs.js +72 -0
  24. package/dist/hooks.d.ts +10 -0
  25. package/dist/hooks.d.ts.map +1 -0
  26. package/dist/hooks.js +13 -0
  27. package/dist/index.d.ts +6 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +4 -0
  30. package/dist/labels.d.ts +67 -0
  31. package/dist/labels.d.ts.map +1 -0
  32. package/dist/labels.en.d.ts +3 -0
  33. package/dist/labels.en.d.ts.map +1 -0
  34. package/dist/labels.en.js +75 -0
  35. package/dist/labels.fr.d.ts +3 -0
  36. package/dist/labels.fr.d.ts.map +1 -0
  37. package/dist/labels.fr.js +75 -0
  38. package/dist/labels.js +14 -0
  39. package/dist/styles.d.ts +39 -0
  40. package/dist/styles.d.ts.map +1 -0
  41. package/dist/styles.js +79 -0
  42. package/dist/useBackupUi.d.ts +35 -0
  43. package/dist/useBackupUi.d.ts.map +1 -0
  44. package/dist/useBackupUi.js +65 -0
  45. package/package.json +51 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Forthtilliath
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,57 @@
1
- # Temporary Holding Version
1
+ # @forthtilliath/expo-backup-ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The "Settings ▸ Backup" screen for [`@forthtilliath/expo-backup`](../expo-backup#readme), shared by every app: automatic Google Drive backup, manual export/import with undo, encryption and recovery code. French by default, English with `locale="en"`, every text and style overridable.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @forthtilliath/expo-backup-ui @forthtilliath/expo-backup
9
+ ```
10
+
11
+ No dependency beyond `react` / `react-native` (no icon library, no other kit).
12
+
13
+ ## Usage
14
+
15
+ ```tsx
16
+ // app/_layout.tsx — once
17
+ import { useAutoBackup } from "@forthtilliath/expo-backup-ui";
18
+ import { backup } from "@/lib/backup";
19
+
20
+ useAutoBackup(backup);
21
+ ```
22
+
23
+ ```tsx
24
+ // app/(tabs)/settings/backup.tsx
25
+ import { BackupScreen } from "@forthtilliath/expo-backup-ui";
26
+
27
+ <ScrollView contentContainerStyle={{ padding: 16 }}>
28
+ <BackupScreen
29
+ manager={backup}
30
+ describeBackup={(data) =>
31
+ `${data.foods.length} aliments, ${data.weighings.length} pesées`
32
+ }
33
+ styles={{
34
+ section: { backgroundColor: colors.surface, borderColor: colors.border },
35
+ sectionTitle: { color: colors.text },
36
+ hint: { color: colors.textMuted },
37
+ button: { backgroundColor: colors.primary },
38
+ }}
39
+ />
40
+ </ScrollView>;
41
+ ```
42
+
43
+ | Prop | |
44
+ | ----------------------- | ------------------------------------------------------------------------------------------------ |
45
+ | `manager` | The app's `createBackupManager(...)` |
46
+ | `cloud` | `false` hides the Google Drive section (no OAuth client yet) |
47
+ | `describeBackup` | Short description of a backup in the confirmations |
48
+ | `onRestored` | Called after a restore, e.g. to reload in-memory stores |
49
+ | `locale` / `dateLocale` | `"fr"` (default) or `"en"`; dates follow the locale unless `dateLocale` is set |
50
+ | `labels` | Any subset of the texts, error messages included (`labels.errors.network`…) |
51
+ | `styles` | Per-slot overrides merged on the defaults (`section`, `button`, `secondaryButton`, `modalCard`…) |
52
+
53
+ Every action runs one at a time (a double tap is ignored) and shows errors in an alert: the package's own in the screen's language, the app's `parse` errors as is.
54
+
55
+ The encryption section only appears when the manager's encryption isn't `off`: a switch in `optional` mode, a status line in `required` mode, and — on demand only, since it is the key — the recovery code, shown selectable so it can be copied into a password manager.
56
+
57
+ Also exported: `useBackupState(manager)` (the manager's state, re-rendering on change) and `errorMessage(error, labels)`.
@@ -0,0 +1,12 @@
1
+ import { type MergedStyles } from "./styles.js";
2
+ export interface ActionButtonProps {
3
+ label: string;
4
+ onPress: () => void;
5
+ variant?: "primary" | "secondary" | "ghost";
6
+ disabled?: boolean;
7
+ /** Shows a spinner instead of the label. */
8
+ busy?: boolean;
9
+ styles: MergedStyles;
10
+ }
11
+ export declare function ActionButton({ label, onPress, variant, disabled, busy, styles, }: ActionButtonProps): import("react").JSX.Element;
12
+ //# sourceMappingURL=ActionButton.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ActionButton.d.ts","sourceRoot":"","sources":["../src/ActionButton.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,YAAY,EAAiB,MAAM,aAAa,CAAC;AAE/D,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,SAAS,GAAG,WAAW,GAAG,OAAO,CAAC;IAC5C,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,4CAA4C;IAC5C,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,YAAY,CAAC;CACtB;AAED,wBAAgB,YAAY,CAAC,EAC3B,KAAK,EACL,OAAO,EACP,OAAmB,EACnB,QAAgB,EAChB,IAAY,EACZ,MAAM,GACP,EAAE,iBAAiB,+BAqCnB"}
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { ActivityIndicator, Pressable, Text } from "react-native";
3
+ import { PRIMARY_COLOR } from "./styles.js";
4
+ export function ActionButton({ label, onPress, variant = "primary", disabled = false, busy = false, styles, }) {
5
+ const variantStyle = variant === "secondary"
6
+ ? styles.secondaryButton
7
+ : variant === "ghost"
8
+ ? styles.ghostButton
9
+ : undefined;
10
+ const variantTextStyle = variant === "secondary"
11
+ ? styles.secondaryButtonText
12
+ : variant === "ghost"
13
+ ? styles.ghostButtonText
14
+ : undefined;
15
+ const spinnerColor = styles.activityIndicatorColor ??
16
+ (variant === "primary" ? "#ffffff" : PRIMARY_COLOR);
17
+ return (_jsx(Pressable, { style: [
18
+ styles.button,
19
+ variantStyle,
20
+ (disabled || busy) && styles.buttonDisabled,
21
+ ], onPress: onPress, disabled: disabled || busy, accessibilityRole: "button", accessibilityLabel: label, accessibilityState: { disabled: disabled || busy, busy }, children: busy ? (_jsx(ActivityIndicator, { color: spinnerColor })) : (_jsx(Text, { style: [styles.buttonText, variantTextStyle], children: label })) }));
22
+ }
@@ -0,0 +1,27 @@
1
+ import type { BackupManager } from "@forthtilliath/expo-backup";
2
+ import { type BackupLocale, type BackupScreenLabelOverrides } from "./labels.js";
3
+ import { type BackupScreenStyles } from "./styles.js";
4
+ export interface BackupScreenProps<T> {
5
+ manager: BackupManager<T>;
6
+ /** Hide the Google Drive section (e.g. an app without an OAuth client yet). */
7
+ cloud?: boolean;
8
+ /** Short description of a backup for the confirmations, e.g. "12 aliments, 340 pesées". */
9
+ describeBackup?: (data: T) => string;
10
+ /** Called after a restore (file or cloud), e.g. to refresh in-memory stores. */
11
+ onRestored?: (data: T) => void;
12
+ /** @defaultValue "fr" */
13
+ locale?: BackupLocale;
14
+ /** Locale of the dates. Defaults to the `locale`'s (`fr-FR` / `en-US`). */
15
+ dateLocale?: string;
16
+ /** Overrides individual built-in texts, whatever the `locale`. */
17
+ labels?: BackupScreenLabelOverrides;
18
+ styles?: BackupScreenStyles;
19
+ }
20
+ /**
21
+ * The whole "Settings ▸ Backup" screen: automatic Google Drive backup, manual
22
+ * file export/import with undo, and — unless the manager's encryption is
23
+ * `off` — the encryption status and recovery code. Render it inside the
24
+ * app's own ScrollView/screen container.
25
+ */
26
+ export declare function BackupScreen<T>({ manager, cloud, describeBackup, onRestored, locale, dateLocale, labels, styles, }: BackupScreenProps<T>): import("react").JSX.Element;
27
+ //# sourceMappingURL=BackupScreen.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BackupScreen.d.ts","sourceRoot":"","sources":["../src/BackupScreen.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAKhE,OAAO,EACL,KAAK,YAAY,EACjB,KAAK,0BAA0B,EAEhC,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,KAAK,kBAAkB,EAAe,MAAM,aAAa,CAAC;AAKnE,MAAM,WAAW,iBAAiB,CAAC,CAAC;IAClC,OAAO,EAAE,aAAa,CAAC,CAAC,CAAC,CAAC;IAC1B,+EAA+E;IAC/E,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,2FAA2F;IAC3F,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,MAAM,CAAC;IACrC,gFAAgF;IAChF,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC;IAC/B,yBAAyB;IACzB,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,2EAA2E;IAC3E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kEAAkE;IAClE,MAAM,CAAC,EAAE,0BAA0B,CAAC;IACpC,MAAM,CAAC,EAAE,kBAAkB,CAAC;CAC7B;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,EAC9B,OAAO,EACP,KAAY,EACZ,cAAc,EACd,UAAU,EACV,MAAa,EACb,UAAU,EACV,MAAM,EACN,MAAM,GACP,EAAE,iBAAiB,CAAC,CAAC,CAAC,+BA2BtB"}
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { View } from "react-native";
3
+ import { CloudSection } from "./CloudSection.js";
4
+ import { EncryptionSection } from "./EncryptionSection.js";
5
+ import { FileSection } from "./FileSection.js";
6
+ import { resolveLabels, } from "./labels.js";
7
+ import { RecoveryCodeInputModal } from "./RecoveryCodeModals.js";
8
+ import { mergeStyles } from "./styles.js";
9
+ import { useBackupUi } from "./useBackupUi.js";
10
+ const DATE_LOCALES = { fr: "fr-FR", en: "en-US" };
11
+ /**
12
+ * The whole "Settings ▸ Backup" screen: automatic Google Drive backup, manual
13
+ * file export/import with undo, and — unless the manager's encryption is
14
+ * `off` — the encryption status and recovery code. Render it inside the
15
+ * app's own ScrollView/screen container.
16
+ */
17
+ export function BackupScreen({ manager, cloud = true, describeBackup, onRestored, locale = "fr", dateLocale, labels, styles, }) {
18
+ const merged = mergeStyles(styles);
19
+ const t = resolveLabels(locale, labels);
20
+ const { ui, recoveryPrompt } = useBackupUi({
21
+ manager,
22
+ t,
23
+ styles: merged,
24
+ describeBackup,
25
+ dateLocale: dateLocale ?? DATE_LOCALES[locale],
26
+ onRestored,
27
+ });
28
+ return (_jsxs(View, { style: merged.container, children: [cloud && _jsx(CloudSection, { ui: ui }), _jsx(FileSection, { ui: ui }), _jsx(EncryptionSection, { ui: ui }), _jsx(RecoveryCodeInputModal, { visible: recoveryPrompt !== null, error: recoveryPrompt?.error, onSubmit: (code) => recoveryPrompt?.resolve(code), onCancel: () => recoveryPrompt?.resolve(null), t: t, styles: merged })] }));
29
+ }
@@ -0,0 +1,6 @@
1
+ import type { BackupUi } from "./useBackupUi.js";
2
+ /** Linking a Google account, then the automatic backup's status and actions. */
3
+ export declare function CloudSection<T>({ ui }: {
4
+ ui: BackupUi<T>;
5
+ }): import("react").JSX.Element;
6
+ //# sourceMappingURL=CloudSection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CloudSection.d.ts","sourceRoot":"","sources":["../src/CloudSection.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAEjD,gFAAgF;AAChF,wBAAgB,YAAY,CAAC,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE;IAAE,EAAE,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAA;CAAE,+BAgG1D"}
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Alert, Text, View } from "react-native";
3
+ import { ActionButton } from "./ActionButton.js";
4
+ import { askConfirmation, askConflictChoice } from "./dialogs.js";
5
+ /** Linking a Google account, then the automatic backup's status and actions. */
6
+ export function CloudSection({ ui }) {
7
+ const { manager, state, t, styles, busy, run } = ui;
8
+ const { cloudAccount } = state;
9
+ const handleConnect = () => run(async () => {
10
+ const result = await manager.connectCloud();
11
+ if (result.status === "cancelled")
12
+ return;
13
+ if (result.status === "conflict") {
14
+ const keep = await askConflictChoice(t, ui.describe(result.remote));
15
+ if (keep === null) {
16
+ await manager.cancelConflict();
17
+ return;
18
+ }
19
+ await manager.resolveConflict(result, keep);
20
+ }
21
+ Alert.alert(t.connectedTitle, t.connectedConfirm(result.email));
22
+ });
23
+ const handleSyncNow = () => run(async () => {
24
+ await manager.syncNow();
25
+ // Errors are kept in the state (shown below) rather than thrown.
26
+ });
27
+ const handleRestore = () => run(async () => {
28
+ const remote = await manager.fetchCloudBackup();
29
+ if (remote === null) {
30
+ Alert.alert(t.noCloudBackupTitle, t.noCloudBackupMessage);
31
+ return;
32
+ }
33
+ await ui.confirmAndRestore(remote, "cloud");
34
+ });
35
+ const handleDisconnect = () => run(async () => {
36
+ const confirmed = await askConfirmation(t, t.disconnectConfirmTitle, t.disconnectConfirmMessage, t.disconnectButton);
37
+ if (confirmed)
38
+ await manager.disconnectCloud();
39
+ });
40
+ if (!cloudAccount) {
41
+ return (_jsxs(View, { style: styles.section, children: [_jsx(Text, { style: styles.sectionTitle, children: t.cloudTitle }), _jsx(Text, { style: styles.hint, children: t.cloudHint }), _jsx(ActionButton, { label: t.connectButton, onPress: () => void handleConnect(), busy: busy, styles: styles })] }));
42
+ }
43
+ return (_jsxs(View, { style: styles.section, children: [_jsx(Text, { style: styles.sectionTitle, children: t.cloudTitle }), _jsx(Text, { style: styles.status, children: cloudAccount.email }), _jsxs(Text, { style: styles.status, children: [t.lastCloudBackup(ui.formatDate(state.lastCloudBackupAt)), state.cloudPending && !state.cloudError ? t.cloudPending : ""] }), state.cloudError && (_jsx(Text, { style: styles.errorText, children: t.errors[state.cloudError] })), _jsx(Text, { style: styles.hint, children: t.autoHint }), _jsx(ActionButton, { label: t.syncNowButton, variant: "secondary", onPress: () => void handleSyncNow(), disabled: busy, styles: styles }), _jsx(ActionButton, { label: t.restoreCloudButton, variant: "secondary", onPress: () => void handleRestore(), disabled: busy, styles: styles }), _jsx(ActionButton, { label: t.disconnectButton, variant: "ghost", onPress: () => void handleDisconnect(), disabled: busy, styles: styles })] }));
44
+ }
@@ -0,0 +1,9 @@
1
+ import type { BackupUi } from "./useBackupUi.js";
2
+ /**
3
+ * Encryption status (toggle in `optional` mode), plus the recovery code: shown
4
+ * only on demand, since it is the key itself.
5
+ */
6
+ export declare function EncryptionSection<T>({ ui }: {
7
+ ui: BackupUi<T>;
8
+ }): import("react").JSX.Element | null;
9
+ //# sourceMappingURL=EncryptionSection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EncryptionSection.d.ts","sourceRoot":"","sources":["../src/EncryptionSection.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAEjD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE;IAAE,EAAE,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAA;CAAE,sCA0E/D"}
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Alert, Pressable, Switch, Text, View } from "react-native";
4
+ import { ActionButton } from "./ActionButton.js";
5
+ import { RecoveryCodeDisplayModal } from "./RecoveryCodeModals.js";
6
+ /**
7
+ * Encryption status (toggle in `optional` mode), plus the recovery code: shown
8
+ * only on demand, since it is the key itself.
9
+ */
10
+ export function EncryptionSection({ ui }) {
11
+ const { manager, state, t, styles, busy, run } = ui;
12
+ const [recoveryCode, setRecoveryCode] = useState(null);
13
+ if (manager.encryptionMode === "off")
14
+ return null;
15
+ const active = manager.isEncryptionActive();
16
+ const handleToggle = (enabled) => run(() => manager.setEncryptionEnabled(enabled));
17
+ const handleShowCode = () => run(async () => {
18
+ setRecoveryCode(await manager.getRecoveryCode());
19
+ });
20
+ const handleEnterCode = () => run(async () => {
21
+ const code = await ui.askRecoveryCode();
22
+ if (code === null)
23
+ return;
24
+ await manager.addRecoveryCode(code);
25
+ Alert.alert(t.recoveryCodeAdded);
26
+ });
27
+ return (_jsxs(View, { style: styles.section, children: [_jsx(Text, { style: styles.sectionTitle, children: t.encryptionTitle }), manager.encryptionMode === "optional" ? (_jsxs(Pressable, { style: styles.switchRow, disabled: busy, onPress: () => void handleToggle(!state.encryptionEnabled), accessibilityRole: "switch", accessibilityState: { checked: state.encryptionEnabled }, accessibilityLabel: t.encryptionToggle, children: [_jsx(Text, { style: styles.switchLabel, children: t.encryptionToggle }), _jsx(Switch, { value: state.encryptionEnabled, disabled: busy, onValueChange: (value) => void handleToggle(value) })] })) : null, _jsx(Text, { style: styles.hint, children: manager.encryptionMode === "required"
28
+ ? t.encryptionRequiredHint
29
+ : t.encryptionOptionalHint }), active && (_jsx(ActionButton, { label: t.showRecoveryCodeButton, variant: "secondary", onPress: () => void handleShowCode(), disabled: busy, styles: styles })), _jsx(ActionButton, { label: t.enterRecoveryCodeButton, variant: "ghost", onPress: () => void handleEnterCode(), disabled: busy, styles: styles }), _jsx(RecoveryCodeDisplayModal, { code: recoveryCode, onClose: () => {
30
+ setRecoveryCode(null);
31
+ }, t: t, styles: styles })] }));
32
+ }
@@ -0,0 +1,6 @@
1
+ import type { BackupUi } from "./useBackupUi.js";
2
+ /** Export to a file through the share sheet, import one back, undo the last restore. */
3
+ export declare function FileSection<T>({ ui }: {
4
+ ui: BackupUi<T>;
5
+ }): import("react").JSX.Element;
6
+ //# sourceMappingURL=FileSection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FileSection.d.ts","sourceRoot":"","sources":["../src/FileSection.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAEjD,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE;IAAE,EAAE,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAA;CAAE,+BAwEzD"}
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Alert, Text, View } from "react-native";
3
+ import { ActionButton } from "./ActionButton.js";
4
+ import { askConfirmation, errorMessage } from "./dialogs.js";
5
+ /** Export to a file through the share sheet, import one back, undo the last restore. */
6
+ export function FileSection({ ui }) {
7
+ const { manager, state, t, styles, busy, run } = ui;
8
+ const handleExport = () => run(() => manager.exportToFile());
9
+ const handleImport = () => run(async () => {
10
+ let result = await manager.pickBackupFile();
11
+ let promptError;
12
+ // A file from another phone, encrypted with a key this one doesn't
13
+ // have: ask for that phone's recovery code until it fits or the user
14
+ // gives up.
15
+ while (result.status === "missing-key") {
16
+ const code = await ui.askRecoveryCode(promptError);
17
+ if (code === null)
18
+ return;
19
+ try {
20
+ result = await result.retry(code);
21
+ }
22
+ catch (error) {
23
+ promptError = errorMessage(error, t);
24
+ }
25
+ }
26
+ if (result.status === "cancelled")
27
+ return;
28
+ await ui.confirmAndRestore(result.data, "file");
29
+ });
30
+ const handleUndo = () => run(async () => {
31
+ const confirmed = await askConfirmation(t, t.undoConfirmTitle, t.undoConfirmMessage, t.confirm);
32
+ if (!confirmed)
33
+ return;
34
+ await manager.undoLastRestore();
35
+ Alert.alert(t.undoneTitle);
36
+ });
37
+ return (_jsxs(View, { style: styles.section, children: [_jsx(Text, { style: styles.sectionTitle, children: t.fileTitle }), _jsx(Text, { style: styles.status, children: t.lastBackup(ui.formatDate(state.lastBackupAt)) }), _jsx(Text, { style: styles.hint, children: manager.isEncryptionActive() ? t.fileHintEncrypted : t.fileHint }), _jsx(ActionButton, { label: t.exportButton, variant: "secondary", onPress: () => void handleExport(), disabled: busy, styles: styles }), _jsx(ActionButton, { label: t.importButton, variant: "secondary", onPress: () => void handleImport(), disabled: busy, styles: styles }), state.hasSafetyBackup && (_jsx(ActionButton, { label: t.undoButton, variant: "ghost", onPress: () => void handleUndo(), disabled: busy, styles: styles }))] }));
38
+ }
@@ -0,0 +1,23 @@
1
+ import type { BackupScreenLabels } from "./labels.js";
2
+ import type { MergedStyles } from "./styles.js";
3
+ interface ModalProps {
4
+ t: BackupScreenLabels;
5
+ styles: MergedStyles;
6
+ }
7
+ /** Shows the recovery code, selectable so it can be copied to a password manager. */
8
+ export declare function RecoveryCodeDisplayModal({ code, onClose, t, styles, }: ModalProps & {
9
+ code: string | null;
10
+ onClose: () => void;
11
+ }): import("react").JSX.Element;
12
+ /**
13
+ * Asks for a recovery code. Android has no `Alert.prompt`, hence a modal.
14
+ * `error` shows why the previous attempt was refused.
15
+ */
16
+ export declare function RecoveryCodeInputModal({ visible, error, onSubmit, onCancel, t, styles, }: ModalProps & {
17
+ visible: boolean;
18
+ error: string | undefined;
19
+ onSubmit: (code: string) => void;
20
+ onCancel: () => void;
21
+ }): import("react").JSX.Element;
22
+ export {};
23
+ //# sourceMappingURL=RecoveryCodeModals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RecoveryCodeModals.d.ts","sourceRoot":"","sources":["../src/RecoveryCodeModals.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,UAAU,UAAU;IAClB,CAAC,EAAE,kBAAkB,CAAC;IACtB,MAAM,EAAE,YAAY,CAAC;CACtB;AAED,qFAAqF;AACrF,wBAAgB,wBAAwB,CAAC,EACvC,IAAI,EACJ,OAAO,EACP,CAAC,EACD,MAAM,GACP,EAAE,UAAU,GAAG;IAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,+BAoB3D;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,OAAO,EACP,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,CAAC,EACD,MAAM,GACP,EAAE,UAAU,GAAG;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB,+BAwDA"}
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Modal, Text, TextInput, View } from "react-native";
4
+ import { ActionButton } from "./ActionButton.js";
5
+ /** Shows the recovery code, selectable so it can be copied to a password manager. */
6
+ export function RecoveryCodeDisplayModal({ code, onClose, t, styles, }) {
7
+ return (_jsx(Modal, { visible: code !== null, transparent: true, animationType: "fade", onRequestClose: onClose, children: _jsx(View, { style: styles.modalBackdrop, children: _jsxs(View, { style: styles.modalCard, children: [_jsx(Text, { style: styles.sectionTitle, children: t.recoveryCodeTitle }), _jsx(Text, { style: styles.recoveryCode, selectable: true, children: code }), _jsx(Text, { style: styles.hint, children: t.recoveryCodeHint }), _jsx(ActionButton, { label: t.close, onPress: onClose, styles: styles })] }) }) }));
8
+ }
9
+ /**
10
+ * Asks for a recovery code. Android has no `Alert.prompt`, hence a modal.
11
+ * `error` shows why the previous attempt was refused.
12
+ */
13
+ export function RecoveryCodeInputModal({ visible, error, onSubmit, onCancel, t, styles, }) {
14
+ const [code, setCode] = useState("");
15
+ function submit() {
16
+ const typed = code;
17
+ setCode("");
18
+ onSubmit(typed);
19
+ }
20
+ function cancel() {
21
+ setCode("");
22
+ onCancel();
23
+ }
24
+ return (_jsx(Modal, { visible: visible, transparent: true, animationType: "fade", onRequestClose: cancel, children: _jsx(View, { style: styles.modalBackdrop, children: _jsxs(View, { style: styles.modalCard, children: [_jsx(Text, { style: styles.sectionTitle, children: t.recoveryCodeInputTitle }), _jsx(Text, { style: styles.hint, children: t.recoveryCodeInputHint }), _jsx(TextInput, { style: styles.input, value: code, onChangeText: setCode, placeholder: t.recoveryCodePlaceholder, placeholderTextColor: styles.placeholderTextColor, autoCapitalize: "characters", autoCorrect: false, autoComplete: "off",
25
+ // Keeps the code out of the keyboard's learned words and suggestions.
26
+ importantForAutofill: "no", secureTextEntry: false, keyboardType: "visible-password" }), error !== undefined && _jsx(Text, { style: styles.errorText, children: error }), _jsx(ActionButton, { label: t.confirm, onPress: submit, disabled: code.trim() === "", styles: styles }), _jsx(ActionButton, { label: t.cancel, variant: "ghost", onPress: cancel, styles: styles })] }) }) }));
27
+ }
@@ -0,0 +1,9 @@
1
+ import type { BackupScreenLabels } from "./labels.js";
2
+ /** User-facing text of any error: localized for the package's own, as is for the app's. */
3
+ export declare function errorMessage(error: unknown, t: BackupScreenLabels): string;
4
+ export declare function showError(error: unknown, t: BackupScreenLabels): void;
5
+ /** Resolves to true only if the user taps `confirmLabel` (not on cancel or dismiss). */
6
+ export declare function askConfirmation(t: BackupScreenLabels, title: string, message: string, confirmLabel: string): Promise<boolean>;
7
+ /** Which backup to keep when linking an account that already holds one; null if cancelled. */
8
+ export declare function askConflictChoice(t: BackupScreenLabels, description: string): Promise<"device" | "cloud" | null>;
9
+ //# sourceMappingURL=dialogs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dialogs.d.ts","sourceRoot":"","sources":["../src/dialogs.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEtD,2FAA2F;AAC3F,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE,kBAAkB,GAAG,MAAM,CAI1E;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAErE;AAED,wFAAwF;AACxF,wBAAgB,eAAe,CAC7B,CAAC,EAAE,kBAAkB,EACrB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,OAAO,CAAC,CA6BlB;AAED,8FAA8F;AAC9F,wBAAgB,iBAAiB,CAC/B,CAAC,EAAE,kBAAkB,EACrB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,QAAQ,GAAG,OAAO,GAAG,IAAI,CAAC,CAmCpC"}
@@ -0,0 +1,72 @@
1
+ import { Alert } from "react-native";
2
+ // Subpath: errors.js loads no native module, unlike the package root.
3
+ import { BackupError } from "@forthtilliath/expo-backup/errors";
4
+ /** User-facing text of any error: localized for the package's own, as is for the app's. */
5
+ export function errorMessage(error, t) {
6
+ if (error instanceof BackupError)
7
+ return t.errors[error.code];
8
+ if (error instanceof Error && error.message)
9
+ return error.message;
10
+ return t.errors.unknown;
11
+ }
12
+ export function showError(error, t) {
13
+ Alert.alert(t.errorTitle, errorMessage(error, t));
14
+ }
15
+ /** Resolves to true only if the user taps `confirmLabel` (not on cancel or dismiss). */
16
+ export function askConfirmation(t, title, message, confirmLabel) {
17
+ return new Promise((resolve) => {
18
+ Alert.alert(title, message, [
19
+ {
20
+ text: t.cancel,
21
+ style: "cancel",
22
+ onPress: () => {
23
+ resolve(false);
24
+ },
25
+ },
26
+ {
27
+ text: confirmLabel,
28
+ style: "destructive",
29
+ onPress: () => {
30
+ resolve(true);
31
+ },
32
+ },
33
+ ], {
34
+ cancelable: true,
35
+ onDismiss: () => {
36
+ resolve(false);
37
+ },
38
+ });
39
+ });
40
+ }
41
+ /** Which backup to keep when linking an account that already holds one; null if cancelled. */
42
+ export function askConflictChoice(t, description) {
43
+ return new Promise((resolve) => {
44
+ Alert.alert(t.conflictTitle, t.conflictMessage(description), [
45
+ {
46
+ text: t.cancel,
47
+ style: "cancel",
48
+ onPress: () => {
49
+ resolve(null);
50
+ },
51
+ },
52
+ {
53
+ text: t.conflictKeepDevice,
54
+ style: "destructive",
55
+ onPress: () => {
56
+ resolve("device");
57
+ },
58
+ },
59
+ {
60
+ text: t.conflictKeepCloud,
61
+ onPress: () => {
62
+ resolve("cloud");
63
+ },
64
+ },
65
+ ], {
66
+ cancelable: true,
67
+ onDismiss: () => {
68
+ resolve(null);
69
+ },
70
+ });
71
+ });
72
+ }
@@ -0,0 +1,10 @@
1
+ import type { BackupManager, BackupState } from "@forthtilliath/expo-backup";
2
+ /** The manager's state (linked account, last backup, pending upload…), re-rendering on change. */
3
+ export declare function useBackupState<T>(manager: BackupManager<T>): BackupState;
4
+ /**
5
+ * Starts the manager for the component's lifetime: reads the persisted state,
6
+ * retries a pending upload, flushes when the app goes to the background.
7
+ * Mount it once, in the root layout.
8
+ */
9
+ export declare function useAutoBackup<T>(manager: BackupManager<T>): void;
10
+ //# sourceMappingURL=hooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAE7E,kGAAkG;AAClG,wBAAgB,cAAc,CAAC,CAAC,EAAE,OAAO,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,WAAW,CAExE;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,OAAO,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,IAAI,CAEhE"}
package/dist/hooks.js ADDED
@@ -0,0 +1,13 @@
1
+ import { useEffect, useSyncExternalStore } from "react";
2
+ /** The manager's state (linked account, last backup, pending upload…), re-rendering on change. */
3
+ export function useBackupState(manager) {
4
+ return useSyncExternalStore(manager.subscribe, manager.getState);
5
+ }
6
+ /**
7
+ * Starts the manager for the component's lifetime: reads the persisted state,
8
+ * retries a pending upload, flushes when the app goes to the background.
9
+ * Mount it once, in the root layout.
10
+ */
11
+ export function useAutoBackup(manager) {
12
+ useEffect(() => manager.start(), [manager]);
13
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./BackupScreen.js";
2
+ export { errorMessage } from "./dialogs.js";
3
+ export * from "./hooks.js";
4
+ export * from "./labels.js";
5
+ export type { BackupScreenStyles } from "./styles.js";
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,YAAY,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./BackupScreen.js";
2
+ export { errorMessage } from "./dialogs.js";
3
+ export * from "./hooks.js";
4
+ export * from "./labels.js";
@@ -0,0 +1,67 @@
1
+ import type { BackupErrorCode } from "@forthtilliath/expo-backup";
2
+ /** Languages the built-in labels are available in. */
3
+ export type BackupLocale = "fr" | "en";
4
+ /** Every text of the screen. Pass a partial object in `labels` to override some. */
5
+ export interface BackupScreenLabels {
6
+ cloudTitle: string;
7
+ cloudHint: string;
8
+ connectButton: string;
9
+ connectedTitle: string;
10
+ connectedConfirm: (email: string) => string;
11
+ lastCloudBackup: (date: string) => string;
12
+ cloudPending: string;
13
+ autoHint: string;
14
+ syncNowButton: string;
15
+ restoreCloudButton: string;
16
+ disconnectButton: string;
17
+ disconnectConfirmTitle: string;
18
+ disconnectConfirmMessage: string;
19
+ conflictTitle: string;
20
+ conflictMessage: (description: string) => string;
21
+ conflictKeepDevice: string;
22
+ conflictKeepCloud: string;
23
+ noCloudBackupTitle: string;
24
+ noCloudBackupMessage: string;
25
+ fileTitle: string;
26
+ lastBackup: (date: string) => string;
27
+ fileHint: string;
28
+ fileHintEncrypted: string;
29
+ exportButton: string;
30
+ importButton: string;
31
+ undoButton: string;
32
+ undoConfirmTitle: string;
33
+ undoConfirmMessage: string;
34
+ undoneTitle: string;
35
+ restoreConfirmTitle: string;
36
+ restoreConfirmMessage: (description: string) => string;
37
+ restoreConfirmButton: string;
38
+ restoredTitle: string;
39
+ restoredMessage: string;
40
+ encryptionTitle: string;
41
+ encryptionToggle: string;
42
+ encryptionOptionalHint: string;
43
+ encryptionRequiredHint: string;
44
+ showRecoveryCodeButton: string;
45
+ enterRecoveryCodeButton: string;
46
+ recoveryCodeTitle: string;
47
+ recoveryCodeHint: string;
48
+ recoveryCodeInputTitle: string;
49
+ recoveryCodeInputHint: string;
50
+ recoveryCodePlaceholder: string;
51
+ recoveryCodeAdded: string;
52
+ /** Shown when the app's `describeBackup` isn't given. */
53
+ defaultDescription: string;
54
+ never: string;
55
+ cancel: string;
56
+ confirm: string;
57
+ close: string;
58
+ errorTitle: string;
59
+ errors: Record<BackupErrorCode, string>;
60
+ }
61
+ /** What `labels` accepts: any subset of the texts, error messages included. */
62
+ export type BackupScreenLabelOverrides = Partial<Omit<BackupScreenLabels, "errors">> & {
63
+ errors?: Partial<BackupScreenLabels["errors"]>;
64
+ };
65
+ export declare const defaultLabels: Record<BackupLocale, BackupScreenLabels>;
66
+ export declare function resolveLabels(locale: BackupLocale, overrides: BackupScreenLabelOverrides | undefined): BackupScreenLabels;
67
+ //# sourceMappingURL=labels.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"labels.d.ts","sourceRoot":"","sources":["../src/labels.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAKlE,sDAAsD;AACtD,MAAM,MAAM,YAAY,GAAG,IAAI,GAAG,IAAI,CAAC;AAEvC,oFAAoF;AACpF,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IAC5C,eAAe,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IAC1C,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gBAAgB,EAAE,MAAM,CAAC;IACzB,sBAAsB,EAAE,MAAM,CAAC;IAC/B,wBAAwB,EAAE,MAAM,CAAC;IACjC,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,CAAC;IACjD,kBAAkB,EAAE,MAAM,CAAC;IAC3B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,kBAAkB,EAAE,MAAM,CAAC;IAC3B,oBAAoB,EAAE,MAAM,CAAC;IAE7B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,MAAM,CAAC;IACzB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,WAAW,EAAE,MAAM,CAAC;IAEpB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,qBAAqB,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,CAAC;IACvD,oBAAoB,EAAE,MAAM,CAAC;IAC7B,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IAExB,eAAe,EAAE,MAAM,CAAC;IACxB,gBAAgB,EAAE,MAAM,CAAC;IACzB,sBAAsB,EAAE,MAAM,CAAC;IAC/B,sBAAsB,EAAE,MAAM,CAAC;IAC/B,sBAAsB,EAAE,MAAM,CAAC;IAC/B,uBAAuB,EAAE,MAAM,CAAC;IAChC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,gBAAgB,EAAE,MAAM,CAAC;IACzB,sBAAsB,EAAE,MAAM,CAAC;IAC/B,qBAAqB,EAAE,MAAM,CAAC;IAC9B,uBAAuB,EAAE,MAAM,CAAC;IAChC,iBAAiB,EAAE,MAAM,CAAC;IAE1B,yDAAyD;IACzD,kBAAkB,EAAE,MAAM,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC;CACzC;AAED,+EAA+E;AAC/E,MAAM,MAAM,0BAA0B,GAAG,OAAO,CAC9C,IAAI,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CACnC,GAAG;IACF,MAAM,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,QAAQ,CAAC,CAAC,CAAC;CAChD,CAAC;AAEF,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,YAAY,EAAE,kBAAkB,CAGlE,CAAC;AAEF,wBAAgB,aAAa,CAC3B,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,0BAA0B,GAAG,SAAS,GAChD,kBAAkB,CAOpB"}
@@ -0,0 +1,3 @@
1
+ import type { BackupScreenLabels } from "./labels.js";
2
+ export declare const enLabels: BackupScreenLabels;
3
+ //# sourceMappingURL=labels.en.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"labels.en.d.ts","sourceRoot":"","sources":["../src/labels.en.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEtD,eAAO,MAAM,QAAQ,EAAE,kBAkGtB,CAAC"}
@@ -0,0 +1,75 @@
1
+ export const enLabels = {
2
+ cloudTitle: "Automatic backup",
3
+ cloudHint: "Link your Google account: your data is sent to your Google Drive (in a private folder only this app can see) after every change. Handy to switch phones without losing anything.",
4
+ connectButton: "Connect Google Drive",
5
+ connectedTitle: "Automatic backup enabled",
6
+ connectedConfirm: (email) => `Your data is now sent to the Google Drive of ${email} after every change.`,
7
+ lastCloudBackup: (date) => `Last upload: ${date}`,
8
+ cloudPending: " · upload pending…",
9
+ autoHint: "Uploaded automatically after every change.",
10
+ syncNowButton: "Back up now",
11
+ restoreCloudButton: "Restore from Google Drive",
12
+ disconnectButton: "Disconnect",
13
+ disconnectConfirmTitle: "Turn off automatic backup?",
14
+ disconnectConfirmMessage: "The last backup stays on your Google Drive, but future changes won't be sent there anymore.",
15
+ conflictTitle: "A backup already exists",
16
+ conflictMessage: (description) => `This Google account already holds a backup (${description}). Which one do you want to keep?`,
17
+ conflictKeepDevice: "This phone",
18
+ conflictKeepCloud: "Google Drive",
19
+ noCloudBackupTitle: "No backup",
20
+ noCloudBackupMessage: "Nothing has been sent to Google Drive yet.",
21
+ fileTitle: "Manual backup",
22
+ lastBackup: (date) => `Last backup: ${date}`,
23
+ fileHint: "A file to keep somewhere safe or move to another phone.",
24
+ fileHintEncrypted: "An encrypted file: unreadable without this phone's key, or its recovery code.",
25
+ exportButton: "Export my data",
26
+ importButton: "Import a backup",
27
+ undoButton: "Undo the last restore",
28
+ undoConfirmTitle: "Undo the last restore?",
29
+ undoConfirmMessage: "Puts the data back as it was right before the last restore. Anything done since will be lost.",
30
+ undoneTitle: "Data from before the restore is back.",
31
+ restoreConfirmTitle: "Restore this backup?",
32
+ restoreConfirmMessage: (description) => `Backup: ${description}. All current data will be replaced (you can undo the restore afterwards).`,
33
+ restoreConfirmButton: "Restore",
34
+ restoredTitle: "Restore complete",
35
+ restoredMessage: "Your data has been replaced.",
36
+ encryptionTitle: "Encryption",
37
+ encryptionToggle: "Encrypt my backups",
38
+ encryptionOptionalHint: "Exported files and the Google Drive copy become unreadable without the app's key. Nothing to remember: the key is kept on this phone and in your Google Drive.",
39
+ encryptionRequiredHint: "Your backups are always encrypted. Nothing to remember: the key is kept on this phone and in your Google Drive.",
40
+ showRecoveryCodeButton: "Show recovery code",
41
+ enterRecoveryCodeButton: "Enter a recovery code",
42
+ recoveryCodeTitle: "Recovery code",
43
+ recoveryCodeHint: "Only needed without Google Drive: write it down (or keep it in a password manager) to read your exported files on another phone. Never share it.",
44
+ recoveryCodeInputTitle: "Enter a recovery code",
45
+ recoveryCodeInputHint: "The recovery code of the phone that created the backup (Settings ▸ Backup ▸ Show recovery code).",
46
+ recoveryCodePlaceholder: "XXXXX-XXXXX-…",
47
+ recoveryCodeAdded: "Recovery code added.",
48
+ defaultDescription: "app data",
49
+ never: "never",
50
+ cancel: "Cancel",
51
+ confirm: "OK",
52
+ close: "Close",
53
+ errorTitle: "Error",
54
+ errors: {
55
+ "google-unavailable": "Google Drive backup isn't available in this version of the app.",
56
+ "play-services-unavailable": "Google Play services aren't available on this phone.",
57
+ "oauth-misconfigured": "Google doesn't recognize this version of the app (missing OAuth client or different SHA-1).",
58
+ "sign-in-failed": "Signing in to Google failed.",
59
+ "reconnect-required": "Reconnect your Google account to resume backups.",
60
+ "drive-api-disabled": "The Google Drive API isn't enabled in the app's Google Cloud project.",
61
+ "drive-permission-missing": "Access to Google Drive wasn't granted. Reconnect your account and allow Drive access.",
62
+ "drive-access-denied": "Google Drive denied access. Reconnect your Google account.",
63
+ "drive-unavailable": "Google Drive is temporarily unavailable.",
64
+ network: "No internet connection.",
65
+ "missing-key": "This backup is encrypted with a key this phone doesn't know. Enter the recovery code of the device that created it.",
66
+ "invalid-recovery-code": "This recovery code contains a typo.",
67
+ "recovery-code-mismatch": "This recovery code is valid, but it isn't the one that encrypted this file.",
68
+ corrupted: "This backup is damaged or was modified.",
69
+ "newer-format": "This backup comes from a newer version of the app. Update the app before importing it.",
70
+ "no-safety-backup": "No restore to undo.",
71
+ "sharing-unavailable": "File sharing isn't available on this device.",
72
+ "file-too-large": "This file is too large to be a backup of the app.",
73
+ unknown: "An unexpected error occurred.",
74
+ },
75
+ };
@@ -0,0 +1,3 @@
1
+ import type { BackupScreenLabels } from "./labels.js";
2
+ export declare const frLabels: BackupScreenLabels;
3
+ //# sourceMappingURL=labels.fr.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"labels.fr.d.ts","sourceRoot":"","sources":["../src/labels.fr.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEtD,eAAO,MAAM,QAAQ,EAAE,kBAuGtB,CAAC"}
@@ -0,0 +1,75 @@
1
+ export const frLabels = {
2
+ cloudTitle: "Sauvegarde automatique",
3
+ cloudHint: "Relie ton compte Google : tes données sont envoyées automatiquement sur ton Google Drive (dans un dossier privé réservé à l'app) après chaque modification. Pratique pour changer de téléphone sans rien perdre.",
4
+ connectButton: "Connecter Google Drive",
5
+ connectedTitle: "Sauvegarde automatique activée",
6
+ connectedConfirm: (email) => `Tes données sont désormais envoyées sur le Google Drive de ${email} après chaque modification.`,
7
+ lastCloudBackup: (date) => `Dernier envoi : ${date}`,
8
+ cloudPending: " · envoi en attente…",
9
+ autoHint: "Envoi automatique après chaque modification.",
10
+ syncNowButton: "Sauvegarder maintenant",
11
+ restoreCloudButton: "Restaurer depuis Google Drive",
12
+ disconnectButton: "Déconnecter",
13
+ disconnectConfirmTitle: "Désactiver la sauvegarde automatique ?",
14
+ disconnectConfirmMessage: "La dernière sauvegarde reste sur ton Google Drive, mais les prochaines modifications n'y seront plus envoyées.",
15
+ conflictTitle: "Une sauvegarde existe déjà",
16
+ conflictMessage: (description) => `Ce compte Google contient déjà une sauvegarde (${description}). Laquelle garder ?`,
17
+ conflictKeepDevice: "Ce téléphone",
18
+ conflictKeepCloud: "Google Drive",
19
+ noCloudBackupTitle: "Aucune sauvegarde",
20
+ noCloudBackupMessage: "Rien n'a encore été envoyé sur Google Drive.",
21
+ fileTitle: "Sauvegarde manuelle",
22
+ lastBackup: (date) => `Dernière sauvegarde : ${date}`,
23
+ fileHint: "Un fichier à garder précieusement ou à transférer vers un autre téléphone.",
24
+ fileHintEncrypted: "Un fichier chiffré : illisible sans la clé de ce téléphone, ou sa clé de secours.",
25
+ exportButton: "Exporter mes données",
26
+ importButton: "Importer une sauvegarde",
27
+ undoButton: "Annuler la dernière restauration",
28
+ undoConfirmTitle: "Annuler la dernière restauration ?",
29
+ undoConfirmMessage: "Remet les données telles qu'elles étaient juste avant la dernière restauration. Ce qui a été fait depuis sera perdu.",
30
+ undoneTitle: "Données d'avant la restauration remises en place.",
31
+ restoreConfirmTitle: "Restaurer cette sauvegarde ?",
32
+ restoreConfirmMessage: (description) => `Sauvegarde : ${description}. Toutes les données actuelles seront remplacées (tu pourras annuler la restauration ensuite).`,
33
+ restoreConfirmButton: "Restaurer",
34
+ restoredTitle: "Restauration terminée",
35
+ restoredMessage: "Les données ont été remplacées.",
36
+ encryptionTitle: "Chiffrement",
37
+ encryptionToggle: "Chiffrer mes sauvegardes",
38
+ encryptionOptionalHint: "Les fichiers exportés et la copie Google Drive deviennent illisibles sans la clé de l'app. Rien à retenir : la clé est gardée sur ce téléphone et dans ton Google Drive.",
39
+ encryptionRequiredHint: "Tes sauvegardes sont toujours chiffrées. Rien à retenir : la clé est gardée sur ce téléphone et dans ton Google Drive.",
40
+ showRecoveryCodeButton: "Afficher la clé de secours",
41
+ enterRecoveryCodeButton: "Saisir une clé de secours",
42
+ recoveryCodeTitle: "Clé de secours",
43
+ recoveryCodeHint: "Utile seulement sans Google Drive : note-la (ou garde-la dans un gestionnaire de mots de passe) pour relire tes fichiers exportés sur un autre téléphone. Ne la partage avec personne.",
44
+ recoveryCodeInputTitle: "Saisir une clé de secours",
45
+ recoveryCodeInputHint: "La clé de secours du téléphone qui a créé la sauvegarde (Réglages ▸ Sauvegarde ▸ Afficher la clé de secours).",
46
+ recoveryCodePlaceholder: "XXXXX-XXXXX-…",
47
+ recoveryCodeAdded: "Clé de secours ajoutée.",
48
+ defaultDescription: "données de l'app",
49
+ never: "jamais",
50
+ cancel: "Annuler",
51
+ confirm: "Valider",
52
+ close: "Fermer",
53
+ errorTitle: "Erreur",
54
+ errors: {
55
+ "google-unavailable": "La sauvegarde Google Drive n'est pas disponible dans cette version de l'app.",
56
+ "play-services-unavailable": "Les services Google Play sont indisponibles sur ce téléphone.",
57
+ "oauth-misconfigured": "Google ne reconnaît pas cette version de l'app (client OAuth absent ou empreinte SHA-1 différente).",
58
+ "sign-in-failed": "La connexion à Google a échoué.",
59
+ "reconnect-required": "Reconnecte ton compte Google pour reprendre la sauvegarde.",
60
+ "drive-api-disabled": "L'API Google Drive n'est pas activée dans le projet Google Cloud de l'app.",
61
+ "drive-permission-missing": "L'accès à Google Drive n'a pas été autorisé. Reconnecte ton compte en acceptant l'accès à Drive.",
62
+ "drive-access-denied": "Google Drive a refusé l'accès. Reconnecte ton compte Google.",
63
+ "drive-unavailable": "Google Drive est momentanément indisponible.",
64
+ network: "Pas de connexion internet.",
65
+ "missing-key": "Cette sauvegarde est chiffrée avec une clé inconnue de ce téléphone. Saisis la clé de secours de l'appareil qui l'a créée.",
66
+ "invalid-recovery-code": "Cette clé de secours contient une faute de frappe.",
67
+ "recovery-code-mismatch": "Cette clé de secours est valide, mais ce n'est pas celle qui a chiffré ce fichier.",
68
+ corrupted: "Cette sauvegarde est endommagée ou a été modifiée.",
69
+ "newer-format": "Cette sauvegarde vient d'une version plus récente de l'app. Mets l'app à jour avant de l'importer.",
70
+ "no-safety-backup": "Aucune restauration à annuler.",
71
+ "sharing-unavailable": "Le partage de fichiers n'est pas disponible sur cet appareil.",
72
+ "file-too-large": "Ce fichier est trop volumineux pour être une sauvegarde de l'app.",
73
+ unknown: "Une erreur inattendue est survenue.",
74
+ },
75
+ };
package/dist/labels.js ADDED
@@ -0,0 +1,14 @@
1
+ import { enLabels } from "./labels.en.js";
2
+ import { frLabels } from "./labels.fr.js";
3
+ export const defaultLabels = {
4
+ fr: frLabels,
5
+ en: enLabels,
6
+ };
7
+ export function resolveLabels(locale, overrides) {
8
+ const base = defaultLabels[locale];
9
+ return {
10
+ ...base,
11
+ ...overrides,
12
+ errors: { ...base.errors, ...overrides?.errors },
13
+ };
14
+ }
@@ -0,0 +1,39 @@
1
+ import type { StyleProp, TextStyle, ViewStyle } from "react-native";
2
+ /** Per-slot overrides, merged on top of the defaults (not replacing them). */
3
+ export interface BackupScreenStyles {
4
+ container?: StyleProp<ViewStyle>;
5
+ section?: StyleProp<ViewStyle>;
6
+ sectionTitle?: StyleProp<TextStyle>;
7
+ hint?: StyleProp<TextStyle>;
8
+ status?: StyleProp<TextStyle>;
9
+ errorText?: StyleProp<TextStyle>;
10
+ button?: StyleProp<ViewStyle>;
11
+ buttonText?: StyleProp<TextStyle>;
12
+ secondaryButton?: StyleProp<ViewStyle>;
13
+ secondaryButtonText?: StyleProp<TextStyle>;
14
+ ghostButton?: StyleProp<ViewStyle>;
15
+ ghostButtonText?: StyleProp<TextStyle>;
16
+ buttonDisabled?: StyleProp<ViewStyle>;
17
+ switchRow?: StyleProp<ViewStyle>;
18
+ switchLabel?: StyleProp<TextStyle>;
19
+ modalBackdrop?: StyleProp<ViewStyle>;
20
+ modalCard?: StyleProp<ViewStyle>;
21
+ recoveryCode?: StyleProp<TextStyle>;
22
+ input?: StyleProp<TextStyle>;
23
+ /** Not a style: color of the spinners. */
24
+ activityIndicatorColor?: string;
25
+ /** Not a style: placeholder color of the recovery-code input. */
26
+ placeholderTextColor?: string;
27
+ }
28
+ type Slot = Exclude<keyof BackupScreenStyles, "activityIndicatorColor" | "placeholderTextColor">;
29
+ export declare const defaultStyles: Record<Slot, ViewStyle | TextStyle>;
30
+ export declare const PRIMARY_COLOR = "#2563eb";
31
+ export type MergedStyles = Record<Slot, StyleProp<ViewStyle | TextStyle>[]> & {
32
+ /** Undefined: white on primary buttons, primary color elsewhere. */
33
+ activityIndicatorColor: string | undefined;
34
+ placeholderTextColor: string;
35
+ };
36
+ /** Each slot as `[default, override]`, so overriding one property keeps the rest. */
37
+ export declare function mergeStyles(overrides: BackupScreenStyles | undefined): MergedStyles;
38
+ export {};
39
+ //# sourceMappingURL=styles.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"styles.d.ts","sourceRoot":"","sources":["../src/styles.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEpE,8EAA8E;AAC9E,MAAM,WAAW,kBAAkB;IACjC,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,OAAO,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC/B,YAAY,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACpC,IAAI,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC5B,MAAM,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC9B,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC9B,UAAU,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAClC,eAAe,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACvC,mBAAmB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC3C,WAAW,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACnC,eAAe,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACvC,cAAc,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACtC,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,WAAW,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACnC,aAAa,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACrC,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,YAAY,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACpC,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,0CAA0C;IAC1C,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,iEAAiE;IACjE,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,KAAK,IAAI,GAAG,OAAO,CACjB,MAAM,kBAAkB,EACxB,wBAAwB,GAAG,sBAAsB,CAClD,CAAC;AAIF,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,IAAI,EAAE,SAAS,GAAG,SAAS,CAgE7D,CAAC;AAEF,eAAO,MAAM,aAAa,YAAU,CAAC;AAErC,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,IAAI,EAAE,SAAS,CAAC,SAAS,GAAG,SAAS,CAAC,EAAE,CAAC,GAAG;IAC5E,oEAAoE;IACpE,sBAAsB,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C,oBAAoB,EAAE,MAAM,CAAC;CAC9B,CAAC;AAEF,qFAAqF;AACrF,wBAAgB,WAAW,CACzB,SAAS,EAAE,kBAAkB,GAAG,SAAS,GACxC,YAAY,CAUd"}
package/dist/styles.js ADDED
@@ -0,0 +1,79 @@
1
+ const primary = "#2563eb";
2
+ export const defaultStyles = {
3
+ container: { gap: 16 },
4
+ section: {
5
+ backgroundColor: "#ffffff",
6
+ borderWidth: 1,
7
+ borderColor: "#e5e7eb",
8
+ borderRadius: 12,
9
+ padding: 16,
10
+ gap: 10,
11
+ },
12
+ sectionTitle: { fontSize: 16, fontWeight: "700", color: "#111827" },
13
+ hint: { fontSize: 13, lineHeight: 18, color: "#6b7280" },
14
+ status: { fontSize: 14, color: "#111827" },
15
+ errorText: { fontSize: 13, color: "#dc2626" },
16
+ button: {
17
+ backgroundColor: primary,
18
+ borderRadius: 10,
19
+ paddingVertical: 12,
20
+ paddingHorizontal: 16,
21
+ alignItems: "center",
22
+ },
23
+ buttonText: { color: "#ffffff", fontSize: 15, fontWeight: "600" },
24
+ secondaryButton: {
25
+ backgroundColor: "transparent",
26
+ borderWidth: 1,
27
+ borderColor: primary,
28
+ },
29
+ secondaryButtonText: { color: primary },
30
+ ghostButton: { backgroundColor: "transparent" },
31
+ ghostButtonText: { color: "#6b7280" },
32
+ buttonDisabled: { opacity: 0.5 },
33
+ switchRow: {
34
+ flexDirection: "row",
35
+ alignItems: "center",
36
+ justifyContent: "space-between",
37
+ },
38
+ switchLabel: { flex: 1, fontSize: 15, color: "#111827" },
39
+ modalBackdrop: {
40
+ flex: 1,
41
+ justifyContent: "center",
42
+ padding: 24,
43
+ backgroundColor: "rgba(0,0,0,0.5)",
44
+ },
45
+ modalCard: {
46
+ backgroundColor: "#ffffff",
47
+ borderRadius: 12,
48
+ padding: 20,
49
+ gap: 12,
50
+ },
51
+ recoveryCode: {
52
+ fontSize: 17,
53
+ lineHeight: 26,
54
+ fontFamily: "monospace",
55
+ letterSpacing: 1,
56
+ color: "#111827",
57
+ },
58
+ input: {
59
+ borderWidth: 1,
60
+ borderColor: "#d1d5db",
61
+ borderRadius: 8,
62
+ padding: 12,
63
+ fontFamily: "monospace",
64
+ color: "#111827",
65
+ },
66
+ };
67
+ export const PRIMARY_COLOR = primary;
68
+ /** Each slot as `[default, override]`, so overriding one property keeps the rest. */
69
+ export function mergeStyles(overrides) {
70
+ const merged = {};
71
+ for (const slot of Object.keys(defaultStyles)) {
72
+ merged[slot] = [defaultStyles[slot], overrides?.[slot]];
73
+ }
74
+ return {
75
+ ...merged,
76
+ activityIndicatorColor: overrides?.activityIndicatorColor,
77
+ placeholderTextColor: overrides?.placeholderTextColor ?? "#9ca3af",
78
+ };
79
+ }
@@ -0,0 +1,35 @@
1
+ import type { BackupManager, BackupState } from "@forthtilliath/expo-backup";
2
+ import type { BackupScreenLabels } from "./labels.js";
3
+ import type { MergedStyles } from "./styles.js";
4
+ /** What every section of the screen shares. */
5
+ export interface BackupUi<T> {
6
+ manager: BackupManager<T>;
7
+ state: BackupState;
8
+ t: BackupScreenLabels;
9
+ styles: MergedStyles;
10
+ /** One action at a time for the whole screen: a second tap is ignored, errors are alerted. */
11
+ busy: boolean;
12
+ run: (action: () => Promise<void>) => Promise<void>;
13
+ describe: (data: T) => string;
14
+ formatDate: (epochMs: number | null) => string;
15
+ /** Confirms with a description of `data`, restores it, tells the user. */
16
+ confirmAndRestore: (data: T, source: "file" | "cloud") => Promise<void>;
17
+ /** Opens the recovery-code input; null if the user closes it. */
18
+ askRecoveryCode: (error?: string) => Promise<string | null>;
19
+ }
20
+ export interface RecoveryPrompt {
21
+ error: string | undefined;
22
+ resolve: (code: string | null) => void;
23
+ }
24
+ export declare function useBackupUi<T>({ manager, t, styles, describeBackup, dateLocale, onRestored, }: {
25
+ manager: BackupManager<T>;
26
+ t: BackupScreenLabels;
27
+ styles: MergedStyles;
28
+ describeBackup: ((data: T) => string) | undefined;
29
+ dateLocale: string;
30
+ onRestored: ((data: T) => void) | undefined;
31
+ }): {
32
+ ui: BackupUi<T>;
33
+ recoveryPrompt: RecoveryPrompt | null;
34
+ };
35
+ //# sourceMappingURL=useBackupUi.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useBackupUi.d.ts","sourceRoot":"","sources":["../src/useBackupUi.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAI7E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,+CAA+C;AAC/C,MAAM,WAAW,QAAQ,CAAC,CAAC;IACzB,OAAO,EAAE,aAAa,CAAC,CAAC,CAAC,CAAC;IAC1B,KAAK,EAAE,WAAW,CAAC;IACnB,CAAC,EAAE,kBAAkB,CAAC;IACtB,MAAM,EAAE,YAAY,CAAC;IACrB,8FAA8F;IAC9F,IAAI,EAAE,OAAO,CAAC;IACd,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,MAAM,CAAC;IAC9B,UAAU,EAAE,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,KAAK,MAAM,CAAC;IAC/C,0EAA0E;IAC1E,iBAAiB,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACxE,iEAAiE;IACjE,eAAe,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC7D;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;CACxC;AAED,wBAAgB,WAAW,CAAC,CAAC,EAAE,EAC7B,OAAO,EACP,CAAC,EACD,MAAM,EACN,cAAc,EACd,UAAU,EACV,UAAU,GACX,EAAE;IACD,OAAO,EAAE,aAAa,CAAC,CAAC,CAAC,CAAC;IAC1B,CAAC,EAAE,kBAAkB,CAAC;IACtB,MAAM,EAAE,YAAY,CAAC;IACrB,cAAc,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,KAAK,MAAM,CAAC,GAAG,SAAS,CAAC;IAClD,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC,GAAG,SAAS,CAAC;CAC7C,GAAG;IAAE,EAAE,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC;IAAC,cAAc,EAAE,cAAc,GAAG,IAAI,CAAA;CAAE,CAqE7D"}
@@ -0,0 +1,65 @@
1
+ import { useRef, useState } from "react";
2
+ import { Alert } from "react-native";
3
+ import { askConfirmation, showError } from "./dialogs.js";
4
+ import { useBackupState } from "./hooks.js";
5
+ export function useBackupUi({ manager, t, styles, describeBackup, dateLocale, onRestored, }) {
6
+ const state = useBackupState(manager);
7
+ const [busy, setBusy] = useState(false);
8
+ // A ref, not `busy`: two taps handled before the re-render would both
9
+ // still read the stale `false`.
10
+ const inFlightRef = useRef(false);
11
+ const [recoveryPrompt, setRecoveryPrompt] = useState(null);
12
+ async function run(action) {
13
+ if (inFlightRef.current)
14
+ return;
15
+ inFlightRef.current = true;
16
+ setBusy(true);
17
+ try {
18
+ await action();
19
+ }
20
+ catch (error) {
21
+ showError(error, t);
22
+ }
23
+ finally {
24
+ inFlightRef.current = false;
25
+ setBusy(false);
26
+ }
27
+ }
28
+ const describe = (data) => describeBackup?.(data) ?? t.defaultDescription;
29
+ async function confirmAndRestore(data, source) {
30
+ const confirmed = await askConfirmation(t, t.restoreConfirmTitle, t.restoreConfirmMessage(describe(data)), t.restoreConfirmButton);
31
+ if (!confirmed)
32
+ return;
33
+ await manager.restore(data, { source });
34
+ Alert.alert(t.restoredTitle, t.restoredMessage);
35
+ onRestored?.(data);
36
+ }
37
+ function askRecoveryCode(error) {
38
+ return new Promise((resolve) => {
39
+ setRecoveryPrompt({
40
+ error,
41
+ resolve: (code) => {
42
+ setRecoveryPrompt(null);
43
+ resolve(code);
44
+ },
45
+ });
46
+ });
47
+ }
48
+ return {
49
+ ui: {
50
+ manager,
51
+ state,
52
+ t,
53
+ styles,
54
+ busy,
55
+ run,
56
+ describe,
57
+ formatDate: (epochMs) => epochMs === null
58
+ ? t.never
59
+ : new Date(epochMs).toLocaleString(dateLocale),
60
+ confirmAndRestore,
61
+ askRecoveryCode,
62
+ },
63
+ recoveryPrompt,
64
+ };
65
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@forthtilliath/expo-backup-ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/Forthtilliath/forthtilliath-packages.git",
13
+ "directory": "packages/expo-backup-ui"
14
+ },
15
+ "homepage": "https://github.com/Forthtilliath/forthtilliath-packages/tree/main/packages/expo-backup-ui#readme",
16
+ "bugs": "https://github.com/Forthtilliath/forthtilliath-packages/issues",
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "exports": {
21
+ ".": "./dist/index.js",
22
+ "./*": "./dist/*.js"
23
+ },
24
+ "dependencies": {
25
+ "@forthtilliath/expo-backup": "^0.2.0"
26
+ },
27
+ "peerDependencies": {
28
+ "react": ">=19",
29
+ "react-native": ">=0.81"
30
+ },
31
+ "devDependencies": {
32
+ "@types/react": "^19.2.18",
33
+ "@types/react-test-renderer": "^19.1.0",
34
+ "@vitest/coverage-v8": "5.0.2",
35
+ "eslint": "10.7.0",
36
+ "react": "^19.2.8",
37
+ "react-native": "0.86.0",
38
+ "react-test-renderer": "19.2.3",
39
+ "typescript": "^5.9.3",
40
+ "vitest": "^5.0.2",
41
+ "@forthtilliath/typescript-config": "0.1.1",
42
+ "@forthtilliath/eslint-config": "0.9.0"
43
+ },
44
+ "scripts": {
45
+ "lint": "eslint . --max-warnings 0",
46
+ "check-types": "tsc --noEmit",
47
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
48
+ "dev": "tsc --watch -p tsconfig.build.json",
49
+ "build": "pnpm run clean && tsc -p tsconfig.build.json",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest",
52
+ "coverage": "vitest run --coverage"
53
+ }
6
54
  }