chahinebrini 77ce5e5a80 feat(native): dritter 'Gesamt'-Ring mit Mobil/Computer-Verteilung
DeviceSlotDonut auf Segment-API umgestellt (Mehr-Segment-Bögen). Gesamt-Ring
zeigt belegte Slots gesamt + farbliche Verteilung (Mobil grün / Computer blau).
Labels gekürzt (Mobil/Gesamt/Computer) für 3-across-Layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:25:40 +02:00

142 lines
4.1 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { Animated, Easing, Text, View } from 'react-native';
import Svg, { Circle, Path } from 'react-native-svg';
import { useColors } from '../../lib/theme';
const SIZE = 88;
const STROKE = 11;
const R = (SIZE - STROKE) / 2;
/** Geräte-Kategorie-Farben — auch im Gesamt-Ring (Verteilung) verwendet. */
export const MOBILE_COLOR = '#22c55e';
export const DESKTOP_COLOR = '#2563eb';
export type SlotSegment = { value: number; color: string };
function polar(cx: number, cy: number, r: number, deg: number) {
const rad = (deg * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
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 large = endDeg - startDeg > 180 ? 1 : 0;
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 1 ${end.x} ${end.y}`;
}
/**
* Voller Progress-Ring mit Mehr-Segment-Support (react-native-svg, keine Lib).
* - Einzel-Kategorie: ein Segment (Mobil/Computer).
* - Gesamt: zwei Segmente (Mobil-/Computer-Anteil farblich getrennt).
* Animiert über sweep der Bögen.
*/
export function DeviceSlotDonut({
segments,
total,
label,
atLimit,
}: {
segments: SlotSegment[];
total: number;
label: string;
atLimit: boolean;
}) {
const colors = useColors();
const cx = SIZE / 2;
const cy = SIZE / 2;
const r = R;
const safeTotal = Math.max(1, total);
const used = segments.reduce((s, x) => s + x.value, 0);
const anim = useRef(new Animated.Value(0)).current;
const [progress, setProgress] = useState(0);
useEffect(() => {
anim.setValue(0);
const l = anim.addListener(({ value }) => setProgress(value));
Animated.timing(anim, {
toValue: 1,
duration: 1400,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
return () => anim.removeListener(l);
}, [used, total, anim]);
// Konsekutive Bögen, Start oben (-90°).
let cum = -90;
const arcs = segments.map((seg) => {
const span = 360 * (seg.value / safeTotal);
const start = cum;
const end = cum + span;
cum = end;
return { start, end, color: seg.color };
});
return (
<View style={{ alignItems: 'center', flex: 1 }}>
<View style={{ width: SIZE, height: SIZE }}>
<Svg width={SIZE} height={SIZE}>
<Circle
cx={cx}
cy={cy}
r={r}
stroke={colors.surfaceElevated}
strokeWidth={STROKE}
fill="none"
/>
{arcs.map((a, i) => {
const animEnd = a.start + (a.end - a.start) * progress;
if (animEnd <= a.start + 0.5) return null;
// Voll-Kreis vermeiden (start==end wäre degeneriert).
const drawEnd = Math.min(animEnd, a.start + 359.99);
return (
<Path
key={i}
d={arcPath(cx, cy, r, a.start, drawEnd)}
stroke={a.color}
strokeWidth={STROKE}
fill="none"
strokeLinecap="round"
/>
);
})}
</Svg>
<View
pointerEvents="none"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 22, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.5 }}>
{used}
<Text style={{ fontSize: 12, fontFamily: 'Nunito_700Bold', color: colors.textMuted }}>
/{total}
</Text>
</Text>
</View>
</View>
<Text
numberOfLines={1}
style={{
fontSize: 11,
color: atLimit ? colors.brandOrange : colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
marginTop: 6,
textAlign: 'center',
}}
>
{label}
</Text>
</View>
);
}