/** * Impostazioni: esporta, importa, svuota, tema, lingua, assistenza, privacy, * consiglia a un amico. * * È la sezione Impostazioni del menù in basso, non una pagina aperta da * qualche parte: niente freccia indietro nella barra. * * La pagina è tre riquadri arrotondati (come Impostazioni di iPhone): righe * con un segno a sinistra, il valore tenue a destra se c'è, e in fondo la * freccia o la freccia obliqua di chi apre il browser. Tema e Lingua aprono * una schermata dedicata (Scelta.tsx, che questa pagina non duplica: le * scelte restano su TEMI/SCELTE_LINGUA e su ContestoApp). * * Export e import stavano nell'intestazione della home, come due icone senza * intenzione. Qui hanno un posto e un motivo: sono la copia del ricettario, * intera o di una categoria. La condivisione di una ricetta sola sta nella * ricetta. * * Le decisioni (cosa vale una preferenza salvata, cosa finisce nel file, come * si chiama una riga, quale segno le sta a sinistra) stanno in * logica-impostazioni.ts, src/io e src/data: qui si mostra. * * Il tema e la lingua cambiano subito, prima che il disco confermi: se la * scrittura fallisce si è persa una preferenza, non una ricetta, e lo si dice * con lo stesso avviso delle altre scritture fallite. * * L'import qui si limita a far scegliere il file: a leggerlo, mostrare cosa * entrerebbe e scrivere ci pensa l'Anteprima. * * Svuota il ricettario: la riga è rossa (colori.errore) e si spegne da sola * quando non c'è già nulla da svuotare (svuotaAttivo su un conteggio letto a * ogni ritorno sulla pagina, col focus — non solo al montaggio, perché tra * un ritorno e l'altro si può aver cancellato l'ultima ricetta altrove). * Col conteggio si rileggono anche le categorie del foglio Esporta: dopo uno * Svuota o un Sostituisci quelle lette al montaggio non esistono più. */ import { useCallback, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Alert, Linking, Platform, Pressable, ScrollView, Share, StyleSheet, Text, View, } from 'react-native'; import Constants from 'expo-constants'; import * as DocumentPicker from 'expo-document-picker'; import { useFocusEffect } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { elencoCategorie } from '../../data/categorie.ts'; import { contaVive, svuotaRicettario } from '../../data/ricettario.ts'; import type { ContiVivi } from '../../data/ricettario.ts'; import type { Categoria } from '../../domain/types.ts'; import type { Selezione } from '../../io/esporta.ts'; import { Foglio } from '../componenti/Foglio.tsx'; import Barra from '../componenti/Barra.tsx'; import { Riquadro, RigaRiquadro } from '../componenti/Riquadro.tsx'; import SopraIlMenu from '../componenti/SopraIlMenu.tsx'; import { useApp } from '../contesto.ts'; import { esportaECondividi } from '../esportaCondivisione.ts'; import { chiaveValoreLingua, chiaveValoreTema, etichettaRigaValore, INDIRIZZO_ASSISTENZA, messaggioSvuota, ruoloRigaImpostazioni, segnoFineRiga, segnoRiga, svuotaAttivo, testoConsiglia, } from '../logica-impostazioni.ts'; import { margineSottoScorrevole } from '../logica-menu.ts'; import type { PropsSezione } from '../navigazione.ts'; import { creaConfermaUnica, creaScrittoreUnico } from '../scritturaUnica.ts'; import type { Colori } from '../tema.ts'; import { SPAZIO, TESTO, useColori } from '../tema.ts'; /** L'indirizzo pubblico dell'informativa, sul sito Due Bytes. Con www e la barra finale: senza, il server rimanda e un passaggio scende in http. */ const INDIRIZZO_PRIVACY = 'https://www.duebytes.it/quantobasta/privacy/'; const CONTI_INIZIALI: ContiVivi = { ricette: 0, categorie: 0 }; export default function Impostazioni({ navigation }: PropsSezione<'Impostazioni'>) { const { db, testo, tema, sceltaLingua, lingua } = useApp(); const colori = useColori(); const stili = useMemo(() => creaStili(colori), [colori]); const insetSotto = useSafeAreaInsets().bottom; // Il margine in fondo alla ScrollView: la scelta fra l'inset a mano e // `contentInsetAdjustmentBehavior="automatic"` (sulla ScrollView qui sotto) // sta in margineSottoScorrevole(), non qui — Impostazioni è sempre dentro // una sezione del menù, mai a tutto schermo. const sotto = margineSottoScorrevole(Platform.OS, insetSotto, true); // Il foglio di export è un Modal di sistema, una finestra sua fuori da // SopraIlMenu: su Android disegna fino al bordo, la barra del menù non // c'entra. Ci vuole sempre l'inset pieno, non `sotto` (che su Android vale // 0 perché quello spazio l'ha già preso la barra per le liste, e su iOS // ora vale 0 perché ci pensa la ScrollView qui sopra, che il foglio non è). const sottoFoglio = insetSotto; const [categorie, setCategorie] = useState([]); const [conti, setConti] = useState(CONTI_INIZIALI); const [scegliExport, setScegliExport] = useState(false); /** La scelta fatta nel foglio, in attesa che il foglio sia sparito. */ const [daEsportare, setDaEsportare] = useState(null); const [inCorso, setInCorso] = useState(false); /** Quale operazione è in corso, per mettere lo spinner sulla riga giusta: la porta di scritturaUnica è una sola, ma le righe che ci scrivono sono due. */ const [operazione, setOperazione] = useState<'esporta' | 'svuota' | null>(null); /** * Una operazione per volta, col meccanismo di scritturaUnica.ts: due import * (o uno svuota e un export) sovrapposti farebbero collidere le loro * transazioni. Lo spinner sta dentro la scrittura e non fuori: si accende * solo quando l'operazione parte davvero, cioè quando la porta era aperta. */ const scrittoreUnico = useRef(creaScrittoreUnico()).current; const confermaUnica = useRef(creaConfermaUnica()).current; const scriviUnaSola = (nome: 'esporta' | 'svuota', operazione_: () => Promise) => scrittoreUnico(async () => { setInCorso(true); setOperazione(nome); try { await operazione_(); } finally { setInCorso(false); setOperazione(null); } }); // Il conteggio delle ricette e categorie vive, che decide se «Svuota» è // spenta e cosa dice l'Alert, e le categorie del foglio Esporta: si // rileggono a ogni ritorno sulla pagina (il focus), non solo al montaggio — // tornando da Modifica dopo aver cancellato l'ultima ricetta la riga deve // spegnersi senza un giro in più, e tornando dall'Anteprima dopo un // Sostituisci il foglio non deve offrire categorie che non ci sono più. useFocusEffect( useCallback(() => { let vivo = true; contaVive(db) .then((c) => { if (vivo) setConti(c); }) // Un conteggio non aggiornato lascia la riga com'era: non blocca la // pagina, e il prossimo focus riprova. .catch(() => {}); elencoCategorie(db) .then((c) => { if (vivo) setCategorie(c); }) // Senza categorie il foglio offre solo «Tutte le ricette»: l'export // resta possibile, e l'errore di lettura lo dirà la home. .catch(() => {}); return () => { vivo = false; }; }, [db]), ); // Scrive l'archivio e offre di condividerlo: la stessa funzione di // esportaCondivisione.ts che usa anche l'Anteprima per «Esporta prima una // copia» nella conferma di Sostituisci — non due copie della stessa cosa. const avvia = (selezione: Selezione) => { setDaEsportare(null); void scriviUnaSola('esporta', () => esportaECondividi(db, testo, selezione)); }; /** * La scelta si tiene da parte e l'export parte quando il foglio e' sparito * (`onChiuso` di Foglio): un Alert presentato mentre il foglio sta ancora * andando via, su iOS non compare mai, e con un ricettario piccolo lo zip * finirebbe proprio in quella finestra. */ const scegli = (selezione: Selezione) => { setDaEsportare(selezione); setScegliExport(false); }; const importa = 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. return; } if (scelta.canceled) return; navigation.navigate('Anteprima', { uri: scelta.assets[0].uri }); }; const svuota = () => scriviUnaSola('svuota', async () => { try { await svuotaRicettario(db); } catch { // Anche quando è la copia di sicurezza a non riuscire: il ricettario // è rimasto com'era, e riprovare è l'unica cosa da fare. Alert.alert(testo('app.nome'), testo('errore.scrittura')); return; } // Svuotato: zero e zero per costruzione, senza rileggerli. Una lettura // fallita qui non deve far dire che lo svuotamento non è riuscito. setConti(CONTI_INIZIALI); // Le categorie del foglio Esporta sì, subito: non ci sono più. Se la // lettura fallisce il foglio resta con «Tutte le ricette» e basta. setCategorie(await elencoCategorie(db).catch(() => [])); }); // Una conferma alla volta, come in Anteprima: con due Alert aperti il secondo // «Svuota» rifarebbe la copia di sicurezza sul ricettario già vuoto e // perderebbe quella buona. const confermaSvuota = () => confermaUnica((chiusa) => Alert.alert( testo('impostazioni.svuota.titolo'), messaggioSvuota(conti, lingua), [ { text: testo('impostazioni.esporta.prima'), onPress: () => { chiusa(); avvia({ tipo: 'tutto' }); }, }, { text: testo('impostazioni.svuota.tasto'), style: 'destructive', onPress: () => { chiusa(); void svuota(); }, }, { text: testo('modifica.annulla'), style: 'cancel', onPress: chiusa }, ], { cancelable: true, onDismiss: chiusa }, ), ); const consiglia = () => { const { message, url } = testoConsiglia(Platform.OS, lingua); Share.share({ message, url }).catch(() => {}); }; // Dall'app.json che la build si porta dentro: nativeBuildVersion di Constants // è deprecato e torna vuoto, e ogni build mostrava «(1)». const versione = Constants.expoConfig?.version ?? ''; const build = String( (Platform.OS === 'ios' ? Constants.expoConfig?.ios?.buildNumber : Constants.expoConfig?.android?.versionCode) ?? '', ); const attivaSvuota = svuotaAttivo(conti); return ( {/* La Barra sta in fondo all'albero e torna in cima con `column-reverse` (in stili.pagina): il menù di vetro dell'iPhone si rimpicciolisce scorrendo solo se trova questa ScrollView, e la cerca solo lungo i primi figli. Il perché per intero sta in Categorie.tsx. */} setScegliExport(true)} inCorso={inCorso && operazione === 'esporta'} disabled={inCorso} /> void importa()} disabled={inCorso} /> navigation.navigate('Scelta', { campo: 'tema' })} /> navigation.navigate('Scelta', { campo: 'lingua' })} ultima /> void Linking.openURL(INDIRIZZO_ASSISTENZA)} /> void Linking.openURL(INDIRIZZO_PRIVACY)} /> {testo('impostazioni.versione', { versione, build })} setScegliExport(false)} onChiuso={() => { if (daEsportare !== null) avvia(daEsportare); }} > {testo('impostazioni.esporta.scegli')} scegli({ tipo: 'tutto' })} accessibilityRole="button" > {testo('impostazioni.esporta.tutte')} {categorie.map((c) => ( scegli({ tipo: 'categoria', id: c.id })} accessibilityRole="button" > {c.nome} ))} ); } const creaStili = (colori: Colori) => StyleSheet.create({ /** `column-reverse`: la ScrollView è prima nell'albero ma sta sotto la Barra. Il perché è nel render. */ pagina: { flex: 1, backgroundColor: colori.fondo, flexDirection: 'column-reverse' }, contenuto: { padding: SPAZIO.l, gap: SPAZIO.xl }, versione: { ...TESTO.piccolo, color: colori.testoTenue, textAlign: 'center', marginTop: SPAZIO.s, }, riga: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', minHeight: 44, paddingVertical: SPAZIO.m, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colori.bordo, }, rigaTesto: { ...TESTO.corpo, color: colori.testo }, // Il foglio è quello dell'editor in GestioneCategorie: stessi angoli, // stesso tetto. Velo e ingresso li fa Foglio. foglio: { backgroundColor: colori.superficie, borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: SPAZIO.l, gap: SPAZIO.s, // Niente tetto in percentuale: dentro Foglio, che si misura sul // contenuto, «70%» diventava il 70% del foglio stesso e tagliava le // righe. Il limite lo mette `elenco`, che scorre. }, titoloFoglio: { ...TESTO.titolo, color: colori.testo }, // Una ScrollView nasce con flexGrow 1, e dentro un foglio che si misura // sul contenuto finisce alta zero: le righe c'erano, ma non si vedevano. // Con flexGrow 0 si misura sulle righe, e il tetto la fa scorrere quando // le categorie sono tante. elenco: { flexGrow: 0, maxHeight: 400 }, });