feat: Add System Theme Support, Yin & Yang Theme, and Adaptive Plugin Badge Color
Closes #437 Closes #1374 Closes #1578
This commit is contained in:
@@ -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<T extends string = string> {
|
||||
value: T;
|
||||
label: string;
|
||||
icon?: React.ComponentProps<typeof MaterialCommunityIcons>['name'];
|
||||
}
|
||||
|
||||
export interface SegmentedControlProps<T extends string = string> {
|
||||
options: SegmentedControlOption<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
theme: ThemeColors;
|
||||
showCheckIcon?: boolean;
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string = string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
theme,
|
||||
showCheckIcon = true,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{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 (
|
||||
<View key={option.value} style={buttonStyles}>
|
||||
<Pressable
|
||||
style={styles.segmentPressable}
|
||||
onPress={() => onChange(option.value)}
|
||||
android_ripple={{
|
||||
color: theme.rippleColor,
|
||||
borderless: false,
|
||||
}}
|
||||
>
|
||||
{showCheckIcon && isSelected && (
|
||||
<MaterialCommunityIcons
|
||||
name="check"
|
||||
size={18}
|
||||
color={textColor}
|
||||
style={styles.checkIcon}
|
||||
/>
|
||||
)}
|
||||
{option.icon && !isSelected && (
|
||||
<MaterialCommunityIcons
|
||||
name={option.icon}
|
||||
size={18}
|
||||
color={textColor}
|
||||
style={styles.icon}
|
||||
/>
|
||||
)}
|
||||
<Text style={[styles.segmentText, { color: textColor }]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export { SegmentedControl } from './SegmentedControl';
|
||||
export type {
|
||||
SegmentedControlOption,
|
||||
SegmentedControlProps,
|
||||
} from './SegmentedControl';
|
||||
@@ -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';
|
||||
|
||||
@@ -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<ThemeColors>('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<ColorSchemeName>(
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -118,6 +118,10 @@ const BottomNavigator = () => {
|
||||
headerShown: false,
|
||||
animation: 'shift',
|
||||
lazy: true,
|
||||
tabBarBadgeStyle: {
|
||||
backgroundColor: theme.error,
|
||||
color: theme.onError,
|
||||
},
|
||||
})}
|
||||
tabBar={renderBottomBar}
|
||||
>
|
||||
|
||||
@@ -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 <PickThemeStep />;
|
||||
return <ThemeSelectionStep />;
|
||||
default:
|
||||
return <PickThemeStep />;
|
||||
return <ThemeSelectionStep />;
|
||||
}
|
||||
};
|
||||
const renderHelptext = () => {
|
||||
@@ -29,7 +29,7 @@ export default function OnboardingScreen() {
|
||||
case OnboardingStep.PICK_THEME:
|
||||
return getString('onboardingScreen.pickATheme');
|
||||
default:
|
||||
return <PickThemeStep />;
|
||||
return getString('onboardingScreen.pickATheme');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<FlatList
|
||||
contentContainerStyle={styles.flatListContainer}
|
||||
data={list}
|
||||
renderItem={({ item }) => (
|
||||
<ThemePicker
|
||||
key={item.id}
|
||||
currentTheme={theme}
|
||||
theme={item}
|
||||
onPress={() => {
|
||||
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 (
|
||||
<View
|
||||
style={[
|
||||
styles.schemeButtonWrapper,
|
||||
isDarkButton ? styles.darkButton : styles.lightButton,
|
||||
{
|
||||
borderColor: theme.onSurface,
|
||||
backgroundColor: isSelected ? theme.primary : theme.surface,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
android_ripple={{ color: theme.rippleColor, borderless: true }}
|
||||
style={styles.schemeButton}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text style={{ color: isSelected ? theme.onPrimary : theme.onSurface }}>
|
||||
{isDarkButton
|
||||
? getString('onboardingScreen.dark')
|
||||
: getString('onboardingScreen.light')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<View style={styles.flex}>
|
||||
<View style={styles.buttonContainer}>
|
||||
<SchemeButton
|
||||
onPress={() => {
|
||||
if (theme.isDark) {
|
||||
setTheme(getInverseTheme(theme));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SchemeButton
|
||||
onPress={() => {
|
||||
if (!theme.isDark) {
|
||||
setTheme(getInverseTheme(theme));
|
||||
}
|
||||
}}
|
||||
isDarkButton={true}
|
||||
/>
|
||||
</View>
|
||||
<ThemeList theme={theme} list={theme.isDark ? darkThemes : lightThemes} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
@@ -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<AmoledToggleProps> = ({ theme }) => {
|
||||
const [isAmoledBlack = false, setAmoledBlack] =
|
||||
useMMKVBoolean('AMOLED_BLACK');
|
||||
|
||||
if (!theme.isDark) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.amoledContainer}>
|
||||
<Text style={[styles.amoledLabel, { color: theme.onSurface }]}>
|
||||
{getString('appearanceScreen.pureBlackDarkMode')}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => setAmoledBlack(!isAmoledBlack)}
|
||||
style={[
|
||||
styles.toggle,
|
||||
{
|
||||
backgroundColor: isAmoledBlack
|
||||
? theme.primary
|
||||
: theme.surfaceVariant,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.toggleThumb,
|
||||
isAmoledBlack && styles.toggleThumbActive,
|
||||
{
|
||||
backgroundColor: isAmoledBlack
|
||||
? theme.onPrimary
|
||||
: theme.onSurfaceVariant,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
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<ThemeMode>[] = 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 (
|
||||
<View style={styles.container}>
|
||||
{/* Segmented Control */}
|
||||
<View style={styles.segmentedControlContainer}>
|
||||
<SegmentedControl
|
||||
options={themeModeOptions}
|
||||
value={currentMode}
|
||||
onChange={handleModeChange}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Theme List */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.themeScrollContent}
|
||||
>
|
||||
{availableThemes.map(item => (
|
||||
<View key={item.id} style={styles.themeItem}>
|
||||
<ThemePicker
|
||||
currentTheme={theme}
|
||||
theme={item}
|
||||
onPress={() => handleThemeSelect(item)}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* AMOLED Toggle */}
|
||||
<AmoledToggle theme={theme} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -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<ThemeMode>[] = 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 (
|
||||
<SafeAreaView excludeTop>
|
||||
<Appbar
|
||||
@@ -54,6 +101,18 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => {
|
||||
<List.SubHeader theme={theme}>
|
||||
{getString('appearanceScreen.appTheme')}
|
||||
</List.SubHeader>
|
||||
|
||||
{/* Theme Mode Selector */}
|
||||
<View style={styles.segmentedControlContainer}>
|
||||
<SegmentedControl
|
||||
options={themeModeOptions}
|
||||
value={currentMode}
|
||||
onChange={handleModeChange}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Light Themes */}
|
||||
<Text style={[{ color: theme.onSurface }, styles.themeSectionText]}>
|
||||
{getString('appearanceScreen.lightTheme')}
|
||||
</Text>
|
||||
@@ -68,13 +127,12 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => {
|
||||
key={item.id}
|
||||
currentTheme={theme}
|
||||
theme={item}
|
||||
onPress={() => {
|
||||
setTheme(item);
|
||||
setCustomAccentColor(undefined);
|
||||
}}
|
||||
onPress={() => handleThemeSelect(item)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Dark Themes */}
|
||||
<Text style={[{ color: theme.onSurface }, styles.themeSectionText]}>
|
||||
{getString('appearanceScreen.darkTheme')}
|
||||
</Text>
|
||||
@@ -89,13 +147,11 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => {
|
||||
key={item.id}
|
||||
currentTheme={theme}
|
||||
theme={item}
|
||||
onPress={() => {
|
||||
setTheme(item);
|
||||
setCustomAccentColor(undefined);
|
||||
}}
|
||||
onPress={() => handleThemeSelect(item)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{theme.isDark ? (
|
||||
<SettingSwitch
|
||||
label={getString('appearanceScreen.pureBlackDarkMode')}
|
||||
@@ -188,4 +244,8 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 8,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
segmentedControlContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { lavenderTheme } from './lavender';
|
||||
import { strawberryDaiquiriTheme } from './strawberry';
|
||||
import { takoTheme } from './tako';
|
||||
import { catppuccinTheme } from './catppuccin';
|
||||
import { yinyangTheme } from './yinyang';
|
||||
|
||||
export const lightThemes = [
|
||||
defaultTheme.light,
|
||||
@@ -16,6 +17,7 @@ export const lightThemes = [
|
||||
strawberryDaiquiriTheme.light,
|
||||
takoTheme.light,
|
||||
catppuccinTheme.light,
|
||||
yinyangTheme.light,
|
||||
];
|
||||
export const darkThemes = [
|
||||
defaultTheme.dark,
|
||||
@@ -26,4 +28,5 @@ export const darkThemes = [
|
||||
strawberryDaiquiriTheme.dark,
|
||||
takoTheme.dark,
|
||||
catppuccinTheme.dark,
|
||||
yinyangTheme.dark,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { getString } from '@strings/translations';
|
||||
|
||||
export const yinyangTheme = {
|
||||
light: {
|
||||
id: 9,
|
||||
name: getString('appearanceScreen.theme.yinyang'),
|
||||
isDark: false,
|
||||
primary: '#000000',
|
||||
onPrimary: '#FFFFFF',
|
||||
primaryContainer: '#000000',
|
||||
onPrimaryContainer: '#FFFFFF',
|
||||
secondary: '#000000',
|
||||
onSecondary: '#FFFFFF',
|
||||
secondaryContainer: '#DDDDDD',
|
||||
onSecondaryContainer: '#0C0C0C',
|
||||
tertiary: '#FFFFFF',
|
||||
onTertiary: '#000000',
|
||||
tertiaryContainer: '#D8E2FF',
|
||||
onTertiaryContainer: '#001947',
|
||||
error: '#BA1A1A',
|
||||
onError: '#FFFFFF',
|
||||
errorContainer: '#FFDAD6',
|
||||
onErrorContainer: '#410002',
|
||||
background: '#FDFDFD',
|
||||
onBackground: '#222222',
|
||||
surface: '#FDFDFD',
|
||||
onSurface: '#222222',
|
||||
surfaceVariant: '#E8E8E8',
|
||||
onSurfaceVariant: '#515151',
|
||||
outline: '#838383',
|
||||
outlineVariant: '#CFCFCF',
|
||||
shadow: '#000000',
|
||||
scrim: '#000000',
|
||||
inverseSurface: '#333333',
|
||||
inverseOnSurface: '#F4F4F4',
|
||||
inversePrimary: '#A6A6A6',
|
||||
surfaceDisabled: 'rgba(34, 34, 34, 0.12)',
|
||||
onSurfaceDisabled: 'rgba(34, 34, 34, 0.38)',
|
||||
backdrop: 'rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
dark: {
|
||||
id: 10,
|
||||
name: getString('appearanceScreen.theme.yinyang'),
|
||||
isDark: true,
|
||||
primary: '#FFFFFF',
|
||||
onPrimary: '#5A5A5A',
|
||||
primaryContainer: '#FFFFFF',
|
||||
onPrimaryContainer: '#000000',
|
||||
secondary: '#FFFFFF',
|
||||
onSecondary: '#5A5A5A',
|
||||
secondaryContainer: '#717171',
|
||||
onSecondaryContainer: '#E4E4E4',
|
||||
tertiary: '#000000',
|
||||
onTertiary: '#FFFFFF',
|
||||
tertiaryContainer: '#00419E',
|
||||
onTertiaryContainer: '#D8E2FF',
|
||||
error: '#FFB4AB',
|
||||
onError: '#690005',
|
||||
errorContainer: '#93000A',
|
||||
onErrorContainer: '#FFB4AB',
|
||||
background: '#1E1E1E',
|
||||
onBackground: '#E6E6E6',
|
||||
surface: '#1E1E1E',
|
||||
onSurface: '#E6E6E6',
|
||||
surfaceVariant: '#313131',
|
||||
onSurfaceVariant: '#D1D1D1',
|
||||
outline: '#999999',
|
||||
outlineVariant: '#2A2A2A',
|
||||
shadow: '#000000',
|
||||
scrim: '#000000',
|
||||
inverseSurface: '#E6E6E6',
|
||||
inverseOnSurface: '#1E1E1E',
|
||||
inversePrimary: '#CECECE',
|
||||
surfaceDisabled: 'rgba(230, 230, 230, 0.12)',
|
||||
onSurfaceDisabled: 'rgba(230, 230, 230, 0.38)',
|
||||
backdrop: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
};
|
||||
@@ -46,6 +46,10 @@
|
||||
"pureBlackDarkMode": "Pure black dark mode",
|
||||
"showHistoryInTheNav": "Show history in the nav",
|
||||
"showUpdatesInTheNav": "Show updates in the nav",
|
||||
"themeMode": "Theme mode",
|
||||
"themeModeLight": "Light",
|
||||
"themeModeDark": "Dark",
|
||||
"themeModeSystem": "System",
|
||||
"theme": {
|
||||
"default": "Default",
|
||||
"lavender": "Lavender",
|
||||
@@ -56,7 +60,8 @@
|
||||
"teal": "Teal",
|
||||
"turquoise": "Turquoise",
|
||||
"yotsuba": "Yotsuba",
|
||||
"catppuccin": "Catppuccin"
|
||||
"catppuccin": "Catppuccin",
|
||||
"yinyang": "Yin & Yang"
|
||||
}
|
||||
},
|
||||
"backupScreen": {
|
||||
@@ -510,6 +515,7 @@
|
||||
"pickATheme": "Pick a theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System",
|
||||
"complete": "Complete"
|
||||
},
|
||||
"notifications": {
|
||||
|
||||
@@ -45,6 +45,10 @@ export interface StringMap {
|
||||
'appearanceScreen.pureBlackDarkMode': 'string';
|
||||
'appearanceScreen.showHistoryInTheNav': 'string';
|
||||
'appearanceScreen.showUpdatesInTheNav': 'string';
|
||||
'appearanceScreen.themeMode': 'string';
|
||||
'appearanceScreen.themeModeLight': 'string';
|
||||
'appearanceScreen.themeModeDark': 'string';
|
||||
'appearanceScreen.themeModeSystem': 'string';
|
||||
'appearanceScreen.theme.default': 'string';
|
||||
'appearanceScreen.theme.lavender': 'string';
|
||||
'appearanceScreen.theme.midnightDusk': 'string';
|
||||
@@ -55,6 +59,7 @@ export interface StringMap {
|
||||
'appearanceScreen.theme.turquoise': 'string';
|
||||
'appearanceScreen.theme.yotsuba': 'string';
|
||||
'appearanceScreen.theme.catppuccin': 'string';
|
||||
'appearanceScreen.theme.yinyang': 'string';
|
||||
'backupScreen.backupName': 'string';
|
||||
'backupScreen.createBackup': 'string';
|
||||
'backupScreen.createBackupDesc': 'string';
|
||||
@@ -415,6 +420,7 @@ export interface StringMap {
|
||||
'onboardingScreen.pickATheme': 'string';
|
||||
'onboardingScreen.light': 'string';
|
||||
'onboardingScreen.dark': 'string';
|
||||
'onboardingScreen.system': 'string';
|
||||
'onboardingScreen.complete': 'string';
|
||||
'notifications.IMPORT_EPUB': 'string';
|
||||
'notifications.UPDATE_LIBRARY': 'string';
|
||||
|
||||
Reference in New Issue
Block a user