/** * Il dosatore: la schermata che si guarda mentre si cucina. * * Due modi di riscalare, entrambi salvati come RICHIESTA e non come fattore: * 1. il controllo delle porzioni, che non compare se la ricetta non le * dichiara (2 ricette su 12 dell'archivio vero); * 2. il tocco sulla quantità di un ingrediente, cioè "ne ho davvero * questa quantità": è la funzione che dà valore all'app. * * All'apertura, e ad ogni ritorno sulla schermata, la ricetta si rilegge e il * fattore si ricalcola con riscaloCorrente() dalla richiesta salvata. Se la * ricetta nel frattempo è cambiata e la richiesta non è più risolvibile si * torna alle dosi originali senza dire niente: non è un guasto. * * In alto a destra la matita, che apre questa ricetta in modifica: è l'unico * ingresso alla scrittura di una ricetta che esiste già, e quindi anche l'unica * via verso «Elimina ricetta». * * La foto sta in una copertina in cima, col titolo scritto sopra, ed è la * stessa forma della tessera nell'elenco: aprendo una ricetta si vede la * tessera ingrandirsi. Per un pezzo qui la foto non c'era affatto, e la ragione * era giusta — una fotografia in cima spinge in basso i numeri per cui si è * aperta la schermata. La copertina la risolve scorrendo via: appena si comincia * a lavorare esce dallo schermo e il titolo passa nell'intestazione, così i * numeri salgono in cima e la foto non è più in mezzo. * * Senza foto la copertina non c'è e il titolo sta in alto da subito: non si * mostra un riquadro vuoto grande metà schermata. * * In fondo, il procedimento — che fino a ieri non si vedeva da nessuna parte: * si scriveva in modifica e non lo si poteva più rileggere. Compare solo se c'è * qualcosa da leggere: i procedimenti veri sono appunti di due righe, e alcune * ricette non ne hanno affatto. * * Lo schermo non si spegne: qui si hanno le mani sporche. * * Ogni decisione (quale fascia, quando compare il controllo delle porzioni, * quando le intestazioni dei gruppi, come si presenta un q.b., cosa fare * quando riscaloCorrente torna null) sta in logica-dosatore.ts, non qui: qui * c'è solo il disegno. */ import { useCallback, useMemo, useRef, useState } from 'react'; import { Alert, Image, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useFocusEffect } from '@react-navigation/native'; import { useKeepAwake } from 'expo-keep-awake'; import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; import * as Clipboard from 'expo-clipboard'; import * as Sharing from 'expo-sharing'; import type { PropsSchermata } from '../navigazione.ts'; import { useApp } from '../contesto.ts'; import type { Ricetta, Richiesta } from '../../domain/types.ts'; import { tuttiGliIngredienti } from '../../domain/types.ts'; import { leggiRicetta } from '../../data/ricette.ts'; import { percorsoFoto } from '../../data/foto.ts'; import { dimenticaRiscalo, riscaloCorrente, salvaRiscalo } from '../../data/riscalo.ts'; import { FATTORE_ORIGINALE, porzioniScalate, risolviRichiesta, scala } from '../../domain/scaling.ts'; import { vocabolario } from '../../domain/lingua/index.ts'; import { numero } from '../../domain/format.ts'; import { esporta } from '../../io/esporta.ts'; import { ricettaInTesto } from '../../domain/testo.ts'; import { uriDiFile } from '../scambio.ts'; import type { StatoRiscalo } from '../logica-dosatore.ts'; import { mostraProcedimento, titoloInIntestazione, mostraControlloPorzioni, mostraIntestazioneGruppo, propsCampoQuantita, quantitaPerRichiesta, ricettaVisibile, richiestaPorzioni, ricettaComeSulloSchermo, scrittaPorzioni, statoDaRiscalo, } from '../logica-dosatore.ts'; import { creaScrittoreUltimo } from '../scritturaUnica.ts'; import CampoQuantita from '../componenti/CampoQuantita.tsx'; import Segno from '../componenti/Segno.tsx'; import { segnoCondividi } from '../segni.ts'; import Barra from '../componenti/Barra.tsx'; import FasciaRiscalo from '../componenti/FasciaRiscalo.tsx'; import type { Colori } from '../tema.ts'; import { RAGGIO, SPAZIO, TESTO, useColori } from '../tema.ts'; /** * Quanto è alta la copertina. Duecentodieci punti: la stessa proporzione della * tessera nell'elenco, così aprendo una ricetta la tessera sembra ingrandirsi * invece che essere sostituita da un'altra cosa. * * Sta qui e non negli stili perché serve anche al conto dello scorrimento — * `titoloInIntestazione` la riceve — e due numeri scritti in due posti che * devono restare uguali sono un numero che prima o poi diverge. */ const ALTEZZA_COPERTINA = 210; export default function Dosatore({ route, navigation }: PropsSchermata<'Dosatore'>) { // La barra dei tasti di Android (e l'indicatore home dell'iPhone) sta sopra // l'app, che disegna fino al bordo: senza questo il tondo col «+» finiva // mezzo sotto i tasti, sul Redmi di prova. const sotto = useSafeAreaInsets().bottom; useKeepAwake(); const { db, lingua, testo } = useApp(); const colori = useColori(); const stili = useMemo(() => creaStili(colori), [colori]); const voc = useMemo(() => vocabolario(lingua), [lingua]); const { ricettaId } = route.params; const [ricetta, setRicetta] = useState(null); const [richiesta, setRichiesta] = useState(null); const [fattore, setFattore] = useState(FATTORE_ORIGINALE); // La lettura non è riuscita. Senza questo stato la schermata restava bianca // per sempre, senza una parola e senza modo di distinguere "sto caricando" // da "sono rotta": è l'unica delle cinque che non lo diceva. const [errore, setErrore] = useState(false); /** Il percorso della foto sul disco, o null: la copertina c'è solo se c'è. */ const [foto, setFoto] = useState(null); /** * Se il titolo è già passato nell'intestazione. È uno stato che cambia una * volta sola per direzione — la soglia la decide titoloInIntestazione — e non * ad ogni pixel di scorrimento: aggiornarlo di continuo ridisegnerebbe la * schermata mentre il dito la trascina. */ const [titoloSopra, setTitoloSopra] = useState(false); // Le stesse due cose degli stati qui sopra, ma leggibili DENTRO il tocco: // `mostrato` è quello che si vede adesso, `salvato` quello che il disco ha // davvero. Servono perché fra due tocchi ravvicinati React non ridisegna, e // chi legge dagli stati legge il tocco prima: quattro «+» di fila su otto // porzioni chiederebbero tutti nove, invece di arrivare a dodici. Sono ref e // non stati per lo stesso motivo per cui lo è la guardia di scritturaUnica.ts: // devono valere subito, non al render successivo. const mostrato = useRef({ richiesta: null, fattore: FATTORE_ORIGINALE }); const salvato = useRef({ richiesta: null, fattore: FATTORE_ORIGINALE }); // Una scrittura per volta, e delle ravvicinate vale l'ultima: il perché sta in // scritturaUnica.ts. Qui non si può usare la guardia che le butta — i tocchi // dal secondo in poi sono proprio quelli che portano da otto porzioni a dodici. const scriviUltima = useRef(creaScrittoreUltimo()).current; /** Porta a schermo uno stato, e lo lascia leggibile al tocco successivo. */ const mostra = useCallback((stato: StatoRiscalo) => { mostrato.current = stato; setRichiesta(stato.richiesta); setFattore(stato.fattore); }, []); useFocusEffect( useCallback(() => { let vivo = true; (async () => { try { const r = await leggiRicetta(db, ricettaId); if (!vivo) return; if (!ricettaVisibile(r)) { // Sparita o cancellata mentre eravamo altrove: si torna da dove si // veniva, che sia l'elenco filtrato o la ricerca della schermata // principale. ricettaVisibile() controlla anche cancellataIl, non // solo null: leggiRicetta restituisce anche le tombstone, perché // servono all'export, e senza questo ramo il dosatore mostrerebbe le // dosi di una ricetta che nell'elenco non c'è più. navigation.goBack(); return; } const corrente = await riscaloCorrente(db, r); // La foto si risolve qui perché percorsoFoto() controlla che il file // esista davvero, e non può aspettare una promessa. Se il // file non c'è — succede importando un archivio senza le foto — la // copertina semplicemente non compare, e non si dice niente. const scatto = await percorsoFoto(r.foto); if (!vivo) return; setFoto(scatto); const stato = statoDaRiscalo(corrente); setRicetta(r); mostra(stato); salvato.current = stato; setErrore(false); } catch { // Il ricettario non si è aperto: si dice, come nelle altre quattro // schermate. Le dosi che si stavano guardando restano a schermo se // c'erano già — questa è una rilettura al ritorno sulla schermata, non // per forza la prima — e il messaggio prende il posto della ricetta // solo quando non c'è mai stato niente da mostrare. if (vivo) setErrore(true); } })(); return () => { vivo = false; }; }, [db, ricettaId, navigation, mostra]), ); /** * Le dosi nuove si vedono subito e il disco insegue, come il riordino in * GestioneCategorie.tsx: aspettare il disco per muovere un numero sotto il * dito si sente, e se il disco rifiuta si torna indietro dicendolo — senza, * a schermo restavano dosi che non erano state salvate, e al rientro nella * schermata tornavano com'erano senza che nessuno avesse detto niente. * * Si torna a `salvato`, cioè a quello che il disco ha DAVVERO, e non allo * stato di prima di questo tocco: fra i due può esserci passato un altro * tocco che invece è stato scritto. */ const cambia = useCallback( (nuovo: StatoRiscalo, scrivi: () => Promise) => { mostra(nuovo); void scriviUltima(async () => { await scrivi(); salvato.current = nuovo; }).catch(() => { mostra(salvato.current); Alert.alert(testo('app.nome'), testo('errore.scrittura')); }); }, [mostra, scriviUltima, testo], ); /** * Un riscalo nuovo: si salva la richiesta, il fattore si ricalcola da lei. * null significa "il tocco non chiedeva niente" (il «meno» a una porzione): * non si tocca la richiesta salvata. */ const applica = useCallback( (nuova: Richiesta | null) => { if (nuova === null || ricetta === null) return; const f = risolviRichiesta(ricetta, nuova); if (f === null) return; // valori assurdi: non si fa nulla cambia({ richiesta: nuova, fattore: f }, () => salvaRiscalo(db, ricetta.id, nuova)); }, [db, ricetta, cambia], ); /** * Un tocco su «+» o su «-»: una porzione in più o in meno di quelle mostrate * ADESSO, che si leggono dal riferimento e non dall'ultimo disegno. È la sola * differenza che conta fra un tocco lento e quattro di fila: con quattro * tocchi ravvicinati il disegno è fermo al primo, e leggendo da lì tutti e * quattro chiederebbero nove porzioni invece di arrivare a dodici. * * Quante porzioni siano quelle mostrate lo sa porzioniScalate(), la stessa * che le calcola per il numero a schermo: qui non si rifà il conto. */ const passoPorzioni = useCallback( (direzione: -1 | 1) => { if (ricetta === null) return; const mostrate = porzioniScalate(ricetta, mostrato.current.fattore); if (mostrate === null) return; applica(richiestaPorzioni(mostrate, direzione)); }, [ricetta, applica], ); const tornaAgliOriginali = useCallback(() => { if (ricetta === null) return; cambia({ richiesta: null, fattore: FATTORE_ORIGINALE }, () => dimenticaRiscalo(db, ricetta.id)); }, [db, ricetta, cambia]); const scalata = useMemo( () => (ricetta === null ? null : scala(ricetta, fattore, voc)), [ricetta, fattore, voc], ); // L'unità con cui ogni ingrediente è SCRITTO nella ricetta, per id. La riga // scalata porta quella mostrata, che può già essere il risultato di una // conversione ("kg" dove la ricetta dice "g"): ripartire da lei vorrebbe dire // convertire due volte. const unitaOriginali = useMemo( () => new Map( ricetta === null ? [] : tuttiGliIngredienti(ricetta).map((i) => [i.id, i.unita]), ), [ricetta], ); // Prima del bianco del caricamento: un guasto in lettura si dice, e la // schermata bianca da sola non lo distingue da "sto ancora leggendo". // // `&& ricetta === null` è la sola differenza dalle gemelle, ed è voluta. // Loro mostrano elenchi: sostituirne uno con un messaggio non toglie niente // a nessuno. Qui ci sono i numeri che uno sta usando davanti ai fornelli, e // questa lettura si rifà a ogni ritorno sulla schermata: cancellare dosi // buone perché non si è potuta rifare la lettura vuol dire togliere di mano // una cosa che funzionava per annunciare un guasto che in quel momento non // serve sapere. Il messaggio prende il posto della ricetta solo quando non // c'è mai stato niente da mostrare. Se un giorno servirà dirlo anche con le // dosi a schermo, si dirà senza portarle via. if (errore && ricetta === null) { return ( {testo('errore.db')} ); } if (ricetta === null || scalata === null) return ; const porzioni = scalata.porzioni; const numeroGruppi = scalata.gruppi.length; // Il «meno» si spegne quando sotto non c'è una porzione da togliere: null vuol // dire "questo tocco non chiederebbe niente", e la stessa null spegne il tasto // e non lo fa più annunciare come comando buono. A saperlo è richiestaPorzioni, // non questo file: qui non si riscrive «se le porzioni sono una». Parte dal // numero mostrato, che è quello vero: il riscalo per ingrediente può averlo // portato sotto la porzione. Il «più» non si spegne mai, e cosa chiede lo // decide passoPorzioni al momento del tocco, non qui. const conPorzioni = mostraControlloPorzioni(porzioni); const menoPorzioni = conPorzioni ? richiestaPorzioni(porzioni, -1) : null; /** * Cosa si legge accanto a «Porzioni». Il numero passa da `numero()` e non va * dritto dentro il : le porzioni scalate non sono più un intero — * mezza porzione è la risposta a "ho un uovo solo" — e vanno scritte col * separatore della lingua, «0,5» e non «0.5», come le scrive la fascia. * Quando sono così poche che il numero direbbe «0», al posto suo va una * frase: a deciderlo è scrittaPorzioni, qui si traduce e basta. */ const porzioniALeggere = (quante: number): string => { const scritta = scrittaPorzioni(quante); return scritta.tipo === 'numero' ? numero(scritta.porzioni, voc) : testo(scritta.chiave); }; /** * Il menu «Altro»: condividere il file, o copiare la ricetta come testo. Un * `Alert` a tre righe funziona uguale su iOS e Android, e per due comandi * non serve altro. */ const apriAltro = () => Alert.alert(ricetta.titolo, undefined, [ { text: testo('dosatore.condividi'), onPress: () => void condividiRicetta() }, { text: testo('dosatore.copia'), onPress: () => void copiaComeTesto() }, { text: testo('modifica.annulla'), style: 'cancel' }, ]); /** Esporta questa sola ricetta in un archivio, e lo passa al foglio di condivisione del sistema. */ const condividiRicetta = async () => { let esito; try { esito = await esporta(db, { tipo: 'ricetta', id: ricettaId }); } catch { // A rompersi è quasi sempre lo zip: errore.scrittura («riprova») è il // consiglio giusto per un disco pieno. Alert.alert(testo('app.nome'), testo('errore.scrittura')); return; } try { if (await Sharing.isAvailableAsync()) { await Sharing.shareAsync(uriDiFile(esito.uri), { mimeType: 'application/zip', UTI: 'it.duebytes.quantobasta.ricettario', dialogTitle: testo('dosatore.condividi'), }); } else { // Niente foglio di condivisione: almeno diciamo dov'è finito il file. Alert.alert(esito.nome, esito.uri); } } catch { // L'archivio è già scritto: solo il passaggio al foglio è fallito. Alert.alert(testo('app.nome'), testo('io.condividi.errore')); } }; /** La ricetta come testo negli appunti, con le dosi che si vedono a schermo. */ const copiaComeTesto = async () => { const daCopiare = scalata === null ? ricetta : ricettaComeSulloSchermo(ricetta, scalata); await Clipboard.setStringAsync(ricettaInTesto(daCopiare, voc)); Alert.alert(testo('dosatore.copiata')); }; return ( {/* Il titolo compare qui solo quando la copertina è quasi uscita: fino a lì è scritto sulla foto, e averlo in due posti insieme è ripetizione. Senza copertina — ricetta senza foto — sta qui da subito. La matita è l'ingresso alla modifica di una ricetta che esiste già: sta qui e non nell'elenco perché è qui che ci si accorge che una dose è sbagliata, cioè mentre si cucina. È anche l'unica via verso «Elimina ricetta», che vive in fondo a Modifica.tsx. */} navigation.navigate('Modifica', { ricettaId })} hitSlop={12} accessibilityRole="button" accessibilityLabel={testo('dosatore.modifica')} > } /> {/* automaticallyAdjustKeyboardInsets: toccando una quantità in fondo alla lista si apre un campo di testo, e senza questo la tastiera gli si sovrappone — si scrive un numero che non si vede. Lo fa iOS da sé alla ScrollView (parte da false); su Android la finestra si ridimensiona già, e scansarsi due volte alzerebbe troppo il contenuto. Stessa riga e stesso motivo di Modifica.tsx. */} { const sopra = titoloInIntestazione( e.nativeEvent.contentOffset.y, foto === null ? 0 : ALTEZZA_COPERTINA, ); // Si riscrive solo quando la soglia viene attraversata: senza questo // confronto sarebbe un setState per fotogramma. if (sopra !== titoloSopra) setTitoloSopra(sopra); }} > {foto === null ? ( {ricetta.titolo} ) : ( {/* Lo stesso velo della tessera nell'elenco, e per la stessa ragione: il titolo bianco sopra una fotografia è leggibile solo se sotto, dove sta il testo, la fotografia non c'è più. */} {ricetta.titolo} )} {/* Il controllo delle porzioni sta SOPRA la fascia, e non è una questione di gusto: la fascia compare solo quando un riscalo c'è, cioè dal primo tocco in poi, e comparendo sopra spingeva giù di una cinquantina di punti tutto quello che aveva sotto. Il «+» si spostava da sotto il dito, e il secondo tocco allo stesso punto cadeva su «Dosi originali», che nella fascia sta esattamente lì — stesso bordo destro, stessa altezza. Da otto porzioni: il primo tocco portava a nove, il secondo tornava a otto cancellando il riscalo dal disco, il terzo a nove, il quarto a otto. Quattro tocchi «non facevano niente». Qui sotto la fascia sposta solo la lista, dove non si tocca due volte di fila lo stesso punto. */} {conPorzioni && ( {testo('dosatore.porzioni')} {/* `disabled` a una porzione: il «meno» lì non cambierebbe niente nemmeno senza — applica() su null non fa nulla — ma senza `disabled` Pressable non lo passa ad accessibilityState, e il lettore di schermo continua ad annunciare come comando buono un tasto che non fa niente. Stessa cura delle frecce ai bordi in GestioneCategorie.tsx, colore spento compreso: un tasto morto che resta acceso è una bugia. */} passoPorzioni(-1)} > - {porzioniALeggere(porzioni)} passoPorzioni(1)} > + )} {scalata.gruppi.map((gruppo) => ( {mostraIntestazioneGruppo(gruppo.nome, numeroGruppi) && ( {gruppo.nome} )} {gruppo.ingredienti.map((ing) => ( {ing.nome} applica({ tipo: 'ingrediente', ingredienteId: ing.id, // Il numero digitato è nell'unità mostrata sulla riga // ("1,16 kg"), la richiesta va salvata in quella scritta // nella ricetta (580 g): la conversione la rifà // quantitaPerRichiesta dall'unità originale, senza passare // dal numero arrotondato che si legge sulla riga. quantita: quantitaPerRichiesta( digitato, ing.quantitaEsatta ?? 0, unitaOriginali.get(ing.id) ?? null, voc, ), }) } /> ))} ))} {/* Il procedimento, dopo le dosi: è dove lo si cerca quando si è finito di pesare. Compare solo se c'è qualcosa da leggere — lo decide mostraProcedimento — perché su una ricetta che è solo dosi un'intestazione sopra il nulla sembrerebbe un pezzo mancante. */} {mostraProcedimento(ricetta.descrizione) && ( {testo('dosatore.procedimento')} {/* Il testo dell'utente non viene mai interpretato: rende testo semplice, e gli a capo che ha scritto restano quelli. */} {ricetta.descrizione.trim()} )} ); } const creaStili = (colori: Colori) => StyleSheet.create({ schermo: { flex: 1, backgroundColor: colori.fondo }, scorrevole: { flex: 1 }, // Senza flex: si combina con `schermo`, che il flex ce l'ha già. Stessi // due stili di Modifica.tsx, che mostra lo stesso messaggio. centro: { alignItems: 'center', justifyContent: 'center', padding: SPAZIO.xxl }, errore: { ...TESTO.etichetta, color: colori.errore, textAlign: 'center' }, contenuto: { paddingBottom: SPAZIO.xxl }, copertina: { height: ALTEZZA_COPERTINA, justifyContent: 'flex-end', marginBottom: SPAZIO.l, // Squadrata: la copertina è una fotografia a tutta larghezza, non una // scheda appoggiata. Con gli angoli tondi sembrava una tessera cresciuta. overflow: 'hidden', // Il fondo si vede nell'attimo prima che la foto sia pronta: meglio il // buio della copertina che un lampo bianco sopra il crema. backgroundColor: '#17120F', }, velo: { position: 'absolute', left: 0, right: 0, bottom: 0, height: 132, width: undefined, }, titoloSuFoto: { ...TESTO.titolo, fontSize: 27, lineHeight: 32, color: '#FFFFFF', paddingHorizontal: SPAZIO.l, paddingBottom: SPAZIO.l, }, procedimento: { marginTop: SPAZIO.xl, marginHorizontal: SPAZIO.l, padding: SPAZIO.m, borderRadius: RAGGIO.riga, backgroundColor: colori.superficie, }, procedimentoEtichetta: { ...TESTO.piccolo, fontSize: 11, fontWeight: '600', letterSpacing: 0.9, textTransform: 'uppercase', color: colori.testoTenue, marginBottom: SPAZIO.xs, }, /** * Interlinea larga: sono appunti scritti a mano, con gli a capo dove li ha * messi chi li ha scritti, e stretti si leggerebbero come un blocco unico. */ procedimentoTesto: { ...TESTO.corpo, color: colori.testo, lineHeight: 24 }, titolo: { marginTop: SPAZIO.l, ...TESTO.titolo, color: colori.testo, paddingHorizontal: SPAZIO.l, marginBottom: SPAZIO.m, }, porzioni: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: SPAZIO.l, marginBottom: SPAZIO.l, }, porzioniEtichetta: { flex: 1, ...TESTO.corpo, color: colori.testo }, porzioniNumero: { minWidth: 48, textAlign: 'center', ...TESTO.corpo, fontWeight: '600', color: colori.testo, }, passo: { width: 44, height: 44, borderRadius: RAGGIO.tondo, backgroundColor: colori.superficie, borderWidth: StyleSheet.hairlineWidth, borderColor: colori.bordo, alignItems: 'center', justifyContent: 'center', }, passoSegno: { fontSize: 24, lineHeight: 28, color: colori.accento }, // Un tasto spento che resta acceso è una bugia: si spegne il colore, come // le frecce ai bordi in GestioneCategorie.tsx. passoSpento: { color: colori.testoTenue }, gruppo: { ...TESTO.piccolo, fontWeight: '700', letterSpacing: 0.6, textTransform: 'uppercase', color: colori.testoTenue, paddingHorizontal: SPAZIO.l, marginTop: SPAZIO.xl, marginBottom: SPAZIO.xs, }, riga: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', // L'altezza della riga la dà il bersaglio della quantità, che è alto 44: // qui basta il respiro attorno. Con SPAZIO.m la riga sarebbe di 68 punti // e la lista si allungherebbe di mezzo schermo. Stesso conto e stessa // scelta delle righe di GestioneCategorie.tsx. paddingVertical: SPAZIO.xs, paddingHorizontal: SPAZIO.l, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colori.bordo, }, nome: { flex: 1, ...TESTO.corpo, color: colori.testo, marginRight: SPAZIO.m }, });