/** * Export e import del ricettario, dal menu nell'intestazione della schermata * principale. * * L'export scrive un archivio zip con dentro ricettario.json e la cartella * foto/, dice che cosa c'è finito, e lo passa al foglio di condivisione del * sistema. L'import lo fa scegliere all'utente e accetta sia lo zip sia un JSON * nudo: i file scritti a mano e quelli di versione 1, che le foto non le * avevano, devono continuare a entrare. * * importaArchivio e importaJson sono additivi e fanno già da sé la copia di * sicurezza del database: qui non si duplica. L'esito si dice sempre, riuscito o * rifiutato che sia — anche quando il file scelto non si riesce nemmeno a * leggere, che si tratta come lo stesso rifiuto di un file che non è nostro, * mai con il messaggio tecnico grezzo dell'eccezione. * * db, lingua e testo arrivano da useApp(), come nelle schermate: chi monta la * barra le passa solo che cosa fare dopo un import riuscito. */ import { useRef, useState } from 'react'; import { ActivityIndicator, Alert, Pressable, StyleSheet, View } from 'react-native'; import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; import * as DocumentPicker from 'expo-document-picker'; import * as Sharing from 'expo-sharing'; import { File } from 'expo-file-system'; import { esporta } from '../../io/esporta.ts'; import { importaArchivio, importaJson } from '../../io/importa.ts'; import type { EsitoImport } from '../../io/importa.ts'; import { useApp } from '../contesto.ts'; import { avvisoExport, righeImport, sembraZip, uriDiFile } from '../scambio.ts'; import { creaScrittoreUnico } from '../scritturaUnica.ts'; import type { Avviso } from '../scambio.ts'; import { SPAZIO, useColori } from '../tema.ts'; import Segno from './Segno.tsx'; export type PropsBarraIo = { /** Chiamata dopo un import riuscito: la schermata principale si rilegge. */ onImportato: () => void; }; export function BarraIo({ onImportato }: PropsBarraIo) { const { db, lingua, testo } = useApp(); const colori = useColori(); const [inCorso, setInCorso] = useState(false); /** * Una operazione per volta. `withTransactionAsync` di expo-sqlite non è * esclusiva: due import sovrapposti farebbero collidere le loro transazioni * (la seconda BEGIN muore dentro la prima), e anche solo due letture del * ricettario locale partite a momenti diversi manderebbero storti i conteggi * di aggiunte/aggiornate. Il meccanismo è quello di scritturaUnica.ts, lo * stesso degli altri tre punti che scrivono, e lì è collaudato. * * Lo spinner sta dentro la scrittura e non fuori: si accende solo quando * l'operazione parte davvero, cioè quando la porta era aperta. Acceso prima * della guardia, un secondo tocco durante un import lungo lo avrebbe spento * uscendo, lasciando l'import in corso senza niente a schermo. */ const scrittoreUnico = useRef(creaScrittoreUnico()).current; const scriviUnaSola = (operazione: () => Promise) => scrittoreUnico(async () => { setInCorso(true); try { await operazione(); } finally { setInCorso(false); } }); /** Le righe di un `Avviso[]` unite in un solo messaggio, una per riga. */ const messaggio = (righe: Avviso[]): string => righe .map((r) => (r.etichetta === null ? '' : `${testo(r.etichetta)}: `) + testo(r.chiave, r.valori)) .join('\n'); const condividi = async (uri: string) => { try { if (await Sharing.isAvailableAsync()) { await Sharing.shareAsync(uri, { mimeType: 'application/zip', UTI: 'public.zip-archive', dialogTitle: testo('io.esporta'), }); } else { // Niente foglio di condivisione: almeno diciamo dov'è finito il file. Alert.alert(testo('io.esporta'), uri); } } catch { // L'archivio è comunque già scritto e confermato dall'avviso // precedente: solo il passaggio al foglio di condivisione è fallito. // La frase lo dice nell'ordine giusto, la notizia buona prima — chi // legge di corsa deve sapere subito che il lavoro non è perso. Alert.alert(testo('app.nome'), testo('io.condividi.errore')); } }; const esportaRicettario = () => scriviUnaSola(async () => { try { const esito = await esporta(db); const avviso = avvisoExport(esito); // Il conto si dice prima di condividere: «12 ricette e 5 foto» è il // modo più corto per sapere che nell'archivio ci sono davvero anche le // foto. Alert.alert(testo('io.esporta'), testo(avviso.chiave, avviso.valori), [ { text: testo('modifica.annulla'), style: 'cancel' }, { text: testo('io.esporta'), onPress: () => void condividi(uriDiFile(esito.uri)) }, ]); } catch { // errore.db è per una lettura fallita; qui la lettura delle ricette è // la parte semplice, quella che quasi non fallisce mai. A rompersi è // quasi sempre lo zip: comprimere e scrivere l'archivio nella cartella // cache. errore.scrittura ("riprova") è il consiglio giusto per un // disco pieno o un guasto momentaneo, mentre errore.db suggerirebbe di // chiudere e riaprire l'app per un problema che riprovare risolve. Alert.alert(testo('app.nome'), testo('errore.scrittura')); } }); const importaRicettario = async () => { // '*/*' e non 'application/zip': su Android il gestore file dichiara i // nostri file come octet-stream e col filtro stretto non si riescono a // scegliere. let scelta: DocumentPicker.DocumentPickerResult; try { scelta = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true, multiple: false, }); } catch { // Il picker di sistema non è nostro: se non si apre nemmeno, si può solo // riprovare toccando di nuovo. Niente spinner acceso per questo, non è // ancora cominciata nessuna lettura. return; } if (scelta.canceled) return; await scriviUnaSola(async () => { let esito: EsitoImport; try { const file = new File(scelta.assets[0].uri); const byte = await file.bytes(); esito = sembraZip(byte) ? await importaArchivio(db, byte) : await importaJson(db, await file.text()); } catch { // Il file non si è nemmeno potuto leggere: stesso trattamento di un // file che non è nostro, con lo stesso messaggio tradotto. Mai il // messaggio tecnico grezzo dell'eccezione: sarebbe "codice a schermo" // tanto quanto mostrare il motivo di src/io senza tradurlo. esito = { ok: false, motivo: 'file-non-valido' }; } Alert.alert(testo('io.importa'), messaggio(righeImport(esito, lingua))); if (esito.ok) onImportato(); }); }; // Un tasto solo e un menu, invece di due tasti: «Esporta ricettario e foto» // nell'intestazione non ci sta, e accorciarlo vorrebbe dire una chiave nuova // per dire la stessa cosa peggio. const menu = () => { Alert.alert(testo('app.nome'), undefined, [ { text: testo('io.esporta'), onPress: () => void esportaRicettario() }, { text: testo('io.importa'), onPress: () => void importaRicettario() }, { text: testo('modifica.annulla'), style: 'cancel' }, ]); }; return ( {/* 44x44 dichiarati, non un hitSlop: il tasto sta accanto a "Gestisci", che il suo hitSlop ce l'ha già, e due hitSlop ravvicinati si sovrapporrebbero nel varco fra i due (lo stesso difetto pagato in GestioneCategorie.tsx). Da spento il tasto resta al suo posto e leggibile, solo più tenue: farlo sparire per mettere lo spinner al suo posto toglierebbe al lettore di schermo il comando che stava annunciando. */} {inCorso ? : null} ); } // Niente colore qui dentro: l'unico colore del componente va sull'icona e // sullo spinner, che cambiano con `inCorso`, e creaStili(colori) per due stili // senza un colore sarebbe un giro a vuoto. const stili = StyleSheet.create({ barra: { flexDirection: 'row', alignItems: 'center', gap: SPAZIO.xs }, tasto: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, });