/** * La foto della ricetta: il banner in cima alla schermata Modifica. * * Com'è fatto. Con la foto, è la copertina che la ricetta avrà nel Dosatore: * tutta larghezza, 210 punti, e in basso a destra la pillola «Cambia foto». * Senza, è un riquadro di accento tenue con la macchina fotografica e «Aggiungi * una foto», più basso, per non spingere giù il nome. Tutto il banner si * tocca, e solo allora si sceglie: scatta, scegli dalla libreria, rimuovi * (MenuAzioni). Prima c'erano tre comandi di testo sempre a schermo, sotto il * titolo: i tester non capivano cosa fossero. * * 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. Rimuovere 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ù. Il banner * intanto mostra la rotellina e non si tocca. * * 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 type { VoceFoto } from '../logica-modifica.ts'; import { chiaveMessaggioPermesso, deveAzzerareFoto, formaBanner, vociFoto } 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'; import type { VoceMenu } from './MenuAzioni.tsx'; import { MenuAzioni } from './MenuAzioni.tsx'; import Segno from './Segno.tsx'; /** La stessa altezza della copertina del Dosatore (ALTEZZA_COPERTINA in SchedaRicetta.tsx). */ const ALTEZZA_FOTO = 210; const ALTEZZA_VUOTO = 148; 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; /** Il file della foto non c'è più: il campo va azzerato, ma non l'ha deciso l'utente. */ onSparita: () => void; /** * La foto sta arrivando da una pagina web (scaricaFoto, nella schermata): * il banner mostra la rotellina e non si tocca, come mentre comprime. */ inArrivo?: boolean; }; export function FotoRicetta({ foto, onCambia, onInCorso, onSparita, inArrivo = false }: PropsFotoRicetta) { const { testo } = useApp(); const colori = useColori(); const stili = useMemo(() => creaStili(colori), [colori]); // undefined: il file si sta ancora cercando (vedi formaBanner). const [percorso, setPercorso] = useState(undefined); const [inCorso, setInCorso] = useState(false); const [permessoNegato, setPermessoNegato] = useState(null); const [menu, setMenu] = useState(false); // 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)) onSparita(); }) .catch(() => { if (vivo) setPercorso(null); }); return () => { vivo = false; }; }, [foto]); // Lo stesso stato serve qui (il banner non si tocca) e alla schermata // (spegne «Salva»): si scrive nei due posti insieme. 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 // scelto la voce. 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: la foto semplicemente non compare, e il // resto della ricetta non è toccato. console.warn('salvataggio della foto fallito', errore); } finally { segnaInCorso(false); } }; const forma = formaBanner(foto, percorso); const occupato = inCorso || inArrivo; const voce = (v: VoceFoto): VoceMenu => { switch (v) { case 'fotocamera': return { etichetta: testo('foto.scatta'), segno: 'fotocamera', azione: () => void prendi('fotocamera') }; case 'libreria': return { etichetta: testo('foto.scegli'), segno: 'importa', azione: () => void prendi('libreria') }; case 'rimuovi': return { etichetta: testo('foto.rimuovi'), segno: 'cestino', pericolo: true, azione: () => onCambia(null) }; } }; return ( setMenu(true)} disabled={occupato || forma === 'attesa'} accessibilityRole="button" accessibilityLabel={testo(forma === 'foto' ? 'foto.cambia' : 'foto.aggiungi')} accessibilityState={{ disabled: occupato, busy: occupato }} style={({ pressed }) => [pressed ? stili.premuto : null]} > {forma === 'vuoto' ? ( {occupato ? ( ) : ( <> {testo('foto.aggiungi')} {testo('foto.aggiungi.spiega')} )} ) : ( {forma === 'foto' && typeof percorso === 'string' ? ( ) : null} {occupato ? ( ) : forma === 'foto' ? ( // Il velo della pillola è al 62%: sopra il caso peggiore, una // foto bianca, il bianco ci si legge a 5,2:1 (la soglia del // 53,5% sta in DESIGN.md). {testo('foto.cambia')} ) : null} )} {permessoNegato !== null ? ( {testo(chiaveMessaggioPermesso(permessoNegato))} ) : null} setMenu(false)} /> ); } const creaStili = (colori: Colori) => StyleSheet.create({ premuto: { opacity: 0.8 }, vuoto: { marginHorizontal: SPAZIO.l, marginTop: SPAZIO.xs, height: ALTEZZA_VUOTO, borderRadius: RAGGIO.foto, backgroundColor: colori.accentoTenue, alignItems: 'center', justifyContent: 'center', gap: 6, }, vuotoTitolo: { ...TESTO.corpo, fontWeight: '600', color: colori.accento }, vuotoSotto: { ...TESTO.piccolo, color: colori.testoTenue }, // Il carbone della copertina del Dosatore: aspettando il file, un lampo // di crema sarebbe più brusco del buio. copertina: { height: ALTEZZA_FOTO, backgroundColor: '#17120F' }, immagine: { width: '100%', height: '100%' }, attesa: { ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(23,18,15,0.45)' }, pillola: { position: 'absolute', right: SPAZIO.m, bottom: SPAZIO.m, minHeight: 32, borderRadius: RAGGIO.tondo, paddingLeft: 10, paddingRight: SPAZIO.m, flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: 'rgba(23,18,15,0.62)', }, pillolaTesto: { ...TESTO.piccolo, fontWeight: '600', color: '#FFFFFF' }, // 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, marginHorizontal: SPAZIO.l, marginTop: SPAZIO.s }, });