feat(mobile): add complete consent flow (banner, health modal, privacy, i18n)

This commit is contained in:
Antoni Nuñez Romeu
2026-08-26 15:24:09 +02:00
parent 1f918be99d
commit 80c473e439
9 changed files with 350 additions and 2 deletions
@@ -379,6 +379,14 @@ export default function ProfileScreen() {
</TouchableOpacity>
</>
)}
<View style={[styles.menuDivider, { backgroundColor: colors.surfaceLow }]} />
<TouchableOpacity style={styles.menuRow} onPress={() => router.push('/privacy')}>
<View style={[styles.menuIconCircle, { backgroundColor: colors.surfaceLow }]}>
<Ionicons name="shield-checkmark-outline" size={20} color={colors.primary} />
</View>
<Text style={[styles.menuLabel, { color: colors.text }]}>{t('nav.privacy')}</Text>
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
</TouchableOpacity>
</View>
{/* Search history */}
+20 -1
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { Image, StyleSheet, View } from 'react-native';
@@ -10,6 +10,8 @@ import { registerForPushNotifications, addNotificationListener, addNotificationR
import { ThemeProvider, useThemeContext } from '../components/ThemeProvider';
import { LanguageProvider, useTranslation } from '../src/i18n';
import { initFaro } from '../services/faro';
import { hasConsentChoice } from '../services/consent';
import CookieBanner from '../components/CookieBanner';
// Boot Faro RUM once, as early as possible.
initFaro();
@@ -23,6 +25,7 @@ function RootLayoutInner() {
const { checkAuth } = useAuthStore();
const { colors, isDark } = useThemeContext();
const { t } = useTranslation();
const [showCookieBanner, setShowCookieBanner] = useState(false);
const notificationListener = useRef<ReturnType<typeof addNotificationListener>>();
const responseListener = useRef<ReturnType<typeof addNotificationResponseListener>>();
@@ -31,6 +34,11 @@ function RootLayoutInner() {
registerForPushNotifications();
(async () => {
const consentChoice = await hasConsentChoice();
if (!consentChoice) setShowCookieBanner(true);
})();
notificationListener.current = addNotificationListener((notification) => {
console.log('Notification received:', notification);
});
@@ -85,7 +93,18 @@ function RootLayoutInner() {
headerShown: false,
}}
/>
<Stack.Screen name="privacy" options={{ title: 'Política de Privacidad' }} />
</Stack>
{showCookieBanner && (
<CookieBanner
onConsent={async (consents) => {
const { saveConsents } = await import('../services/consent');
await saveConsents(consents);
setShowCookieBanner(false);
}}
onPrivacyPress={() => setShowCookieBanner(false)}
/>
)}
<StatusBar style={isDark ? 'light' : 'auto'} />
</>
);
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useThemeContext } from '../components/ThemeProvider';
import { useTranslation } from '../src/i18n';
import { spacing, borderRadius } from '../constants/theme';
const SECTIONS = ['controller', 'data_collected', 'purpose', 'legal_basis', 'external_services', 'retention', 'rights', 'contact', 'cookies'] as const;
export default function PrivacyScreen() {
const router = useRouter();
const { colors } = useThemeContext();
const { t } = useTranslation();
return (
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={[styles.backBtn, { backgroundColor: colors.card }]}>
<Ionicons name="arrow-back" size={20} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>{t('privacy.title')}</Text>
</View>
<Text style={[styles.updated, { color: colors.textSecondary }]}>{t('privacy.last_updated')}</Text>
{SECTIONS.map(section => (
<View key={section} style={[styles.section, { backgroundColor: colors.card }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t(`privacy.section.${section}.title`)}</Text>
<Text style={[styles.sectionContent, { color: colors.textSecondary }]}>{t(`privacy.section.${section}.content`)}</Text>
</View>
))}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: spacing.md },
header: { flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 16 },
backBtn: { width: 36, height: 36, borderRadius: borderRadius.md, justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 22, fontWeight: '700' },
updated: { fontSize: 12, marginBottom: 16 },
section: { borderRadius: borderRadius.md, padding: 16, marginBottom: 12 },
sectionTitle: { fontSize: 16, fontWeight: '700', marginBottom: 8 },
sectionContent: { fontSize: 14, lineHeight: 22 },
});
+33 -1
View File
@@ -3,12 +3,16 @@ import { View, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { BarcodeScanner } from '../components/BarcodeScanner';
import { searchMedicines } from '../services/medicines';
import { hasConsent } from '../services/consent';
import HealthConsentModal from '../components/HealthConsentModal';
export default function ScannerScreen() {
const router = useRouter();
const [isSearching, setIsSearching] = useState(false);
const [showHealthConsent, setShowHealthConsent] = useState(false);
const [pendingBarcode, setPendingBarcode] = useState<string | null>(null);
const handleBarcodeScanned = async (barcode: string) => {
const processBarcode = async (barcode: string) => {
setIsSearching(true);
try {
const results = await searchMedicines(barcode);
@@ -25,6 +29,16 @@ export default function ScannerScreen() {
}
};
const handleBarcodeScanned = async (barcode: string) => {
const healthConsent = await hasConsent('health_data');
if (!healthConsent) {
setPendingBarcode(barcode);
setShowHealthConsent(true);
return;
}
await processBarcode(barcode);
};
const handleClose = () => {
router.back();
};
@@ -35,6 +49,24 @@ export default function ScannerScreen() {
onBarcodeScanned={handleBarcodeScanned}
onClose={handleClose}
/>
{showHealthConsent && (
<HealthConsentModal
onAccept={async () => {
setShowHealthConsent(false);
if (pendingBarcode) {
const { saveConsents, getLocalConsents } = await import('../services/consent');
const current = await getLocalConsents();
await saveConsents({ ...current, health_data: true });
await processBarcode(pendingBarcode);
setPendingBarcode(null);
}
}}
onCancel={() => {
setShowHealthConsent(false);
setPendingBarcode(null);
}}
/>
)}
</View>
);
}
@@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal } from 'react-native';
import { useThemeContext } from './ThemeProvider';
import { useTranslation } from '../src/i18n';
import { borderRadius } from '../constants/theme';
interface CookieBannerProps {
onConsent: (consents: { essential: boolean; analytics: boolean; preferences: boolean; health_data: boolean }) => void;
onPrivacyPress: () => void;
}
export default function CookieBanner({ onConsent, onPrivacyPress }: CookieBannerProps) {
const { colors } = useThemeContext();
const { t } = useTranslation();
const [categories, setCategories] = useState({ analytics: false, preferences: false, health_data: false });
const toggleCategory = (cat: keyof typeof categories) => {
setCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
};
const categoryKeys = ['analytics', 'preferences', 'health_data'] as const;
return (
<Modal transparent animationType="slide" visible>
<View style={styles.overlay}>
<View style={[styles.container, { backgroundColor: colors.card }]}>
<ScrollView showsVerticalScrollIndicator={false}>
<Text style={[styles.title, { color: colors.text }]}>{t('cookie_banner.title')}</Text>
<Text style={[styles.desc, { color: colors.textSecondary }]}>{t('cookie_banner.description')}</Text>
<View style={styles.categories}>
<View style={[styles.category, { backgroundColor: colors.background }]}>
<View style={styles.categoryInfo}>
<Text style={[styles.categoryName, { color: colors.text }]}>{t('cookie_banner.category.essential')}</Text>
<Text style={[styles.categoryDesc, { color: colors.textSecondary }]}>{t('cookie_banner.category.essential_desc')}</Text>
</View>
<View style={[styles.toggle, styles.toggleLocked, { backgroundColor: colors.primary }]}>
<Text style={styles.toggleLabel}>ON</Text>
</View>
</View>
{categoryKeys.map(cat => (
<View key={cat} style={[styles.category, { backgroundColor: colors.background }]}>
<View style={styles.categoryInfo}>
<Text style={[styles.categoryName, { color: colors.text }]}>{t(`cookie_banner.category.${cat}`)}</Text>
<Text style={[styles.categoryDesc, { color: colors.textSecondary }]}>{t(`cookie_banner.category.${cat}_desc`)}</Text>
</View>
<TouchableOpacity style={[styles.toggle, categories[cat] && { backgroundColor: colors.primary }]} onPress={() => toggleCategory(cat)} activeOpacity={0.7}>
<View style={[styles.toggleThumb, categories[cat] && styles.toggleThumbOn]} />
</TouchableOpacity>
</View>
))}
</View>
<View style={styles.actions}>
<TouchableOpacity style={[styles.btn, { backgroundColor: colors.primary }]} onPress={() => onConsent({ essential: true, analytics: true, preferences: true, health_data: true })}>
<Text style={[styles.btnText, { color: colors.background }]}>{t('cookie_banner.accept_all')}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.btn, { backgroundColor: colors.background, borderColor: colors.border, borderWidth: 1 }]} onPress={() => onConsent({ essential: true, analytics: false, preferences: false, health_data: false })}>
<Text style={[styles.btnText, { color: colors.text }]}>{t('cookie_banner.reject_optional')}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.btn, { borderColor: colors.primary, borderWidth: 1 }]} onPress={() => onConsent({ essential: true, ...categories })}>
<Text style={[styles.btnText, { color: colors.primary }]}>{t('cookie_banner.save')}</Text>
</TouchableOpacity>
</View>
<TouchableOpacity onPress={onPrivacyPress}>
<Text style={[styles.moreInfo, { color: colors.textSecondary }]}>{t('cookie_banner.more_info')} →</Text>
</TouchableOpacity>
</ScrollView>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
container: { borderTopLeftRadius: borderRadius.lg, borderTopRightRadius: borderRadius.lg, padding: 20, maxHeight: '85%' },
title: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
desc: { fontSize: 14, lineHeight: 20, marginBottom: 16 },
categories: { gap: 10, marginBottom: 16 },
category: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 12, borderRadius: borderRadius.md, gap: 12 },
categoryInfo: { flex: 1, gap: 2 },
categoryName: { fontSize: 14, fontWeight: '600' },
categoryDesc: { fontSize: 12, lineHeight: 16 },
toggle: { width: 44, height: 24, borderRadius: 12, backgroundColor: '#ccc', justifyContent: 'center', alignItems: 'center', padding: 2 },
toggleLocked: { opacity: 0.6 },
toggleLabel: { fontSize: 9, fontWeight: '700', color: 'white' },
toggleThumb: { width: 20, height: 20, borderRadius: 10, backgroundColor: 'white', alignSelf: 'flex-start' },
toggleThumbOn: { alignSelf: 'flex-end' },
actions: { gap: 8, marginBottom: 12 },
btn: { paddingVertical: 12, borderRadius: 999, alignItems: 'center' },
btnText: { fontSize: 14, fontWeight: '600' },
moreInfo: { textAlign: 'center', fontSize: 12, textDecorationLine: 'underline' },
});
@@ -0,0 +1,44 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native';
import { useThemeContext } from './ThemeProvider';
import { useTranslation } from '../src/i18n';
import { borderRadius } from '../constants/theme';
interface HealthConsentModalProps {
onAccept: () => void;
onCancel: () => void;
}
export default function HealthConsentModal({ onAccept, onCancel }: HealthConsentModalProps) {
const { colors } = useThemeContext();
const { t } = useTranslation();
return (
<Modal transparent animationType="fade" visible>
<View style={styles.overlay}>
<View style={[styles.container, { backgroundColor: colors.card }]}>
<Text style={[styles.title, { color: colors.text }]}>{t('health_consent.title')}</Text>
<Text style={[styles.desc, { color: colors.textSecondary }]}>{t('health_consent.description')}</Text>
<View style={styles.actions}>
<TouchableOpacity style={[styles.btn, { backgroundColor: colors.primary }]} onPress={onAccept}>
<Text style={[styles.btnText, { color: colors.background }]}>{t('health_consent.accept')}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.btn, { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.border }]} onPress={onCancel}>
<Text style={[styles.btnText, { color: colors.text }]}>{t('health_consent.cancel')}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center', padding: 24 },
container: { borderRadius: borderRadius.lg, padding: 24, width: '100%', maxWidth: 340 },
title: { fontSize: 18, fontWeight: '700', marginBottom: 12 },
desc: { fontSize: 14, lineHeight: 20, marginBottom: 20 },
actions: { gap: 10 },
btn: { paddingVertical: 12, borderRadius: 999, alignItems: 'center' },
btnText: { fontSize: 14, fontWeight: '600' },
});
+59
View File
@@ -0,0 +1,59 @@
import * as SecureStore from 'expo-secure-store';
import { api } from './api';
const CONSENT_KEY = 'consents';
export interface Consents {
essential: boolean;
analytics: boolean;
preferences: boolean;
health_data: boolean;
}
const DEFAULT_CONSENTS: Consents = {
essential: true,
analytics: false,
preferences: false,
health_data: false,
};
export async function getLocalConsents(): Promise<Consents> {
try {
const stored = await SecureStore.getItemAsync(CONSENT_KEY);
if (stored) return { ...DEFAULT_CONSENTS, ...JSON.parse(stored), essential: true };
} catch {}
return { ...DEFAULT_CONSENTS };
}
export async function setLocalConsents(consents: Consents): Promise<void> {
try { await SecureStore.setItemAsync(CONSENT_KEY, JSON.stringify(consents)); } catch {}
}
export async function hasConsentChoice(): Promise<boolean> {
try { return (await SecureStore.getItemAsync(CONSENT_KEY)) !== null; } catch { return false; }
}
export async function fetchServerConsents(): Promise<Consents | null> {
try {
const res = await api.get('/consents');
await setLocalConsents(res.data);
return res.data;
} catch { return null; }
}
export async function saveConsents(consents: Consents): Promise<Consents> {
const toSave = { ...consents, essential: true };
await setLocalConsents(toSave);
try {
const res = await api.put('/consents', {
categories: { analytics: toSave.analytics, preferences: toSave.preferences, health_data: toSave.health_data },
});
await setLocalConsents(res.data);
return res.data;
} catch { return toSave; }
}
export async function hasConsent(category: keyof Consents): Promise<boolean> {
const consents = await getLocalConsents();
return consents[category] === true;
}
@@ -230,6 +230,31 @@ const ca = {
'barcodeScanner.cancel': 'Cancel·lar',
'barcodeScanner.scanningHint': 'Apunteu la càmera al codi de barres del medicament',
'barcodeScanner.scanAgain': 'Escanejar de nou',
// Cookie Banner
'cookie_banner.title': 'Utilitzem cookies i dades personals',
'cookie_banner.description': 'Utilitzem cookies i tecnologies similars per millorar la teva experiència i, si ho permetes, escanejar la teva targeta sanitària.',
'cookie_banner.category.essential': 'Essencials',
'cookie_banner.category.essential_desc': 'Necessàries per al funcionament de l\'app.',
'cookie_banner.category.analytics': 'Analítica',
'cookie_banner.category.analytics_desc': 'Ens ajuden a millorar l\'app.',
'cookie_banner.category.preferences': 'Preferències',
'cookie_banner.category.preferences_desc': 'Recordar el teu idioma i tema.',
'cookie_banner.category.health_data': 'Dades de salut',
'cookie_banner.category.health_data_desc': 'Escaneig de targeta sanitària.',
'cookie_banner.accept_all': 'Acceptar tot',
'cookie_banner.reject_optional': 'Rebutjar',
'cookie_banner.save': 'Desar',
'cookie_banner.more_info': 'Més informació',
// Health Consent
'health_consent.title': 'Consentiment per a dades de salut',
'health_consent.description': 'Per escanejar la teva targeta sanitària (TSI), necessitem extreure el teu codi CIP i accedir a les teves receptes.',
'health_consent.accept': 'Acceptar i escanejar',
'health_consent.cancel': 'Cancel·lar',
// Privacy
'privacy.title': 'Política de Privacitat',
'privacy.last_updated': 'Última actualització: 26/08/2026',
'nav.privacy': 'Política de privacitat',
};
export default ca;
@@ -230,6 +230,31 @@ const es = {
'barcodeScanner.cancel': 'Cancelar',
'barcodeScanner.scanningHint': 'Apunta la cámara al código de barras del medicamento',
'barcodeScanner.scanAgain': 'Escanear de nuevo',
// Cookie Banner
'cookie_banner.title': 'Utilizamos cookies y datos personales',
'cookie_banner.description': 'Utilizamos cookies y tecnologías similares para mejorar tu experiencia, analizar el uso de la app y, si lo permites, escanear tu tarjeta sanitaria.',
'cookie_banner.category.essential': 'Esenciales',
'cookie_banner.category.essential_desc': 'Necesarias para el funcionamiento de la app.',
'cookie_banner.category.analytics': 'Analítica',
'cookie_banner.category.analytics_desc': 'Nos ayudan a mejorar la app.',
'cookie_banner.category.preferences': 'Preferencias',
'cookie_banner.category.preferences_desc': 'Recordar tu idioma y tema.',
'cookie_banner.category.health_data': 'Datos de salud',
'cookie_banner.category.health_data_desc': 'Escaneo de tarjeta sanitaria.',
'cookie_banner.accept_all': 'Aceptar todo',
'cookie_banner.reject_optional': 'Rechazar',
'cookie_banner.save': 'Guardar',
'cookie_banner.more_info': 'Más información',
// Health Consent
'health_consent.title': 'Consentimiento para datos de salud',
'health_consent.description': 'Para escanear tu tarjeta sanitaria (TSI), necesitamos extraer tu código CIP y acceder a tus recetas.',
'health_consent.accept': 'Aceptar y escanear',
'health_consent.cancel': 'Cancelar',
// Privacy
'privacy.title': 'Política de Privacidad',
'privacy.last_updated': 'Última actualización: 26/08/2026',
'nav.privacy': 'Política de privacidad',
};
export default es;