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

130 lines
4.0 KiB
TypeScript

import { useRef, useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useColors } from '../../lib/theme';
export function formatVoiceDuration(s: number): string {
const m = Math.floor(s / 60);
const sec = (s % 60).toString().padStart(2, '0');
return `${m}:${sec}`;
}
export function VoiceBars({ count, baseColor, active }: { count: number; baseColor: string; active: boolean }) {
const anims = useRef(Array.from({ length: count }, () => new Animated.Value(3))).current;
const runningRef = useRef(false);
useEffect(() => {
if (active && !runningRef.current) {
runningRef.current = true;
const animations = anims.map((a, i) =>
Animated.loop(
Animated.sequence([
Animated.timing(a, { toValue: 3 + Math.random() * 14, duration: 400 + (i % 5) * 90, useNativeDriver: false }),
Animated.timing(a, { toValue: 3, duration: 400 + (i % 5) * 90, useNativeDriver: false }),
])
)
);
animations.forEach((a) => a.start());
return () => { animations.forEach((a) => a.stop()); runningRef.current = false; };
} else if (!active) {
anims.forEach((a) => Animated.timing(a, { toValue: 3, duration: 150, useNativeDriver: false }).start());
runningRef.current = false;
}
}, [active]);
return (
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-evenly', height: 20 }}>
{anims.map((a, i) => (
<Animated.View
key={i}
style={{ width: active ? 2.5 : 2, height: active ? a : 2, borderRadius: 2, backgroundColor: baseColor, opacity: active ? 0.75 : 0.4 }}
/>
))}
</View>
);
}
type Props = {
duration: number;
level: number;
trashFlash: boolean;
onCancel: () => void;
onSend: () => void;
/** Icon for the send button. Default: 'arrow-up' (coach). DM uses 'checkmark'. */
sendIcon?: 'arrow-up' | 'checkmark';
/** Accent color for the send button. Defaults to brandOrange. */
accentColor?: string;
};
export function VoiceRecordingBar({ duration, level, trashFlash, onCancel, onSend, sendIcon = 'arrow-up', accentColor }: Props) {
const colors = useColors();
const accent = accentColor ?? colors.brandOrange;
return (
<View style={[styles.bar, { backgroundColor: colors.surfaceElevated, borderColor: colors.border }]}>
<TouchableOpacity
style={[styles.sideBtn, { backgroundColor: trashFlash ? 'rgba(220,38,38,0.15)' : colors.bg }]}
onPress={onCancel}
activeOpacity={0.7}
hitSlop={8}
>
<Ionicons name="trash-outline" size={17} color={trashFlash ? '#ef4444' : colors.textMuted} />
</TouchableOpacity>
<View style={styles.center}>
<View style={[styles.liveDot, { backgroundColor: '#ef4444' }]} />
<VoiceBars count={22} baseColor={colors.text} active={level > 0.1} />
<Text style={[styles.timer, { color: colors.textMuted }]}>
{formatVoiceDuration(duration)}
</Text>
</View>
<TouchableOpacity
style={[styles.sideBtn, { backgroundColor: accent }]}
onPress={onSend}
activeOpacity={0.8}
hitSlop={6}
>
<Ionicons name={sendIcon} size={sendIcon === 'checkmark' ? 20 : 18} color="#fff" />
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
bar: {
flex: 1,
height: 44,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 22,
paddingHorizontal: 6,
},
sideBtn: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
center: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
liveDot: {
width: 7,
height: 7,
borderRadius: 3.5,
},
timer: {
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
fontVariant: ['tabular-nums'],
minWidth: 32,
},
});