/** * Radice dell'app: apre il database, legge le preferenze, monta la navigazione. * * Il database si apre una volta all'avvio. Se non si apre, l'app lo dice e si * ferma: non parte vuota fingendo che vada tutto bene, perché un ricettario * vuoto sembra un ricettario perso (spec, sezione 9). * * Per lo stesso motivo qui NON si monta ``: il provider di * expo-sqlite apre il database per conto suo e trasforma il fallimento in un * throw che noi non intercettiamo, mentre a noi serve mostrare `errore.db`. * `apriDb()` la chiamiamo noi, e la connessione la distribuiamo con ContestoApp. * * La rotta iniziale è Principale, che contiene la pila dello sfoglia: la prima * cosa che si vede aprendo l'app sono i raggruppamenti, non l'elenco. * * Tema e lingua sono preferenze: si leggono dal database nella stessa attesa * che lo apre, e si cambiano a caldo dalla pagina Impostazioni. Finché non si * sa niente (nessuna preferenza salvata, o database ancora chiuso) si segue il * telefono. Il tema scelto si impone con `Appearance.setColorScheme`, così vale * anche per lo strato nativo (Alert, fogli di sistema, barra di stato) e * `useColorScheme()` lo restituisce già forzato ovunque. * * Questo file esporta solo il componente: i tipi delle rotte stanno in * navigazione.ts, che le schermate importano. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Appearance, StyleSheet, Text, useWindowDimensions, View, } from 'react-native'; import type { SQLiteDatabase } from 'expo-sqlite'; import { CommonActions, NavigationContainer, createNavigationContainerRef, } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { useFonts } from 'expo-font'; import * as Linking from 'expo-linking'; import { StatusBar } from 'expo-status-bar'; import { NavigationBar } from 'expo-navigation-bar'; // Dal percorso del peso, non dalla radice del pacchetto: l'indice di // @expo-google-fonts/caveat fa un `require` per ognuno dei quattro pesi, e chi // importa la radice se li porta dentro tutti e quattro — tre quarti di mega di // carattere che l'app non usa. `useFonts` è quello di expo-font: quello del // pacchetto dei font ne è una copia, e importarlo dalla radice rimetterebbe // dentro gli altri tre. import { Caveat_700Bold } from '@expo-google-fonts/caveat/700Bold'; import { apriDb } from '../data/db.ts'; import { leggiImpostazione, salvaImpostazione } from '../data/impostazioni.ts'; import { leggiRicetta } from '../data/ricette.ts'; import type { Lingua } from '../domain/lingua/index.ts'; import type { Chiave } from '../i18n/index.ts'; import { linguaDispositivo, t } from '../i18n/index.ts'; import { ContestoApp } from './contesto.ts'; import type { StatoApp } from './contesto.ts'; import { memoriaScorrimento } from './componenti/SchedaRicetta.tsx'; import { eFileDaImportare } from './logica-apertura.ts'; import { ricettaVisibile } from './logica-dosatore.ts'; import type { SceltaLingua, Tema } from './logica-impostazioni.ts'; import { CHIAVE_LINGUA, CHIAVE_TEMA, linguaEffettiva, linguaSalvata, schemaForzato, temaSalvato, } from './logica-impostazioni.ts'; import type { Rotta } from './logica-pannelli.ts'; import { CHIAVE_RICETTA_ULTIMA, dopoCambioLarghezza, duePannelli, larghezzaSfoglia, ricettaIniziale, } from './logica-pannelli.ts'; import type { ParametriNav } from './navigazione.ts'; import { ContestoPannelli } from './pannelli.ts'; import type { StatoPannelli } from './pannelli.ts'; import Anteprima from './schermate/Anteprima.tsx'; import Principale from './schermate/Principale.tsx'; import Dosatore from './schermate/Dosatore.tsx'; import GestioneCategorie from './schermate/GestioneCategorie.tsx'; import Impostazioni from './schermate/Impostazioni.tsx'; import Modifica from './schermate/Modifica.tsx'; import type { Colori } from './tema.ts'; import { MANO, TESTO, useColori } from './tema.ts'; const Stack = createNativeStackNavigator(); const navigazione = createNavigationContainerRef(); /** Porta all'anteprima l'URL con cui il sistema ha aperto l'app, se è un file. */ function apri(url: string | null) { if (eFileDaImportare(url) && navigazione.isReady()) navigazione.navigate('Anteprima', { uri: url }); } type Fase = | { nome: 'apertura' } | { nome: 'pronto'; db: SQLiteDatabase } | { nome: 'errore' }; export default function App() { const [fase, setFase] = useState({ nome: 'apertura' }); const [tema, setTema] = useState('sistema'); const [sceltaLingua, setSceltaLingua] = useState('sistema'); // La ricetta del pannello di destra (sul telefono, l'ultima aperta). const [selezionata, setSelezionata] = useState(null); const lingua = linguaEffettiva(sceltaLingua, linguaDispositivo()); const colori = useColori(); useEffect(() => { Appearance.setColorScheme(schemaForzato(tema)); }, [tema]); // Il serif dei titoli si carica dentro la stessa attesa che apre il database, // così non si vede il titolo cambiare carattere a schermata già aperta. Se il // caricamento fallisce si va avanti lo stesso col carattere di sistema: per un // font non si tiene fuori l'utente dal suo ricettario. const [fontPronto, erroreFont] = useFonts({ [MANO]: Caveat_700Bold }); useEffect(() => { let vivo = true; apriDb() .then(async (db) => { // Le preferenze si leggono prima di dichiararsi pronti: così la prima // schermata vera nasce già col tema e la lingua giusti, senza un // fotogramma «come il telefono» che poi salta. const [temaLetto, linguaLetta] = await Promise.all([ leggiImpostazione(db, CHIAVE_TEMA), leggiImpostazione(db, CHIAVE_LINGUA), ]); // L'ultima ricetta aperta, se c'è ancora: si riapre lì. Se leggerla // fallisce si parte dall'invito — per la ricetta di ieri non si tiene // l'utente fuori dal ricettario. La stringa vuota è «nessuna» (la // scrive scegli), e allora non c'è niente da cercare. const ultima = await leggiImpostazione(db, CHIAVE_RICETTA_ULTIMA).catch((errore) => { console.warn("lettura dell'ultima ricetta aperta fallita", errore); return null; }); const esiste = ultima !== null && ultima !== '' && ricettaVisibile( await leggiRicetta(db, ultima).catch((errore) => { console.warn('lettura della ricetta da riaprire fallita', errore); return null; }), ); if (!vivo) return; setTema(temaSalvato(temaLetto)); setSceltaLingua(linguaSalvata(linguaLetta)); setSelezionata(ricettaIniziale(ultima, esiste)); setFase({ nome: 'pronto', db }); }) .catch((errore) => { console.warn('apertura del database fallita', errore); if (vivo) setFase({ nome: 'errore' }); }); return () => { vivo = false; }; }, []); const contesto = useMemo( () => fase.nome === 'pronto' ? { db: fase.db, lingua, testo: (chiave: Chiave, valori?: Record) => t(chiave, lingua, valori), tema, sceltaLingua, // Prima lo stato, poi il disco: il cambio si vede subito, e se la // scrittura fallisce si è persa una preferenza, non una ricetta. impostaTema: async (nuovo) => { setTema(nuovo); await salvaImpostazione(fase.db, CHIAVE_TEMA, nuovo); }, impostaLingua: async (nuova) => { setSceltaLingua(nuova); await salvaImpostazione(fase.db, CHIAVE_LINGUA, nuova); }, } : null, [fase, lingua, tema, sceltaLingua], ); // I due pannelli: sugli schermi larghi le categorie e le ricette stanno a // sinistra, la ricetta aperta a destra. All'avvio il pannello riparte // dall'ultima ricetta aperta, letta qui sopra insieme alle preferenze. const { width: larghezza, height: altezza } = useWindowDimensions(); const due = duePannelli(larghezza); // Scegliere una ricetta la ricorda sul disco, per la prossima apertura; // null la cancella scrivendo la stringa vuota, che ricettaIniziale legge // come «nessuna». Passando a un'altra ricetta si dimentica dove si era // arrivati scorrendo la precedente: ritornandoci si riparte dall'alto, // come sul telefono. const scegli = useCallback( (id: string | null) => { if (selezionata !== null && selezionata !== id) memoriaScorrimento.dimentica(selezionata); setSelezionata(id); if (fase.nome === 'pronto') { // Un'«ultima ricetta» persa non vale un avviso all'utente: la // scrittura va da sé, e se fallisce resta solo una riga nel log. salvaImpostazione(fase.db, CHIAVE_RICETTA_ULTIMA, id ?? '').catch((errore) => { console.warn("salvataggio dell'ultima ricetta aperta fallito", errore); }); } }, [fase, selezionata], ); const pannelli = useMemo( () => ({ due, larghezzaSfoglia: larghezzaSfoglia(larghezza, altezza), selezionata, scegli, apriRicetta: (id) => { scegli(id); if (due) return; // Sul telefono aprire da un elenco è sempre un'apertura nuova: si // riparte dall'alto, comunque si sia lasciata la pagina l'ultima volta // (la freccia, il tasto indietro di Android, il bordo di iOS). La // piega non passa di qui, e il punto lo ritrova. memoriaScorrimento.dimentica(id); if (navigazione.isReady()) navigazione.navigate('Dosatore', { ricettaId: id }); }, }), [due, larghezza, altezza, selezionata, scegli], ); // Il Duo che si piega o si apre: la larghezza passa da un modo all'altro // sotto le dita, e l'utente deve ritrovare la ricetta che guardava. Cosa // diventa la pila lo decide dopoCambioLarghezza; qui la si applica. Le rotte // che c'erano restano gli stessi oggetti (stessa chiave, stesso stato // annidato), così la pila dello sfoglia dentro Principale non si ricostruisce; // solo il Dosatore aggiunto piegando è nuovo, e React Navigation gli dà una // chiave sua. const duePrima = useRef(due); useEffect(() => { const prima = duePrima.current; duePrima.current = due; if (prima === due || !navigazione.isReady()) return; const stato = navigazione.getRootState(); if (stato === undefined) return; const disposizione = { rotte: stato.routes.map((r) => ({ name: r.name, params: r.params as Rotta['params'], key: r.key })), selezionata, }; const dopo = dopoCambioLarghezza(prima, due, disposizione); // Quando non c'è niente da cambiare dopoCambioLarghezza restituisce lo // stesso oggetto: è questo a dire se la pila va rifatta, non un conteggio. if (dopo === disposizione) return; setSelezionata(dopo.selezionata); const perChiave = new Map(stato.routes.map((r) => [r.key, r])); const rotte = dopo.rotte.map((r) => (r.key === undefined ? r : perChiave.get(r.key) ?? r)); navigazione.dispatch( CommonActions.reset({ ...stato, routes: rotte as typeof stato.routes, index: rotte.length - 1, }), ); }, [due, selezionata]); // Il sistema puo' aprire l'app con un file (un .quantobasta da WhatsApp, da // Files, da una mail): quel file va all'anteprima. Ad app già aperta l'URL // arriva come evento, e si ascolta l'evento e non uno stato: lo stesso file // aperto due volte di fila è lo stesso URL, e uno stato non cambierebbe. A // freddo l'URL c'è prima che il navigatore esista, e lo si legge in `onReady`. useEffect(() => { const ascolto = Linking.addEventListener('url', ({ url }) => apri(url)); return () => ascolto.remove(); }, []); if (fase.nome === 'apertura' || (!fontPronto && !erroreFont)) { return ( ); } if (contesto === null) { return ( {t('errore.db', lingua)} ); } return ( {/* La barra dei tasti di Android, sotto: col tema forzato su «Scuro» e il telefono in chiaro restava bianca, l'unica cosa chiara dell'app. «auto» la fa seguire lo schema che Appearance ha già forzato, come fa StatusBar sopra. Su iOS non fa niente. */} apri(Linking.getLinkingURL())}> : il // perché sta nell'intestazione di quel componente. Qui resta al // navigatore quello che sa fare meglio di noi — la transizione // fra le schermate e il gesto di trascinare dal bordo. headerShown: false, contentStyle: { backgroundColor: colori.fondo }, }} > ); } /** * Il marchio nell'intestazione della schermata principale: il nome scritto a * mano e, sotto, la riga che dice cosa fa l'app. * * "Quanto Basta" non si traduce, è il nome; il sottotitolo sì, ed è anche la * riga con cui l'app si trova cercando negli store. Restano attaccati perché * separati non vogliono dire niente: il nome da solo non spiega, la riga da * sola non è un marchio. */ export function Marchio({ colori, lingua }: { colori: Colori; lingua: Lingua }) { return ( {t('app.nome', lingua)} {t('app.sottotitolo', lingua)} ); } const stili = StyleSheet.create({ centro: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 }, marchio: { alignItems: 'center', justifyContent: 'center' }, errore: { fontSize: 16, textAlign: 'center' }, });