refactor(native): central Button component + sweep across devices/plan flows

Replaces ad-hoc TouchableOpacity+styled-Text pairs with a single
`<Button>` covering the four variants we actually use (primary,
secondary, ghost, destructive), with size (sm/md/lg), loading,
disabled, icon, iconPosition, and a style escape hatch.

Migrated files: AddMacSheet, AddWindowsSheet, PlanChangeSheet,
devices.tsx CTA, settings SubscriptionSheet CTA.

Skipped (kept as-is to avoid hostile overrides): auth flow buttons
(Google/Apple OAuth with custom SVGs), list-row Touchables, blocker
& mail components (separate sweep when those screens come up).

paddingVertical default 12 (md) — matches the slimmer-buttons direction
we landed on in the devices-page redesign.
This commit is contained in:
chahinebrini 2026-05-15 23:31:26 +02:00
parent e8ea00568e
commit e4ac3ae51c
6 changed files with 170 additions and 298 deletions

View File

@ -7,6 +7,7 @@ import {
TouchableOpacity,
View,
} from 'react-native';
import { Button } from '../components/Button';
import { useEffect, useState } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
@ -540,24 +541,12 @@ export default function DevicesScreen() {
{/* CTA or Upgrade */}
{isLegend ? (
atDeviceLimit ? (
<TouchableOpacity
activeOpacity={1}
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 14,
paddingVertical: 12,
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>
<Button
title={t('devices.add_device')}
icon="add-circle-outline"
disabled
style={{ backgroundColor: colors.surfaceElevated }}
/>
) : (
<MenuView
title={t('devices.add_device')}
@ -571,23 +560,10 @@ export default function DevicesScreen() {
}}
shouldOpenOnLongPress={false}
>
<TouchableOpacity
activeOpacity={0.7}
style={{
backgroundColor: colors.brandOrange,
borderRadius: 14,
paddingVertical: 12,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
}}
>
<Ionicons name="add-circle-outline" size={20} color="#fff" />
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
{t('devices.add_device')}
</Text>
</TouchableOpacity>
<Button
title={t('devices.add_device')}
icon="add-circle-outline"
/>
</MenuView>
)
) : (
@ -609,19 +585,7 @@ export default function DevicesScreen() {
{t('devices.subtitle_legend')}
</Text>
</View>
<TouchableOpacity
activeOpacity={0.7}
style={{
backgroundColor: colors.brandOrange,
borderRadius: 12,
paddingVertical: 12,
alignItems: 'center',
}}
>
<Text style={{ fontSize: 15, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
{t('devices.upgrade_cta')}
</Text>
</TouchableOpacity>
<Button title={t('devices.upgrade_cta')} />
</View>
)}

View File

@ -18,6 +18,7 @@ import { TrueSheet } from '@lodev09/react-native-true-sheet';
import { useTranslation } from 'react-i18next';
import { LanguageIcon } from '../components/icons/LanguageIcon';
import { useColors } from '../lib/theme';
import { Button } from '../components/Button';
import { useAuthStore } from '../stores/auth';
import { useAppLockStore } from '../stores/appLock';
import { useThemeStore, type ThemeMode } from '../stores/theme';
@ -105,32 +106,16 @@ function SubscriptionSheet({ plan, colors, t }: SubscriptionSheetProps) {
{t('settings.subscription_sheet_body')}
</Text>
<TouchableOpacity
onPress={() => {
// TODO: für iOS-Submission ggf. zu nicht-tippbarem Text degradieren
// (Apple Guideline 3.1.1: externe Abo-Links können Review-Ablehnung triggern,
// wenn sie als Kauf-Umgehung gewertet werden. Standalone-URL ohne Preis-Info
// sollte ok sein, ist aber ungeprüft — bei Submission erneut prüfen.)
Linking.openURL('https://rebreak.org/account');
}}
activeOpacity={0.8}
style={{
backgroundColor: accentColor,
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
}}
>
<Text
style={{
fontSize: 16,
color: '#ffffff',
fontFamily: 'Nunito_700Bold',
}}
>
{t('settings.subscription_sheet_cta')}
</Text>
</TouchableOpacity>
{/* TODO: für iOS-Submission ggf. zu nicht-tippbarem Text degradieren
(Apple Guideline 3.1.1: externe Abo-Links können Review-Ablehnung triggern,
wenn sie als Kauf-Umgehung gewertet werden. Standalone-URL ohne Preis-Info
sollte ok sein, ist aber ungeprüft bei Submission erneut prüfen.) */}
<Button
title={t('settings.subscription_sheet_cta')}
onPress={() => Linking.openURL('https://rebreak.org/account')}
size="lg"
style={{ backgroundColor: accentColor }}
/>
</View>
);
}

View File

@ -1,57 +1,110 @@
import { ActivityIndicator, Pressable, Text } from 'react-native';
import type { PressableProps } from 'react-native';
import {
ActivityIndicator,
Text,
TouchableOpacity,
View,
type ViewStyle,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useColors } from '../lib/theme';
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
type Variant = 'primary' | 'secondary' | 'ghost' | 'destructive';
type Size = 'sm' | 'md' | 'lg';
type Props = PressableProps & {
children: React.ReactNode;
type Props = {
title: string;
onPress?: () => void;
variant?: Variant;
size?: Size;
loading?: boolean;
disabled?: boolean;
className?: string;
icon?: React.ComponentProps<typeof Ionicons>['name'];
iconPosition?: 'left' | 'right';
style?: ViewStyle;
};
const variantStyles: Record<Variant, { container: string; text: string }> = {
primary: {
container: 'bg-rebreak-500 rounded-xl px-5 py-3.5 items-center justify-center',
text: 'text-white text-base',
},
secondary: {
container: 'bg-neutral-100 border border-neutral-200 rounded-xl px-5 py-3.5 items-center justify-center',
text: 'text-neutral-800 text-base',
},
ghost: {
container: 'rounded-xl px-5 py-3.5 items-center justify-center',
text: 'text-neutral-600 text-base',
},
danger: {
container: 'bg-red-50 border border-red-200 rounded-xl px-5 py-3.5 items-center justify-center',
text: 'text-red-600 text-base',
},
};
const PADDING: Record<Size, number> = { sm: 8, md: 12, lg: 16 };
const FONT_SIZE: Record<Size, number> = { sm: 14, md: 15, lg: 16 };
export function Button({
children,
title,
onPress,
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
className = '',
...rest
icon,
iconPosition = 'left',
style,
}: Props) {
const styles = variantStyles[variant];
const colors = useColors();
const isDisabled = disabled || loading;
const paddingVertical = PADDING[size];
const fontSize = FONT_SIZE[size];
const containerBase: ViewStyle = {
borderRadius: 14,
paddingVertical,
paddingHorizontal: 16,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 8,
opacity: isDisabled ? 0.5 : 1,
};
let containerVariant: ViewStyle;
let textColor: string;
let indicatorColor: string;
switch (variant) {
case 'secondary':
containerVariant = {
borderWidth: 1.5,
borderColor: colors.brandOrange,
};
textColor = colors.brandOrange;
indicatorColor = colors.brandOrange;
break;
case 'ghost':
containerVariant = {};
textColor = colors.brandOrange;
indicatorColor = colors.brandOrange;
break;
case 'destructive':
containerVariant = { backgroundColor: colors.error };
textColor = '#ffffff';
indicatorColor = '#ffffff';
break;
default:
containerVariant = { backgroundColor: colors.brandOrange };
textColor = '#ffffff';
indicatorColor = '#ffffff';
break;
}
const iconEl = icon ? (
<Ionicons name={icon} size={fontSize + 2} color={loading ? 'transparent' : textColor} />
) : null;
return (
<Pressable
className={`${styles.container} ${isDisabled ? 'opacity-50' : ''} ${className}`}
<TouchableOpacity
onPress={onPress}
disabled={isDisabled}
{...rest}
activeOpacity={0.7}
style={[containerBase, containerVariant, style]}
>
{loading ? (
<ActivityIndicator color={variant === 'primary' ? '#fff' : '#f59e0b'} size="small" />
<ActivityIndicator color={indicatorColor} size="small" />
) : (
<Text className={styles.text} style={{ fontFamily: 'Nunito_600SemiBold' }}>{children}</Text>
<>
{iconEl && iconPosition === 'left' ? iconEl : null}
<Text style={{ fontSize, color: textColor, fontFamily: 'Nunito_600SemiBold' }}>
{title}
</Text>
{iconEl && iconPosition === 'right' ? iconEl : null}
</>
)}
</Pressable>
</TouchableOpacity>
);
}

View File

@ -3,7 +3,6 @@ import {
Alert,
Linking,
ScrollView,
TouchableOpacity,
Text,
TextInput,
View,
@ -15,6 +14,7 @@ import * as Haptics from 'expo-haptics';
import { useColors } from '../../lib/theme';
import { FormSheet } from '../FormSheet';
import { RiveAvatar } from '../RiveAvatar';
import { Button } from '../Button';
import { useProtectedDevicesStore } from '../../stores/protectedDevices';
import { useProtectedDevicesRealtime } from '../../hooks/useProtectedDevicesRealtime';
import { useRouter } from 'expo-router';
@ -197,26 +197,12 @@ function Step1LabelContent({
</Text>
) : null}
<TouchableOpacity
<Button
title={t('devices.prepare_profile')}
onPress={onPrepare}
loading={enrolling}
disabled={enrolling}
activeOpacity={0.7}
style={{
backgroundColor: colors.brandOrange,
borderRadius: 14,
paddingVertical: 12,
alignItems: 'center',
opacity: enrolling ? 0.7 : 1,
}}
>
{enrolling ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
{t('devices.prepare_profile')}
</Text>
)}
</TouchableOpacity>
/>
</View>
);
}
@ -308,24 +294,11 @@ function Step2OnboardingContent({
</View>
{/* Download button */}
<TouchableOpacity
<Button
title={t('devices.download_button')}
onPress={onDownload}
activeOpacity={0.7}
style={{
backgroundColor: colors.brandOrange,
borderRadius: 14,
paddingVertical: 12,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
}}
>
<Ionicons name="download-outline" size={18} color="#fff" />
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
{t('devices.download_button')}
</Text>
</TouchableOpacity>
icon="download-outline"
/>
{/* Pending auto-detect pill */}
<View
@ -359,22 +332,13 @@ function Step2OnboardingContent({
</View>
{/* Need help */}
<TouchableOpacity
<Button
title={t('devices.need_help')}
onPress={onNeedHelp}
activeOpacity={0.5}
style={{ alignItems: 'center' }}
>
<Text
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textDecorationLine: 'underline',
}}
>
{t('devices.need_help')}
</Text>
</TouchableOpacity>
variant="ghost"
size="sm"
style={{ alignSelf: 'center' }}
/>
</ScrollView>
);
}
@ -424,22 +388,11 @@ function Step3SuccessContent({
</Text>
</View>
<TouchableOpacity
<Button
title={t('common.ok')}
onPress={onClose}
activeOpacity={0.7}
style={{
backgroundColor: colors.brandOrange,
borderRadius: 14,
paddingVertical: 12,
paddingHorizontal: 40,
alignItems: 'center',
alignSelf: 'stretch',
}}
>
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
{t('common.ok')}
</Text>
</TouchableOpacity>
style={{ alignSelf: 'stretch' }}
/>
</View>
);
}

View File

@ -1,9 +1,7 @@
import {
ActivityIndicator,
Alert,
Linking,
ScrollView,
TouchableOpacity,
Text,
TextInput,
View,
@ -15,6 +13,7 @@ import * as Haptics from 'expo-haptics';
import { useColors } from '../../lib/theme';
import { FormSheet } from '../FormSheet';
import { RiveAvatar } from '../RiveAvatar';
import { Button } from '../Button';
import { useProtectedDevicesStore } from '../../stores/protectedDevices';
import { useRouter } from 'expo-router';
@ -205,26 +204,12 @@ function WindowsStep1LabelContent({
</Text>
) : null}
<TouchableOpacity
<Button
title={t('devices.prepare_profile')}
onPress={onPrepare}
loading={enrolling}
disabled={enrolling}
activeOpacity={0.7}
style={{
backgroundColor: colors.brandOrange,
borderRadius: 14,
paddingVertical: 12,
alignItems: 'center',
opacity: enrolling ? 0.7 : 1,
}}
>
{enrolling ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
{t('devices.prepare_profile')}
</Text>
)}
</TouchableOpacity>
/>
</View>
);
}
@ -320,65 +305,29 @@ function WindowsStep2OnboardingContent({
</View>
{/* Download button */}
<TouchableOpacity
<Button
title={t('devices.windows_download_button')}
onPress={onDownload}
activeOpacity={0.7}
style={{
backgroundColor: colors.brandOrange,
borderRadius: 14,
paddingVertical: 12,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
}}
>
<Ionicons name="download-outline" size={18} color="#fff" />
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
{t('devices.windows_download_button')}
</Text>
</TouchableOpacity>
icon="download-outline"
/>
{/* Confirm installed */}
<TouchableOpacity
<Button
title={t('devices.confirm_installed')}
onPress={onConfirmInstalled}
variant="secondary"
loading={confirming}
disabled={confirming}
activeOpacity={0.7}
style={{
borderWidth: 1.5,
borderColor: colors.brandOrange,
borderRadius: 14,
paddingVertical: 12,
alignItems: 'center',
opacity: confirming ? 0.7 : 1,
}}
>
{confirming ? (
<ActivityIndicator color={colors.brandOrange} />
) : (
<Text style={{ fontSize: 15, color: colors.brandOrange, fontFamily: 'Nunito_600SemiBold' }}>
{t('devices.confirm_installed')}
</Text>
)}
</TouchableOpacity>
/>
{/* Need help */}
<TouchableOpacity
<Button
title={t('devices.need_help')}
onPress={onNeedHelp}
activeOpacity={0.5}
style={{ alignItems: 'center' }}
>
<Text
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textDecorationLine: 'underline',
}}
>
{t('devices.need_help')}
</Text>
</TouchableOpacity>
variant="ghost"
size="sm"
style={{ alignSelf: 'center' }}
/>
</ScrollView>
);
}
@ -428,22 +377,11 @@ function WindowsStep3SuccessContent({
</Text>
</View>
<TouchableOpacity
<Button
title={t('common.ok')}
onPress={onClose}
activeOpacity={0.7}
style={{
backgroundColor: colors.brandOrange,
borderRadius: 14,
paddingVertical: 12,
paddingHorizontal: 40,
alignItems: 'center',
alignSelf: 'stretch',
}}
>
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
{t('common.ok')}
</Text>
</TouchableOpacity>
style={{ alignSelf: 'stretch' }}
/>
</View>
);
}

View File

@ -5,7 +5,6 @@ import {
Modal,
ScrollView,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@ -13,6 +12,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../../lib/api';
import { useColors } from '../../lib/theme';
import { Button } from '../Button';
import type { Plan } from '../../hooks/useMe';
export type ChangePreviewItem = {
@ -138,15 +138,11 @@ export function PlanChangeSheet({ visible, targetPlan, onConfirm, onClose }: Pro
<Text style={{ fontSize: 14, color: colors.error, fontFamily: 'Nunito_400Regular', textAlign: 'center' }}>
{error}
</Text>
<TouchableOpacity
activeOpacity={0.7}
<Button
title={t('common.back')}
onPress={onClose}
style={{ paddingVertical: 12, paddingHorizontal: 24, backgroundColor: colors.surfaceElevated, borderRadius: 12 }}
>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
{t('common.back')}
</Text>
</TouchableOpacity>
variant="ghost"
/>
</View>
) : preview ? (
<SheetContent
@ -337,43 +333,26 @@ function SheetContent({ preview, targetPlan, isDowngrade, colors, onConfirm, onC
{/* CTAs */}
<View style={{ gap: 10, marginTop: 4 }}>
<TouchableOpacity
activeOpacity={0.7}
<Button
title={isDowngrade ? t('plan.change.cta_confirm_downgrade') : t('plan.change.cta_confirm_upgrade')}
onPress={onConfirm}
style={{
backgroundColor: '#007AFF',
borderRadius: 14,
paddingVertical: 16,
alignItems: 'center',
}}
>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#ffffff' }}>
{isDowngrade ? t('plan.change.cta_confirm_downgrade') : t('plan.change.cta_confirm_upgrade')}
</Text>
</TouchableOpacity>
size="lg"
/>
{isDowngrade && (
<TouchableOpacity
activeOpacity={0.7}
<Button
title={t('plan.change.cta_stay', { plan: PLAN_LABEL[preview.from] })}
onPress={onClose}
style={{ paddingVertical: 12, alignItems: 'center' }}
>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('plan.change.cta_stay', { plan: PLAN_LABEL[preview.from] })}
</Text>
</TouchableOpacity>
variant="ghost"
/>
)}
{!isDowngrade && (
<TouchableOpacity
activeOpacity={0.7}
<Button
title={t('common.cancel')}
onPress={onClose}
style={{ paddingVertical: 12, alignItems: 'center' }}
>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('common.cancel')}
</Text>
</TouchableOpacity>
variant="ghost"
/>
)}
</View>
</>