chahinebrini 2e49aad386 feat(voice+chat): voice notes DM, chat list attachment preview, DiGA milestone modal
Voice Notes (DM):
- WhatsApp-style voice recording bar (shared VoiceRecordingBar component)
- Audio bubbles: 80 fixed-2dp bars (Instagram-style thin), space-between layout,
  deterministic waveform, moving blue position dot, WA gray bar colors
- Cancel flash fix: setIsVoiceRecording delayed 350ms so trash flash is visible
- Mic button 44pt (Apple min), hitSlop on all recording controls
- startReply shows 🎤/📷 label for voice/image instead of empty

Chat list:
- lastAttachmentType from backend (getDmConversations now selects attachmentType)
- Shows '🎤 Sprachnachricht' / '📷 Foto' / '📎 Medien' as fallback per type
- User search second stage: GET /api/users/search?q= + debounced frontend section
- Push preview: audio → '🎤 Sprachnachricht', image → '📷 Foto' (was '📎 Anhang')

Blocker iOS Layer 3 (Screen Time):
- ScreentimePasscodeCard visible in locked-in state (was hidden once both layers active)
- Confirmed status loaded from backend on mount
- Numbered step instructions (iOS has no deep link to passcode dialog)
- Guard: only for unsupervised VPN+FC path (!mdmManaged && !nefilterActive)
- URL fallback: App-Prefs:SCREEN_TIME → App-Prefs:root=SCREEN_TIME → openSettings

DiGA Milestone Modal:
- Day 3/7/10 celebratory bottom sheet with soft demographic data ask
- Per-user/milestone AsyncStorage tracking, never shows if demographics filled
- Opens DemographicsAccordion in profile via ?openDemo=1 param

Lyra coach: contextual DiGA demographic nudge (optional, positive moments only)
i18n: DE/EN/FR/AR for voice_message, photo, media_sent, mic_access, diga_milestone,
  screentime steps, chat search strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 01:59:26 +02:00

153 lines
5.0 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { View, Text, TouchableOpacity, Animated } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useMe } from '../hooks/useMe';
import { apiFetch } from '../lib/api';
import { FormSheet } from './FormSheet';
import { useColors } from '../lib/theme';
const MILESTONES = [3, 7, 10] as const;
function storageKey(userId: string, days: number) {
return `@rebreak/diga_milestone_${userId}_${days}`;
}
type DemographicsResp = { birthYear: number | null } & Record<string, unknown>;
export function DiGaMilestoneModal() {
const { t } = useTranslation();
const colors = useColors();
const router = useRouter();
const { me } = useMe();
const [milestone, setMilestone] = useState<number | null>(null);
const scaleAnim = useRef(new Animated.Value(0.8)).current;
// Lean demographics check — only birthYear needed to determine completeness
const { data: demo } = useQuery<DemographicsResp>({
queryKey: ['diga-demo-check'],
queryFn: () => apiFetch('/api/profile/me/demographics'),
enabled: !!me,
staleTime: 60_000,
});
useEffect(() => {
if (!me || demo === undefined) return;
const streak = me.streak ?? 0;
const demographicsComplete = !!(demo?.birthYear);
if (demographicsComplete) return; // already filled → never show
(async () => {
// Find highest milestone reached and not yet shown
for (let i = MILESTONES.length - 1; i >= 0; i--) {
const m = MILESTONES[i];
if (streak < m) continue;
const shown = await AsyncStorage.getItem(storageKey(me.id, m));
if (!shown) {
setMilestone(m);
return;
}
}
})();
}, [me?.id, me?.streak, demo]);
useEffect(() => {
if (milestone !== null) {
Animated.spring(scaleAnim, { toValue: 1, useNativeDriver: true, damping: 14 }).start();
} else {
scaleAnim.setValue(0.8);
}
}, [milestone]);
async function dismiss() {
if (!me || !milestone) return;
await AsyncStorage.setItem(storageKey(me.id, milestone), '1');
setMilestone(null);
}
async function openProfile() {
await dismiss();
router.push('/profile?openDemo=1' as any);
}
if (!milestone) return null;
const badgeColor = milestone >= 10 ? '#f59e0b' : milestone >= 7 ? '#8b5cf6' : colors.brandOrange;
return (
<FormSheet
visible={true}
onClose={dismiss}
title=""
initialHeightPct={0.52}
dismissOnBackdrop
>
<Animated.View style={{ transform: [{ scale: scaleAnim }], paddingHorizontal: 24, paddingTop: 8, paddingBottom: 32, alignItems: 'center', gap: 0 }}>
{/* Milestone badge */}
<View style={{
width: 80, height: 80, borderRadius: 40,
backgroundColor: badgeColor + '18',
alignItems: 'center', justifyContent: 'center',
marginBottom: 16,
}}>
<Text style={{ fontSize: 40 }}>
{milestone >= 10 ? '🏆' : milestone >= 7 ? '🌟' : '🎉'}
</Text>
</View>
<View style={{
backgroundColor: badgeColor + '18',
borderRadius: 20,
paddingHorizontal: 14,
paddingVertical: 5,
marginBottom: 14,
}}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: badgeColor }}>
{t('diga_milestone.badge', { days: milestone })}
</Text>
</View>
<Text style={{ fontSize: 22, fontFamily: 'Nunito_800ExtraBold', color: colors.text, textAlign: 'center', marginBottom: 10 }}>
{t('diga_milestone.title', { days: milestone })}
</Text>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_400Regular', color: colors.textMuted, textAlign: 'center', lineHeight: 21, marginBottom: 28 }}>
{t('diga_milestone.body')}
</Text>
{/* Primary CTA */}
<TouchableOpacity
onPress={openProfile}
activeOpacity={0.85}
style={{
width: '100%',
backgroundColor: badgeColor,
borderRadius: 14,
paddingVertical: 15,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
marginBottom: 10,
}}
>
<Ionicons name="person-outline" size={17} color="#fff" />
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('diga_milestone.cta')}
</Text>
</TouchableOpacity>
{/* Dismiss */}
<TouchableOpacity onPress={dismiss} activeOpacity={0.7} hitSlop={12}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('diga_milestone.later')}
</Text>
</TouchableOpacity>
</Animated.View>
</FormSheet>
);
}