diff --git a/src/components/SegmentedControl/SegmentedControl.tsx b/src/components/SegmentedControl/SegmentedControl.tsx new file mode 100644 index 00000000..bc5acbaa --- /dev/null +++ b/src/components/SegmentedControl/SegmentedControl.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; +import { ThemeColors } from '@theme/types'; + +export interface SegmentedControlOption { + value: T; + label: string; + icon?: React.ComponentProps['name']; +} + +export interface SegmentedControlProps { + options: SegmentedControlOption[]; + value: T; + onChange: (value: T) => void; + theme: ThemeColors; + showCheckIcon?: boolean; +} + +export function SegmentedControl({ + options, + value, + onChange, + theme, + showCheckIcon = true, +}: SegmentedControlProps) { + return ( + + {options.map((option, index) => { + const isSelected = value === option.value; + const isFirst = index === 0; + const isLast = index === options.length - 1; + + const buttonStyles = [ + styles.segment, + isFirst && styles.segmentFirst, + isLast && styles.segmentLast, + !isFirst && !isLast && styles.segmentMiddle, + { + backgroundColor: isSelected + ? theme.secondaryContainer + : 'transparent', + borderColor: theme.outline, + }, + ]; + + const textColor = isSelected + ? theme.onSecondaryContainer + : theme.onSurface; + + return ( + + onChange(option.value)} + android_ripple={{ + color: theme.rippleColor, + borderless: false, + }} + > + {showCheckIcon && isSelected && ( + + )} + {option.icon && !isSelected && ( + + )} + + {option.label} + + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + height: 48, + }, + segment: { + flex: 1, + borderTopWidth: 1, + borderBottomWidth: 1, + overflow: 'hidden', + }, + segmentFirst: { + borderLeftWidth: 1, + borderRightWidth: 1, + borderTopLeftRadius: 24, + borderBottomLeftRadius: 24, + }, + segmentMiddle: { + borderRightWidth: 1, + }, + segmentLast: { + borderRightWidth: 1, + borderTopRightRadius: 24, + borderBottomRightRadius: 24, + }, + segmentPressable: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 12, + paddingVertical: 10, + }, + segmentText: { + fontSize: 14, + fontWeight: '500', + letterSpacing: 0.1, + }, + checkIcon: { + marginRight: 8, + }, + icon: { + marginRight: 8, + }, +}); diff --git a/src/components/SegmentedControl/index.ts b/src/components/SegmentedControl/index.ts new file mode 100644 index 00000000..fb86da64 --- /dev/null +++ b/src/components/SegmentedControl/index.ts @@ -0,0 +1,5 @@ +export { SegmentedControl } from './SegmentedControl'; +export type { + SegmentedControlOption, + SegmentedControlProps, +} from './SegmentedControl'; diff --git a/src/components/index.ts b/src/components/index.ts index 31fdc8c4..fae0f3d9 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -15,3 +15,4 @@ export { RadioButton } from './RadioButton/RadioButton'; export { default as ConfirmationDialog } from './ConfirmationDialog/ConfirmationDialog'; export { default as SafeAreaView } from './SafeAreaView/SafeAreaView'; export { default as Modal } from './Modal/Modal'; +export { SegmentedControl } from './SegmentedControl'; diff --git a/src/hooks/persisted/useTheme.ts b/src/hooks/persisted/useTheme.ts index 684de339..13e7d070 100644 --- a/src/hooks/persisted/useTheme.ts +++ b/src/hooks/persisted/useTheme.ts @@ -1,8 +1,8 @@ -import { useMemo } from 'react'; -import { Appearance } from 'react-native'; +import { useEffect, useMemo, useState } from 'react'; +import { Appearance, ColorSchemeName } from 'react-native'; import { useMMKVBoolean, - useMMKVObject, + useMMKVNumber, useMMKVString, } from 'react-native-mmkv'; import { overlay } from 'react-native-paper'; @@ -10,52 +10,110 @@ import Color from 'color'; import { defaultTheme } from '@theme/md3/defaultTheme'; import { ThemeColors } from '@theme/types'; +import { darkThemes, lightThemes } from '@theme/md3'; -const getElevationColor = (colors: ThemeColors, elevation: number) => { +const getElevationColor = (colors: ThemeColors, elevation: number): string => { return Color(colors.surface) .mix(Color(colors.primary), elevation) .rgb() .string(); }; +const addComputedColors = (colors: ThemeColors): ThemeColors => ({ + ...colors, + surface2: getElevationColor(colors, 0.08), + overlay3: overlay(3, colors.surface), + rippleColor: Color(colors.primary).alpha(0.12).toString(), + surfaceReader: Color(colors.surface).alpha(0.9).toString(), +}); + +const applyAmoledBlack = ( + colors: ThemeColors, + isEnabled: boolean, +): ThemeColors => { + if (!isEnabled || !colors.isDark) { + return colors; + } + + return { + ...colors, + background: '#000000', + surface: '#000000', + }; +}; + +const applyCustomAccent = ( + colors: ThemeColors, + accentColor?: string, +): ThemeColors => { + if (!accentColor) { + return colors; + } + + return { + ...colors, + primary: accentColor, + secondary: accentColor, + }; +}; + +const findThemeById = ( + themeId: number | undefined, + isDark: boolean, +): ThemeColors => { + const themeList = isDark ? darkThemes : lightThemes; + + if (themeId !== undefined) { + const theme = themeList.find(t => t.id === themeId); + if (theme) { + return theme; + } + } + + return isDark ? defaultTheme.dark : defaultTheme.light; +}; + +const getBaseTheme = ( + themeMode: string, + themeId: number | undefined, + systemColorScheme: ColorSchemeName, +): ThemeColors => { + if (themeMode === 'system') { + const shouldUseDarkTheme = systemColorScheme === 'dark'; + return findThemeById(themeId, shouldUseDarkTheme); + } + + const isDark = themeMode === 'dark'; + + return findThemeById(themeId, isDark); +}; + export const useTheme = (): ThemeColors => { - const [appTheme] = useMMKVObject('APP_THEME'); - const [isAmoledBlack] = useMMKVBoolean('AMOLED_BLACK'); + const [themeId] = useMMKVNumber('APP_THEME_ID'); + const [themeMode = 'system'] = useMMKVString('THEME_MODE'); + const [isAmoledBlack = false] = useMMKVBoolean('AMOLED_BLACK'); const [customAccent] = useMMKVString('CUSTOM_ACCENT_COLOR'); - const theme: ThemeColors = useMemo(() => { - const isDeviveColorSchemeDark = Appearance.getColorScheme() === 'dark'; + const [systemColorScheme, setSystemColorScheme] = useState( + Appearance.getColorScheme(), + ); - let colors: ThemeColors = - appTheme || - (isDeviveColorSchemeDark ? defaultTheme.dark : defaultTheme.light); + useEffect(() => { + const subscription = Appearance.addChangeListener(({ colorScheme }) => { + setSystemColorScheme(colorScheme); + }); - if (isAmoledBlack && colors.isDark) { - colors = { - ...colors, - background: '#000000', - surface: '#000000', - }; - } + return () => subscription.remove(); + }, []); - if (customAccent) { - colors = { - ...colors, - primary: customAccent, - secondary: customAccent, - }; - } + const theme = useMemo(() => { + const baseTheme = getBaseTheme(themeMode, themeId, systemColorScheme); + const withAmoled = applyAmoledBlack(baseTheme, isAmoledBlack); + const withAccent = applyCustomAccent(withAmoled, customAccent); + const finalTheme = addComputedColors(withAccent); - colors = { - ...colors, - surface2: getElevationColor(colors, 0.08), - overlay3: overlay(3, colors.surface), - rippleColor: Color(colors.primary).alpha(0.12).toString(), - surfaceReader: Color(colors.surface).alpha(0.9).toString(), - }; - - return colors; - }, [appTheme, isAmoledBlack, customAccent]); + return finalTheme; + }, [themeId, themeMode, systemColorScheme, isAmoledBlack, customAccent]); return theme; }; diff --git a/src/navigators/BottomNavigator.tsx b/src/navigators/BottomNavigator.tsx index eee21c02..d99ef62d 100644 --- a/src/navigators/BottomNavigator.tsx +++ b/src/navigators/BottomNavigator.tsx @@ -118,6 +118,10 @@ const BottomNavigator = () => { headerShown: false, animation: 'shift', lazy: true, + tabBarBadgeStyle: { + backgroundColor: theme.error, + color: theme.onError, + }, })} tabBar={renderBottomBar} > diff --git a/src/screens/onboarding/OnboardingScreen.tsx b/src/screens/onboarding/OnboardingScreen.tsx index 94447b4f..ccfe3a18 100644 --- a/src/screens/onboarding/OnboardingScreen.tsx +++ b/src/screens/onboarding/OnboardingScreen.tsx @@ -3,7 +3,7 @@ import { Text } from 'react-native-paper'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Image, StyleSheet, View } from 'react-native'; import { Button } from '@components'; -import PickThemeStep from './PickThemeStep'; +import ThemeSelectionStep from './ThemeSelectionStep'; import { useState } from 'react'; import { MMKVStorage } from '@utils/mmkv/mmkv'; import { getString } from '@strings/translations'; @@ -19,9 +19,9 @@ export default function OnboardingScreen() { const renderStep = () => { switch (step) { case OnboardingStep.PICK_THEME: - return ; + return ; default: - return ; + return ; } }; const renderHelptext = () => { @@ -29,7 +29,7 @@ export default function OnboardingScreen() { case OnboardingStep.PICK_THEME: return getString('onboardingScreen.pickATheme'); default: - return ; + return getString('onboardingScreen.pickATheme'); } }; diff --git a/src/screens/onboarding/PickThemeStep.tsx b/src/screens/onboarding/PickThemeStep.tsx deleted file mode 100644 index 7202f44f..00000000 --- a/src/screens/onboarding/PickThemeStep.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React from 'react'; -import { View, Text, Pressable, StyleSheet, FlatList } from 'react-native'; -import { ThemePicker } from '@components/ThemePicker/ThemePicker'; -import { ThemeColors } from '@theme/types'; -import { useMMKVObject } from 'react-native-mmkv'; -import { useTheme } from '@hooks/persisted'; -import { darkThemes, lightThemes } from '@theme/md3'; -import { getString } from '@strings/translations'; - -const ThemeList = ({ - theme, - list, -}: { - theme: ThemeColors; - list: ThemeColors[]; -}) => { - const [, setTheme] = useMMKVObject('APP_THEME'); - return ( - ( - { - setTheme(item); - }} - /> - )} - horizontal={false} - numColumns={3} - /> - ); -}; - -const SchemeButton = ({ - onPress, - isDarkButton = false, -}: { - onPress: () => void; - isDarkButton?: boolean; -}) => { - const theme = useTheme(); - const isSelected = - (theme.isDark && isDarkButton) || (!theme.isDark && !isDarkButton); - return ( - - - - {isDarkButton - ? getString('onboardingScreen.dark') - : getString('onboardingScreen.light')} - - - - ); -}; - -const getInverseTheme = (theme: ThemeColors) => { - let index = (theme.isDark ? darkThemes : lightThemes).findIndex( - item => item.id === theme.id, - ); - if (index === -1) { - index = 1; - } - if (theme.isDark) { - return lightThemes[index]; - } - return darkThemes[index]; -}; - -export default function PickThemeStep() { - const theme = useTheme(); - const [, setTheme] = useMMKVObject('APP_THEME'); - return ( - - - { - if (theme.isDark) { - setTheme(getInverseTheme(theme)); - } - }} - /> - { - if (!theme.isDark) { - setTheme(getInverseTheme(theme)); - } - }} - isDarkButton={true} - /> - - - - ); -} - -const styles = StyleSheet.create({ - buttonContainer: { - flexDirection: 'row', - justifyContent: 'center', - paddingBottom: 16, - }, - darkButton: { - borderBottomRightRadius: 24, - borderRightWidth: 1, - borderTopRightRadius: 24, - }, - lightButton: { - borderBottomLeftRadius: 24, - borderLeftWidth: 1, - borderRightWidth: 1, - borderTopLeftRadius: 24, - }, - schemeButton: { - paddingHorizontal: 36, - paddingVertical: 8, - }, - schemeButtonWrapper: { - borderBottomWidth: 1, - borderTopWidth: 1, - }, - flatListContainer: { - margin: 'auto', - paddingHorizontal: 8, - paddingBottom: 8, - }, - flex: { flex: 1 }, -}); diff --git a/src/screens/onboarding/ThemeSelectionStep.tsx b/src/screens/onboarding/ThemeSelectionStep.tsx new file mode 100644 index 00000000..36aae08e --- /dev/null +++ b/src/screens/onboarding/ThemeSelectionStep.tsx @@ -0,0 +1,191 @@ +import React, { useMemo } from 'react'; +import { View, Text, Pressable, StyleSheet, ScrollView } from 'react-native'; +import { + useMMKVBoolean, + useMMKVNumber, + useMMKVString, +} from 'react-native-mmkv'; +import { SegmentedControl } from '@components'; +import type { SegmentedControlOption } from '@components/SegmentedControl'; +import { ThemePicker } from '@components/ThemePicker/ThemePicker'; +import { ThemeColors } from '@theme/types'; +import { useTheme } from '@hooks/persisted'; +import { darkThemes, lightThemes } from '@theme/md3'; +import { getString } from '@strings/translations'; + +type ThemeMode = 'light' | 'dark' | 'system'; + +interface AmoledToggleProps { + theme: ThemeColors; +} + +const AmoledToggle: React.FC = ({ theme }) => { + const [isAmoledBlack = false, setAmoledBlack] = + useMMKVBoolean('AMOLED_BLACK'); + + if (!theme.isDark) { + return null; + } + + return ( + + + {getString('appearanceScreen.pureBlackDarkMode')} + + setAmoledBlack(!isAmoledBlack)} + style={[ + styles.toggle, + { + backgroundColor: isAmoledBlack + ? theme.primary + : theme.surfaceVariant, + }, + ]} + > + + + + ); +}; + +export default function ThemeSelectionStep() { + const theme = useTheme(); + const [themeMode = 'system', setThemeMode] = useMMKVString('THEME_MODE'); + const [, setThemeId] = useMMKVNumber('APP_THEME_ID'); + + const currentMode = themeMode as ThemeMode; + + const availableThemes = useMemo(() => { + return theme.isDark ? darkThemes : lightThemes; + }, [theme.isDark]); + + const themeModeOptions: SegmentedControlOption[] = useMemo( + () => [ + { + value: 'system', + label: getString('onboardingScreen.system'), + }, + { + value: 'light', + label: getString('onboardingScreen.light'), + }, + { + value: 'dark', + label: getString('onboardingScreen.dark'), + }, + ], + [], + ); + + 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 (!currentThemeInMode) { + setThemeId(themes[0].id); + } + } + }; + + const handleThemeSelect = (selectedTheme: ThemeColors) => { + setThemeId(selectedTheme.id); + setThemeMode(selectedTheme.isDark ? 'dark' : 'light'); + }; + + return ( + + {/* Segmented Control */} + + + + + {/* Theme List */} + + {availableThemes.map(item => ( + + handleThemeSelect(item)} + /> + + ))} + + + {/* AMOLED Toggle */} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 16, + }, + segmentedControlContainer: { + marginBottom: 24, + }, + themeScrollContent: { + paddingVertical: 16, + paddingHorizontal: 24, + }, + themeItem: { + marginHorizontal: 8, + }, + amoledContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 16, + paddingHorizontal: 4, + marginTop: 'auto', + }, + amoledLabel: { + fontSize: 16, + fontWeight: '400', + }, + toggle: { + width: 52, + height: 32, + borderRadius: 16, + padding: 2, + justifyContent: 'center', + }, + toggleThumb: { + width: 28, + height: 28, + borderRadius: 14, + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 2, + }, + toggleThumbActive: { + alignSelf: 'flex-end', + }, +}); diff --git a/src/screens/settings/SettingsAppearanceScreen.tsx b/src/screens/settings/SettingsAppearanceScreen.tsx index 8d7202c4..98a20d3b 100644 --- a/src/screens/settings/SettingsAppearanceScreen.tsx +++ b/src/screens/settings/SettingsAppearanceScreen.tsx @@ -1,24 +1,29 @@ -import React, { useState } from 'react'; -import { ScrollView, Text, StyleSheet } from 'react-native'; +import React, { useMemo, useState } from 'react'; +import { ScrollView, Text, StyleSheet, View } from 'react-native'; import { ThemePicker } from '@components/ThemePicker/ThemePicker'; +import type { SegmentedControlOption } from '@components/SegmentedControl'; import SettingSwitch from './components/SettingSwitch'; import ColorPickerModal from '@components/ColorPickerModal/ColorPickerModal'; import { useAppSettings, useTheme } from '@hooks/persisted'; import { useMMKVBoolean, - useMMKVObject, + useMMKVNumber, useMMKVString, } from 'react-native-mmkv'; -import { Appbar, List, SafeAreaView } from '@components'; +import { Appbar, List, SafeAreaView, SegmentedControl } from '@components'; import { AppearanceSettingsScreenProps } from '@navigators/types'; import { getString } from '@strings/translations'; import { darkThemes, lightThemes } from '@theme/md3'; +import { ThemeColors } from '@theme/types'; + +type ThemeMode = 'light' | 'dark' | 'system'; const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { const theme = useTheme(); - const [, setTheme] = useMMKVObject('APP_THEME'); + const [, setThemeId] = useMMKVNumber('APP_THEME_ID'); + const [themeMode = 'system', setThemeMode] = useMMKVString('THEME_MODE'); const [isAmoledBlack = false, setAmoledBlack] = useMMKVBoolean('AMOLED_BLACK'); const [, setCustomAccentColor] = useMMKVString('CUSTOM_ACCENT_COLOR'); @@ -32,6 +37,8 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { setAppSettings, } = useAppSettings(); + const currentMode = themeMode as ThemeMode; + /** * Accent Color Modal */ @@ -39,6 +46,46 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { const showAccentColorModal = () => setAccentColorModal(true); const hideAccentColorModal = () => setAccentColorModal(false); + const themeModeOptions: SegmentedControlOption[] = useMemo( + () => [ + { + value: 'system', + label: getString('appearanceScreen.themeModeSystem'), + }, + { + value: 'light', + label: getString('appearanceScreen.themeModeLight'), + }, + { + value: 'dark', + label: getString('appearanceScreen.themeModeDark'), + }, + ], + [], + ); + + 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 (!currentThemeInMode) { + setThemeId(themes[0].id); + } + } + }; + + const handleThemeSelect = (selectedTheme: ThemeColors) => { + setThemeId(selectedTheme.id); + setCustomAccentColor(undefined); + + if (currentMode !== 'system') { + setThemeMode(selectedTheme.isDark ? 'dark' : 'light'); + } + }; + return ( { {getString('appearanceScreen.appTheme')} + + {/* Theme Mode Selector */} + + + + + {/* Light Themes */} {getString('appearanceScreen.lightTheme')} @@ -68,13 +127,12 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { key={item.id} currentTheme={theme} theme={item} - onPress={() => { - setTheme(item); - setCustomAccentColor(undefined); - }} + onPress={() => handleThemeSelect(item)} /> ))} + + {/* Dark Themes */} {getString('appearanceScreen.darkTheme')} @@ -89,13 +147,11 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { key={item.id} currentTheme={theme} theme={item} - onPress={() => { - setTheme(item); - setCustomAccentColor(undefined); - }} + onPress={() => handleThemeSelect(item)} /> ))} + {theme.isDark ? (