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

View File

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

View File

@ -1,57 +1,110 @@
import { ActivityIndicator, Pressable, Text } from 'react-native'; import {
import type { PressableProps } from 'react-native'; 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 & { type Props = {
children: React.ReactNode; title: string;
onPress?: () => void;
variant?: Variant; variant?: Variant;
size?: Size;
loading?: boolean; loading?: boolean;
disabled?: boolean; disabled?: boolean;
className?: string; icon?: React.ComponentProps<typeof Ionicons>['name'];
iconPosition?: 'left' | 'right';
style?: ViewStyle;
}; };
const variantStyles: Record<Variant, { container: string; text: string }> = { const PADDING: Record<Size, number> = { sm: 8, md: 12, lg: 16 };
primary: { const FONT_SIZE: Record<Size, number> = { sm: 14, md: 15, lg: 16 };
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',
},
};
export function Button({ export function Button({
children, title,
onPress,
variant = 'primary', variant = 'primary',
size = 'md',
loading = false, loading = false,
disabled = false, disabled = false,
className = '', icon,
...rest iconPosition = 'left',
style,
}: Props) { }: Props) {
const styles = variantStyles[variant]; const colors = useColors();
const isDisabled = disabled || loading; 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 ( return (
<Pressable <TouchableOpacity
className={`${styles.container} ${isDisabled ? 'opacity-50' : ''} ${className}`} onPress={onPress}
disabled={isDisabled} disabled={isDisabled}
{...rest} activeOpacity={0.7}
style={[containerBase, containerVariant, style]}
> >
{loading ? ( {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, Alert,
Linking, Linking,
ScrollView, ScrollView,
TouchableOpacity,
Text, Text,
TextInput, TextInput,
View, View,
@ -15,6 +14,7 @@ import * as Haptics from 'expo-haptics';
import { useColors } from '../../lib/theme'; import { useColors } from '../../lib/theme';
import { FormSheet } from '../FormSheet'; import { FormSheet } from '../FormSheet';
import { RiveAvatar } from '../RiveAvatar'; import { RiveAvatar } from '../RiveAvatar';
import { Button } from '../Button';
import { useProtectedDevicesStore } from '../../stores/protectedDevices'; import { useProtectedDevicesStore } from '../../stores/protectedDevices';
import { useProtectedDevicesRealtime } from '../../hooks/useProtectedDevicesRealtime'; import { useProtectedDevicesRealtime } from '../../hooks/useProtectedDevicesRealtime';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
@ -197,26 +197,12 @@ function Step1LabelContent({
</Text> </Text>
) : null} ) : null}
<TouchableOpacity <Button
title={t('devices.prepare_profile')}
onPress={onPrepare} onPress={onPrepare}
loading={enrolling}
disabled={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> </View>
); );
} }
@ -308,24 +294,11 @@ function Step2OnboardingContent({
</View> </View>
{/* Download button */} {/* Download button */}
<TouchableOpacity <Button
title={t('devices.download_button')}
onPress={onDownload} onPress={onDownload}
activeOpacity={0.7} icon="download-outline"
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>
{/* Pending auto-detect pill */} {/* Pending auto-detect pill */}
<View <View
@ -359,22 +332,13 @@ function Step2OnboardingContent({
</View> </View>
{/* Need help */} {/* Need help */}
<TouchableOpacity <Button
title={t('devices.need_help')}
onPress={onNeedHelp} onPress={onNeedHelp}
activeOpacity={0.5} variant="ghost"
style={{ alignItems: 'center' }} size="sm"
> style={{ alignSelf: 'center' }}
<Text />
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textDecorationLine: 'underline',
}}
>
{t('devices.need_help')}
</Text>
</TouchableOpacity>
</ScrollView> </ScrollView>
); );
} }
@ -424,22 +388,11 @@ function Step3SuccessContent({
</Text> </Text>
</View> </View>
<TouchableOpacity <Button
title={t('common.ok')}
onPress={onClose} onPress={onClose}
activeOpacity={0.7} style={{ alignSelf: 'stretch' }}
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>
</View> </View>
); );
} }

View File

@ -1,9 +1,7 @@
import { import {
ActivityIndicator,
Alert, Alert,
Linking, Linking,
ScrollView, ScrollView,
TouchableOpacity,
Text, Text,
TextInput, TextInput,
View, View,
@ -15,6 +13,7 @@ import * as Haptics from 'expo-haptics';
import { useColors } from '../../lib/theme'; import { useColors } from '../../lib/theme';
import { FormSheet } from '../FormSheet'; import { FormSheet } from '../FormSheet';
import { RiveAvatar } from '../RiveAvatar'; import { RiveAvatar } from '../RiveAvatar';
import { Button } from '../Button';
import { useProtectedDevicesStore } from '../../stores/protectedDevices'; import { useProtectedDevicesStore } from '../../stores/protectedDevices';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
@ -205,26 +204,12 @@ function WindowsStep1LabelContent({
</Text> </Text>
) : null} ) : null}
<TouchableOpacity <Button
title={t('devices.prepare_profile')}
onPress={onPrepare} onPress={onPrepare}
loading={enrolling}
disabled={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> </View>
); );
} }
@ -320,65 +305,29 @@ function WindowsStep2OnboardingContent({
</View> </View>
{/* Download button */} {/* Download button */}
<TouchableOpacity <Button
title={t('devices.windows_download_button')}
onPress={onDownload} onPress={onDownload}
activeOpacity={0.7} icon="download-outline"
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>
{/* Confirm installed */} {/* Confirm installed */}
<TouchableOpacity <Button
title={t('devices.confirm_installed')}
onPress={onConfirmInstalled} onPress={onConfirmInstalled}
variant="secondary"
loading={confirming}
disabled={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 */} {/* Need help */}
<TouchableOpacity <Button
title={t('devices.need_help')}
onPress={onNeedHelp} onPress={onNeedHelp}
activeOpacity={0.5} variant="ghost"
style={{ alignItems: 'center' }} size="sm"
> style={{ alignSelf: 'center' }}
<Text />
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textDecorationLine: 'underline',
}}
>
{t('devices.need_help')}
</Text>
</TouchableOpacity>
</ScrollView> </ScrollView>
); );
} }
@ -428,22 +377,11 @@ function WindowsStep3SuccessContent({
</Text> </Text>
</View> </View>
<TouchableOpacity <Button
title={t('common.ok')}
onPress={onClose} onPress={onClose}
activeOpacity={0.7} style={{ alignSelf: 'stretch' }}
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>
</View> </View>
); );
} }

View File

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