/** * 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, cioè il menù in basso: la prima cosa che si * vede aprendo l'app è la sezione Ricette, coi raggruppamenti e 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, Alert, Appearance, StyleSheet, Text, useWindowDimensions, View, } from 'react-native'; import type { SQLiteDatabase } from 'expo-sqlite'; import { CommonActions, StackActions, DarkTheme, DefaultTheme, NavigationContainer, TabActions, 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 { useShareIntent } from 'expo-share-intent'; 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 { leggiLink } from '../io/link.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, linkCondiviso } 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, portaSuRicette, 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 Modifica from './schermate/Modifica.tsx'; import Scelta from './schermate/Scelta.tsx'; import RicetteItaliane from './schermate/RicetteItaliane.tsx'; import RegioneItaliana from './schermate/RegioneItaliana.tsx'; import RicettaItaliana from './schermate/RicettaItaliana.tsx'; import type { Colori } from './tema.ts'; import { MANO, TESTO, useColori, useSchema } 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 }); } /** * Cambia la sezione del menù su Ricette, senza toccare la pila sopra * Principale. Non è `navigazione.navigate('Principale', { screen: 'Ricette' })`: * lo StackRouter riusa la rotta Principale solo quando è quella in cima, e * quando sopra c'è restata un'altra pagina (la Modifica, piegando il Duo) ne * impilerebbe una seconda copia. `jumpTo`, mirato con `target` alla chiave del * navigatore annidato nella tab, cambia la sezione da sotto qualunque cosa ci * sia sopra, senza toccare la pila. La prima rotta della pila è sempre * Principale (vedi logica-pannelli.ts). */ function vaiSuSezioneRicette() { if (!navigazione.isReady()) return; const chiaveMenu = navigazione.getRootState()?.routes[0]?.state?.key; if (chiaveMenu === undefined) return; navigazione.dispatch({ ...TabActions.jumpTo('Ricette'), target: chiaveMenu }); } 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(); const scuro = useSchema() === 'dark'; // Il tema di React Navigation, sennò la tab bar nativa di Android resta // quella di DefaultTheme (fondo bianco, testo scuro): sul tema scuro // dell'app era l'unica cosa chiara rimasta. `fonts` resta quello del tema // di sistema scelto: qui non abbiamo un carattere di sistema nostro da // dargli. const temaNav = useMemo(() => { const base = scuro ? DarkTheme : DefaultTheme; return { ...base, dark: scuro, colors: { ...base.colors, background: colori.fondo, card: colori.superficie, text: colori.testo, primary: colori.accento, border: colori.bordo, notification: colori.accento, }, }; }, [scuro, colori]); 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) { // Sugli schermi larghi la ricetta va nel pannello di destra, che sta // nella sezione Ricette: se si arriva da un'altra (la ricerca), il // menù ci passa da solo. vaiSuSezioneRicette(); 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; // La sezione del menù attiva, dal navigatore annidato dentro Principale // (la prima rotta): serve a dopoCambioLarghezza per non impilare la // ricetta del pannello sopra Impostazioni, Categorie o Cerca, che con lei // non c'entrano (la regressione della revisione finale). const menu = stato.routes[0]?.state; const sezione = menu === undefined || menu.index === undefined ? null : (menu.routes[menu.index]?.name ?? null); const disposizione = { rotte: stato.routes.map((r) => ({ name: r.name, params: r.params as Rotta['params'], key: r.key })), selezionata, sezione, }; 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, }), ); // Aprendo il Duo con una ricetta a tutto schermo la ricetta passa nel // pannello, che sta nella sezione Ricette: il menù ci va con lei (non se si // sta chiudendo, o se due schermi larghi si susseguono senza una ricetta). if (portaSuRicette(prima, due, dopo.selezionata)) vaiSuSezioneRicette(); }, [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(); }, []); // Condividi dal browser (o da un'altra app) verso Quanto Basta: arriva un // link, lo si legge come fa «Da un link» e si apre la schermata di scrittura // già compilata. Su iPhone passa dall'estensione di condivisione, che apre // l'app con quantobasta://dataUrl=...; su Android è un intent SEND. Tutti e // due li raccoglie expo-share-intent. Il navigatore deve esserci già: a // freddo il link arriva prima, e aspetta `navPronta`. const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent(); const [navPronta, setNavPronta] = useState(false); const [lettura, setLettura] = useState(false); // Una lettura alla volta: una seconda condivisione mentre si legge la prima // (o lo stesso intent che la libreria rimanda tornando in primo piano) // aprirebbe due schermate una sopra l'altra. const inLettura = useRef(false); useEffect(() => { if (!hasShareIntent || !navPronta || fase.nome !== 'pronto') return; const indirizzo = linkCondiviso(shareIntent); resetShareIntent(); if (inLettura.current) return; if (indirizzo === null) { Alert.alert(t('app.nome', lingua), t('condividi.nessunLink', lingua)); return; } // Niente guardia «vivo» qui: resetShareIntent qui sopra fa rigirare // l'effetto, e la sua pulizia butterebbe via la lettura appena partita. // App non si smonta mai. inLettura.current = true; setLettura(true); leggiLink(indirizzo) .then((esito) => { if (esito.tipo === 'errore') { Alert.alert(t('app.nome', lingua), t(esito.chiave, lingua)); return; } // push e non navigate: con una Modifica già aperta, navigate le // cambierebbe i parametri e la ricetta condivisa non si vedrebbe. navigazione.dispatch(StackActions.push('Modifica', { ricettaId: null, importa: esito.ricetta })); }) .finally(() => { inLettura.current = false; setLettura(false); }); }, [hasShareIntent, navPronta, fase.nome]); 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. */} { setNavPronta(true); 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 }, }} > {lettura ? ( {t('condividi.lettura', lingua)} ) : null} ); } /** * 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' }, // Sopra tutto, mentre si legge la pagina condivisa: si vede che l'app ha // ricevuto il link, e non si tocca niente sotto finché non ha finito. lettura: { alignItems: 'center', justifyContent: 'center', gap: 12, opacity: 0.96 }, });