Skip to content

Commit

Permalink
Merge pull request #4211 from tloncorp/ja/theme-switcher
Browse files Browse the repository at this point in the history
  • Loading branch information
jamesacklin authored Nov 24, 2024
2 parents fa3a49c + 25f3c98 commit 403f5eb
Show file tree
Hide file tree
Showing 12 changed files with 378 additions and 94 deletions.
2 changes: 1 addition & 1 deletion apps/tlon-mobile/src/App.main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export default function ConnectedApp() {
return (
<ErrorBoundary>
<FeatureFlagConnectedInstrumentationProvider>
<TamaguiProvider defaultTheme={isDarkMode ? 'dark' : 'light'}>
<TamaguiProvider>
<ShipProvider>
<NavigationContainer
theme={isDarkMode ? DarkTheme : DefaultTheme}
Expand Down
10 changes: 9 additions & 1 deletion packages/app/features/settings/ProfileScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useMutableRef } from '@tloncorp/shared';
import { NavBarView, ProfileScreenView, View } from '@tloncorp/ui';
import { useCallback, useEffect, useState } from 'react';
import { Platform } from 'react-native';
import { getVariableValue, useTheme } from 'tamagui';

import { useDMLureLink } from '../../hooks/useBranchLink';
import { useCurrentUserId } from '../../hooks/useCurrentUser';
Expand Down Expand Up @@ -61,8 +62,14 @@ export default function ProfileScreen(props: Props) {
navigationRef.current.navigate('Profile');
}, [navigationRef]);

const onThemePressed = useCallback(() => {
navigationRef.current.navigate('Theme');
}, [navigationRef]);

const backgroundColor = getVariableValue(useTheme().background);

return (
<View backgroundColor="$background" flex={1}>
<View backgroundColor={backgroundColor} flex={1}>
<ProfileScreenView
hasHostedAuth={hasHostedAuth}
currentUserId={currentUserId}
Expand All @@ -74,6 +81,7 @@ export default function ProfileScreen(props: Props) {
onBlockedUsersPressed={onBlockedUsersPressed}
onManageAccountPressed={onManageAccountPressed}
onExperimentalFeaturesPressed={onExperimentalFeaturesPressed}
onThemePressed={onThemePressed}
dmLink={dmLink}
/>
<NavBarView
Expand Down
111 changes: 111 additions & 0 deletions packages/app/features/settings/ThemeScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { themeSettings } from '@tloncorp/shared/db';
import {
ListItem,
ListItemInputOption,
LoadingSpinner,
Pressable,
RadioControl,
ScreenHeader,
View,
} from '@tloncorp/ui';
import { useContext, useEffect, useState } from 'react';
import { ScrollView, YStack } from 'tamagui';
import type { ThemeName } from 'tamagui';

import { useIsDarkMode } from '../../hooks/useIsDarkMode';
import { RootStackParamList } from '../../navigation/types';
import { ThemeContext, clearTheme, setTheme } from '../../provider';

type Props = NativeStackScreenProps<RootStackParamList, 'Theme'>;

export function ThemeScreen(props: Props) {
const { setActiveTheme } = useContext(ThemeContext);
const isDarkMode = useIsDarkMode();
const [selectedTheme, setSelectedTheme] = useState<ThemeName | 'auto'>(
'auto'
);
const [loadingTheme, setLoadingTheme] = useState<ThemeName | 'auto' | null>(
null
);

const themes: ListItemInputOption<ThemeName | 'auto'>[] = [
{
title: 'Auto',
value: 'auto',
subtitle: `Uses system ${isDarkMode ? 'dark' : 'light'} theme`,
},
{ title: 'Tlon Light', value: 'light' },
{ title: 'Tlon Dark', value: 'dark' },
{ title: 'Dracula', value: 'dracula' },
{ title: 'Greenscreen', value: 'greenscreen' },
{ title: 'Gruvbox', value: 'gruvbox' },
{ title: 'Monokai', value: 'monokai' },
{ title: 'Nord', value: 'nord' },
{ title: 'Peony', value: 'peony' },
{ title: 'Solarized', value: 'solarized' },
];

const handleThemeChange = async (value: ThemeName | 'auto') => {
if (value === selectedTheme || loadingTheme) return;

setLoadingTheme(value);
try {
if (value === 'auto') {
await clearTheme(setActiveTheme, isDarkMode);
} else {
await setTheme(value, setActiveTheme);
}
setSelectedTheme(value);
} finally {
setLoadingTheme(null);
}
};

useEffect(() => {
const checkSelected = async () => {
const storedTheme = await themeSettings.getValue();
setSelectedTheme(storedTheme ?? 'auto');
};
checkSelected();
}, []);

return (
<>
<ScreenHeader
title="Theme"
backAction={() => props.navigation.goBack()}
/>
<ScrollView>
<YStack flex={1} padding="$l">
{themes.map((theme) => (
<Pressable
key={theme.value}
disabled={loadingTheme !== null}
onPress={() => handleThemeChange(theme.value)}
borderRadius="$xl"
>
<ListItem>
<ListItem.MainContent>
<ListItem.Title>{theme.title}</ListItem.Title>
{theme.subtitle && (
<ListItem.Subtitle>{theme.subtitle}</ListItem.Subtitle>
)}
</ListItem.MainContent>
<ListItem.EndContent>
{loadingTheme === theme.value ? (
<View padding="$m">
<LoadingSpinner color="$primaryText" size="small" />
</View>
) : (
<RadioControl checked={theme.value === selectedTheme} />
)}
</ListItem.EndContent>
</ListItem>
</Pressable>
))}
</YStack>
</ScrollView>
</>
);
}
2 changes: 2 additions & 0 deletions packages/app/navigation/RootStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { FeatureFlagScreen } from '../features/settings/FeatureFlagScreen';
import { ManageAccountScreen } from '../features/settings/ManageAccountScreen';
import ProfileScreen from '../features/settings/ProfileScreen';
import { PushNotificationSettingsScreen } from '../features/settings/PushNotificationSettingsScreen';
import { ThemeScreen } from '../features/settings/ThemeScreen';
import { UserBugReportScreen } from '../features/settings/UserBugReportScreen';
import { ActivityScreen } from '../features/top/ActivityScreen';
import ChannelScreen from '../features/top/ChannelScreen';
Expand Down Expand Up @@ -94,6 +95,7 @@ export function RootStack() {
options={{ gestureEnabled: false }}
/>
<Root.Screen name="BlockedUsers" component={BlockedUsersScreen} />
<Root.Screen name="Theme" component={ThemeScreen} />
<Root.Screen name="AppInfo" component={AppInfoScreen} />
<Root.Screen name="FeatureFlags" component={FeatureFlagScreen} />
<Root.Screen
Expand Down
1 change: 1 addition & 0 deletions packages/app/navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export type RootStackParamList = {
};
GroupSettings: NavigatorScreenParams<GroupSettingsStackParamList>;
AppSettings: undefined;
Theme: undefined;
FeatureFlags: undefined;
ManageAccount: undefined;
BlockedUsers: undefined;
Expand Down
80 changes: 77 additions & 3 deletions packages/app/provider/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,87 @@
import { themeSettings } from '@tloncorp/shared/db';
import { config } from '@tloncorp/ui';
import { useEffect, useState } from 'react';
import React from 'react';
import { TamaguiProvider, TamaguiProviderProps } from 'tamagui';
import type { ThemeName } from 'tamagui';

import { useIsDarkMode } from '../hooks/useIsDarkMode';

export const ThemeContext = React.createContext<{
setActiveTheme: (theme: ThemeName) => void;
}>({ setActiveTheme: () => {} });

export function Provider({
children,
...rest
}: Omit<TamaguiProviderProps, 'config'>) {
const isDarkMode = useIsDarkMode();
const [activeTheme, setActiveTheme] = useState<ThemeName>(
isDarkMode ? 'dark' : 'light'
);

useEffect(() => {
const loadStoredTheme = async () => {
try {
const storedTheme = await themeSettings.getValue();
if (storedTheme) {
setActiveTheme(storedTheme);
} else {
// If no stored theme, follow system theme
setActiveTheme(isDarkMode ? 'dark' : 'light');
}
} catch (error) {
console.warn('Failed to load theme preference:', error);
}
};

loadStoredTheme();
}, [isDarkMode]);

useEffect(() => {
const handleSystemThemeChange = async () => {
try {
const storedTheme = await themeSettings.getValue();
if (!storedTheme) {
setActiveTheme(isDarkMode ? 'dark' : 'light');
}
} catch (error) {
console.warn('Failed to check theme preference:', error);
}
};

handleSystemThemeChange();
}, [isDarkMode]);

return (
<TamaguiProvider {...rest} config={config}>
{children}
</TamaguiProvider>
<ThemeContext.Provider value={{ setActiveTheme }}>
<TamaguiProvider {...rest} config={config} defaultTheme={activeTheme}>
{children}
</TamaguiProvider>
</ThemeContext.Provider>
);
}

export const setTheme = async (
theme: ThemeName,
setActiveTheme: (theme: ThemeName) => void
) => {
try {
await themeSettings.setValue(theme);
setActiveTheme(theme);
} catch (error) {
console.warn('Failed to save theme preference:', error);
}
};

export const clearTheme = async (
setActiveTheme: (theme: ThemeName) => void,
isDarkMode: boolean
) => {
try {
await themeSettings.resetValue();
setActiveTheme(isDarkMode ? 'dark' : 'light');
} catch (error) {
console.warn('Failed to clear theme preference:', error);
}
};
7 changes: 7 additions & 0 deletions packages/shared/src/db/keyValue.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useQuery } from '@tanstack/react-query';
import { ThemeName } from 'tamagui';

import {
StorageConfiguration,
Expand Down Expand Up @@ -27,6 +28,7 @@ export const IS_TLON_EMPLOYEE_QUERY_KEY = ['settings', 'isTlonEmployee'];
export const APP_INFO_QUERY_KEY = ['settings', 'appInfo'];
export const BASE_VOLUME_SETTING_QUERY_KEY = ['volume', 'base'];
export const SHOW_BENEFITS_SHEET_QUERY_KEY = ['showBenefitsSheet'];
export const THEME_STORAGE_KEY = '@user_theme';

export type ChannelSortPreference = 'recency' | 'arranged';
export async function storeChannelSortPreference(
Expand Down Expand Up @@ -295,3 +297,8 @@ export const finishingSelfHostedLogin = createStorageItem<boolean>({
key: 'finishingSelfHostedLogin',
defaultValue: false,
});

export const themeSettings = createStorageItem<ThemeName | null>({
key: THEME_STORAGE_KEY,
defaultValue: null,
});
14 changes: 6 additions & 8 deletions packages/ui/src/components/BareChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -561,13 +561,10 @@ export default function BareChatInput({
}, [setEditingPost, clearDraft, clearAttachments]);

const theme = useTheme();
// placeholderTextColor is not supported on native, just web
// https://necolas.github.io/react-native-web/docs/text-input/
const placeholderTextColor = isWeb
? {
placeholderTextColor: getVariableValue(theme.secondaryText),
}
: {};

const placeholderTextColor = {
placeholderTextColor: getVariableValue(theme.secondaryText),
};

const adjustTextInputSize = (e: any) => {
if (!isWeb) {
Expand Down Expand Up @@ -634,6 +631,7 @@ export default function BareChatInput({
}}
multiline
placeholder={placeholder}
{...(!isWeb ? placeholderTextColor : {})}
style={{
backgroundColor: 'transparent',
minHeight: initialHeight,
Expand All @@ -646,7 +644,7 @@ export default function BareChatInput({
textAlignVertical: 'center',
letterSpacing: -0.032,
color: getVariableValue(useTheme().primaryText),
...placeholderTextColor,
...(isWeb ? placeholderTextColor : {}),
...(isWeb ? { outlineStyle: 'none' } : {}),
}}
>
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/Form/inputs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ const ControlFrame = styled(View, {
width: '$3xl',
height: '$3xl',
borderWidth: 1,
borderColor: '$shadow',
borderColor: '$border',
alignItems: 'center',
justifyContent: 'center',
variants: {
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/NavBar/NavIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default function NavIcon({
>
<Icon
type={resolvedType}
color={isActive ? '$primaryText' : '$activeBorder'}
color={isActive ? '$primaryText' : '$tertiaryText'}
/>
{shouldShowUnreads ? (
<View justifyContent="center" alignItems="center">
Expand Down
7 changes: 7 additions & 0 deletions packages/ui/src/components/ProfileScreenView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface Props {
onNotificationSettingsPressed: () => void;
onBlockedUsersPressed: () => void;
onManageAccountPressed: () => void;
onThemePressed?: () => void;
onLogoutPressed?: () => void;
onSendBugReportPressed?: () => void;
onExperimentalFeaturesPressed?: () => void;
Expand Down Expand Up @@ -112,6 +113,12 @@ export function ProfileScreenView(props: Props) {
onPress={props.onManageAccountPressed}
/>
)}
<ProfileAction
title="Theme"
leftIcon="ChannelGalleries"
rightIcon={'ChevronRight'}
onPress={props.onThemePressed}
/>
<ProfileAction
title="App info"
leftIcon="Info"
Expand Down
Loading

0 comments on commit 403f5eb

Please sign in to comment.