fix(native): Gesamt-Verteilung als Half-Donut, 2 Circles + 1 Donut (gleiche Größe)

DeviceSlotDonut bekommt half-Modus. Reihenfolge: Mobil-Circle, Computer-Circle,
Gesamt-Half-Donut (Mobil/Computer-Anteil als zwei Bögen). Alle SIZE×SIZE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-08 00:30:08 +02:00
parent 77ce5e5a80
commit c3478f4743
2 changed files with 35 additions and 27 deletions

View File

@ -577,6 +577,13 @@ export default function DevicesScreen() {
label={t('devices.progress_mobile')} label={t('devices.progress_mobile')}
/> />
<DeviceSlotDonut <DeviceSlotDonut
segments={[{ value: desktopCount, color: DESKTOP_COLOR }]}
total={desktopLimit}
atLimit={atDesktopLimit}
label={t('devices.progress_desktop')}
/>
<DeviceSlotDonut
half
segments={[ segments={[
{ value: mobileCount, color: MOBILE_COLOR }, { value: mobileCount, color: MOBILE_COLOR },
{ value: desktopCount, color: DESKTOP_COLOR }, { value: desktopCount, color: DESKTOP_COLOR },
@ -585,12 +592,6 @@ export default function DevicesScreen() {
atLimit={mobileCount + desktopCount >= mobileLimit + desktopLimit} atLimit={mobileCount + desktopCount >= mobileLimit + desktopLimit}
label={t('devices.progress_total')} label={t('devices.progress_total')}
/> />
<DeviceSlotDonut
segments={[{ value: desktopCount, color: DESKTOP_COLOR }]}
total={desktopLimit}
atLimit={atDesktopLimit}
label={t('devices.progress_desktop')}
/>
</View> </View>
</View> </View>

View File

@ -7,7 +7,7 @@ const SIZE = 88;
const STROKE = 11; const STROKE = 11;
const R = (SIZE - STROKE) / 2; const R = (SIZE - STROKE) / 2;
/** Geräte-Kategorie-Farben — auch im Gesamt-Ring (Verteilung) verwendet. */ /** Geräte-Kategorie-Farben — auch im Gesamt-Half-Donut (Verteilung). */
export const MOBILE_COLOR = '#22c55e'; export const MOBILE_COLOR = '#22c55e';
export const DESKTOP_COLOR = '#2563eb'; export const DESKTOP_COLOR = '#2563eb';
@ -26,29 +26,35 @@ function arcPath(cx: number, cy: number, r: number, startDeg: number, endDeg: nu
} }
/** /**
* Voller Progress-Ring mit Mehr-Segment-Support (react-native-svg, keine Lib). * Slot-Anzeige (react-native-svg, keine Lib), gleiche Größe in beiden Modi:
* - Einzel-Kategorie: ein Segment (Mobil/Computer). * - `half=false` (Default): voller Progress-Ring eine Kategorie (Mobil/Computer).
* - Gesamt: zwei Segmente (Mobil-/Computer-Anteil farblich getrennt). * - `half=true`: Half-Donut für die Gesamt-Verteilung (Mobil-/Computer-Anteil
* Animiert über sweep der Bögen. * als zwei farbige Bögen).
*/ */
export function DeviceSlotDonut({ export function DeviceSlotDonut({
segments, segments,
total, total,
label, label,
atLimit, atLimit,
half = false,
}: { }: {
segments: SlotSegment[]; segments: SlotSegment[];
total: number; total: number;
label: string; label: string;
atLimit: boolean; atLimit: boolean;
half?: boolean;
}) { }) {
const colors = useColors(); const colors = useColors();
const cx = SIZE / 2; const cx = SIZE / 2;
const cy = SIZE / 2; // Half-Donut tiefer setzen, damit der Bogen mittig im SIZE×SIZE-Feld sitzt.
const cy = half ? SIZE / 2 + R / 2 : SIZE / 2;
const r = R; const r = R;
const safeTotal = Math.max(1, total); const safeTotal = Math.max(1, total);
const used = segments.reduce((s, x) => s + x.value, 0); const used = segments.reduce((s, x) => s + x.value, 0);
const startAngle = half ? 180 : -90;
const sweep = half ? 180 : 360;
const anim = useRef(new Animated.Value(0)).current; const anim = useRef(new Animated.Value(0)).current;
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
@ -64,10 +70,9 @@ export function DeviceSlotDonut({
return () => anim.removeListener(l); return () => anim.removeListener(l);
}, [used, total, anim]); }, [used, total, anim]);
// Konsekutive Bögen, Start oben (-90°). let cum = startAngle;
let cum = -90;
const arcs = segments.map((seg) => { const arcs = segments.map((seg) => {
const span = 360 * (seg.value / safeTotal); const span = sweep * (seg.value / safeTotal);
const start = cum; const start = cum;
const end = cum + span; const end = cum + span;
cum = end; cum = end;
@ -78,19 +83,21 @@ export function DeviceSlotDonut({
<View style={{ alignItems: 'center', flex: 1 }}> <View style={{ alignItems: 'center', flex: 1 }}>
<View style={{ width: SIZE, height: SIZE }}> <View style={{ width: SIZE, height: SIZE }}>
<Svg width={SIZE} height={SIZE}> <Svg width={SIZE} height={SIZE}>
<Circle {half ? (
cx={cx} <Path
cy={cy} d={arcPath(cx, cy, r, 180, 360)}
r={r} stroke={colors.surfaceElevated}
stroke={colors.surfaceElevated} strokeWidth={STROKE}
strokeWidth={STROKE} fill="none"
fill="none" strokeLinecap="round"
/> />
) : (
<Circle cx={cx} cy={cy} r={r} stroke={colors.surfaceElevated} strokeWidth={STROKE} fill="none" />
)}
{arcs.map((a, i) => { {arcs.map((a, i) => {
const animEnd = a.start + (a.end - a.start) * progress; const animEnd = a.start + (a.end - a.start) * progress;
if (animEnd <= a.start + 0.5) return null; if (animEnd <= a.start + 0.5) return null;
// Voll-Kreis vermeiden (start==end wäre degeneriert). const drawEnd = Math.min(animEnd, a.start + (half ? 179.99 : 359.99));
const drawEnd = Math.min(animEnd, a.start + 359.99);
return ( return (
<Path <Path
key={i} key={i}
@ -107,12 +114,12 @@ export function DeviceSlotDonut({
pointerEvents="none" pointerEvents="none"
style={{ style={{
position: 'absolute', position: 'absolute',
top: 0, top: half ? SIZE / 2 - 4 : 0,
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: half ? 'flex-start' : 'center',
}} }}
> >
<Text style={{ fontSize: 22, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.5 }}> <Text style={{ fontSize: 22, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.5 }}>