chahinebrini a841b32c31 feat(rebreak-native): <FormSheet> — one reusable bottom-sheet composable (phase 1)
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>
2026-05-12 21:03:17 +02:00

588 lines
18 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 {
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>
);
}