The custom modals each rolled their own Modal + animated-height + PanResponder +
keyboard handling, inconsistently. <FormSheet> is the single parametrized
composable, generalized from the proven PostCommentsSheet pattern:
- standard header: centred grabber + left-aligned title — NO Fertig/Abbrechen/
Zurück buttons (dismiss = swipe down / backdrop tap)
- resizable via drag on handle/header; drag-down past minHeightPct (or a fast
flick) dismisses
- height hard-capped at 75% of the screen — drag AND keyboard-expand
- keyboard-aware: sheet grows by the keyboard height (capped), iOS paddingBottom
pushes the content exactly above the keyboard; Android adjustResize handles it
- JS-driver height / native-driver translateY split (avoids the "height not
supported by native animated module" crash)
- props: title, initialHeightPct, minHeightPct, backdropOpacity, dismissOnBackdrop,
safeAreaBottom, growWithKeyboard, topRadius
Migrated (phase 1 — the no-input content sheets):
- ProtectionDetailsSheet → drops the bespoke Modal/PanResponder + the "Fertig"
header button; was 0.9–0.95 tall, now ≤0.75
- DeactivationExplainerSheet → was a pageSheet Modal with a "Zurück" button;
now the standard bottom sheet, header button gone
- PostCommentsSheet → capped its expand height 0.92 → 0.75 (TODO phase-1b: move
it onto <FormSheet> too instead of pinning magic numbers)
Phase 2 (next): <SheetFieldStack> — progressive multi-input flow (active input
pinned above the keyboard + "→" to advance, filled fields stack above, the rest
of the form reveals after the last field) for ConnectMailSheet / AddDomainSheet /
EditMailAccountSheet / CreateRoomSheet; then the auth/edit full-screen pages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
588 lines
18 KiB
TypeScript
588 lines
18 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import {
|
||
View,
|
||
Text,
|
||
TouchableOpacity,
|
||
ScrollView,
|
||
Animated,
|
||
ActivityIndicator,
|
||
Easing,
|
||
} from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
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';
|
||
import { useColors } from '../../lib/theme';
|
||
import { FormSheet } from '../FormSheet';
|
||
|
||
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;
|
||
};
|
||
|
||
// 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 colors = useColors();
|
||
const insets = useSafeAreaInsets();
|
||
const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US';
|
||
|
||
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 (
|
||
<FormSheet
|
||
visible={visible}
|
||
onClose={onClose}
|
||
title={t('blocker.details_title')}
|
||
initialHeightPct={0.75}
|
||
minHeightPct={0.3}
|
||
safeAreaBottom={false}
|
||
>
|
||
<ScrollView
|
||
style={{ flex: 1 }}
|
||
contentContainerStyle={{ padding: 20, paddingBottom: Math.max(insets.bottom, 16) + 32, 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: colors.border,
|
||
backgroundColor: colors.bg,
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<View>
|
||
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
||
{t('blocker.kpi_submissions_title')}
|
||
</Text>
|
||
<Text style={{ fontSize: 11, color: colors.textMuted, 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: colors.textMuted,
|
||
letterSpacing: 0.5,
|
||
textTransform: 'uppercase',
|
||
}}
|
||
>
|
||
{t('blocker.faq_heading')}
|
||
</Text>
|
||
<Ionicons name="help-circle-outline" size={18} color={colors.textMuted} />
|
||
</View>
|
||
{[1, 2, 3, 4].map((n) => (
|
||
<FaqItem
|
||
key={n}
|
||
question={t(`blocker.faq${n}_q`)}
|
||
answer={t(`blocker.faq${n}_a`)}
|
||
/>
|
||
))}
|
||
</View>
|
||
|
||
{/* "Schutz deaktivieren" – outline button: TouchableOpacity=card, inner View=flex-row */}
|
||
<TouchableOpacity
|
||
onPress={onRequestDeactivation}
|
||
activeOpacity={0.75}
|
||
style={{ marginTop: 4 }}
|
||
>
|
||
<View style={{
|
||
paddingVertical: 14,
|
||
paddingHorizontal: 16,
|
||
borderRadius: 12,
|
||
borderWidth: 1.5,
|
||
borderColor: HERO_COLOR,
|
||
backgroundColor: colors.surface,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 8,
|
||
}}>
|
||
<Ionicons name="lock-open-outline" size={18} color={HERO_COLOR} />
|
||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: HERO_COLOR }}>
|
||
{t('blocker.more_info_title')}
|
||
</Text>
|
||
</View>
|
||
</TouchableOpacity>
|
||
</ScrollView>
|
||
</FormSheet>
|
||
);
|
||
}
|
||
|
||
// ─── 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;
|
||
}) {
|
||
const colors = useColors();
|
||
return (
|
||
<View
|
||
style={{
|
||
flex: 1,
|
||
padding: 12,
|
||
borderRadius: 12,
|
||
borderWidth: 1,
|
||
borderColor: colors.border,
|
||
backgroundColor: colors.surface,
|
||
gap: 6,
|
||
}}
|
||
>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||
<Ionicons name={icon} size={14} color={colors.textMuted} />
|
||
<Text style={{ flex: 1, fontSize: 10, color: colors.textMuted, 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: colors.text, letterSpacing: -0.3 }}
|
||
/>
|
||
{suffix ? (
|
||
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_700Bold' }}>{suffix}</Text>
|
||
) : null}
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ─── Legend Item (compact, centered row) ───────────────────────────────────
|
||
function LegendItem({
|
||
color,
|
||
label,
|
||
value,
|
||
}: {
|
||
color: string;
|
||
label: string;
|
||
value: number;
|
||
}) {
|
||
const colors = useColors();
|
||
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: colors.textMuted, fontFamily: 'Nunito_700Bold' }}>{value}</Text>
|
||
</View>
|
||
<Text style={{ fontSize: 10, color: colors.textMuted, 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 colors = useColors();
|
||
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={colors.surfaceElevated}
|
||
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: colors.text, letterSpacing: -0.5 }}>
|
||
{centerValue}
|
||
</Text>
|
||
<Text style={{ fontSize: 10, color: colors.textMuted, 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 colors = useColors();
|
||
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: colors.border,
|
||
borderRadius: 12,
|
||
overflow: 'hidden',
|
||
backgroundColor: colors.bg,
|
||
}}
|
||
>
|
||
<TouchableOpacity
|
||
onPress={() => setOpen((v) => !v)}
|
||
activeOpacity={0.75}
|
||
>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14 }}>
|
||
<View style={{ flex: 1, paddingRight: 12 }}>
|
||
<Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: colors.text, lineHeight: 18 }}>
|
||
{question}
|
||
</Text>
|
||
</View>
|
||
<Animated.View
|
||
style={{
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 14,
|
||
backgroundColor: colors.surfaceElevated,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
transform: [{ rotate }],
|
||
}}
|
||
>
|
||
<Ionicons name="chevron-down" size={16} color={colors.textMuted} />
|
||
</Animated.View>
|
||
</View>
|
||
</TouchableOpacity>
|
||
{open && (
|
||
<View style={{ paddingHorizontal: 14, paddingBottom: 14, paddingTop: 0 }}>
|
||
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 19 }}>
|
||
{answer}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|