feat(native): Geräte-Slots als Progress-Ringe + Status-Pill in der Liste

- 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>
This commit is contained in:
chahinebrini 2026-06-08 00:17:05 +02:00
parent e0eb1711db
commit e2e5a1003c
5 changed files with 236 additions and 40 deletions

View File

@ -32,7 +32,8 @@ import { useUserDevicesRealtime } from '../hooks/useUserDevicesRealtime';
import { useUserPlan } from '../hooks/useUserPlan';
import { AppHeader } from '../components/AppHeader';
import { MagicSheet } from '../components/devices/MagicSheet';
import { DeviceProgressBar } from '../components/devices/DeviceProgressBar';
import { DeviceSlotDonut } from '../components/devices/DeviceSlotDonut';
import { DeviceStatusPill } from '../components/devices/DeviceStatusPill';
import { DeviceDetailSheet, type DeviceDetail } from '../components/devices/DeviceDetailSheet';
import { deviceImage } from '../components/devices/deviceIcon';
@ -270,19 +271,10 @@ function MobileDeviceRow({
) : null}
</View>
<Text
numberOfLines={1}
style={{
fontSize: 11,
color: releaseActive ? '#b45309' : colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 3,
}}
>
{releaseActive && releaseAt
? t('devices.release_countdown', { remaining: formatCountdown(releaseAt) })
: footerText}
</Text>
<DeviceStatusPill
kind={releaseActive ? 'cooldown' : 'online'}
durationText={releaseActive && releaseAt ? formatCountdown(releaseAt) : undefined}
/>
</View>
</TouchableOpacity>
@ -399,22 +391,17 @@ function ProtectedDeviceRow({
>
{device.label}
</Text>
<StatusBadge status={device.status} />
</View>
<Text
numberOfLines={1}
style={{
fontSize: 11,
color: device.status === 'degraded' ? colors.error : colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 3,
}}
>
{device.status === 'degraded'
? t('plan_limit.device_degraded_body')
: `${t('settings.devices_since')} ${formatSince(device.createdAt)}`}
</Text>
<DeviceStatusPill
kind={
device.status === 'active'
? 'online'
: device.status === 'pending'
? 'pending'
: 'unprotected'
}
/>
</View>
</TouchableOpacity>
@ -592,19 +579,21 @@ export default function DevicesScreen() {
>
{subtitle}
</Text>
<DeviceProgressBar
<View style={{ flexDirection: 'row', gap: 12, marginTop: 4 }}>
<DeviceSlotDonut
count={mobileCount}
max={mobileLimit}
atLimit={mobileCount >= mobileLimit}
label={t('devices.progress_mobile')}
/>
<DeviceProgressBar
<DeviceSlotDonut
count={desktopCount}
max={desktopLimit}
atLimit={atDesktopLimit}
label={t('devices.progress_desktop')}
/>
</View>
</View>
{/* Unified devices section: Mobile zuerst, dann Desktop */}
<View>

View File

@ -0,0 +1,108 @@
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>
);
}

View File

@ -0,0 +1,53 @@
import { Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
export type DeviceStatusKind = 'online' | 'cooldown' | 'unprotected' | 'pending';
/**
* Status-Zeile in der Geräte-Liste: farbiger Punkt + Text.
* - online geschützt/verbunden (grün)
* - cooldown Pause beantragt, läuft ab (amber, mit Restdauer)
* - unprotected Schutz aus (rot, mit Dauer)
* - pending wartet auf Aktivierung (amber)
*/
export function DeviceStatusPill({
kind,
durationText,
}: {
kind: DeviceStatusKind;
durationText?: string;
}) {
const { t } = useTranslation();
const colors = useColors();
const cfg: Record<DeviceStatusKind, { color: string; label: string }> = {
online: { color: colors.success, label: t('devices.status_online') },
cooldown: {
color: '#f59e0b',
label: durationText
? t('devices.status_cooldown', { time: durationText })
: t('devices.status_cooldown_short'),
},
unprotected: {
color: colors.error,
label: durationText
? t('devices.status_unprotected_since', { time: durationText })
: t('devices.status_unprotected'),
},
pending: { color: '#f59e0b', label: t('devices.status_pending') },
};
const c = cfg[kind];
return (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3 }}>
<View style={{ width: 7, height: 7, borderRadius: 4, backgroundColor: c.color }} />
<Text
numberOfLines={1}
style={{ fontSize: 12, color: c.color, fontFamily: 'Nunito_600SemiBold', flexShrink: 1 }}
>
{c.label}
</Text>
</View>
);
}

View File

@ -664,6 +664,24 @@
"title": "Wähle deinen Alias",
"body": "Das ist dein einziger Name in rebreak. Niemand sieht deine Mail oder deinen echten Namen.",
"finish": "Verstanden"
},
"protection_confirm": {
"checkbox": "Ich habs verstanden",
"cta": "Weiter",
"vpn_title": "VPN-Erlaubnis",
"vpn_body": "Gleich fragt Android nach VPN-Erlaubnis. Tipp im Dialog auf „Zulassen/OK“ — das ist kein echtes VPN, der Filter läuft lokal auf deinem Gerät.",
"deviceadmin_title": "Geräteschutz",
"deviceadmin_body": "Im nächsten Dialog auf „Aktivieren“ tippen. Damit ist der Schutz schon ab dem Neustart aktiv — es werden keine weiteren Rechte angefragt.",
"applock_title": "App-Sperre",
"applock_body": "Achtung im nächsten Dialog: Tipp den UNTEREN Button — nicht den blauen. Nur so wird die App-Sperre aktiv.",
"urlfilter_title": "Inhaltsfilter",
"urlfilter_body": "Gleich fragt iOS nach Erlaubnis für den Filter. Tipp auf „Erlauben“.",
"a11y_title": "Schutz-Wächter (Bedienungshilfen)",
"a11y_body": "Dieser Schritt ist etwas länger — ich führ dich durch. Über die Bedienungshilfen schützt ReBreak deine Einstellungen vor versehentlichem Abschalten.",
"a11y_step1": "Zuerst fragt Android nach „Über anderen Apps anzeigen“ — tippe Erlauben (brauchen wir, um dir den nächsten Schritt einzublenden).",
"a11y_step2": "Dann öffnet sich die Bedienungshilfen-Liste — such „ReBreak“.",
"a11y_step3": "Tippe ReBreak an und schalte den Schalter ein. Komm danach zurück zur App.",
"a11y_indicator": "Hier ReBreak antippen & einschalten"
}
},
"protection_onboarding": {
@ -1331,6 +1349,11 @@
"status_pending": "Bereit zum Installieren",
"status_active": "Aktiv",
"status_revoked": "Entfernt",
"status_online": "Online",
"status_cooldown": "Cooldown · noch %{time}",
"status_cooldown_short": "Cooldown",
"status_unprotected": "Ungeschützt",
"status_unprotected_since": "Ungeschützt · seit %{time}",
"label_placeholder": "z.B. MacBook Pro",
"label_default": "MacBook Pro",
"label_question": "Wie soll der Mac heißen?",

View File

@ -664,6 +664,24 @@
"title": "Pick your alias",
"body": "This is your only name on rebreak. No one sees your email or real name.",
"finish": "Got it"
},
"protection_confirm": {
"checkbox": "I understand",
"cta": "Continue",
"vpn_title": "VPN permission",
"vpn_body": "Android will now ask for VPN permission. Tap “Allow/OK” in the dialog — its not a real VPN, the filter runs locally on your device.",
"deviceadmin_title": "Device protection",
"deviceadmin_body": "Tap “Activate” in the next dialog. This keeps protection on right from restart — no further rights are requested.",
"applock_title": "App lock",
"applock_body": "Heads up in the next dialog: tap the BOTTOM button — not the blue one. Thats the only way the app lock turns on.",
"urlfilter_title": "Content filter",
"urlfilter_body": "iOS will now ask permission for the filter. Tap “Allow”.",
"a11y_title": "Protection guard (Accessibility)",
"a11y_body": "This step is a little longer — Ill guide you. Via Accessibility, ReBreak protects your settings from being switched off by accident.",
"a11y_step1": "First Android asks for “Display over other apps” — tap Allow (we need it to show you the next step on screen).",
"a11y_step2": "Then the Accessibility list opens — find “ReBreak”.",
"a11y_step3": "Tap ReBreak and turn the switch on. Then come back to the app.",
"a11y_indicator": "Tap ReBreak here & switch it on"
}
},
"protection_onboarding": {
@ -1331,6 +1349,11 @@
"status_pending": "Ready to install",
"status_active": "Active",
"status_revoked": "Removed",
"status_online": "Online",
"status_cooldown": "Cooldown · %{time} left",
"status_cooldown_short": "Cooldown",
"status_unprotected": "Unprotected",
"status_unprotected_since": "Unprotected · for %{time}",
"label_placeholder": "e.g. MacBook Pro",
"label_default": "MacBook Pro",
"label_question": "What should this Mac be called?",