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
+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;