diff --git a/package.json b/package.json index 9bdb29050c..a9045dba7f 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "react-native-screens": "^4.24.0", "react-native-shimmer-placeholder": "^2.0.9", "react-native-tab-view": "^4.3.0", + "react-native-theme-switch-animation": "^0.8.0", "react-native-url-polyfill": "^3.0.0", "react-native-webview": "^13.16.1", "react-native-worklets": "^0.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d28dd0161d..ab35cb4e32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,6 +200,9 @@ importers: react-native-tab-view: specifier: ^4.3.0 version: 4.3.0(react-native-pager-view@8.0.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + react-native-theme-switch-animation: + specifier: ^0.8.0 + version: 0.8.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) react-native-url-polyfill: specifier: ^3.0.0 version: 3.0.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4)) @@ -5288,6 +5291,12 @@ packages: react-native: '*' react-native-pager-view: '>= 6.0.0' + react-native-theme-switch-animation@0.8.0: + resolution: {integrity: sha512-z4f3QGSuUP4tagycls2mekng/7uxAbr75Gn0GGm7JRkrviyao++V2CtJ8VUDx+hSOsgfjEhD9D5JubsGbbHB5w==} + peerDependencies: + react: '*' + react-native: '*' + react-native-url-polyfill@3.0.0: resolution: {integrity: sha512-aA5CiuUCUb/lbrliVCJ6lZ17/RpNJzvTO/C7gC/YmDQhTUoRD5q5HlJfwLWcxz4VgAhHwXKzhxH+wUN24tAdqg==} peerDependencies: @@ -12489,6 +12498,11 @@ snapshots: react-native-pager-view: 8.0.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) use-latest-callback: 0.2.6(react@19.2.4) + react-native-theme-switch-animation@0.8.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4) + react-native-url-polyfill@3.0.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4)): dependencies: react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4) diff --git a/src/components/SegmentedControl/SegmentedControl.tsx b/src/components/SegmentedControl/SegmentedControl.tsx index b743356bf3..59e190b6cb 100644 --- a/src/components/SegmentedControl/SegmentedControl.tsx +++ b/src/components/SegmentedControl/SegmentedControl.tsx @@ -1,5 +1,11 @@ import React from 'react'; -import { View, Text, Pressable, StyleSheet } from 'react-native'; +import { + View, + Text, + StyleSheet, + Pressable, + GestureResponderEvent, +} from 'react-native'; import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; import { ThemeColors } from '@theme/types'; @@ -12,7 +18,7 @@ export interface SegmentedControlOption { export interface SegmentedControlProps { options: SegmentedControlOption[]; value: T; - onChange: (value: T) => void; + onChange: (value: T, event: GestureResponderEvent) => void; theme: ThemeColors; showCheckIcon?: boolean; } @@ -52,7 +58,7 @@ export function SegmentedControl({ onChange(option.value)} + onPress={e => onChange(option.value, e)} android_ripple={{ color: theme.rippleColor, borderless: false, diff --git a/src/components/ThemePicker/ThemePicker.tsx b/src/components/ThemePicker/ThemePicker.tsx index 82cfaf8966..8a64a5c4f5 100644 --- a/src/components/ThemePicker/ThemePicker.tsx +++ b/src/components/ThemePicker/ThemePicker.tsx @@ -1,5 +1,11 @@ import React from 'react'; -import { View, Text, StyleSheet, Pressable } from 'react-native'; +import { + View, + Text, + StyleSheet, + Pressable, + GestureResponderEvent, +} from 'react-native'; import { overlay } from 'react-native-paper'; import color from 'color'; import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; @@ -8,8 +14,9 @@ import { ThemeColors } from '@theme/types'; interface ThemePickerProps { theme: ThemeColors; currentTheme: ThemeColors; - onPress: () => void; + onPress: (event: GestureResponderEvent) => void; horizontal?: boolean; + isDark?: boolean; } export const ThemePicker = ({ @@ -17,119 +24,124 @@ export const ThemePicker = ({ currentTheme, onPress, horizontal = false, -}: ThemePickerProps) => ( - - - - {currentTheme.id === theme.id ? ( - - ) : null} - - - - +}: ThemePickerProps) => { + return ( + + + + {currentTheme.id !== theme.id ? null : ( + + )} - - + > - - + + + + + + + + + - - - - - - + + + + + + - - + + + + {theme.name} + - - {theme.name} - - -); + ); +}; const styles = StyleSheet.create({ container: { justifyContent: 'center', alignItems: 'center', paddingBottom: 8, - width: '33%', }, horizontalContainer: { width: undefined, marginHorizontal: 4, + paddingBottom: 0, }, card: { borderWidth: 3.6, @@ -143,7 +155,7 @@ const styles = StyleSheet.create({ shadowOpacity: 0.2, shadowRadius: 4, // Elevation for Android - elevation: 2, + //elevation: 2, }, flex1: { flex: 1, diff --git a/src/hooks/persisted/useTheme.ts b/src/hooks/persisted/useTheme.ts index 13e7d07078..dd72881dfd 100644 --- a/src/hooks/persisted/useTheme.ts +++ b/src/hooks/persisted/useTheme.ts @@ -8,7 +8,6 @@ import { import { overlay } from 'react-native-paper'; import Color from 'color'; -import { defaultTheme } from '@theme/md3/defaultTheme'; import { ThemeColors } from '@theme/types'; import { darkThemes, lightThemes } from '@theme/md3'; @@ -62,17 +61,46 @@ const findThemeById = ( isDark: boolean, ): ThemeColors => { const themeList = isDark ? darkThemes : lightThemes; - + let theme: ThemeColors | undefined; if (themeId !== undefined) { - const theme = themeList.find(t => t.id === themeId); - if (theme) { - return theme; - } + const id = transformThemeId(themeId, isDark); + theme = themeList.find(t => t.id === id); } - return isDark ? defaultTheme.dark : defaultTheme.light; + return theme ?? themeList[0]; }; +// transforms legacy theme IDs to new IDs +function transformThemeId(themeId: number, isDark: boolean): number { + if (themeId > 99) return themeId; + const lightIdMap: Record = { + '1': 100, + '8': 102, + '9': 108, + '10': 101, + '12': 103, + '14': 104, + '16': 105, + '18': 106, + '20': 107, + }; + const darkIdMap: Record = { + '2': 100, + '9': 102, + '10': 108, + '11': 101, + '13': 103, + '15': 104, + '17': 105, + '19': 106, + '21': 107, + }; + if (isDark) { + return darkIdMap[themeId] ?? themeId; + } + return lightIdMap[themeId] ?? themeId; +} + const getBaseTheme = ( themeMode: string, themeId: number | undefined, @@ -95,7 +123,7 @@ export const useTheme = (): ThemeColors => { const [customAccent] = useMMKVString('CUSTOM_ACCENT_COLOR'); const [systemColorScheme, setSystemColorScheme] = useState( - Appearance.getColorScheme(), + Appearance.getColorScheme() ?? 'unspecified', ); useEffect(() => { diff --git a/src/screens/onboarding/ThemeSelectionStep.tsx b/src/screens/onboarding/ThemeSelectionStep.tsx index 36aae08ee6..4a5e0063f9 100644 --- a/src/screens/onboarding/ThemeSelectionStep.tsx +++ b/src/screens/onboarding/ThemeSelectionStep.tsx @@ -1,5 +1,11 @@ import React, { useMemo } from 'react'; -import { View, Text, Pressable, StyleSheet, ScrollView } from 'react-native'; +import { + View, + Text, + Pressable, + StyleSheet, + GestureResponderEvent, +} from 'react-native'; import { useMMKVBoolean, useMMKVNumber, @@ -12,6 +18,9 @@ import { ThemeColors } from '@theme/types'; import { useTheme } from '@hooks/persisted'; import { darkThemes, lightThemes } from '@theme/md3'; import { getString } from '@strings/translations'; +import { LegendList } from '@legendapp/list'; +import Switch from '@components/Switch/Switch'; +import switchTheme from 'react-native-theme-switch-animation'; type ThemeMode = 'light' | 'dark' | 'system'; @@ -23,39 +32,23 @@ const AmoledToggle: React.FC = ({ theme }) => { const [isAmoledBlack = false, setAmoledBlack] = useMMKVBoolean('AMOLED_BLACK'); - if (!theme.isDark) { - return null; - } + const toggle = () => setAmoledBlack(!isAmoledBlack); + + if (!theme.isDark) return null; return ( - + {getString('appearanceScreen.pureBlackDarkMode')} - setAmoledBlack(!isAmoledBlack)} - style={[ - styles.toggle, - { - backgroundColor: isAmoledBlack - ? theme.primary - : theme.surfaceVariant, - }, - ]} - > - - - + + ); }; @@ -88,22 +81,41 @@ export default function ThemeSelectionStep() { [], ); - const handleModeChange = (mode: ThemeMode) => { + const handleModeChange = (mode: ThemeMode, event: GestureResponderEvent) => { setThemeMode(mode); - - if (mode !== 'system') { - const themes = mode === 'dark' ? darkThemes : lightThemes; - const currentThemeInMode = themes.find(t => t.id === theme.id); - - if (!currentThemeInMode) { - setThemeId(themes[0].id); - } - } + event.currentTarget.measure((_x1, _y1, width, height, px, py) => { + switchTheme({ + switchThemeFunction: () => {}, + animationConfig: { + type: 'circular', + duration: 900, + startingPoint: { + cy: py + height / 2, + cx: px + width / 2, + }, + }, + }); + }); }; - const handleThemeSelect = (selectedTheme: ThemeColors) => { + const handleThemeSelect = ( + selectedTheme: ThemeColors, + event: GestureResponderEvent, + ) => { setThemeId(selectedTheme.id); - setThemeMode(selectedTheme.isDark ? 'dark' : 'light'); + event.currentTarget.measure((_x1, _y1, width, height, px, py) => { + switchTheme({ + switchThemeFunction: () => {}, + animationConfig: { + type: 'circular', + duration: 900, + startingPoint: { + cy: py + height / 2, + cx: px + width / 2, + }, + }, + }); + }); }; return ( @@ -117,24 +129,23 @@ export default function ThemeSelectionStep() { theme={theme} /> - {/* Theme List */} - - {availableThemes.map(item => ( - + data={availableThemes} + extraData={theme} + keyExtractor={item => 'theme-' + item.id} + renderItem={({ item }) => ( + handleThemeSelect(item)} + onPress={e => handleThemeSelect(item, e)} /> - ))} - - + )} + /> {/* AMOLED Toggle */} @@ -149,13 +160,6 @@ const styles = StyleSheet.create({ segmentedControlContainer: { marginBottom: 24, }, - themeScrollContent: { - paddingVertical: 16, - paddingHorizontal: 24, - }, - themeItem: { - marginHorizontal: 8, - }, amoledContainer: { flexDirection: 'row', alignItems: 'center', diff --git a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx index 65b8ca8cf2..3951687b98 100644 --- a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx +++ b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx @@ -1,5 +1,11 @@ import React, { useMemo, useState } from 'react'; -import { ScrollView, Text, StyleSheet, View } from 'react-native'; +import { + ScrollView, + StyleSheet, + View, + Appearance, + GestureResponderEvent, +} from 'react-native'; import { ThemePicker } from '@components/ThemePicker/ThemePicker'; import type { SegmentedControlOption } from '@components/SegmentedControl'; @@ -18,13 +24,17 @@ import { AppearanceSettingsScreenProps } from '@navigators/types'; import { getString } from '@strings/translations'; import { darkThemes, lightThemes } from '@theme/md3'; import { ThemeColors } from '@theme/types'; +import switchTheme from 'react-native-theme-switch-animation'; type ThemeMode = 'light' | 'dark' | 'system'; const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { const theme = useTheme(); const [, setThemeId] = useMMKVNumber('APP_THEME_ID'); - const [themeMode = 'system', setThemeMode] = useMMKVString('THEME_MODE'); + const [themeMode = 'system', setThemeMode] = useMMKVString('THEME_MODE') as [ + ThemeMode, + (mode: ThemeMode) => void, + ]; const [isAmoledBlack = false, setAmoledBlack] = useMMKVBoolean('AMOLED_BLACK'); const [, setCustomAccentColor] = useMMKVString('CUSTOM_ACCENT_COLOR'); @@ -38,7 +48,13 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { setAppSettings, } = useAppSettings(); - const currentMode = themeMode as ThemeMode; + const colorScheme = Appearance.getColorScheme() ?? 'light'; + const actualThemeMode: Exclude = + themeMode !== 'system' + ? themeMode + : colorScheme === 'unspecified' + ? 'light' + : colorScheme; /** * Accent Color Modal @@ -117,26 +133,63 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { [], ); - const handleModeChange = (mode: ThemeMode) => { - setThemeMode(mode); + // const handleModeChange = (mode: ThemeMode) => { + // setThemeMode(mode); - if (mode !== 'system') { - const themes = mode === 'dark' ? darkThemes : lightThemes; - const currentThemeInMode = themes.find(t => t.id === theme.id); + // if (mode !== 'system') { + // const themes = mode === 'dark' ? darkThemes : lightThemes; + // const currentThemeInMode = themes.find(t => t.id === theme.id); - if (!currentThemeInMode) { - setThemeId(themes[0].id); - } - } + // if (!currentThemeInMode) { + // setThemeId(themes[0].id); + // } + // } + // }; + + // const handleThemeSelect = (selectedTheme: ThemeColors) => { + // setThemeId(selectedTheme.id); + // setCustomAccentColor(undefined); + + // if (actualThemeMode !== 'system') { + // setThemeMode(selectedTheme.isDark ? 'dark' : 'light'); + // } + // }; + + const handleModeChange = (mode: ThemeMode, event: GestureResponderEvent) => { + setThemeMode(mode); + event.currentTarget.measure((_x1, _y1, width, height, px, py) => { + switchTheme({ + switchThemeFunction: () => {}, + animationConfig: { + type: 'circular', + duration: 900, + startingPoint: { + cy: py + height / 2, + cx: px + width / 2, + }, + }, + }); + }); }; - const handleThemeSelect = (selectedTheme: ThemeColors) => { + const handleThemeSelect = ( + selectedTheme: ThemeColors, + event: GestureResponderEvent, + ) => { setThemeId(selectedTheme.id); - setCustomAccentColor(undefined); - - if (currentMode !== 'system') { - setThemeMode(selectedTheme.isDark ? 'dark' : 'light'); - } + event.currentTarget.measure((_x1, _y1, width, height, px, py) => { + switchTheme({ + switchThemeFunction: () => {}, + animationConfig: { + type: 'circular', + duration: 900, + startingPoint: { + cy: py + height / 2, + cx: px + width / 2, + }, + }, + }); + }); }; return ( @@ -159,52 +212,38 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { {/* Light Themes */} - + {/* {getString('appearanceScreen.lightTheme')} - - - {lightThemes.map(item => ( - handleThemeSelect(item)} - /> - ))} - - - {/* Dark Themes */} - - {getString('appearanceScreen.darkTheme')} - - - {darkThemes.map(item => ( - handleThemeSelect(item)} - /> - ))} - - + */} + + + {(actualThemeMode === 'light' ? lightThemes : darkThemes).map( + item => ( + handleThemeSelect(item, e)} + /> + ), + )} + + {theme.isDark ? ( ({ ...theme, id: 100 + i })); export const darkThemes = [ defaultTheme.dark, midnightDusk.dark, @@ -29,4 +36,4 @@ export const darkThemes = [ takoTheme.dark, catppuccinTheme.dark, yinyangTheme.dark, -]; +].map((theme, i) => ({ ...theme, id: 100 + i })); diff --git a/src/theme/md3/lavender.ts b/src/theme/md3/lavender.ts index d3515ad1c9..e08094c0dc 100644 --- a/src/theme/md3/lavender.ts +++ b/src/theme/md3/lavender.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const lavenderTheme = { light: { - id: 14, name: getString('appearanceScreen.theme.lavender'), isDark: false, primary: 'rgb(121, 68, 173)', @@ -39,7 +38,6 @@ export const lavenderTheme = { backdrop: 'rgba(52, 47, 55, 0.4)', }, dark: { - id: 15, name: getString('appearanceScreen.theme.lavender'), isDark: true, primary: 'rgb(221, 184, 255)', diff --git a/src/theme/md3/mignightDusk.ts b/src/theme/md3/mignightDusk.ts index 8a47708c98..0af08b6c4c 100644 --- a/src/theme/md3/mignightDusk.ts +++ b/src/theme/md3/mignightDusk.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const midnightDusk = { light: { - id: 10, name: getString('appearanceScreen.theme.daybreakBloom'), isDark: false, primary: 'rgb(240, 36, 117)', @@ -39,7 +38,6 @@ export const midnightDusk = { backdrop: 'rgba(58, 45, 47, 0.4)', }, dark: { - id: 11, name: getString('appearanceScreen.theme.midnightDusk'), isDark: true, primary: 'rgb(240, 36, 117)', diff --git a/src/theme/md3/strawberry.ts b/src/theme/md3/strawberry.ts index 612992179f..b617ecbf5e 100644 --- a/src/theme/md3/strawberry.ts +++ b/src/theme/md3/strawberry.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const strawberryDaiquiriTheme = { light: { - id: 16, name: getString('appearanceScreen.theme.strawberry'), isDark: false, primary: 'rgb(182, 30, 64)', @@ -39,7 +38,6 @@ export const strawberryDaiquiriTheme = { backdrop: 'rgba(59, 45, 46, 0.4)', }, dark: { - id: 17, name: getString('appearanceScreen.theme.strawberry'), isDark: true, primary: 'rgb(255, 178, 184)', diff --git a/src/theme/md3/tako.ts b/src/theme/md3/tako.ts index 2502c2f2cd..588fb5d63f 100644 --- a/src/theme/md3/tako.ts +++ b/src/theme/md3/tako.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const takoTheme = { light: { - id: 18, name: getString('appearanceScreen.theme.tako'), isDark: false, primary: '#66577E', @@ -39,7 +38,6 @@ export const takoTheme = { backdrop: 'rgba(51, 47, 55, 0.4)', }, dark: { - id: 19, name: getString('appearanceScreen.theme.tako'), isDark: true, primary: '#F3B375', diff --git a/src/theme/md3/tealTurquoise.ts b/src/theme/md3/tealTurquoise.ts index f9bb458bf9..30619be2cd 100644 --- a/src/theme/md3/tealTurquoise.ts +++ b/src/theme/md3/tealTurquoise.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const tealTurquoise = { light: { - id: 8, name: getString('appearanceScreen.theme.teal'), isDark: false, primary: 'rgb(0, 106, 106)', @@ -39,7 +38,6 @@ export const tealTurquoise = { backdrop: 'rgba(41, 50, 50, 0.4)', }, dark: { - id: 9, name: getString('appearanceScreen.theme.turquoise'), isDark: true, primary: 'rgb(76, 218, 218)', diff --git a/src/theme/md3/yinyang.ts b/src/theme/md3/yinyang.ts index 5f4b934d25..5b8b32e16f 100644 --- a/src/theme/md3/yinyang.ts +++ b/src/theme/md3/yinyang.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const yinyangTheme = { light: { - id: 9, name: getString('appearanceScreen.theme.yinyang'), isDark: false, primary: '#000000', @@ -39,7 +38,6 @@ export const yinyangTheme = { backdrop: 'rgba(0, 0, 0, 0.4)', }, dark: { - id: 10, name: getString('appearanceScreen.theme.yinyang'), isDark: true, primary: '#FFFFFF', diff --git a/src/theme/md3/yotsuba.ts b/src/theme/md3/yotsuba.ts index 0105636e99..1b981769e2 100644 --- a/src/theme/md3/yotsuba.ts +++ b/src/theme/md3/yotsuba.ts @@ -2,7 +2,6 @@ import { getString } from '@strings/translations'; export const yotsubaTheme = { light: { - id: 12, name: getString('appearanceScreen.theme.yotsuba'), isDark: false, primary: 'rgb(174, 50, 0)', @@ -39,7 +38,6 @@ export const yotsubaTheme = { backdrop: 'rgba(59, 45, 41, 0.4)', }, dark: { - id: 13, name: getString('appearanceScreen.theme.yotsuba'), isDark: true, primary: 'rgb(255, 181, 158)',