feat: enhance SettingsScreen with dynamic localization and theming

- Integrated useColors hook for dynamic theming in SettingsScreen.
- Updated tab labels and titles to use localized strings.
- Refactored styles to utilize theme palette for consistent styling.
- Improved locale subscription to re-render labels on language change.

feat: implement useSheetDrag hook for bottom sheet gesture handling

- Added useSheetDrag hook to manage drag-to-dismiss functionality for bottom sheets.
- Configured pan responder for handling drag gestures and dismissing the sheet based on distance and velocity.

refactor: restructure color and token management

- Introduced useColors and useResolvedTheme hooks for better theme management.
- Separated light and dark color palettes for improved maintainability.
- Updated tokens to reflect new theming structure and ensure consistency across components.
This commit is contained in:
Linus Rath
2026-04-27 13:30:45 +02:00
parent 792b028b7a
commit cf3f919fa2
66 changed files with 2290 additions and 1811 deletions
+27 -18
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { useColorScheme } from 'react-native';
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
@@ -39,7 +40,8 @@ import { useLocaleStore } from './src/stores/locale-store';
import { useNetworkStore } from './src/stores/network-store';
import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { colors, spacing, typography } from './src/theme/tokens';
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
import { useColors } from './src/theme/colors';
const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<MainTabsParamList>();
@@ -55,32 +57,32 @@ function navigateToNotificationTap(payload: NotificationTapPayload): void {
}
function LoadingScreen({ message }: { message: string }) {
const c = useColors();
return (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} />
<Text style={styles.loadingText}>{message}</Text>
<View style={[styles.loadingContainer, { backgroundColor: c.background }]}>
<ActivityIndicator color={c.primary} />
<Text style={[styles.loadingText, { color: c.textSecondary }]}>{message}</Text>
</View>
);
}
function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParamList, 'MainTabs'>) {
const c = useColors();
const mailboxes = useEmailStore((state) => state.mailboxes);
const logout = useAuthStore((state) => state.logout);
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<View style={{ flex: 1, backgroundColor: c.background }}>
<UpdateBanner />
<Tab.Navigator
screenOptions={{
headerShown: false,
// Matches webmail horizontal NavigationRail: bg-background, border-t border-border,
// active uses foreground (white), inactive uses muted-foreground.
tabBarActiveTintColor: colors.text,
tabBarInactiveTintColor: colors.textSecondary,
tabBarActiveTintColor: c.text,
tabBarInactiveTintColor: c.textSecondary,
tabBarStyle: {
backgroundColor: colors.background,
borderTopColor: colors.border,
backgroundColor: c.background,
borderTopColor: c.border,
borderTopWidth: 1,
elevation: 0,
shadowOpacity: 0,
@@ -98,8 +100,8 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
tabBarBadgeStyle: {
backgroundColor: colors.error,
color: colors.primaryForeground,
backgroundColor: c.error,
color: c.primaryForeground,
fontSize: 10,
fontWeight: '700',
minWidth: 16,
@@ -163,6 +165,15 @@ export default function App() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const client = useAuthStore((state) => state.client);
const restoreSession = useAuthStore((state) => state.restoreSession);
// Resolve the user's theme preference to a concrete light/dark style for the
// system status bar. The rest of the app's colors are still hard-coded dark
// until the StyleSheet migration to a theme-aware `useColors` hook lands.
const themePref = useSettingsStore((state) => state.theme);
const systemScheme = useColorScheme();
const resolvedScheme: 'light' | 'dark' =
themePref === 'system' ? (systemScheme === 'light' ? 'light' : 'dark') : themePref;
const statusBarStyle: 'light' | 'dark' = resolvedScheme === 'light' ? 'dark' : 'light';
// Persisted active account is the signal that the user was already signed
// in on the previous launch. When present we render the main UI with the
// cached mail list instead of the "Restoring session" spinner; the real
@@ -309,7 +320,7 @@ export default function App() {
if (!hasRestoredSession && !hasPersistedAccount) {
return (
<>
<StatusBar style="light" />
<StatusBar style={statusBarStyle} />
<LoadingScreen message="Loading..." />
</>
);
@@ -318,7 +329,7 @@ export default function App() {
if (hasRestoredSession && !isAuthenticated) {
return (
<>
<StatusBar style="light" />
<StatusBar style={statusBarStyle} />
<LoginScreen />
</>
);
@@ -326,7 +337,7 @@ export default function App() {
return (
<NavigationContainer ref={navigationRef}>
<StatusBar style="light" />
<StatusBar style={statusBarStyle} />
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
@@ -374,12 +385,10 @@ const styles = StyleSheet.create({
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background,
gap: spacing.md,
},
loadingText: {
...typography.body,
color: colors.textSecondary,
},
});
+28 -45
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface BadgeProps {
children: string;
@@ -8,57 +9,39 @@ interface BadgeProps {
style?: ViewStyle;
}
/**
* Matches webmail badge patterns:
* - rounded-full px-2 py-0.5 text-xs font-medium
*/
export default function Badge({ children, variant = 'default', style }: BadgeProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.base, variantStyles[variant], style]}>
<Text style={[styles.text, textVariantStyles[variant]]}>
<View style={[styles.base, styles[`bg_${variant}` as const], style]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]}>
{children}
</Text>
</View>
);
}
const styles = StyleSheet.create({
base: {
borderRadius: radius.full,
paddingHorizontal: 8, // px-2
paddingVertical: 2, // py-0.5
alignSelf: 'flex-start',
},
text: {
...typography.caption,
fontWeight: '500',
},
});
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
borderRadius: radius.full,
paddingHorizontal: 8,
paddingVertical: 2,
alignSelf: 'flex-start',
},
text: { ...typography.caption, fontWeight: '500' },
const variantStyles: Record<string, ViewStyle> = {
default: {
backgroundColor: colors.surface,
},
primary: {
backgroundColor: colors.primaryBg,
},
destructive: {
backgroundColor: colors.errorBg,
},
outline: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: colors.border,
},
muted: {
backgroundColor: colors.muted,
},
};
bg_default: { backgroundColor: c.surface },
bg_primary: { backgroundColor: c.primaryBg },
bg_destructive: { backgroundColor: c.errorBg },
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
bg_muted: { backgroundColor: c.muted },
text_default: { color: c.text },
text_primary: { color: c.primary },
text_destructive: { color: c.error },
text_outline: { color: c.text },
text_muted: { color: c.mutedForeground },
});
}
const textVariantStyles: Record<string, { color: string }> = {
default: { color: colors.text },
primary: { color: colors.primary },
destructive: { color: colors.error },
outline: { color: colors.text },
muted: { color: colors.mutedForeground },
};
+37 -105
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { Pressable, Text, StyleSheet, ViewStyle, TextStyle, ActivityIndicator } from 'react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import { Pressable, Text, StyleSheet, ViewStyle, ActivityIndicator } from 'react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type ButtonVariant = 'default' | 'ghost' | 'outline' | 'destructive';
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
@@ -26,15 +27,17 @@ export default function Button({
icon,
style,
}: ButtonProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isDisabled = disabled || loading;
return (
<Pressable
style={({ pressed }) => [
styles.base,
sizeStyles[size],
variantStyles[variant],
pressed && !isDisabled && pressedStyles[variant],
styles[`size_${size}` as const],
styles[`bg_${variant}` as const],
pressed && !isDisabled && styles[`pressed_${variant}` as const],
isDisabled && styles.disabled,
style,
]}
@@ -44,13 +47,13 @@ export default function Button({
{loading ? (
<ActivityIndicator
size="small"
color={variant === 'default' ? colors.primaryForeground : colors.primary}
color={variant === 'default' ? c.primaryForeground : c.primary}
/>
) : (
<>
{icon}
{typeof children === 'string' ? (
<Text style={[styles.text, textVariantStyles[variant], isDisabled && styles.textDisabled]}>
<Text style={[styles.text, styles[`text_${variant}` as const], isDisabled && styles.textDisabled]}>
{children}
</Text>
) : (
@@ -62,105 +65,34 @@ export default function Button({
);
}
const styles = StyleSheet.create({
base: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.md, // rounded-md (web)
gap: spacing.sm,
},
text: {
...typography.bodyMedium, // text-sm font-medium (web)
},
disabled: {
opacity: 0.5,
},
textDisabled: {
opacity: 0.5,
},
});
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
borderRadius: radius.md, gap: spacing.sm,
},
text: { ...typography.bodyMedium },
disabled: { opacity: 0.5 },
textDisabled: { opacity: 0.5 },
// Matches web: h-9 (sm), h-10 (md), h-11 (lg), h-10 w-10 (icon)
const sizeStyles: Record<ButtonSize, ViewStyle> = {
sm: {
height: componentSizes.buttonSm,
paddingHorizontal: spacing.md, // px-3
},
md: {
height: componentSizes.buttonMd,
paddingHorizontal: spacing.lg, // px-4
paddingVertical: spacing.sm, // py-2
},
lg: {
height: componentSizes.buttonLg,
paddingHorizontal: spacing.xxxl, // px-8
},
icon: {
height: componentSizes.buttonMd,
width: componentSizes.buttonMd,
paddingHorizontal: 0,
},
};
size_sm: { height: componentSizes.buttonSm, paddingHorizontal: spacing.md },
size_md: { height: componentSizes.buttonMd, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm },
size_lg: { height: componentSizes.buttonLg, paddingHorizontal: spacing.xxxl },
size_icon: { height: componentSizes.buttonMd, width: componentSizes.buttonMd, paddingHorizontal: 0 },
// Matches web button variant classes exactly
const variantStyles: Record<ButtonVariant, ViewStyle> = {
default: {
backgroundColor: colors.primary,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
elevation: 1,
},
ghost: {
backgroundColor: 'transparent',
},
outline: {
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
},
destructive: {
backgroundColor: colors.error,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
elevation: 1,
},
};
bg_default: { backgroundColor: c.primary, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
bg_ghost: { backgroundColor: 'transparent' },
bg_outline: { backgroundColor: c.background, borderWidth: 1, borderColor: c.border },
bg_destructive: { backgroundColor: c.error, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
const pressedStyles: Record<ButtonVariant, ViewStyle> = {
default: {
backgroundColor: colors.primaryDark,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
ghost: {
backgroundColor: colors.accent,
},
outline: {
backgroundColor: colors.accent,
},
destructive: {
opacity: 0.9,
},
};
pressed_default: { backgroundColor: c.primaryDark, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
pressed_ghost: { backgroundColor: c.accent },
pressed_outline: { backgroundColor: c.accent },
pressed_destructive: { opacity: 0.9 },
const textVariantStyles: Record<ButtonVariant, TextStyle> = {
default: {
color: colors.primaryForeground,
},
ghost: {
color: colors.text,
},
outline: {
color: colors.text,
},
destructive: {
color: colors.errorForeground,
},
};
text_default: { color: c.primaryForeground },
text_ghost: { color: c.text },
text_outline: { color: c.text },
text_destructive: { color: c.errorForeground },
});
}
+19 -10
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface CardProps {
children: React.ReactNode;
@@ -13,6 +14,8 @@ interface CardProps {
* - card-foreground for text
*/
export function Card({ children, style }: CardProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return <View style={[styles.card, style]}>{children}</View>;
}
@@ -27,6 +30,8 @@ interface SectionHeaderProps {
* - text-sm text-muted-foreground for description
*/
export function SectionHeader({ title, description }: SectionHeaderProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{title}</Text>
@@ -51,6 +56,8 @@ interface SettingItemRowProps {
* - description: text-xs text-muted-foreground mt-1
*/
export function SettingItemRow({ label, description, right, icon, iconBg, bottomBorder = true }: SettingItemRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.settingRow, bottomBorder && styles.settingRowBorder]}>
{icon && (
@@ -67,12 +74,13 @@ export function SettingItemRow({ label, description, right, icon, iconBg, bottom
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
card: {
backgroundColor: colors.card,
backgroundColor: c.card,
borderRadius: radius.lg, // rounded-lg
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
overflow: 'hidden',
},
sectionHeader: {
@@ -81,11 +89,11 @@ const styles = StyleSheet.create({
},
sectionTitle: {
...typography.h3, // text-lg font-medium
color: colors.text,
color: c.text,
},
sectionDescription: {
...typography.body,
color: colors.mutedForeground, // text-muted-foreground
color: c.mutedForeground, // text-muted-foreground
marginTop: 4,
},
settingRow: {
@@ -97,7 +105,7 @@ const styles = StyleSheet.create({
},
settingRowBorder: {
borderBottomWidth: 1,
borderBottomColor: colors.border, // border-b border-border
borderBottomColor: c.border, // border-b border-border
},
settingIcon: {
width: 32,
@@ -112,14 +120,15 @@ const styles = StyleSheet.create({
},
settingLabel: {
...typography.bodyMedium, // text-sm font-medium text-foreground
color: colors.text,
color: c.text,
},
settingDescription: {
...typography.caption, // text-xs text-muted-foreground
color: colors.mutedForeground,
color: c.mutedForeground,
marginTop: 2,
},
settingRight: {
flexShrink: 0,
},
});
});
}
+14 -9
View File
@@ -2,7 +2,8 @@ import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal } from 'react-native';
import { AlertTriangle } from 'lucide-react-native';
import Button from './Button';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface DialogProps {
visible: boolean;
@@ -34,6 +35,8 @@ export default function Dialog({
onConfirm,
onCancel,
}: DialogProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<View style={styles.backdrop}>
@@ -41,7 +44,7 @@ export default function Dialog({
<View style={styles.content}>
{variant === 'destructive' && (
<View style={styles.iconBadge}>
<AlertTriangle size={20} color={colors.error} />
<AlertTriangle size={20} color={c.error} />
</View>
)}
<Text style={styles.title}>{title}</Text>
@@ -61,7 +64,8 @@ export default function Dialog({
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
@@ -72,9 +76,9 @@ const styles = StyleSheet.create({
dialog: {
width: '100%',
maxWidth: 400,
backgroundColor: colors.background,
backgroundColor: c.background,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.lg,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
@@ -89,18 +93,18 @@ const styles = StyleSheet.create({
width: 40,
height: 40,
borderRadius: radius.full,
backgroundColor: colors.errorBg,
backgroundColor: c.errorBg,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.md,
},
title: {
...typography.h3, // text-lg font-semibold
color: colors.text,
color: c.text,
},
message: {
...typography.body, // text-sm
color: colors.mutedForeground, // text-muted-foreground
color: c.mutedForeground, // text-muted-foreground
marginTop: spacing.sm,
},
footer: {
@@ -110,4 +114,5 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.xxl, // px-6
paddingBottom: spacing.xxl, // pb-6
},
});
});
}
+13 -8
View File
@@ -6,7 +6,8 @@ import type { Email } from '../api/types';
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody } from '../lib/email-html';
import { jmapClient } from '../api/jmap-client';
import { useSettingsStore } from '../stores/settings-store';
import { colors, spacing, typography } from '../theme/tokens';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
// Largest inline image we'll pull inline as a data: URI. Anything bigger gets
// skipped so we don't freeze the JS thread base64-encoding megabytes.
@@ -129,6 +130,8 @@ const HEIGHT_REPORTER = `
`;
export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
@@ -247,13 +250,13 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
<View style={styles.banner}>
{externalContentPolicy === 'ask' && (
<Pressable style={styles.bannerButton} onPress={onLoadImages} hitSlop={8}>
<ImageIcon size={14} color={colors.textSecondary} />
<ImageIcon size={14} color={c.textSecondary} />
<Text style={styles.bannerButtonText}>Load external content</Text>
</Pressable>
)}
{senderEmail ? (
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
<ShieldCheck size={14} color={colors.textSecondary} />
<ShieldCheck size={14} color={c.textSecondary} />
<Text style={styles.bannerButtonText}>Trust sender</Text>
</Pressable>
) : null}
@@ -308,7 +311,8 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrapper: { width: '100%' },
banner: {
flexDirection: 'row',
@@ -317,9 +321,9 @@ const styles = StyleSheet.create({
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.surfaceHover,
backgroundColor: c.surfaceHover,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
bannerButton: {
flexDirection: 'row',
@@ -329,7 +333,7 @@ const styles = StyleSheet.create({
},
bannerButtonText: {
...typography.caption,
color: colors.textSecondary,
color: c.textSecondary,
},
webContainer: {
width: '100%',
@@ -343,4 +347,5 @@ const styles = StyleSheet.create({
webviewContainer: {
backgroundColor: 'transparent',
},
});
});
}
+16 -11
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { TextInput as RNTextInput, View, Text, StyleSheet, ViewStyle, TextInputProps } from 'react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface InputProps extends TextInputProps {
label?: string;
@@ -28,6 +29,8 @@ export default function Input({
style,
...props
}: InputProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [focused, setFocused] = React.useState(false);
return (
@@ -43,7 +46,7 @@ export default function Input({
<RNTextInput
{...props}
style={[styles.input, style]}
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
onFocus={(e) => { setFocused(true); props.onFocus?.(e); }}
onBlur={(e) => { setFocused(false); props.onBlur?.(e); }}
/>
@@ -54,10 +57,11 @@ export default function Input({
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
label: {
...typography.bodyMedium,
color: colors.textSecondary,
color: c.textSecondary,
marginBottom: spacing.sm,
},
inputWrapper: {
@@ -65,17 +69,17 @@ const styles = StyleSheet.create({
alignItems: 'center',
height: componentSizes.inputHeight, // h-10 = 40px
borderWidth: 1,
borderColor: colors.border, // border-input
borderColor: c.border, // border-input
borderRadius: radius.sm, // rounded-md = 6px
backgroundColor: colors.background, // bg-background
backgroundColor: c.background, // bg-background
paddingHorizontal: spacing.md, // px-3 = 12px
},
inputFocused: {
borderColor: colors.borderFocus, // border-ring (blue-500)
borderColor: c.borderFocus, // border-ring (blue-500)
borderWidth: 2,
},
inputError: {
borderColor: colors.error,
borderColor: c.error,
},
inputDisabled: {
opacity: 0.5,
@@ -83,7 +87,7 @@ const styles = StyleSheet.create({
input: {
flex: 1,
...typography.body, // text-sm = 14px
color: colors.text, // text-foreground
color: c.text, // text-foreground
paddingVertical: 0,
},
iconLeft: {
@@ -94,7 +98,8 @@ const styles = StyleSheet.create({
},
errorText: {
...typography.caption,
color: colors.error,
color: c.error,
marginTop: spacing.xs,
},
});
});
}
+22 -17
View File
@@ -2,7 +2,8 @@ import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CloudOff } from 'lucide-react-native';
import { useNetworkStore } from '../stores/network-store';
import { colors, spacing, typography } from '../theme/tokens';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface OfflineBannerProps {
/** Optional extra notice (e.g. "Showing cached mail"). */
@@ -10,11 +11,13 @@ interface OfflineBannerProps {
}
export function OfflineBanner({ hint }: OfflineBannerProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const online = useNetworkStore((s) => s.online);
if (online) return null;
return (
<View style={styles.bar}>
<CloudOff size={14} color={colors.primaryForeground} />
<CloudOff size={14} color={c.primaryForeground} />
<Text style={styles.text} numberOfLines={1}>
You are offline{hint ? ` — ${hint}` : ''}
</Text>
@@ -22,18 +25,20 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
);
}
const styles = StyleSheet.create({
bar: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: 6,
backgroundColor: '#a16207',
},
text: {
...typography.caption,
color: colors.primaryForeground,
flex: 1,
},
});
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
bar: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: 6,
backgroundColor: '#a16207',
},
text: {
...typography.caption,
color: c.primaryForeground,
flex: 1,
},
});
}
+12 -7
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, ViewStyle } from 'react-native';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface RadioOption {
label: string;
@@ -22,6 +23,8 @@ interface RadioGroupProps {
* - unselected: bg-muted text-foreground
*/
export default function RadioGroup({ options, value, onChange, style }: RadioGroupProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.container, style]}>
{options.map((opt) => {
@@ -42,7 +45,8 @@ export default function RadioGroup({ options, value, onChange, style }: RadioGro
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: {
flexDirection: 'row',
gap: 6, // gap-1.5
@@ -53,19 +57,20 @@ const styles = StyleSheet.create({
borderRadius: radius.sm, // rounded-md
},
optionSelected: {
backgroundColor: colors.primary,
backgroundColor: c.primary,
},
optionUnselected: {
backgroundColor: colors.muted,
backgroundColor: c.muted,
},
optionText: {
...typography.caption, // text-xs
},
textSelected: {
color: colors.primaryForeground,
color: c.primaryForeground,
fontWeight: '500',
},
textUnselected: {
color: colors.text,
color: c.text,
},
});
});
}
+54 -45
View File
@@ -10,7 +10,8 @@ import {
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useEmailStore } from '../stores/email-store';
import { useAuthStore } from '../stores/auth-store';
import { useAccountStore } from '../stores/account-store';
@@ -52,17 +53,19 @@ const ROLE_COLOR: Record<string, string> = {
inbox: '#60a5fa',
sent: '#4ade80',
drafts: '#a78bfa',
trash: colors.textMuted,
trash: c.textMuted,
junk: '#f87171',
archive: '#fbbf24',
};
function iconColor(role: string | null | undefined, isSelected: boolean): string {
if (role && ROLE_COLOR[role]) return ROLE_COLOR[role];
return isSelected ? colors.text : colors.textSecondary;
return isSelected ? c.text : c.textSecondary;
}
function RowCounts({ unread, total }: { unread: number; total: number }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
if (unread === 0 && total === 0) return null;
return (
<View style={styles.counts}>
@@ -90,6 +93,8 @@ function SidebarRow({
icon, label, depth, isSelected, unread, total,
hasChildren, isExpanded, onPress, onToggleExpand,
}: SidebarRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const leftPad = ROW_PX_BASE + depth * INDENT_STEP;
return (
<Pressable
@@ -108,9 +113,9 @@ function SidebarRow({
style={styles.chevron}
>
{isExpanded ? (
<ChevronDown size={12} color={colors.textMuted} />
<ChevronDown size={12} color={c.textMuted} />
) : (
<ChevronRight size={12} color={colors.textMuted} />
<ChevronRight size={12} color={c.textMuted} />
)}
</Pressable>
) : (
@@ -135,6 +140,8 @@ interface SidebarDrawerProps {
}
export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const mailboxes = useEmailStore((s) => s.mailboxes);
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
const selectMailbox = useEmailStore((s) => s.selectMailbox);
@@ -237,7 +244,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
[accountEmail],
);
const avatarBg = React.useMemo(
() => (accountEmail ? generateAvatarColor(accountEmail) : colors.primary),
() => (accountEmail ? generateAvatarColor(accountEmail) : c.primary),
[accountEmail],
);
const hostname = React.useMemo(() => {
@@ -267,7 +274,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
{/* Header: close + account switcher */}
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
<X size={20} color={colors.text} />
<X size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => setAccountMenuOpen((v) => !v)}
@@ -284,7 +291,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
</View>
<ChevronDown
size={16}
color={colors.textMuted}
color={c.textMuted}
style={accountMenuOpen ? styles.accountChevronOpen : undefined}
/>
</Pressable>
@@ -323,7 +330,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
</View>
{isActive && (
<View style={styles.accountMenuCheckBadge}>
<Check size={10} color={colors.primaryForeground} strokeWidth={3} />
<Check size={10} color={c.primaryForeground} strokeWidth={3} />
</View>
)}
</View>
@@ -364,7 +371,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
navigation.navigate('AddAccount');
}}
>
<Plus size={16} color={colors.textSecondary} />
<Plus size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>Add account</Text>
</Pressable>
</>
@@ -382,7 +389,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
]}
onPress={() => setDefaultAccount(active.id)}
>
<Star size={16} color={colors.textSecondary} />
<Star size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>Set as default</Text>
</Pressable>
);
@@ -394,7 +401,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
]}
onPress={() => { setAccountMenuOpen(false); onClose(); void logout(); }}
>
<LogOut size={16} color={colors.textSecondary} />
<LogOut size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText} numberOfLines={1}>
Sign out of {accountEmailFull || 'account'}
</Text>
@@ -407,8 +414,8 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
]}
onPress={() => { setAccountMenuOpen(false); onClose(); void logoutAll(); }}
>
<LogOut size={16} color={colors.error} />
<Text style={[styles.accountMenuActionText, { color: colors.error }]}>
<LogOut size={16} color={c.error} />
<Text style={[styles.accountMenuActionText, { color: c.error }]}>
Sign out all
</Text>
</Pressable>
@@ -420,14 +427,14 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
{/* Folders section */}
<Pressable style={styles.sectionHeader} onPress={toggleFoldersSection}>
{foldersExpanded ? (
<ChevronDown size={14} color={colors.textMuted} />
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={colors.textMuted} />
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>Folders</Text>
<View style={{ flex: 1 }} />
<View style={styles.sectionSettings}>
<Settings size={14} color={colors.textMuted} />
<Settings size={14} color={c.textMuted} />
</View>
</Pressable>
@@ -465,7 +472,8 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
@@ -478,7 +486,7 @@ const styles = StyleSheet.create({
maxWidth: 340,
backgroundColor: '#262626', // matches webmail bg-secondary (dark mode)
borderRightWidth: 1,
borderRightColor: colors.border,
borderRightColor: c.border,
},
drawerSafe: { flex: 1 },
@@ -490,7 +498,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
headerClose: {
width: 36, height: 36,
@@ -506,7 +514,7 @@ const styles = StyleSheet.create({
paddingVertical: 6,
borderRadius: radius.sm,
},
accountPressed: { backgroundColor: colors.surfaceHover },
accountPressed: { backgroundColor: c.surfaceHover },
accountChevronOpen: { transform: [{ rotate: '180deg' }] },
accountAvatar: {
width: 36, height: 36,
@@ -515,11 +523,11 @@ const styles = StyleSheet.create({
},
accountAvatarText: {
...typography.bodySemibold,
color: colors.primaryForeground,
color: c.primaryForeground,
},
accountInfo: { flex: 1, minWidth: 0 },
accountName: { ...typography.bodyMedium, color: colors.text },
accountEmail: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
accountName: { ...typography.bodyMedium, color: c.text },
accountEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 },
// Account menu - floating popover card under the header
accountMenu: {
@@ -529,7 +537,7 @@ const styles = StyleSheet.create({
backgroundColor: '#1f1f1f',
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
overflow: 'hidden',
// Elevation / shadow
shadowColor: '#000',
@@ -566,14 +574,14 @@ const styles = StyleSheet.create({
accountMenuAvatarText: {
...typography.caption,
fontWeight: '600',
color: colors.primaryForeground,
color: c.primaryForeground,
},
accountMenuCheckBadge: {
position: 'absolute',
right: -3, bottom: -3,
width: 16, height: 16,
borderRadius: 8,
backgroundColor: colors.primary,
backgroundColor: c.primary,
alignItems: 'center', justifyContent: 'center',
borderWidth: 2,
borderColor: '#1f1f1f',
@@ -586,13 +594,13 @@ const styles = StyleSheet.create({
},
accountMenuName: {
...typography.bodyMedium,
color: colors.text,
color: c.text,
flexShrink: 1,
fontWeight: '600',
},
accountMenuHost: {
...typography.caption,
color: colors.textMuted,
color: c.textMuted,
marginTop: 2,
},
accountMenuStatusRow: {
@@ -607,17 +615,17 @@ const styles = StyleSheet.create({
backgroundColor: '#22c55e',
},
accountMenuStatusDotOffline: {
backgroundColor: colors.textMuted,
backgroundColor: c.textMuted,
},
accountMenuStatusText: {
fontSize: 10,
lineHeight: 12,
color: colors.textMuted,
color: c.textMuted,
flexShrink: 1,
},
accountMenuDivider: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.border,
backgroundColor: c.border,
},
accountMenuAction: {
flexDirection: 'row',
@@ -627,10 +635,10 @@ const styles = StyleSheet.create({
paddingVertical: 10,
minHeight: 40,
},
accountMenuActionPressed: { backgroundColor: colors.surfaceHover },
accountMenuActionPressed: { backgroundColor: c.surfaceHover },
accountMenuActionText: {
...typography.body,
color: colors.text,
color: c.text,
flexShrink: 1,
fontSize: 13,
},
@@ -650,13 +658,13 @@ const styles = StyleSheet.create({
},
sectionHeaderText: {
...typography.bodySemibold,
color: colors.text,
color: c.text,
},
sectionSettings: { padding: 4 },
empty: {
...typography.body,
color: colors.textMuted,
color: c.textMuted,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
@@ -670,10 +678,10 @@ const styles = StyleSheet.create({
borderLeftWidth: 2,
borderLeftColor: 'transparent',
},
rowPressed: { backgroundColor: colors.surfaceHover },
rowPressed: { backgroundColor: c.surfaceHover },
rowSelected: {
backgroundColor: colors.accent,
borderLeftColor: colors.primary,
backgroundColor: c.accent,
borderLeftColor: c.primary,
},
rowIndent: {
flexDirection: 'row',
@@ -692,11 +700,11 @@ const styles = StyleSheet.create({
rowLabel: {
flex: 1,
...typography.body,
color: colors.text,
color: c.text,
},
rowLabelSelected: {
...typography.bodySemibold,
color: colors.text,
color: c.text,
},
counts: {
flexDirection: 'row',
@@ -707,16 +715,17 @@ const styles = StyleSheet.create({
countUnread: {
...typography.caption,
fontWeight: '600',
color: colors.text,
color: c.text,
},
countSep: {
...typography.caption,
color: colors.textMuted,
color: c.textMuted,
opacity: 0.6,
},
countTotal: {
...typography.caption,
color: colors.textMuted,
color: c.textMuted,
},
});
});
}
+10 -5
View File
@@ -6,7 +6,8 @@ import {
Archive, Trash2, ShieldAlert, MailOpen, Mail, Star,
type LucideIcon,
} from 'lucide-react-native';
import { colors, typography } from '../theme/tokens';
import { typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import type { SwipeAction } from '../stores/settings-store';
interface SwipeableRowProps {
@@ -44,6 +45,8 @@ function actionLabel(action: SwipeAction, context: { unread: boolean; starred: b
export function SwipeableRow({
children, leftAction, rightAction, context, onAction,
}: SwipeableRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const dx = useRef(new Animated.Value(0)).current;
const claimed = useRef(false);
const widthRef = useRef(Dimensions.get('window').width);
@@ -159,9 +162,10 @@ export function SwipeableRow({
);
}
const styles = StyleSheet.create({
wrap: { position: 'relative', backgroundColor: colors.background },
content: { backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: { position: 'relative', backgroundColor: c.background },
content: { backgroundColor: c.background },
band: {
position: 'absolute',
top: 0,
@@ -171,4 +175,5 @@ const styles = StyleSheet.create({
},
bandInner: { alignItems: 'center', gap: 4 },
bandLabel: { color: '#fff', fontWeight: '600', ...typography.caption },
});
});
}
+11 -6
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { Pressable, View, StyleSheet } from 'react-native';
import { colors, radius, componentSizes } from '../theme/tokens';
import { radius, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface ToggleSwitchProps {
value: boolean;
@@ -16,6 +17,8 @@ interface ToggleSwitchProps {
* - translate-x-6 (checked) / translate-x-1 (unchecked)
*/
export default function ToggleSwitch({ value, onValueChange, disabled = false }: ToggleSwitchProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable
style={[
@@ -37,7 +40,8 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false }:
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
track: {
width: componentSizes.toggleWidth, // w-11 = 44
height: componentSizes.toggleHeight, // h-6 = 24
@@ -45,18 +49,19 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
trackOn: {
backgroundColor: colors.primary,
backgroundColor: c.primary,
},
trackOff: {
backgroundColor: colors.muted,
backgroundColor: c.muted,
},
thumb: {
width: componentSizes.toggleThumb, // h-4 w-4 = 16
height: componentSizes.toggleThumb,
borderRadius: radius.full,
backgroundColor: colors.background,
backgroundColor: c.background,
},
disabled: {
opacity: 0.5,
},
});
});
}
+14 -9
View File
@@ -2,9 +2,12 @@ import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Download, X } from 'lucide-react-native';
import { useUpdatesStore } from '../stores/updates-store';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
export function UpdateBanner(): React.ReactElement | null {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
const installing = useUpdatesStore((s) => s.installing);
@@ -18,7 +21,7 @@ export function UpdateBanner(): React.ReactElement | null {
return (
<View style={styles.banner}>
<Download size={16} color={colors.primaryForeground} />
<Download size={16} color={c.primaryForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.title}>Update available</Text>
<Text style={styles.subtitle}>
@@ -33,29 +36,31 @@ export function UpdateBanner(): React.ReactElement | null {
<Text style={styles.installText}>{installing ? '…' : 'Install'}</Text>
</Pressable>
<Pressable style={styles.dismiss} onPress={dismissCurrent} hitSlop={8}>
<X size={14} color={colors.primaryForeground} />
<X size={14} color={c.primaryForeground} />
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
banner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: colors.primary,
backgroundColor: c.primary,
},
title: { ...typography.bodyMedium, color: colors.primaryForeground },
subtitle: { ...typography.caption, color: colors.primaryForeground, opacity: 0.85 },
title: { ...typography.bodyMedium, color: c.primaryForeground },
subtitle: { ...typography.caption, color: c.primaryForeground, opacity: 0.85 },
installButton: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: 'rgba(255,255,255,0.2)',
},
installText: { ...typography.captionMedium, color: colors.primaryForeground },
installText: { ...typography.captionMedium, color: c.primaryForeground },
dismiss: { padding: 4 },
});
});
}
+17 -12
View File
@@ -3,7 +3,8 @@ import { SectionList, StyleSheet, Text, View } from 'react-native';
import { format, isSameDay, isToday, isTomorrow } from 'date-fns';
import { CalendarDays } from 'lucide-react-native';
import type { Calendar, CalendarEvent } from '../../api/types';
import { colors, spacing, typography } from '../../theme/tokens';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
buildEventDayIndex,
eventsOnDayFromIndex,
@@ -41,6 +42,8 @@ export function AgendaView({
eventsByDay,
onSelectEvent,
}: AgendaViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events),
[eventsByDay, events],
@@ -78,7 +81,7 @@ export function AgendaView({
if (sections.length === 0) {
return (
<View style={styles.empty}>
<CalendarDays size={40} color={colors.surfaceActive} />
<CalendarDays size={40} color={c.surfaceActive} />
<Text style={styles.emptyTitle}>No upcoming events</Text>
<Text style={styles.emptySubtitle}>The next {daysAhead} days are clear.</Text>
</View>
@@ -118,7 +121,8 @@ export function AgendaView({
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
listContent: { paddingBottom: 80 },
sectionHeader: {
flexDirection: 'row',
@@ -126,16 +130,16 @@ const styles = StyleSheet.create({
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.surface,
backgroundColor: c.surface,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
sectionTitle: { ...typography.bodyMedium, color: colors.text },
sectionTitleToday: { color: colors.primary },
sectionSub: { ...typography.caption, color: colors.textMuted },
sectionTitle: { ...typography.bodyMedium, color: c.text },
sectionTitleToday: { color: c.primary },
sectionSub: { ...typography.caption, color: c.textMuted },
itemWrap: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
emptyDayWrap: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
emptyDayText: { ...typography.caption, color: colors.textMuted, textAlign: 'center' },
emptyDayText: { ...typography.caption, color: c.textMuted, textAlign: 'center' },
empty: {
flex: 1,
alignItems: 'center',
@@ -143,6 +147,7 @@ const styles = StyleSheet.create({
gap: spacing.sm,
paddingVertical: spacing.xxxl,
},
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
emptySubtitle: { ...typography.caption, color: colors.textMuted },
});
emptyTitle: { ...typography.bodyMedium, color: c.textSecondary },
emptySubtitle: { ...typography.caption, color: c.textMuted },
});
}
@@ -13,7 +13,8 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { Check, X } from 'lucide-react-native';
import type { Calendar } from '../../api/types';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { getCalendarColor } from '../../lib/calendar-utils';
interface CalendarSidebarDrawerProps {
@@ -31,6 +32,8 @@ export function CalendarSidebarDrawer({
onToggle,
onClose,
}: CalendarSidebarDrawerProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
@@ -89,7 +92,7 @@ export function CalendarSidebarDrawer({
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
<X size={20} color={colors.text} />
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>Calendars</Text>
</View>
@@ -134,15 +137,17 @@ function Section({
hiddenSet: Set<string>;
onToggle: (id: string) => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{calendars.map((c) => {
const visible = !hiddenSet.has(c.id);
{calendars.map((cal) => {
const visible = !hiddenSet.has(cal.id);
return (
<Pressable
key={c.id}
onPress={() => onToggle(c.id)}
key={cal.id}
onPress={() => onToggle(cal.id)}
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
@@ -152,15 +157,15 @@ function Section({
style={[
styles.swatch,
{
backgroundColor: visible ? getCalendarColor(c) : 'transparent',
borderColor: getCalendarColor(c),
backgroundColor: visible ? getCalendarColor(cal) : 'transparent',
borderColor: getCalendarColor(cal),
},
]}
>
{visible && <Check size={12} color={colors.textInverse} />}
{visible && <Check size={12} color={c.textInverse} />}
</View>
<Text style={[styles.rowName, !visible && styles.rowNameMuted]}>
{c.name}
{cal.name}
</Text>
</Pressable>
);
@@ -169,7 +174,8 @@ function Section({
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
@@ -184,7 +190,7 @@ const styles = StyleSheet.create({
maxWidth: 340,
backgroundColor: '#262626',
borderRightWidth: 1,
borderRightColor: colors.border,
borderRightColor: c.border,
},
drawerSafe: { flex: 1 },
@@ -195,7 +201,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
headerClose: {
width: 36,
@@ -204,14 +210,14 @@ const styles = StyleSheet.create({
justifyContent: 'center',
borderRadius: radius.sm,
},
headerTitle: { ...typography.h3, color: colors.text },
headerTitle: { ...typography.h3, color: c.text },
scroll: { flex: 1 },
scrollContent: { paddingBottom: spacing.lg },
empty: {
...typography.body,
color: colors.textMuted,
color: c.textMuted,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.lg,
},
@@ -219,7 +225,7 @@ const styles = StyleSheet.create({
section: { paddingTop: spacing.md },
sectionTitle: {
...typography.bodySemibold,
color: colors.text,
color: c.text,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xs,
},
@@ -231,7 +237,7 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
minHeight: 44,
},
rowPressed: { backgroundColor: colors.surfaceHover },
rowPressed: { backgroundColor: c.surfaceHover },
swatch: {
width: 18,
height: 18,
@@ -240,6 +246,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
rowName: { flex: 1, ...typography.body, color: colors.text },
rowNameMuted: { color: colors.textMuted },
});
rowName: { flex: 1, ...typography.body, color: c.text },
rowNameMuted: { color: c.textMuted },
});
}
+18 -13
View File
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Clock, MapPin, Users } from 'lucide-react-native';
import { format } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { eventTimeRange, getEventColor } from '../../lib/calendar-utils';
interface EventCardProps {
@@ -24,6 +25,8 @@ function formatTimeRange(event: CalendarEvent): string {
}
export function EventCard({ event, calendars, onPress, onLongPress }: EventCardProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const color = getEventColor(event, calendars);
const time = formatTimeRange(event);
const count = participantCount(event);
@@ -48,13 +51,13 @@ export function EventCard({ event, calendars, onPress, onLongPress }: EventCardP
</View>
{!event.showWithoutTime && (
<View style={styles.detailRow}>
<Clock size={12} color={colors.textMuted} />
<Clock size={12} color={c.textMuted} />
<Text style={styles.detailText}>{time}</Text>
</View>
)}
{event.description ? (
<View style={styles.detailRow}>
<MapPin size={12} color={colors.textMuted} />
<MapPin size={12} color={c.textMuted} />
<Text style={styles.detailText} numberOfLines={1}>
{event.description}
</Text>
@@ -62,7 +65,7 @@ export function EventCard({ event, calendars, onPress, onLongPress }: EventCardP
) : null}
{count > 0 && (
<View style={styles.detailRow}>
<Users size={12} color={colors.textMuted} />
<Users size={12} color={c.textMuted} />
<Text style={styles.detailText}>{count} participants</Text>
</View>
)}
@@ -71,17 +74,18 @@ export function EventCard({ event, calendars, onPress, onLongPress }: EventCardP
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
card: {
flexDirection: 'row',
backgroundColor: colors.card,
backgroundColor: c.card,
borderRadius: radius.lg,
marginBottom: spacing.sm,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
cardPressed: { backgroundColor: colors.surfaceHover },
cardPressed: { backgroundColor: c.surfaceHover },
colorBar: { width: 3 },
body: { flex: 1, padding: spacing.md, gap: 4 },
headerRow: {
@@ -90,14 +94,15 @@ const styles = StyleSheet.create({
alignItems: 'center',
gap: spacing.sm,
},
title: { ...typography.bodyMedium, color: colors.text, flex: 1 },
title: { ...typography.bodyMedium, color: c.text, flex: 1 },
allDayBadge: {
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: radius.full,
},
allDayText: { ...typography.small, color: colors.primary },
allDayText: { ...typography.small, color: c.primary },
detailRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
detailText: { ...typography.caption, color: colors.textMuted, flexShrink: 1 },
});
detailText: { ...typography.caption, color: c.textMuted, flexShrink: 1 },
});
}
+62 -40
View File
@@ -26,12 +26,14 @@ import {
} from 'lucide-react-native';
import { format } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
eventTimeRange,
getEventColor,
getPrimaryCalendarId,
} from '../../lib/calendar-utils';
import { useSheetDrag } from '../../lib/use-sheet-drag';
interface EventDetailSheetProps {
event: CalendarEvent | null;
@@ -106,9 +108,16 @@ export function EventDetailSheet({
onDelete,
onDuplicate,
}: EventDetailSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const visible = !!event;
const dragHandlers = useSheetDrag({
slideY,
closedY: Dimensions.get('window').height,
onClose,
});
React.useEffect(() => {
if (visible) {
@@ -166,17 +175,21 @@ export function EventDetailSheet({
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
<View style={styles.handle} />
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<View style={[styles.colorBar, { backgroundColor: color }]} />
<View style={styles.headerText}>
<Text style={styles.title}>{event.title || 'Untitled'}</Text>
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
<View style={styles.header}>
<View style={[styles.colorBar, { backgroundColor: color }]} />
<View style={styles.headerText}>
<Text style={styles.title}>{event.title || 'Untitled'}</Text>
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
</View>
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
<X size={20} color={c.textMuted} />
</Pressable>
</View>
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
<X size={20} color={colors.textMuted} />
</Pressable>
</View>
<ScrollView
@@ -184,29 +197,29 @@ export function EventDetailSheet({
contentContainerStyle={styles.bodyContent}
showsVerticalScrollIndicator={false}
>
<DetailRow icon={<Clock size={16} color={colors.textMuted} />} text={range} />
<DetailRow icon={<Clock size={16} color={c.textMuted} />} text={range} />
{event.description ? (
<DetailRow
icon={<AlignLeft size={16} color={colors.textMuted} />}
icon={<AlignLeft size={16} color={c.textMuted} />}
text={event.description}
multiline
/>
) : null}
{recurrence && (
<DetailRow
icon={<Repeat size={16} color={colors.textMuted} />}
icon={<Repeat size={16} color={c.textMuted} />}
text={recurrence}
/>
)}
{alert && (
<DetailRow
icon={<Bell size={16} color={colors.textMuted} />}
icon={<Bell size={16} color={c.textMuted} />}
text={alert}
/>
)}
{calendar && (
<DetailRow
icon={<CalendarIcon size={16} color={colors.textMuted} />}
icon={<CalendarIcon size={16} color={c.textMuted} />}
text={calendar.name}
/>
)}
@@ -214,7 +227,7 @@ export function EventDetailSheet({
{participants.length > 0 && (
<View style={styles.participantsBlock}>
<View style={styles.participantsHeader}>
<Users size={16} color={colors.textMuted} />
<Users size={16} color={c.textMuted} />
<Text style={styles.participantsHeaderText}>
{participants.length} participants
</Text>
@@ -244,21 +257,21 @@ export function EventDetailSheet({
<View style={styles.actions}>
{onEdit && (
<ActionButton
icon={<Pencil size={18} color={colors.text} />}
icon={<Pencil size={18} color={c.text} />}
label="Edit"
onPress={() => onEdit(event)}
/>
)}
{onDuplicate && (
<ActionButton
icon={<Copy size={18} color={colors.text} />}
icon={<Copy size={18} color={c.text} />}
label="Duplicate"
onPress={() => onDuplicate(event)}
/>
)}
{onDelete && (
<ActionButton
icon={<Trash2 size={18} color={colors.error} />}
icon={<Trash2 size={18} color={c.error} />}
label="Delete"
onPress={() => onDelete(event)}
destructive
@@ -280,6 +293,8 @@ function DetailRow({
text: string;
multiline?: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.detailRow}>
<View style={styles.detailIcon}>{icon}</View>
@@ -301,6 +316,8 @@ function ActionButton({
onPress: () => void;
destructive?: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable
onPress={onPress}
@@ -320,13 +337,13 @@ function ActionButton({
function statusColor(status?: string): string {
switch (status) {
case 'accepted':
return colors.success;
return c.success;
case 'declined':
return colors.error;
return c.error;
case 'tentative':
return colors.warning;
return c.warning;
default:
return colors.textMuted;
return c.textMuted;
}
}
@@ -345,7 +362,8 @@ function statusLabel(status?: string): string {
}
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
@@ -357,20 +375,23 @@ const styles = StyleSheet.create({
right: 0,
bottom: 0,
maxHeight: '85%',
backgroundColor: colors.background,
backgroundColor: c.background,
borderTopLeftRadius: radius.xl,
borderTopRightRadius: radius.xl,
borderTopWidth: 1,
borderTopColor: colors.border,
borderTopColor: c.border,
},
sheetSafe: { paddingTop: spacing.sm },
handleHit: {
alignItems: 'center',
paddingTop: spacing.xs,
paddingBottom: spacing.sm,
},
handle: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.surfaceActive,
marginBottom: spacing.sm,
backgroundColor: c.surfaceActive,
},
header: {
@@ -382,8 +403,8 @@ const styles = StyleSheet.create({
},
colorBar: { width: 4, alignSelf: 'stretch', borderRadius: 2 },
headerText: { flex: 1 },
title: { ...typography.h3, color: colors.text },
subtitle: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
title: { ...typography.h3, color: c.text },
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
closeBtn: { padding: 4 },
body: { maxHeight: 400 },
@@ -394,7 +415,7 @@ const styles = StyleSheet.create({
},
detailRow: { flexDirection: 'row', gap: spacing.md, alignItems: 'flex-start' },
detailIcon: { width: 16, paddingTop: 2 },
detailText: { flex: 1, ...typography.body, color: colors.text },
detailText: { flex: 1, ...typography.body, color: c.text },
participantsBlock: {
marginTop: spacing.sm,
@@ -406,7 +427,7 @@ const styles = StyleSheet.create({
gap: spacing.md,
marginBottom: spacing.xs,
},
participantsHeaderText: { ...typography.bodyMedium, color: colors.text },
participantsHeaderText: { ...typography.bodyMedium, color: c.text },
participantRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -414,13 +435,13 @@ const styles = StyleSheet.create({
paddingVertical: 4,
},
participantDot: { width: 8, height: 8, borderRadius: 4 },
participantName: { flex: 1, ...typography.body, color: colors.text },
participantStatus: { ...typography.caption, color: colors.textMuted },
participantName: { flex: 1, ...typography.body, color: c.text },
participantStatus: { ...typography.caption, color: c.textMuted },
actions: {
flexDirection: 'row',
borderTopWidth: 1,
borderTopColor: colors.border,
borderTopColor: c.border,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
},
@@ -432,7 +453,8 @@ const styles = StyleSheet.create({
gap: 4,
borderRadius: radius.sm,
},
actionBtnPressed: { backgroundColor: colors.surfaceHover },
actionLabel: { ...typography.caption, color: colors.text },
actionLabelDestructive: { color: colors.error },
});
actionBtnPressed: { backgroundColor: c.surfaceHover },
actionLabel: { ...typography.caption, color: c.text },
actionLabelDestructive: { color: c.error },
});
}
+50 -43
View File
@@ -24,7 +24,8 @@ import {
} from 'lucide-react-native';
import { addHours, addMinutes, format } from 'date-fns';
import type { Calendar, CalendarEvent, Participant } from '../../api/types';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
buildAllDayDuration,
getCalendarColor,
@@ -150,6 +151,8 @@ export function EventModal({
onDelete,
onClose,
}: EventModalProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isEdit = !!event;
const [title, setTitle] = React.useState('');
@@ -264,7 +267,7 @@ export function EventModal({
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable onPress={onClose} hitSlop={8} style={styles.headerBtn}>
<X size={20} color={colors.text} />
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>{isEdit ? 'Edit event' : 'New event'}</Text>
<Button
@@ -287,12 +290,12 @@ export function EventModal({
value={title}
onChangeText={setTitle}
placeholder="Title"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
style={styles.titleInput}
/>
<Section
icon={<CalendarIcon size={16} color={colors.textMuted} />}
icon={<CalendarIcon size={16} color={c.textMuted} />}
label="Calendar"
>
<Pressable
@@ -340,14 +343,14 @@ export function EventModal({
)}
</Section>
<Section icon={<Clock size={16} color={colors.textMuted} />} label="Time">
<Section icon={<Clock size={16} color={c.textMuted} />} label="Time">
<View style={styles.allDayRow}>
<Text style={styles.fieldText}>All-day</Text>
<Switch
value={allDay}
onValueChange={setAllDay}
thumbColor={allDay ? colors.primary : colors.textMuted}
trackColor={{ false: colors.surface, true: colors.primaryBg }}
thumbColor={allDay ? c.primary : c.textMuted}
trackColor={{ false: c.surface, true: c.primaryBg }}
/>
</View>
@@ -392,7 +395,7 @@ export function EventModal({
</View>
</Section>
<Section icon={<Repeat size={16} color={colors.textMuted} />} label="Repeat">
<Section icon={<Repeat size={16} color={c.textMuted} />} label="Repeat">
<Pressable
style={styles.fieldButton}
onPress={() => setRecurrenceOpen((v) => !v)}
@@ -420,7 +423,7 @@ export function EventModal({
)}
</Section>
<Section icon={<Bell size={16} color={colors.textMuted} />} label="Reminder">
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminder">
<Pressable
style={styles.fieldButton}
onPress={() => setAlertOpen((v) => !v)}
@@ -449,21 +452,21 @@ export function EventModal({
</Section>
<Section
icon={<MapPin size={16} color={colors.textMuted} />}
icon={<MapPin size={16} color={c.textMuted} />}
label="Description"
>
<TextInput
value={description}
onChangeText={setDescription}
placeholder="Notes, location, link…"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
multiline
style={styles.descriptionInput}
/>
</Section>
<Section
icon={<Users size={16} color={colors.textMuted} />}
icon={<Users size={16} color={c.textMuted} />}
label="Participants"
>
<ParticipantInput
@@ -480,7 +483,7 @@ export function EventModal({
]}
onPress={handleDelete}
>
<Trash2 size={16} color={colors.error} />
<Trash2 size={16} color={c.error} />
<Text style={styles.deleteBtnText}>Delete event</Text>
</Pressable>
)}
@@ -560,6 +563,8 @@ function Section({
label: string;
children: React.ReactNode;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
@@ -571,15 +576,16 @@ function Section({
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
gap: spacing.md,
},
headerBtn: {
@@ -589,23 +595,23 @@ const styles = StyleSheet.create({
justifyContent: 'center',
borderRadius: radius.full,
},
headerTitle: { flex: 1, ...typography.h3, color: colors.text },
headerTitle: { flex: 1, ...typography.h3, color: c.text },
body: { flex: 1 },
bodyContent: { padding: spacing.lg, gap: spacing.lg, paddingBottom: spacing.xxxl },
titleInput: {
...typography.h2,
color: colors.text,
color: c.text,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
paddingVertical: spacing.sm,
},
section: { gap: spacing.sm },
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
sectionIcon: { width: 16 },
sectionLabel: { ...typography.bodyMedium, color: colors.textSecondary },
sectionLabel: { ...typography.bodyMedium, color: c.textSecondary },
sectionBody: { gap: spacing.sm },
fieldButton: {
@@ -614,20 +620,20 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
borderColor: c.border,
backgroundColor: c.surface,
justifyContent: 'center',
},
fieldText: { ...typography.body, color: colors.text },
fieldText: { ...typography.body, color: c.text },
calendarRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
calendarSwatch: { width: 12, height: 12, borderRadius: 6 },
popover: {
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: colors.surface,
backgroundColor: c.surface,
overflow: 'hidden',
},
popoverRow: {
@@ -637,10 +643,10 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
borderBottomColor: c.borderLight,
},
popoverRowActive: { backgroundColor: colors.primaryBg },
popoverRowText: { ...typography.body, color: colors.text },
popoverRowActive: { backgroundColor: c.primaryBg },
popoverRowText: { ...typography.body, color: c.text },
allDayRow: {
flexDirection: 'row',
@@ -649,9 +655,9 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
backgroundColor: colors.surface,
backgroundColor: c.surface,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
dateRow: {
flexDirection: 'row',
@@ -660,20 +666,20 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
borderColor: c.border,
backgroundColor: c.surface,
},
dateLabel: { ...typography.body, color: colors.textSecondary, flex: 1 },
dateLabel: { ...typography.body, color: c.textSecondary, flex: 1 },
datePickers: { flexDirection: 'row', gap: spacing.sm },
dateBtn: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.xs,
backgroundColor: colors.background,
backgroundColor: c.background,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
dateText: { ...typography.body, color: colors.text },
dateText: { ...typography.body, color: c.text },
descriptionInput: {
minHeight: 80,
@@ -681,9 +687,9 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
color: colors.text,
borderColor: c.border,
backgroundColor: c.surface,
color: c.text,
...typography.body,
textAlignVertical: 'top',
},
@@ -696,9 +702,10 @@ const styles = StyleSheet.create({
paddingVertical: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.errorBorder,
borderColor: c.errorBorder,
marginTop: spacing.lg,
},
deleteBtnPressed: { backgroundColor: colors.errorBg },
deleteBtnText: { ...typography.bodyMedium, color: colors.error },
});
deleteBtnPressed: { backgroundColor: c.errorBg },
deleteBtnText: { ...typography.bodyMedium, color: c.error },
});
}
+16 -11
View File
@@ -12,7 +12,8 @@ import {
startOfWeek,
} from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { colors, componentSizes, radius, spacing, typography } from '../../theme/tokens';
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
buildEventDayIndex,
eventsOnDayFromIndex,
@@ -41,6 +42,8 @@ function MonthViewInner({
onSelectDate,
onLongPressDate,
}: MonthViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events),
[eventsByDay, events],
@@ -120,14 +123,15 @@ function MonthViewInner({
export const MonthView = React.memo(MonthViewInner);
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
grid: { paddingHorizontal: spacing.sm },
weekdayRow: { flexDirection: 'row', paddingHorizontal: spacing.xs, marginBottom: 4 },
weekdayLabel: {
flex: 1,
textAlign: 'center',
...typography.small,
color: colors.textMuted,
color: c.textMuted,
},
daysGrid: { flexDirection: 'row', flexWrap: 'wrap' },
dayCell: { width: '14.28%', alignItems: 'center', paddingVertical: 4 },
@@ -138,12 +142,12 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
dayText: { ...typography.body, color: colors.text },
dayTextMuted: { color: colors.textMuted },
todayCircle: { backgroundColor: colors.primary },
todayText: { color: colors.textInverse, fontWeight: '700' },
selectedCircle: { backgroundColor: colors.primaryBg, borderWidth: 1.5, borderColor: colors.primary },
selectedText: { color: colors.primary, fontWeight: '600' },
dayText: { ...typography.body, color: c.text },
dayTextMuted: { color: c.textMuted },
todayCircle: { backgroundColor: c.primary },
todayText: { color: c.textInverse, fontWeight: '700' },
selectedCircle: { backgroundColor: c.primaryBg, borderWidth: 1.5, borderColor: c.primary },
selectedText: { color: c.primary, fontWeight: '600' },
dotsRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -160,9 +164,10 @@ const styles = StyleSheet.create({
borderRadius: componentSizes.eventDot / 2,
},
overflowText: {
color: colors.textMuted,
color: c.textMuted,
fontSize: 9,
lineHeight: 10,
marginLeft: 1,
},
});
});
}
+21 -16
View File
@@ -8,7 +8,8 @@ import {
ScrollView,
} from 'react-native';
import { X } from 'lucide-react-native';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useContactsStore } from '../../stores/contacts-store';
import type { Participant } from '../../api/types';
@@ -44,6 +45,8 @@ function flattenContactEmails(): Suggestion[] {
}
export function ParticipantInput({ participants, onChange }: ParticipantInputProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [draft, setDraft] = React.useState('');
const [allSuggestions] = React.useState(() => flattenContactEmails());
@@ -110,7 +113,7 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
{p.name || p.email || 'Unknown'}
</Text>
<Pressable onPress={() => removeParticipant(id)} hitSlop={6}>
<X size={12} color={colors.textMuted} />
<X size={12} color={c.textMuted} />
</Pressable>
</View>
))}
@@ -121,7 +124,7 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
value={draft}
onChangeText={setDraft}
placeholder="Add participant by email"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
@@ -155,45 +158,47 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.sm },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: colors.surface,
backgroundColor: c.surface,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.full,
paddingHorizontal: spacing.md,
paddingVertical: 4,
maxWidth: '100%',
},
chipText: { ...typography.caption, color: colors.text, maxWidth: 200 },
chipText: { ...typography.caption, color: c.text, maxWidth: 200 },
input: {
height: 40,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
color: colors.text,
color: c.text,
...typography.body,
},
suggestions: {
maxHeight: 180,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: colors.surface,
backgroundColor: c.surface,
},
suggestionRow: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
borderBottomColor: c.borderLight,
},
suggestionRowPressed: { backgroundColor: colors.surfaceHover },
suggestionName: { ...typography.bodyMedium, color: colors.text },
suggestionEmail: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
});
suggestionRowPressed: { backgroundColor: c.surfaceHover },
suggestionName: { ...typography.bodyMedium, color: c.text },
suggestionEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 },
});
}
@@ -1,7 +1,8 @@
import React from 'react';
import { Modal, View, Text, Pressable, StyleSheet } from 'react-native';
import { Repeat, Trash2 } from 'lucide-react-native';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button } from '..';
export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all';
@@ -25,6 +26,8 @@ export function RecurrenceScopeDialog({
onSelect,
onClose,
}: RecurrenceScopeDialogProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [selected, setSelected] = React.useState<RecurrenceEditScope>('this');
const isDelete = actionType === 'delete';
@@ -39,9 +42,9 @@ export function RecurrenceScopeDialog({
<View style={styles.header}>
<View style={[styles.iconBadge, isDelete ? styles.iconBadgeDanger : styles.iconBadgePrimary]}>
{isDelete ? (
<Trash2 size={20} color={colors.error} />
<Trash2 size={20} color={c.error} />
) : (
<Repeat size={20} color={colors.primary} />
<Repeat size={20} color={c.primary} />
)}
</View>
<View style={styles.headerText}>
@@ -90,7 +93,8 @@ export function RecurrenceScopeDialog({
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
@@ -101,9 +105,9 @@ const styles = StyleSheet.create({
dialog: {
width: '100%',
maxWidth: 400,
backgroundColor: colors.background,
backgroundColor: c.background,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.lg,
padding: spacing.xxl,
gap: spacing.lg,
@@ -116,11 +120,11 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
iconBadgeDanger: { backgroundColor: colors.errorBg },
iconBadgePrimary: { backgroundColor: colors.primaryBg },
iconBadgeDanger: { backgroundColor: c.errorBg },
iconBadgePrimary: { backgroundColor: c.primaryBg },
headerText: { flex: 1 },
title: { ...typography.h3, color: colors.text },
description: { ...typography.body, color: colors.textMuted, marginTop: 4 },
title: { ...typography.h3, color: c.text },
description: { ...typography.body, color: c.textMuted, marginTop: 4 },
options: { gap: spacing.xs },
option: {
flexDirection: 'row',
@@ -132,27 +136,28 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: 'transparent',
},
optionActive: { backgroundColor: colors.primaryBg, borderColor: colors.primary },
optionActive: { backgroundColor: c.primaryBg, borderColor: c.primary },
radio: {
width: 18,
height: 18,
borderRadius: 9,
borderWidth: 1.5,
borderColor: colors.border,
borderColor: c.border,
alignItems: 'center',
justifyContent: 'center',
},
radioActive: { borderColor: colors.primary },
radioActive: { borderColor: c.primary },
radioDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.primary,
backgroundColor: c.primary,
},
optionLabel: { ...typography.body, color: colors.text, flex: 1 },
optionLabel: { ...typography.body, color: c.text, flex: 1 },
footer: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.md,
},
});
});
}
+28 -23
View File
@@ -8,7 +8,8 @@ import {
} from 'react-native';
import { addDays, format, isSameDay, isToday, startOfWeek } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
buildEventDayIndex,
buildTimedFullDayWeekSegments,
@@ -49,6 +50,8 @@ function WeekViewInner({
onCreateAtTime,
weekStartsOn = 0,
}: WeekViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const scrollRef = React.useRef<ScrollView>(null);
const index = React.useMemo(
@@ -311,12 +314,13 @@ function minutesToTimeLabel(minutes: number): string {
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
headerRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
gutter: { width: GUTTER_WIDTH },
dayHeaders: { flex: 1, flexDirection: 'row' },
@@ -328,7 +332,7 @@ const styles = StyleSheet.create({
},
dayHeaderWeekday: {
...typography.small,
color: colors.textMuted,
color: c.textMuted,
textTransform: 'uppercase',
},
dayHeaderNumber: {
@@ -338,29 +342,29 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
dayHeaderNumberToday: { backgroundColor: colors.primary },
dayHeaderNumberToday: { backgroundColor: c.primary },
dayHeaderNumberSelected: {
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
borderWidth: 1,
borderColor: colors.primary,
borderColor: c.primary,
},
dayHeaderNumberText: { ...typography.bodyMedium, color: colors.text },
dayHeaderNumberTextToday: { color: colors.textInverse, fontWeight: '700' },
dayHeaderNumberTextSelected: { color: colors.primary, fontWeight: '600' },
dayHeaderNumberText: { ...typography.bodyMedium, color: c.text },
dayHeaderNumberTextToday: { color: c.textInverse, fontWeight: '700' },
dayHeaderNumberTextSelected: { color: c.primary, fontWeight: '600' },
allDayStrip: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
paddingVertical: 2,
},
allDayLabelWrap: { alignItems: 'flex-end', justifyContent: 'flex-start', paddingRight: 4 },
allDayLabel: { ...typography.small, color: colors.textMuted },
allDayLabel: { ...typography.small, color: c.textMuted },
allDayGrid: { flex: 1, flexDirection: 'row', position: 'relative' },
allDayCol: {
flex: 1,
borderLeftWidth: 1,
borderLeftColor: colors.borderLight,
borderLeftColor: c.borderLight,
},
allDayOverlay: {
position: 'absolute',
@@ -376,7 +380,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 4,
justifyContent: 'center',
},
allDayChipText: { fontSize: 10, color: colors.text, fontWeight: '500' },
allDayChipText: { fontSize: 10, color: c.text, fontWeight: '500' },
scroll: { flex: 1 },
scrollContent: {},
@@ -385,19 +389,19 @@ const styles = StyleSheet.create({
hourLabelCell: { paddingRight: 4, alignItems: 'flex-end' },
hourLabel: {
...typography.small,
color: colors.textMuted,
color: c.textMuted,
transform: [{ translateY: -6 }],
},
dayCols: { flex: 1, flexDirection: 'row' },
dayCol: {
flex: 1,
borderLeftWidth: 1,
borderLeftColor: colors.border,
borderLeftColor: c.border,
position: 'relative',
},
hourSlot: {
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
borderBottomColor: c.borderLight,
},
eventBlock: {
position: 'absolute',
@@ -407,8 +411,8 @@ const styles = StyleSheet.create({
paddingVertical: 2,
overflow: 'hidden',
},
eventBlockTitle: { fontSize: 10, color: colors.text, fontWeight: '600' },
eventBlockTime: { fontSize: 9, color: colors.textMuted, marginTop: 1 },
eventBlockTitle: { fontSize: 10, color: c.text, fontWeight: '600' },
eventBlockTime: { fontSize: 9, color: c.textMuted, marginTop: 1 },
nowLine: {
position: 'absolute',
@@ -421,7 +425,8 @@ const styles = StyleSheet.create({
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: colors.error,
backgroundColor: c.error,
},
nowBar: { flex: 1, height: 1, backgroundColor: colors.error },
});
nowBar: { flex: 1, height: 1, backgroundColor: c.error },
});
}
+23 -18
View File
@@ -9,7 +9,8 @@ import type { RootStackParamList } from '../../navigation/types';
import { getEmails, queryEmailsByFilter } from '../../api/email';
import { useCalendarStore } from '../../stores/calendar-store';
import SenderAvatar from '../SenderAvatar';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
const EMAIL_LIMIT = 5;
const EVENT_LIMIT = 5;
@@ -82,6 +83,8 @@ interface Props {
}
export function ContactActivity({ contact }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
const calendarEvents = useCalendarStore((s) => s.events);
@@ -142,12 +145,12 @@ export function ContactActivity({ contact }: Props) {
<View style={styles.wrap}>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<MailIcon size={14} color={colors.primary} />
<MailIcon size={14} color={c.primary} />
<Text style={styles.sectionTitle}>Recent emails</Text>
</View>
{emailsLoading ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={colors.primary} />
<ActivityIndicator size="small" color={c.primary} />
</View>
) : emailsError ? (
<Text style={styles.emptyText}>Couldn't load emails.</Text>
@@ -190,7 +193,7 @@ export function ContactActivity({ contact }: Props) {
<View style={styles.section}>
<View style={styles.sectionHeader}>
<CalendarDays size={14} color={colors.primary} />
<CalendarDays size={14} color={c.primary} />
<Text style={styles.sectionTitle}>Upcoming events</Text>
</View>
{upcomingEvents.length === 0 ? (
@@ -215,21 +218,22 @@ export function ContactActivity({ contact }: Props) {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: { gap: spacing.md, paddingHorizontal: spacing.lg, marginBottom: spacing.lg },
section: {
backgroundColor: colors.card,
backgroundColor: c.card,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.sm,
borderLeftWidth: 3,
borderLeftColor: colors.primary,
borderLeftColor: c.primary,
},
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
sectionTitle: { ...typography.bodySemibold, color: colors.text },
sectionTitle: { ...typography.bodySemibold, color: c.text },
loading: { paddingVertical: spacing.sm, alignItems: 'flex-start' },
emptyText: { ...typography.caption, color: colors.textMuted },
emptyText: { ...typography.caption, color: c.textMuted },
row: {
flexDirection: 'row',
@@ -238,21 +242,22 @@ const styles = StyleSheet.create({
paddingVertical: 6,
borderRadius: radius.xs,
},
rowPressed: { backgroundColor: colors.surface },
rowPressed: { backgroundColor: c.surface },
rowContent: { flex: 1, minWidth: 0 },
rowTop: { flexDirection: 'row', justifyContent: 'space-between', gap: spacing.sm },
rowSender: { ...typography.bodyMedium, color: colors.text, flex: 1 },
rowDate: { ...typography.caption, color: colors.textMuted },
rowSubject: { ...typography.body, color: colors.text },
rowPreview: { ...typography.caption, color: colors.textMuted },
rowSender: { ...typography.bodyMedium, color: c.text, flex: 1 },
rowDate: { ...typography.caption, color: c.textMuted },
rowSubject: { ...typography.body, color: c.text },
rowPreview: { ...typography.caption, color: c.textMuted },
eventRow: { flexDirection: 'row', gap: spacing.sm, alignItems: 'baseline', paddingVertical: 4 },
eventTime: {
...typography.caption,
color: colors.textMuted,
color: c.textMuted,
width: 64,
},
eventInfo: { flex: 1, minWidth: 0 },
eventTitle: { ...typography.body, color: colors.text },
eventDate: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
});
eventTitle: { ...typography.body, color: c.text },
eventDate: { ...typography.caption, color: c.textMuted, marginTop: 2 },
});
}
+20 -15
View File
@@ -10,7 +10,8 @@ import {
isGroup,
} from '../../lib/contact-utils';
import SenderAvatar from '../SenderAvatar';
import { colors, spacing, radius, typography, componentSizes } from '../../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface ContactListRowProps {
contact: ContactCard;
@@ -29,6 +30,8 @@ export default function ContactListRow({
selectionMode,
secondaryText,
}: ContactListRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const name = getContactDisplayName(contact) || 'Unnamed';
const email = getContactPrimaryEmail(contact);
const org = getPrimaryOrg(contact);
@@ -50,7 +53,7 @@ export default function ContactListRow({
>
{selectionMode && (
<View style={[styles.checkbox, selected && styles.checkboxSelected]}>
{selected && <Check size={14} color={colors.primaryForeground} />}
{selected && <Check size={14} color={c.primaryForeground} />}
</View>
)}
@@ -58,7 +61,7 @@ export default function ContactListRow({
<Image source={{ uri: photoUri }} style={styles.avatarImage} />
) : group ? (
<View style={styles.groupAvatar}>
<Users size={18} color={colors.primary} />
<Users size={18} color={c.primary} />
</View>
) : (
<SenderAvatar name={name} email={email} size={componentSizes.avatarMd} />
@@ -77,7 +80,8 @@ export default function ContactListRow({
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
@@ -85,37 +89,38 @@ const styles = StyleSheet.create({
paddingVertical: spacing.md,
gap: spacing.md,
},
rowPressed: { backgroundColor: colors.surfaceHover },
rowSelected: { backgroundColor: colors.primaryBg },
rowPressed: { backgroundColor: c.surfaceHover },
rowSelected: { backgroundColor: c.primaryBg },
checkbox: {
width: 22,
height: 22,
borderRadius: radius.xs,
borderWidth: 2,
borderColor: colors.border,
borderColor: c.border,
alignItems: 'center',
justifyContent: 'center',
},
checkboxSelected: {
backgroundColor: colors.primary,
borderColor: colors.primary,
backgroundColor: c.primary,
borderColor: c.primary,
},
avatarImage: {
width: componentSizes.avatarMd,
height: componentSizes.avatarMd,
borderRadius: componentSizes.avatarMd / 2,
backgroundColor: colors.surface,
backgroundColor: c.surface,
},
groupAvatar: {
width: componentSizes.avatarMd,
height: componentSizes.avatarMd,
borderRadius: componentSizes.avatarMd / 2,
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
alignItems: 'center',
justifyContent: 'center',
},
info: { flex: 1, minWidth: 0 },
name: { ...typography.bodyMedium, color: colors.text },
subtitle: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
tertiary: { ...typography.small, color: colors.textSecondary, marginTop: 1 },
});
name: { ...typography.bodyMedium, color: c.text },
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 1 },
tertiary: { ...typography.small, color: c.textSecondary, marginTop: 1 },
});
}
+52 -34
View File
@@ -12,7 +12,9 @@ import {
} from '../../stores/contacts-store';
import { matchesContactSearch, isGroup } from '../../lib/contact-utils';
import ContactListRow from './ContactListRow';
import { colors, spacing, radius, typography, componentSizes } from '../../theme/tokens';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface Props {
visible: boolean;
@@ -26,9 +28,11 @@ interface Props {
export default function ContactPickerSheet({
visible, onClose, onSelect, title = 'Add Contact', excludedIds, multi = true,
}: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const allContacts = useContactsStore((s) => s.contacts);
const sorted = React.useMemo(
() => sortContactsByDisplayName(allContacts.filter((c) => !isGroup(c))),
() => sortContactsByDisplayName(allContacts.filter((contact) => !isGroup(contact))),
[allContacts],
);
const [query, setQuery] = React.useState('');
@@ -36,6 +40,11 @@ export default function ContactPickerSheet({
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
const overlay = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({
slideY,
closedY: Dimensions.get('window').height,
onClose,
});
React.useEffect(() => {
if (visible) {
@@ -90,37 +99,41 @@ export default function ContactPickerSheet({
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView style={{ flex: 1 }} edges={['bottom']}>
<View style={styles.handle} />
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerBtn} hitSlop={8}>
<X size={20} color={colors.text} />
</Pressable>
<Text style={styles.headerTitle}>{title}</Text>
{multi && (
<Pressable
onPress={confirm}
disabled={selected.size === 0}
style={[styles.doneBtn, selected.size === 0 && styles.doneBtnDisabled]}
hitSlop={8}
>
<Text style={styles.doneLabel}>Add{selected.size > 0 ? ` (${selected.size})` : ''}</Text>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerBtn} hitSlop={8}>
<X size={20} color={c.text} />
</Pressable>
)}
<Text style={styles.headerTitle}>{title}</Text>
{multi && (
<Pressable
onPress={confirm}
disabled={selected.size === 0}
style={[styles.doneBtn, selected.size === 0 && styles.doneBtnDisabled]}
hitSlop={8}
>
<Text style={styles.doneLabel}>Add{selected.size > 0 ? ` (${selected.size})` : ''}</Text>
</Pressable>
)}
</View>
</View>
<View style={styles.searchBar}>
<Search size={16} color={colors.textMuted} />
<Search size={16} color={c.textMuted} />
<TextInput
style={styles.searchInput}
placeholder="Search contacts..."
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
value={query}
onChangeText={setQuery}
autoFocus
/>
{query.length > 0 && (
<Pressable onPress={() => setQuery('')} hitSlop={8}>
<X size={16} color={colors.textMuted} />
<X size={16} color={c.textMuted} />
</Pressable>
)}
</View>
@@ -150,24 +163,28 @@ export default function ContactPickerSheet({
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
overlayPress: { flex: 1 },
sheet: {
position: 'absolute',
left: 0, right: 0, bottom: 0,
top: '15%',
backgroundColor: colors.background,
backgroundColor: c.background,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
},
handleHit: {
alignItems: 'center',
paddingTop: spacing.sm,
paddingBottom: spacing.xs,
},
handle: {
alignSelf: 'center',
width: 40,
height: 4,
borderRadius: 2,
backgroundColor: colors.border,
marginTop: spacing.sm,
backgroundColor: c.border,
},
header: {
flexDirection: 'row',
@@ -176,23 +193,23 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
gap: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
borderBottomColor: c.borderLight,
},
headerBtn: {
width: 40, height: 40,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.full,
},
headerTitle: { ...typography.h3, color: colors.text, flex: 1, marginLeft: spacing.xs },
headerTitle: { ...typography.h3, color: c.text, flex: 1, marginLeft: spacing.xs },
doneBtn: {
paddingHorizontal: spacing.md,
height: 36,
justifyContent: 'center',
backgroundColor: colors.primary,
backgroundColor: c.primary,
borderRadius: radius.full,
},
doneBtnDisabled: { opacity: 0.5 },
doneLabel: { ...typography.bodyMedium, color: colors.primaryForeground },
doneLabel: { ...typography.bodyMedium, color: c.primaryForeground },
searchBar: {
flexDirection: 'row',
@@ -202,14 +219,15 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
height: componentSizes.inputHeight,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.sm,
gap: spacing.sm,
},
searchInput: { flex: 1, ...typography.body, color: colors.text, paddingVertical: 0 },
searchInput: { flex: 1, ...typography.body, color: c.text, paddingVertical: 0 },
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
separator: { height: 1, backgroundColor: c.borderLight, marginLeft: 68 },
empty: { paddingVertical: spacing.xxxl, alignItems: 'center' },
emptyText: { ...typography.body, color: colors.textMuted },
});
emptyText: { ...typography.body, color: c.textMuted },
});
}
@@ -9,7 +9,8 @@ import {
import type { ContactCategory } from '../../stores/contacts-store';
import { useContactsStore } from '../../stores/contacts-store';
import { getContactDisplayName, getContactKeywords, isGroup } from '../../lib/contact-utils';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface Props {
visible: boolean;
@@ -25,6 +26,8 @@ function isSameCategory(a: ContactCategory, b: ContactCategory): boolean {
}
export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const selectedCategory = useContactsStore((s) => s.selectedCategory);
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
const contacts = useContactsStore((s) => s.contacts);
@@ -93,14 +96,14 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
<X size={20} color={colors.text} />
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>Contacts</Text>
</View>
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
<CategoryRow
icon={<Inbox size={16} color={colors.primary} />}
icon={<Inbox size={16} color={c.primary} />}
label="All Contacts"
count={totalCount}
active={selectedCategory.type === 'all'}
@@ -115,7 +118,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
{expanded.books && books.map((book) => (
<CategoryRow
key={book.id}
icon={<BookUser size={16} color={colors.textSecondary} />}
icon={<BookUser size={16} color={c.textSecondary} />}
label={book.name}
count={book.count}
active={isSameCategory(selectedCategory, { type: 'addressBook', addressBookId: book.id })}
@@ -124,7 +127,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
))}
{expanded.books && (
<CategoryRow
icon={<BookUser size={16} color={colors.textMuted} />}
icon={<BookUser size={16} color={c.textMuted} />}
label="Uncategorized"
count={0}
active={selectedCategory.type === 'uncategorized'}
@@ -142,7 +145,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
{expanded.groups && groups.map((g) => (
<CategoryRow
key={g.id}
icon={<Users size={16} color={colors.textSecondary} />}
icon={<Users size={16} color={c.textSecondary} />}
label={getContactDisplayName(g) || 'Group'}
count={g.members ? Object.keys(g.members).length : 0}
active={isSameCategory(selectedCategory, { type: 'group', groupId: g.id })}
@@ -160,7 +163,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
{expanded.tags && keywords.map((kw) => (
<CategoryRow
key={kw.keyword}
icon={<Tag size={16} color={colors.textSecondary} />}
icon={<Tag size={16} color={c.textSecondary} />}
label={kw.keyword}
count={kw.count}
active={isSameCategory(selectedCategory, { type: 'keyword', keyword: kw.keyword })}
@@ -175,12 +178,14 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
}
function SectionHeader({ label, expanded, onPress }: { label: string; expanded: boolean; onPress: () => void }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable style={styles.sectionHeader} onPress={onPress}>
{expanded ? (
<ChevronDown size={14} color={colors.textMuted} />
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={colors.textMuted} />
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{label}</Text>
</Pressable>
@@ -196,6 +201,8 @@ function CategoryRow({
active: boolean;
onPress: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable
onPress={onPress}
@@ -212,7 +219,8 @@ function CategoryRow({
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
overlayPress: { flex: 1 },
drawer: {
@@ -222,7 +230,7 @@ const styles = StyleSheet.create({
maxWidth: 340,
backgroundColor: '#262626',
borderRightWidth: 1,
borderRightColor: colors.border,
borderRightColor: c.border,
},
drawerSafe: { flex: 1 },
@@ -233,14 +241,14 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
headerClose: {
width: 36, height: 36,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.sm,
},
headerTitle: { ...typography.h3, color: colors.text },
headerTitle: { ...typography.h3, color: c.text },
scroll: { flex: 1 },
scrollContent: { paddingBottom: spacing.md },
@@ -253,7 +261,7 @@ const styles = StyleSheet.create({
paddingTop: spacing.md,
paddingBottom: 4,
},
sectionHeaderText: { ...typography.bodySemibold, color: colors.text },
sectionHeaderText: { ...typography.bodySemibold, color: c.text },
row: {
flexDirection: 'row',
@@ -263,14 +271,15 @@ const styles = StyleSheet.create({
borderLeftWidth: 2,
borderLeftColor: 'transparent',
},
rowPressed: { backgroundColor: colors.surfaceHover },
rowActive: { backgroundColor: colors.accent, borderLeftColor: colors.primary },
rowPressed: { backgroundColor: c.surfaceHover },
rowActive: { backgroundColor: c.accent, borderLeftColor: c.primary },
rowIcon: {
width: 20, height: 20,
alignItems: 'center', justifyContent: 'center',
marginRight: spacing.sm,
},
rowLabel: { flex: 1, ...typography.body, color: colors.text },
rowLabelActive: { ...typography.bodySemibold, color: colors.text },
rowCount: { ...typography.caption, color: colors.textMuted, marginLeft: spacing.sm },
});
rowLabel: { flex: 1, ...typography.body, color: c.text },
rowLabelActive: { ...typography.bodySemibold, color: c.text },
rowCount: { ...typography.caption, color: c.textMuted, marginLeft: spacing.sm },
});
}
+66 -57
View File
@@ -1,19 +1,22 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Plus, X } from 'lucide-react-native';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
export type CategoryAccent = 'contact' | 'work' | 'location' | 'personal' | 'digital' | 'calendar' | 'notes';
const ACCENT_COLORS: Record<CategoryAccent, string> = {
contact: colors.primary,
work: colors.calendar.orange,
location: colors.calendar.green,
personal: colors.calendar.pink,
digital: colors.calendar.teal,
calendar: colors.calendar.indigo,
notes: colors.calendar.purple,
};
function accentColor(category: CategoryAccent, c: ThemePalette): string {
switch (category) {
case 'contact': return c.primary;
case 'work': return c.calendar.orange;
case 'location': return c.calendar.green;
case 'personal': return c.calendar.pink;
case 'digital': return c.calendar.teal;
case 'calendar': return c.calendar.indigo;
case 'notes': return c.calendar.purple;
}
}
interface FieldBlockProps {
title: string;
@@ -27,7 +30,9 @@ interface FieldBlockProps {
export default function FieldBlock({
title, category = 'contact', icon, onAdd, addLabel, children,
}: FieldBlockProps) {
const accent = ACCENT_COLORS[category];
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const accent = accentColor(category, c);
return (
<View style={[styles.block, { borderLeftColor: accent }]}>
<View style={styles.header}>
@@ -36,7 +41,7 @@ export default function FieldBlock({
<View style={{ flex: 1 }} />
{onAdd && (
<Pressable onPress={onAdd} hitSlop={8} style={styles.addBtn}>
<Plus size={14} color={colors.primary} />
<Plus size={14} color={c.primary} />
{!!addLabel && <Text style={styles.addLabel}>{addLabel}</Text>}
</Pressable>
)}
@@ -52,59 +57,63 @@ interface FieldRowProps {
}
export function FieldRow({ onRemove, children }: FieldRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.row}>
<View style={{ flex: 1 }}>{children}</View>
{onRemove && (
<Pressable onPress={onRemove} hitSlop={8} style={styles.removeBtn}>
<X size={14} color={colors.textMuted} />
<X size={14} color={c.textMuted} />
</Pressable>
)}
</View>
);
}
const styles = StyleSheet.create({
block: {
marginHorizontal: spacing.lg,
marginBottom: spacing.md,
paddingLeft: spacing.md,
paddingRight: spacing.sm,
paddingVertical: spacing.sm,
borderLeftWidth: 3,
borderRadius: radius.sm,
backgroundColor: colors.card,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginBottom: spacing.sm,
},
title: { ...typography.bodySemibold, color: colors.text },
addBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
backgroundColor: colors.primaryBg,
},
addLabel: { ...typography.caption, color: colors.primary },
body: { gap: spacing.sm },
row: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
},
removeBtn: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.full,
backgroundColor: colors.surface,
marginTop: 4,
},
});
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
block: {
marginHorizontal: spacing.lg,
marginBottom: spacing.md,
paddingLeft: spacing.md,
paddingRight: spacing.sm,
paddingVertical: spacing.sm,
borderLeftWidth: 3,
borderRadius: radius.sm,
backgroundColor: c.card,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginBottom: spacing.sm,
},
title: { ...typography.bodySemibold, color: c.text },
addBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
addLabel: { ...typography.caption, color: c.primary },
body: { gap: spacing.sm },
row: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
},
removeBtn: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.full,
backgroundColor: c.surface,
marginTop: 4,
},
});
}
+18 -13
View File
@@ -4,7 +4,8 @@ import Constants from 'expo-constants';
import { ExternalLink } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
@@ -18,6 +19,8 @@ const DEBUG_CATEGORIES = [
];
export function AboutDataSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
const hydrated = useSettingsStore((s) => s.hydrated);
@@ -64,7 +67,7 @@ export function AboutDataSettings() {
onPress={() => Linking.openURL('https://github.com/bulwarkmail/webmail')}
>
<Text style={styles.ghText}>GitHub</Text>
<ExternalLink size={12} color={colors.mutedForeground} />
<ExternalLink size={12} color={c.mutedForeground} />
</Pressable>
</View>
</View>
@@ -122,14 +125,15 @@ export function AboutDataSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
aboutBox: {
padding: spacing.xl,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.card,
borderColor: c.border,
backgroundColor: c.card,
},
aboutRow: {
flexDirection: 'row',
@@ -140,30 +144,31 @@ const styles = StyleSheet.create({
width: 48,
height: 48,
borderRadius: radius.md,
backgroundColor: colors.primary,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
},
logoText: { fontSize: 24, fontWeight: '700', color: colors.primaryForeground },
aboutTitle: { ...typography.bodyMedium, color: colors.text },
aboutVersion: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
aboutCommit: { color: colors.mutedForeground, opacity: 0.6 },
logoText: { fontSize: 24, fontWeight: '700', color: c.primaryForeground },
aboutTitle: { ...typography.bodyMedium, color: c.text },
aboutVersion: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
aboutCommit: { color: c.mutedForeground, opacity: 0.6 },
ghLink: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
ghText: { ...typography.caption, color: colors.mutedForeground },
ghText: { ...typography.caption, color: c.mutedForeground },
debugCategoriesBox: {
marginLeft: spacing.lg,
paddingLeft: spacing.lg,
borderLeftWidth: 2,
borderLeftColor: colors.muted,
borderLeftColor: c.muted,
gap: 4,
},
debugCategoriesHint: {
...typography.caption,
color: colors.mutedForeground,
color: c.mutedForeground,
marginBottom: spacing.sm,
},
});
}
@@ -4,7 +4,8 @@ import { Key, Smartphone, Lock, Eye, EyeOff, Shield, Monitor, Trash2 } from 'luc
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Input from '../Input';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface Session {
id: string;
@@ -19,6 +20,8 @@ const MOCK_SESSIONS: Session[] = [
];
export function AccountSecuritySettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [currentPwd, setCurrentPwd] = useState('');
const [newPwd, setNewPwd] = useState('');
const [confirmPwd, setConfirmPwd] = useState('');
@@ -42,9 +45,9 @@ export function AccountSecuritySettings() {
/>
<Pressable style={styles.eyeBtn} onPress={() => setShowCurrent((v) => !v)}>
{showCurrent ? (
<EyeOff size={16} color={colors.mutedForeground} />
<EyeOff size={16} color={c.mutedForeground} />
) : (
<Eye size={16} color={colors.mutedForeground} />
<Eye size={16} color={c.mutedForeground} />
)}
</Pressable>
</View>
@@ -61,9 +64,9 @@ export function AccountSecuritySettings() {
/>
<Pressable style={styles.eyeBtn} onPress={() => setShowNew((v) => !v)}>
{showNew ? (
<EyeOff size={16} color={colors.mutedForeground} />
<EyeOff size={16} color={c.mutedForeground} />
) : (
<Eye size={16} color={colors.mutedForeground} />
<Eye size={16} color={c.mutedForeground} />
)}
</Pressable>
</View>
@@ -79,7 +82,7 @@ export function AccountSecuritySettings() {
</View>
<View style={{ alignItems: 'flex-end' }}>
<Button size="sm" icon={<Key size={14} color={colors.primaryForeground} />}>
<Button size="sm" icon={<Key size={14} color={c.primaryForeground} />}>
Change Password
</Button>
</View>
@@ -93,7 +96,7 @@ export function AccountSecuritySettings() {
{twoFactor && (
<View style={styles.infoBox}>
<Smartphone size={16} color={colors.primary} />
<Smartphone size={16} color={c.primary} />
<Text style={styles.infoText}>
Scan the QR code in your authenticator app to finish setup.
</Text>
@@ -104,7 +107,7 @@ export function AccountSecuritySettings() {
<SettingsSection title="App Passwords" description="Generate passwords for other mail clients.">
{appPwds.length === 0 ? (
<View style={styles.empty}>
<Lock size={24} color={colors.mutedForeground} />
<Lock size={24} color={c.mutedForeground} />
<Text style={styles.emptyText}>No app passwords yet.</Text>
</View>
) : null}
@@ -119,7 +122,7 @@ export function AccountSecuritySettings() {
{MOCK_SESSIONS.map((s) => (
<View key={s.id} style={styles.sessionRow}>
<View style={styles.sessionIcon}>
<Monitor size={18} color={colors.text} />
<Monitor size={18} color={c.text} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.sessionDevice}>{s.device}</Text>
@@ -129,7 +132,7 @@ export function AccountSecuritySettings() {
</View>
{!s.current && (
<Pressable style={styles.trashBtn}>
<Trash2 size={16} color={colors.error} />
<Trash2 size={16} color={c.error} />
</Pressable>
)}
</View>
@@ -139,23 +142,24 @@ export function AccountSecuritySettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
pwField: { gap: 4 },
pwLabel: { ...typography.caption, color: colors.mutedForeground },
pwLabel: { ...typography.caption, color: c.mutedForeground },
pwInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
eyeBtn: { padding: spacing.sm },
infoBox: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.md,
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
borderRadius: radius.sm,
alignItems: 'center',
},
infoText: {
...typography.caption,
color: colors.primary,
color: c.primary,
flex: 1,
},
empty: {
@@ -165,7 +169,7 @@ const styles = StyleSheet.create({
},
emptyText: {
...typography.body,
color: colors.mutedForeground,
color: c.mutedForeground,
},
sessionRow: {
flexDirection: 'row',
@@ -173,18 +177,18 @@ const styles = StyleSheet.create({
gap: spacing.md,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
sessionIcon: {
width: 36,
height: 36,
borderRadius: radius.sm,
backgroundColor: colors.muted,
backgroundColor: c.muted,
alignItems: 'center',
justifyContent: 'center',
},
sessionDevice: { ...typography.bodyMedium, color: colors.text },
sessionMeta: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
sessionDevice: { ...typography.bodyMedium, color: c.text },
sessionMeta: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
trashBtn: {
width: 32,
height: 32,
@@ -193,3 +197,4 @@ const styles = StyleSheet.create({
borderRadius: radius.sm,
},
});
}
+10 -5
View File
@@ -1,7 +1,8 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { SettingsSection, SettingItem } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAuthStore } from '../../stores/auth-store';
import { useAccountStore } from '../../stores/account-store';
@@ -29,6 +30,8 @@ function formatFileSize(bytes: number): string {
}
export function AccountSettings(props: AccountSettingsProps = {}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const authUsername = useAuthStore((s) => s.username);
const authServerUrl = useAuthStore((s) => s.serverUrl);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
@@ -99,10 +102,11 @@ export function AccountSettings(props: AccountSettingsProps = {}) {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
value: {
...typography.body,
color: colors.text,
color: c.text,
maxWidth: 240,
},
storage: {
@@ -112,13 +116,13 @@ const styles = StyleSheet.create({
bar: {
width: 128,
height: 8,
backgroundColor: colors.muted,
backgroundColor: c.muted,
borderRadius: radius.full,
overflow: 'hidden',
},
barFill: {
height: '100%',
backgroundColor: colors.primary,
backgroundColor: c.primary,
borderRadius: radius.full,
},
demoRow: {
@@ -137,3 +141,4 @@ const styles = StyleSheet.create({
color: '#fbbf24',
},
});
}
+17 -10
View File
@@ -1,7 +1,8 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type ThemeMode, type FontSize, type Density } from '../../stores/settings-store';
type Theme = ThemeMode;
@@ -20,6 +21,8 @@ const PREVIEW_ROWS = [
];
function DensityPreview({ density }: { density: Density }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const cfg = DENSITY_PREVIEW[density];
return (
<View style={styles.densityPreview}>
@@ -46,7 +49,7 @@ function DensityPreview({ density }: { density: Density }) {
<Text
style={[
styles.densitySender,
row.unread ? { color: colors.text, fontWeight: '600' } : { color: colors.mutedForeground },
row.unread ? { color: c.text, fontWeight: '600' } : { color: c.mutedForeground },
]}
numberOfLines={1}
>
@@ -57,7 +60,7 @@ function DensityPreview({ density }: { density: Density }) {
<Text
style={[
styles.densitySubject,
row.unread ? { color: colors.text, fontWeight: '500' } : { color: 'rgba(250,250,250,0.8)' },
row.unread ? { color: c.text, fontWeight: '500' } : { color: 'rgba(250,250,250,0.8)' },
]}
numberOfLines={1}
>
@@ -76,6 +79,8 @@ function DensityPreview({ density }: { density: Density }) {
}
export function AppearanceSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const theme = useSettingsStore((s) => s.theme);
const setTheme = useSettingsStore((s) => s.setTheme);
const fontSize = useSettingsStore((s) => s.fontSize);
@@ -146,24 +151,25 @@ export function AppearanceSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
densityPreview: {
marginTop: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
overflow: 'hidden',
backgroundColor: colors.background,
backgroundColor: c.background,
},
densityRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
densityAvatar: {
borderRadius: radius.full,
backgroundColor: colors.muted,
backgroundColor: c.muted,
},
densityHeader: {
flexDirection: 'row',
@@ -177,7 +183,7 @@ const styles = StyleSheet.create({
},
densityTime: {
fontSize: 10,
color: colors.mutedForeground,
color: c.mutedForeground,
},
densitySubject: {
...typography.caption,
@@ -188,7 +194,8 @@ const styles = StyleSheet.create({
},
divider: {
height: 1,
backgroundColor: colors.border,
backgroundColor: c.border,
marginTop: spacing.md,
},
});
}
+17 -12
View File
@@ -3,10 +3,13 @@ import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { X, Plus } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
export function ComposingSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
const setAutoSelectReplyIdentity = useSettingsStore((s) => s.setAutoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
@@ -68,7 +71,7 @@ export function ComposingSettings() {
<View key={kw} style={styles.chip}>
<Text style={styles.chipText}>{kw}</Text>
<Pressable onPress={() => removeKeyword(kw)} hitSlop={6}>
<X size={12} color={colors.textSecondary} />
<X size={12} color={c.textSecondary} />
</Pressable>
</View>
))}
@@ -79,7 +82,7 @@ export function ComposingSettings() {
value={newKeyword}
onChangeText={setNewKeyword}
placeholder="e.g. attached"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
style={styles.input}
@@ -91,7 +94,7 @@ export function ComposingSettings() {
size="sm"
onPress={addKeyword}
disabled={!newKeyword.trim()}
icon={<Plus size={14} color={colors.primaryForeground} />}
icon={<Plus size={14} color={c.primaryForeground} />}
>
Add
</Button>
@@ -102,10 +105,11 @@ export function ComposingSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
subLabel: { ...typography.bodyMedium, color: colors.text },
subDescription: { ...typography.caption, color: colors.mutedForeground },
subLabel: { ...typography.bodyMedium, color: c.text },
subDescription: { ...typography.caption, color: c.mutedForeground },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
chip: {
flexDirection: 'row',
@@ -114,19 +118,20 @@ const styles = StyleSheet.create({
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: radius.full,
backgroundColor: colors.muted,
backgroundColor: c.muted,
},
chipText: { ...typography.caption, color: colors.text },
chipText: { ...typography.caption, color: c.text },
addRow: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
input: {
flex: 1,
backgroundColor: colors.background,
backgroundColor: c.background,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
paddingHorizontal: spacing.md,
paddingVertical: 8,
color: colors.text,
color: c.text,
...typography.body,
},
});
}
+5 -3
View File
@@ -9,9 +9,11 @@ import { useSettingsStore } from '../../stores/settings-store';
import { useContactsStore } from '../../stores/contacts-store';
import { contactsToVCard } from '../../lib/vcard';
import { isGroup } from '../../lib/contact-utils';
import { colors, typography } from '../../theme/tokens';
import { typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
export function ContactsSettings() {
const c = useColors();
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
const contacts = useContactsStore((s) => s.contacts);
@@ -67,7 +69,7 @@ export function ContactsSettings() {
<Button
variant="outline"
size="sm"
icon={<Download size={14} color={colors.text} />}
icon={<Download size={14} color={c.text} />}
onPress={() => { void handleExport(); }}
disabled={exportable.length === 0 || exporting}
loading={exporting}
@@ -80,7 +82,7 @@ export function ContactsSettings() {
label="Import contacts"
description="Importing vCard files is currently available on the web client only."
>
<Text style={[typography.caption, { color: colors.textMuted }]}>Web only</Text>
<Text style={[typography.caption, { color: c.textMuted }]}>Web only</Text>
</SettingItem>
</SettingsSection>
);
@@ -5,10 +5,13 @@ import {
import { ChevronRight, X, Plus, Trash2 } from 'lucide-react-native';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
export function ContentSendersSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
const setExternalContentPolicy = useSettingsStore((s) => s.setExternalContentPolicy);
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
@@ -64,7 +67,7 @@ export function ContentSendersSettings() {
style={({ pressed }) => [styles.trustedButton, pressed && styles.trustedButtonPressed]}
>
<Text style={styles.trustedButtonText}>{trustedLabel}</Text>
<ChevronRight size={14} color={colors.textMuted} />
<ChevronRight size={14} color={c.textMuted} />
</Pressable>
</SettingItem>
</SettingsSection>
@@ -80,7 +83,7 @@ export function ContentSendersSettings() {
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Trusted senders</Text>
<Pressable onPress={() => setModalOpen(false)} hitSlop={6}>
<X size={18} color={colors.textSecondary} />
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
@@ -89,7 +92,7 @@ export function ContentSendersSettings() {
value={newSender}
onChangeText={setNewSender}
placeholder="sender@example.com"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
@@ -111,7 +114,7 @@ export function ContentSendersSettings() {
}
}}
disabled={!newSender.trim()}
icon={<Plus size={14} color={colors.primaryForeground} />}
icon={<Plus size={14} color={c.primaryForeground} />}
>
Add
</Button>
@@ -133,7 +136,7 @@ export function ContentSendersSettings() {
hitSlop={6}
style={({ pressed }) => [styles.removeBtn, pressed && styles.removeBtnPressed]}
>
<Trash2 size={16} color={colors.error} />
<Trash2 size={16} color={c.error} />
</Pressable>
</View>
)}
@@ -146,18 +149,19 @@ export function ContentSendersSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
trustedButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.md,
paddingVertical: 6,
backgroundColor: colors.muted,
backgroundColor: c.muted,
borderRadius: radius.sm,
},
trustedButtonPressed: { opacity: 0.7 },
trustedButtonText: { ...typography.body, color: colors.text },
trustedButtonText: { ...typography.body, color: c.text },
modalBackdrop: {
flex: 1,
@@ -166,10 +170,10 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
},
modalCard: {
backgroundColor: colors.card,
backgroundColor: c.card,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
padding: spacing.lg,
maxHeight: '80%',
},
@@ -179,34 +183,35 @@ const styles = StyleSheet.create({
alignItems: 'center',
marginBottom: spacing.md,
},
modalTitle: { ...typography.h3, color: colors.text },
modalTitle: { ...typography.h3, color: c.text },
addRow: { flexDirection: 'row', gap: spacing.sm, marginBottom: spacing.md },
input: {
flex: 1,
backgroundColor: colors.background,
backgroundColor: c.background,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
paddingHorizontal: spacing.md,
paddingVertical: 8,
color: colors.text,
color: c.text,
...typography.body,
},
list: { maxHeight: 300 },
separator: { height: 1, backgroundColor: colors.border },
separator: { height: 1, backgroundColor: c.border },
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.md,
},
rowText: { ...typography.body, color: colors.text, flex: 1, marginRight: spacing.sm },
rowText: { ...typography.body, color: c.text, flex: 1, marginRight: spacing.sm },
removeBtn: { padding: 6, borderRadius: radius.sm },
removeBtnPressed: { backgroundColor: colors.muted },
removeBtnPressed: { backgroundColor: c.muted },
emptyText: {
...typography.body,
color: colors.textMuted,
color: c.textMuted,
textAlign: 'center',
paddingVertical: spacing.xl,
},
});
}
+26 -19
View File
@@ -4,7 +4,8 @@ import {
Folder, FileText, FileCode, FileAudio, File, Image as ImageIcon,
} from 'lucide-react-native';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
type FolderLayout = 'inline' | 'sidebar';
type ViewMode = 'list' | 'grid';
@@ -58,23 +59,25 @@ function formatSize(b: number) {
}
function getIcon(file: SampleFile, colored: boolean, sz: number) {
if (file.isFolder) return <Folder size={sz} color={colored ? '#60a5fa' : colors.mutedForeground} />;
if (file.isFolder) return <Folder size={sz} color={colored ? '#60a5fa' : c.mutedForeground} />;
const ext = file.name.split('.').pop()?.toLowerCase();
switch (ext) {
case 'jpg': case 'png': case 'gif':
return <ImageIcon size={sz} color={colored ? '#4ade80' : colors.mutedForeground} />;
return <ImageIcon size={sz} color={colored ? '#4ade80' : c.mutedForeground} />;
case 'mp3': case 'wav':
return <FileAudio size={sz} color={colored ? '#a78bfa' : colors.mutedForeground} />;
return <FileAudio size={sz} color={colored ? '#a78bfa' : c.mutedForeground} />;
case 'pdf':
return <FileText size={sz} color={colored ? '#f87171' : colors.mutedForeground} />;
return <FileText size={sz} color={colored ? '#f87171' : c.mutedForeground} />;
case 'md': case 'json': case 'js': case 'ts':
return <FileCode size={sz} color={colored ? '#fbbf24' : colors.mutedForeground} />;
return <FileCode size={sz} color={colored ? '#fbbf24' : c.mutedForeground} />;
default:
return <File size={sz} color={colors.mutedForeground} />;
return <File size={sz} color={c.mutedForeground} />;
}
}
function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const files = SAMPLE.filter((f) => {
if (!prefs.showHiddenFiles && f.hidden) return false;
return true;
@@ -127,6 +130,8 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
}
export function FilesSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [prefs, setPrefs] = useState<FilesPrefs>(DEFAULTS);
const update = (patch: Partial<FilesPrefs>) => setPrefs((p) => ({ ...p, ...patch }));
@@ -188,7 +193,7 @@ export function FilesSettings() {
<SettingItem label="Show icons" description="Display icons for files and folders.">
<ToggleSwitch checked={prefs.showIcons} onChange={(v) => update({ showIcons: v })} />
</SettingItem>
<SettingItem label="Colored icons" description="Use file-type-specific colors.">
<SettingItem label="Colored icons" description="Use file-type-specific c.">
<ToggleSwitch
checked={prefs.coloredIcons}
onChange={(v) => update({ coloredIcons: v })}
@@ -212,14 +217,15 @@ export function FilesSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
previewLabel: { ...typography.bodyMedium, color: colors.text, marginBottom: spacing.sm },
previewLabel: { ...typography.bodyMedium, color: c.text, marginBottom: spacing.sm },
previewBox: {
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
borderColor: c.border,
backgroundColor: c.background,
overflow: 'hidden',
minHeight: 160,
},
@@ -230,10 +236,10 @@ const styles = StyleSheet.create({
paddingVertical: 4,
paddingHorizontal: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
backgroundColor: colors.muted,
borderBottomColor: c.border,
backgroundColor: c.muted,
},
listHeaderText: { fontSize: 10, fontWeight: '500', color: colors.mutedForeground },
listHeaderText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
listRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -241,10 +247,10 @@ const styles = StyleSheet.create({
paddingVertical: 6,
paddingHorizontal: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
listName: { fontSize: 11, color: colors.text, flex: 1 },
listMeta: { fontSize: 10, color: colors.mutedForeground, width: 60, textAlign: 'right' },
listName: { fontSize: 11, color: c.text, flex: 1 },
listMeta: { fontSize: 10, color: c.mutedForeground, width: 60, textAlign: 'right' },
gridWrap: {
flexDirection: 'row',
flexWrap: 'wrap',
@@ -258,5 +264,6 @@ const styles = StyleSheet.create({
width: 72,
borderRadius: radius.sm,
},
gridName: { fontSize: 9, color: colors.text, textAlign: 'center' },
gridName: { fontSize: 9, color: c.text, textAlign: 'center' },
});
}
+20 -15
View File
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Plus, GripVertical, X, Code, Filter, AlertTriangle, RotateCcw } from 'lucide-react-native';
import { SettingsSection, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface Rule {
id: string;
@@ -16,6 +17,8 @@ interface Rule {
const SAMPLE_RULES: Rule[] = [];
export function FilterSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [rules, setRules] = useState<Rule[]>(SAMPLE_RULES);
const [expandedView, setExpandedView] = useState(false);
const [isOpaque] = useState(false);
@@ -45,7 +48,7 @@ export function FilterSettings() {
<Text style={styles.linkText}>Open Sieve Editor</Text>
</Pressable>
<Pressable style={styles.resetLink}>
<RotateCcw size={12} color={colors.error} />
<RotateCcw size={12} color={c.error} />
<Text style={styles.resetText}>Reset to visual</Text>
</Pressable>
</View>
@@ -55,7 +58,7 @@ export function FilterSettings() {
{rules.length === 0 && !isOpaque && (
<View style={styles.emptyState}>
<Filter size={40} color={colors.mutedForeground} style={{ opacity: 0.4 }} />
<Filter size={40} color={c.mutedForeground} style={{ opacity: 0.4 }} />
<Text style={styles.emptyText}>No rules yet.</Text>
</View>
)}
@@ -65,7 +68,7 @@ export function FilterSettings() {
{rules.map((rule) => (
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
<View style={styles.grip}>
<GripVertical size={16} color={colors.mutedForeground} />
<GripVertical size={16} color={c.mutedForeground} />
</View>
<ToggleSwitch checked={rule.enabled} onChange={() => toggle(rule.id)} />
@@ -88,7 +91,7 @@ export function FilterSettings() {
</View>
) : (
<Pressable style={styles.iconBtn} onPress={() => setDeleteId(rule.id)}>
<X size={16} color={colors.mutedForeground} />
<X size={16} color={c.mutedForeground} />
</Pressable>
)}
</View>
@@ -100,11 +103,11 @@ export function FilterSettings() {
<View style={styles.footer}>
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
{!isOpaque && (
<Button variant="outline" size="sm" icon={<Plus size={14} color={colors.text} />}>
<Button variant="outline" size="sm" icon={<Plus size={14} color={c.text} />}>
Add Rule
</Button>
)}
<Button variant="outline" size="sm" icon={<Code size={14} color={colors.text} />}>
<Button variant="outline" size="sm" icon={<Code size={14} color={c.text} />}>
Raw Editor
</Button>
</View>
@@ -120,7 +123,8 @@ export function FilterSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xl },
opaqueWarning: {
flexDirection: 'row',
@@ -134,11 +138,11 @@ const styles = StyleSheet.create({
},
opaqueText: { ...typography.caption, color: '#fde68a' },
opaqueActions: { flexDirection: 'row', gap: spacing.md, marginTop: spacing.sm },
linkText: { ...typography.caption, color: colors.primary, fontWeight: '500' },
linkText: { ...typography.caption, color: c.primary, fontWeight: '500' },
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
resetText: { ...typography.caption, color: colors.error, fontWeight: '500' },
resetText: { ...typography.caption, color: c.error, fontWeight: '500' },
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
emptyText: { ...typography.body, color: colors.mutedForeground },
emptyText: { ...typography.body, color: c.mutedForeground },
ruleRow: {
flexDirection: 'row',
alignItems: 'flex-start',
@@ -146,12 +150,12 @@ const styles = StyleSheet.create({
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
ruleDisabled: { opacity: 0.6 },
grip: { paddingTop: 2 },
ruleName: { ...typography.bodyMedium, color: colors.text },
ruleSummary: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
ruleName: { ...typography.bodyMedium, color: c.text },
ruleSummary: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
iconBtn: {
width: 28,
height: 28,
@@ -167,5 +171,6 @@ const styles = StyleSheet.create({
flexWrap: 'wrap',
},
expandedToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
expandedLabel: { ...typography.caption, color: colors.mutedForeground },
expandedLabel: { ...typography.caption, color: c.mutedForeground },
});
}
+16 -11
View File
@@ -4,7 +4,8 @@ import {
Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive,
} from 'lucide-react-native';
import { SettingsSection, SettingItem } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useEmailStore } from '../../stores/email-store';
import type { Mailbox } from '../../api/types';
@@ -23,6 +24,8 @@ function getIcon(mb: Mailbox) {
}
export function FolderSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const mailboxes = useEmailStore((s) => s.mailboxes);
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
@@ -47,7 +50,7 @@ export function FolderSettings() {
>
{mailboxes.length === 0 ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={colors.primary} />
<ActivityIndicator size="small" color={c.primary} />
</View>
) : (
<View>
@@ -58,7 +61,7 @@ export function FolderSettings() {
<View style={styles.folderLeft}>
<Icon
size={16}
color={mb.role ? colors.primary : colors.mutedForeground}
color={mb.role ? c.primary : c.mutedForeground}
/>
<Text style={styles.folderName} numberOfLines={1}>{mb.name}</Text>
{mb.role && ROLE_LABEL[mb.role] && (
@@ -94,7 +97,8 @@ export function FolderSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
loading: { paddingVertical: 40, alignItems: 'center' },
folderRow: {
@@ -106,22 +110,23 @@ const styles = StyleSheet.create({
borderRadius: radius.sm,
},
folderLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flex: 1 },
folderName: { ...typography.body, color: colors.text, flexShrink: 1 },
folderName: { ...typography.body, color: c.text, flexShrink: 1 },
rolePill: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
},
rolePillText: { fontSize: 10, fontWeight: '500', color: colors.primary },
rolePillText: { fontSize: 10, fontWeight: '500', color: c.primary },
folderRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
unreadBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: colors.primary,
backgroundColor: c.primary,
},
unreadText: { fontSize: 10, fontWeight: '500', color: colors.primaryForeground },
total: { ...typography.caption, color: colors.mutedForeground, minWidth: 32, textAlign: 'right' },
caption: { ...typography.caption, color: colors.mutedForeground },
unreadText: { fontSize: 10, fontWeight: '500', color: c.primaryForeground },
total: { ...typography.caption, color: c.mutedForeground, minWidth: 32, textAlign: 'right' },
caption: { ...typography.caption, color: c.mutedForeground },
});
}
+15 -10
View File
@@ -2,10 +2,13 @@ import React, { useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { SettingsSection, SettingItem } from './settings-section';
import Button from '../Button';
import { colors, typography, spacing, radius } from '../../theme/tokens';
import { typography, spacing, radius, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
export function IdentitySettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const identities = useSettingsStore((s) => s.identities);
const loading = useSettingsStore((s) => s.loading);
const error = useSettingsStore((s) => s.error);
@@ -30,7 +33,7 @@ export function IdentitySettings() {
>
<View style={styles.row}>
{loading ? (
<ActivityIndicator size="small" color={colors.primary} />
<ActivityIndicator size="small" color={c.primary} />
) : (
<Text style={styles.count}>{countLabel}</Text>
)}
@@ -67,7 +70,8 @@ export function IdentitySettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
@@ -75,14 +79,14 @@ const styles = StyleSheet.create({
},
count: {
...typography.body,
color: colors.text,
color: c.text,
},
errorBox: {
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: colors.errorBg,
backgroundColor: c.errorBg,
},
errorText: { ...typography.caption, color: colors.error },
errorText: { ...typography.caption, color: c.error },
identityRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -90,15 +94,16 @@ const styles = StyleSheet.create({
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
backgroundColor: colors.muted,
backgroundColor: c.muted,
},
identityName: { ...typography.bodyMedium, color: colors.text },
identityEmail: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
identityName: { ...typography.bodyMedium, color: c.text },
identityEmail: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
badge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: 'rgba(59,130,246,0.15)',
},
badgeText: { fontSize: 10, fontWeight: '500', color: colors.primary },
badgeText: { fontSize: 10, fontWeight: '500', color: c.primary },
});
}
+41 -31
View File
@@ -2,14 +2,20 @@ import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { Plus, Pencil, Trash2, Check, X, RotateCcw } from 'lucide-react-native';
import { SettingsSection } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store';
import { DARK_COLORS } from '../../theme/tokens';
type Keyword = KeywordDef;
const PALETTE_KEYS = Object.keys(colors.tags) as (keyof typeof colors.tags)[];
// Palette keys are theme-agnostic (same names in both palettes), so use DARK_COLORS
// at module load. The actual rendered swatch colors come from the active theme via `c.tags[key]`.
const PALETTE_KEYS = Object.keys(DARK_COLORS.tags) as (keyof typeof DARK_COLORS.tags)[];
export function KeywordSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const keywords = useKeywordsStore((s) => s.keywords);
const addKeyword = useKeywordsStore((s) => s.add);
const updateKeyword = useKeywordsStore((s) => s.update);
@@ -53,7 +59,7 @@ export function KeywordSettings() {
/>
);
}
const palette = colors.tags[kw.color];
const palette = c.tags[kw.color];
return (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
@@ -61,10 +67,10 @@ export function KeywordSettings() {
<Text style={styles.kwId}>$label:{kw.id}</Text>
<View style={{ flexDirection: 'row', gap: 2 }}>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)}>
<Pencil size={14} color={colors.mutedForeground} />
<Pencil size={14} color={c.mutedForeground} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)}>
<Trash2 size={14} color={colors.mutedForeground} />
<Trash2 size={14} color={c.mutedForeground} />
</Pressable>
</View>
</View>
@@ -82,11 +88,11 @@ export function KeywordSettings() {
{!isAdding && editingId === null && (
<View style={styles.bottomActions}>
<Pressable style={styles.outlineBtn} onPress={() => setIsAdding(true)}>
<Plus size={14} color={colors.mutedForeground} />
<Plus size={14} color={c.mutedForeground} />
<Text style={styles.outlineBtnText}>Add keyword</Text>
</Pressable>
<Pressable style={styles.outlineBtn} onPress={resetDefaults}>
<RotateCcw size={14} color={colors.mutedForeground} />
<RotateCcw size={14} color={c.mutedForeground} />
<Text style={styles.outlineBtnText}>Reset defaults</Text>
</Pressable>
</View>
@@ -104,8 +110,10 @@ interface KeywordFormProps {
}
function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [label, setLabel] = useState(initial?.label ?? '');
const [color, setColor] = useState<keyof typeof colors.tags>(initial?.color ?? 'blue');
const [color, setColor] = useState<keyof typeof DARK_COLORS.tags>(initial?.color ?? 'blue');
const normalizedId = label
.trim()
@@ -130,7 +138,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
value={label}
onChangeText={setLabel}
placeholder="e.g. Important"
placeholderTextColor={colors.mutedForeground}
placeholderTextColor={c.mutedForeground}
style={styles.input}
maxLength={30}
autoFocus
@@ -142,7 +150,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
<Text style={styles.formLabel}>Color</Text>
<View style={styles.palette}>
{PALETTE_KEYS.map((key) => {
const p = colors.tags[key];
const p = c.tags[key];
return (
<Pressable
key={key}
@@ -160,7 +168,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
<View style={styles.formActions}>
<Pressable style={styles.cancelFormBtn} onPress={onCancel}>
<X size={14} color={colors.text} />
<X size={14} color={c.text} />
<Text style={styles.cancelFormText}>Cancel</Text>
</Pressable>
<Pressable
@@ -168,7 +176,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
onPress={handleSave}
disabled={!isValid}
>
<Check size={14} color={colors.primaryForeground} />
<Check size={14} color={c.primaryForeground} />
<Text style={styles.saveBtnText}>{initial ? 'Save' : 'Add'}</Text>
</Pressable>
</View>
@@ -176,7 +184,8 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
kwRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -184,12 +193,12 @@ const styles = StyleSheet.create({
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
borderColor: c.border,
backgroundColor: c.background,
},
kwDot: { width: 20, height: 20, borderRadius: 10 },
kwLabel: { ...typography.bodyMedium, color: colors.text, flex: 1 },
kwId: { fontSize: 10, color: colors.mutedForeground, fontFamily: 'monospace' },
kwLabel: { ...typography.bodyMedium, color: c.text, flex: 1 },
kwId: { fontSize: 10, color: c.mutedForeground, fontFamily: 'monospace' },
iconBtn: {
width: 28,
height: 28,
@@ -211,34 +220,34 @@ const styles = StyleSheet.create({
borderRadius: radius.sm,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: colors.border,
borderColor: c.border,
},
outlineBtnText: { ...typography.caption, color: colors.mutedForeground },
outlineBtnText: { ...typography.caption, color: c.mutedForeground },
form: {
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.primaryBorder,
backgroundColor: colors.accent,
borderColor: c.primaryBorder,
backgroundColor: c.accent,
},
formLabel: { ...typography.caption, color: colors.mutedForeground, marginBottom: 4 },
formLabel: { ...typography.caption, color: c.mutedForeground, marginBottom: 4 },
input: {
paddingHorizontal: spacing.sm,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.background,
backgroundColor: c.background,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
borderColor: c.border,
color: c.text,
...typography.body,
},
errorText: { ...typography.caption, color: colors.error, marginTop: 4 },
errorText: { ...typography.caption, color: c.error, marginTop: 4 },
palette: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
colorSwatch: { width: 24, height: 24, borderRadius: 12 },
colorSwatchSelected: {
borderWidth: 2,
borderColor: colors.text,
borderColor: c.text,
},
formActions: {
flexDirection: 'row',
@@ -253,9 +262,9 @@ const styles = StyleSheet.create({
paddingVertical: 6,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
cancelFormText: { ...typography.caption, color: colors.text },
cancelFormText: { ...typography.caption, color: c.text },
saveBtn: {
flexDirection: 'row',
alignItems: 'center',
@@ -263,7 +272,8 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.primary,
backgroundColor: c.primary,
},
saveBtnText: { ...typography.caption, color: colors.primaryForeground, fontWeight: '500' },
saveBtnText: { ...typography.caption, color: c.primaryForeground, fontWeight: '500' },
});
}
+13 -8
View File
@@ -2,11 +2,14 @@ import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Check } from 'lucide-react-native';
import { SettingsSection, SettingItem } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { SUPPORTED_LOCALES, detectDeviceLocale, type LocaleCode } from '../../i18n';
export function LanguageSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const override = useLocaleStore((s) => s.override);
const setOverride = useLocaleStore((s) => s.setOverride);
const hydrated = useLocaleStore((s) => s.hydrated);
@@ -30,7 +33,7 @@ export function LanguageSettings() {
<Text style={styles.label}>{label}</Text>
{sublabel ? <Text style={styles.sublabel}>{sublabel}</Text> : null}
</View>
{active ? <Check size={16} color={colors.primary} /> : null}
{active ? <Check size={16} color={c.primary} /> : null}
</Pressable>
);
};
@@ -57,8 +60,9 @@ export function LanguageSettings() {
);
}
const styles = StyleSheet.create({
list: { borderRadius: radius.md, overflow: 'hidden', backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
list: { borderRadius: radius.md, overflow: 'hidden', backgroundColor: c.background },
row: {
flexDirection: 'row',
alignItems: 'center',
@@ -66,8 +70,9 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
gap: spacing.sm,
},
rowPressed: { backgroundColor: colors.muted },
divider: { height: 1, backgroundColor: colors.border, marginHorizontal: spacing.md },
label: { ...typography.body, color: colors.text },
sublabel: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
rowPressed: { backgroundColor: c.muted },
divider: { height: 1, backgroundColor: c.border, marginHorizontal: spacing.md },
label: { ...typography.body, color: c.text },
sublabel: { ...typography.caption, color: c.textMuted, marginTop: 2 },
});
}
+11 -6
View File
@@ -2,7 +2,8 @@ import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
import { colors, spacing, typography } from '../../theme/tokens';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type SwipeAction } from '../../stores/settings-store';
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
@@ -15,6 +16,8 @@ const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
];
export function LayoutSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
const setSwipeLeftAction = useSettingsStore((s) => s.setSwipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
@@ -28,7 +31,7 @@ export function LayoutSettings() {
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
<View style={{ gap: spacing.sm }}>
<View style={styles.row}>
<ArrowRight size={14} color={colors.mutedForeground} />
<ArrowRight size={14} color={c.mutedForeground} />
<Text style={styles.rowLabel}>Swipe right (left → right)</Text>
</View>
<Text style={styles.rowDescription}>
@@ -43,7 +46,7 @@ export function LayoutSettings() {
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
<View style={styles.row}>
<ArrowLeft size={14} color={colors.mutedForeground} />
<ArrowLeft size={14} color={c.mutedForeground} />
<Text style={styles.rowLabel}>Swipe left (right → left)</Text>
</View>
<Text style={styles.rowDescription}>
@@ -64,8 +67,10 @@ export function LayoutSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
rowLabel: { ...typography.bodyMedium, color: colors.text },
rowDescription: { ...typography.caption, color: colors.mutedForeground },
rowLabel: { ...typography.bodyMedium, color: c.text },
rowDescription: { ...typography.caption, color: c.mutedForeground },
});
}
@@ -10,7 +10,8 @@ import { CheckCircle2, Volume2, XCircle } from 'lucide-react-native';
import Button from '../Button';
import Dialog from '../Dialog';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAuthStore } from '../../stores/auth-store';
import {
DEFAULT_RELAY_BASE_URL,
@@ -35,6 +36,8 @@ type PushStatus =
| { kind: 'error'; message: string };
export function NotificationSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const username = useAuthStore((s) => s.username);
const [sound, setSound] = useState('default');
@@ -120,26 +123,26 @@ export function NotificationSettings() {
<View style={styles.statusRow}>
{pushStatus.kind === 'busy' && (
<>
<ActivityIndicator size="small" color={colors.primary} />
<ActivityIndicator size="small" color={c.primary} />
<Text style={styles.statusText}>{pushStatus.message}</Text>
</>
)}
{pushStatus.kind === 'enabled' && (
<>
<CheckCircle2 size={14} color={colors.success} />
<Text style={[styles.statusText, { color: colors.success }]}>Active</Text>
<CheckCircle2 size={14} color={c.success} />
<Text style={[styles.statusText, { color: c.success }]}>Active</Text>
</>
)}
{pushStatus.kind === 'error' && (
<>
<XCircle size={14} color={colors.error} />
<Text style={[styles.statusText, { color: colors.error }]}>
<XCircle size={14} color={c.error} />
<Text style={[styles.statusText, { color: c.error }]}>
{pushStatus.message}
</Text>
</>
)}
{pushStatus.kind === 'idle' && (
<Text style={[styles.statusText, { color: colors.mutedForeground }]}>
<Text style={[styles.statusText, { color: c.mutedForeground }]}>
Not configured
</Text>
)}
@@ -152,7 +155,7 @@ export function NotificationSettings() {
value={relayUrl}
onChangeText={setRelayUrl}
placeholder={DEFAULT_RELAY_BASE_URL}
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
@@ -183,7 +186,7 @@ export function NotificationSettings() {
<Button
variant="ghost"
size="icon"
icon={<Volume2 size={16} color={colors.text} />}
icon={<Volume2 size={16} color={c.text} />}
/>
<Select
value={sound}
@@ -229,12 +232,13 @@ export function NotificationSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
pushCard: {
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.md,
padding: spacing.lg,
gap: spacing.sm,
@@ -247,18 +251,18 @@ const styles = StyleSheet.create({
},
fieldLabel: {
...typography.bodyMedium,
color: colors.text,
color: c.text,
},
fieldDescription: {
...typography.caption,
color: colors.mutedForeground,
color: c.mutedForeground,
},
urlInput: {
...typography.body,
color: colors.text,
backgroundColor: colors.surface,
color: c.text,
backgroundColor: c.surface,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: 8,
@@ -266,6 +270,7 @@ const styles = StyleSheet.create({
marginTop: 4,
},
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flexShrink: 1 },
statusText: { ...typography.caption, color: colors.text },
statusText: { ...typography.caption, color: c.text },
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.md },
});
}
+27 -22
View File
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Upload, Puzzle, AlertTriangle, Lock, Trash2, ChevronDown, ChevronRight } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface Plugin {
id: string;
@@ -20,14 +21,16 @@ interface Plugin {
}
const STATUS_STYLE: Record<Plugin['status'], { bg: string; fg: string }> = {
installed: { bg: colors.muted, fg: colors.mutedForeground },
installed: { bg: c.muted, fg: c.mutedForeground },
enabled: { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' },
running: { bg: 'rgba(34, 197, 94, 0.15)', fg: '#4ade80' },
disabled: { bg: colors.muted, fg: colors.mutedForeground },
disabled: { bg: c.muted, fg: c.mutedForeground },
error: { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' },
};
export function PluginsSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [plugins, setPlugins] = useState<Plugin[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -52,7 +55,7 @@ export function PluginsSettings() {
>
{plugins.length === 0 ? (
<View style={styles.empty}>
<Puzzle size={48} color={colors.mutedForeground} style={{ opacity: 0.3 }} />
<Puzzle size={48} color={c.mutedForeground} style={{ opacity: 0.3 }} />
<Text style={styles.emptyTitle}>No plugins installed</Text>
<Text style={styles.emptyDesc}>Upload a plugin .zip file to get started.</Text>
</View>
@@ -66,7 +69,7 @@ export function PluginsSettings() {
key={plugin.id}
style={[
styles.card,
plugin.status === 'error' && { borderColor: colors.errorBorder },
plugin.status === 'error' && { borderColor: c.errorBorder },
]}
>
<View style={styles.cardHeader}>
@@ -107,7 +110,7 @@ export function PluginsSettings() {
{plugin.error && (
<View style={styles.errorBox}>
<AlertTriangle size={14} color={colors.error} />
<AlertTriangle size={14} color={c.error} />
<Text style={styles.errorText}>{plugin.error}</Text>
</View>
)}
@@ -145,7 +148,7 @@ export function PluginsSettings() {
)}
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file.">
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
Upload .zip
</Button>
</SettingItem>
@@ -153,18 +156,19 @@ export function PluginsSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
empty: {
alignItems: 'center',
paddingVertical: spacing.xxxl,
gap: spacing.sm,
},
emptyTitle: { ...typography.body, color: colors.mutedForeground },
emptyDesc: { ...typography.caption, color: colors.mutedForeground },
emptyTitle: { ...typography.body, color: c.mutedForeground },
emptyDesc: { ...typography.caption, color: c.mutedForeground },
card: {
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
cardHeader: {
flexDirection: 'row',
@@ -173,7 +177,7 @@ const styles = StyleSheet.create({
padding: spacing.md,
},
headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
pluginName: { ...typography.bodyMedium, color: colors.text },
pluginName: { ...typography.bodyMedium, color: c.text },
statusPill: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full },
statusText: { fontSize: 10, fontWeight: '500' },
forcedPill: {
@@ -187,38 +191,39 @@ const styles = StyleSheet.create({
},
forcedText: { fontSize: 10, fontWeight: '500', color: '#fbbf24' },
metaRow: { flexDirection: 'row', gap: spacing.sm, marginTop: 2 },
meta: { ...typography.caption, color: colors.mutedForeground },
metaDot: { ...typography.caption, color: colors.mutedForeground, opacity: 0.6 },
meta: { ...typography.caption, color: c.mutedForeground },
metaDot: { ...typography.caption, color: c.mutedForeground, opacity: 0.6 },
cardBody: {
borderTopWidth: 1,
borderTopColor: colors.border,
borderTopColor: c.border,
padding: spacing.md,
gap: spacing.md,
},
description: { ...typography.caption, color: colors.mutedForeground },
description: { ...typography.caption, color: c.mutedForeground },
errorBox: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.sm,
borderRadius: radius.sm,
backgroundColor: colors.errorBg,
backgroundColor: c.errorBg,
alignItems: 'flex-start',
},
errorText: { ...typography.caption, color: colors.error, flex: 1 },
sectionLabel: { ...typography.captionMedium, color: colors.text, marginBottom: 4 },
errorText: { ...typography.caption, color: c.error, flex: 1 },
sectionLabel: { ...typography.captionMedium, color: c.text, marginBottom: 4 },
permWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 },
permPill: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.xs,
backgroundColor: colors.muted,
backgroundColor: c.muted,
},
permText: { fontSize: 10, color: colors.mutedForeground },
permText: { fontSize: 10, color: c.mutedForeground },
uninstallRow: {
flexDirection: 'row',
justifyContent: 'flex-end',
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
borderTopColor: c.border,
},
});
}
+43 -36
View File
@@ -2,7 +2,8 @@ import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { AlertTriangle, FolderSync, Mail, X } from 'lucide-react-native';
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
type MailLayout = 'split' | 'focus';
@@ -14,6 +15,8 @@ type AttachmentPosition = 'beside-sender' | 'below-header';
const DEFAULT_KEYWORDS = ['attached', 'attachment', 'enclosed', 'attaching'];
function MailLayoutPreview({ value }: { value: MailLayout }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isSplit = value === 'split';
return (
<View style={styles.layoutPreview}>
@@ -66,6 +69,8 @@ function MailLayoutPreview({ value }: { value: MailLayout }) {
}
export function ReadingSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
const setExternalContentPolicyStore = useSettingsStore((s) => s.setExternalContentPolicy);
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
@@ -124,7 +129,7 @@ export function ReadingSettings() {
/>
{deleteAction === 'permanent' && (
<View style={styles.warning}>
<AlertTriangle size={14} color={colors.error} />
<AlertTriangle size={14} color={c.error} />
<Text style={styles.warningText}>Permanent deletion cannot be undone.</Text>
</View>
)}
@@ -144,7 +149,7 @@ export function ReadingSettings() {
/>
{archiveMode !== 'single' && (
<Pressable style={styles.inlineBtn}>
<FolderSync size={14} color={colors.text} />
<FolderSync size={14} color={c.text} />
<Text style={styles.inlineBtnText}>Reorganize existing archive</Text>
</Pressable>
)}
@@ -206,7 +211,7 @@ export function ReadingSettings() {
}
hitSlop={6}
>
<X size={12} color={colors.mutedForeground} />
<X size={12} color={c.mutedForeground} />
</Pressable>
</View>
))}
@@ -216,7 +221,7 @@ export function ReadingSettings() {
value={newKeyword}
onChangeText={setNewKeyword}
placeholder="Add a keyword"
placeholderTextColor={colors.mutedForeground}
placeholderTextColor={c.mutedForeground}
style={styles.keywordInput}
onSubmitEditing={() => {
const t = newKeyword.trim().toLowerCase();
@@ -286,7 +291,8 @@ export function ReadingSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
group: {},
warning: {
flexDirection: 'row',
@@ -298,12 +304,12 @@ const styles = StyleSheet.create({
},
warningText: {
...typography.caption,
color: colors.error,
color: c.error,
flex: 1,
},
divider: {
height: 1,
backgroundColor: colors.border,
backgroundColor: c.border,
marginTop: spacing.md,
},
inlineBtn: {
@@ -312,23 +318,23 @@ const styles = StyleSheet.create({
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: 6,
backgroundColor: colors.muted,
backgroundColor: c.muted,
borderRadius: radius.sm,
alignSelf: 'flex-start',
marginTop: spacing.sm,
},
inlineBtnText: {
...typography.body,
color: colors.text,
color: c.text,
},
subLabel: {
...typography.bodyMedium,
color: colors.text,
color: c.text,
marginTop: spacing.sm,
},
subDesc: {
...typography.caption,
color: colors.mutedForeground,
color: c.mutedForeground,
marginTop: 2,
marginBottom: spacing.sm,
},
@@ -344,11 +350,11 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: colors.muted,
backgroundColor: c.muted,
},
chipText: {
...typography.caption,
color: colors.text,
color: c.text,
},
addKeywordRow: {
flexDirection: 'row',
@@ -360,37 +366,37 @@ const styles = StyleSheet.create({
height: 32,
paddingHorizontal: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: colors.background,
color: colors.text,
backgroundColor: c.background,
color: c.text,
...typography.body,
},
addKeywordBtn: {
paddingHorizontal: spacing.md,
justifyContent: 'center',
backgroundColor: colors.muted,
backgroundColor: c.muted,
borderRadius: radius.sm,
},
addKeywordText: {
...typography.body,
color: colors.text,
color: c.text,
},
layoutPreview: {
marginTop: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
borderColor: c.border,
backgroundColor: c.background,
padding: spacing.md,
},
layoutTitle: {
...typography.bodyMedium,
color: colors.text,
color: c.text,
},
layoutDesc: {
...typography.caption,
color: colors.mutedForeground,
color: c.mutedForeground,
marginTop: 2,
},
layoutFrame: {
@@ -399,27 +405,27 @@ const styles = StyleSheet.create({
marginTop: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
overflow: 'hidden',
backgroundColor: 'rgba(24,24,27,0.4)',
},
layoutRail: {
width: 32,
borderRightWidth: 1,
borderRightColor: colors.border,
borderRightColor: c.border,
backgroundColor: 'rgba(24,24,27,0.6)',
},
layoutList: {
width: 96,
borderRightWidth: 1,
borderRightColor: colors.border,
backgroundColor: colors.background,
borderRightColor: c.border,
backgroundColor: c.background,
},
layoutRow: {
paddingHorizontal: 6,
paddingVertical: 4,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
layoutRowSel: {
backgroundColor: 'rgba(59,130,246,0.1)',
@@ -427,16 +433,16 @@ const styles = StyleSheet.create({
layoutRowName: {
fontSize: 9,
fontWeight: '500',
color: colors.text,
color: c.text,
},
layoutRowSub: {
fontSize: 9,
color: colors.mutedForeground,
color: c.mutedForeground,
},
layoutReader: {
flex: 1,
padding: spacing.md,
backgroundColor: colors.background,
backgroundColor: c.background,
},
layoutBar: {
height: 6,
@@ -455,14 +461,14 @@ const styles = StyleSheet.create({
},
layoutFocusText: {
fontSize: 9,
color: colors.text,
color: c.text,
},
trustedList: {
marginTop: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.muted,
borderColor: c.border,
backgroundColor: c.muted,
},
trustedRow: {
flexDirection: 'row',
@@ -472,7 +478,8 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
trustedEmail: { ...typography.caption, color: colors.text, flex: 1 },
trustedEmail: { ...typography.caption, color: c.text, flex: 1 },
});
}
+36 -31
View File
@@ -5,7 +5,8 @@ import {
} from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface SidebarApp {
id: string;
@@ -17,6 +18,8 @@ interface SidebarApp {
}
export function SidebarAppsSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [keepLoaded, setKeepLoaded] = useState(false);
const [apps, setApps] = useState<SidebarApp[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
@@ -63,9 +66,9 @@ export function SidebarAppsSettings() {
}
return (
<View key={app.id} style={styles.appRow}>
<GripVertical size={16} color={colors.mutedForeground} style={{ opacity: 0.5 }} />
<GripVertical size={16} color={c.mutedForeground} style={{ opacity: 0.5 }} />
<View style={styles.appIcon}>
<Globe size={16} color={colors.text} />
<Globe size={16} color={c.text} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.appName} numberOfLines={1}>{app.name}</Text>
@@ -77,16 +80,16 @@ export function SidebarAppsSettings() {
]}>
<Text style={[
styles.modeBadgeText,
{ color: app.openMode === 'inline' ? '#60a5fa' : colors.mutedForeground },
{ color: app.openMode === 'inline' ? '#60a5fa' : c.mutedForeground },
]}>
{app.openMode === 'inline' ? 'Inline' : 'Tab'}
</Text>
</View>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(app.id)}>
<Pencil size={14} color={colors.mutedForeground} />
<Pencil size={14} color={c.mutedForeground} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => removeApp(app.id)}>
<Trash2 size={14} color={colors.mutedForeground} />
<Trash2 size={14} color={c.mutedForeground} />
</Pressable>
</View>
);
@@ -101,7 +104,7 @@ export function SidebarAppsSettings() {
{!isAdding && editingId === null && (
<Pressable style={styles.addBtn} onPress={() => setIsAdding(true)}>
<Plus size={16} color={colors.text} />
<Plus size={16} color={c.text} />
<Text style={styles.addBtnText}>Add app</Text>
</Pressable>
)}
@@ -133,7 +136,7 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
value={name}
onChangeText={setName}
placeholder="App name"
placeholderTextColor={colors.mutedForeground}
placeholderTextColor={c.mutedForeground}
style={formStyles.input}
/>
</View>
@@ -144,7 +147,7 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
value={url}
onChangeText={setUrl}
placeholder="https://example.com"
placeholderTextColor={colors.mutedForeground}
placeholderTextColor={c.mutedForeground}
style={formStyles.input}
autoCapitalize="none"
keyboardType="url"
@@ -158,8 +161,8 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
onPress={() => setOpenMode('tab')}
style={[formStyles.modeBtn, openMode === 'tab' && formStyles.modeBtnActive]}
>
<ExternalLink size={14} color={openMode === 'tab' ? colors.primary : colors.text} />
<Text style={[formStyles.modeBtnText, openMode === 'tab' && { color: colors.primary }]}>
<ExternalLink size={14} color={openMode === 'tab' ? c.primary : c.text} />
<Text style={[formStyles.modeBtnText, openMode === 'tab' && { color: c.primary }]}>
New tab
</Text>
</Pressable>
@@ -167,8 +170,8 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
onPress={() => setOpenMode('inline')}
style={[formStyles.modeBtn, openMode === 'inline' && formStyles.modeBtnActive]}
>
<PanelRight size={14} color={openMode === 'inline' ? colors.primary : colors.text} />
<Text style={[formStyles.modeBtnText, openMode === 'inline' && { color: colors.primary }]}>
<PanelRight size={14} color={openMode === 'inline' ? c.primary : c.text} />
<Text style={[formStyles.modeBtnText, openMode === 'inline' && { color: c.primary }]}>
Inline
</Text>
</Pressable>
@@ -194,11 +197,12 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
emptyText: {
...typography.body,
color: colors.mutedForeground,
color: c.mutedForeground,
textAlign: 'center',
paddingVertical: spacing.lg,
},
@@ -209,25 +213,25 @@ const styles = StyleSheet.create({
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
appIcon: {
width: 32,
height: 32,
borderRadius: radius.sm,
backgroundColor: colors.muted,
backgroundColor: c.muted,
alignItems: 'center',
justifyContent: 'center',
},
appName: { ...typography.bodyMedium, color: colors.text },
appUrl: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
appName: { ...typography.bodyMedium, color: c.text },
appUrl: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
modeBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
},
modeBadgeInline: { backgroundColor: 'rgba(59, 130, 246, 0.1)' },
modeBadgeTab: { backgroundColor: colors.muted },
modeBadgeTab: { backgroundColor: c.muted },
modeBadgeText: { fontSize: 10, fontWeight: '500' },
iconBtn: {
width: 28,
@@ -244,10 +248,11 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
addBtnText: { ...typography.body, color: colors.text },
addBtnText: { ...typography.body, color: c.text },
});
}
const formStyles = StyleSheet.create({
form: {
@@ -255,19 +260,19 @@ const formStyles = StyleSheet.create({
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.muted,
borderColor: c.border,
backgroundColor: c.muted,
},
label: { ...typography.captionMedium, color: colors.text },
label: { ...typography.captionMedium, color: c.text },
input: {
marginTop: 4,
paddingHorizontal: spacing.md,
paddingVertical: 8,
borderRadius: radius.sm,
backgroundColor: colors.background,
backgroundColor: c.background,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
borderColor: c.border,
color: c.text,
...typography.body,
},
modeBtn: {
@@ -278,11 +283,11 @@ const formStyles = StyleSheet.create({
paddingVertical: 8,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
modeBtnActive: {
borderColor: colors.primary,
borderColor: c.primary,
backgroundColor: 'rgba(59, 130, 246, 0.1)',
},
modeBtnText: { ...typography.caption, color: colors.text },
modeBtnText: { ...typography.caption, color: c.text },
});
+27 -22
View File
@@ -6,7 +6,8 @@ import {
} from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface KeyRecord {
id: string;
@@ -31,6 +32,8 @@ const MOCK_KEYS: KeyRecord[] = [];
const MOCK_CERTS: PublicCert[] = [];
export function SmimeSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [keys, setKeys] = useState<KeyRecord[]>(MOCK_KEYS);
const [certs, setCerts] = useState<PublicCert[]>(MOCK_CERTS);
const [bindings, setBindings] = useState<Record<string, string>>({});
@@ -64,9 +67,9 @@ export function SmimeSettings() {
<View style={styles.certLeft}>
<View style={[styles.certIcon, expired ? styles.certIconError : styles.certIconOk]}>
{expired ? (
<ShieldAlert size={16} color={colors.error} />
<ShieldAlert size={16} color={c.error} />
) : (
<ShieldCheck size={16} color={colors.primary} />
<ShieldCheck size={16} color={c.primary} />
)}
</View>
<View style={{ flex: 1 }}>
@@ -80,19 +83,19 @@ export function SmimeSettings() {
<View style={styles.certActions}>
<Pressable style={styles.iconBtn} onPress={() => toggleUnlock(k.id)}>
{k.unlocked ? (
<Unlock size={16} color={colors.success} />
<Unlock size={16} color={c.success} />
) : (
<Lock size={16} color={colors.text} />
<Lock size={16} color={c.text} />
)}
</Pressable>
<Pressable style={styles.iconBtn}>
<Eye size={16} color={colors.text} />
<Eye size={16} color={c.text} />
</Pressable>
<Pressable style={styles.iconBtn}>
<Download size={16} color={colors.text} />
<Download size={16} color={c.text} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteKey(k.id)}>
<Trash2 size={16} color={colors.error} />
<Trash2 size={16} color={c.error} />
</Pressable>
</View>
</View>
@@ -105,7 +108,7 @@ export function SmimeSettings() {
</View>
<View style={{ alignItems: 'flex-start', marginTop: spacing.sm }}>
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
Import PKCS#12
</Button>
</View>
@@ -122,7 +125,7 @@ export function SmimeSettings() {
<View key={c.id} style={styles.certRow}>
<View style={styles.certLeft}>
<View style={[styles.certIcon, styles.certIconMuted]}>
<Users size={16} color={colors.mutedForeground} />
<Users size={16} color={c.mutedForeground} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.certName} numberOfLines={1}>{c.email || c.subject}</Text>
@@ -134,10 +137,10 @@ export function SmimeSettings() {
</View>
<View style={styles.certActions}>
<Pressable style={styles.iconBtn}>
<Eye size={16} color={colors.text} />
<Eye size={16} color={c.text} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteCert(c.id)}>
<Trash2 size={16} color={colors.error} />
<Trash2 size={16} color={c.error} />
</Pressable>
</View>
</View>
@@ -150,7 +153,7 @@ export function SmimeSettings() {
</View>
<View style={{ alignItems: 'flex-start', marginTop: spacing.sm }}>
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
Import Certificate
</Button>
</View>
@@ -185,7 +188,8 @@ export function SmimeSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
certRow: {
flexDirection: 'row',
@@ -195,7 +199,7 @@ const styles = StyleSheet.create({
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
certLeft: {
flexDirection: 'row',
@@ -211,12 +215,12 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
certIconOk: { backgroundColor: colors.primaryBg },
certIconError: { backgroundColor: colors.errorBg },
certIconMuted: { backgroundColor: colors.muted },
certName: { ...typography.bodyMedium, color: colors.text },
certMeta: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
expiredText: { color: colors.error },
certIconOk: { backgroundColor: c.primaryBg },
certIconError: { backgroundColor: c.errorBg },
certIconMuted: { backgroundColor: c.muted },
certName: { ...typography.bodyMedium, color: c.text },
certMeta: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
expiredText: { color: c.error },
certActions: { flexDirection: 'row', gap: 2 },
iconBtn: {
width: 32,
@@ -227,8 +231,9 @@ const styles = StyleSheet.create({
},
empty: {
...typography.body,
color: colors.mutedForeground,
color: c.mutedForeground,
textAlign: 'center',
paddingVertical: spacing.lg,
},
});
}
+11 -6
View File
@@ -3,9 +3,12 @@ import { View, Text, StyleSheet } from 'react-native';
import { FileText, Download, Upload } from 'lucide-react-native';
import { SettingsSection } from './settings-section';
import Button from '../Button';
import { colors, spacing, typography } from '../../theme/tokens';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
export function TemplateSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [templates] = useState<{ id: string; name: string }[]>([]);
return (
@@ -13,7 +16,7 @@ export function TemplateSettings() {
<SettingsSection title="Templates" description="Reusable email templates for common responses.">
<View style={styles.row}>
<View style={styles.countRow}>
<FileText size={16} color={colors.mutedForeground} />
<FileText size={16} color={c.mutedForeground} />
<Text style={styles.count}>
{templates.length === 1 ? '1 template' : `${templates.length} templates`}
</Text>
@@ -28,11 +31,11 @@ export function TemplateSettings() {
variant="outline"
size="sm"
disabled={templates.length === 0}
icon={<Download size={14} color={colors.text} />}
icon={<Download size={14} color={c.text} />}
>
Export
</Button>
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
Import
</Button>
</View>
@@ -41,7 +44,8 @@ export function TemplateSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
row: {
flexDirection: 'row',
@@ -50,6 +54,7 @@ const styles = StyleSheet.create({
paddingVertical: spacing.md,
},
countRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
count: { ...typography.body, color: colors.mutedForeground },
count: { ...typography.body, color: c.mutedForeground },
actions: { flexDirection: 'row', gap: spacing.md, paddingVertical: spacing.md },
});
}
+18 -13
View File
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Upload, Check, Palette, Trash2 } from 'lucide-react-native';
import { SettingsSection, SettingItem } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface Theme {
id: string | null;
@@ -20,6 +21,8 @@ const BUILT_IN: Theme[] = [
];
export function ThemesSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [themes] = useState<Theme[]>(BUILT_IN);
const [active, setActive] = useState<string | null>(null);
@@ -40,12 +43,12 @@ export function ThemesSettings() {
style={[styles.card, isActive && styles.cardActive]}
>
<View style={styles.preview}>
<Palette size={32} color={colors.mutedForeground} style={{ opacity: 0.4 }} />
<Palette size={32} color={c.mutedForeground} style={{ opacity: 0.4 }} />
</View>
<View style={{ width: '100%' }}>
<View style={styles.cardHeader}>
<Text style={styles.cardName} numberOfLines={1}>{theme.name}</Text>
{isActive && <Check size={16} color={colors.primary} />}
{isActive && <Check size={16} color={c.primary} />}
</View>
<Text style={styles.cardAuthor} numberOfLines={1}>{theme.author}</Text>
{theme.variants && (
@@ -67,7 +70,7 @@ export function ThemesSettings() {
label="Upload Theme"
description="Install a custom theme from a .zip file."
>
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
Upload .zip
</Button>
</SettingItem>
@@ -75,7 +78,8 @@ export function ThemesSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
@@ -87,19 +91,19 @@ const styles = StyleSheet.create({
padding: spacing.md,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.border,
backgroundColor: colors.card,
borderColor: c.border,
backgroundColor: c.card,
gap: spacing.sm,
},
cardActive: {
borderColor: colors.primary,
borderColor: c.primary,
backgroundColor: 'rgba(59, 130, 246, 0.05)',
},
preview: {
width: '100%',
aspectRatio: 16 / 10,
borderRadius: radius.md,
backgroundColor: colors.muted,
backgroundColor: c.muted,
alignItems: 'center',
justifyContent: 'center',
},
@@ -108,14 +112,15 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'space-between',
},
cardName: { ...typography.bodyMedium, color: colors.text, flex: 1 },
cardAuthor: { ...typography.caption, color: colors.mutedForeground },
cardName: { ...typography.bodyMedium, color: c.text, flex: 1 },
cardAuthor: { ...typography.caption, color: c.mutedForeground },
variants: { flexDirection: 'row', gap: 4, marginTop: 4 },
variantPill: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.xs,
backgroundColor: colors.muted,
backgroundColor: c.muted,
},
variantText: { fontSize: 10, color: colors.mutedForeground },
variantText: { fontSize: 10, color: c.mutedForeground },
});
}
+15 -10
View File
@@ -2,7 +2,8 @@ import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Linking, Alert } from 'react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useUpdatesStore } from '../../stores/updates-store';
import type { InstallProgress } from '../../lib/install-update';
@@ -38,6 +39,8 @@ function installButtonLabel(
}
export function UpdatesSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const hydrated = useUpdatesStore((s) => s.hydrated);
const hydrate = useUpdatesStore((s) => s.hydrate);
const checking = useUpdatesStore((s) => s.checking);
@@ -158,19 +161,20 @@ export function UpdatesSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
value: { ...typography.bodyMedium, color: colors.text },
value: { ...typography.bodyMedium, color: c.text },
notesBox: {
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.muted,
borderColor: c.border,
backgroundColor: c.muted,
gap: spacing.sm,
},
notesTitle: { ...typography.caption, color: colors.mutedForeground, textTransform: 'uppercase' },
notesBody: { ...typography.body, color: colors.text, lineHeight: 20 },
notesTitle: { ...typography.caption, color: c.mutedForeground, textTransform: 'uppercase' },
notesBody: { ...typography.body, color: c.text, lineHeight: 20 },
progressBox: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
@@ -179,12 +183,13 @@ const styles = StyleSheet.create({
progressTrack: {
height: 6,
borderRadius: 3,
backgroundColor: colors.muted,
backgroundColor: c.muted,
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: colors.primary,
backgroundColor: c.primary,
},
progressLabel: { ...typography.caption, color: colors.mutedForeground },
progressLabel: { ...typography.caption, color: c.mutedForeground },
});
}
+38 -33
View File
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, TextInput, Pressable, ActivityIndicator, Alert
import { AlertTriangle, Eye, EyeOff } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useVacationStore } from '../../stores/vacation-store';
function toJmapDate(input: string): string | null {
@@ -23,6 +24,8 @@ function fromJmapDate(value: string | null): string {
}
export function VacationSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const store = useVacationStore();
const [enabled, setEnabled] = useState(store.isEnabled);
@@ -75,7 +78,7 @@ export function VacationSettings() {
if (store.isLoading && !store.hasLoaded) {
return (
<View style={styles.centered}>
<ActivityIndicator color={colors.primary} />
<ActivityIndicator color={c.primary} />
</View>
);
}
@@ -84,7 +87,7 @@ export function VacationSettings() {
return (
<SettingsSection title="Vacation Responder" description="Automatically reply while you are away.">
<View style={styles.unsupported}>
<AlertTriangle size={16} color={colors.warning} />
<AlertTriangle size={16} color={c.warning} />
<Text style={styles.unsupportedText}>
Your server does not advertise support for the JMAP vacation responder.
</Text>
@@ -114,7 +117,7 @@ export function VacationSettings() {
value={fromDate}
onChangeText={setFromDate}
placeholder="YYYY-MM-DD HH:MM"
placeholderTextColor={colors.mutedForeground}
placeholderTextColor={c.mutedForeground}
style={styles.dateInput}
autoCapitalize="none"
/>
@@ -124,7 +127,7 @@ export function VacationSettings() {
value={toDate}
onChangeText={setToDate}
placeholder="YYYY-MM-DD HH:MM"
placeholderTextColor={colors.mutedForeground}
placeholderTextColor={c.mutedForeground}
style={styles.dateInput}
autoCapitalize="none"
/>
@@ -137,7 +140,7 @@ export function VacationSettings() {
value={subject}
onChangeText={setSubject}
placeholder="Out of office"
placeholderTextColor={colors.mutedForeground}
placeholderTextColor={c.mutedForeground}
style={styles.subjectInput}
/>
</SettingItem>
@@ -148,7 +151,7 @@ export function VacationSettings() {
value={body}
onChangeText={setBody}
placeholder="I am away from…"
placeholderTextColor={colors.mutedForeground}
placeholderTextColor={c.mutedForeground}
style={styles.bodyInput}
multiline
numberOfLines={6}
@@ -161,9 +164,9 @@ export function VacationSettings() {
<SettingsSection title="Preview">
<Pressable style={styles.previewToggle} onPress={() => setShowPreview((v) => !v)}>
{showPreview ? (
<EyeOff size={14} color={colors.primary} />
<EyeOff size={14} color={c.primary} />
) : (
<Eye size={14} color={colors.primary} />
<Eye size={14} color={c.primary} />
)}
<Text style={styles.previewToggleText}>
{showPreview ? 'Hide preview' : 'Show preview'}
@@ -182,7 +185,7 @@ export function VacationSettings() {
<View style={styles.warnings}>
{warnings.map((w, i) => (
<View key={i} style={styles.warnRow}>
<AlertTriangle size={14} color={colors.warning} />
<AlertTriangle size={14} color={c.warning} />
<Text style={styles.warnText}>{w}</Text>
</View>
))}
@@ -204,7 +207,8 @@ export function VacationSettings() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xl },
centered: { paddingVertical: 40, alignItems: 'center' },
unsupported: {
@@ -215,7 +219,7 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(202,138,4,0.12)',
borderRadius: radius.sm,
},
unsupportedText: { ...typography.body, color: colors.warning, flex: 1 },
unsupportedText: { ...typography.body, color: c.warning, flex: 1 },
statusRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
pill: {
paddingHorizontal: 8,
@@ -223,19 +227,19 @@ const styles = StyleSheet.create({
borderRadius: radius.full,
},
pillActive: { backgroundColor: 'rgba(22,163,74,0.2)' },
pillInactive: { backgroundColor: colors.muted },
pillInactive: { backgroundColor: c.muted },
pillText: { fontSize: 11, fontWeight: '500' },
pillTextActive: { color: '#4ade80' },
pillTextInactive: { color: colors.mutedForeground },
pillTextInactive: { color: c.mutedForeground },
dateInput: {
minWidth: 180,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.muted,
backgroundColor: c.muted,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
borderColor: c.border,
color: c.text,
...typography.body,
},
subjectInput: {
@@ -243,47 +247,48 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.muted,
backgroundColor: c.muted,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
borderColor: c.border,
color: c.text,
...typography.body,
},
bodyBlock: { paddingVertical: spacing.md },
bodyLabel: { ...typography.bodyMedium, color: colors.text },
bodyDesc: { ...typography.caption, color: colors.mutedForeground, marginTop: 2, marginBottom: spacing.sm },
bodyLabel: { ...typography.bodyMedium, color: c.text },
bodyDesc: { ...typography.caption, color: c.mutedForeground, marginTop: 2, marginBottom: spacing.sm },
bodyInput: {
width: '100%',
minHeight: 96,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
backgroundColor: colors.muted,
backgroundColor: c.muted,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
borderColor: c.border,
color: c.text,
...typography.body,
},
previewToggle: { flexDirection: 'row', alignItems: 'center', gap: 6 },
previewToggleText: { ...typography.body, color: colors.primary },
previewToggleText: { ...typography.body, color: c.primary },
previewBox: {
marginTop: spacing.sm,
padding: spacing.lg,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
borderColor: c.border,
backgroundColor: c.background,
},
previewSubject: { ...typography.bodyMedium, color: colors.text, marginBottom: spacing.sm },
previewBody: { ...typography.body, color: colors.mutedForeground },
previewSubject: { ...typography.bodyMedium, color: c.text, marginBottom: spacing.sm },
previewBody: { ...typography.body, color: c.mutedForeground },
warnings: { gap: spacing.sm },
warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
warnText: { ...typography.body, color: colors.warning, flex: 1 },
warnText: { ...typography.body, color: c.warning, flex: 1 },
errorBox: {
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: colors.errorBg,
backgroundColor: c.errorBg,
},
errorText: { ...typography.body, color: colors.error },
errorText: { ...typography.body, color: c.error },
saveRow: { alignItems: 'flex-end' },
});
}
+33 -22
View File
@@ -9,7 +9,8 @@ import {
ViewStyle,
} from 'react-native';
import { FlaskConical, Lock, ChevronDown, Check } from 'lucide-react-native';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import ToggleSwitchComponent from '../ToggleSwitch';
/**
@@ -36,6 +37,8 @@ export function SettingsSection({
experimental,
experimentalDescription,
}: SettingsSectionProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.section}>
{experimental && (
@@ -73,12 +76,14 @@ export function SettingItem({
locked,
noBorder,
}: SettingItemProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked]}>
<View style={styles.settingContent}>
<View style={styles.settingLabelRow}>
<Text style={styles.settingLabel}>{label}</Text>
{locked && <Lock size={12} color={colors.mutedForeground} />}
{locked && <Lock size={12} color={c.mutedForeground} />}
</View>
{description && <Text style={styles.settingDescription}>{description}</Text>}
</View>
@@ -111,6 +116,8 @@ interface RadioGroupProps {
}
export function RadioGroup({ value, onChange, options, style }: RadioGroupProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.radioGroup, style]}>
{options.map((opt) => {
@@ -145,6 +152,8 @@ interface SelectProps {
}
export function Select({ value, onChange, options, style }: SelectProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [open, setOpen] = useState(false);
const current = options.find((o) => o.value === value);
@@ -152,7 +161,7 @@ export function Select({ value, onChange, options, style }: SelectProps) {
<>
<Pressable style={[styles.select, style]} onPress={() => setOpen(true)}>
<Text style={styles.selectText}>{current?.label ?? ''}</Text>
<ChevronDown size={14} color={colors.mutedForeground} />
<ChevronDown size={14} color={c.mutedForeground} />
</Pressable>
<Modal
visible={open}
@@ -178,7 +187,7 @@ export function Select({ value, onChange, options, style }: SelectProps) {
}}
>
<Text style={styles.selectItemText}>{opt.label}</Text>
{selected && <Check size={16} color={colors.primary} />}
{selected && <Check size={16} color={c.primary} />}
</Pressable>
);
})}
@@ -190,7 +199,8 @@ export function Select({ value, onChange, options, style }: SelectProps) {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
section: {
gap: spacing.lg,
},
@@ -199,11 +209,11 @@ const styles = StyleSheet.create({
},
sectionTitle: {
...typography.h3,
color: colors.text,
color: c.text,
},
sectionDescription: {
...typography.body,
color: colors.mutedForeground,
color: c.mutedForeground,
marginTop: 4,
},
experimentalBanner: {
@@ -233,7 +243,7 @@ const styles = StyleSheet.create({
},
settingItemBorder: {
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
locked: {
opacity: 0.6,
@@ -249,11 +259,11 @@ const styles = StyleSheet.create({
},
settingLabel: {
...typography.bodyMedium,
color: colors.text,
color: c.text,
},
settingDescription: {
...typography.caption,
color: colors.mutedForeground,
color: c.mutedForeground,
marginTop: 2,
},
settingRight: {
@@ -270,20 +280,20 @@ const styles = StyleSheet.create({
borderRadius: radius.sm,
},
radioSelected: {
backgroundColor: colors.primary,
backgroundColor: c.primary,
},
radioUnselected: {
backgroundColor: colors.muted,
backgroundColor: c.muted,
},
radioText: {
...typography.caption,
},
radioTextSelected: {
color: colors.primaryForeground,
color: c.primaryForeground,
fontWeight: '500',
},
radioTextUnselected: {
color: colors.text,
color: c.text,
},
select: {
flexDirection: 'row',
@@ -292,14 +302,14 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.muted,
backgroundColor: c.muted,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
minWidth: 120,
},
selectText: {
...typography.body,
color: colors.text,
color: c.text,
flex: 1,
},
modalBackdrop: {
@@ -313,10 +323,10 @@ const styles = StyleSheet.create({
width: '100%',
maxWidth: 320,
maxHeight: '70%',
backgroundColor: colors.popover,
backgroundColor: c.popover,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
paddingVertical: spacing.sm,
},
selectItem: {
@@ -327,10 +337,11 @@ const styles = StyleSheet.create({
paddingVertical: spacing.md,
},
selectItemPressed: {
backgroundColor: colors.accent,
backgroundColor: c.accent,
},
selectItemText: {
...typography.body,
color: colors.text,
color: c.text,
},
});
});
}
+76
View File
@@ -0,0 +1,76 @@
import React from 'react';
import { Animated, PanResponder } from 'react-native';
interface Options {
/** Animated value driving the sheet's translateY. Resting (open) position is 0. */
slideY: Animated.Value;
/** Off-screen target the sheet animates to when dismissed. */
closedY: number;
/** Called when the gesture commits a dismissal. */
onClose: () => void;
/** Pixels of downward drag needed to commit a dismiss on release. */
closeDistance?: number;
/** Downward velocity (px/ms) that commits a dismiss regardless of distance. */
closeVelocity?: number;
}
/**
* Wires drag-to-dismiss on a bottom sheet's grab handle. Spread the returned
* panHandlers on the handle/header area — not the scrollable body — so vertical
* scrolling inside the sheet is not intercepted.
*/
export function useSheetDrag({
slideY,
closedY,
onClose,
closeDistance = 100,
closeVelocity = 0.6,
}: Options) {
const startRef = React.useRef(0);
const panResponder = React.useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: (_, g) =>
Math.abs(g.dy) > 4 && Math.abs(g.dy) > Math.abs(g.dx),
onPanResponderGrant: () => {
slideY.stopAnimation((v) => {
startRef.current = v;
});
},
onPanResponderMove: (_, g) => {
const next = startRef.current + Math.max(0, g.dy);
slideY.setValue(next);
},
onPanResponderRelease: (_, g) => {
const traveled = startRef.current + Math.max(0, g.dy);
if (traveled > closeDistance || g.vy > closeVelocity) {
Animated.timing(slideY, {
toValue: closedY,
duration: 180,
useNativeDriver: true,
}).start(({ finished }) => {
if (finished) onClose();
});
} else {
Animated.spring(slideY, {
toValue: 0,
useNativeDriver: true,
bounciness: 0,
}).start();
}
},
onPanResponderTerminate: () => {
Animated.spring(slideY, {
toValue: 0,
useNativeDriver: true,
bounciness: 0,
}).start();
},
}),
[slideY, closedY, onClose, closeDistance, closeVelocity],
);
return panResponder.panHandlers;
}
+31 -24
View File
@@ -31,7 +31,8 @@ import {
addWeeks,
subWeeks,
} from 'date-fns';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button } from '../components';
import { useCalendarStore } from '../stores/calendar-store';
import { MonthView } from '../components/calendar/MonthView';
@@ -92,6 +93,8 @@ function headerTitle(viewMode: ViewMode, currentDate: Date): string {
}
export default function CalendarScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [currentDate, setCurrentDate] = React.useState(new Date());
const [selectedDate, setSelectedDate] = React.useState(new Date());
const [viewMode, setViewMode] = React.useState<ViewMode>('month');
@@ -261,7 +264,7 @@ export default function CalendarScreen() {
hitSlop={8}
style={styles.headerBtn}
>
<Menu size={20} color={colors.text} />
<Menu size={20} color={c.text} />
</Pressable>
<View style={styles.headerLeft}>
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate)}</Text>
@@ -281,26 +284,26 @@ export default function CalendarScreen() {
style={[styles.viewToggleBtn, active && styles.viewToggleBtnActive]}
onPress={() => setViewMode(mode)}
>
<Icon size={16} color={active ? colors.primary : colors.textMuted} />
<Icon size={16} color={active ? c.primary : c.textMuted} />
</Pressable>
);
})}
</View>
<Pressable style={styles.fab} onPress={() => openCreate(selectedDate)}>
<Plus size={18} color={colors.primaryForeground} />
<Plus size={18} color={c.primaryForeground} />
</Pressable>
</View>
</View>
<View style={styles.nav}>
<Pressable onPress={goPrev} style={styles.navBtn} hitSlop={8}>
<ChevronLeft size={20} color={colors.text} />
<ChevronLeft size={20} color={c.text} />
</Pressable>
<Button variant="outline" size="sm" onPress={goToday}>
Today
</Button>
<Pressable onPress={goNext} style={styles.navBtn} hitSlop={8}>
<ChevronRight size={20} color={colors.text} />
<ChevronRight size={20} color={c.text} />
</Pressable>
</View>
@@ -350,7 +353,7 @@ export default function CalendarScreen() {
<Text style={styles.dayDetailTitle}>
{isSelectedToday ? "Today's Events" : format(selectedDate, 'EEEE, MMMM d')}
</Text>
{loading && <ActivityIndicator size="small" color={colors.textMuted} />}
{loading && <ActivityIndicator size="small" color={c.textMuted} />}
</View>
<DayEventList
date={selectedDate}
@@ -415,6 +418,8 @@ function DayEventList({
refreshing: boolean;
onRefresh: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const dayEvents = React.useMemo(
() => eventsOnDayFromIndex(eventsByDay, date),
[eventsByDay, date],
@@ -424,10 +429,10 @@ function DayEventList({
<ScrollView
contentContainerStyle={styles.emptyState}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.textMuted} />
}
>
<CalendarDays size={32} color={colors.surfaceActive} />
<CalendarDays size={32} color={c.surfaceActive} />
<Text style={styles.emptyTitle}>No events</Text>
<Text style={styles.emptySubtitle}>Tap + to create one</Text>
</ScrollView>
@@ -437,7 +442,7 @@ function DayEventList({
<ScrollView
contentContainerStyle={styles.dayList}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.textMuted} />
}
>
{dayEvents.map((event) => (
@@ -452,8 +457,9 @@ function DayEventList({
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
@@ -470,12 +476,12 @@ const styles = StyleSheet.create({
borderRadius: radius.full,
},
headerLeft: { flex: 1 },
headerTitle: { ...typography.h3, color: colors.text },
headerSubtitle: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
headerTitle: { ...typography.h3, color: c.text },
headerSubtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
viewToggle: {
flexDirection: 'row',
backgroundColor: colors.surface,
backgroundColor: c.surface,
borderRadius: radius.md,
padding: 2,
},
@@ -486,12 +492,12 @@ const styles = StyleSheet.create({
justifyContent: 'center',
borderRadius: radius.sm,
},
viewToggleBtnActive: { backgroundColor: colors.background },
viewToggleBtnActive: { backgroundColor: c.background },
fab: {
width: 36,
height: 36,
borderRadius: radius.full,
backgroundColor: colors.primary,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
},
@@ -512,18 +518,18 @@ const styles = StyleSheet.create({
},
errorBanner: {
backgroundColor: colors.errorBg,
backgroundColor: c.errorBg,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
errorText: { ...typography.caption, color: colors.errorForeground },
errorText: { ...typography.caption, color: c.errorForeground },
content: { flex: 1 },
dayDetail: {
flex: 1,
borderTopWidth: 1,
borderTopColor: colors.border,
borderTopColor: c.border,
},
dayDetailHeader: {
flexDirection: 'row',
@@ -532,7 +538,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
dayDetailTitle: { ...typography.bodyMedium, color: colors.text },
dayDetailTitle: { ...typography.bodyMedium, color: c.text },
dayList: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
emptyState: {
@@ -541,6 +547,7 @@ const styles = StyleSheet.create({
gap: spacing.xs,
flexGrow: 1,
},
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
emptySubtitle: { ...typography.caption, color: colors.textMuted },
});
emptyTitle: { ...typography.bodyMedium, color: c.textSecondary },
emptySubtitle: { ...typography.caption, color: c.textMuted },
});
}
+52 -43
View File
@@ -9,7 +9,8 @@ import {
X, Send, Paperclip, ChevronDown, Bold, Italic, List,
Link2, Image as ImageIcon, MoreHorizontal,
} from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button } from '../components';
import { useEmailStore } from '../stores/email-store';
import { useContactsStore } from '../stores/contacts-store';
@@ -42,13 +43,15 @@ function parseRecipients(input: string): Recipient[] {
}
function RecipientChip({ recipient, onRemove }: { recipient: Recipient; onRemove: () => void }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.chip}>
<Text style={styles.chipText} numberOfLines={1}>
{recipient.name || recipient.email}
</Text>
<Pressable onPress={onRemove} hitSlop={8}>
<X size={12} color={colors.textMuted} />
<X size={12} color={c.textMuted} />
</Pressable>
</View>
);
@@ -60,6 +63,8 @@ function SuggestionList({
suggestions: Array<{ contact: ContactCard; name: string; email: string }>;
onPick: (s: { name: string; email: string }) => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.suggestionBox}>
{suggestions.map((s, i) => {
@@ -89,6 +94,8 @@ function SuggestionList({
}
export default function ComposeScreen({ route, navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const replyTo = route.params?.replyTo;
const mode = route.params?.mode;
const prefillTo = route.params?.prefillTo;
@@ -274,14 +281,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerBtn}>
<X size={22} color={colors.text} />
<X size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>
{mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message'}
</Text>
<View style={styles.headerRight}>
<Pressable style={styles.headerBtn}>
<Paperclip size={20} color={colors.text} />
<Paperclip size={20} color={c.text} />
</Pressable>
<Button
variant="default"
@@ -290,11 +297,11 @@ export default function ComposeScreen({ route, navigation }: Props) {
disabled={!canSend}
icon={
sending ? (
<ActivityIndicator color={colors.primaryForeground} size="small" />
<ActivityIndicator color={c.primaryForeground} size="small" />
) : (
<Send
size={14}
color={canSend ? colors.primaryForeground : colors.textMuted}
color={canSend ? c.primaryForeground : c.textMuted}
/>
)
}
@@ -316,7 +323,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<Text style={styles.fromText} numberOfLines={1}>
{primaryIdentity?.email ?? (identityError ? 'identity unavailable' : 'Loading...')}
</Text>
<ChevronDown size={14} color={colors.textMuted} />
<ChevronDown size={14} color={c.textMuted} />
</View>
</View>
@@ -333,7 +340,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<TextInput
style={styles.recipientInput}
placeholder="Add recipient..."
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
value={toInput}
onChangeText={setToInput}
onFocus={() => setActiveField('to')}
@@ -371,7 +378,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<TextInput
style={styles.recipientInput}
placeholder="Add Cc..."
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
value={ccInput}
onChangeText={setCcInput}
onFocus={() => setActiveField('cc')}
@@ -396,7 +403,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<TextInput
style={styles.subjectInput}
placeholder="Email subject"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
value={subject}
onChangeText={setSubject}
/>
@@ -405,7 +412,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<TextInput
style={styles.bodyInput}
placeholder="Write your message..."
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
value={body}
onChangeText={setBody}
multiline
@@ -430,22 +437,22 @@ export default function ComposeScreen({ route, navigation }: Props) {
<View style={styles.formatBar}>
<View style={styles.formatActions}>
<Pressable style={styles.formatBtn}>
<Bold size={18} color={colors.textSecondary} />
<Bold size={18} color={c.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<Italic size={18} color={colors.textSecondary} />
<Italic size={18} color={c.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<List size={18} color={colors.textSecondary} />
<List size={18} color={c.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<Link2 size={18} color={colors.textSecondary} />
<Link2 size={18} color={c.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<ImageIcon size={18} color={colors.textSecondary} />
<ImageIcon size={18} color={c.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<MoreHorizontal size={18} color={colors.textSecondary} />
<MoreHorizontal size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
@@ -454,8 +461,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
flex: { flex: 1 },
header: {
@@ -465,7 +473,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
gap: spacing.sm,
},
headerBtn: {
@@ -475,7 +483,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
borderRadius: radius.full,
},
headerTitle: { ...typography.h3, color: colors.text, flex: 1 },
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
headerRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
sendButtonDisabled: { opacity: 0.5 },
@@ -485,12 +493,12 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
borderBottomColor: c.borderLight,
gap: spacing.md,
},
fieldLabel: {
...typography.body,
color: colors.textMuted,
color: c.textMuted,
width: 56,
paddingTop: 10,
},
@@ -501,7 +509,7 @@ const styles = StyleSheet.create({
paddingVertical: 10,
gap: spacing.sm,
},
fromText: { ...typography.body, color: colors.text },
fromText: { ...typography.body, color: c.text },
recipientField: {
flex: 1,
flexDirection: 'row',
@@ -517,24 +525,24 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
backgroundColor: colors.surfaceActive,
backgroundColor: c.surfaceActive,
maxWidth: 220,
},
chipText: { ...typography.caption, color: colors.text, flexShrink: 1 },
chipText: { ...typography.caption, color: c.text, flexShrink: 1 },
recipientInput: {
flexGrow: 1,
minWidth: 100,
...typography.body,
color: colors.text,
color: c.text,
paddingVertical: 6,
},
suggestionBox: {
marginTop: spacing.xs,
marginLeft: 60,
borderWidth: 1,
borderColor: colors.borderLight,
borderColor: c.borderLight,
borderRadius: radius.sm,
backgroundColor: colors.card,
backgroundColor: c.card,
overflow: 'hidden',
},
suggestionRow: {
@@ -544,33 +552,33 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
gap: spacing.sm,
},
suggestionRowPressed: { backgroundColor: colors.surfaceHover },
suggestionRowPressed: { backgroundColor: c.surfaceHover },
suggestionAvatar: {
width: 32, height: 32,
borderRadius: 16,
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
alignItems: 'center',
justifyContent: 'center',
},
suggestionAvatarText: { ...typography.captionMedium, color: colors.primary },
suggestionAvatarText: { ...typography.captionMedium, color: c.primary },
suggestionText: { flex: 1 },
suggestionName: { ...typography.bodyMedium, color: colors.text },
suggestionEmail: { ...typography.caption, color: colors.textSecondary },
suggestionName: { ...typography.bodyMedium, color: c.text },
suggestionEmail: { ...typography.caption, color: c.textSecondary },
ccToggle: {
paddingHorizontal: spacing.sm,
paddingVertical: 6,
},
ccToggleText: { ...typography.caption, color: colors.primary },
ccToggleText: { ...typography.caption, color: c.primary },
subjectInput: {
flex: 1,
...typography.body,
color: colors.text,
color: c.text,
paddingVertical: 10,
},
bodyInput: {
...typography.body,
color: colors.text,
color: c.text,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
minHeight: 200,
@@ -583,15 +591,15 @@ const styles = StyleSheet.create({
marginTop: spacing.md,
gap: spacing.sm,
},
quoteBorder: { width: 3, backgroundColor: colors.border, borderRadius: radius.xs },
quoteBorder: { width: 3, backgroundColor: c.border, borderRadius: radius.xs },
quoteContent: { flex: 1 },
quoteMeta: { ...typography.caption, color: colors.textMuted, marginBottom: spacing.xs },
quoteText: { ...typography.caption, color: colors.textSecondary, lineHeight: 18 },
quoteMeta: { ...typography.caption, color: c.textMuted, marginBottom: spacing.xs },
quoteText: { ...typography.caption, color: c.textSecondary, lineHeight: 18 },
formatBar: {
borderTopWidth: 1,
borderTopColor: colors.border,
backgroundColor: colors.surface,
borderTopColor: c.border,
backgroundColor: c.surface,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
},
@@ -606,4 +614,5 @@ const styles = StyleSheet.create({
justifyContent: 'center',
borderRadius: radius.sm,
},
});
});
}
+62 -51
View File
@@ -24,18 +24,19 @@ import { contactToVCard } from '../lib/vcard';
import SenderAvatar from '../components/SenderAvatar';
import Dialog from '../components/Dialog';
import { ContactActivity } from '../components/contacts/ContactActivity';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
const CATEGORY_COLORS: Record<string, string> = {
contact: colors.primary,
work: colors.calendar.orange,
location: colors.calendar.green,
personal: colors.calendar.pink,
digital: colors.calendar.teal,
notes: colors.calendar.purple,
contact: c.primary,
work: c.calendar.orange,
location: c.calendar.green,
personal: c.calendar.pink,
digital: c.calendar.teal,
notes: c.calendar.purple,
};
function Section({
@@ -46,7 +47,9 @@ function Section({
category?: keyof typeof CATEGORY_COLORS;
children: React.ReactNode;
}) {
const accent = CATEGORY_COLORS[category] || colors.primary;
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const accent = CATEGORY_COLORS[category] || c.primary;
return (
<View style={[styles.section, { borderLeftColor: accent }]}>
<View style={styles.sectionHeader}>
@@ -59,6 +62,8 @@ function Section({
}
function ContextChips({ contexts }: { contexts?: Record<string, boolean> }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
if (!contexts) return null;
const keys = Object.keys(contexts).filter((k) => contexts[k]);
if (keys.length === 0) return null;
@@ -74,6 +79,8 @@ function ContextChips({ contexts }: { contexts?: Record<string, boolean> }) {
}
export default function ContactDetailScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
const route = useRoute<Route>();
const { contactId } = route.params;
@@ -90,7 +97,7 @@ export default function ContactDetailScreen() {
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<ArrowLeft size={22} color={colors.text} />
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>Contact</Text>
</View>
@@ -183,7 +190,7 @@ export default function ContactDetailScreen() {
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<ArrowLeft size={22} color={colors.text} />
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{name}</Text>
<View style={styles.headerActions}>
@@ -193,7 +200,7 @@ export default function ContactDetailScreen() {
style={styles.headerBtn}
hitSlop={8}
>
<Copy size={18} color={colors.text} />
<Copy size={18} color={c.text} />
</Pressable>
)}
<Pressable
@@ -201,14 +208,14 @@ export default function ContactDetailScreen() {
style={styles.headerBtn}
hitSlop={8}
>
<Edit2 size={18} color={colors.text} />
<Edit2 size={18} color={c.text} />
</Pressable>
<Pressable
onPress={() => setConfirmDelete(true)}
style={styles.headerBtn}
hitSlop={8}
>
<Trash2 size={18} color={colors.error} />
<Trash2 size={18} color={c.error} />
</Pressable>
</View>
</View>
@@ -227,17 +234,17 @@ export default function ContactDetailScreen() {
<View style={styles.quickActions}>
{!!email && (
<QuickAction icon={<Mail size={18} color={colors.primary} />} label="Email" onPress={() => openMail(email)} />
<QuickAction icon={<Mail size={18} color={c.primary} />} label="Email" onPress={() => openMail(email)} />
)}
{!!phone && (
<QuickAction icon={<Phone size={18} color={colors.primary} />} label="Call" onPress={() => openTel(phone)} />
<QuickAction icon={<Phone size={18} color={c.primary} />} label="Call" onPress={() => openTel(phone)} />
)}
{!!phone && (
<QuickAction icon={<MessageSquare size={18} color={colors.primary} />} label="SMS" onPress={() => openSms(phone)} />
<QuickAction icon={<MessageSquare size={18} color={c.primary} />} label="SMS" onPress={() => openSms(phone)} />
)}
{!!name && (
<QuickAction
icon={<Share2 size={18} color={colors.primary} />}
icon={<Share2 size={18} color={c.primary} />}
label="Share"
onPress={async () => {
const vcard = contactToVCard(contact);
@@ -263,7 +270,7 @@ export default function ContactDetailScreen() {
</View>
{emails.length > 0 && (
<Section icon={<Mail size={14} color={colors.primary} />} title="Email" category="contact">
<Section icon={<Mail size={14} color={c.primary} />} title="Email" category="contact">
{emails.map((e) => (
<Pressable key={e.id} onPress={() => openMail(e.address)} style={styles.fieldRow}>
<Text style={styles.fieldValue} selectable>{e.address}</Text>
@@ -275,7 +282,7 @@ export default function ContactDetailScreen() {
)}
{phones.length > 0 && (
<Section icon={<Phone size={14} color={colors.primary} />} title="Phone" category="contact">
<Section icon={<Phone size={14} color={c.primary} />} title="Phone" category="contact">
{phones.map((p) => (
<Pressable key={p.id} onPress={() => openTel(p.number)} style={styles.fieldRow}>
<Text style={styles.fieldValue} selectable>{p.number}</Text>
@@ -287,7 +294,7 @@ export default function ContactDetailScreen() {
)}
{orgs.length > 0 && (
<Section icon={<Building size={14} color={colors.calendar.orange} />} title="Organization" category="work">
<Section icon={<Building size={14} color={c.calendar.orange} />} title="Organization" category="work">
{orgs.map((o, i) => (
<View key={i} style={styles.fieldRow}>
<Text style={styles.fieldValue}>{o.name}</Text>
@@ -300,7 +307,7 @@ export default function ContactDetailScreen() {
)}
{titles.length > 0 && (
<Section icon={<Briefcase size={14} color={colors.calendar.orange} />} title="Title" category="work">
<Section icon={<Briefcase size={14} color={c.calendar.orange} />} title="Title" category="work">
{titles.map((tl, i) => (
<View key={i} style={styles.fieldRow}>
<Text style={styles.fieldValue}>{tl.name}</Text>
@@ -311,7 +318,7 @@ export default function ContactDetailScreen() {
)}
{addresses.length > 0 && (
<Section icon={<MapPin size={14} color={colors.calendar.green} />} title="Addresses" category="location">
<Section icon={<MapPin size={14} color={c.calendar.green} />} title="Addresses" category="location">
{addresses.map((a) => {
const formatted = formatAddress(a);
return (
@@ -325,7 +332,7 @@ export default function ContactDetailScreen() {
)}
{anniversaries.length > 0 && (
<Section icon={<Cake size={14} color={colors.calendar.pink} />} title="Anniversaries" category="personal">
<Section icon={<Cake size={14} color={c.calendar.pink} />} title="Anniversaries" category="personal">
{anniversaries.map((a, i) => (
<View key={i} style={styles.fieldRow}>
<Text style={styles.fieldValue}>{formatPartialDate(a.date)}</Text>
@@ -336,7 +343,7 @@ export default function ContactDetailScreen() {
)}
{onlineServices.length > 0 && (
<Section icon={<Globe size={14} color={colors.calendar.teal} />} title="Online" category="digital">
<Section icon={<Globe size={14} color={c.calendar.teal} />} title="Online" category="digital">
{onlineServices.map((s, i) => (
<Pressable
key={i}
@@ -352,7 +359,7 @@ export default function ContactDetailScreen() {
)}
{personalInfo.length > 0 && (
<Section icon={<Heart size={14} color={colors.calendar.pink} />} title="Personal" category="personal">
<Section icon={<Heart size={14} color={c.calendar.pink} />} title="Personal" category="personal">
{personalInfo.map((pi, i) => (
<View key={i} style={styles.fieldRow}>
<Text style={styles.fieldValue}>{pi.value}</Text>
@@ -363,7 +370,7 @@ export default function ContactDetailScreen() {
)}
{relatedTo.length > 0 && (
<Section icon={<Users size={14} color={colors.calendar.pink} />} title="Related" category="personal">
<Section icon={<Users size={14} color={c.calendar.pink} />} title="Related" category="personal">
{relatedTo.map(([uri, rel], i) => {
const relType = rel.relation ? Object.keys(rel.relation).find((k) => rel.relation![k]) : undefined;
return (
@@ -377,7 +384,7 @@ export default function ContactDetailScreen() {
)}
{notes.length > 0 && (
<Section icon={<FileText size={14} color={colors.calendar.purple} />} title="Notes" category="notes">
<Section icon={<FileText size={14} color={c.calendar.purple} />} title="Notes" category="notes">
{notes.map((n, i) => (
<Text key={i} style={styles.noteText}>{n.note}</Text>
))}
@@ -385,7 +392,7 @@ export default function ContactDetailScreen() {
)}
{keywords.length > 0 && (
<Section icon={<Tag size={14} color={colors.calendar.teal} />} title="Tags" category="digital">
<Section icon={<Tag size={14} color={c.calendar.teal} />} title="Tags" category="digital">
<View style={styles.chipRow}>
{keywords.map((kw) => (
<View key={kw} style={styles.tagChip}>
@@ -397,7 +404,7 @@ export default function ContactDetailScreen() {
)}
{bookNames.length > 0 && (
<Section icon={<BookUser size={14} color={colors.textSecondary} />} title="Address Book" category="contact">
<Section icon={<BookUser size={14} color={c.textSecondary} />} title="Address Book" category="contact">
{bookNames.map((n, i) => (
<Text key={i} style={styles.fieldValue}>{n}</Text>
))}
@@ -427,6 +434,8 @@ function QuickAction({
label: string;
onPress: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable style={({ pressed }) => [styles.quickBtn, pressed && styles.quickBtnPressed]} onPress={onPress}>
<View style={styles.quickIcon}>{icon}</View>
@@ -435,8 +444,9 @@ function QuickAction({
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
scrollContent: { paddingBottom: spacing.xxxl * 2 },
header: {
@@ -446,14 +456,14 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
gap: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
borderBottomColor: c.borderLight,
},
headerBtn: {
width: 40, height: 40,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.full,
},
headerTitle: { ...typography.h3, color: colors.text, flex: 1, marginLeft: spacing.xs },
headerTitle: { ...typography.h3, color: c.text, flex: 1, marginLeft: spacing.xs },
headerActions: { flexDirection: 'row', gap: spacing.xs },
hero: {
@@ -466,10 +476,10 @@ const styles = StyleSheet.create({
width: 96,
height: 96,
borderRadius: 48,
backgroundColor: colors.surface,
backgroundColor: c.surface,
},
heroName: { ...typography.h2, color: colors.text, textAlign: 'center', marginTop: spacing.sm },
heroSubtitle: { ...typography.body, color: colors.textSecondary, textAlign: 'center' },
heroName: { ...typography.h2, color: c.text, textAlign: 'center', marginTop: spacing.sm },
heroSubtitle: { ...typography.body, color: c.textSecondary, textAlign: 'center' },
quickActions: {
flexDirection: 'row',
@@ -483,17 +493,17 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingVertical: spacing.md,
borderRadius: radius.md,
backgroundColor: colors.surface,
backgroundColor: c.surface,
gap: spacing.xs,
},
quickBtnPressed: { backgroundColor: colors.surfaceHover },
quickBtnPressed: { backgroundColor: c.surfaceHover },
quickIcon: {
width: 32, height: 32,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.full,
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
},
quickLabel: { ...typography.caption, color: colors.textSecondary },
quickLabel: { ...typography.caption, color: c.textSecondary },
section: {
marginHorizontal: spacing.lg,
@@ -502,33 +512,34 @@ const styles = StyleSheet.create({
paddingLeft: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
backgroundColor: colors.card,
backgroundColor: c.card,
},
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginBottom: spacing.sm },
sectionTitle: { ...typography.bodySemibold, color: colors.text },
sectionTitle: { ...typography.bodySemibold, color: c.text },
sectionBody: { gap: spacing.sm },
fieldRow: { gap: 2 },
fieldValue: { ...typography.body, color: colors.text },
fieldLabel: { ...typography.caption, color: colors.textMuted },
noteText: { ...typography.body, color: colors.text },
fieldValue: { ...typography.body, color: c.text },
fieldLabel: { ...typography.caption, color: c.textMuted },
noteText: { ...typography.body, color: c.text },
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, marginTop: 2 },
contextChip: {
paddingHorizontal: 6,
paddingVertical: 2,
backgroundColor: colors.surface,
backgroundColor: c.surface,
borderRadius: radius.xs,
},
contextChipText: { ...typography.small, color: colors.textMuted },
contextChipText: { ...typography.small, color: c.textMuted },
tagChip: {
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
},
tagChipText: { ...typography.caption, color: colors.primary },
tagChipText: { ...typography.caption, color: c.primary },
missing: { flex: 1, alignItems: 'center', justifyContent: 'center' },
missingText: { ...typography.body, color: colors.textMuted },
});
missingText: { ...typography.body, color: c.textMuted },
});
}
+66 -59
View File
@@ -22,7 +22,8 @@ import { getContactKeywords } from '../lib/contact-utils';
import { getContactDisplayName } from '../lib/contact-utils';
import FieldBlock, { FieldRow } from '../components/contacts/FieldBlock';
import Dialog from '../components/Dialog';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactForm'>;
type Route = RouteProp<RootStackParamList, 'ContactForm'>;
@@ -250,6 +251,8 @@ function ContextPills({
options: string[];
onChange: (v: string) => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.pillRow}>
{options.map((o) => (
@@ -266,6 +269,8 @@ function ContextPills({
}
export default function ContactFormScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
const route = useRoute<Route>();
const { contactId, addressBookId: initialBook, asGroup } = route.params || {};
@@ -360,7 +365,7 @@ export default function ContactFormScreen() {
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={handleBack} style={styles.headerBtn} hitSlop={8}>
<ArrowLeft size={22} color={colors.text} />
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{headerTitle}</Text>
<Pressable onPress={handleSave} disabled={saving} style={styles.saveBtn} hitSlop={8}>
@@ -368,7 +373,7 @@ export default function ContactFormScreen() {
<Text style={styles.saveLabel}>Saving…</Text>
) : (
<>
<Check size={16} color={colors.primaryForeground} />
<Check size={16} color={c.primaryForeground} />
<Text style={styles.saveLabel}>Save</Text>
</>
)}
@@ -382,7 +387,7 @@ export default function ContactFormScreen() {
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
{/* Address book picker */}
{addressBooks.length > 1 && (
<FieldBlock title="Address Book" icon={<User size={14} color={colors.primary} />} category="contact">
<FieldBlock title="Address Book" icon={<User size={14} color={c.primary} />} category="contact">
<View style={styles.pillRow}>
{addressBooks.map((book) => (
<Pressable
@@ -400,7 +405,7 @@ export default function ContactFormScreen() {
)}
{/* Photo */}
<FieldBlock title="Photo" icon={<Camera size={14} color={colors.primary} />} category="contact">
<FieldBlock title="Photo" icon={<Camera size={14} color={c.primary} />} category="contact">
<View style={styles.photoRow}>
{form.photoUri ? (
<View style={styles.photoThumbWrap}>
@@ -413,12 +418,12 @@ export default function ContactFormScreen() {
style={styles.photoRemoveBtn}
hitSlop={8}
>
<XIcon size={14} color={colors.primaryForeground} />
<XIcon size={14} color={c.primaryForeground} />
</Pressable>
</View>
) : (
<View style={[styles.photoThumb, styles.photoPlaceholder]}>
<Camera size={28} color={colors.textMuted} />
<Camera size={28} color={c.textMuted} />
</View>
)}
<Pressable
@@ -455,25 +460,25 @@ export default function ContactFormScreen() {
</FieldBlock>
{/* Name */}
<FieldBlock title="Name" icon={<User size={14} color={colors.primary} />} category="contact">
<FieldBlock title="Name" icon={<User size={14} color={c.primary} />} category="contact">
<TextInput
style={styles.input}
placeholder="First name"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
value={form.given}
onChangeText={(v) => updateForm('given', v)}
/>
<TextInput
style={styles.input}
placeholder="Last name"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
value={form.surname}
onChangeText={(v) => updateForm('surname', v)}
/>
<TextInput
style={styles.input}
placeholder="Display name (optional)"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
value={form.full}
onChangeText={(v) => updateForm('full', v)}
/>
@@ -482,7 +487,7 @@ export default function ContactFormScreen() {
{/* Emails */}
<FieldBlock
title="Email"
icon={<Mail size={14} color={colors.primary} />}
icon={<Mail size={14} color={c.primary} />}
category="contact"
onAdd={() => updateForm('emails', [...form.emails, { address: '', label: '', context: 'personal' }])}
addLabel="Add"
@@ -495,7 +500,7 @@ export default function ContactFormScreen() {
<TextInput
style={styles.input}
placeholder="email@example.com"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
keyboardType="email-address"
autoCapitalize="none"
value={e.address}
@@ -521,7 +526,7 @@ export default function ContactFormScreen() {
{/* Phones */}
<FieldBlock
title="Phone"
icon={<Phone size={14} color={colors.primary} />}
icon={<Phone size={14} color={c.primary} />}
category="contact"
onAdd={() => updateForm('phones', [...form.phones, { number: '', label: '', context: 'personal' }])}
addLabel="Add"
@@ -534,7 +539,7 @@ export default function ContactFormScreen() {
<TextInput
style={styles.input}
placeholder="+1 555 0100"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
keyboardType="phone-pad"
value={p.number}
onChangeText={(v) => {
@@ -559,7 +564,7 @@ export default function ContactFormScreen() {
{/* Orgs */}
<FieldBlock
title="Organization"
icon={<Building size={14} color={colors.calendar.orange} />}
icon={<Building size={14} color={c.calendar.orange} />}
category="work"
onAdd={() => updateForm('orgs', [...form.orgs, { name: '', title: '' }])}
addLabel="Add"
@@ -572,7 +577,7 @@ export default function ContactFormScreen() {
<TextInput
style={styles.input}
placeholder="Company"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
value={o.name}
onChangeText={(v) => {
const next = [...form.orgs];
@@ -583,7 +588,7 @@ export default function ContactFormScreen() {
<TextInput
style={styles.input}
placeholder="Title"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
value={o.title}
onChangeText={(v) => {
const next = [...form.orgs];
@@ -598,7 +603,7 @@ export default function ContactFormScreen() {
{/* Addresses */}
<FieldBlock
title="Address"
icon={<MapPin size={14} color={colors.calendar.green} />}
icon={<MapPin size={14} color={c.calendar.green} />}
category="location"
onAdd={() => updateForm('addresses', [...form.addresses, { full: '', context: 'home' }])}
addLabel="Add"
@@ -611,7 +616,7 @@ export default function ContactFormScreen() {
<TextInput
style={[styles.input, styles.multiline]}
placeholder="Street, city, region, postcode, country"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
multiline
value={a.full}
onChangeText={(v) => {
@@ -636,7 +641,7 @@ export default function ContactFormScreen() {
{/* Anniversaries */}
<FieldBlock
title="Important Dates"
icon={<Cake size={14} color={colors.calendar.pink} />}
icon={<Cake size={14} color={c.calendar.pink} />}
category="personal"
onAdd={() => updateForm('anniversaries', [...form.anniversaries, { kind: 'birth', date: '' }])}
addLabel="Add"
@@ -650,7 +655,7 @@ export default function ContactFormScreen() {
<TextInput
style={[styles.input, styles.dateTextInput]}
placeholder="YYYY-MM-DD or --MM-DD"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
value={a.date}
onChangeText={(v) => {
const next = [...form.anniversaries];
@@ -663,7 +668,7 @@ export default function ContactFormScreen() {
style={styles.datePickerBtn}
hitSlop={8}
>
<Calendar size={18} color={colors.primary} />
<Calendar size={18} color={c.primary} />
</Pressable>
</View>
<ContextPills
@@ -680,7 +685,7 @@ export default function ContactFormScreen() {
</FieldBlock>
{/* Keywords */}
<FieldBlock title="Tags" icon={<Tag size={14} color={colors.calendar.teal} />} category="digital">
<FieldBlock title="Tags" icon={<Tag size={14} color={c.calendar.teal} />} category="digital">
<View style={styles.chipRow}>
{form.keywords.map((kw) => (
<Pressable
@@ -696,7 +701,7 @@ export default function ContactFormScreen() {
<TextInput
style={styles.input}
placeholder="Add tag and press Enter"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
value={keywordInput}
onChangeText={setKeywordInput}
onSubmitEditing={() => {
@@ -729,7 +734,7 @@ export default function ContactFormScreen() {
{/* Notes */}
<FieldBlock
title="Notes"
icon={<FileText size={14} color={colors.calendar.purple} />}
icon={<FileText size={14} color={c.calendar.purple} />}
category="notes"
onAdd={() => updateForm('notes', [...form.notes, { note: '' }])}
addLabel="Add"
@@ -750,7 +755,7 @@ export default function ContactFormScreen() {
<TextInput
style={[styles.input, styles.multiline]}
placeholder="Notes"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
multiline
value={n.note}
onChangeText={(v) => {
@@ -852,8 +857,9 @@ function formatDateAsISO(d: Date): string {
return `${y}-${m}-${da}`;
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
scrollContent: { paddingVertical: spacing.md, paddingBottom: spacing.xxxl * 2 },
dateInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
@@ -862,34 +868,34 @@ const styles = StyleSheet.create({
width: 40, height: 40,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.full,
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
},
photoRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
photoThumbWrap: { position: 'relative' },
photoThumb: {
width: 72, height: 72,
borderRadius: 36,
backgroundColor: colors.surface,
backgroundColor: c.surface,
},
photoPlaceholder: {
alignItems: 'center', justifyContent: 'center',
borderWidth: 1, borderColor: colors.border, borderStyle: 'dashed',
borderWidth: 1, borderColor: c.border, borderStyle: 'dashed',
},
photoRemoveBtn: {
position: 'absolute',
top: -4, right: -4,
width: 24, height: 24,
borderRadius: 12,
backgroundColor: colors.error,
backgroundColor: c.error,
alignItems: 'center', justifyContent: 'center',
},
photoPickBtn: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
},
photoPickLabel: { ...typography.bodyMedium, color: colors.primary },
photoPickLabel: { ...typography.bodyMedium, color: c.primary },
pickerOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
@@ -897,7 +903,7 @@ const styles = StyleSheet.create({
pickerSheet: {
position: 'absolute',
left: 0, right: 0, bottom: 0,
backgroundColor: colors.background,
backgroundColor: c.background,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
paddingBottom: spacing.lg,
@@ -907,9 +913,9 @@ const styles = StyleSheet.create({
justifyContent: 'flex-end',
padding: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
borderBottomColor: c.borderLight,
},
pickerDone: { ...typography.bodySemibold, color: colors.primary },
pickerDone: { ...typography.bodySemibold, color: c.primary },
header: {
flexDirection: 'row',
@@ -918,34 +924,34 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
gap: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
borderBottomColor: c.borderLight,
},
headerBtn: {
width: 40, height: 40,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.full,
},
headerTitle: { ...typography.h3, color: colors.text, flex: 1 },
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
saveBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.md,
height: 36,
backgroundColor: colors.primary,
backgroundColor: c.primary,
borderRadius: radius.full,
},
saveLabel: { ...typography.bodyMedium, color: colors.primaryForeground },
saveLabel: { ...typography.bodyMedium, color: c.primaryForeground },
input: {
height: componentSizes.inputHeight,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: colors.background,
backgroundColor: c.background,
paddingHorizontal: spacing.md,
...typography.body,
color: colors.text,
color: c.text,
},
multiline: { height: 80, paddingVertical: spacing.sm, textAlignVertical: 'top' },
@@ -954,13 +960,13 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
backgroundColor: colors.surface,
backgroundColor: c.surface,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
pillActive: { backgroundColor: colors.primary, borderColor: colors.primary },
pillText: { ...typography.caption, color: colors.textSecondary },
pillTextActive: { color: colors.primaryForeground },
pillActive: { backgroundColor: c.primary, borderColor: c.primary },
pillText: { ...typography.caption, color: c.textSecondary },
pillTextActive: { color: c.primaryForeground },
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
keywordChip: {
@@ -969,28 +975,29 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
},
keywordChipText: { ...typography.caption, color: colors.primary },
keywordChipRemove: { ...typography.caption, color: colors.primary },
keywordChipText: { ...typography.caption, color: c.primary },
keywordChipRemove: { ...typography.caption, color: c.primary },
suggestedChip: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
backgroundColor: colors.surface,
backgroundColor: c.surface,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
suggestedChipText: { ...typography.caption, color: colors.textSecondary },
suggestedChipText: { ...typography.caption, color: c.textSecondary },
addNoteBtn: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderStyle: 'dashed',
alignItems: 'center',
},
addNoteText: { ...typography.caption, color: colors.textMuted },
});
addNoteText: { ...typography.caption, color: c.textMuted },
});
}
+43 -38
View File
@@ -29,7 +29,8 @@ import { ContactListRow } from '../components/contacts';
import Dialog from '../components/Dialog';
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
import { useSettingsStore } from '../stores/settings-store';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type Nav = NativeStackNavigationProp<RootStackParamList>;
@@ -77,6 +78,8 @@ function categoryTitle(category: ContactCategory, books: Array<{ id: string; nam
}
export default function ContactsScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
const contacts = useContactsStore((s) => s.contacts);
const addressBooks = useContactsStore((s) => s.addressBooks);
@@ -227,7 +230,7 @@ export default function ContactsScreen() {
{selectionMode ? (
<>
<Pressable onPress={clearSelection} style={styles.headerIconBtn} hitSlop={8}>
<X size={22} color={colors.text} />
<X size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>{selection.size} selected</Text>
<View style={styles.headerActions}>
@@ -236,14 +239,14 @@ export default function ContactsScreen() {
style={styles.headerIconBtn}
hitSlop={8}
>
<Trash2 size={20} color={colors.error} />
<Trash2 size={20} color={c.error} />
</Pressable>
</View>
</>
) : (
<>
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerIconBtn} hitSlop={8}>
<Menu size={22} color={colors.text} />
<Menu size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
<Text style={styles.headerCount}>{visible.length}</Text>
@@ -253,14 +256,14 @@ export default function ContactsScreen() {
style={styles.headerIconBtn}
hitSlop={8}
>
<Search size={20} color={colors.text} />
<Search size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => navigation.navigate('ContactForm', {})}
style={styles.addBtn}
hitSlop={8}
>
<Plus size={18} color={colors.primaryForeground} />
<Plus size={18} color={c.primaryForeground} />
</Pressable>
</View>
</>
@@ -269,18 +272,18 @@ export default function ContactsScreen() {
{searchActive && (
<View style={styles.searchBar}>
<Search size={16} color={colors.textMuted} />
<Search size={16} color={c.textMuted} />
<TextInput
style={styles.searchInput}
placeholder="Search contacts..."
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus
/>
{searchQuery.length > 0 && (
<Pressable onPress={() => setSearchQuery('')} hitSlop={8}>
<X size={16} color={colors.textMuted} />
<X size={16} color={c.textMuted} />
</Pressable>
)}
</View>
@@ -346,17 +349,17 @@ export default function ContactsScreen() {
void fetchContacts();
void fetchAddressBooks();
}}
tintColor={colors.primary}
tintColor={c.primary}
/>
}
ListEmptyComponent={
loading ? (
<View style={styles.emptyState}>
<ActivityIndicator color={colors.primary} />
<ActivityIndicator color={c.primary} />
</View>
) : (
<View style={styles.emptyState}>
<UserCircle size={48} color={colors.surfaceActive} />
<UserCircle size={48} color={c.surfaceActive} />
<Text style={styles.emptyTitle}>
{searchQuery ? 'No contacts found' : 'No contacts yet'}
</Text>
@@ -381,8 +384,9 @@ export default function ContactsScreen() {
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
listContent: { paddingBottom: 80 },
header: {
@@ -392,11 +396,11 @@ const styles = StyleSheet.create({
paddingVertical: spacing.md,
gap: spacing.sm,
},
headerTitle: { ...typography.h3, color: colors.text, flexShrink: 1 },
headerTitle: { ...typography.h3, color: c.text, flexShrink: 1 },
headerCount: {
...typography.small,
color: colors.textMuted,
backgroundColor: colors.surface,
color: c.textMuted,
backgroundColor: c.surface,
borderRadius: radius.full,
paddingHorizontal: 8,
paddingVertical: 2,
@@ -421,7 +425,7 @@ const styles = StyleSheet.create({
height: 36,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primary,
backgroundColor: c.primary,
borderRadius: radius.full,
},
@@ -432,13 +436,13 @@ const styles = StyleSheet.create({
marginBottom: spacing.md,
paddingHorizontal: spacing.md,
height: componentSizes.inputHeight,
backgroundColor: colors.background,
backgroundColor: c.background,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
gap: spacing.sm,
},
searchInput: { flex: 1, ...typography.body, color: colors.text, paddingVertical: 0 },
searchInput: { flex: 1, ...typography.body, color: c.text, paddingVertical: 0 },
chipBar: { flexGrow: 0, marginBottom: spacing.sm },
chipBarContent: {
@@ -450,33 +454,33 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
backgroundColor: colors.surface,
backgroundColor: c.surface,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
chipActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
backgroundColor: c.primary,
borderColor: c.primary,
},
chipText: { ...typography.captionMedium, color: colors.textSecondary },
chipTextActive: { color: colors.primaryForeground },
chipText: { ...typography.captionMedium, color: c.textSecondary },
chipTextActive: { color: c.primaryForeground },
chipDivider: {
width: 1,
height: 16,
backgroundColor: colors.border,
backgroundColor: c.border,
marginHorizontal: spacing.xs,
},
sectionHeader: {
backgroundColor: colors.background,
backgroundColor: c.background,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
borderBottomColor: c.borderLight,
},
sectionHeaderText: { ...typography.small, color: colors.primary },
sectionHeaderText: { ...typography.small, color: c.primary },
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
separator: { height: 1, backgroundColor: c.borderLight, marginLeft: 68 },
errorBanner: {
marginHorizontal: spacing.lg,
@@ -484,17 +488,18 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
backgroundColor: colors.errorBg,
backgroundColor: c.errorBg,
borderWidth: 1,
borderColor: colors.errorBorder,
borderColor: c.errorBorder,
},
errorText: { ...typography.caption, color: colors.errorForeground },
errorText: { ...typography.caption, color: c.errorForeground },
emptyState: {
alignItems: 'center',
paddingVertical: spacing.xxxl * 2,
gap: spacing.sm,
},
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
emptySubtitle: { ...typography.caption, color: colors.textMuted },
});
emptyTitle: { ...typography.bodyMedium, color: c.textSecondary },
emptySubtitle: { ...typography.caption, color: c.textMuted },
});
}
+102 -95
View File
@@ -6,7 +6,8 @@ import {
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
} from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import SidebarDrawer from '../components/SidebarDrawer';
import SenderAvatar from '../components/SenderAvatar';
import { SwipeableRow } from '../components/SwipeableRow';
@@ -60,6 +61,8 @@ const EmailRow = React.memo(function EmailRow({
selected: boolean;
selectionMode: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const senderName = getSenderName(item);
const senderEmail = getSenderEmail(item);
const unread = isUnread(item);
@@ -82,9 +85,9 @@ const EmailRow = React.memo(function EmailRow({
{selectionMode && (
<View style={styles.rowCheckboxWrap}>
{selected ? (
<SquareCheck size={16} color={colors.primary} />
<SquareCheck size={16} color={c.primary} />
) : (
<Square size={16} color={colors.textMuted} />
<Square size={16} color={c.textMuted} />
)}
</View>
)}
@@ -99,10 +102,10 @@ const EmailRow = React.memo(function EmailRow({
{senderName}
</Text>
{starred && (
<Star size={componentSizes.statusIcon} color={colors.starred} fill={colors.starred} />
<Star size={componentSizes.statusIcon} color={c.starred} fill={c.starred} />
)}
{item.hasAttachment && (
<Paperclip size={componentSizes.statusIcon} color={colors.textMuted} />
<Paperclip size={componentSizes.statusIcon} color={c.textMuted} />
)}
</View>
<View style={styles.timeAndTag}>
@@ -136,6 +139,8 @@ interface EmailListScreenProps {
}
export default function EmailListScreen({ onEmailPress, onComposePress }: EmailListScreenProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [drawerOpen, setDrawerOpen] = React.useState(false);
const [filterMenuOpen, setFilterMenuOpen] = React.useState(false);
const emails = useEmailStore((s) => s.emails);
@@ -385,7 +390,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
{selectionMode ? (
<View style={styles.header}>
<Pressable onPress={clearSelection} style={styles.headerButton}>
<X size={20} color={colors.text} />
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>{selectedIds.size} selected</Text>
<Pressable
@@ -395,8 +400,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
>
<Star
size={20}
color={allSelectedAreStarred ? colors.starred : colors.text}
fill={allSelectedAreStarred ? colors.starred : 'transparent'}
color={allSelectedAreStarred ? c.starred : c.text}
fill={allSelectedAreStarred ? c.starred : 'transparent'}
/>
</Pressable>
<Pressable
@@ -405,9 +410,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
hitSlop={6}
>
{allSelectedAreRead ? (
<MailIcon size={20} color={colors.text} />
<MailIcon size={20} color={c.text} />
) : (
<MailOpen size={20} color={colors.text} />
<MailOpen size={20} color={c.text} />
)}
</Pressable>
<Pressable
@@ -415,13 +420,13 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
style={styles.headerButton}
hitSlop={6}
>
<Trash2 size={20} color={colors.text} />
<Trash2 size={20} color={c.text} />
</Pressable>
</View>
) : (
<View style={styles.header}>
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
<Menu size={20} color={colors.textMuted} />
<Menu size={20} color={c.textMuted} />
</Pressable>
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
<View style={{ flex: 1 }} />
@@ -437,21 +442,21 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<View style={styles.searchBar}>
<Pressable style={styles.checkboxButton} onPress={toggleSelectAllVisible} hitSlop={6}>
{allSelected ? (
<SquareCheck size={18} color={colors.primary} />
<SquareCheck size={18} color={c.primary} />
) : selectionMode ? (
<View style={styles.checkboxIndeterminate}>
<Minus size={14} color={colors.background} />
<Minus size={14} color={c.background} />
</View>
) : (
<Square size={18} color={colors.textMuted} />
<Square size={18} color={c.textMuted} />
)}
</Pressable>
<View style={styles.searchInputArea}>
<Search size={16} color={colors.textMuted} />
<Search size={16} color={c.textMuted} />
<TextInput
style={styles.searchInput}
placeholder="Search mail..."
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
value={searchInput}
onChangeText={setSearchInput}
autoCapitalize="none"
@@ -464,7 +469,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
hitSlop={8}
style={styles.searchClearButton}
>
<X size={14} color={colors.textMuted} />
<X size={14} color={c.textMuted} />
</Pressable>
)}
</View>
@@ -472,7 +477,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
style={[styles.filterButton, activeFilterCount > 0 && styles.filterButtonActive]}
onPress={() => setFilterMenuOpen(true)}
>
<Filter size={18} color={activeFilterCount > 0 ? colors.primary : colors.textMuted} />
<Filter size={18} color={activeFilterCount > 0 ? c.primary : c.textMuted} />
{activeFilterCount > 0 && (
<View style={styles.filterBadge}>
<Text style={styles.filterBadgeText}>{activeFilterCount}</Text>
@@ -485,7 +490,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<View style={styles.filterChipsRow}>
{storeSearchQuery ? (
<View style={styles.chip}>
<Search size={12} color={colors.textSecondary} />
<Search size={12} color={c.textSecondary} />
<Text style={styles.chipText} numberOfLines={1}>
{storeSearchQuery}
</Text>
@@ -508,35 +513,35 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
) : null}
{filters.dateAfter ? (
<View style={styles.chip}>
<CalendarDays size={12} color={colors.textSecondary} />
<CalendarDays size={12} color={c.textSecondary} />
<Text style={styles.chipText}>After {filters.dateAfter}</Text>
</View>
) : null}
{filters.dateBefore ? (
<View style={styles.chip}>
<CalendarDays size={12} color={colors.textSecondary} />
<CalendarDays size={12} color={c.textSecondary} />
<Text style={styles.chipText}>Before {filters.dateBefore}</Text>
</View>
) : null}
{filters.isUnread !== undefined && (
<View style={styles.chip}>
{filters.isUnread ? (
<MailIcon size={12} color={colors.textSecondary} />
<MailIcon size={12} color={c.textSecondary} />
) : (
<MailOpen size={12} color={colors.textSecondary} />
<MailOpen size={12} color={c.textSecondary} />
)}
<Text style={styles.chipText}>{filters.isUnread ? 'Unread' : 'Read'}</Text>
</View>
)}
{filters.isStarred !== undefined && (
<View style={styles.chip}>
<Star size={12} color={colors.starred} fill={filters.isStarred ? colors.starred : 'transparent'} />
<Star size={12} color={c.starred} fill={filters.isStarred ? c.starred : 'transparent'} />
<Text style={styles.chipText}>{filters.isStarred ? 'Starred' : 'Not starred'}</Text>
</View>
)}
{filters.hasAttachment !== undefined && (
<View style={styles.chip}>
<Paperclip size={12} color={colors.textSecondary} />
<Paperclip size={12} color={c.textSecondary} />
<Text style={styles.chipText}>
{filters.hasAttachment ? 'Has attachment' : 'No attachment'}
</Text>
@@ -560,7 +565,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
{/* Email list */}
{loading && emails.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} />
<ActivityIndicator color={c.primary} />
<Text style={styles.loadingText}>Loading emails...</Text>
</View>
) : error && emails.length === 0 ? (
@@ -609,7 +614,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
onPress={onComposePress}
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
>
<SquarePen size={24} color={colors.background} />
<SquarePen size={24} color={c.background} />
</Pressable>
<SidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
@@ -632,7 +637,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
style={styles.filterMenuHeaderBtn}
hitSlop={6}
>
<RotateCcw size={12} color={colors.textSecondary} />
<RotateCcw size={12} color={c.textSecondary} />
<Text style={styles.filterMenuHeaderBtnText}>Clear</Text>
</Pressable>
<Pressable
@@ -640,7 +645,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
style={styles.filterMenuClose}
hitSlop={6}
>
<X size={16} color={colors.textSecondary} />
<X size={16} color={c.textSecondary} />
</Pressable>
</View>
</View>
@@ -653,7 +658,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
value={filters.from ?? ''}
onChangeText={(v) => setFilterField('from', v)}
placeholder="sender@example.com"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
style={styles.filterFieldInput}
@@ -665,7 +670,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
value={filters.to ?? ''}
onChangeText={(v) => setFilterField('to', v)}
placeholder="recipient@example.com"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
style={styles.filterFieldInput}
@@ -679,7 +684,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
value={filters.subject ?? ''}
onChangeText={(v) => setFilterField('subject', v)}
placeholder="Subject contains..."
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
style={styles.filterFieldInput}
/>
</View>
@@ -691,7 +696,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
style={styles.filterDateButton}
onPress={() => setDatePickerField('dateAfter')}
>
<CalendarDays size={14} color={colors.textMuted} />
<CalendarDays size={14} color={c.textMuted} />
<Text style={[styles.filterDateText, !filters.dateAfter && styles.filterDateTextEmpty]}>
{filters.dateAfter || 'YYYY-MM-DD'}
</Text>
@@ -700,7 +705,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
onPress={() => setFilterField('dateAfter', undefined)}
hitSlop={6}
>
<X size={14} color={colors.textMuted} />
<X size={14} color={c.textMuted} />
</Pressable>
) : null}
</Pressable>
@@ -711,7 +716,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
style={styles.filterDateButton}
onPress={() => setDatePickerField('dateBefore')}
>
<CalendarDays size={14} color={colors.textMuted} />
<CalendarDays size={14} color={c.textMuted} />
<Text style={[styles.filterDateText, !filters.dateBefore && styles.filterDateTextEmpty]}>
{filters.dateBefore || 'YYYY-MM-DD'}
</Text>
@@ -720,7 +725,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
onPress={() => setFilterField('dateBefore', undefined)}
hitSlop={6}
>
<X size={14} color={colors.textMuted} />
<X size={14} color={c.textMuted} />
</Pressable>
) : null}
</Pressable>
@@ -729,13 +734,13 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<View style={styles.filterToggleGroup}>
<TriToggle
icon={<Paperclip size={14} color={colors.textSecondary} />}
icon={<Paperclip size={14} color={c.textSecondary} />}
label="Has attachment"
value={filters.hasAttachment}
onPress={() => cycleTriState('hasAttachment')}
/>
<TriToggle
icon={<Star size={14} color={colors.starred} fill={filters.isStarred ? colors.starred : 'transparent'} />}
icon={<Star size={14} color={c.starred} fill={filters.isStarred ? c.starred : 'transparent'} />}
label="Starred"
value={filters.isStarred}
onPress={() => cycleTriState('isStarred')}
@@ -743,9 +748,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<TriToggle
icon={
filters.isUnread === false ? (
<MailOpen size={14} color={colors.textSecondary} />
<MailOpen size={14} color={c.textSecondary} />
) : (
<MailIcon size={14} color={colors.textSecondary} />
<MailIcon size={14} color={c.textSecondary} />
)
}
label={filters.isUnread === false ? 'Read' : 'Unread'}
@@ -840,8 +845,9 @@ function TriToggle({
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
// Header - matches web mobile-header: h-14 (56px), px-4, border-b
header: {
@@ -850,7 +856,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
height: componentSizes.headerHeight,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
gap: spacing.md,
},
headerButton: {
@@ -858,7 +864,7 @@ const styles = StyleSheet.create({
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.full,
},
headerTitle: { ...typography.h3, color: colors.text, flex: 1 }, // text-lg font-semibold
headerTitle: { ...typography.h3, color: c.text, flex: 1 }, // text-lg font-semibold
headerLogo: {
width: 28,
height: 28,
@@ -883,13 +889,13 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingHorizontal: spacing.md,
height: componentSizes.inputHeight, // h-10 = 40px
backgroundColor: colors.surface,
backgroundColor: c.surface,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
gap: spacing.sm,
},
searchInput: { flex: 1, ...typography.body, color: colors.text },
searchInput: { flex: 1, ...typography.body, color: c.text },
filterButton: {
width: componentSizes.buttonMd, height: componentSizes.buttonMd,
alignItems: 'center', justifyContent: 'center',
@@ -897,7 +903,7 @@ const styles = StyleSheet.create({
position: 'relative',
},
filterButtonActive: {
backgroundColor: colors.accent,
backgroundColor: c.accent,
},
filterBadge: {
position: 'absolute',
@@ -906,7 +912,7 @@ const styles = StyleSheet.create({
minWidth: 16,
height: 16,
borderRadius: 8,
backgroundColor: colors.primary,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 4,
@@ -914,7 +920,7 @@ const styles = StyleSheet.create({
filterBadgeText: {
fontSize: 10,
fontWeight: '700',
color: colors.primaryForeground,
color: c.primaryForeground,
lineHeight: 14,
},
searchClearButton: {
@@ -936,9 +942,9 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.surface,
backgroundColor: c.surface,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.full,
paddingHorizontal: spacing.md,
paddingVertical: 4,
@@ -946,7 +952,7 @@ const styles = StyleSheet.create({
},
chipText: {
...typography.caption,
color: colors.textSecondary,
color: c.textSecondary,
},
clearAllButton: {
paddingHorizontal: spacing.sm,
@@ -954,7 +960,7 @@ const styles = StyleSheet.create({
},
clearAllText: {
...typography.captionMedium,
color: colors.primary,
color: c.primary,
},
filterBackdrop: {
flex: 1,
@@ -967,10 +973,10 @@ const styles = StyleSheet.create({
width: '100%',
maxWidth: 380,
maxHeight: '85%',
backgroundColor: colors.popover,
backgroundColor: c.popover,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
filterMenuHeader: {
flexDirection: 'row',
@@ -980,11 +986,11 @@ const styles = StyleSheet.create({
paddingTop: spacing.md,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
filterMenuTitle: {
...typography.bodySemibold,
color: colors.text,
color: c.text,
},
filterMenuHeaderActions: {
flexDirection: 'row',
@@ -1001,7 +1007,7 @@ const styles = StyleSheet.create({
},
filterMenuHeaderBtnText: {
...typography.caption,
color: colors.textSecondary,
color: c.textSecondary,
},
filterMenuClose: {
width: 28,
@@ -1021,15 +1027,15 @@ const styles = StyleSheet.create({
filterFieldHalf: { flex: 1 },
filterFieldLabel: {
...typography.caption,
color: colors.textMuted,
color: c.textMuted,
marginBottom: 4,
},
filterFieldInput: {
...typography.body,
color: colors.text,
backgroundColor: colors.surface,
color: c.text,
backgroundColor: c.surface,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: 6,
@@ -1039,20 +1045,20 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: colors.surface,
backgroundColor: c.surface,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
height: 32,
},
filterDateText: {
...typography.body,
color: colors.text,
color: c.text,
flex: 1,
},
filterDateTextEmpty: {
color: colors.textMuted,
color: c.textMuted,
},
filterToggleGroup: {
flexDirection: 'row',
@@ -1069,26 +1075,26 @@ const styles = StyleSheet.create({
paddingVertical: 4,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
borderColor: c.border,
backgroundColor: c.background,
},
triToggleOn: {
backgroundColor: 'rgba(59, 130, 246, 0.1)',
borderColor: 'rgba(59, 130, 246, 0.3)',
},
triToggleOff: {
backgroundColor: colors.muted,
borderColor: colors.border,
backgroundColor: c.muted,
borderColor: c.border,
},
triToggleText: {
...typography.caption,
color: colors.textMuted,
color: c.textMuted,
},
triToggleTextOn: {
color: colors.primary,
color: c.primary,
},
triToggleTextOff: {
color: colors.textMuted,
color: c.textMuted,
textDecorationLine: 'line-through',
},
pickerOverlay: {
@@ -1100,12 +1106,12 @@ const styles = StyleSheet.create({
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.popover,
backgroundColor: c.popover,
paddingBottom: spacing.xl,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
pickerHeader: {
flexDirection: 'row',
@@ -1113,11 +1119,11 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
pickerDone: {
...typography.bodyMedium,
color: colors.primary,
color: c.primary,
},
// Email list - matches web email-list-item
@@ -1128,10 +1134,10 @@ const styles = StyleSheet.create({
paddingVertical: spacing.md, // py-3 = 12px (density-item-py)
gap: spacing.md, // gap-3 = 12px (density-item-gap)
borderBottomWidth: 1,
borderBottomColor: colors.border, // border-b border-border
borderBottomColor: c.border, // border-b border-border
},
emailRowPressed: { backgroundColor: colors.surface },
emailRowSelected: { backgroundColor: colors.selection },
emailRowPressed: { backgroundColor: c.surface },
emailRowSelected: { backgroundColor: c.selection },
rowCheckboxWrap: {
width: 16,
height: componentSizes.avatarMd,
@@ -1143,7 +1149,7 @@ const styles = StyleSheet.create({
width: 18,
height: 18,
borderRadius: radius.xs,
backgroundColor: colors.primary,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
},
@@ -1162,7 +1168,7 @@ const styles = StyleSheet.create({
marginRight: spacing.sm,
},
// Sender: text-sm, font-medium (read) / font-bold (unread)
emailFrom: { ...typography.bodyMedium, color: colors.textSecondary, flexShrink: 1 },
emailFrom: { ...typography.bodyMedium, color: c.textSecondary, flexShrink: 1 },
timeAndTag: {
flexDirection: 'row',
alignItems: 'center',
@@ -1173,20 +1179,20 @@ const styles = StyleSheet.create({
alignItems: 'center',
gap: 3,
},
threadBadgeText: { ...typography.caption, color: colors.textMuted },
threadBadgeText: { ...typography.caption, color: c.textMuted },
// Date: text-xs (12px), tabular-nums
emailDate: { ...typography.caption, color: colors.textMuted },
emailDate: { ...typography.caption, color: c.textMuted },
subjectRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 2,
},
// Subject: text-sm (14px), font-semibold (unread) / font-normal (read)
emailSubject: { ...typography.body, color: colors.text, flex: 1 },
emailSubject: { ...typography.body, color: c.text, flex: 1 },
// Preview: text-sm, leading-relaxed, line-clamp-2
emailPreview: { ...typography.body, color: colors.textSecondary, lineHeight: 20, opacity: 0.8 },
emailPreview: { ...typography.body, color: c.textSecondary, lineHeight: 20, opacity: 0.8 },
// Unread state: text foreground + font-bold
textUnread: { fontWeight: '600', color: colors.text },
textUnread: { fontWeight: '600', color: c.text },
textBold: { fontWeight: '700' },
// Tag pill: text-[10px], rounded-full, px-1.5 py-0.5, gap-1
tagPill: {
@@ -1213,7 +1219,7 @@ const styles = StyleSheet.create({
width: componentSizes.fab, // 56px (h-14)
height: componentSizes.fab, // 56px (w-14)
borderRadius: radius.full, // rounded-full (circle)
backgroundColor: colors.text, // white - matches webmail mobile FAB
backgroundColor: c.text, // white - matches webmail mobile FAB
alignItems: 'center',
justifyContent: 'center',
elevation: 6,
@@ -1234,26 +1240,27 @@ const styles = StyleSheet.create({
},
loadingText: {
...typography.body,
color: colors.textMuted,
color: c.textMuted,
},
errorText: {
...typography.body,
color: colors.error,
color: c.error,
textAlign: 'center',
paddingHorizontal: spacing.lg,
},
retryText: {
...typography.bodyMedium,
color: colors.primary,
color: c.primary,
marginTop: spacing.sm,
},
hintText: {
...typography.caption,
color: colors.textMuted,
color: c.textMuted,
marginTop: spacing.xs,
textAlign: 'center',
paddingHorizontal: spacing.lg,
},
});
});
}
export type { EmailListScreenProps };
+16 -11
View File
@@ -7,11 +7,14 @@ import { ArrowLeft, Share2 } from 'lucide-react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
import { fetchRawEmail, shareEmailEml } from '../lib/email-export';
import { colors, spacing, typography, componentSizes, radius } from '../theme/tokens';
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type Props = NativeStackScreenProps<RootStackParamList, 'EmailSource'>;
export default function EmailSourceScreen({ route, navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { blobId, subject } = route.params;
const [raw, setRaw] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
@@ -41,19 +44,19 @@ export default function EmailSourceScreen({ route, navigation }: Props) {
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<ArrowLeft size={22} color={colors.text} />
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>
{subject || 'Email source'}
</Text>
<Pressable onPress={onShare} style={styles.headerBtn} hitSlop={8} disabled={!raw}>
<Share2 size={20} color={raw ? colors.text : colors.textMuted} />
<Share2 size={20} color={raw ? c.text : c.textMuted} />
</Pressable>
</View>
{!raw && !error ? (
<View style={styles.loading}>
<ActivityIndicator color={colors.primary} />
<ActivityIndicator color={c.primary} />
</View>
) : error ? (
<View style={styles.loading}>
@@ -68,28 +71,30 @@ export default function EmailSourceScreen({ route, navigation }: Props) {
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.headerHeight,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
gap: spacing.sm,
},
headerBtn: {
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
},
headerTitle: { ...typography.h3, color: colors.text, flex: 1 },
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
error: { ...typography.body, color: colors.error, paddingHorizontal: spacing.lg, textAlign: 'center' },
error: { ...typography.body, color: c.error, paddingHorizontal: spacing.lg, textAlign: 'center' },
body: { padding: spacing.lg },
source: {
fontFamily: 'monospace',
fontSize: 11,
lineHeight: 16,
color: colors.text,
color: c.text,
},
});
});
}
+130 -94
View File
@@ -12,7 +12,8 @@ import {
Inbox, Send, File as FileIcon, Ban, Folder, Code, Download, Tag,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import EmailBodyView from '../components/EmailBodyView';
import SenderAvatar from '../components/SenderAvatar';
import { useEmailStore } from '../stores/email-store';
@@ -20,6 +21,7 @@ import { setEmailKeywords } from '../api/email';
import { shareEmailEml } from '../lib/email-export';
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
import { useSheetDrag } from '../lib/use-sheet-drag';
import type { Email, Mailbox } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
@@ -60,6 +62,8 @@ function plainTextBody(email: Email): string {
}
export default function EmailThreadScreen({ route, navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { emailId, subject: subjectParam } = route.params;
const insets = useSafeAreaInsets();
const { width: windowWidth } = useWindowDimensions();
@@ -236,17 +240,17 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
{/* Toolbar */}
<View style={styles.toolbar}>
<Pressable onPress={() => navigation.goBack()} style={styles.toolbarBack} hitSlop={8}>
<ArrowLeft size={22} color={colors.text} />
<ArrowLeft size={22} color={c.text} />
</Pressable>
<View style={styles.toolbarActions}>
<ToolbarButton
icon={<Trash2 size={18} color={colors.textSecondary} />}
icon={<Trash2 size={18} color={c.textSecondary} />}
label="Delete"
onPress={onDelete}
/>
{showArchive && (
<ToolbarButton
icon={<Archive size={18} color={colors.textSecondary} />}
icon={<Archive size={18} color={c.textSecondary} />}
label="Archive"
onPress={onArchive}
/>
@@ -255,9 +259,9 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<ToolbarButton
icon={
unread ? (
<MailOpen size={18} color={colors.textSecondary} />
<MailOpen size={18} color={c.textSecondary} />
) : (
<Mail size={18} color={colors.textSecondary} />
<Mail size={18} color={c.textSecondary} />
)
}
label={unread ? 'Read' : 'Unread'}
@@ -268,15 +272,15 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
icon={
<Star
size={18}
color={starred ? colors.starred : colors.textSecondary}
fill={starred ? colors.starred : 'transparent'}
color={starred ? c.starred : c.textSecondary}
fill={starred ? c.starred : 'transparent'}
/>
}
label="Star"
onPress={onToggleStar}
/>
<ToolbarButton
icon={<MoreVertical size={18} color={colors.textSecondary} />}
icon={<MoreVertical size={18} color={c.textSecondary} />}
label="More"
onPress={() => setMoreMenuOpen(true)}
/>
@@ -285,7 +289,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
{loading ? (
<View style={styles.centered}>
<ActivityIndicator color={colors.primary} />
<ActivityIndicator color={c.primary} />
</View>
) : error ? (
<View style={styles.centered}>
@@ -304,8 +308,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<Pressable onPress={onToggleStar} hitSlop={8} style={styles.subjectStar}>
<Star
size={18}
color={starred ? colors.starred : colors.textMuted}
fill={starred ? colors.starred : 'transparent'}
color={starred ? c.starred : c.textMuted}
fill={starred ? c.starred : 'transparent'}
/>
</Pressable>
<Text style={styles.subjectText}>{subject}</Text>
@@ -348,7 +352,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<View style={styles.attachmentsRow}>
{email.attachments.map((att, idx) => (
<View key={att.blobId ?? idx} style={styles.attachmentChip}>
<Paperclip size={14} color={colors.textMuted} />
<Paperclip size={14} color={c.textMuted} />
<Text style={styles.attachmentName} numberOfLines={1}>
{att.name || 'attachment'}
</Text>
@@ -368,28 +372,28 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
{/* Bottom action bar */}
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
<BottomBarButton
icon={<ChevronLeft size={20} color={colors.textMuted} />}
icon={<ChevronLeft size={20} color={c.textMuted} />}
label="Prev"
onPress={prevEmail ? () => goToEmail(prevEmail) : undefined}
disabled={!prevEmail}
/>
<BottomBarButton
icon={<Reply size={20} color={colors.textSecondary} />}
icon={<Reply size={20} color={c.textSecondary} />}
label="Reply"
onPress={() => navigateCompose('reply')}
/>
<BottomBarButton
icon={<ReplyAll size={20} color={colors.textSecondary} />}
icon={<ReplyAll size={20} color={c.textSecondary} />}
label="Reply All"
onPress={() => navigateCompose('replyAll')}
/>
<BottomBarButton
icon={<Forward size={20} color={colors.textSecondary} />}
icon={<Forward size={20} color={c.textSecondary} />}
label="Forward"
onPress={() => navigateCompose('forward')}
/>
<BottomBarButton
icon={<ChevronRight size={20} color={colors.textMuted} />}
icon={<ChevronRight size={20} color={c.textMuted} />}
label="Next"
onPress={nextEmail ? () => goToEmail(nextEmail) : undefined}
disabled={!nextEmail}
@@ -481,9 +485,12 @@ function MoreMenuSheet({
showSpam, isInJunk, canViewSource, canExport,
onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onViewSource, onExport,
}: MoreMenuSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(400)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 400, onClose });
React.useEffect(() => {
if (visible) {
@@ -510,16 +517,20 @@ function MoreMenuSheet({
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View style={styles.sheetHandle} />
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>More actions</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={colors.textSecondary} />
</Pressable>
<View {...dragHandlers}>
<View style={styles.sheetHandleHit}>
<View style={styles.sheetHandle} />
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>More actions</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
{canArchive && (
<MoreMenuItem
icon={<Archive size={18} color={colors.textSecondary} />}
icon={<Archive size={18} color={c.textSecondary} />}
label="Archive"
onPress={onArchive}
/>
@@ -528,9 +539,9 @@ function MoreMenuSheet({
<MoreMenuItem
icon={
unread ? (
<MailOpen size={18} color={colors.textSecondary} />
<MailOpen size={18} color={c.textSecondary} />
) : (
<Mail size={18} color={colors.textSecondary} />
<Mail size={18} color={c.textSecondary} />
)
}
label={unread ? 'Mark as read' : 'Mark as unread'}
@@ -539,27 +550,27 @@ function MoreMenuSheet({
)}
{canMove && (
<MoreMenuItem
icon={<FolderInput size={18} color={colors.textSecondary} />}
icon={<FolderInput size={18} color={c.textSecondary} />}
label="Move to folder…"
onPress={onMove}
trailing={<ChevronRight size={16} color={colors.textMuted} />}
trailing={<ChevronRight size={16} color={c.textMuted} />}
/>
)}
{canTag && (
<MoreMenuItem
icon={<Tag size={18} color={colors.textSecondary} />}
icon={<Tag size={18} color={c.textSecondary} />}
label="Tag…"
onPress={onTag}
trailing={<ChevronRight size={16} color={colors.textMuted} />}
trailing={<ChevronRight size={16} color={c.textMuted} />}
/>
)}
{showSpam && (
<MoreMenuItem
icon={
isInJunk ? (
<ShieldCheck size={18} color={colors.success} />
<ShieldCheck size={18} color={c.success} />
) : (
<ShieldAlert size={18} color={colors.error} />
<ShieldAlert size={18} color={c.error} />
)
}
label={isInJunk ? 'Not spam' : 'Mark as spam'}
@@ -568,14 +579,14 @@ function MoreMenuSheet({
)}
{canViewSource && (
<MoreMenuItem
icon={<Code size={18} color={colors.textSecondary} />}
icon={<Code size={18} color={c.textSecondary} />}
label="View source"
onPress={onViewSource}
/>
)}
{canExport && (
<MoreMenuItem
icon={<Download size={18} color={colors.textSecondary} />}
icon={<Download size={18} color={c.textSecondary} />}
label="Export email (.eml)"
onPress={onExport}
/>
@@ -594,9 +605,12 @@ interface TagMenuSheetProps {
}
function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: TagMenuSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
React.useEffect(() => {
if (visible) {
@@ -623,29 +637,33 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View style={styles.sheetHandle} />
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Tags</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={colors.textSecondary} />
</Pressable>
<View {...dragHandlers}>
<View style={styles.sheetHandleHit}>
<View style={styles.sheetHandle} />
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Tags</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
{keywords.length === 0 ? (
<Text style={{ ...typography.body, color: colors.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
<Text style={{ ...typography.body, color: c.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
No tags yet. Add some in Settings → Keywords & Labels.
</Text>
) : (
keywords.map((kw) => {
const token = keywordToken(kw.id);
const active = !!activeKeywords[token];
const palette = colors.tags[kw.color];
const palette = c.tags[kw.color];
return (
<MoreMenuItem
key={kw.id}
icon={<View style={{ width: 18, height: 18, borderRadius: 9, backgroundColor: palette.dot }} />}
label={kw.label}
onPress={() => onToggle(token)}
trailing={active ? <Check size={16} color={colors.primary} /> : null}
trailing={active ? <Check size={16} color={c.primary} /> : null}
/>
);
})
@@ -664,9 +682,12 @@ interface MoveMenuSheetProps {
}
function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }: MoveMenuSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
React.useEffect(() => {
if (visible) {
@@ -709,12 +730,16 @@ function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View style={styles.sheetHandle} />
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Move to folder</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={colors.textSecondary} />
</Pressable>
<View {...dragHandlers}>
<View style={styles.sheetHandleHit}>
<View style={styles.sheetHandle} />
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Move to folder</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView>
{visibleNodes.map((node) => {
@@ -732,14 +757,14 @@ function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }
{ paddingLeft: spacing.lg + node.depth * 16 },
]}
>
<Icon size={16} color={canTarget ? colors.textSecondary : colors.textMuted} />
<Icon size={16} color={canTarget ? c.textSecondary : c.textMuted} />
<Text
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
numberOfLines={1}
>
{node.name}
</Text>
{isCurrent && <Check size={14} color={colors.textMuted} />}
{isCurrent && <Check size={14} color={c.textMuted} />}
</Pressable>
);
})}
@@ -752,6 +777,8 @@ function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }
function MoreMenuItem({
icon, label, onPress, trailing,
}: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable
onPress={onPress}
@@ -767,6 +794,8 @@ function MoreMenuItem({
function ToolbarButton({
icon, label, onPress,
}: { icon: React.ReactNode; label: string; onPress?: () => void }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable onPress={onPress} style={styles.toolbarAction} hitSlop={6}>
{icon}
@@ -778,6 +807,8 @@ function ToolbarButton({
function BottomBarButton({
icon, label, onPress, disabled,
}: { icon: React.ReactNode; label: string; onPress?: () => void; disabled?: boolean }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable
onPress={disabled ? undefined : onPress}
@@ -792,8 +823,9 @@ function BottomBarButton({
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
// Top toolbar
toolbar: {
@@ -803,9 +835,9 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.sm,
paddingTop: spacing.xs,
paddingBottom: spacing.xs,
backgroundColor: colors.background,
backgroundColor: c.background,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
toolbarBack: {
width: componentSizes.avatarSm,
@@ -828,7 +860,7 @@ const styles = StyleSheet.create({
},
toolbarActionLabel: {
...typography.small,
color: colors.textSecondary,
color: c.textSecondary,
},
// Content
@@ -838,14 +870,14 @@ const styles = StyleSheet.create({
justifyContent: 'center',
padding: spacing.lg,
},
errorText: { ...typography.body, color: colors.error, textAlign: 'center' },
scroll: { flex: 1, backgroundColor: colors.background },
errorText: { ...typography.body, color: c.error, textAlign: 'center' },
scroll: { flex: 1, backgroundColor: c.background },
// Subject block
subjectBlock: {
backgroundColor: colors.background,
backgroundColor: c.background,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
@@ -868,7 +900,7 @@ const styles = StyleSheet.create({
fontSize: 20,
fontWeight: '700',
lineHeight: 28,
color: colors.text,
color: c.text,
letterSpacing: -0.2,
},
subjectMeta: {
@@ -876,19 +908,19 @@ const styles = StyleSheet.create({
},
subjectDate: {
...typography.caption,
color: colors.textSecondary,
color: c.textSecondary,
},
subjectSize: {
...typography.caption,
color: colors.textMuted,
color: c.textMuted,
marginTop: 2,
},
// Sender block
senderBlock: {
backgroundColor: colors.background,
backgroundColor: c.background,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
@@ -900,27 +932,27 @@ const styles = StyleSheet.create({
senderInfo: { flex: 1, minWidth: 0 },
senderName: {
...typography.bodySemibold,
color: colors.text,
color: c.text,
},
senderEmail: {
...typography.caption,
color: colors.textSecondary,
color: c.textSecondary,
marginTop: 2,
},
senderRecipients: {
...typography.caption,
color: colors.textSecondary,
color: c.textSecondary,
marginTop: 4,
},
senderRecipientsLabel: {
color: colors.textMuted,
color: c.textMuted,
},
// Attachments
attachmentsBlock: {
backgroundColor: colors.background,
backgroundColor: c.background,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
@@ -935,24 +967,24 @@ const styles = StyleSheet.create({
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: 6,
backgroundColor: colors.surface,
backgroundColor: c.surface,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.sm,
},
attachmentName: {
...typography.caption,
color: colors.text,
color: c.text,
maxWidth: 180,
},
attachmentSize: {
...typography.small,
color: colors.textMuted,
color: c.textMuted,
},
// Body
bodyBlock: {
backgroundColor: colors.background,
backgroundColor: c.background,
},
// Bottom bar
@@ -963,9 +995,9 @@ const styles = StyleSheet.create({
bottom: 0,
flexDirection: 'row',
alignItems: 'stretch',
backgroundColor: colors.background,
backgroundColor: c.background,
borderTopWidth: 1,
borderTopColor: colors.border,
borderTopColor: c.border,
},
bottomBarBtn: {
flex: 1,
@@ -980,10 +1012,10 @@ const styles = StyleSheet.create({
},
bottomBarLabel: {
...typography.small,
color: colors.textSecondary,
color: c.textSecondary,
},
bottomBarLabelDisabled: {
color: colors.textMuted,
color: c.textMuted,
},
// Bottom sheet (More menu / Move folder picker)
@@ -997,23 +1029,26 @@ const styles = StyleSheet.create({
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.popover,
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: colors.border,
borderColor: c.border,
paddingTop: spacing.sm,
},
sheetTall: {
maxHeight: '75%',
},
sheetHandleHit: {
alignItems: 'center',
paddingTop: spacing.xs,
paddingBottom: spacing.sm,
},
sheetHandle: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.border,
marginBottom: spacing.sm,
backgroundColor: c.border,
},
sheetHeader: {
flexDirection: 'row',
@@ -1022,11 +1057,11 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
sheetTitle: {
...typography.bodySemibold,
color: colors.text,
color: c.text,
},
sheetClose: {
width: 28,
@@ -1045,7 +1080,7 @@ const styles = StyleSheet.create({
paddingVertical: spacing.md,
minHeight: 48,
},
moreItemPressed: { backgroundColor: colors.surfaceHover },
moreItemPressed: { backgroundColor: c.surfaceHover },
moreItemIcon: {
width: 20,
alignItems: 'center',
@@ -1053,7 +1088,7 @@ const styles = StyleSheet.create({
},
moreItemLabel: {
...typography.body,
color: colors.text,
color: c.text,
flex: 1,
},
@@ -1066,13 +1101,14 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
moveRowPressed: { backgroundColor: colors.surfaceHover },
moveRowPressed: { backgroundColor: c.surfaceHover },
moveRowLabel: {
...typography.body,
color: colors.text,
color: c.text,
flex: 1,
},
moveRowLabelDisabled: {
color: colors.textMuted,
color: c.textMuted,
},
});
});
}
+22 -17
View File
@@ -10,7 +10,8 @@ import {
import { FileText, Folder, HardDrive } from 'lucide-react-native';
import { getFileNodes } from '../api/files';
import type { FileNode } from '../api/types';
import { colors, spacing, typography } from '../theme/tokens';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
function formatFileSize(bytes?: number): string {
if (bytes == null) return '';
@@ -20,6 +21,8 @@ function formatFileSize(bytes?: number): string {
}
export default function FilesScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [files, setFiles] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -44,7 +47,7 @@ export default function FilesScreen() {
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.primary} />
<ActivityIndicator color={c.primary} />
</View>
);
}
@@ -67,7 +70,7 @@ export default function FilesScreen() {
<Text style={styles.title}>Files</Text>
</View>
<View style={styles.center}>
<HardDrive size={48} color={colors.textMuted} />
<HardDrive size={48} color={c.textMuted} />
<Text style={styles.emptyText}>No files yet</Text>
</View>
</View>
@@ -85,9 +88,9 @@ export default function FilesScreen() {
renderItem={({ item }) => (
<View style={styles.fileRow}>
{item.type === 'directory' ? (
<Folder size={20} color={colors.primary} />
<Folder size={20} color={c.primary} />
) : (
<FileText size={20} color={colors.textMuted} />
<FileText size={20} color={c.textMuted} />
)}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>
@@ -104,22 +107,23 @@ export default function FilesScreen() {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
backgroundColor: c.background,
},
header: {
paddingTop: 60,
paddingHorizontal: spacing.md,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
title: {
fontSize: typography.h2.fontSize,
fontWeight: '700' as const,
color: colors.text,
color: c.text,
},
center: {
flex: 1,
@@ -128,12 +132,12 @@ const styles = StyleSheet.create({
gap: spacing.sm,
},
emptyText: {
color: colors.textMuted,
color: c.textMuted,
fontSize: typography.body.fontSize,
marginTop: spacing.sm,
},
errorText: {
color: colors.error,
color: c.error,
fontSize: typography.body.fontSize,
textAlign: 'center',
paddingHorizontal: spacing.lg,
@@ -142,11 +146,11 @@ const styles = StyleSheet.create({
marginTop: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.primary,
backgroundColor: c.primary,
borderRadius: 8,
},
retryText: {
color: colors.primaryForeground,
color: c.primaryForeground,
fontWeight: '600' as const,
},
fileRow: {
@@ -155,7 +159,7 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
gap: spacing.sm,
},
fileInfo: {
@@ -163,11 +167,12 @@ const styles = StyleSheet.create({
},
fileName: {
fontSize: typography.body.fontSize,
color: colors.text,
color: c.text,
},
fileSize: {
fontSize: typography.caption.fontSize,
color: colors.textMuted,
color: c.textMuted,
marginTop: 2,
},
});
});
}
+31 -26
View File
@@ -22,12 +22,15 @@ import type { EmailAddress } from '../api/types';
import ContactListRow from '../components/contacts/ContactListRow';
import ContactPickerSheet from '../components/contacts/ContactPickerSheet';
import Dialog from '../components/Dialog';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type Nav = NativeStackNavigationProp<RootStackParamList, 'GroupDetail'>;
type Route = RouteProp<RootStackParamList, 'GroupDetail'>;
export default function GroupDetailScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
const route = useRoute<Route>();
const { groupId } = route.params;
@@ -53,7 +56,7 @@ export default function GroupDetailScreen() {
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<ArrowLeft size={22} color={colors.text} />
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>Group</Text>
</View>
@@ -127,7 +130,7 @@ export default function GroupDetailScreen() {
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<ArrowLeft size={22} color={colors.text} />
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{name}</Text>
<View style={styles.headerActions}>
@@ -136,17 +139,17 @@ export default function GroupDetailScreen() {
style={styles.headerBtn}
hitSlop={8}
>
<Edit2 size={18} color={colors.text} />
<Edit2 size={18} color={c.text} />
</Pressable>
<Pressable onPress={() => setConfirmDelete(true)} style={styles.headerBtn} hitSlop={8}>
<Trash2 size={18} color={colors.error} />
<Trash2 size={18} color={c.error} />
</Pressable>
</View>
</View>
<View style={styles.hero}>
<View style={styles.heroIcon}>
<Users size={32} color={colors.primary} />
<Users size={32} color={c.primary} />
</View>
<Text style={styles.heroName}>{name}</Text>
<Text style={styles.heroSubtitle}>
@@ -156,11 +159,11 @@ export default function GroupDetailScreen() {
<View style={styles.actionsRow}>
<Pressable onPress={emailAll} style={styles.actionBtn}>
<Mail size={16} color={colors.primary} />
<Mail size={16} color={c.primary} />
<Text style={styles.actionLabel}>Email all</Text>
</Pressable>
<Pressable onPress={() => setPickerOpen(true)} style={[styles.actionBtn, styles.actionBtnPrimary]}>
<Plus size={16} color={colors.primaryForeground} />
<Plus size={16} color={c.primaryForeground} />
<Text style={[styles.actionLabel, styles.actionLabelPrimary]}>Add member</Text>
</Pressable>
</View>
@@ -181,14 +184,14 @@ export default function GroupDetailScreen() {
style={styles.removeMemberBtn}
hitSlop={8}
>
<UserMinus size={16} color={colors.error} />
<UserMinus size={16} color={c.error} />
</Pressable>
</View>
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListEmptyComponent={
<View style={styles.empty}>
<Users size={40} color={colors.surfaceActive} />
<Users size={40} color={c.surfaceActive} />
<Text style={styles.emptyTitle}>No members yet</Text>
<Text style={styles.emptySubtitle}>Tap "Add member" to get started</Text>
</View>
@@ -231,8 +234,9 @@ export default function GroupDetailScreen() {
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
@@ -240,14 +244,14 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
gap: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
borderBottomColor: c.borderLight,
},
headerBtn: {
width: 40, height: 40,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.full,
},
headerTitle: { ...typography.h3, color: colors.text, flex: 1, marginLeft: spacing.xs },
headerTitle: { ...typography.h3, color: c.text, flex: 1, marginLeft: spacing.xs },
headerActions: { flexDirection: 'row', gap: spacing.xs },
hero: {
@@ -258,12 +262,12 @@ const styles = StyleSheet.create({
heroIcon: {
width: 80, height: 80,
borderRadius: 40,
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
alignItems: 'center',
justifyContent: 'center',
},
heroName: { ...typography.h2, color: colors.text, marginTop: spacing.sm },
heroSubtitle: { ...typography.body, color: colors.textSecondary },
heroName: { ...typography.h2, color: c.text, marginTop: spacing.sm },
heroSubtitle: { ...typography.body, color: c.textSecondary },
actionsRow: {
flexDirection: 'row',
@@ -280,11 +284,11 @@ const styles = StyleSheet.create({
gap: spacing.xs,
paddingVertical: spacing.sm,
borderRadius: radius.md,
backgroundColor: colors.surface,
backgroundColor: c.surface,
},
actionBtnPrimary: { backgroundColor: colors.primary },
actionLabel: { ...typography.bodyMedium, color: colors.text },
actionLabelPrimary: { color: colors.primaryForeground },
actionBtnPrimary: { backgroundColor: c.primary },
actionLabel: { ...typography.bodyMedium, color: c.text },
actionLabelPrimary: { color: c.primaryForeground },
memberRow: {
flexDirection: 'row',
@@ -296,12 +300,13 @@ const styles = StyleSheet.create({
borderRadius: radius.full,
marginRight: spacing.md,
},
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
separator: { height: 1, backgroundColor: c.borderLight, marginLeft: 68 },
empty: { alignItems: 'center', paddingVertical: spacing.xxxl, gap: spacing.sm },
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
emptySubtitle: { ...typography.caption, color: colors.textMuted },
emptyTitle: { ...typography.bodyMedium, color: c.textSecondary },
emptySubtitle: { ...typography.caption, color: c.textMuted },
missing: { flex: 1, alignItems: 'center', justifyContent: 'center' },
missingText: { ...typography.body, color: colors.textMuted },
});
missingText: { ...typography.body, color: c.textMuted },
});
}
+19 -14
View File
@@ -2,7 +2,8 @@ import React from 'react';
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Eye, EyeOff, X } from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button, Input } from '../components';
import { useAuthStore } from '../stores/auth-store';
@@ -13,6 +14,8 @@ interface LoginScreenProps {
}
export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScreenProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const login = useAuthStore((state) => state.login);
const isLoading = useAuthStore((state) => state.isLoading);
const error = useAuthStore((state) => state.error);
@@ -56,7 +59,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
<SafeAreaView style={styles.container}>
{isAddMode && onCancel ? (
<Pressable onPress={onCancel} style={styles.cancelButton} hitSlop={10}>
<X size={24} color={colors.text} />
<X size={24} color={c.text} />
</Pressable>
) : null}
<KeyboardAvoidingView
@@ -113,9 +116,9 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
rightIcon={
<Pressable onPress={() => setShowPassword(!showPassword)}>
{showPassword ? (
<EyeOff size={20} color={colors.textMuted} />
<EyeOff size={20} color={c.textMuted} />
) : (
<Eye size={20} color={colors.textMuted} />
<Eye size={20} color={c.textMuted} />
)}
</Pressable>
}
@@ -162,8 +165,9 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
cancelButton: {
position: 'absolute',
top: 50,
@@ -189,8 +193,8 @@ const styles = StyleSheet.create({
width: 72,
height: 72,
},
appName: { ...typography.h1, color: colors.text },
tagline: { ...typography.caption, color: colors.textMuted, marginTop: spacing.xs },
appName: { ...typography.h1, color: c.text },
tagline: { ...typography.caption, color: c.textMuted, marginTop: spacing.xs },
form: { gap: spacing.lg },
@@ -199,7 +203,7 @@ const styles = StyleSheet.create({
},
errorText: {
...typography.caption,
color: colors.error,
color: c.error,
textAlign: 'center',
},
@@ -209,12 +213,13 @@ const styles = StyleSheet.create({
marginVertical: spacing.md,
gap: spacing.md,
},
dividerLine: { flex: 1, height: 1, backgroundColor: colors.border },
dividerText: { ...typography.caption, color: colors.textMuted },
dividerLine: { flex: 1, height: 1, backgroundColor: c.border },
dividerText: { ...typography.caption, color: c.textMuted },
oauthRow: { gap: spacing.md },
footer: { alignItems: 'center', marginTop: 40, gap: spacing.xs },
footerText: { ...typography.caption, color: colors.textMuted },
footerLink: { ...typography.caption, color: colors.textLink },
});
footerText: { ...typography.caption, color: c.textMuted },
footerLink: { ...typography.caption, color: c.textLink },
});
}
+103 -150
View File
@@ -9,7 +9,8 @@ import {
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Bug,
type LucideIcon,
} from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { ReadingSettings } from '../components/settings/ReadingSettings';
import { NotificationSettings } from '../components/settings/NotificationSettings';
import { AccountSettings } from '../components/settings/AccountSettings';
@@ -34,6 +35,7 @@ import { ContentSendersSettings } from '../components/settings/ContentSendersSet
import { LanguageSettings } from '../components/settings/LanguageSettings';
import { ComposingSettings } from '../components/settings/ComposingSettings';
import { LayoutSettings } from '../components/settings/LayoutSettings';
import { useLocaleStore } from '../stores/locale-store';
type Tab =
| 'account' | 'language' | 'notifications'
@@ -147,8 +149,23 @@ interface SettingsScreenProps {
}
export default function SettingsScreen({ onLogout, onBack, onTabSelect }: SettingsScreenProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [selectedTab, setSelectedTab] = useState<Tab | null>(null);
const groupedTabs = groupTabs();
// Subscribe to locale so labels re-render when the user picks a different language.
const locale = useLocaleStore((s) => s.locale);
const t = useLocaleStore((s) => s.t);
const groupedTabs = React.useMemo(() => {
void locale; // dependency: re-translate on locale change
return groupTabs().map((g) => ({
...g,
label: t(`settings.tab_groups.${g.group}`, g.label),
items: g.items.map((tab) => ({
...tab,
label: t(`settings.tabs.${tab.id}`, tab.label),
})),
}));
}, [locale, t]);
useEffect(() => {
if (!selectedTab) return;
@@ -166,9 +183,10 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
};
if (selectedTab) {
const tabDef = TABS.find(t => t.id === selectedTab)!;
const tabDef = TABS.find((tab) => tab.id === selectedTab)!;
const Component = TAB_COMPONENTS[selectedTab];
const TabIcon = tabDef.icon;
const tabLabel = t(`settings.tabs.${tabDef.id}`, tabDef.label);
return (
<SafeAreaView style={styles.container} edges={['top']}>
@@ -177,10 +195,10 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
onPress={() => setSelectedTab(null)}
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
>
<ArrowLeft size={20} color={colors.text} />
<ArrowLeft size={20} color={c.text} />
</Pressable>
<TabIcon size={20} color={colors.mutedForeground} />
<Text style={styles.headerTitle}>{tabDef.label}</Text>
<TabIcon size={20} color={c.mutedForeground} />
<Text style={styles.headerTitle}>{tabLabel}</Text>
</View>
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
@@ -198,10 +216,10 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
onPress={onBack}
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
>
<ArrowLeft size={20} color={colors.text} />
<ArrowLeft size={20} color={c.text} />
</Pressable>
<Settings size={20} color={colors.mutedForeground} />
<Text style={styles.headerTitle}>Settings</Text>
<Settings size={20} color={c.mutedForeground} />
<Text style={styles.headerTitle}>{t('settings.title', 'Settings')}</Text>
</View>
{/* Tab list - matches webmail mobile: flat grouped list, no cards */}
@@ -230,7 +248,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
]}
>
<View style={styles.tabItemLeft}>
<Icon size={16} color={colors.mutedForeground} />
<Icon size={16} color={c.mutedForeground} />
<Text
style={[
styles.tabItemLabel,
@@ -250,7 +268,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
<Text style={styles.notWorkingText}>Not implemented</Text>
</View>
) : (
<ChevronRight size={16} color={colors.mutedForeground} />
<ChevronRight size={16} color={c.mutedForeground} />
)}
</Pressable>
);
@@ -265,8 +283,8 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
onPress={onLogout}
style={({ pressed }) => [styles.logoutBtn, pressed && styles.logoutBtnPressed]}
>
<LogOut size={16} color={colors.error} />
<Text style={styles.logoutText}>Sign Out</Text>
<LogOut size={16} color={c.error} />
<Text style={styles.logoutText}>{t('sidebar.sign_out', 'Sign Out')}</Text>
</Pressable>
</View>
</ScrollView>
@@ -274,147 +292,82 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.headerHeight,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: c.border,
gap: spacing.sm,
},
headerBackBtn: {
width: 40, height: 40,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.md,
},
headerBackBtnPressed: { backgroundColor: c.accent },
headerTitle: { ...typography.h3, color: c.text },
// Header - webmail: h-14, px-4, border-b, flex items-center gap-2
header: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.headerHeight,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
gap: spacing.sm,
},
headerBackBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.md,
},
headerBackBtnPressed: {
backgroundColor: colors.accent,
},
headerTitle: {
...typography.h3,
color: colors.text,
},
scrollArea: { flex: 1 },
scrollContent: { paddingBottom: 40 },
detailContent: { padding: spacing.lg, paddingBottom: 40 },
scrollArea: {
flex: 1,
},
scrollContent: {
paddingBottom: 40,
},
detailContent: {
padding: spacing.lg,
paddingBottom: 40,
},
tabList: { paddingVertical: spacing.sm },
tabList: {
paddingVertical: spacing.sm,
},
groupDivider: {
height: 1, backgroundColor: c.border,
marginHorizontal: 20, marginVertical: spacing.sm,
},
groupHeader: { paddingHorizontal: 20, paddingTop: spacing.md, paddingBottom: 6 },
groupLabel: {
fontSize: 11, fontWeight: '600',
textTransform: 'uppercase', letterSpacing: 0.8,
color: c.mutedForeground,
},
// Group divider - webmail: mx-5 my-2 border-t border-border
groupDivider: {
height: 1,
backgroundColor: colors.border,
marginHorizontal: 20,
marginVertical: spacing.sm,
},
tabItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20, paddingVertical: 14,
},
tabItemPressed: { backgroundColor: c.muted },
tabItemLeft: {
flexDirection: 'row', alignItems: 'center',
gap: spacing.md, flex: 1,
},
tabItemLabel: { ...typography.body, color: c.text },
// Group header - webmail: px-5 pt-3 pb-1.5
groupHeader: {
paddingHorizontal: 20,
paddingTop: spacing.md,
paddingBottom: 6,
},
groupLabel: {
fontSize: 11,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.8,
color: colors.mutedForeground,
},
experimentalBadge: {
backgroundColor: 'rgba(202, 138, 4, 0.15)',
borderRadius: radius.full,
paddingHorizontal: 6, paddingVertical: 2,
},
experimentalText: { fontSize: 10, fontWeight: '500', color: c.warning },
// Tab item - webmail: px-5 py-3.5, icon w-4 h-4 text-muted-foreground, gap-3, ChevronRight
tabItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 14,
},
tabItemPressed: {
backgroundColor: colors.muted,
},
tabItemLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
flex: 1,
},
tabItemLabel: {
...typography.body,
color: colors.text,
},
tabItemDisabled: { opacity: 0.55 },
tabItemLabelDisabled: { color: c.mutedForeground },
notWorkingBadge: {
backgroundColor: c.muted,
borderRadius: radius.full,
paddingHorizontal: 8, paddingVertical: 2,
},
notWorkingText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
// Experimental badge - webmail: text-[10px] rounded-full bg-warning/15 text-warning
experimentalBadge: {
backgroundColor: 'rgba(202, 138, 4, 0.15)',
borderRadius: radius.full,
paddingHorizontal: 6,
paddingVertical: 2,
},
experimentalText: {
fontSize: 10,
fontWeight: '500',
color: colors.warning,
},
tabItemDisabled: {
opacity: 0.55,
},
tabItemLabelDisabled: {
color: colors.mutedForeground,
},
notWorkingBadge: {
backgroundColor: colors.muted,
borderRadius: radius.full,
paddingHorizontal: 8,
paddingVertical: 2,
},
notWorkingText: {
fontSize: 10,
fontWeight: '500',
color: colors.mutedForeground,
},
// Logout - webmail: border-t border-border, px-5 py-3, destructive text
logoutSection: {
borderTopWidth: 1,
borderTopColor: colors.border,
paddingHorizontal: 20,
paddingVertical: spacing.md,
},
logoutBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: 10,
paddingHorizontal: spacing.sm,
borderRadius: radius.sm,
},
logoutBtnPressed: {
backgroundColor: colors.muted,
},
logoutText: {
...typography.body,
color: colors.error,
},
});
logoutSection: {
borderTopWidth: 1, borderTopColor: c.border,
paddingHorizontal: 20, paddingVertical: spacing.md,
},
logoutBtn: {
flexDirection: 'row', alignItems: 'center', gap: spacing.md,
paddingVertical: 10, paddingHorizontal: spacing.sm,
borderRadius: radius.sm,
},
logoutBtnPressed: { backgroundColor: c.muted },
logoutText: { ...typography.body, color: c.error },
});
}
+30 -42
View File
@@ -1,45 +1,33 @@
export const colors = {
// Primary brand colors (matching Bulwark web)
primary: '#3b82f6',
primaryDark: '#2563eb',
primaryLight: '#60a5fa',
import { useColorScheme } from 'react-native';
import { useSettingsStore } from '../stores/settings-store';
import { LIGHT_COLORS, DARK_COLORS, type ThemePalette } from './tokens';
// Backgrounds
background: '#ffffff',
surface: '#f8fafc',
surfaceHover: '#f1f5f9',
export type { ThemePalette };
// Text
text: '#0f172a',
textSecondary: '#64748b',
textMuted: '#94a3b8',
textInverse: '#ffffff',
/**
* Returns the active palette for the current render. Resolves the user's
* theme preference ('light' | 'dark' | 'system') against the OS scheme.
*
* Prefer this hook in new and migrated components. Static `import { colors }`
* still works for not-yet-migrated code (it always returns the dark palette).
*/
export function useColors(): ThemePalette {
const themePref = useSettingsStore((s) => s.theme);
const systemScheme = useColorScheme();
const resolved =
themePref === 'system'
? systemScheme === 'light' ? 'light' : 'dark'
: themePref;
return resolved === 'light' ? LIGHT_COLORS : DARK_COLORS;
}
// Borders
border: '#e2e8f0',
borderLight: '#f1f5f9',
// Status
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
info: '#3b82f6',
// Email-specific
unread: '#0f172a',
read: '#64748b',
starred: '#f59e0b',
flagged: '#ef4444',
// Dark mode
dark: {
background: '#0f172a',
surface: '#1e293b',
surfaceHover: '#334155',
text: '#f8fafc',
textSecondary: '#94a3b8',
textMuted: '#64748b',
border: '#334155',
borderLight: '#1e293b',
},
} as const;
/**
* Resolves the user's theme preference to a concrete 'light' | 'dark'.
* Useful for components that need to choose icons/imagery rather than colors.
*/
export function useResolvedTheme(): 'light' | 'dark' {
const themePref = useSettingsStore((s) => s.theme);
const systemScheme = useColorScheme();
if (themePref === 'system') return systemScheme === 'light' ? 'light' : 'dark';
return themePref;
}
+253 -121
View File
@@ -1,194 +1,326 @@
// Qui Dark Theme - exact values from Bulwark Webmail's builtin-themes.ts
export const colors = {
// Primary brand (Qui theme)
// Mirrors the webmail design tokens defined in app/globals.css.
// Two palettes (light + dark) live here; consumers should call `useColors()`
// from theme/colors.tsx to pick the active one based on the user's setting.
// The `colors` export below is kept as a "dark-default" fallback for
// not-yet-migrated StyleSheets so they keep rendering while migration is
// underway.
interface CalendarColors {
blue: string; purple: string; green: string; orange: string;
red: string; pink: string; teal: string; indigo: string;
purpleBg: string; tealBg: string; indigoBg: string; pinkBg: string; orangeBg: string;
}
interface TagColor { dot: string; bg: string; text: string; }
interface TagColors {
red: TagColor; orange: TagColor; yellow: TagColor; green: TagColor;
blue: TagColor; purple: TagColor; pink: TagColor; teal: TagColor;
cyan: TagColor; indigo: TagColor; amber: TagColor; lime: TagColor; gray: TagColor;
}
export interface ThemePalette {
// Brand
primary: string;
primaryDark: string;
primaryLight: string;
primaryBg: string;
primaryBgHover: string;
primaryForeground: string;
primaryBorder: string;
// Surfaces
background: string;
surface: string;
surfaceHover: string;
surfaceActive: string;
card: string;
cardForeground: string;
// Text
text: string;
textSecondary: string;
textMuted: string;
textInverse: string;
textLink: string;
// Borders
border: string;
borderLight: string;
borderFocus: string;
// Status
success: string;
successBg: string;
successForeground: string;
warning: string;
warningBg: string;
warningForeground: string;
error: string;
errorBg: string;
errorForeground: string;
errorBorder: string;
info: string;
infoForeground: string;
// Email-specific
unread: string;
read: string;
starred: string;
flagged: string;
draft: string;
// Calendar
calendar: CalendarColors;
// Tags
tags: TagColors;
// Navigation
navActive: string;
navInactive: string;
navBadge: string;
// Secondary / muted / accent
secondary: string;
secondaryForeground: string;
muted: string;
mutedForeground: string;
accent: string;
accentForeground: string;
// Selection
selection: string;
selectionForeground: string;
// Popover
popover: string;
popoverForeground: string;
// Charts
chart1: string; chart2: string; chart3: string; chart4: string; chart5: string;
}
const TAGS: TagColors = {
red: { dot: '#ef4444', bg: 'rgba(69,10,10,0.30)', text: '#f87171' },
orange: { dot: '#f97316', bg: 'rgba(67,20,7,0.30)', text: '#fb923c' },
yellow: { dot: '#eab308', bg: 'rgba(66,32,6,0.30)', text: '#fbbf24' },
green: { dot: '#22c55e', bg: 'rgba(5,46,22,0.30)', text: '#4ade80' },
blue: { dot: '#3b82f6', bg: 'rgba(23,37,84,0.30)', text: '#60a5fa' },
purple: { dot: '#a855f7', bg: 'rgba(59,7,100,0.30)', text: '#c084fc' },
pink: { dot: '#ec4899', bg: 'rgba(80,7,36,0.30)', text: '#f472b6' },
teal: { dot: '#14b8a6', bg: 'rgba(4,47,46,0.30)', text: '#2dd4bf' },
cyan: { dot: '#06b6d4', bg: 'rgba(8,51,68,0.30)', text: '#22d3ee' },
indigo: { dot: '#6366f1', bg: 'rgba(30,27,75,0.30)', text: '#818cf8' },
amber: { dot: '#f59e0b', bg: 'rgba(69,26,3,0.30)', text: '#fbbf24' },
lime: { dot: '#84cc16', bg: 'rgba(26,46,5,0.30)', text: '#a3e635' },
gray: { dot: '#6b7280', bg: 'rgba(3,7,18,0.30)', text: '#9ca3af' },
};
export const DARK_COLORS: ThemePalette = {
primary: '#3b82f6',
primaryDark: '#2563eb',
primaryLight: '#60a5fa',
primaryBg: '#172554',
primaryBgHover: '#1e40af',
primaryForeground: '#ffffff',
primaryBorder: 'rgba(59, 130, 246, 0.19)',
// Backgrounds (Qui dark)
background: '#09090b',
surface: '#18181b', // --color-secondary / --color-muted
surface: '#18181b',
surfaceHover: '#1f1f23',
surfaceActive: '#27272a',
card: '#141414', // --color-card
card: '#141414',
cardForeground: '#fafafa',
// Text (Qui dark)
text: '#fafafa', // --color-foreground
textSecondary: '#a1a1aa', // --color-muted-foreground
text: '#fafafa',
textSecondary: '#a1a1aa',
textMuted: '#71717a',
textInverse: '#ffffff',
textInverse: '#0f172a',
textLink: '#60a5fa',
// Borders (Qui dark)
border: '#27272a', // --color-border / --color-input
border: '#27272a',
borderLight: '#18181b',
borderFocus: '#3b82f6', // --color-ring
borderFocus: '#3b82f6',
// Status
success: '#16a34a',
successBg: '#052e16',
successForeground: '#ffffff',
warning: '#ca8a04',
warningBg: '#422006',
warningForeground: '#ffffff',
error: '#ef4444', // --color-destructive
error: '#ef4444',
errorBg: '#450a0a',
errorForeground: '#fafafa',
errorBorder: 'rgba(239, 68, 68, 0.25)',
info: '#60a5fa',
infoForeground: '#ffffff',
// Email-specific
unread: '#60a5fa', // --color-unread
unread: '#60a5fa',
read: '#a1a1aa',
starred: '#fbbf24', // amber-400
starred: '#fbbf24',
flagged: '#ef4444',
draft: '#a78bfa',
// Border variants (for inline opacity hacks)
primaryBorder: 'rgba(59, 130, 246, 0.19)', // primary + '30'
errorBorder: 'rgba(239, 68, 68, 0.25)', // error + '40'
// Calendar event colors
calendar: {
blue: '#60a5fa',
purple: '#a78bfa',
green: '#4ade80',
orange: '#fbbf24',
red: '#f87171',
pink: '#f472b6',
teal: '#2dd4bf',
indigo: '#818cf8',
// Dark-mode backgrounds for colored icons (replaces hardcoded light colors)
purpleBg: 'rgba(167, 139, 250, 0.08)', // purple-950/30 dark
tealBg: 'rgba(45, 212, 191, 0.08)', // teal-950/30 dark
indigoBg: 'rgba(129, 140, 248, 0.08)', // indigo-950/30 dark
pinkBg: 'rgba(244, 114, 182, 0.08)', // pink-950/30 dark
orangeBg: 'rgba(251, 191, 36, 0.08)', // orange-950/30 dark
blue: '#60a5fa', purple: '#a78bfa', green: '#4ade80', orange: '#fbbf24',
red: '#f87171', pink: '#f472b6', teal: '#2dd4bf', indigo: '#818cf8',
purpleBg: 'rgba(167, 139, 250, 0.08)',
tealBg: 'rgba(45, 212, 191, 0.08)',
indigoBg: 'rgba(129, 140, 248, 0.08)',
pinkBg: 'rgba(244, 114, 182, 0.08)',
orangeBg: 'rgba(251, 191, 36, 0.08)',
},
// Tag colors - from KEYWORD_PALETTE in stores/settings-store.ts
// dark mode: bg-{color}-950/30, dot: bg-{color}-500, text: {color}-400
tags: {
red: { dot: '#ef4444', bg: 'rgba(69,10,10,0.30)', text: '#f87171' },
orange: { dot: '#f97316', bg: 'rgba(67,20,7,0.30)', text: '#fb923c' },
yellow: { dot: '#eab308', bg: 'rgba(66,32,6,0.30)', text: '#fbbf24' },
green: { dot: '#22c55e', bg: 'rgba(5,46,22,0.30)', text: '#4ade80' },
blue: { dot: '#3b82f6', bg: 'rgba(23,37,84,0.30)', text: '#60a5fa' },
purple: { dot: '#a855f7', bg: 'rgba(59,7,100,0.30)', text: '#c084fc' },
pink: { dot: '#ec4899', bg: 'rgba(80,7,36,0.30)', text: '#f472b6' },
teal: { dot: '#14b8a6', bg: 'rgba(4,47,46,0.30)', text: '#2dd4bf' },
cyan: { dot: '#06b6d4', bg: 'rgba(8,51,68,0.30)', text: '#22d3ee' },
indigo: { dot: '#6366f1', bg: 'rgba(30,27,75,0.30)', text: '#818cf8' },
amber: { dot: '#f59e0b', bg: 'rgba(69,26,3,0.30)', text: '#fbbf24' },
lime: { dot: '#84cc16', bg: 'rgba(26,46,5,0.30)', text: '#a3e635' },
gray: { dot: '#6b7280', bg: 'rgba(3,7,18,0.30)', text: '#9ca3af' },
},
tags: TAGS,
// Navigation - matches web nav-rail
navActive: '#3b82f6',
navInactive: '#71717a', // --color-muted-foreground
navBadge: '#ef4444', // red-500
navInactive: '#71717a',
navBadge: '#ef4444',
// Secondary / Muted (Qui dark)
secondary: '#18181b',
secondaryForeground: '#fafafa',
muted: '#18181b',
mutedForeground: '#a1a1aa',
// Accent (Qui dark)
accent: '#172554',
accentForeground: '#93c5fd',
// Selection (Qui dark)
selection: 'rgba(59, 130, 246, 0.25)',
selectionForeground: '#93c5fd',
// Popover (Qui dark)
popover: '#18181b',
popoverForeground: '#fafafa',
// Chart colors (Qui dark)
chart1: '#60a5fa',
chart2: '#4ade80',
chart3: '#fbbf24',
chart4: '#f87171',
chart5: '#a78bfa',
} as const;
chart1: '#60a5fa', chart2: '#4ade80', chart3: '#fbbf24', chart4: '#f87171', chart5: '#a78bfa',
};
const LIGHT_TAGS: TagColors = {
// For light mode, dot stays as the saturated swatch but bg gets lighter and text gets darker.
red: { dot: '#ef4444', bg: 'rgba(254,226,226,0.6)', text: '#b91c1c' },
orange: { dot: '#f97316', bg: 'rgba(254,237,213,0.6)', text: '#c2410c' },
yellow: { dot: '#eab308', bg: 'rgba(254,249,195,0.6)', text: '#a16207' },
green: { dot: '#22c55e', bg: 'rgba(220,252,231,0.6)', text: '#15803d' },
blue: { dot: '#3b82f6', bg: 'rgba(219,234,254,0.6)', text: '#1d4ed8' },
purple: { dot: '#a855f7', bg: 'rgba(243,232,255,0.6)', text: '#7e22ce' },
pink: { dot: '#ec4899', bg: 'rgba(252,231,243,0.6)', text: '#be185d' },
teal: { dot: '#14b8a6', bg: 'rgba(204,251,241,0.6)', text: '#0f766e' },
cyan: { dot: '#06b6d4', bg: 'rgba(207,250,254,0.6)', text: '#0e7490' },
indigo: { dot: '#6366f1', bg: 'rgba(224,231,255,0.6)', text: '#4338ca' },
amber: { dot: '#f59e0b', bg: 'rgba(254,243,199,0.6)', text: '#b45309' },
lime: { dot: '#84cc16', bg: 'rgba(236,252,203,0.6)', text: '#4d7c0f' },
gray: { dot: '#6b7280', bg: 'rgba(243,244,246,0.6)', text: '#374151' },
};
export const LIGHT_COLORS: ThemePalette = {
primary: '#3b82f6',
primaryDark: '#2563eb',
primaryLight: '#60a5fa',
primaryBg: '#dbeafe',
primaryBgHover: '#bfdbfe',
primaryForeground: '#ffffff',
primaryBorder: 'rgba(59, 130, 246, 0.25)',
background: '#ffffff',
surface: '#f8fafc',
surfaceHover: '#f1f5f9',
surfaceActive: '#e2e8f0',
card: '#ffffff',
cardForeground: '#0f172a',
text: '#0f172a',
textSecondary: '#64748b',
textMuted: '#94a3b8',
textInverse: '#ffffff',
textLink: '#1d4ed8',
border: '#e2e8f0',
borderLight: '#f1f5f9',
borderFocus: '#94a3b8',
success: '#22c55e',
successBg: '#dcfce7',
successForeground: '#ffffff',
warning: '#eab308',
warningBg: '#fef9c3',
warningForeground: '#ffffff',
error: '#ef4444',
errorBg: '#fee2e2',
errorForeground: '#ffffff',
errorBorder: 'rgba(239, 68, 68, 0.30)',
info: '#3b82f6',
infoForeground: '#ffffff',
unread: '#3b82f6',
read: '#64748b',
starred: '#eab308',
flagged: '#ef4444',
draft: '#a855f7',
calendar: {
blue: '#3b82f6', purple: '#a855f7', green: '#22c55e', orange: '#f59e0b',
red: '#ef4444', pink: '#ec4899', teal: '#14b8a6', indigo: '#6366f1',
purpleBg: 'rgba(168, 85, 247, 0.10)',
tealBg: 'rgba(20, 184, 166, 0.10)',
indigoBg: 'rgba(99, 102, 241, 0.10)',
pinkBg: 'rgba(236, 72, 153, 0.10)',
orangeBg: 'rgba(245, 158, 11, 0.10)',
},
tags: LIGHT_TAGS,
navActive: '#3b82f6',
navInactive: '#64748b',
navBadge: '#ef4444',
secondary: '#f8fafc',
secondaryForeground: '#0f172a',
muted: '#f1f5f9',
mutedForeground: '#64748b',
accent: '#dbeafe',
accentForeground: '#1e40af',
selection: 'rgba(59, 130, 246, 0.18)',
selectionForeground: '#1e40af',
popover: '#ffffff',
popoverForeground: '#0f172a',
chart1: '#3b82f6', chart2: '#22c55e', chart3: '#f59e0b', chart4: '#ef4444', chart5: '#8b5cf6',
};
// Backwards-compat: not-yet-migrated StyleSheets keep importing `colors` and
// see the dark palette. Migrate component-by-component to `useColors()`.
export const colors = DARK_COLORS;
// Spacing matches Tailwind's rem scale converted to px
export const spacing = {
xs: 4, // 0.25rem
sm: 8, // 0.5rem
md: 12, // 0.75rem
lg: 16, // 1rem
xl: 20, // 1.25rem
xxl: 24, // 1.5rem
xxxl: 32, // 2rem
xs: 4, sm: 8, md: 12, lg: 16, xl: 20, xxl: 24, xxxl: 32,
} as const;
// Border radii matching Tailwind's rounded-* scale
export const radius = {
xs: 4, // rounded
sm: 6, // rounded-md
md: 8, // rounded-lg
lg: 12, // rounded-xl
xl: 16, // rounded-2xl
full: 9999, // rounded-full
xs: 4, sm: 6, md: 8, lg: 12, xl: 16, full: 9999,
} as const;
// Typography matching web's Tailwind text-* classes
export const typography = {
// Headings
h1: { fontSize: 24, fontWeight: '700' as const, lineHeight: 32 }, // text-2xl
h2: { fontSize: 20, fontWeight: '700' as const, lineHeight: 28 }, // text-xl
h3: { fontSize: 18, fontWeight: '600' as const, lineHeight: 28 }, // text-lg
// Body = text-sm (14px) - the web uses text-sm for almost all content
h1: { fontSize: 24, fontWeight: '700' as const, lineHeight: 32 },
h2: { fontSize: 20, fontWeight: '700' as const, lineHeight: 28 },
h3: { fontSize: 18, fontWeight: '600' as const, lineHeight: 28 },
body: { fontSize: 14, fontWeight: '400' as const, lineHeight: 20 },
bodyMedium: { fontSize: 14, fontWeight: '500' as const, lineHeight: 20 },
bodySemibold: { fontSize: 14, fontWeight: '600' as const, lineHeight: 20 },
bodyBold: { fontSize: 14, fontWeight: '700' as const, lineHeight: 20 },
// Base = text-base (16px) - used for email content body
base: { fontSize: 16, fontWeight: '400' as const, lineHeight: 24 },
baseMedium: { fontSize: 16, fontWeight: '500' as const, lineHeight: 24 },
// Caption = text-xs (12px)
caption: { fontSize: 12, fontWeight: '400' as const, lineHeight: 16 },
captionMedium: { fontSize: 12, fontWeight: '500' as const, lineHeight: 16 },
// Small = 10px - for tags, badges, tab labels
small: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 },
tabLabel: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 },
} as const;
// Component-specific sizes matching web
export const componentSizes = {
// Avatars (web: w-8/w-10/w-12)
avatarSm: 32,
avatarMd: 40,
avatarLg: 48,
// Nav icons (web: w-5 h-5 = 20px)
navIcon: 20,
// Status icons (web: w-3.5 h-3.5 = 14px)
statusIcon: 14,
// Mobile header height (web: h-14 = 56px)
headerHeight: 56,
// Input height (web: h-10 = 40px)
inputHeight: 40,
// Button heights
buttonSm: 36, // h-9
buttonMd: 40, // h-10
buttonLg: 44, // h-11
// Tag dot (web: w-1.5 h-1.5 = 6px)
tagDot: 6,
// Event dot (web: w-1.5 h-1.5 = 6px)
eventDot: 6,
// Toggle switch (web: h-6 w-11)
toggleHeight: 24,
toggleWidth: 44,
toggleThumb: 16,
// FAB (standard material)
fab: 56,
// Nav badge (web: min-w-4 h-4 = 16px)
badgeSize: 16,
avatarSm: 32, avatarMd: 40, avatarLg: 48,
navIcon: 20, statusIcon: 14,
headerHeight: 56, inputHeight: 40,
buttonSm: 36, buttonMd: 40, buttonLg: 44,
tagDot: 6, eventDot: 6,
toggleHeight: 24, toggleWidth: 44, toggleThumb: 16,
fab: 56, badgeSize: 16,
} as const;