/** * 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 è Categorie: 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 { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Appearance, StyleSheet, Text, View } from 'react-native'; import type { SQLiteDatabase } from 'expo-sqlite'; import { 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 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 { eFileDaImportare } from './logica-apertura.ts'; import type { SceltaLingua, Tema } from './logica-impostazioni.ts'; import { CHIAVE_LINGUA, CHIAVE_TEMA, linguaEffettiva, linguaSalvata, schemaForzato, temaSalvato, } from './logica-impostazioni.ts'; import type { ParametriNav } from './navigazione.ts'; import Anteprima from './schermate/Anteprima.tsx'; import Categorie from './schermate/Categorie.tsx'; import Elenco from './schermate/Elenco.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'); 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), ]); if (!vivo) return; setTema(temaSalvato(temaLetto)); setSceltaLingua(linguaSalvata(linguaLetta)); 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], ); // 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' }, });