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>
153 lines
5.0 KiB
TypeScript
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>
|
|
);
|
|
}
|