diff --git a/apps/rebreak-native/app/devices.tsx b/apps/rebreak-native/app/devices.tsx index 44bbb48..99dc265 100644 --- a/apps/rebreak-native/app/devices.tsx +++ b/apps/rebreak-native/app/devices.tsx @@ -32,7 +32,7 @@ import { useUserDevicesRealtime } from '../hooks/useUserDevicesRealtime'; import { useUserPlan } from '../hooks/useUserPlan'; import { AppHeader } from '../components/AppHeader'; import { MagicSheet } from '../components/devices/MagicSheet'; -import { DeviceSlotDonut } from '../components/devices/DeviceSlotDonut'; +import { DeviceSlotDonut, MOBILE_COLOR, DESKTOP_COLOR } from '../components/devices/DeviceSlotDonut'; import { DeviceStatusPill } from '../components/devices/DeviceStatusPill'; import { DeviceDetailSheet, type DeviceDetail } from '../components/devices/DeviceDetailSheet'; import { deviceImage } from '../components/devices/deviceIcon'; @@ -567,18 +567,27 @@ export default function DevicesScreen() { }} showsVerticalScrollIndicator={false} > - {/* Slot-Ringe */} + {/* Slot-Ringe: Mobil · Gesamt (Verteilung) · Computer */} - + = mobileLimit} label={t('devices.progress_mobile')} /> = mobileLimit + desktopLimit} + label={t('devices.progress_total')} + /> + diff --git a/apps/rebreak-native/components/devices/DeviceSlotDonut.tsx b/apps/rebreak-native/components/devices/DeviceSlotDonut.tsx index 37cecda..6d549b9 100644 --- a/apps/rebreak-native/components/devices/DeviceSlotDonut.tsx +++ b/apps/rebreak-native/components/devices/DeviceSlotDonut.tsx @@ -1,32 +1,53 @@ import { useEffect, useRef, useState } from 'react'; import { Animated, Easing, Text, View } from 'react-native'; -import Svg, { Circle } from 'react-native-svg'; +import Svg, { Circle, Path } from 'react-native-svg'; import { useColors } from '../../lib/theme'; const SIZE = 88; const STROKE = 11; const R = (SIZE - STROKE) / 2; -const C = 2 * Math.PI * R; + +/** 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 (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). + * 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({ - count, - max, - atLimit, + segments, + total, label, + atLimit, }: { - count: number; - max: number; - atLimit: boolean; + segments: SlotSegment[]; + total: number; label: string; + atLimit: boolean; }) { const colors = useColors(); - const ratio = max > 0 ? Math.min(count / max, 1) : 0; - const fill = atLimit ? colors.brandOrange : colors.success; + 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); @@ -41,34 +62,46 @@ export function DeviceSlotDonut({ useNativeDriver: false, }).start(); return () => anim.removeListener(l); - }, [ratio, anim]); + }, [used, total, anim]); - const offset = C * (1 - ratio * progress); + // 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 ( - + {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 ( + + ); + })} - {count} + {used} - /{max} + /{total} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 3caadab..d6aad39 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -1409,8 +1409,9 @@ "release_cancel_body": "Das Gerät bleibt weiterhin an deinen Account gebunden.", "release_cancel_cta": "Ja, abbrechen", "subtitle_pro": "Schutz für dein Handy und deinen Computer — dort, wo du wirklich gefährdet bist.", - "progress_mobile": "Mobil (iOS / Android)", - "progress_desktop": "Computer (Mac / Windows)", + "progress_mobile": "Mobil", + "progress_total": "Gesamt", + "progress_desktop": "Computer", "detail_connection": "Verbindung", "detail_added": "Verbunden seit", "detail_last_seen": "Zuletzt aktiv", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 1ee03be..b2758bd 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -1409,8 +1409,9 @@ "release_cancel_body": "The device will remain bound to your account.", "release_cancel_cta": "Yes, cancel", "subtitle_pro": "Protection for your phone and your computer — where it really matters.", - "progress_mobile": "Mobile (iOS / Android)", - "progress_desktop": "Computer (Mac / Windows)", + "progress_mobile": "Mobile", + "progress_total": "Total", + "progress_desktop": "Computer", "detail_connection": "Connection", "detail_added": "Connected since", "detail_last_seen": "Last active",