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:
parent
e0eb1711db
commit
e2e5a1003c
@ -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,18 +579,20 @@ export default function DevicesScreen() {
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
<DeviceProgressBar
|
||||
count={mobileCount}
|
||||
max={mobileLimit}
|
||||
atLimit={mobileCount >= mobileLimit}
|
||||
label={t('devices.progress_mobile')}
|
||||
/>
|
||||
<DeviceProgressBar
|
||||
count={desktopCount}
|
||||
max={desktopLimit}
|
||||
atLimit={atDesktopLimit}
|
||||
label={t('devices.progress_desktop')}
|
||||
/>
|
||||
<View style={{ flexDirection: 'row', gap: 12, marginTop: 4 }}>
|
||||
<DeviceSlotDonut
|
||||
count={mobileCount}
|
||||
max={mobileLimit}
|
||||
atLimit={mobileCount >= mobileLimit}
|
||||
label={t('devices.progress_mobile')}
|
||||
/>
|
||||
<DeviceSlotDonut
|
||||
count={desktopCount}
|
||||
max={desktopLimit}
|
||||
atLimit={atDesktopLimit}
|
||||
label={t('devices.progress_desktop')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Unified devices section: Mobile zuerst, dann Desktop */}
|
||||
|
||||
108
apps/rebreak-native/components/devices/DeviceSlotDonut.tsx
Normal file
108
apps/rebreak-native/components/devices/DeviceSlotDonut.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
apps/rebreak-native/components/devices/DeviceStatusPill.tsx
Normal file
53
apps/rebreak-native/components/devices/DeviceStatusPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 hab’s 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?",
|
||||
|
||||
@ -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 — it’s 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. That’s 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 — I’ll 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?",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user