Sheets via neuer KeyboardAwareSheet-Composable (in Modal pattern, auto-grow mit Tastatur, paddingBottom-Lift): EditMail, AddDomain, CreateRoom, ConnectMail. GameOverScreen behält Spring-Slide-In, nutzt RN Keyboard.addListener für Lift. - KeyboardAwareSheet.tsx — universal modal with sheet-grow + keyboard-padding - react-native-keyboard-controller installiert + KeyboardProvider in Root - Snake: time + ScoreProgressBar + useSnakeSounds (haptic, audio TODO) - Tetris: title weg, Buttons zentriert, kein Pressable mit style-fn - DPad-Buttons 60→48, more bg, no scale - useMe: pub-sub listener pattern für app-weite avatar/nickname-Updates - dm.tsx: resolveAvatar wrap (iron.png-Warning) - Mail-error-humanizer + locales Recovery-Doc-Update in docs/internal/RECOVERY_LOG_2026-05-10.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
160 lines
4.3 KiB
TypeScript
160 lines
4.3 KiB
TypeScript
import { useState } from 'react';
|
|
import { Pressable, Text, View } from 'react-native';
|
|
import Svg, { Rect, Text as SvgText } from 'react-native-svg';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useColors } from '../../lib/theme';
|
|
import type { DailyStat } from '../../hooks/useMailStatus';
|
|
|
|
type Props = {
|
|
dailyStats: DailyStat[];
|
|
totalBlocked: number;
|
|
};
|
|
|
|
const CHART_HEIGHT = 72;
|
|
const BAR_RADIUS = 4;
|
|
const LABEL_HEIGHT = 16;
|
|
const SVG_HEIGHT = CHART_HEIGHT + LABEL_HEIGHT;
|
|
|
|
export function MailWeeklyChart({ dailyStats, totalBlocked }: Props) {
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
const [activeIdx, setActiveIdx] = useState<number | null>(null);
|
|
|
|
const chartMax = Math.max(...dailyStats.map((d) => d.count), 1);
|
|
|
|
const weekTotal = dailyStats.reduce((s, d) => s + d.count, 0);
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
backgroundColor: colors.surface,
|
|
borderRadius: 16,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
paddingHorizontal: 16,
|
|
paddingTop: 14,
|
|
paddingBottom: 14,
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 13,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: colors.text,
|
|
}}
|
|
>
|
|
{t('mail.chart_title')}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
color: colors.error,
|
|
}}
|
|
>
|
|
{t('mail.chart_week_total', { count: weekTotal })}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Tooltip */}
|
|
{activeIdx !== null && dailyStats[activeIdx] !== undefined && (
|
|
<View
|
|
style={{
|
|
alignSelf: 'center',
|
|
backgroundColor: colors.surfaceElevated,
|
|
borderRadius: 8,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 4,
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: colors.text,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{dailyStats[activeIdx].label}: {dailyStats[activeIdx].count}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* SVG Bar Chart */}
|
|
<View style={{ width: '100%' }}>
|
|
<Svg width="100%" height={SVG_HEIGHT} viewBox={`0 0 ${7 * 40} ${SVG_HEIGHT}`} preserveAspectRatio="none">
|
|
{dailyStats.map((day, i) => {
|
|
const barH = day.count > 0
|
|
? Math.max(6, Math.round((day.count / chartMax) * CHART_HEIGHT))
|
|
: 4;
|
|
const x = i * 40 + 4;
|
|
const barW = 32;
|
|
const y = CHART_HEIGHT - barH;
|
|
const isActive = activeIdx === i;
|
|
const fill = day.count > 0
|
|
? isActive ? '#b91c1c' : '#ef4444'
|
|
: colors.border;
|
|
|
|
return (
|
|
<Rect
|
|
key={day.date}
|
|
x={x}
|
|
y={y}
|
|
width={barW}
|
|
height={barH}
|
|
rx={BAR_RADIUS}
|
|
ry={BAR_RADIUS}
|
|
fill={fill}
|
|
/>
|
|
);
|
|
})}
|
|
{dailyStats.map((day, i) => (
|
|
<SvgText
|
|
key={`label-${day.date}`}
|
|
x={i * 40 + 20}
|
|
y={SVG_HEIGHT - 2}
|
|
textAnchor="middle"
|
|
fontSize={10}
|
|
fill={colors.textMuted}
|
|
fontFamily="Nunito_400Regular"
|
|
>
|
|
{day.label}
|
|
</SvgText>
|
|
))}
|
|
</Svg>
|
|
|
|
{/* Invisible tap targets per bar */}
|
|
<View
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
flexDirection: 'row',
|
|
}}
|
|
>
|
|
{dailyStats.map((day, i) => (
|
|
<Pressable
|
|
key={`tap-${day.date}`}
|
|
style={{ flex: 1, height: '100%' }}
|
|
onPress={() => setActiveIdx((prev) => (prev === i ? null : i))}
|
|
accessibilityLabel={`${day.label}: ${day.count}`}
|
|
/>
|
|
))}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|