/** * Scrivi e modifica una ricetta. * * `route.params.ricettaId` null è una ricetta nuova; con un id si modifica * quella esistente. db, lingua e testo arrivano da useApp(): la schermata è * registrata con `component={Modifica}` e non riceve props oltre a quelle * della navigazione. * * Gli ingredienti si inseriscono incollando un elenco nel riquadro grande, che * resta sempre a schermo: il parser lo trasforma in righe modificabili e le * accoda a quelle già presenti. Le righe che non ha capito restano in evidenza * finché l'utente non le tocca. * * La foto e la categoria vivono nella bozza come tutto il resto: la bozza non * tocca mai il disco. Il file della foto invece lo scrive salvaFoto al momento * dello scatto, e ogni foto ha un nome suo: quelli che avanzano — la vecchia * se si salva, la nuova se si annulla — si cancellano tutti insieme quando si * lascia la schermata, così «Annulla» non porta via niente. * * Ogni decisione (cosa fare dopo il caricamento, quando si legge l'incolla, * quando compare "aggiungi sezione", quale messaggio di permesso mostrare) * sta in logica-modifica.ts, non qui: qui c'è solo il disegno. `node --test` * non carica questo file, quindi una decisione lasciata qui dentro potrebbe * rompersi senza che nessun test se ne accorga. * * I colori non si scrivono qui: vengono da tema.ts, come nelle altre schermate. */ import { useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Keyboard, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { cancellaFoto } from '../../data/foto.ts'; import { cancellaRicetta, leggiRicetta, salvaRicetta } from '../../data/ricette.ts'; import { FotoRicetta } from '../componenti/FotoRicetta.tsx'; import { RigaIngrediente } from '../componenti/RigaIngrediente.tsx'; import type { CampoRiga, Passo } from '../logica-modifica.ts'; import { campoDopo, catenaCampi, chiaveCampo } from '../logica-modifica.ts'; import { SceltaCategoria } from '../componenti/SceltaCategoria.tsx'; import { useApp } from '../contesto.ts'; import { usePannelli } from '../pannelli.ts'; import { aggiungiRiga, aggiungiSezione, bozzaDaRicetta, bozzaNuova, cambiaRiga, cambiaSezione, deveMostrareSezioni, ricettaDaBozza, togliRiga, } from '../bozza.ts'; import { esitoCaricamento, esitoIncolla, fotoDaCancellare, sipuoAggiungereSezione, sipuoLeggereIncolla, sipuoSalvareOra, } from '../logica-modifica.ts'; import { creaScrittoreUnico } from '../scritturaUnica.ts'; import type { Colori } from '../tema.ts'; import { RAGGIO, SPAZIO, TESTO, useColori } from '../tema.ts'; import type { Bozza } from '../bozza.ts'; import type { PropsSchermata } from '../navigazione.ts'; import Barra from '../componenti/Barra.tsx'; import Segno from '../componenti/Segno.tsx'; export default function Modifica({ route, navigation }: PropsSchermata<'Modifica'>) { // 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; const { db, lingua, testo } = useApp(); const { due, scegli } = usePannelli(); const colori = useColori(); const stili = useMemo(() => creaStili(colori), [colori]); const { ricettaId } = route.params; const [bozza, setBozza] = useState(null); const [incolla, setIncolla] = useState(''); // La foto sta comprimendo: «Salva» resta spento finché non ha finito, se no // si scrive una ricetta senza foto e sul disco resta un file che nessuno // nomina più. const [fotoInCorso, setFotoInCorso] = useState(false); // Si è provato a leggere il riquadro e non ne è uscito nessun ingrediente. // Senza dirlo, il tocco su «+ Ingredienti» non produce niente di visibile e // si ritocca. Si spegne appena il testo cambia: è di quel testo che parla. const [incollaNiente, setIncollaNiente] = useState(false); /** * I campi delle righe, per poterli mettere a fuoco: la chiave è * `riga:campo`, la stessa che usa la catena in logica-modifica.ts. * * Un ref e non uno stato: cambia ad ogni riga che nasce o muore, e ogni * cambiamento ridisegnerebbe la schermata per niente — qui dentro non c'è * niente che si veda. */ const campi = useRef(new Map()); // Distinto da un guasto di scrittura: qui la lettura è fallita e la ricetta // non è stata toccata, mentre errore.scrittura vale per un salvataggio o // un'eliminazione andati male. const [erroreLettura, setErroreLettura] = useState(false); /** * Com'era la ricetta quando si è aperta, più i file di foto scritti da * allora. Un ref e non uno stato: lo legge la pulizia allo smontaggio, che * non fa render, e la conferma di eliminazione, che deve nominare la ricetta * che sta nel ricettario e non il titolo che l'utente sta ancora scrivendo. * * `fotoDaTenere` parte dalla foto iniziale, cioè da quello che vale se si * annulla; il salvataggio la sposta sulla foto salvata prima di tornare * indietro. */ const sessione = useRef<{ titolo: string; fotoIniziale: string | null; fotoScritte: string[]; fotoDaTenere: string | null; }>({ titolo: '', fotoIniziale: null, fotoScritte: [], fotoDaTenere: null }); // Una scrittura per volta: due tocchi ravvicinati su «Salva» farebbero // morire la seconda transazione con «cannot start a transaction within a // transaction». Il perché e il come stanno in scritturaUnica.ts, dove il // meccanismo è collaudato e da dove lo prendono anche gli altri tre punti // che scrivono. const scriviUnaSola = useRef(creaScrittoreUnico()).current; useEffect(() => { let vivo = true; (async () => { try { const ricetta = ricettaId ? await leggiRicetta(db, ricettaId) : null; if (!vivo) return; const esito = esitoCaricamento(ricettaId, ricetta); // Ricetta assente o cancellata da un'altra schermata (o da un import) // mentre eravamo altrove: non c'è più niente da scrivere qui. if (esito.tipo === 'assente') return navigation.goBack(); const aperta = esito.tipo === 'trovata' ? bozzaDaRicetta(esito.ricetta, lingua) : bozzaNuova(new Date().toISOString()); sessione.current.titolo = aperta.titolo; sessione.current.fotoIniziale = aperta.foto; sessione.current.fotoDaTenere = aperta.foto; setBozza(aperta); } catch { if (vivo) setErroreLettura(true); } })(); return () => { vivo = false; }; // lingua, testo e navigation non entrano fra le dipendenze: sono fissi per // tutta la vita dell'app, e ricaricare qui butterebbe via quello che // l'utente ha già scritto. }, [db, ricettaId]); // I file delle foto che avanzano si cancellano uscendo, e da qui si passa // comunque: col tasto «Annulla», con la freccia indietro, con lo scorrimento // dal bordo. Le dipendenze sono vuote apposta: la pulizia deve girare allo // smontaggio e basta, e quello che le serve sta tutto nel ref. useEffect( () => () => { const { fotoScritte, fotoIniziale, fotoDaTenere } = sessione.current; for (const nome of fotoDaCancellare(fotoScritte, fotoIniziale, fotoDaTenere)) { void cancellaFoto(nome); } }, [], ); /** * Sempre funzionale. * * La foto e la categoria creata al volo tornano da un `await`, e la `bozza` * catturata quando il gestore è nato è già vecchia: riscriverla sopra * butterebbe via tutto quello che l'utente ha scritto nel frattempo (la * compressione di una foto dura qualche secondo, e scrivere si può). Il null * qui non capita mai — sotto la guardia poco più in basso — ma setBozza non * lo sa. */ const modifica = (cambia: (b: Bozza) => Bozza) => setBozza((b) => (b === null ? null : cambia(b))); const leggiIncolla = () => { if (bozza === null) return; const esito = esitoIncolla(bozza, incolla, lingua); setIncollaNiente(esito.niente); // Il riquadro si svuota solo se ne è uscito qualcosa: se il parser non ha // ricavato nemmeno un ingrediente, il testo dell'utente resta dov'è. if (!esito.letto) return; setBozza(esito.bozza); setIncolla(''); }; const salva = () => scriviUnaSola(async () => { if (bozza === null) return; // Quello che sta ancora nel riquadro dell'incolla è lavoro dell'utente // come il resto: si legge adesso. Il giro naturale è scrivere il titolo, // incollare l'elenco e toccare «Salva», e senza questa riga quel giro // salvava la ricetta senza ingredienti e buttava via il testo. const esito = esitoIncolla(bozza, incolla, lingua); if (!sipuoSalvareOra(esito.bozza, fotoInCorso)) return; if (esito.letto) { setBozza(esito.bozza); setIncolla(''); } try { // salvaRicetta non timbra: la data di modifica la mette qui chi modifica. const nuova = ricettaDaBozza(esito.bozza, lingua, new Date().toISOString()); await salvaRicetta(db, nuova); // Da qui in poi la foto buona è quella salvata: la pulizia allo // smontaggio si porterà via la vecchia e gli scatti scartati. sessione.current.fotoDaTenere = esito.bozza.foto; // Una ricetta nuova, sugli schermi larghi, si ritrova aperta a destra: // è quella a cui si stava pensando. Sul telefono si torna all'elenco // come sempre. if (ricettaId === null && due) scegli(nuova.id); navigation.goBack(); } catch { Alert.alert(testo('app.nome'), testo('errore.scrittura')); } }); const elimina = () => { if (bozza === null) return; // Il titolo va passato: 'modifica.conferma.elimina' ha il segnaposto // {titolo}, e senza valore t() lancia invece di mostrarlo in chiaro. È // quello della ricetta salvata, non quello della bozza: chi ha cambiato il // titolo senza salvare sta per eliminare la ricetta che sta nel ricettario, // e la conferma deve nominare quella. Alert.alert( testo('modifica.elimina'), testo('modifica.conferma.elimina', { titolo: sessione.current.titolo }), [ { text: testo('modifica.annulla'), style: 'cancel' }, { text: testo('modifica.elimina'), style: 'destructive', onPress: () => void scriviUnaSola(async () => { try { // cancellaRicetta si porta via anche il file della foto: il Task // 14 ci ha già pensato, qui non si ripete. Restano gli scatti di // questa sessione, e non c'è più niente da tenere. await cancellaRicetta(db, bozza.id); sessione.current.fotoDaTenere = null; navigation.goBack(); } catch { Alert.alert(testo('app.nome'), testo('errore.scrittura')); } }), }, ], ); }; if (erroreLettura) { return ( {testo('errore.db')} ); } // La prima lettura non è ancora finita: niente form, niente stato vuoto. // Stesso trattamento di Dosatore.tsx per una ricetta sola. if (bozza === null) return ; const incollaAttiva = sipuoLeggereIncolla(incolla); const salvabile = sipuoSalvareOra(bozza, fotoInCorso); /** * Se dopo questo campo ce n'è un altro. Decide cosa scrive il tasto della * tastiera: «avanti» se si può proseguire, «fine» sull'ultimo. * * Un tasto che dice «avanti» e non porta da nessuna parte è il difetto che si * nota subito: si preme, non succede niente, e si resta a chiedersi se il * campo abbia accettato quello che si è scritto. */ const haSeguito = (da: Passo): boolean => campoDopo(catenaCampi(bozza), da) !== null; /** * Porta il cursore al campo dopo, o chiude la tastiera se non c'è. * * Quale sia il campo vicino lo sa `campoVicino`, che i test caricano: qui si * cerca solo l'elemento nel registro e gli si dice di prendere il fuoco. */ const vaiAvanti = (da: Passo) => { const meta = campoDopo(catenaCampi(bozza), da); if (meta === null) { Keyboard.dismiss(); return; } campi.current.get(chiaveCampo(meta))?.focus(); }; return ( {/* automaticallyAdjustKeyboardInsets: è la schermata dove si scrive di più, e senza questo, con la tastiera aperta, il riquadro dell'incolla e i tasti «Salva» ed «Elimina» restano sotto la tastiera, irraggiungibili. 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. */} {/* Su una ricetta nuova il cursore parte da qui e la tastiera si apre da sola: la prima cosa che si fa aprendo questa schermata è dare un nome alla ricetta, e farla toccare a mano è un tocco chiesto per niente. Su una ricetta che esiste già no: lì si è entrati per correggere qualcosa, e la tastiera che salta su da sola coprirebbe metà di quello che si è venuti a rileggere. */} modifica((b) => ({ ...b, titolo: v }))} placeholder={testo('modifica.titolo')} placeholderTextColor={colori.testoTenue} autoFocus={ricettaId === null} returnKeyType="done" /> {/* Ogni nome che esce di qui è un file appena scritto sul disco: si segna, perché uscendo dalla schermata quelli che avanzano vanno cancellati (fotoDaCancellare). */} { if (foto !== null) sessione.current.fotoScritte.push(foto); modifica((b) => ({ ...b, foto })); }} /> modifica((b) => ({ ...b, descrizione: v }))} placeholder={testo('modifica.descrizione')} placeholderTextColor={colori.testoTenue} multiline /> modifica((b) => ({ ...b, porzioni: v }))} placeholder={testo('modifica.porzioni')} placeholderTextColor={colori.testoTenue} keyboardType="numeric" /> {testo('modifica.categoria')} modifica((b) => ({ ...b, categoriaId }))} /> {testo('modifica.ingredienti')} {/* Prima il riquadro dove si incolla, poi le righe che ne escono, e in fondo il modo di scriverne una a mano. L'ordine di prima era il contrario — righe, «+», e solo dopo il riquadro — e non si capiva niente: incollare un elenco è il modo normale di riempire una ricetta, ed è quello per cui l'app esiste, ma stava in fondo; il «+» in cima aggiungeva una riga vuota, che nel campo della quantità mostra «q.b.» perché una quantità vuota è davvero un q.b., e uno se la trovava lì senza sapere da dove arrivasse. */} { setIncolla(v); setIncollaNiente(false); }} onBlur={leggiIncolla} placeholder={testo('modifica.incolla')} placeholderTextColor={colori.testoTenue} multiline textAlignVertical="top" /> {/* Il comando esplicito che legge il riquadro. L'`onBlur` qui sopra fa la stessa cosa quando si tocca fuori, ma è un gesto che nessuno indovina: alla prima ricetta, senza questo tasto, non c'è niente da toccare che dica «adesso leggi quello che ho incollato». Il tasto non fa leggere due volte: la ScrollView ha keyboardShouldPersistTaps="handled", quindi il tocco arriva qui senza togliere il fuoco al riquadro, e quando il fuoco se ne va davvero il riquadro è già vuoto. Anche «Salva» legge il riquadro, per lo stesso motivo: quel testo è lavoro dell'utente e non si butta via. */} { leggiIncolla(); // Il riquadro ha finito il suo lavoro: la tastiera se ne va e si // vedono le righe appena comparse, che sono la risposta al gesto. // Senza, restano coperte da mezzo schermo di tastiera. Keyboard.dismiss(); }} disabled={!incollaAttiva} style={stili.tastoChiaro} accessibilityRole="button" accessibilityState={{ disabled: !incollaAttiva }} > {/* Spento si spegne il colore (testoTenue, verificato per il contrasto in tema.ts), non lo si fa sparire con l'opacità: un'opacità bassa sopra l'accento scende sotto il contrasto minimo. */} {testo('modifica.incolla.leggi')} {/* I gruppi si mostrano sempre, anche quando non c'è ancora nessuna riga: una bozza nuova ha già un gruppo vuoto, ed è lì dentro che sta il tasto con cui si scrive la prima riga a mano. Nascondendoli finché le righe sono zero, su una ricetta nuova non ci sarebbe nessun modo di aggiungerne una. */} {bozza.gruppi.map((g) => ( {deveMostrareSezioni(bozza) ? ( modifica((b) => cambiaSezione(b, g.id, v))} placeholder={testo('modifica.sezione.aggiungi')} placeholderTextColor={colori.testoTenue} /> ) : null} {g.righe.map((r) => ( modifica((b) => cambiaRiga(b, r.id, cambi))} onTogli={() => modifica((b) => togliRiga(b, r.id))} registra={(campo, el) => { const chiave = chiaveCampo({ rigaId: r.id, campo }); // Si toglie invece di tenere un null: una riga cancellata // lascerebbe la sua chiave nel registro per sempre. if (el === null) campi.current.delete(chiave); else campi.current.set(chiave, el); }} onAvanti={(campo) => vaiAvanti({ rigaId: r.id, campo })} haSeguito={(campo) => haSeguito({ rigaId: r.id, campo })} /> ))} {/* Per esteso e non un «+» solo: un più sotto una lista vuota non dice che riga si sta per aggiungere. */} modifica((b) => aggiungiRiga(b, g.id))} style={stili.tastoChiaro} accessibilityRole="button" accessibilityLabel={testo('modifica.riga.aggiungi')} > {testo('modifica.riga.aggiungi.tasto')} ))} {/* Avviso e non errore: non è andato storto niente, semplicemente lì dentro non c'erano ingredienti. Il testo resta nel riquadro, quindi si può correggere e riprovare. */} {incollaNiente ? {testo('modifica.incolla.niente')} : null} {sipuoAggiungereSezione(bozza) ? ( modifica(aggiungiSezione)} style={stili.tastoChiaro} accessibilityRole="button" > {testo('modifica.sezione.aggiungi')} ) : null} navigation.goBack()} style={stili.tastoChiaro} accessibilityRole="button"> {testo('modifica.annulla')} void salva()} disabled={!salvabile} style={[stili.tasto, salvabile ? null : stili.tastoSpento]} accessibilityRole="button" accessibilityState={{ disabled: !salvabile }} > {testo('modifica.salva')} {bozza.esistente ? ( {testo('modifica.elimina')} ) : null} ); } const creaStili = (colori: Colori) => StyleSheet.create({ schermo: { flex: 1, backgroundColor: colori.fondo }, scorrevole: { flex: 1 }, centro: { alignItems: 'center', justifyContent: 'center', padding: SPAZIO.xxl }, errore: { ...TESTO.etichetta, color: colori.errore, textAlign: 'center' }, contenuto: { padding: SPAZIO.l, paddingBottom: SPAZIO.xxl, gap: SPAZIO.m }, titolo: { ...TESTO.titolo, color: colori.testo, paddingVertical: SPAZIO.xs }, campo: { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colori.bordo, paddingVertical: SPAZIO.s, ...TESTO.corpo, color: colori.testo, }, intestazione: { ...TESTO.piccolo, textTransform: 'uppercase', color: colori.testoTenue, marginTop: SPAZIO.m, }, incolla: { minHeight: 140, borderWidth: StyleSheet.hairlineWidth, borderColor: colori.bordo, borderRadius: RAGGIO.campo, padding: SPAZIO.m, ...TESTO.corpo, color: colori.testo, }, gruppo: { gap: 2 }, nomeSezione: { ...TESTO.corpo, fontWeight: '600', color: colori.testo, paddingVertical: SPAZIO.xs }, tastoChiaro: { alignSelf: 'flex-start', minHeight: 44, justifyContent: 'center', paddingHorizontal: SPAZIO.xs }, // Il `+` da solo misura una decina di punti: senza questo il bersaglio è // alto 44 e largo 18, e si manca. Le altre scritte sono larghe da sé. tastoPiu: { minWidth: 44, alignItems: 'center' }, tastoChiaroTesto: { ...TESTO.corpo, color: colori.accento }, tastoChiaroTestoSpento: { color: colori.testoTenue }, avviso: { ...TESTO.piccolo, color: colori.attenzione }, azioni: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: SPAZIO.m, }, tasto: { backgroundColor: colori.accento, borderRadius: RAGGIO.campo, paddingVertical: SPAZIO.m, paddingHorizontal: SPAZIO.xl, }, tastoSpento: { backgroundColor: colori.bordo }, tastoTesto: { ...TESTO.corpo, fontWeight: '600', color: colori.accentoSopra }, tastoTestoSpento: { color: colori.testoTenue }, elimina: { ...TESTO.corpo, color: colori.errore }, });