Add security settings nav scaffolding (#771)

This commit is contained in:
Leendert de Borst
2025-05-10 11:50:57 +02:00
parent 6714201057
commit 3958ce94c1
11 changed files with 955 additions and 12 deletions

View File

@@ -20,8 +20,6 @@ export default function CredentialsLayout(): React.ReactNode {
options={{
title: 'Add Credential',
presentation: Platform.OS === 'ios' ? 'modal' : 'card',
headerShown: true,
gestureEnabled: true,
...defaultHeaderOptions,
}}
/>
@@ -29,8 +27,6 @@ export default function CredentialsLayout(): React.ReactNode {
name="add-edit-page"
options={{
title: 'Add Credential',
headerShown: true,
gestureEnabled: true,
...defaultHeaderOptions,
}}
/>
@@ -39,7 +35,6 @@ export default function CredentialsLayout(): React.ReactNode {
options={{
title: 'Credential Created',
presentation: Platform.OS === 'ios' ? 'modal' : 'card',
headerShown: true,
...defaultHeaderOptions,
}}
/>
@@ -47,7 +42,6 @@ export default function CredentialsLayout(): React.ReactNode {
name="[id]"
options={{
title: 'Credential Details',
headerShown: true,
...defaultHeaderOptions,
}}
/>
@@ -55,7 +49,6 @@ export default function CredentialsLayout(): React.ReactNode {
name="email/[id]"
options={{
title: 'Email Preview',
headerShown: true,
}}
/>
</Stack>

View File

@@ -16,7 +16,6 @@ export default function EmailsLayout(): React.ReactNode {
name="[id]"
options={{
title: 'Email',
headerShown: true,
}}
/>
</Stack>

View File

@@ -19,7 +19,6 @@ export default function SettingsLayout(): React.ReactNode {
options={{
title: 'iOS Autofill',
headerBackTitle: 'Settings',
headerShown: true,
...defaultHeaderOptions,
}}
/>
@@ -28,16 +27,50 @@ export default function SettingsLayout(): React.ReactNode {
options={{
title: 'Vault Unlock Method',
headerBackTitle: 'Settings',
headerShown: true,
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="auto-lock"
options={{
title: 'Auto-lock Settings',
title: 'Auto-lock Timeout',
headerBackTitle: 'Settings',
headerShown: true,
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="security/index"
options={{
title: 'Security Settings',
headerBackTitle: 'Settings',
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="security/change-password"
options={{
title: 'Change Password',
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="security/active-sessions"
options={{
title: 'Active Sessions',
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="security/auth-logs"
options={{
title: 'Auth Logs',
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="security/delete-account"
options={{
title: 'Delete Account',
...defaultHeaderOptions,
}}
/>

View File

@@ -258,6 +258,7 @@ export default function SettingsScreen() : React.ReactNode {
<ThemedText style={styles.settingItemBadgeText}>1</ThemedText>
</View>
)}
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
</View>
</TouchableOpacity>
<View style={styles.separator} />
@@ -300,6 +301,21 @@ export default function SettingsScreen() : React.ReactNode {
</TouchableOpacity>
</View>
<View style={styles.section}>
<TouchableOpacity
style={styles.settingItem}
onPress={() => router.push('/(tabs)/settings/security')}
>
<View style={styles.settingItemIcon}>
<Ionicons name="shield-checkmark" size={20} color={colors.text} />
</View>
<View style={styles.settingItemContent}>
<ThemedText style={styles.settingItemText}>Security Settings</ThemedText>
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
</View>
</TouchableOpacity>
</View>
<View style={styles.section}>
<TouchableOpacity
style={styles.settingItem}

View File

@@ -0,0 +1,156 @@
import { StyleSheet, View, TouchableOpacity, Alert, FlatList } from 'react-native';
import { router } from 'expo-router';
import { useRef, useState, useEffect } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useColors } from '@/hooks/useColorScheme';
import { useWebApi } from '@/context/WebApiContext';
import { InlineSkeletonLoader } from '@/components/ui/InlineSkeletonLoader';
interface Session {
id: string;
deviceName: string;
lastActive: string;
ipAddress: string;
}
/**
* Active sessions screen.
*/
export default function ActiveSessionsScreen() : React.ReactNode {
const colors = useColors();
const insets = useSafeAreaInsets();
const webApi = useWebApi();
const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true);
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: insets.bottom,
paddingHorizontal: 14,
},
section: {
backgroundColor: colors.accentBackground,
borderRadius: 10,
marginTop: 20,
overflow: 'hidden',
},
sessionItem: {
padding: 16,
},
sessionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
deviceName: {
color: colors.text,
fontSize: 16,
fontWeight: '600',
},
sessionDetails: {
marginBottom: 8,
},
detailText: {
color: colors.textMuted,
fontSize: 14,
marginBottom: 4,
},
revokeButton: {
color: colors.primary,
fontSize: 14,
fontWeight: '600',
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
emptyStateText: {
color: colors.textMuted,
fontSize: 16,
textAlign: 'center',
},
});
const loadSessions = async () => {
try {
setIsLoading(true);
const response = await webApi.getActiveSessions();
setSessions(response);
} catch (error) {
Alert.alert('Error', 'Failed to load active sessions');
} finally {
setIsLoading(false);
}
};
const handleRevokeSession = async (sessionId: string) => {
Alert.alert(
'Revoke Session',
'Are you sure you want to revoke this session?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Revoke',
style: 'destructive',
onPress: async () => {
try {
await webApi.revokeSession(sessionId);
await loadSessions();
} catch (error) {
Alert.alert('Error', 'Failed to revoke session');
}
},
},
]
);
};
useEffect(() => {
loadSessions();
}, []);
const renderSession = ({ item }: { item: Session }) => (
<View style={styles.sessionItem}>
<View style={styles.sessionHeader}>
<ThemedText style={styles.deviceName}>{item.deviceName}</ThemedText>
<TouchableOpacity onPress={() => handleRevokeSession(item.id)}>
<ThemedText style={styles.revokeButton}>Revoke</ThemedText>
</TouchableOpacity>
</View>
<View style={styles.sessionDetails}>
<ThemedText style={styles.detailText}>Last active: {item.lastActive}</ThemedText>
<ThemedText style={styles.detailText}>IP Address: {item.ipAddress}</ThemedText>
</View>
</View>
);
return (
<ThemedView style={styles.container}>
<View style={styles.section}>
{isLoading ? (
<View style={styles.emptyState}>
<InlineSkeletonLoader width={200} />
</View>
) : sessions.length === 0 ? (
<View style={styles.emptyState}>
<ThemedText style={styles.emptyStateText}>No active sessions</ThemedText>
</View>
) : (
<FlatList
data={sessions}
renderItem={renderSession}
keyExtractor={(item) => item.id}
scrollEnabled={false}
/>
)}
</View>
</ThemedView>
);
}

View File

@@ -0,0 +1,161 @@
import { StyleSheet, View, TouchableOpacity, Animated, FlatList } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRef, useState, useEffect } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useColors } from '@/hooks/useColorScheme';
import { TitleContainer } from '@/components/ui/TitleContainer';
import { CollapsibleHeader } from '@/components/ui/CollapsibleHeader';
import { useWebApi } from '@/context/WebApiContext';
import { InlineSkeletonLoader } from '@/components/ui/InlineSkeletonLoader';
interface IAuthLog {
id: string;
timestamp: string;
eventType: string;
ipAddress: string;
deviceName: string;
success: boolean;
}
/**
* Auth logs screen.
*/
export default function AuthLogsScreen() : React.ReactNode {
const colors = useColors();
const insets = useSafeAreaInsets();
const scrollY = useRef(new Animated.Value(0)).current;
const webApi = useWebApi();
const [logs, setLogs] = useState<IAuthLog[]>([]);
const [isLoading, setIsLoading] = useState(true);
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: insets.bottom,
paddingHorizontal: 14,
paddingTop: insets.top,
},
scrollContent: {
paddingBottom: 40,
paddingTop: 42,
},
scrollView: {
flex: 1,
},
section: {
backgroundColor: colors.accentBackground,
borderRadius: 10,
marginTop: 20,
overflow: 'hidden',
},
logItem: {
padding: 16,
},
logHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
eventType: {
color: colors.text,
fontSize: 16,
fontWeight: '600',
},
status: {
fontSize: 14,
fontWeight: '600',
},
statusSuccess: {
color: colors.success,
},
statusFailure: {
color: colors.error,
},
logDetails: {
marginBottom: 8,
},
detailText: {
color: colors.textMuted,
fontSize: 14,
marginBottom: 4,
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
emptyStateText: {
color: colors.textMuted,
fontSize: 16,
textAlign: 'center',
},
});
/**
* Loads the authentication logs from the server.
*/
const loadLogs = async () : Promise<void> => {
try {
setIsLoading(true);
const response = await webApi.getAuthLogs();
setLogs(response);
} catch (error) {
// Error handling is done by the WebApiService
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadLogs();
}, []);
const renderLog = ({ item }: { item: IAuthLog }) => (
<View style={styles.logItem}>
<View style={styles.logHeader}>
<ThemedText style={styles.eventType}>{item.eventType}</ThemedText>
<ThemedText style={[
styles.status,
item.success ? styles.statusSuccess : styles.statusFailure
]}>
{item.success ? 'Success' : 'Failed'}
</ThemedText>
</View>
<View style={styles.logDetails}>
<ThemedText style={styles.detailText}>Time: {item.timestamp}</ThemedText>
<ThemedText style={styles.detailText}>Device: {item.deviceName}</ThemedText>
<ThemedText style={styles.detailText}>IP Address: {item.ipAddress}</ThemedText>
</View>
</View>
);
return (
<ThemedView style={styles.container}>
<View style={styles.scrollContent}>
<View style={styles.section}>
{isLoading ? (
<View style={styles.emptyState}>
<InlineSkeletonLoader width={200} />
</View>
) : logs.length === 0 ? (
<View style={styles.emptyState}>
<ThemedText style={styles.emptyStateText}>No auth logs found</ThemedText>
</View>
) : (
<FlatList
data={logs}
renderItem={renderLog}
keyExtractor={(item) => item.id}
scrollEnabled={false}
/>
)}
</View>
</View>
</ThemedView>
);
}

View File

@@ -0,0 +1,144 @@
import { StyleSheet, View, TouchableOpacity, Animated, Alert } from 'react-native';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useRef, useState } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useColors } from '@/hooks/useColorScheme';
import { TitleContainer } from '@/components/ui/TitleContainer';
import { CollapsibleHeader } from '@/components/ui/CollapsibleHeader';
import { ThemedTextInput } from '@/components/themed/ThemedTextInput';
import { ThemedButton } from '@/components/themed/ThemedButton';
import { useWebApi } from '@/context/WebApiContext';
/**
* Change password screen.
*/
export default function ChangePasswordScreen() : React.ReactNode {
const colors = useColors();
const insets = useSafeAreaInsets();
const scrollY = useRef(new Animated.Value(0)).current;
const webApi = useWebApi();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: insets.bottom,
paddingHorizontal: 14,
paddingTop: insets.top,
},
scrollContent: {
paddingBottom: 40,
paddingTop: 42,
},
scrollView: {
flex: 1,
},
form: {
backgroundColor: colors.accentBackground,
borderRadius: 10,
marginTop: 20,
padding: 16,
},
inputContainer: {
marginBottom: 16,
},
label: {
color: colors.text,
fontSize: 16,
marginBottom: 8,
},
button: {
marginTop: 8,
},
});
const handleSubmit = async () => {
if (!currentPassword || !newPassword || !confirmPassword) {
Alert.alert('Error', 'Please fill in all fields');
return;
}
if (newPassword !== confirmPassword) {
Alert.alert('Error', 'New passwords do not match');
return;
}
try {
setIsLoading(true);
// Verify current password
/*const isValid = await verifyPassword(currentPassword);
if (!isValid) {
Alert.alert('Error', 'Current password is incorrect');
return;
}
// Create new vault version with new password
await createNewVersion(newPassword);
// Submit to server
await webApi.submitVaultVersion(vault.getCurrentVersion());
Alert.alert('Success', 'Password changed successfully', [
{ text: 'OK', onPress: () => router.back() }
]);*/
} catch (error) {
Alert.alert('Error', 'Failed to change password. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<ThemedView style={styles.container}>
<View style={styles.scrollContent}>
<View style={styles.form}>
<View style={styles.inputContainer}>
<ThemedText style={styles.label}>Current Password</ThemedText>
<ThemedTextInput
secureTextEntry
value={currentPassword}
onChangeText={setCurrentPassword}
placeholder="Enter current password"
/>
</View>
<View style={styles.inputContainer}>
<ThemedText style={styles.label}>New Password</ThemedText>
<ThemedTextInput
secureTextEntry
value={newPassword}
onChangeText={setNewPassword}
placeholder="Enter new password"
/>
</View>
<View style={styles.inputContainer}>
<ThemedText style={styles.label}>Confirm New Password</ThemedText>
<ThemedTextInput
secureTextEntry
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="Confirm new password"
/>
</View>
<ThemedButton
title="Change Password"
onPress={handleSubmit}
loading={isLoading}
style={styles.button}
/>
</View>
</View>
</ThemedView>
);
}

View File

@@ -0,0 +1,174 @@
import { StyleSheet, View, TouchableOpacity, Animated, Alert } from 'react-native';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useRef, useState } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useColors } from '@/hooks/useColorScheme';
import { TitleContainer } from '@/components/ui/TitleContainer';
import { CollapsibleHeader } from '@/components/ui/CollapsibleHeader';
import { ThemedTextInput } from '@/components/themed/ThemedTextInput';
import { ThemedButton } from '@/components/themed/ThemedButton';
import { useWebApi } from '@/context/WebApiContext';
import { useAuth } from '@/context/AuthContext';
/**
* Delete account screen.
*/
export default function DeleteAccountScreen() : React.ReactNode {
const colors = useColors();
const insets = useSafeAreaInsets();
const scrollY = useRef(new Animated.Value(0)).current;
const webApi = useWebApi();
const { username } = useAuth();
const [confirmUsername, setConfirmUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [step, setStep] = useState<'username' | 'password'>('username');
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: insets.bottom,
paddingHorizontal: 14,
paddingTop: insets.top,
},
scrollContent: {
paddingBottom: 40,
paddingTop: 42,
},
scrollView: {
flex: 1,
},
form: {
backgroundColor: colors.accentBackground,
borderRadius: 10,
marginTop: 20,
padding: 16,
},
inputContainer: {
marginBottom: 16,
},
label: {
color: colors.text,
fontSize: 16,
marginBottom: 8,
},
warningText: {
color: colors.error,
fontSize: 14,
marginBottom: 16,
textAlign: 'center',
},
button: {
marginTop: 8,
},
buttonDanger: {
backgroundColor: colors.error,
},
});
const handleUsernameSubmit = () => {
if (confirmUsername !== username) {
Alert.alert('Error', 'Username does not match');
return;
}
setStep('password');
};
const handleDeleteAccount = async () => {
if (!password) {
Alert.alert('Error', 'Please enter your password');
return;
}
Alert.alert(
'Delete Account',
'Are you absolutely sure you want to delete your account? This action cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete Account',
style: 'destructive',
onPress: async () => {
try {
setIsLoading(true);
// Verify password
/*const isValid = await vault.verifyPassword(password);
if (!isValid) {
Alert.alert('Error', 'Password is incorrect');
return;
}
// Delete account
await webApi.deleteAccount();*/
// Log out and return to login
await webApi.logout();
router.replace('/login');
} catch (error) {
Alert.alert('Error', 'Failed to delete account. Please try again.');
} finally {
setIsLoading(false);
}
},
},
]
);
};
return (
<ThemedView style={styles.container}>
<View style={styles.scrollContent}>
<View style={styles.form}>
{step === 'username' ? (
<>
<ThemedText style={styles.warningText}>
Warning: This action cannot be undone. All your data will be permanently deleted.
</ThemedText>
<View style={styles.inputContainer}>
<ThemedText style={styles.label}>Enter your username to confirm</ThemedText>
<ThemedTextInput
value={confirmUsername}
onChangeText={setConfirmUsername}
placeholder="Enter username"
autoCapitalize="none"
/>
</View>
<ThemedButton
title="Continue"
onPress={handleUsernameSubmit}
style={styles.button}
/>
</>
) : (
<>
<ThemedText style={styles.warningText}>
Please enter your password to confirm account deletion
</ThemedText>
<View style={styles.inputContainer}>
<ThemedText style={styles.label}>Password</ThemedText>
<ThemedTextInput
secureTextEntry
value={password}
onChangeText={setPassword}
placeholder="Enter password"
/>
</View>
<ThemedButton
title="Delete Account"
onPress={handleDeleteAccount}
loading={isLoading}
style={[styles.button, styles.buttonDanger]}
/>
</>
)}
</View>
</View>
</ThemedView>
);
}

View File

@@ -0,0 +1,146 @@
import { StyleSheet, View, TouchableOpacity, Animated, ScrollView } from 'react-native';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useRef } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useColors } from '@/hooks/useColorScheme';
import { TitleContainer } from '@/components/ui/TitleContainer';
import { CollapsibleHeader } from '@/components/ui/CollapsibleHeader';
/**
* Security settings screen.
*/
export default function SecuritySettingsScreen() : React.ReactNode {
const colors = useColors();
const insets = useSafeAreaInsets();
const scrollY = useRef(new Animated.Value(0)).current;
const scrollViewRef = useRef<ScrollView>(null);
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: insets.bottom,
paddingHorizontal: 14,
paddingTop: insets.top,
},
scrollContent: {
paddingBottom: 40,
paddingTop: 42,
},
scrollView: {
flex: 1,
},
section: {
backgroundColor: colors.accentBackground,
borderRadius: 10,
marginTop: 20,
overflow: 'hidden',
},
separator: {
backgroundColor: colors.accentBorder,
height: StyleSheet.hairlineWidth,
marginLeft: 52,
},
settingItem: {
alignItems: 'center',
backgroundColor: colors.accentBackground,
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 6,
},
settingItemContent: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
paddingVertical: 10,
},
settingItemIcon: {
alignItems: 'center',
height: 24,
justifyContent: 'center',
marginRight: 12,
width: 24,
},
settingItemText: {
color: colors.text,
flex: 1,
fontSize: 16,
},
});
return (
<ThemedView style={styles.container}>
<Animated.ScrollView
ref={scrollViewRef}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{ useNativeDriver: true }
)}
scrollEventThrottle={16}
contentContainerStyle={styles.scrollContent}
scrollIndicatorInsets={{ bottom: 40 }}
style={styles.scrollView}
>
<View style={styles.section}>
<TouchableOpacity
style={styles.settingItem}
onPress={() => router.push('/settings/security/change-password')}
>
<View style={styles.settingItemIcon}>
<Ionicons name="key" size={20} color={colors.text} />
</View>
<View style={styles.settingItemContent}>
<ThemedText style={styles.settingItemText}>Change Master Password</ThemedText>
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
</View>
</TouchableOpacity>
<View style={styles.separator} />
<TouchableOpacity
style={styles.settingItem}
onPress={() => router.push('/settings/security/active-sessions')}
>
<View style={styles.settingItemIcon}>
<Ionicons name="desktop" size={20} color={colors.text} />
</View>
<View style={styles.settingItemContent}>
<ThemedText style={styles.settingItemText}>Active Sessions</ThemedText>
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
</View>
</TouchableOpacity>
<View style={styles.separator} />
<TouchableOpacity
style={styles.settingItem}
onPress={() => router.push('/settings/security/auth-logs')}
>
<View style={styles.settingItemIcon}>
<Ionicons name="list" size={20} color={colors.text} />
</View>
<View style={styles.settingItemContent}>
<ThemedText style={styles.settingItemText}>Recent Auth Logs</ThemedText>
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
</View>
</TouchableOpacity>
<View style={styles.separator} />
<TouchableOpacity
style={styles.settingItem}
onPress={() => router.push('/settings/security/delete-account')}
>
<View style={styles.settingItemIcon}>
<Ionicons name="trash" size={20} color={colors.primary} />
</View>
<View style={styles.settingItemContent}>
<ThemedText style={[styles.settingItemText, { color: colors.primary }]}>Delete Account</ThemedText>
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
</View>
</TouchableOpacity>
</View>
</Animated.ScrollView>
</ThemedView>
);
}

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { TouchableOpacity, StyleSheet, ActivityIndicator, ViewStyle, TextStyle } from 'react-native';
import { ThemedText } from '@/components/themed/ThemedText';
import { useColors } from '@/hooks/useColorScheme';
type ThemedButtonProps = {
title: string;
onPress: () => void;
loading?: boolean;
disabled?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
};
/**
* Themed button component that matches the app's design system.
*/
export const ThemedButton: React.FC<ThemedButtonProps> = ({
title,
onPress,
loading = false,
disabled = false,
style,
textStyle,
}) => {
const colors = useColors();
const styles = StyleSheet.create({
button: {
alignItems: 'center',
backgroundColor: colors.primary,
borderRadius: 6,
justifyContent: 'center',
padding: 12,
},
buttonDisabled: {
backgroundColor: colors.textMuted,
opacity: 0.7,
},
buttonText: {
color: colors.background,
fontSize: 16,
fontWeight: '600',
},
loadingContainer: {
position: 'absolute',
},
});
return (
<TouchableOpacity
style={[
styles.button,
disabled && styles.buttonDisabled,
style,
]}
onPress={onPress}
disabled={disabled || loading}
>
<ThemedText style={[styles.buttonText, textStyle]}>
{title}
</ThemedText>
{loading && (
<ActivityIndicator
style={styles.loadingContainer}
color={colors.background}
/>
)}
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { TextInput, TextInputProps, StyleSheet, View, Text } from 'react-native';
import { useColors } from '@/hooks/useColorScheme';
type ThemedTextInputProps = TextInputProps & {
error?: string;
};
/**
* Themed text input component that matches the app's design system.
*/
export const ThemedTextInput: React.FC<ThemedTextInputProps> = ({ error, style, ...props }) => {
const colors = useColors();
const styles = StyleSheet.create({
errorText: {
color: 'red',
fontSize: 12,
marginTop: 4,
},
input: {
color: colors.text,
fontSize: 16,
padding: 10,
},
inputContainer: {
backgroundColor: colors.background,
borderColor: error ? 'red' : colors.accentBorder,
borderRadius: 6,
borderWidth: 1,
},
});
return (
<View>
<View style={styles.inputContainer}>
<TextInput
style={[styles.input, style]}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
{...props}
/>
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
};