feat(native): settings overhaul — DEV cleanup + notifications section

- Remove __DEV__ debug section from settings.tsx:
  Plan-Override-Row (4 rows: LLM, TTS, plan-override, realtime),
  PlanPickerSheetContent, planSheetRef, DEV_PLANS/DEV_PLAN_ACCENT,
  ActivityIndicator import. The debug.tsx page + realtimeDebug.ts store
  are kept — only UI entry points removed from settings.
- Wire notifications section: pushEnabled/streakReminderEnabled toggles
  + streakReminderTime picker (hour/minute scroll, quarters). State
  persisted in AsyncStorage via new stores/notificationPrefs.ts.
  setPushEnabled calls Notifications.requestPermissionsAsync() — if
  denied, toggle stays off. scheduleNotificationAsync is a TODO (no
  backend endpoint yet).
- Add _layout.tsx Stack.Screen entry for help/* route group.
- i18n: new keys settings.notifications_push_sublabel,
  notifications_streak_time, notifications_hour/minute,
  section_help, help_faq/contact/about/crisis + descs in DE/EN/FR.

TODO: expo-notifications scheduleNotificationAsync wiring once
backend streak-reminder endpoint exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-15 23:42:33 +02:00
parent a9fb9273b8
commit 943afb827b
6 changed files with 454 additions and 139 deletions

View File

@ -176,6 +176,14 @@ function RootLayoutInner() {
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="help"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
</Stack>
</AppLockGate>
);

View File

@ -1,6 +1,5 @@
import {
Alert,
ActivityIndicator,
Linking,
Platform,
ScrollView,
@ -24,9 +23,10 @@ import { useAppLockStore } from '../stores/appLock';
import { useThemeStore, type ThemeMode } from '../stores/theme';
import { useLanguageStore, type AppLanguage } from '../stores/language';
import { useUserPlan } from '../hooks/useUserPlan';
import { useMe, invalidateMe, type Plan } from '../hooks/useMe';
import { useMe, invalidateMe } from '../hooks/useMe';
import { apiFetch } from '../lib/api';
import { AppHeader } from '../components/AppHeader';
import { useNotificationPrefsStore } from '../stores/notificationPrefs';
// ─── Subscription Sheet ────────────────────────────────────────────────────
@ -171,6 +171,18 @@ export default function SettingsScreen() {
const { me } = useMe();
const pushEnabled = useNotificationPrefsStore((s) => s.pushEnabled);
const streakReminderEnabled = useNotificationPrefsStore((s) => s.streakReminderEnabled);
const streakReminderTime = useNotificationPrefsStore((s) => s.streakReminderTime);
const setPushEnabled = useNotificationPrefsStore((s) => s.setPushEnabled);
const setStreakReminderEnabled = useNotificationPrefsStore((s) => s.setStreakReminderEnabled);
const setStreakReminderTime = useNotificationPrefsStore((s) => s.setStreakReminderTime);
const initNotifPrefs = useNotificationPrefsStore((s) => s.init);
useEffect(() => {
initNotifPrefs();
}, []);
const hydratedVoice =
me?.lyraVoiceId === 'iFSsEDGbm0FiEd2IVH4w' || me?.lyraVoiceId === 'Gt7OshJCH7MuzX96wFHi'
? (me.lyraVoiceId as LyraVoiceId)
@ -183,7 +195,6 @@ export default function SettingsScreen() {
}, [hydratedVoice]);
const subscriptionSheetRef = useRef<TrueSheet>(null);
const planSheetRef = useRef<TrueSheet>(null);
async function handleVoiceSelect(voiceId: LyraVoiceId) {
if (voiceSaving || voiceId === selectedVoice) return;
@ -256,6 +267,8 @@ export default function SettingsScreen() {
? t('settings.lyra_voice_2')
: t('settings.lyra_voice_default');
const [streakTimePickerVisible, setStreakTimePickerVisible] = useState(false);
const sections: Section[] = [
// Profile-Section entfernt — Profile-Edits sind in /profile-Page direkt
{
@ -328,15 +341,36 @@ export default function SettingsScreen() {
{
icon: 'notifications-outline',
label: t('settings.notifications_push'),
sublabel: t('settings.notifications_push_desc'),
soon: true,
},
{
icon: 'flame-outline',
label: t('settings.notifications_streak'),
sublabel: t('settings.notifications_streak_desc'),
soon: true,
sublabel: t('settings.notifications_push_sublabel'),
toggle: {
value: pushEnabled,
onValueChange: setPushEnabled,
},
},
...(pushEnabled
? [
{
icon: 'flame-outline' as const,
label: t('settings.notifications_streak'),
sublabel: t('settings.notifications_streak_desc'),
toggle: {
value: streakReminderEnabled,
onValueChange: setStreakReminderEnabled,
},
},
...(streakReminderEnabled
? [
{
icon: 'time-outline' as const,
label: t('settings.notifications_streak_time'),
sublabel: t('settings.notifications_streak_time_desc'),
value: `${String(streakReminderTime.hour).padStart(2, '0')}:${String(streakReminderTime.minute).padStart(2, '0')}`,
onPress: () => setStreakTimePickerVisible(true),
},
]
: []),
]
: []),
],
},
{
@ -403,6 +437,36 @@ export default function SettingsScreen() {
} satisfies Section,
]
: []),
{
key: 'help',
title: t('settings.section_help'),
rows: [
{
icon: 'help-circle-outline',
label: t('settings.help_faq'),
sublabel: t('settings.help_faq_desc'),
onPress: () => router.push('/help/faq'),
},
{
icon: 'mail-outline',
label: t('settings.help_contact'),
sublabel: t('settings.help_contact_desc'),
onPress: () => router.push('/help/contact'),
},
{
icon: 'information-circle-outline',
label: t('settings.help_about'),
sublabel: t('settings.help_about_desc'),
onPress: () => router.push('/help/about'),
},
{
icon: 'heart-outline',
label: t('settings.help_crisis'),
sublabel: t('settings.help_crisis_desc'),
onPress: () => router.push('/help/crisis'),
},
],
},
{
key: 'account',
title: t('settings.danger_section'),
@ -424,40 +488,6 @@ export default function SettingsScreen() {
},
];
if (__DEV__) {
sections.push({
key: 'debug',
title: t('settings.section_debug'),
rows: [
{
icon: 'bug-outline',
label: t('settings.debug_llm'),
sublabel: t('settings.debug_llm_desc'),
soon: true,
},
{
icon: 'volume-high-outline',
label: t('settings.debug_tts'),
sublabel: t('settings.debug_tts_desc'),
soon: true,
},
{
icon: 'star-outline',
label: t('settings.debug_plan'),
sublabel: t('settings.debug_plan_desc'),
value: me?.plan ?? '…',
onPress: () => planSheetRef.current?.present(),
},
{
icon: 'pulse-outline',
label: t('settings.debug_realtime'),
sublabel: t('settings.debug_realtime_desc'),
onPress: () => router.push('/debug'),
},
],
});
}
return (
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
<AppHeader showBack title={t('settings.title')} />
@ -705,117 +735,145 @@ export default function SettingsScreen() {
<SubscriptionSheet plan={plan} colors={colors} t={t} />
</TrueSheet>
{__DEV__ && (
<TrueSheet
ref={planSheetRef}
detents={['auto']}
cornerRadius={20}
grabber
backgroundColor={colors.surface}
>
<PlanPickerSheetContent
currentPlan={me?.plan ?? 'free'}
colors={colors}
t={t}
onDone={() => planSheetRef.current?.dismiss()}
/>
</TrueSheet>
)}
{streakTimePickerVisible ? (
<StreakTimePickerSheet
hour={streakReminderTime.hour}
minute={streakReminderTime.minute}
colors={colors}
t={t}
onConfirm={(hour, minute) => {
setStreakReminderTime(hour, minute);
setStreakTimePickerVisible(false);
}}
onClose={() => setStreakTimePickerVisible(false)}
/>
) : null}
</View>
);
}
// ─── Plan-Override Sheet (DEV only) ───────────────────────────────────────
// ─── Streak Time Picker Sheet ─────────────────────────────────────────────
const DEV_PLANS: Plan[] = ['free', 'pro', 'legend'];
const DEV_PLAN_ACCENT: Record<Plan, string> = {
free: '#737373',
pro: '#007AFF',
legend: '#f59e0b',
};
function PlanPickerSheetContent({
currentPlan,
function StreakTimePickerSheet({
hour,
minute,
colors,
t,
onDone,
onConfirm,
onClose,
}: {
currentPlan: Plan;
hour: number;
minute: number;
colors: import('../lib/theme').ColorScheme;
t: (key: string) => string;
onDone: () => void;
onConfirm: (hour: number, minute: number) => void;
onClose: () => void;
}) {
const [loading, setLoading] = useState(false);
const [localHour, setLocalHour] = useState(hour);
const [localMinute, setLocalMinute] = useState(minute);
async function pick(plan: Plan) {
if (plan === currentPlan || loading) return;
setLoading(true);
try {
await apiFetch('/api/dev/set-plan', { method: 'POST', body: { plan } });
invalidateMe();
onDone();
} catch (e: unknown) {
Alert.alert(t('common.error'), e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}
const hourOptions = Array.from({ length: 24 }, (_, i) => i);
const minuteOptions = [0, 15, 30, 45];
return (
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 32, backgroundColor: colors.surface }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text, marginBottom: 4 }}>
{t('settings.debug_plan')}
<View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: colors.surface,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 40,
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 8,
}}
>
<Text style={{ fontSize: 17, fontFamily: 'Nunito_700Bold', color: colors.text, marginBottom: 4 }}>
{t('settings.notifications_streak_time_picker_title')}
</Text>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, marginBottom: 20, lineHeight: 17 }}>
{t('settings.debug_plan_desc')}
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted, marginBottom: 20 }}>
{t('settings.notifications_streak_time_picker_desc')}
</Text>
{DEV_PLANS.map((plan, idx) => {
const isActive = plan === currentPlan;
const accent = DEV_PLAN_ACCENT[plan];
return (
<TouchableOpacity
key={plan}
onPress={() => pick(plan)}
disabled={loading || isActive}
activeOpacity={0.6}
>
<View
<View style={{ flexDirection: 'row', gap: 12, marginBottom: 24 }}>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 8 }}>
{t('settings.notifications_hour')}
</Text>
<ScrollView style={{ maxHeight: 160 }} showsVerticalScrollIndicator={false}>
{hourOptions.map((h) => (
<TouchableOpacity
key={h}
onPress={() => setLocalHour(h)}
activeOpacity={0.7}
style={{
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 8,
marginBottom: 2,
backgroundColor: h === localHour ? '#6366f1' + '22' : 'transparent',
}}
>
<Text style={{ fontSize: 16, fontFamily: h === localHour ? 'Nunito_700Bold' : 'Nunito_400Regular', color: h === localHour ? '#6366f1' : colors.text, textAlign: 'center' }}>
{String(h).padStart(2, '0')}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 8 }}>
{t('settings.notifications_minute')}
</Text>
{minuteOptions.map((m) => (
<TouchableOpacity
key={m}
onPress={() => setLocalMinute(m)}
activeOpacity={0.7}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
borderBottomWidth: idx < DEV_PLANS.length - 1 ? 1 : 0,
borderBottomColor: colors.border,
opacity: loading && !isActive ? 0.5 : 1,
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 8,
marginBottom: 2,
backgroundColor: m === localMinute ? '#6366f1' + '22' : 'transparent',
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<View style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: accent }} />
<Text
style={{
fontSize: 16,
color: colors.text,
fontFamily: isActive ? 'Nunito_700Bold' : 'Nunito_400Regular',
textTransform: 'capitalize',
}}
>
{plan}
</Text>
</View>
{isActive ? (
loading ? (
<ActivityIndicator size="small" color={accent} />
) : (
<Ionicons name="checkmark" size={20} color={accent} />
)
) : null}
</View>
</TouchableOpacity>
);
})}
<Text style={{ fontSize: 16, fontFamily: m === localMinute ? 'Nunito_700Bold' : 'Nunito_400Regular', color: m === localMinute ? '#6366f1' : colors.text, textAlign: 'center' }}>
{String(m).padStart(2, '0')}
</Text>
</TouchableOpacity>
))}
</View>
</View>
<View style={{ flexDirection: 'row', gap: 12 }}>
<TouchableOpacity
onPress={onClose}
activeOpacity={0.7}
style={{ flex: 1, paddingVertical: 14, borderRadius: 12, backgroundColor: colors.surfaceElevated, alignItems: 'center' }}
>
<Text style={{ fontSize: 15, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('common.cancel')}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => onConfirm(localHour, localMinute)}
activeOpacity={0.7}
style={{ flex: 2, paddingVertical: 14, borderRadius: 12, backgroundColor: '#6366f1', alignItems: 'center' }}
>
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#ffffff' }}>
{t('common.confirm')}
</Text>
</TouchableOpacity>
</View>
</View>
);
}

View File

@ -583,7 +583,23 @@
"app_lock": "App-Sperre",
"app_lock_desc": "Beim Öffnen mit Face ID, Touch ID oder Code entsperren",
"app_lock_unavailable": "Auf diesem Gerät nicht verfügbar",
"app_lock_desc_android": "Beim Öffnen mit Fingerabdruck, Gesichtsentsperrung oder PIN entsperren"
"app_lock_desc_android": "Beim Öffnen mit Fingerabdruck, Gesichtsentsperrung oder PIN entsperren",
"notifications_push_sublabel": "Erinnerungen, Lyra-Nachrichten, Streak-Updates",
"notifications_streak_time": "Erinnerungszeit",
"notifications_streak_time_desc": "Wann soll die tägliche Erinnerung erscheinen?",
"notifications_streak_time_picker_title": "Erinnerungszeit wählen",
"notifications_streak_time_picker_desc": "Stunde und Minute für die tägliche Streak-Erinnerung.",
"notifications_hour": "Stunde",
"notifications_minute": "Minute",
"section_help": "Hilfe & Support",
"help_faq": "FAQ",
"help_faq_desc": "Häufige Fragen zur App",
"help_contact": "Kontakt",
"help_contact_desc": "Schreib uns — wir antworten innerhalb 2448h",
"help_about": "Über Rebreak",
"help_about_desc": "Mission, Datenschutz, DiGA-Pfad",
"help_crisis": "Krisen-Hilfe",
"help_crisis_desc": "Externe Beratungsstellen & Notfall-Nummern"
},
"device_limit": {
"title": "Geräte-Limit erreicht",
@ -987,5 +1003,51 @@
"error_file_too_large": "Das Bild ist zu groß.",
"details_label": "Details",
"compress_error_title": "Bild konnte nicht verarbeitet werden"
},
"help": {
"faq_title": "FAQ",
"faq_q1": "Was ist Rebreak?",
"faq_a1": "Rebreak ist eine deutsche App, die Menschen mit problematischem Glücksspielverhalten dabei unterstützt, clean zu bleiben. Sie kombiniert einen technischen Sperr-Mechanismus (Blocker) mit einem KI-Coach (Lyra), Community-Support und einem Mail-Schutz — alles DSGVO-konform auf deutschen Servern.",
"faq_q2": "Wie funktioniert der Blocker?",
"faq_a2": "Auf iOS läuft der Blocker als Netzwerk-Inhaltsfilter direkt auf deinem Gerät — kein Traffic verlässt dein iPhone. Über 208.000 Glücksspiel-Domains werden lokal blockiert. Auf deaktiviert setzen erfordert einen 24-Stunden-Cooldown, damit du Impulsen widerstehen kannst.",
"faq_q3": "Wie funktioniert das Mac-DNS-Profil?",
"faq_a3": "Du lädst ein Konfigurationsprofil herunter, das auf deinem Mac einen DNS-over-HTTPS-Filter aktiviert. Glücksspiel-Domains werden dadurch systemweit auf dem Mac geblockt — in Safari, Chrome, Firefox und allen anderen Apps. Das Profil entfernen erfordert das Admin-Passwort.",
"faq_q4": "Kann ich mein Abo kündigen?",
"faq_a4": "Ja. Du verwaltest dein Abo unter rebreak.org/account — dort kannst du kündigen, downgraden oder upgraden. Das Abo läuft bis zum Ende des bezahlten Zeitraums weiter.",
"faq_q5": "Was passiert mit meinen Daten?",
"faq_a5": "Deine Daten werden ausschließlich auf Servern in Deutschland (Hetzner) gespeichert. Wir verkaufen keine Daten an Dritte. Chat-Verläufe mit Lyra bleiben privat. Die vollständige Datenschutzerklärung findest du auf rebreak.org/datenschutz.",
"faq_q6": "Wie melde ich Bugs oder Feedback?",
"faq_a6": "Schreib uns direkt an hilfe@rebreak.org. Wir antworten innerhalb von 2448h an Werktagen. Für dringende technische Probleme bitte den Betreff 'Bug: ...' verwenden.",
"faq_q7": "Was passiert wenn ich aus Versehen eine Glücksspiel-Domain in der Whitelist habe?",
"faq_a7": "Eigene Domains auf deiner Blockliste sind dauerhaft. Wenn du eine Domain versehentlich hinzugefügt hast, schreib uns — wir können das manuell korrigieren. Im Pro-Plan kannst du Domains zur Community-Abstimmung freigeben und damit den Slot zurückgewinnen.",
"faq_q8": "Was ist DiGA?",
"faq_a8": "DiGA steht für Digitale Gesundheitsanwendung — eine Zertifizierung des Bundesinstituts für Arzneimittel und Medizinprodukte (BfArM). DiGA-zertifizierte Apps können von Ärzten verschrieben und von Krankenkassen erstattet werden. Rebreak befindet sich auf dem DiGA-Zertifizierungspfad.",
"contact_title": "Kontakt",
"contact_email_label": "Support per E-Mail",
"contact_email_desc": "Schreib uns für technische Hilfe, Feedback oder Datenschutz-Anfragen. Wir antworten innerhalb von 2448h an Werktagen.",
"contact_email_cta": "E-Mail schreiben",
"contact_address_label": "Anschrift",
"contact_address_block": "Rebreak\nhilfe@rebreak.org\n\nDeutschland",
"about_title": "Über Rebreak",
"about_headline": "Rebreak",
"about_body": "Rebreak ist eine deutsche App gegen Spielsucht — wir bauen den ersten DiGA-zertifizierten Begleiter für problematisches Glücksspiel.\n\nUnser Ansatz verbindet technischen Schutz (Blocker, Mail-Filter, DNS-Profile) mit einem KI-Coach auf Basis kognitivverhaltenstherapeutischer Methoden. Alles läuft auf deutschen Servern bei Hetzner, DSGVO-konform.\n\nRebreak ist kein Ersatz für professionelle Therapie. Wir verstehen uns als Ergänzung — ein 24/7-Werkzeug für Momente, in denen du allein mit dem Drang bist.",
"about_fact_diga": "DiGA-Zertifizierungspfad aktiv",
"about_fact_servers": "Server ausschließlich in Deutschland (Hetzner)",
"about_fact_privacy": "DSGVO-konform — keine Datenweitergabe an Dritte",
"crisis_title": "Krisen-Hilfe",
"crisis_section_gambling": "Spielsucht-Beratung",
"crisis_section_general": "Allgemeine Krisen-Hilfe",
"crisis_bzga_label": "BZgA Spielsucht-Hotline",
"crisis_bzga_sublabel": "0800 1 372 700 · kostenlos · 24/7",
"crisis_checkdein_label": "check-dein-spiel.de",
"crisis_checkdein_sublabel": "Online-Beratung & Selbsttest",
"crisis_anonyme_label": "Anonyme Spieler",
"crisis_anonyme_sublabel": "www.anonyme-spieler.org · Selbsthilfegruppen",
"crisis_seelsorge_label": "Telefonseelsorge",
"crisis_seelsorge_sublabel": "0800 111 0 111 · kostenlos · 24/7",
"crisis_emergency_label": "Akute Suizidgedanken?",
"crisis_emergency_desc": "Wenn du oder jemand in deiner Nähe in akuter Gefahr ist, ruf sofort den Notruf an.",
"crisis_emergency_cta": "112 — Notruf",
"crisis_disclaimer": "Diese Stellen sind unabhängig von Rebreak. Wir verweisen weiter, beraten aber nicht selbst."
}
}

View File

@ -583,7 +583,23 @@
"app_lock": "App lock",
"app_lock_desc": "Unlock with Face ID, Touch ID or passcode when opening",
"app_lock_unavailable": "Not available on this device",
"app_lock_desc_android": "Unlock with fingerprint, face unlock or PIN when opening"
"app_lock_desc_android": "Unlock with fingerprint, face unlock or PIN when opening",
"notifications_push_sublabel": "Reminders, Lyra messages, streak updates",
"notifications_streak_time": "Reminder time",
"notifications_streak_time_desc": "When should the daily reminder appear?",
"notifications_streak_time_picker_title": "Choose reminder time",
"notifications_streak_time_picker_desc": "Select hour and minute for your daily streak reminder.",
"notifications_hour": "Hour",
"notifications_minute": "Minute",
"section_help": "Help & Support",
"help_faq": "FAQ",
"help_faq_desc": "Common questions about the app",
"help_contact": "Contact",
"help_contact_desc": "Write to us — we reply within 2448h",
"help_about": "About Rebreak",
"help_about_desc": "Mission, privacy, DiGA path",
"help_crisis": "Crisis help",
"help_crisis_desc": "External counselling & emergency numbers"
},
"device_limit": {
"title": "Device limit reached",
@ -987,5 +1003,51 @@
"error_file_too_large": "The image is too large.",
"details_label": "Details",
"compress_error_title": "Could not process image"
},
"help": {
"faq_title": "FAQ",
"faq_q1": "What is Rebreak?",
"faq_a1": "Rebreak is a German app that helps people with problematic gambling behaviour stay clean. It combines a technical blocking mechanism with an AI coach (Lyra), community support and email protection — fully GDPR-compliant on German servers.",
"faq_q2": "How does the blocker work?",
"faq_a2": "On iOS the blocker runs as a network content filter directly on your device — no traffic leaves your iPhone. Over 208,000 gambling domains are blocked locally. Disabling requires a 24-hour cooldown so you can resist impulses.",
"faq_q3": "How does the Mac DNS profile work?",
"faq_a3": "You download a configuration profile that activates a DNS-over-HTTPS filter on your Mac. Gambling domains are blocked system-wide — in Safari, Chrome, Firefox and all other apps. Removing the profile requires the admin password.",
"faq_q4": "Can I cancel my subscription?",
"faq_a4": "Yes. Manage your subscription at rebreak.org/account — cancel, downgrade or upgrade there. Your subscription runs until the end of the paid period.",
"faq_q5": "What happens to my data?",
"faq_a5": "Your data is stored exclusively on servers in Germany (Hetzner). We never sell data to third parties. Lyra chat histories remain private. The full privacy policy is at rebreak.org/datenschutz.",
"faq_q6": "How do I report bugs or give feedback?",
"faq_a6": "Write to us at hilfe@rebreak.org. We reply within 2448 hours on business days. For urgent technical issues please use the subject line 'Bug: ...'.",
"faq_q7": "What if I accidentally have a gambling domain in my custom list?",
"faq_a7": "Custom domains on your blocklist are permanent. If you added one by mistake, write to us — we can correct it manually. On the Pro plan, you can release domains to a community vote and recover the slot.",
"faq_q8": "What is DiGA?",
"faq_a8": "DiGA stands for Digitale Gesundheitsanwendung (Digital Health Application) — a certification by Germany's Federal Institute for Drugs and Medical Devices (BfArM). DiGA-certified apps can be prescribed by doctors and reimbursed by health insurers. Rebreak is on the DiGA certification path.",
"contact_title": "Contact",
"contact_email_label": "Support by email",
"contact_email_desc": "Write to us for technical help, feedback or privacy requests. We reply within 2448 hours on business days.",
"contact_email_cta": "Send email",
"contact_address_label": "Address",
"contact_address_block": "Rebreak\nhilfe@rebreak.org\n\nGermany",
"about_title": "About Rebreak",
"about_headline": "Rebreak",
"about_body": "Rebreak is a German app for gambling addiction — we're building the first DiGA-certified companion for problematic gambling.\n\nOur approach combines technical protection (blocker, mail filter, DNS profiles) with an AI coach based on cognitive behavioural therapy methods. Everything runs on German servers at Hetzner, fully GDPR-compliant.\n\nRebreak is not a substitute for professional therapy. We see ourselves as a complement — a 24/7 tool for moments when you're alone with the urge.",
"about_fact_diga": "DiGA certification path active",
"about_fact_servers": "Servers exclusively in Germany (Hetzner)",
"about_fact_privacy": "GDPR-compliant — no data shared with third parties",
"crisis_title": "Crisis help",
"crisis_section_gambling": "Gambling counselling",
"crisis_section_general": "General crisis support",
"crisis_bzga_label": "BZgA Gambling Helpline",
"crisis_bzga_sublabel": "0800 1 372 700 · free · 24/7",
"crisis_checkdein_label": "check-dein-spiel.de",
"crisis_checkdein_sublabel": "Online counselling & self-test",
"crisis_anonyme_label": "Anonyme Spieler",
"crisis_anonyme_sublabel": "www.anonyme-spieler.org · self-help groups",
"crisis_seelsorge_label": "Telefonseelsorge",
"crisis_seelsorge_sublabel": "0800 111 0 111 · free · 24/7",
"crisis_emergency_label": "Acute suicidal thoughts?",
"crisis_emergency_desc": "If you or someone nearby is in immediate danger, call emergency services immediately.",
"crisis_emergency_cta": "112 — Emergency",
"crisis_disclaimer": "These services are independent of Rebreak. We refer you onward but do not offer counselling ourselves."
}
}

View File

@ -583,7 +583,23 @@
"app_lock": "Verrouillage de l'app",
"app_lock_desc": "Déverrouiller avec Face ID, Touch ID ou code à l'ouverture",
"app_lock_unavailable": "Non disponible sur cet appareil",
"app_lock_desc_android": "Déverrouiller avec empreinte digitale, déverrouillage facial ou PIN à l'ouverture"
"app_lock_desc_android": "Déverrouiller avec empreinte digitale, déverrouillage facial ou PIN à l'ouverture",
"notifications_push_sublabel": "Rappels, messages Lyra, mises à jour de série",
"notifications_streak_time": "Heure de rappel",
"notifications_streak_time_desc": "À quelle heure le rappel quotidien doit-il apparaître ?",
"notifications_streak_time_picker_title": "Choisir l'heure de rappel",
"notifications_streak_time_picker_desc": "Sélectionnez l'heure et les minutes pour votre rappel quotidien.",
"notifications_hour": "Heure",
"notifications_minute": "Minute",
"section_help": "Aide & Support",
"help_faq": "FAQ",
"help_faq_desc": "Questions fréquentes sur l'application",
"help_contact": "Contact",
"help_contact_desc": "Écrivez-nous — réponse sous 2448h",
"help_about": "À propos de Rebreak",
"help_about_desc": "Mission, confidentialité, parcours DiGA",
"help_crisis": "Aide en crise",
"help_crisis_desc": "Services d'écoute & numéros d'urgence"
},
"device_limit": {
"title": "Limite d'appareils atteinte",
@ -984,5 +1000,51 @@
"error_file_too_large": "L'image est trop grande.",
"details_label": "Détails",
"compress_error_title": "Impossible de traiter l'image"
},
"help": {
"faq_title": "FAQ",
"faq_q1": "Was ist Rebreak?",
"faq_a1": "Rebreak ist eine deutsche App, die Menschen mit problematischem Glücksspielverhalten dabei unterstützt, clean zu bleiben. Sie kombiniert einen technischen Sperr-Mechanismus (Blocker) mit einem KI-Coach (Lyra), Community-Support und einem Mail-Schutz — alles DSGVO-konform auf deutschen Servern.",
"faq_q2": "Wie funktioniert der Blocker?",
"faq_a2": "Auf iOS läuft der Blocker als Netzwerk-Inhaltsfilter direkt auf deinem Gerät — kein Traffic verlässt dein iPhone. Über 208.000 Glücksspiel-Domains werden lokal blockiert. Auf deaktiviert setzen erfordert einen 24-Stunden-Cooldown, damit du Impulsen widerstehen kannst.",
"faq_q3": "Wie funktioniert das Mac-DNS-Profil?",
"faq_a3": "Du lädst ein Konfigurationsprofil herunter, das auf deinem Mac einen DNS-over-HTTPS-Filter aktiviert. Glücksspiel-Domains werden dadurch systemweit auf dem Mac geblockt. Das Profil entfernen erfordert das Admin-Passwort.",
"faq_q4": "Kann ich mein Abo kündigen?",
"faq_a4": "Ja. Du verwaltest dein Abo unter rebreak.org/account — dort kannst du kündigen, downgraden oder upgraden.",
"faq_q5": "Was passiert mit meinen Daten?",
"faq_a5": "Deine Daten werden ausschließlich auf Servern in Deutschland (Hetzner) gespeichert. Wir verkaufen keine Daten an Dritte.",
"faq_q6": "Wie melde ich Bugs oder Feedback?",
"faq_a6": "Schreib uns direkt an hilfe@rebreak.org. Wir antworten innerhalb von 2448h an Werktagen.",
"faq_q7": "Was passiert wenn ich aus Versehen eine Glücksspiel-Domain in der Whitelist habe?",
"faq_a7": "Schreib uns — wir können das manuell korrigieren.",
"faq_q8": "Was ist DiGA?",
"faq_a8": "DiGA steht für Digitale Gesundheitsanwendung — eine Zertifizierung des BfArM. DiGA-zertifizierte Apps können von Ärzten verschrieben und von Krankenkassen erstattet werden.",
"contact_title": "Contact",
"contact_email_label": "Support par e-mail",
"contact_email_desc": "Écrivez-nous pour toute aide technique, retour ou demande liée à la confidentialité. Nous répondons sous 2448h les jours ouvrés.",
"contact_email_cta": "Envoyer un e-mail",
"contact_address_label": "Adresse",
"contact_address_block": "Rebreak\nhilfe@rebreak.org\n\nAllemagne",
"about_title": "À propos de Rebreak",
"about_headline": "Rebreak",
"about_body": "Rebreak est une application allemande contre l'addiction aux jeux d'argent — nous construisons le premier accompagnateur certifié DiGA pour le jeu problématique.\n\nNotre approche combine une protection technique (bloqueur, filtre e-mail, profils DNS) avec un coach IA basé sur les méthodes de thérapie cognitivo-comportementale. Tout fonctionne sur des serveurs allemands chez Hetzner, conformément au RGPD.\n\nRebreak ne remplace pas une thérapie professionnelle. Nous nous voyons comme un complément — un outil disponible 24h/7j pour les moments où vous êtes seul face à l'envie.",
"about_fact_diga": "Parcours de certification DiGA actif",
"about_fact_servers": "Serveurs exclusivement en Allemagne (Hetzner)",
"about_fact_privacy": "Conforme au RGPD — aucune transmission de données à des tiers",
"crisis_title": "Aide en crise",
"crisis_section_gambling": "Conseil en addiction aux jeux",
"crisis_section_general": "Soutien en crise générale",
"crisis_bzga_label": "BZgA Spielsucht-Hotline",
"crisis_bzga_sublabel": "0800 1 372 700 · gratuit · 24h/24",
"crisis_checkdein_label": "check-dein-spiel.de",
"crisis_checkdein_sublabel": "Conseil en ligne & autotest",
"crisis_anonyme_label": "Anonyme Spieler",
"crisis_anonyme_sublabel": "www.anonyme-spieler.org · groupes d'entraide",
"crisis_seelsorge_label": "Telefonseelsorge",
"crisis_seelsorge_sublabel": "0800 111 0 111 · gratuit · 24h/24",
"crisis_emergency_label": "Pensées suicidaires aiguës ?",
"crisis_emergency_desc": "Si vous ou quelqu'un près de vous est en danger immédiat, appelez immédiatement les secours.",
"crisis_emergency_cta": "112 — Urgences",
"crisis_disclaimer": "Ces services sont indépendants de Rebreak. Nous vous orientons mais n'assurons pas de conseil nous-mêmes."
}
}

View File

@ -0,0 +1,63 @@
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Notifications from 'expo-notifications';
const STORAGE_KEY = '@rebreak/notifications-prefs';
type NotificationPrefsState = {
pushEnabled: boolean;
streakReminderEnabled: boolean;
streakReminderTime: { hour: number; minute: number };
init: () => Promise<void>;
setPushEnabled: (value: boolean) => Promise<void>;
setStreakReminderEnabled: (value: boolean) => Promise<void>;
setStreakReminderTime: (hour: number, minute: number) => Promise<void>;
};
async function persist(patch: Partial<Pick<NotificationPrefsState, 'pushEnabled' | 'streakReminderEnabled' | 'streakReminderTime'>>) {
const existing = await AsyncStorage.getItem(STORAGE_KEY);
const current = existing ? JSON.parse(existing) : {};
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ ...current, ...patch }));
}
export const useNotificationPrefsStore = create<NotificationPrefsState>((set, get) => ({
pushEnabled: false,
streakReminderEnabled: false,
streakReminderTime: { hour: 9, minute: 0 },
init: async () => {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (!stored) return;
const parsed = JSON.parse(stored);
set({
pushEnabled: parsed.pushEnabled ?? false,
streakReminderEnabled: parsed.streakReminderEnabled ?? false,
streakReminderTime: parsed.streakReminderTime ?? { hour: 9, minute: 0 },
});
},
// TODO: wire up expo-notifications requestPermissionsAsync + scheduleNotificationAsync
// once the streak reminder backend endpoint exists. Currently persists user intent only.
setPushEnabled: async (value) => {
if (value) {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') {
return;
}
}
set({ pushEnabled: value });
await persist({ pushEnabled: value });
},
setStreakReminderEnabled: async (value) => {
set({ streakReminderEnabled: value });
await persist({ streakReminderEnabled: value });
},
setStreakReminderTime: async (hour, minute) => {
const streakReminderTime = { hour, minute };
set({ streakReminderTime });
await persist({ streakReminderTime });
},
}));