/** * 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. * * Com'è fatta (rifatta il 28/09 dopo i commenti dei tester, che la trovavano * confusa; le scelte di Paolo e le alternative scartate stanno in * docs/design/campionari/modifica.html). L'ordine è quello della ricetta nel * Dosatore: * * - la foto in cima, grande come la copertina, e si tocca per sceglierla; * - nome, porzioni e categoria: campi con l'etichetta sempre sopra; * - «Ingredienti»: una lista che si legge come la ricetta, le sezioni coi * loro titoli; ogni riga si tocca e si scrive in un foglio. Sotto, un solo * «Aggiungi», «Incolla elenco» e «Nuova sezione». Una ricetta ancora vuota * mostra invece le due strade per cominciare, spiegate in una riga; * - «Procedimento», che prima si chiamava «Descrizione» e stava prima degli * ingredienti; * - «Elimina ricetta», staccato da tutto, in fondo. * * Ogni bottone ha la forma del suo ruolo (Bottoni.tsx), e spento resta della * stessa forma: i tester non distinguevano un bottone spento da un'etichetta. * * 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ì uscire senza salvare non porta via niente. * * Uscire con delle modifiche non salvate chiede conferma: prima la freccia * buttava via tutto senza dire niente. * * Ogni decisione (cosa fare dopo il caricamento, quando si mostra un titolo di * sezione, dove va un ingrediente nuovo, quando chiedere prima di uscire) sta * in logica-modifica.ts, non qui: `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, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { usePreventRemove } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { cancellaFoto, scaricaFoto } 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 { SceltaCategoria } from '../componenti/SceltaCategoria.tsx'; import { CampoTesto, TitoloBlocco } from '../componenti/Campo.tsx'; import { BottoneSecondario, ComandoLeggero, PillolaSalva } from '../componenti/Bottoni.tsx'; import { FoglioIngrediente } from '../componenti/FoglioIngrediente.tsx'; import { FoglioIncolla } from '../componenti/FoglioIncolla.tsx'; import { FoglioSezione } from '../componenti/FoglioSezione.tsx'; import { MenuNativo } from '../componenti/MenuNativo.tsx'; import TitoloSezione from '../componenti/TitoloSezione.tsx'; import { useApp } from '../contesto.ts'; import { usePannelli } from '../pannelli.ts'; import type { Bozza, CampiRiga } from '../bozza.ts'; import { aggiungiRiga, bozzaDaRicetta, bozzaDaTesto, bozzaNuova, cambiaRiga, cambiaSezione, nuovaSezione, ricettaDaBozza, rimuoviSezione, spostaRiga, togliRiga, } from '../bozza.ts'; import { bozzaDaPagina, chiediPrimaDiUscire, contaRighe, contenutoGruppo, esitoCaricamento, fotoDaCancellare, gruppoDiRiga, gruppoPredefinito, impronta, improntaSenzaFoto, mostraIntestazione, mostraVuoto, righeIncerte, sezioniPerFoglio, sipuoAggiungereSezione, sipuoSalvareOra, tastieraSulNome, titoloBarra, } 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 { PropsSchermata } from '../navigazione.ts'; import Barra from '../componenti/Barra.tsx'; import Segno from '../componenti/Segno.tsx'; /** Il foglio dell'ingrediente: resta montato con questi dati anche mentre scende. */ interface StatoIngrediente { visibile: boolean; /** Cambia a ogni apertura: il foglio riparte da capo. */ apertura: number; modo: 'nuovo' | 'modifica'; rigaId: string | null; iniziale: CampiRiga; incerta: boolean; gruppo: string; } interface StatoSezione { visibile: boolean; modo: 'nuova' | 'rinomina'; gruppoId: string | null; nome: string; } const CAMPI_VUOTI: CampiRiga = { quantita: '', unita: '', nome: '' }; 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. 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); // 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); // La foto di una ricetta arrivata da un link si scarica dopo che la // schermata si è aperta: intanto il resto si può già leggere e correggere, // e «Salva» aspetta, come mentre una foto comprime (al massimo // ATTESA_FOTO_WEB_MS, poi la ricetta resta senza foto). const [fotoInArrivo, setFotoInArrivo] = useState(false); const arrivo = useRef(null); // La pagina e quanto è scorsa: i menù delle sezioni la fanno scorrere per // aprirsi sotto i tre puntini (MenuNativo, scorri). const pagina = useRef(null); const scorrimento = useRef(0); const smontata = useRef(false); // Distinto da un guasto di scrittura: qui la lettura è fallita e la ricetta // non è stata toccata. const [erroreLettura, setErroreLettura] = useState(false); const [ingrediente, setIngrediente] = useState({ visibile: false, apertura: 0, modo: 'nuovo', rigaId: null, iniziale: CAMPI_VUOTI, incerta: false, gruppo: '', }); const [incolla, setIncolla] = useState(false); const [sezione, setSezione] = useState({ visibile: false, modo: 'nuova', gruppoId: null, nome: '' }); /** La sezione dove è finito l'ultimo ingrediente aggiunto: il prossimo va lì. */ const ultimoGruppo = useRef(null); /** * 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 * esce senza salvare; il salvataggio la sposta sulla foto salvata. * `impronta` è la bozza appena aperta: se quella di adesso è diversa, * uscire chiede conferma. */ const sessione = useRef<{ titolo: string; fotoIniziale: string | null; fotoScritte: string[]; fotoDaTenere: string | null; impronta: string | null; }>({ titolo: '', fotoIniziale: null, fotoScritte: [], fotoDaTenere: null, impronta: null }); /** Vero dopo un salvataggio o un'eliminazione: si esce senza chiedere. */ const uscitaLibera = useRef(false); // 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. 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 adesso = new Date().toISOString(); // Una ricetta arrivata da un link parte compilata, ma si confronta con // la bozza vuota: uscire senza salvarla chiede conferma. const daPagina = esito.tipo === 'nuova' && route.params.importa !== undefined ? bozzaDaPagina(route.params.importa, adesso, lingua) : null; const aperta = esito.tipo === 'trovata' ? bozzaDaRicetta(esito.ricetta, lingua) : daPagina !== null ? daPagina.bozza : bozzaNuova(adesso); sessione.current.titolo = aperta.titolo; sessione.current.fotoIniziale = aperta.foto; sessione.current.fotoDaTenere = aperta.foto; sessione.current.impronta = impronta(daPagina !== null ? daPagina.vuota : aperta); setBozza(aperta); const immagine = daPagina !== null ? route.params.importa?.immagine ?? null : null; if (immagine !== null) scaricaDaPagina(immagine); } 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 salvataggio, 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(() => { smontata.current = false; return () => { smontata.current = true; arrivo.current?.abort(); const { fotoScritte, fotoIniziale, fotoDaTenere } = sessione.current; for (const nome of fotoDaCancellare(fotoScritte, fotoIniziale, fotoDaTenere)) { void cancellaFoto(nome); } }; }, []); /** * Il nome del file segue la strada di ogni foto scritta qui (fotoScritte), * così se l'utente esce senza salvare la pulizia lo cancella. Se arriva a * schermata già chiusa, la pulizia è passata: si cancella subito. */ function scaricaDaPagina(indirizzo: string) { const controllo = new AbortController(); arrivo.current = controllo; setFotoInArrivo(true); scaricaFoto(indirizzo, controllo.signal) .then((nome) => { if (smontata.current) { void cancellaFoto(nome); return; } sessione.current.fotoScritte.push(nome); modifica((b) => (b.foto === null ? { ...b, foto: nome } : b)); }) .catch(() => { // Nessun messaggio: la ricetta arriva senza foto, e se ne può // scegliere una toccando il banner. }) .finally(() => { if (!smontata.current) setFotoInArrivo(false); }); } // Uscire con modifiche non salvate chiede conferma: freccia, scorrimento // dal bordo, tasto indietro di Android. Dopo un salvataggio o // un'eliminazione `uscitaLibera` lascia passare senza chiedere. // Anche mentre una foto si comprime: uscire lì lascerebbe sul disco un file // che arriva quando la pulizia è già passata. usePreventRemove(chiediPrimaDiUscire(sessione.current.impronta, bozza) || fotoInCorso, ({ data }) => { if (uscitaLibera.current) { navigation.dispatch(data.action); return; } Alert.alert(testo('modifica.esci.titolo'), testo('modifica.esci.messaggio'), [ { text: testo('modifica.esci.continua'), style: 'cancel' }, { text: testo('modifica.esci.esci'), style: 'destructive', onPress: () => navigation.dispatch(data.action) }, ]); }); /** * 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. 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 salva = () => scriviUnaSola(async () => { if (bozza === null || !sipuoSalvareOra(bozza, fotoInCorso || fotoInArrivo)) return; try { // salvaRicetta non timbra: la data di modifica la mette qui chi modifica. const nuova = ricettaDaBozza(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 = bozza.foto; uscitaLibera.current = true; // Una ricetta nuova, sugli schermi larghi, si ritrova aperta a destra: // è quella a cui si stava pensando. 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 è 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. Restano // gli scatti di questa sessione, e non c'è più niente da tenere. await cancellaRicetta(db, bozza.id); sessione.current.fotoDaTenere = null; uscitaLibera.current = true; 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. if (bozza === null) return ; const salvabile = sipuoSalvareOra(bozza, fotoInCorso || fotoInArrivo); const incerte = righeIncerte(bozza); const apriNuovo = (gruppo?: string) => setIngrediente((s) => ({ visibile: true, apertura: s.apertura + 1, modo: 'nuovo', rigaId: null, iniziale: CAMPI_VUOTI, incerta: false, gruppo: gruppo ?? gruppoPredefinito(bozza, ultimoGruppo.current), })); const apriRiga = (rigaId: string, gruppoId: string) => { const riga = bozza.gruppi.flatMap((g) => g.righe).find((r) => r.id === rigaId); if (riga === undefined) return; setIngrediente((s) => ({ visibile: true, apertura: s.apertura + 1, modo: 'modifica', rigaId, iniziale: { quantita: riga.quantita, unita: riga.unita, nome: riga.nome }, incerta: riga.incerta, gruppo: gruppoId, })); }; const chiudiIngrediente = () => setIngrediente((s) => ({ ...s, visibile: false })); return ( {/* «Salva» sta nella barra, che resta ferma: la ricetta può essere lunga (le lasagne), e da qui si salva anche con la tastiera aperta. */} void salva()} spento={!salvabile} />} /> {/* automaticallyAdjustKeyboardInsets: con la tastiera aperta il campo del procedimento resterebbe sotto la tastiera. Lo fa iOS da sé alla ScrollView; su Android la finestra si ridimensiona già. */} { scorrimento.current = e.nativeEvent.contentOffset.y; }} scrollEventThrottle={32} style={stili.scorrevole} contentContainerStyle={{ paddingBottom: SPAZIO.xxl + sotto }} keyboardShouldPersistTaps="handled" automaticallyAdjustKeyboardInsets > {/* Ogni nome che esce di qui è un file appena scritto sul disco: si segna, perché uscendo dalla schermata quelli che avanzano vanno cancellati (fotoDaCancellare). */} { // Il file non c'è più e il campo si azzera da sé: non è una // modifica dell'utente, e uscendo non si deve chiedere niente. const aperta = sessione.current.impronta; if (aperta !== null) sessione.current.impronta = improntaSenzaFoto(aperta); modifica((b) => ({ ...b, foto: null })); }} onCambia={(foto) => { if (foto !== null) sessione.current.fotoScritte.push(foto); modifica((b) => ({ ...b, foto })); }} /> {/* Su una ricetta nuova il cursore parte da qui e la tastiera si apre da sola: la prima cosa da fare è dare un nome alla ricetta. Su una che esiste già no: lì si è entrati per correggere qualcosa, e la tastiera coprirebbe metà di quello che si è venuti a rileggere. Lo stesso per una ricetta arrivata da un link (tastieraSulNome). */} modifica((b) => ({ ...b, titolo: v }))} autoFocus={tastieraSulNome(ricettaId, route.params.importa !== undefined)} returnKeyType="done" /> modifica((b) => ({ ...b, porzioni: v }))} keyboardType="numeric" returnKeyType="done" stile={stili.porzioni} /> modifica((b) => ({ ...b, categoriaId }))} stile={stili.categoria} /> {mostraVuoto(bozza) ? ( // Le due strade per cominciare, spiegate: nessuno deve indovinare // che un riquadro serve a incollare. setIncolla(true)} /> apriNuovo()} separata /> ) : ( <> {incerte > 0 ? ( {incerte === 1 ? testo('modifica.incerte.una') : testo('modifica.incerte.molte', { conteggio: String(incerte) })} ) : null} {bozza.gruppi.map((g, i) => ( {mostraIntestazione(bozza, i) ? ( pagina.current?.scrollTo({ y: scorrimento.current + dy, animated: true })} voci={[ { etichetta: testo('sezione.aggiungi.qui'), segno: 'piu', simbolo: 'plus', azione: () => apriNuovo(g.id), }, { etichetta: testo('sezione.rinomina'), segno: 'matita', simbolo: 'pencil', azione: () => setSezione({ visibile: true, modo: 'rinomina', gruppoId: g.id, nome: g.nome }), }, { etichetta: testo('sezione.rimuovi'), spiega: testo(i === 0 ? 'sezione.rimuovi.spiega.prima' : 'sezione.rimuovi.spiega'), segno: 'cestino', simbolo: 'trash', pericolo: true, azione: () => modifica((b) => rimuoviSezione(b, g.id)), }, ]} > } /> ) : i > 0 ? ( ) : null} {/* Il primo gruppo di una ricetta divisa in sezioni perde il suo titolo e resta attaccato al titolo «Ingredienti»: gli altri riquadri hanno sopra il loro. */} {contenutoGruppo(bozza, i) === 'righe' ? ( {g.righe.map((r, j) => ( apriRiga(r.id, g.id)} /> ))} ) : contenutoGruppo(bozza, i) === 'vuoto' ? ( // Una sezione appena creata: il riquadro vuoto si tocca e // apre il foglio già su questa sezione. apriNuovo(g.id)} accessibilityRole="button" accessibilityLabel={testo('sezione.aggiungi.qui')} style={({ pressed }) => [stili.riquadro, stili.sezioneVuota, pressed ? stili.premuto : null]} > {testo('sezione.vuota')} ) : null} ))} apriNuovo()} stile={stili.azione} accessibilityLabel={testo('modifica.vuoto.uno')} /> setIncolla(true)} stile={stili.azione} /> {sipuoAggiungereSezione(bozza) ? ( setSezione({ visibile: true, modo: 'nuova', gruppoId: null, nome: '' })} stile={stili.nuovaSezione} /> ) : null} )} modifica((b) => ({ ...b, descrizione: v }))} alto /> {bozza.esistente ? ( [stili.elimina, pressed ? stili.premuto : null]} > {testo('modifica.elimina')} ) : null} { ultimoGruppo.current = gruppoId; modifica((b) => aggiungiRiga(b, gruppoId, campi)); }} onFatto={(campi, gruppoId) => { const rigaId = ingrediente.rigaId; if (rigaId === null) return; modifica((b) => { const cambiata = cambiaRiga(b, rigaId, campi); return gruppoDiRiga(cambiata, rigaId) === gruppoId ? cambiata : spostaRiga(cambiata, rigaId, gruppoId); }); }} onRimuovi={() => { const rigaId = ingrediente.rigaId; if (rigaId !== null) modifica((b) => togliRiga(b, rigaId)); }} onChiudi={chiudiIngrediente} /> modifica((b) => bozzaDaTesto(b, incollato, lingua))} onChiudi={() => setIncolla(false)} /> { const gruppoId = sezione.gruppoId; if (sezione.modo === 'rinomina' && gruppoId !== null) { modifica((b) => cambiaSezione(b, gruppoId, nome)); return; } // La sezione nuova diventa quella dove va il prossimo ingrediente. setBozza((b) => { if (b === null) return b; const creata = nuovaSezione(b, nome); ultimoGruppo.current = creata.gruppoId; return creata.bozza; }); }} onChiudi={() => setSezione((s) => ({ ...s, visibile: false }))} /> ); } /** Una delle due strade per cominciare una ricetta vuota. */ function InizioRiga({ stili, colori, segno, titolo, spiega, onPress, separata = false, }: { stili: ReturnType; colori: Colori; segno: 'incolla' | 'piu'; titolo: string; spiega: string; onPress: () => void; separata?: boolean; }) { return ( [stili.inizio, separata ? stili.inizioSeparata : null, pressed ? stili.premuta : null]} > {titolo} {spiega} ); } 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' }, campi: { paddingHorizontal: SPAZIO.l, paddingTop: SPAZIO.l + SPAZIO.xs, gap: SPAZIO.l }, campi0: { paddingHorizontal: SPAZIO.l }, coppia: { flexDirection: 'row', gap: SPAZIO.m }, porzioni: { width: 112 }, categoria: { flex: 1 }, riquadro: { marginHorizontal: SPAZIO.l, backgroundColor: colori.superficie, borderRadius: RAGGIO.riga, overflow: 'hidden' }, premuta: { backgroundColor: colori.bordo }, premuto: { opacity: 0.6 }, inizio: { flexDirection: 'row', alignItems: 'center', gap: SPAZIO.m, paddingVertical: 14, paddingLeft: 14, paddingRight: SPAZIO.m }, inizioSeparata: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colori.bordo }, inizioSegno: { width: 40, height: 40, borderRadius: RAGGIO.campo, backgroundColor: colori.accentoTenue, alignItems: 'center', justifyContent: 'center', }, inizioTesti: { flex: 1, gap: 2 }, inizioTitolo: { ...TESTO.corpo, fontWeight: '600', color: colori.accento }, inizioSpiega: { ...TESTO.piccolo, color: colori.testoTenue, lineHeight: 17 }, avviso: { marginHorizontal: SPAZIO.l, marginBottom: 10, borderRadius: RAGGIO.campo, backgroundColor: colori.attenzioneTenue, paddingVertical: 10, paddingHorizontal: SPAZIO.m, flexDirection: 'row', alignItems: 'center', gap: 10, }, pallino: { width: 8, height: 8, borderRadius: 4, backgroundColor: colori.attenzione }, avvisoTesto: { flex: 1, ...TESTO.etichetta, color: colori.testo, lineHeight: 20 }, // Come i gruppi nel Dosatore: maiuscolo piccolo e tenue. Il rientro lo // allinea al testo delle righe, non al bordo del riquadro. intestazione: { paddingLeft: SPAZIO.l + 14, paddingRight: SPAZIO.l - SPAZIO.s, marginTop: SPAZIO.s, }, tastoMenu: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, spazioGruppi: { height: SPAZIO.l }, sezioneVuota: { flexDirection: 'row', alignItems: 'center', gap: SPAZIO.s, minHeight: 48, paddingHorizontal: 14 }, sezioneVuotaTesto: { ...TESTO.etichetta, color: colori.testoTenue }, azioni: { flexDirection: 'row', gap: 10, marginHorizontal: SPAZIO.l, marginTop: SPAZIO.m }, azione: { flex: 1 }, nuovaSezione: { marginHorizontal: SPAZIO.l, marginTop: SPAZIO.xs }, // «Elimina» staccato da tutto: è l'unico gesto che distrugge, e non deve // sembrare parte dell'ultimo blocco. elimina: { marginHorizontal: SPAZIO.l, marginTop: SPAZIO.xxl, minHeight: 48, borderRadius: RAGGIO.riga, backgroundColor: colori.superficie, alignItems: 'center', justifyContent: 'center', }, eliminaTesto: { ...TESTO.corpo, color: colori.errore }, });