/** * Impostazioni: tema, lingua, export e import, informazioni. * * 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) stanno in logica-impostazioni.ts e in src/io: 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. */ import { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Alert, Linking, Pressable, ScrollView, StyleSheet, Text, View, } from 'react-native'; import Constants from 'expo-constants'; import * as DocumentPicker from 'expo-document-picker'; import * as Sharing from 'expo-sharing'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { elencoCategorie } from '../../data/categorie.ts'; import type { Categoria } from '../../domain/types.ts'; import type { Chiave } from '../../i18n/index.ts'; import { esporta } from '../../io/esporta.ts'; import type { Selezione } from '../../io/esporta.ts'; import Barra from '../componenti/Barra.tsx'; import { Foglio } from '../componenti/Foglio.tsx'; import { useApp } from '../contesto.ts'; import { SCELTE_LINGUA, TEMI } from '../logica-impostazioni.ts'; import type { PropsSchermata } from '../navigazione.ts'; import { avvisoExport, uriDiFile } from '../scambio.ts'; import { creaScrittoreUnico } from '../scritturaUnica.ts'; import type { Colori } from '../tema.ts'; import { RAGGIO, SPAZIO, TESTO, useColori } from '../tema.ts'; /** L'indirizzo pubblico dell'informativa: si aggiorna quando Paolo la pubblica. */ const INDIRIZZO_PRIVACY = 'https://duebytes.it/quantobasta/privacy'; export default function Impostazioni({ navigation }: PropsSchermata<'Impostazioni'>) { const { db, testo, tema, sceltaLingua, impostaTema, impostaLingua } = useApp(); const colori = useColori(); const stili = useMemo(() => creaStili(colori), [colori]); const sotto = useSafeAreaInsets().bottom; const [categorie, setCategorie] = useState([]); 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); /** * Una operazione per volta, col meccanismo di scritturaUnica.ts: due import * 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 scriviUnaSola = (operazione: () => Promise) => scrittoreUnico(async () => { setInCorso(true); try { await operazione(); } finally { setInCorso(false); } }); useEffect(() => { let vivo = true; 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]); const preferenza = (scrittura: Promise) => scrittura.catch(() => Alert.alert(testo('app.nome'), testo('errore.scrittura'))); const condividi = async (uri: string) => { try { if (await Sharing.isAvailableAsync()) { await Sharing.shareAsync(uri, { mimeType: 'application/zip', UTI: 'it.duebytes.quantobasta.ricettario', dialogTitle: testo('impostazioni.esporta'), }); } else { // Niente foglio di condivisione: almeno diciamo dov'è finito il file. Alert.alert(testo('impostazioni.esporta'), uri); } } catch { // L'archivio è già scritto e confermato dall'avviso precedente: solo il // passaggio al foglio di condivisione è fallito. Alert.alert(testo('app.nome'), testo('io.condividi.errore')); } }; const avvia = (selezione: Selezione) => { setDaEsportare(null); void scriviUnaSola(async () => { try { const esito = await esporta(db, selezione); const avviso = avvisoExport(esito); // Il nome del file fa da titolo e 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(esito.nome, testo(avviso.chiave, avviso.valori), [ { text: testo('modifica.annulla'), style: 'cancel' }, { text: testo('io.esporta'), onPress: () => void condividi(uriDiFile(esito.uri)) }, ]); } catch { // A rompersi è quasi sempre lo zip, non la lettura: errore.scrittura // («riprova») è il consiglio giusto per un disco pieno. Alert.alert(testo('app.nome'), testo('errore.scrittura')); } }); }; /** * 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 }); }; // Dal binario, non da app.json: è quello che Xcode e Gradle hanno stampato. const versione = Constants.nativeAppVersion ?? Constants.expoConfig?.version ?? ''; const build = Constants.nativeBuildVersion ?? '1'; return ( {testo('impostazioni.aspetto')} {testo('impostazioni.tema')} {TEMI.map((t) => ( void preferenza(impostaTema(t))} style={[stili.scelta, tema === t ? stili.sceltaAttiva : null]} accessibilityRole="button" accessibilityState={{ selected: tema === t }} > {testo(`impostazioni.tema.${t}` as Chiave)} ))} {testo('impostazioni.lingua')} {SCELTE_LINGUA.map((l) => ( void preferenza(impostaLingua(l))} style={[stili.scelta, sceltaLingua === l ? stili.sceltaAttiva : null]} accessibilityRole="button" accessibilityState={{ selected: sceltaLingua === l }} > {testo(`impostazioni.lingua.${l}` as Chiave)} ))} {testo('impostazioni.ricette')} setScegliExport(true)} disabled={inCorso} accessibilityRole="button" accessibilityState={{ disabled: inCorso }} > {testo('impostazioni.esporta')} {inCorso ? : null} void importa()} disabled={inCorso} accessibilityRole="button" accessibilityState={{ disabled: inCorso }} > {testo('impostazioni.importa')} {testo('impostazioni.info')} void Linking.openURL(INDIRIZZO_PRIVACY)} accessibilityRole="link" > {testo('impostazioni.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({ pagina: { flex: 1, backgroundColor: colori.fondo }, contenuto: { paddingHorizontal: SPAZIO.l, paddingBottom: SPAZIO.xxl }, // Come le intestazioni del foglio di GestioneCategorie: piccole, maiuscole, tenui. sezione: { ...TESTO.piccolo, textTransform: 'uppercase', color: colori.testoTenue, marginTop: SPAZIO.xl, marginBottom: SPAZIO.s, }, etichetta: { ...TESTO.etichetta, color: colori.testo, marginTop: SPAZIO.s }, scelte: { flexDirection: 'row', gap: SPAZIO.s, marginTop: SPAZIO.xs, flexWrap: 'wrap' }, scelta: { paddingVertical: SPAZIO.s, paddingHorizontal: SPAZIO.m, borderRadius: RAGGIO.campo, backgroundColor: colori.superficie, borderWidth: StyleSheet.hairlineWidth, borderColor: colori.bordo, }, sceltaAttiva: { backgroundColor: colori.accento, borderColor: colori.accento }, sceltaTesto: { ...TESTO.corpo, color: colori.testo }, sceltaTestoAttivo: { color: colori.accentoSopra }, 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 }, versione: { ...TESTO.piccolo, color: colori.testoTenue, textAlign: 'center', marginTop: SPAZIO.xxl, }, // 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 }, });