/** * L'anteprima di un import: cosa c'è nel file e cosa succederebbe a ognuna * delle sue voci, prima di scrivere. Ci si arriva da Impostazioni → Importa. * * Legge senza scrivere. Il piano lo calcola src/io/piano.ts, lo stesso * confronto che l'import poi esegue: qui si mostra e basta. Il testo del * tasto e il segno di ogni riga li decide scambio.ts, che i test caricano. * * Un file che non si riesce a leggere e uno che non è nostro sono lo stesso * rifiuto, detto nella pagina e non in un Alert: chi lo legge può tornare * indietro con calma. */ import { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { File } from 'expo-file-system'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { categorieDaEsportare } from '../../data/categorie.ts'; import { ricetteDaEsportare } from '../../data/ricette.ts'; import { contaVive } from '../../data/ricettario.ts'; import type { ContiVivi } from '../../data/ricettario.ts'; import type { RicettarioLetto } from '../../io/formato.ts'; import { importaLetto, leggiFile } from '../../io/importa.ts'; import { pianoImport } from '../../io/piano.ts'; import type { Piano } from '../../io/piano.ts'; import Barra from '../componenti/Barra.tsx'; import { useApp } from '../contesto.ts'; import { esportaTutteECondividi } from '../esportaCondivisione.ts'; import { messaggioSostituisci, mostraSostituisci } from '../logica-impostazioni.ts'; import type { PropsSchermata } from '../navigazione.ts'; import { righeAnteprima, righeImport, tastoImporta } from '../scambio.ts'; import type { RigaAnteprima } from '../scambio.ts'; import { creaConfermaUnica, creaScrittoreUnico } from '../scritturaUnica.ts'; import type { Colori } from '../tema.ts'; import { RAGGIO, SPAZIO, TESTO, useColori } from '../tema.ts'; type Letto = { file: RicettarioLetto; foto: Map }; type Stato = | { nome: 'lettura' } | { nome: 'rifiutato' } | { nome: 'pronto'; letto: Letto; piano: Piano; conti: ContiVivi }; export default function Anteprima({ navigation, route }: PropsSchermata<'Anteprima'>) { const { db, lingua, testo } = useApp(); const colori = useColori(); const stili = useMemo(() => creaStili(colori), [colori]); const sotto = useSafeAreaInsets().bottom; const [stato, setStato] = useState({ nome: 'lettura' }); const [inCorso, setInCorso] = useState(false); const scrittoreUnico = useRef(creaScrittoreUnico()).current; /** La conferma di Sostituisci aperta: due tocchi veloci sul tasto non aprono due Alert (scritturaUnica.ts). */ const confermaUnica = useRef(creaConfermaUnica()).current; useEffect(() => { let vivo = true; // Un secondo file mentre il primo è a schermo: via il piano vecchio col suo // tasto attivo, finché il nuovo non è letto. setStato({ nome: 'lettura' }); (async () => { const uri = route.params.uri; let letto: Letto | null = null; try { letto = await leggiFile(await new File(uri).bytes()); // iOS copia in Documents/Inbox i file aperti da altre app e non la svuota mai. if (uri.includes('/Inbox/')) { try { new File(uri).delete(); } catch {} } } catch { letto = null; } if (!vivo) return; if (letto === null) { setStato({ nome: 'rifiutato' }); return; } let ricette; let categorie; let conti; try { // contaVive insieme alla fotografia locale: serve a decidere se // mostrare «Sostituisci il ricettario» e cosa dice la sua conferma. [ricette, categorie, conti] = await Promise.all([ ricetteDaEsportare(db), categorieDaEsportare(db), contaVive(db), ]); } catch { // Senza la fotografia locale il piano non si può fare: si dice come un // rifiuto, che è l'unico stato senza tasto. if (vivo) setStato({ nome: 'rifiutato' }); return; } if (!vivo) return; setStato({ nome: 'pronto', letto, piano: pianoImport(letto.file, ricette, categorie), conti }); })(); return () => { vivo = false; }; }, [db, route.params.uri]); // Importa e Sostituisci finiscono nella stessa transazione di applica() // (src/io/importa.ts), qui distinte solo dall'opzione `sostituisci`: l'esito // si mostra allo stesso modo per entrambe. const eseguiImport = (sostituisci: boolean) => scrittoreUnico(async () => { if (stato.nome !== 'pronto') return; setInCorso(true); try { const esito = await importaLetto(db, stato.letto, { sostituisci }); const righe = righeImport(esito, lingua) .map((r) => (r.etichetta === null ? '' : `${testo(r.etichetta)}: `) + testo(r.chiave, r.valori)) .join('\n'); Alert.alert( testo('anteprima.titolo'), righe, [{ text: 'OK', onPress: () => navigation.goBack() }], { cancelable: false }, ); } finally { setInCorso(false); } }); const importa = () => eseguiImport(false); // «Esporta prima una copia» nella conferma di Sostituisci: la stessa // funzione (esportaCondivisione.ts) che usa Impostazioni per la conferma di // Svuota, dentro la stessa porta di scritturaUnica delle altre azioni di // questa schermata — così non parte insieme a un import. const esportaPrima = () => scrittoreUnico(async () => { setInCorso(true); try { await esportaTutteECondividi(db, testo); } finally { setInCorso(false); } }); // La porta si riapre da ogni tasto, Annulla compreso, e da onDismiss // (Android, tocco fuori dall'Alert): una conferma che si chiude senza // riaprirla lascerebbe il tasto morto. const confermaSostituisci = () => { if (stato.nome !== 'pronto') return; const conti = stato.conti; confermaUnica((chiusa) => Alert.alert( testo('anteprima.sostituisci.titolo'), messaggioSostituisci(conti, lingua), [ { text: testo('impostazioni.esporta.prima'), onPress: () => { chiusa(); void esportaPrima(); }, }, { text: testo('anteprima.sostituisci.tasto'), style: 'destructive', onPress: () => { chiusa(); void eseguiImport(true); }, }, { text: testo('modifica.annulla'), style: 'cancel', onPress: chiusa }, ], { cancelable: true, onDismiss: chiusa }, ), ); }; const riga = (r: RigaAnteprima) => ( {r.nome} {testo(r.segno)} {r.foto ? ` · ${testo('anteprima.foto')}` : ''} ); const rifiuto = righeImport({ ok: false, motivo: 'file-non-valido' }, lingua)[0]; const tasto = stato.nome === 'pronto' ? tastoImporta(stato.piano, lingua) : null; const attivo = tasto !== null && tasto.chiave !== 'anteprima.importa.zero' && !inCorso; // Il tasto «Sostituisci il ricettario» compare solo se il locale ha già // qualcosa e il file porta almeno due ricette vive (mostraSostituisci). const sostituisciVisibile = stato.nome === 'pronto' && mostraSostituisci(stato.conti, stato.letto.file.ricette); // Indipendente dal tasto principale: con un backup più vecchio non «entra» // niente, ma sostituire è proprio quello che serve. Se il tasto si vede, il // file ha già almeno due ricette vive (mostraSostituisci). const sostituibile = stato.nome === 'pronto' && !inCorso; return ( {stato.nome === 'lettura' ? ( ) : stato.nome === 'rifiutato' ? ( {testo(rifiuto.chiave, rifiuto.valori)} ) : ( <> {righeAnteprima(stato.piano.ricette).map(riga)} {stato.piano.categorie.length > 0 ? ( <> {testo('anteprima.categorie')} {righeAnteprima(stato.piano.categorie).map(riga)} ) : null} void importa()} disabled={!attivo} style={[stili.tasto, attivo ? null : stili.tastoSpento]} accessibilityRole="button" accessibilityState={{ disabled: !attivo }} > {inCorso ? ( ) : ( {tasto === null ? '' : testo(tasto.chiave, tasto.valori)} )} {sostituisciVisibile ? ( {testo('anteprima.sostituisci')} ) : null} )} ); } const creaStili = (colori: Colori) => StyleSheet.create({ pagina: { flex: 1, backgroundColor: colori.fondo }, centro: { flex: 1 }, contenuto: { paddingHorizontal: SPAZIO.l, paddingBottom: SPAZIO.xl }, riga: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', gap: SPAZIO.m, minHeight: 44, paddingVertical: SPAZIO.s, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colori.bordo, }, nome: { ...TESTO.corpo, color: colori.testo, flexShrink: 1 }, segno: { ...TESTO.piccolo, color: colori.accento }, segnoFermo: { color: colori.testoTenue }, // Il titolo della sezione Categorie, sotto le ricette: piccolo, maiuscolo, // tenue, per non competere con le righe. sezione: { ...TESTO.piccolo, textTransform: 'uppercase', color: colori.testoTenue, marginTop: SPAZIO.xl, marginBottom: SPAZIO.s, }, rifiuto: { ...TESTO.corpo, color: colori.testo, padding: SPAZIO.xl, textAlign: 'center' }, azioni: { padding: SPAZIO.l }, // Il tasto è quello del foglio di GestioneCategorie, largo quanto la pagina. tasto: { backgroundColor: colori.accento, borderRadius: RAGGIO.campo, paddingVertical: SPAZIO.m, paddingHorizontal: SPAZIO.xl, alignItems: 'center', }, tastoSpento: { backgroundColor: colori.bordo }, tastoTesto: { ...TESTO.corpo, fontWeight: '600', color: colori.accentoSopra }, tastoTestoSpento: { color: colori.testoTenue }, // «Sostituisci il ricettario»: stesso raggio e stessa larghezza del tasto // principale, ma a contorno — bordo e testo color d'accento, niente // riempimento — perché è la scelta secondaria, quella che cancella. tastoContorno: { borderWidth: 2, borderColor: colori.accento, borderRadius: RAGGIO.campo, paddingVertical: SPAZIO.m, paddingHorizontal: SPAZIO.xl, alignItems: 'center', marginTop: SPAZIO.s, }, tastoContornoSpento: { borderColor: colori.bordo }, tastoContornoTesto: { ...TESTO.corpo, fontWeight: '600', color: colori.accento }, tastoContornoTestoSpento: { color: colori.testoTenue }, });