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>
710 lines
22 KiB
TypeScript
710 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|