chahinebrini 3c52d8869e feat(native): WIP checkpoint — Profile/Settings/Demographics + WheelPicker + Maestro
Rollback-Punkt vor Expo SDK 54 / RN 0.81 Upgrade.

UI/UX:
- Profile: ProfileHeader redesign (sign-in chip + member-since), StatsBar 3 pill cards,
  Demographics accordion completed (Geburtsjahr, Geschlecht, Familienstand, Beruf-split,
  Wohnort), Pro-Trial-Banner, Approved-Domains list, DigaMissionBanner
- Settings: section-based layout, neutral icons (matched Header dropdown style)
- Header dropdown: extended with logout + games-page link
- Notifications page: skeleton dummy data
- Locales: i18n keys for new screens

New components:
- WheelPickerModal: native iOS UIPickerView wheel for long lists (Geburtsjahr 91 items,
  Bundesland 16, Stadt 30+/Bundesland)
- OptionsBottomSheet: iOS-style options sheet (used briefly for Geschlecht, currently
  unused — kept for potential future use)
- germanCities.ts: Top-cities per Bundesland (DSGVO-clean static data)

New libs (NewArch-codegen verified):
- @react-native-menu/menu 2.0.0 (UIMenu wrapper, Apple HIG-konform)
- @lodev09/react-native-true-sheet 3.10.1 (UISheetPresentationController wrapper —
  ABER incompatible mit RN 0.79.6, Build-Error → Trigger für SDK-54-Upgrade)

Maestro E2E:
- Initial setup mit auth/community/profile/urge flows

Scripts:
- build-ios-clean.sh: Xcode DerivedData + ios/build cleanup vor expo run:ios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:32:27 +02:00

710 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react';
import {
Modal,
View,
Text,
Pressable,
ScrollView,
Dimensions,
Animated,
PanResponder,
ActivityIndicator,
Easing,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import Svg, { Path, Circle } from 'react-native-svg';
import type { ProtectionState } from '../../lib/protection';
import { apiFetch } from '../../lib/api';
type Props = {
visible: boolean;
state: ProtectionState;
onClose: () => void;
onRequestDeactivation: () => void;
onTalkToLyra: () => void;
};
type StatsResponse = {
current: number;
weeklyAdded: number;
monthlyAdded: number;
history: { label: string; count: number }[];
submissions: { inVote: number; inReview: number };
mySubmissions: { active: number; inVote: number; inReview: number };
avgPerUser: number;
avgApprovalWaitDays: number;
};
const SCREEN_HEIGHT = Dimensions.get('window').height;
const DEFAULT_HEIGHT = SCREEN_HEIGHT * 0.85;
const EXPANDED_HEIGHT = SCREEN_HEIGHT * 0.95;
const MIN_HEIGHT = SCREEN_HEIGHT * 0.4;
const DISMISS_HEIGHT = SCREEN_HEIGHT * 0.3;
// Brand colors
const HERO_COLOR = '#f97316'; // orange-500 (counter accent)
const SEG_ACTIVE = '#16a34a';
const SEG_VOTE = '#3b82f6';
const SEG_REVIEW = '#f59e0b';
export function ProtectionDetailsSheet({
visible,
state,
onClose,
onRequestDeactivation,
}: Props) {
const { t, i18n } = useTranslation();
const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US';
const sheetHeight = useRef(new Animated.Value(DEFAULT_HEIGHT)).current;
const dismissY = useRef(new Animated.Value(0)).current;
const currentHeight = useRef(DEFAULT_HEIGHT);
useEffect(() => {
if (visible) {
sheetHeight.setValue(DEFAULT_HEIGHT);
dismissY.setValue(0);
currentHeight.current = DEFAULT_HEIGHT;
}
}, [visible, sheetHeight, dismissY]);
const handleClose = () => {
sheetHeight.setValue(DEFAULT_HEIGHT);
dismissY.setValue(0);
currentHeight.current = DEFAULT_HEIGHT;
onClose();
};
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderMove: (_, g) => {
const next = currentHeight.current - g.dy;
const clamped = Math.max(DISMISS_HEIGHT - 60, Math.min(EXPANDED_HEIGHT + 20, next));
sheetHeight.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
const finalH = currentHeight.current - g.dy;
const velocity = g.vy;
if (finalH < DISMISS_HEIGHT || velocity > 1.5) {
Animated.timing(dismissY, {
toValue: SCREEN_HEIGHT,
duration: 200,
useNativeDriver: true,
}).start(() => handleClose());
return;
}
let target = finalH;
if (velocity < -1.5) target = EXPANDED_HEIGHT;
const clamped = Math.max(MIN_HEIGHT, Math.min(EXPANDED_HEIGHT, target));
Animated.spring(sheetHeight, {
toValue: clamped,
useNativeDriver: false,
friction: 9,
tension: 70,
}).start();
currentHeight.current = clamped;
},
}),
).current;
const [stats, setStats] = useState<StatsResponse | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
useEffect(() => {
if (!visible) return;
let alive = true;
setLoadingStats(true);
apiFetch<StatsResponse>('/api/blocklist/stats')
.then((res) => { if (alive) setStats(res); })
.catch(() => { /* silent */ })
.finally(() => { if (alive) setLoadingStats(false); });
return () => { alive = false; };
}, [visible]);
const globalCount = stats?.current ?? state.blocklistCount;
const weeklyAdded = stats?.weeklyAdded ?? 0;
const monthlyAdded = stats?.monthlyAdded ?? 0;
const myActive = stats?.mySubmissions?.active ?? 0;
const myInVote = stats?.mySubmissions?.inVote ?? 0;
const myInReview = stats?.mySubmissions?.inReview ?? 0;
const avgPerUser = stats?.avgPerUser ?? 0;
const avgWait = stats?.avgApprovalWaitDays ?? 0;
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={handleClose}
statusBarTranslucent
>
<Pressable
onPress={handleClose}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.45)' }}
/>
<Animated.View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: sheetHeight,
}}
>
<Animated.View
style={{
flex: 1,
backgroundColor: '#fff',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
transform: [{ translateY: dismissY }],
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.08,
shadowRadius: 8,
}}
>
{/* Drag-Bar */}
<View
{...panResponder.panHandlers}
style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}
>
<View style={{ width: 36, height: 5, borderRadius: 3, backgroundColor: '#d4d4d8' }} />
</View>
{/* Header */}
<View
{...panResponder.panHandlers}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 4,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
}}
>
<View style={{ width: 50 }} />
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{t('blocker.details_title')}
</Text>
<Pressable onPress={handleClose} hitSlop={10} style={{ width: 50, alignItems: 'flex-end' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
{t('blocker.details_done')}
</Text>
</Pressable>
</View>
<ScrollView
contentContainerStyle={{ padding: 20, paddingBottom: 40, gap: 18 }}
showsVerticalScrollIndicator
>
{loadingStats && !stats ? (
<View style={{ padding: 40, alignItems: 'center' }}>
<ActivityIndicator color="#737373" />
</View>
) : null}
{/* HERO Globale geblockte Domains: Counter (slow, color) + 2 Delta-Badges */}
<View
style={{
padding: 20,
borderRadius: 16,
backgroundColor: '#0a0a0a',
gap: 14,
}}
>
<View>
<Text style={{ fontSize: 11, color: '#a3a3a3', fontFamily: 'Nunito_700Bold', letterSpacing: 0.5, textTransform: 'uppercase' }}>
{t('blocker.kpi_global_label')}
</Text>
<AnimatedCounter
value={globalCount}
locale={localeTag}
durationMs={2400}
style={{ fontSize: 38, color: HERO_COLOR, fontFamily: 'Nunito_900Black', letterSpacing: -1, marginTop: 2 }}
/>
</View>
<View style={{ flexDirection: 'row', gap: 10 }}>
<DeltaBadge value={weeklyAdded} label={t('blocker.delta_week')} locale={localeTag} />
<DeltaBadge value={monthlyAdded} label={t('blocker.delta_month')} locale={localeTag} />
</View>
</View>
{/* SUBMISSIONS Half Donut mit center-number + center-legend */}
<View
style={{
padding: 18,
borderRadius: 16,
borderWidth: 1,
borderColor: '#e5e5e5',
backgroundColor: '#fff',
gap: 8,
}}
>
<View>
<Text style={{ fontSize: 13, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
{t('blocker.kpi_submissions_title')}
</Text>
<Text style={{ fontSize: 11, color: '#737373', fontFamily: 'Nunito_400Regular', marginTop: 2 }}>
{t('blocker.kpi_submissions_subtitle')}
</Text>
</View>
<HalfDonut
segments={[
{ value: myActive, color: SEG_ACTIVE },
{ value: myInVote, color: SEG_VOTE },
{ value: myInReview, color: SEG_REVIEW },
]}
centerValue={myActive + myInVote + myInReview}
centerLabel={t('blocker.kpi_my_submissions')}
/>
{/* Centered Legend */}
<View
style={{
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'flex-start',
gap: 14,
marginTop: 4,
}}
>
<LegendItem color={SEG_ACTIVE} label={t('blocker.kpi_status_active')} value={myActive} />
<LegendItem color={SEG_VOTE} label={t('blocker.kpi_status_vote')} value={myInVote} />
<LegendItem color={SEG_REVIEW} label={t('blocker.kpi_status_review')} value={myInReview} />
</View>
</View>
{/* AVG KPIs kleiner */}
<View style={{ flexDirection: 'row', gap: 10 }}>
<KpiCard
icon="person-circle-outline"
label={t('blocker.kpi_avg_per_user')}
value={avgPerUser}
locale={localeTag}
decimals={1}
/>
<KpiCard
icon="time-outline"
label={t('blocker.kpi_avg_wait')}
value={avgWait}
locale={localeTag}
decimals={1}
suffix={t('blocker.kpi_days_suffix')}
/>
</View>
{/* FAQ-Banner: Heading-Row mit Help-Icon rechts (kein gestapeltes Layout) */}
<View style={{ alignSelf: 'stretch', gap: 8, marginTop: 4 }}>
<View
style={{
alignSelf: 'stretch',
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
}}
>
<Text
style={{
flex: 1,
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#737373',
letterSpacing: 0.5,
textTransform: 'uppercase',
}}
>
{t('blocker.faq_heading')}
</Text>
<Ionicons name="help-circle-outline" size={18} color="#737373" />
</View>
{[1, 2, 3, 4].map((n) => (
<FaqItem
key={n}
question={t(`blocker.faq${n}_q`)}
answer={t(`blocker.faq${n}_a`)}
/>
))}
</View>
{/* MEHR INFO outline button: Pressable=card, inner View=flex-row */}
<Pressable
onPress={onRequestDeactivation}
style={({ pressed }) => ({
marginTop: 4,
opacity: pressed ? 0.75 : 1,
})}
>
<View style={{
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1.5,
borderColor: HERO_COLOR,
backgroundColor: '#fff7ed',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
}}>
<Ionicons name="information-circle-outline" size={18} color={HERO_COLOR} />
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: HERO_COLOR }}>
{t('blocker.more_info_title')}
</Text>
</View>
</Pressable>
</ScrollView>
</Animated.View>
</Animated.View>
</Modal>
);
}
// ─── Animated Counter ──────────────────────────────────────────────────────
function AnimatedCounter({
value,
locale,
decimals = 0,
durationMs = 1200,
style,
}: {
value: number;
locale: string;
decimals?: number;
durationMs?: number;
style?: any;
}) {
const anim = useRef(new Animated.Value(0)).current;
const [display, setDisplay] = useState(0);
useEffect(() => {
anim.setValue(0);
const listener = anim.addListener(({ value: v }) => {
setDisplay(v * value);
});
Animated.timing(anim, {
toValue: 1,
duration: durationMs,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
return () => anim.removeListener(listener);
}, [value, anim, durationMs]);
const formatted = display.toLocaleString(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
return <Text style={style}>{formatted}</Text>;
}
// ─── Delta Badge (e.g. "+25 diese Woche ↗") ────────────────────────────────
function DeltaBadge({
value,
label,
locale,
}: {
value: number;
label: string;
locale: string;
}) {
const formatted = `+${value.toLocaleString(locale)}`;
return (
<View
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 10,
backgroundColor: '#1f1f1f',
borderWidth: 1,
borderColor: '#262626',
}}
>
<View
style={{
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: 'rgba(22,163,74,0.15)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="arrow-up" size={14} color="#22c55e" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 13, color: '#fff', fontFamily: 'Nunito_900Black', letterSpacing: -0.3 }}>
{formatted}
</Text>
<Text style={{ fontSize: 10, color: '#a3a3a3', fontFamily: 'Nunito_400Regular' }}>
{label}
</Text>
</View>
</View>
);
}
// ─── KPI Card (small) ──────────────────────────────────────────────────────
function KpiCard({
icon,
label,
value,
locale,
decimals = 0,
suffix,
}: {
icon: any;
label: string;
value: number;
locale: string;
decimals?: number;
suffix?: string;
}) {
return (
<View
style={{
flex: 1,
padding: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e5e5',
backgroundColor: '#fafafa',
gap: 6,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name={icon} size={14} color="#737373" />
<Text style={{ flex: 1, fontSize: 10, color: '#737373', fontFamily: 'Nunito_400Regular', lineHeight: 13 }}>
{label}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 4 }}>
<AnimatedCounter
value={value}
locale={locale}
decimals={decimals}
style={{ fontSize: 18, fontFamily: 'Nunito_900Black', color: '#0a0a0a', letterSpacing: -0.3 }}
/>
{suffix ? (
<Text style={{ fontSize: 10, color: '#737373', fontFamily: 'Nunito_700Bold' }}>{suffix}</Text>
) : null}
</View>
</View>
);
}
// ─── Legend Item (compact, centered row) ───────────────────────────────────
function LegendItem({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<View style={{ alignItems: 'center', gap: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 5 }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color }} />
<Text style={{ fontSize: 11, color: '#525252', fontFamily: 'Nunito_700Bold' }}>{value}</Text>
</View>
<Text style={{ fontSize: 10, color: '#737373', fontFamily: 'Nunito_400Regular' }}>{label}</Text>
</View>
);
}
// ─── Half Donut (multi-segment) ────────────────────────────────────────────
function HalfDonut({
segments,
centerValue,
centerLabel,
}: {
segments: { value: number; color: string }[];
centerValue: number;
centerLabel: string;
}) {
const total = Math.max(1, segments.reduce((s, x) => s + x.value, 0));
const W = 220;
const H = 130;
const cx = W / 2;
const cy = H - 8;
const r = 90;
const stroke = 18;
// Compute cumulative angles in [180, 360]
let cumAngle = 180;
const arcs = segments.map((seg) => {
const startAngle = cumAngle;
const endAngle = cumAngle + 180 * (seg.value / total);
cumAngle = endAngle;
return { ...seg, startAngle, endAngle };
});
const animProgress = useRef(new Animated.Value(0)).current;
const [progress, setProgress] = useState(0);
useEffect(() => {
animProgress.setValue(0);
const l = animProgress.addListener(({ value }) => setProgress(value));
Animated.timing(animProgress, {
toValue: 1,
duration: 1100,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
return () => animProgress.removeListener(l);
}, [centerValue, animProgress]);
return (
<View style={{ alignItems: 'center', justifyContent: 'center' }}>
<Svg width={W} height={H}>
{/* Background track */}
<Path
d={arcPath(cx, cy, r, 180, 360)}
stroke="#f0f0f0"
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
/>
{arcs.map((a, i) => {
const animatedEnd =
a.startAngle + (a.endAngle - a.startAngle) * progress;
if (animatedEnd <= a.startAngle + 0.5) return null;
return (
<Path
key={i}
d={arcPath(cx, cy, r, a.startAngle, animatedEnd)}
stroke={a.color}
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
/>
);
})}
{centerValue === 0 && (
<Circle cx={cx} cy={cy - r + stroke / 2} r={3} fill="#d4d4d8" />
)}
</Svg>
{/* Center number — exactly centered horizontally + vertically inside semicircle */}
<View
pointerEvents="none"
style={{
position: 'absolute',
left: 0,
right: 0,
top: H / 2 + 4,
alignItems: 'center',
}}
>
<Text style={{ fontSize: 30, fontFamily: 'Nunito_900Black', color: '#0a0a0a', letterSpacing: -0.5 }}>
{centerValue}
</Text>
<Text style={{ fontSize: 10, color: '#737373', fontFamily: 'Nunito_400Regular', marginTop: -2 }}>
{centerLabel}
</Text>
</View>
</View>
);
}
function arcPath(cx: number, cy: number, r: number, startDeg: number, endDeg: number) {
const start = polar(cx, cy, r, startDeg);
const end = polar(cx, cy, r, endDeg);
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`;
}
function polar(cx: number, cy: number, r: number, angleDeg: number) {
const rad = (angleDeg * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
// ─── FAQ Item (chevron AT END of header row, on right) ─────────────────────
function FaqItem({ question, answer }: { question: string; answer: string }) {
const [open, setOpen] = useState(false);
const rotateAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(rotateAnim, {
toValue: open ? 1 : 0,
duration: 200,
useNativeDriver: true,
}).start();
}, [open, rotateAnim]);
const rotate = rotateAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '180deg'],
});
return (
<View
style={{
alignSelf: 'stretch',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 12,
overflow: 'hidden',
backgroundColor: '#fff',
}}
>
<Pressable
onPress={() => setOpen((v) => !v)}
style={({ pressed }) => ({
opacity: pressed ? 0.75 : 1,
})}
>
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14 }}>
<View style={{ flex: 1, paddingRight: 12 }}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#0a0a0a', lineHeight: 18 }}>
{question}
</Text>
</View>
<Animated.View
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#f5f5f5',
alignItems: 'center',
justifyContent: 'center',
transform: [{ rotate }],
}}
>
<Ionicons name="chevron-down" size={16} color="#525252" />
</Animated.View>
</View>
</Pressable>
{open && (
<View style={{ paddingHorizontal: 14, paddingBottom: 14, paddingTop: 0 }}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: '#525252', lineHeight: 19 }}>
{answer}
</Text>
</View>
)}
</View>
);
}