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>
This commit is contained in:
parent
227c30c3c9
commit
77ce5e5a80
@ -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 */}
|
||||
<View style={{ marginBottom: -4 }}>
|
||||
<View style={{ flexDirection: 'row', gap: 12, marginTop: 4 }}>
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 4 }}>
|
||||
<DeviceSlotDonut
|
||||
count={mobileCount}
|
||||
max={mobileLimit}
|
||||
segments={[{ value: mobileCount, color: MOBILE_COLOR }]}
|
||||
total={mobileLimit}
|
||||
atLimit={mobileCount >= mobileLimit}
|
||||
label={t('devices.progress_mobile')}
|
||||
/>
|
||||
<DeviceSlotDonut
|
||||
count={desktopCount}
|
||||
max={desktopLimit}
|
||||
segments={[
|
||||
{ value: mobileCount, color: MOBILE_COLOR },
|
||||
{ value: desktopCount, color: DESKTOP_COLOR },
|
||||
]}
|
||||
total={mobileLimit + desktopLimit}
|
||||
atLimit={mobileCount + desktopCount >= mobileLimit + desktopLimit}
|
||||
label={t('devices.progress_total')}
|
||||
/>
|
||||
<DeviceSlotDonut
|
||||
segments={[{ value: desktopCount, color: DESKTOP_COLOR }]}
|
||||
total={desktopLimit}
|
||||
atLimit={atDesktopLimit}
|
||||
label={t('devices.progress_desktop')}
|
||||
/>
|
||||
|
||||
@ -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 (
|
||||
<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}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
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})`}
|
||||
/>
|
||||
{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"
|
||||
@ -83,9 +116,9 @@ export function DeviceSlotDonut({
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 22, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.5 }}>
|
||||
{count}
|
||||
{used}
|
||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_700Bold', color: colors.textMuted }}>
|
||||
/{max}
|
||||
/{total}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user