- Slots: zwei animierte volle Progress-Circles (Mobil/Computer) statt Balken, via react-native-svg (keine neue Lib) - Status-Zeile pro Gerät: Online (grün) / Cooldown · noch Xh (amber, aus releaseRequestedAt) / Ungeschützt (rot) — ersetzt Footer + StatusBadge Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
109 lines
2.9 KiB
TypeScript
109 lines
2.9 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Animated, Easing, Text, View } from 'react-native';
|
|
import Svg, { Circle } from 'react-native-svg';
|
|
import { useColors } from '../../lib/theme';
|
|
|
|
const SIZE = 104;
|
|
const STROKE = 9;
|
|
const R = (SIZE - STROKE) / 2;
|
|
const C = 2 * Math.PI * R;
|
|
|
|
/**
|
|
* Voller Progress-Ring (kein Half-Donut) für belegte/freie Geräte-Slots.
|
|
* Mit react-native-svg (schon im Projekt) statt neuer Lib — animiert über
|
|
* strokeDashoffset. Zwei nebeneinander (Mobil + Computer).
|
|
*/
|
|
export function DeviceSlotDonut({
|
|
count,
|
|
max,
|
|
atLimit,
|
|
label,
|
|
}: {
|
|
count: number;
|
|
max: number;
|
|
atLimit: boolean;
|
|
label: string;
|
|
}) {
|
|
const colors = useColors();
|
|
const ratio = max > 0 ? Math.min(count / max, 1) : 0;
|
|
const fill = atLimit ? colors.brandOrange : colors.success;
|
|
|
|
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: 950,
|
|
easing: Easing.out(Easing.cubic),
|
|
useNativeDriver: false,
|
|
}).start();
|
|
return () => anim.removeListener(l);
|
|
}, [ratio, anim]);
|
|
|
|
const offset = C * (1 - ratio * progress);
|
|
|
|
return (
|
|
<View style={{ alignItems: 'center', flex: 1 }}>
|
|
<View style={{ width: SIZE, height: SIZE }}>
|
|
<Svg width={SIZE} height={SIZE}>
|
|
<Circle
|
|
cx={SIZE / 2}
|
|
cy={SIZE / 2}
|
|
r={R}
|
|
stroke={colors.surfaceElevated}
|
|
strokeWidth={STROKE}
|
|
fill="none"
|
|
/>
|
|
<Circle
|
|
cx={SIZE / 2}
|
|
cy={SIZE / 2}
|
|
r={R}
|
|
stroke={fill}
|
|
strokeWidth={STROKE}
|
|
fill="none"
|
|
strokeDasharray={`${C} ${C}`}
|
|
strokeDashoffset={offset}
|
|
strokeLinecap="round"
|
|
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
|
|
/>
|
|
</Svg>
|
|
<View
|
|
pointerEvents="none"
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: 26, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.5 }}>
|
|
{count}
|
|
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.textMuted }}>
|
|
/{max}
|
|
</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>
|
|
);
|
|
}
|