feat(native): devices page — 2-line entries, single UIMenu CTA, dynamic counter, slimmer buttons

- MobileDeviceRow: collapse to 2 lines (name+badge / lastSeen · seit date)
- ProtectedDeviceRow: collapse to 2 lines (name+badge / seit date or degraded hint)
- Both rows now use alignItems:center for visual parity
- Replace dual Mac/Windows buttons with single UIMenu "+ neues Gerät hinzufügen"
- MenuView disabled (no-op TouchableOpacity) when at device limit
- Dynamic counter below subtitle: "X von 3 Geräten · noch Y frei" / "Maximum erreicht"
- paddingVertical 16→12 on all primary CTAs in devices.tsx, AddMacSheet, AddWindowsSheet
- New i18n keys: devices.add_device, devices.counter_some, devices.counter_limit (DE/EN/FR)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-15 23:10:09 +02:00
parent 8851f36f65
commit e8ea00568e
6 changed files with 122 additions and 136 deletions

View File

@ -132,11 +132,14 @@ function MobileDeviceRow({
); );
} }
const deviceName = device.model ?? device.name ?? device.platform;
const footerText = `${formatLastSeen(device.lastSeenAt, t)} · ${t('settings.devices_since')} ${formatSince(device.createdAt)}`;
return ( return (
<View <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-start', alignItems: 'center',
gap: 12, gap: 12,
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 14, paddingVertical: 14,
@ -166,7 +169,7 @@ function MobileDeviceRow({
flexShrink: 1, flexShrink: 1,
}} }}
> >
{device.name ?? device.model ?? device.platform} {deviceName}
</Text> </Text>
{device.isCurrent ? ( {device.isCurrent ? (
<View <View
@ -190,37 +193,17 @@ function MobileDeviceRow({
) : null} ) : null}
</View> </View>
{device.model && device.name && !device.name.includes(device.model) ? ( <Text
<Text numberOfLines={1}
style={{ style={{
fontSize: 11, fontSize: 11,
color: colors.textMuted, color: colors.textMuted,
fontFamily: 'Nunito_400Regular', fontFamily: 'Nunito_400Regular',
marginTop: 1, marginTop: 3,
}} }}
> >
{device.model} {footerText}
</Text> </Text>
) : null}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginTop: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<Ionicons name="time-outline" size={11} color={colors.textMuted} />
<Text
style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}
>
{formatLastSeen(device.lastSeenAt, t)}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<Ionicons name="link-outline" size={11} color={colors.textMuted} />
<Text
style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}
>
{t('settings.devices_since')} {formatSince(device.createdAt)}
</Text>
</View>
</View>
</View> </View>
{!device.isCurrent ? ( {!device.isCurrent ? (
@ -277,7 +260,7 @@ function ProtectedDeviceRow({
<View <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-start', alignItems: 'center',
gap: 12, gap: 12,
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 14, paddingVertical: 14,
@ -297,7 +280,7 @@ function ProtectedDeviceRow({
</View> </View>
<View style={{ flex: 1, minWidth: 0 }}> <View style={{ flex: 1, minWidth: 0 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text <Text
numberOfLines={1} numberOfLines={1}
style={{ style={{
@ -312,26 +295,19 @@ function ProtectedDeviceRow({
<StatusBadge status={device.status} /> <StatusBadge status={device.status} />
</View> </View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}> <Text
<Ionicons name="link-outline" size={11} color={colors.textMuted} /> numberOfLines={1}
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}> style={{
{t('settings.devices_since')} {formatSince(device.createdAt)} fontSize: 11,
</Text> color: device.status === 'degraded' ? colors.error : colors.textMuted,
</View> fontFamily: 'Nunito_400Regular',
marginTop: 3,
{device.status === 'degraded' && ( }}
<Text >
style={{ {device.status === 'degraded'
fontSize: 11, ? t('plan_limit.device_degraded_body')
color: colors.error, : `${t('settings.devices_since')} ${formatSince(device.createdAt)}`}
fontFamily: 'Nunito_400Regular', </Text>
marginTop: 4,
lineHeight: 15,
}}
>
{t('plan_limit.device_degraded_body')}
</Text>
)}
</View> </View>
<MenuView <MenuView
@ -426,12 +402,24 @@ export default function DevicesScreen() {
useProtectedDevicesRealtime(); useProtectedDevicesRealtime();
const MAX_PROTECTED_DEVICES = 2; const MAX_PROTECTED_DEVICES = 2;
const TOTAL_DEVICE_SLOTS = 3;
const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length; const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length;
const totalRegistered = 1 + activeProtectedCount;
const atDeviceLimit = isLegend && activeProtectedCount >= MAX_PROTECTED_DEVICES; const atDeviceLimit = isLegend && activeProtectedCount >= MAX_PROTECTED_DEVICES;
const currentDevice = mobileDevices.find((d) => d.isCurrent); const currentDevice = mobileDevices.find((d) => d.isCurrent);
const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_free'); const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_free');
const counterText = isLegend
? atDeviceLimit
? t('devices.counter_limit', { max: TOTAL_DEVICE_SLOTS })
: t('devices.counter_some', {
count: totalRegistered,
max: TOTAL_DEVICE_SLOTS,
remaining: TOTAL_DEVICE_SLOTS - totalRegistered,
})
: null;
async function handleRemoveProtected(id: string) { async function handleRemoveProtected(id: string) {
try { try {
const { manualRemovalRequired } = await removeProtected(id); const { manualRemovalRequired } = await removeProtected(id);
@ -457,18 +445,30 @@ export default function DevicesScreen() {
}} }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* Subtitle */} {/* Subtitle + counter */}
<Text <View style={{ gap: 4, marginBottom: -12 }}>
style={{ <Text
fontSize: 13, style={{
color: colors.textMuted, fontSize: 13,
fontFamily: 'Nunito_400Regular', color: colors.textMuted,
lineHeight: 18, fontFamily: 'Nunito_400Regular',
marginBottom: -12, lineHeight: 18,
}} }}
> >
{subtitle} {subtitle}
</Text> </Text>
{counterText ? (
<Text
style={{
fontSize: 12,
color: atDeviceLimit ? colors.brandOrange : colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
}}
>
{counterText}
</Text>
) : null}
</View>
{/* Section 1: Dieses Gerät */} {/* Section 1: Dieses Gerät */}
<View> <View>
@ -539,80 +539,57 @@ export default function DevicesScreen() {
{/* CTA or Upgrade */} {/* CTA or Upgrade */}
{isLegend ? ( {isLegend ? (
<View style={{ gap: 10 }}> atDeviceLimit ? (
{atDeviceLimit && (
<View
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: colors.border,
flexDirection: 'row',
gap: 8,
alignItems: 'flex-start',
}}
>
<Ionicons name="information-circle-outline" size={16} color={colors.textMuted} style={{ marginTop: 1 }} />
<Text style={{ flex: 1, fontSize: 13, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 18 }}>
{t('plan_limit.device_add_limit_hint', { max: MAX_PROTECTED_DEVICES })}
</Text>
</View>
)}
<TouchableOpacity <TouchableOpacity
onPress={() => { activeOpacity={1}
if (atDeviceLimit) { style={{
Alert.alert(t('plan_limit.device_add_limit_short'), t('plan_limit.device_add_limit_hint', { max: MAX_PROTECTED_DEVICES })); backgroundColor: colors.surfaceElevated,
return; borderRadius: 14,
} paddingVertical: 12,
setAddMacVisible(true); alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
opacity: 0.5,
}} }}
>
<Ionicons name="add-circle-outline" size={20} color={colors.textMuted} />
<Text style={{ fontSize: 16, color: colors.textMuted, fontFamily: 'Nunito_700Bold' }}>
{t('devices.add_device')}
</Text>
</TouchableOpacity>
) : (
<MenuView
title={t('devices.add_device')}
actions={[
{ id: 'mac', title: 'Mac' },
{ id: 'windows', title: 'Windows-PC' },
]}
onPressAction={({ nativeEvent: { event } }) => {
if (event === 'mac') setAddMacVisible(true);
else if (event === 'windows') setAddWindowsVisible(true);
}}
shouldOpenOnLongPress={false}
>
<TouchableOpacity
activeOpacity={0.7} activeOpacity={0.7}
style={{ style={{
backgroundColor: atDeviceLimit ? colors.surfaceElevated : colors.brandOrange, backgroundColor: colors.brandOrange,
borderRadius: 14, borderRadius: 14,
paddingVertical: 16, paddingVertical: 12,
alignItems: 'center', alignItems: 'center',
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
gap: 8, gap: 8,
}} }}
> >
<Ionicons name="add-circle-outline" size={20} color={atDeviceLimit ? colors.textMuted : '#fff'} /> <Ionicons name="add-circle-outline" size={20} color="#fff" />
<Text style={{ fontSize: 16, color: atDeviceLimit ? colors.textMuted : '#fff', fontFamily: 'Nunito_700Bold' }}> <Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
{t('devices.add_mac')} {t('devices.add_device')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</MenuView>
<TouchableOpacity )
onPress={() => {
if (atDeviceLimit) {
Alert.alert(t('plan_limit.device_add_limit_short'), t('plan_limit.device_add_limit_hint', { max: MAX_PROTECTED_DEVICES }));
return;
}
setAddWindowsVisible(true);
}}
activeOpacity={0.7}
style={{
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
}}
>
<Ionicons name="desktop-outline" size={18} color={colors.textMuted} />
<Text
style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}
>
{t('devices.add_windows_enabled')}
</Text>
</TouchableOpacity>
</View>
) : ( ) : (
<View <View
style={{ style={{
@ -637,7 +614,7 @@ export default function DevicesScreen() {
style={{ style={{
backgroundColor: colors.brandOrange, backgroundColor: colors.brandOrange,
borderRadius: 12, borderRadius: 12,
paddingVertical: 14, paddingVertical: 12,
alignItems: 'center', alignItems: 'center',
}} }}
> >

View File

@ -204,7 +204,7 @@ function Step1LabelContent({
style={{ style={{
backgroundColor: colors.brandOrange, backgroundColor: colors.brandOrange,
borderRadius: 14, borderRadius: 14,
paddingVertical: 16, paddingVertical: 12,
alignItems: 'center', alignItems: 'center',
opacity: enrolling ? 0.7 : 1, opacity: enrolling ? 0.7 : 1,
}} }}
@ -314,7 +314,7 @@ function Step2OnboardingContent({
style={{ style={{
backgroundColor: colors.brandOrange, backgroundColor: colors.brandOrange,
borderRadius: 14, borderRadius: 14,
paddingVertical: 16, paddingVertical: 12,
alignItems: 'center', alignItems: 'center',
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
@ -430,7 +430,7 @@ function Step3SuccessContent({
style={{ style={{
backgroundColor: colors.brandOrange, backgroundColor: colors.brandOrange,
borderRadius: 14, borderRadius: 14,
paddingVertical: 16, paddingVertical: 12,
paddingHorizontal: 40, paddingHorizontal: 40,
alignItems: 'center', alignItems: 'center',
alignSelf: 'stretch', alignSelf: 'stretch',

View File

@ -212,7 +212,7 @@ function WindowsStep1LabelContent({
style={{ style={{
backgroundColor: colors.brandOrange, backgroundColor: colors.brandOrange,
borderRadius: 14, borderRadius: 14,
paddingVertical: 16, paddingVertical: 12,
alignItems: 'center', alignItems: 'center',
opacity: enrolling ? 0.7 : 1, opacity: enrolling ? 0.7 : 1,
}} }}
@ -326,7 +326,7 @@ function WindowsStep2OnboardingContent({
style={{ style={{
backgroundColor: colors.brandOrange, backgroundColor: colors.brandOrange,
borderRadius: 14, borderRadius: 14,
paddingVertical: 16, paddingVertical: 12,
alignItems: 'center', alignItems: 'center',
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
@ -348,7 +348,7 @@ function WindowsStep2OnboardingContent({
borderWidth: 1.5, borderWidth: 1.5,
borderColor: colors.brandOrange, borderColor: colors.brandOrange,
borderRadius: 14, borderRadius: 14,
paddingVertical: 14, paddingVertical: 12,
alignItems: 'center', alignItems: 'center',
opacity: confirming ? 0.7 : 1, opacity: confirming ? 0.7 : 1,
}} }}
@ -434,7 +434,7 @@ function WindowsStep3SuccessContent({
style={{ style={{
backgroundColor: colors.brandOrange, backgroundColor: colors.brandOrange,
borderRadius: 14, borderRadius: 14,
paddingVertical: 16, paddingVertical: 12,
paddingHorizontal: 40, paddingHorizontal: 40,
alignItems: 'center', alignItems: 'center',
alignSelf: 'stretch', alignSelf: 'stretch',

View File

@ -889,6 +889,9 @@
"success_body": "Du kannst weitere Geräte hinzufügen wenn du willst.", "success_body": "Du kannst weitere Geräte hinzufügen wenn du willst.",
"remove_warning_title": "Profile manuell entfernen", "remove_warning_title": "Profile manuell entfernen",
"remove_warning_body": "Wir können das Profile nicht aus der Ferne löschen. Auf dem Mac: Systemeinstellungen → Profile → ReBreak → Entfernen (Admin-Passwort nötig).", "remove_warning_body": "Wir können das Profile nicht aus der Ferne löschen. Auf dem Mac: Systemeinstellungen → Profile → ReBreak → Entfernen (Admin-Passwort nötig).",
"add_device": "+ neues Gerät hinzufügen",
"counter_some": "{{count}} von {{max}} Geräten · noch {{remaining}} frei",
"counter_limit": "Maximum erreicht — {{max}} von {{max}} Geräten",
"add_windows_enabled": "Windows-PC hinzufügen", "add_windows_enabled": "Windows-PC hinzufügen",
"windows_label_question": "Wie soll der Windows-PC heißen?", "windows_label_question": "Wie soll der Windows-PC heißen?",
"windows_label_default": "Windows-PC", "windows_label_default": "Windows-PC",

View File

@ -889,6 +889,9 @@
"success_body": "You can add more devices whenever you like.", "success_body": "You can add more devices whenever you like.",
"remove_warning_title": "Remove profile manually", "remove_warning_title": "Remove profile manually",
"remove_warning_body": "We can't delete the profile remotely. On the Mac: System Settings → Profiles → ReBreak → Remove (admin password required).", "remove_warning_body": "We can't delete the profile remotely. On the Mac: System Settings → Profiles → ReBreak → Remove (admin password required).",
"add_device": "+ add new device",
"counter_some": "{{count}} of {{max}} devices · {{remaining}} more available",
"counter_limit": "Maximum reached — {{max}} of {{max}} devices",
"add_windows_enabled": "Add Windows PC", "add_windows_enabled": "Add Windows PC",
"windows_label_question": "What should this Windows PC be called?", "windows_label_question": "What should this Windows PC be called?",
"windows_label_default": "Windows PC", "windows_label_default": "Windows PC",

View File

@ -886,6 +886,9 @@
"success_body": "Vous pouvez ajouter d'autres appareils quand vous le souhaitez.", "success_body": "Vous pouvez ajouter d'autres appareils quand vous le souhaitez.",
"remove_warning_title": "Supprimer le profil manuellement", "remove_warning_title": "Supprimer le profil manuellement",
"remove_warning_body": "Nous ne pouvons pas supprimer le profil à distance. Sur le Mac : Réglages système → Profils → ReBreak → Supprimer (mot de passe administrateur requis).", "remove_warning_body": "Nous ne pouvons pas supprimer le profil à distance. Sur le Mac : Réglages système → Profils → ReBreak → Supprimer (mot de passe administrateur requis).",
"add_device": "+ ajouter un appareil",
"counter_some": "{{count}} sur {{max}} appareils · encore {{remaining}} disponible",
"counter_limit": "Maximum atteint — {{max}} sur {{max}} appareils",
"add_windows_enabled": "Ajouter un PC Windows", "add_windows_enabled": "Ajouter un PC Windows",
"windows_label_question": "Comment appeler ce PC Windows ?", "windows_label_question": "Comment appeler ce PC Windows ?",
"windows_label_default": "PC Windows", "windows_label_default": "PC Windows",