/** * La foto della ricetta: scattala, scegli dalla libreria, toglila. * * Il permesso si chiede al momento del gesto, mai all'avvio: a chi ha appena * installato l'app e non ha ancora capito a cosa serve, chiedere la fotocamera * significa farsi rispondere di no una volta sola e per sempre. Se l'utente * nega, si mostra il messaggio tradotto, che dice dove si rimedia, e si * smette: non si richiede e non si insiste. * * Il file lo scrive salvaFoto, che comprime PRIMA di salvare e restituisce il * NOME del file, mai un percorso assoluto. Qui si tiene solo quel nome; il * percorso da dare a lo ricostruisce percorsoFoto ogni volta. Se il file * non c'è più, oltre a mostrare la ricetta senza immagine si azzera anche il * campo (deveAzzerareFoto, in logica-modifica.ts): è l'altra metà del * requisito, e senza di lei il nome sbagliato resta nel database per sempre. * * Ogni foto scritta ha un nome suo: rifarla non sovrascrive quella di prima, * che resta sul disco finché la schermata non decide chi tenere. Togliere la * foto è dire onCambia(null). In tutti e due i casi i file che avanzano li * cancella la schermata quando si esce, così «Annulla» non porta via niente. * * La compressione dura qualche secondo: `onInCorso` lo dice alla schermata, * che intanto tiene spento «Salva». Salvare nel mezzo scriverebbe una ricetta * senza foto e lascerebbe sul disco un file che nessuno nomina più. * * I colori non si scrivono qui: vengono da tema.ts, come nelle altre schermate. */ import { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Image, Pressable, StyleSheet, Text, View } from 'react-native'; import { percorsoFoto, salvaFoto, scegliFoto } from '../../data/foto.ts'; import { useApp } from '../contesto.ts'; import { chiaveMessaggioPermesso, deveAzzerareFoto } from '../logica-modifica.ts'; import type { Colori } from '../tema.ts'; import { RAGGIO, SPAZIO, TESTO, useColori } from '../tema.ts'; import type { OrigineFoto } from '../../data/foto.ts'; export type PropsFotoRicetta = { /** NOME del file già salvato, o null. Mai un percorso assoluto. */ foto: string | null; onCambia: (foto: string | null) => void; /** Vero mentre la foto sta comprimendo: chi ci sta sopra spegne «Salva». */ onInCorso: (inCorso: boolean) => void; }; export function FotoRicetta({ foto, onCambia, onInCorso }: PropsFotoRicetta) { const { testo } = useApp(); const colori = useColori(); const stili = useMemo(() => creaStili(colori), [colori]); const [percorso, setPercorso] = useState(null); const [inCorso, setInCorso] = useState(false); const [permessoNegato, setPermessoNegato] = useState(null); // percorsoFoto restituisce null se il file non c'è, e non tocca il database: // una ricetta che dice di avere una foto sparita si mostra senza immagine, // senza messaggi di errore. // // onCambia non entra fra le dipendenze: chi monta il componente la passa come // funzione nuova a ogni render, e metterla lì rifarebbe il giro all'infinito. useEffect(() => { let vivo = true; percorsoFoto(foto) .then((p) => { if (!vivo) return; setPercorso(p); if (deveAzzerareFoto(p, foto)) onCambia(null); }) .catch(() => { if (vivo) setPercorso(null); }); return () => { vivo = false; }; }, [foto]); // Lo stesso stato serve qui (spegne i tre tasti) e alla schermata (spegne // «Salva»): si scrive nei due posti insieme, non ce n'è uno che valga senza // l'altro. const segnaInCorso = (valore: boolean) => { setInCorso(valore); onInCorso(valore); }; const prendi = async (origine: OrigineFoto) => { setPermessoNegato(null); // Il permesso lo chiede scegliFoto, adesso, cioè quando l'utente ha appena // toccato il tasto. const esito = await scegliFoto(origine); if (esito.tipo === 'annullata') return; if (esito.tipo === 'permessoNegato') { setPermessoNegato(origine); return; } segnaInCorso(true); try { onCambia(await salvaFoto(esito.uri)); } catch (errore) { // Nessun testo per questo caso: l'elenco delle chiavi è fissato dal Task // 16 e non si allarga da qui. La foto semplicemente non compare, e il // resto della ricetta non è toccato. console.warn('salvataggio della foto fallito', errore); } finally { segnaInCorso(false); } }; return ( {percorso === null ? ( {testo('foto.aggiungi')} ) : ( )} void prendi('fotocamera')} disabled={inCorso} style={stili.tasto} accessibilityRole="button" accessibilityState={{ disabled: inCorso }} > {testo('foto.scatta')} void prendi('libreria')} disabled={inCorso} style={stili.tasto} accessibilityRole="button" accessibilityState={{ disabled: inCorso }} > {testo('foto.scegli')} {foto !== null ? ( onCambia(null)} disabled={inCorso} style={stili.tasto} accessibilityRole="button" accessibilityState={{ disabled: inCorso }} > {testo('foto.togli')} ) : null} {inCorso ? : null} {permessoNegato !== null ? ( {testo(chiaveMessaggioPermesso(permessoNegato))} ) : null} ); } const creaStili = (colori: Colori) => StyleSheet.create({ blocco: { gap: SPAZIO.s }, intestazione: { ...TESTO.piccolo, textTransform: 'uppercase', color: colori.testoTenue }, foto: { width: '100%', height: 180, borderRadius: RAGGIO.campo, backgroundColor: colori.bordo }, tasti: { flexDirection: 'row', alignItems: 'center', gap: SPAZIO.m }, // minHeight 44 invece di hitSlop: i tre tasti stanno vicini in riga, e un // hitSlop generoso li farebbe sconfinare l'uno nell'altro (vedi // RigaIngrediente.tsx, stesso ragionamento sulla × che toglie una riga). tasto: { minHeight: 44, justifyContent: 'center', paddingHorizontal: SPAZIO.xs }, tastoTesto: { ...TESTO.etichetta, color: colori.accento }, tastoTestoTogli: { ...TESTO.etichetta, color: colori.errore }, tastoTestoSpento: { color: colori.testoTenue }, // errore e non attenzione: il permesso negato non è "si può fare, ma // guarda", è "questo gesto adesso non si può fare" finché non si cambia // impostazione sul telefono. avviso: { ...TESTO.piccolo, color: colori.errore }, });