Skip to content

Commit

Permalink
Option to make theme follow OS setting (#11)
Browse files Browse the repository at this point in the history
* feat: Hook to detect the current OS theme

* fix: Only run media query on the client side

* feat: Updated Settings state and reducer to store the user preferrences for theming

* feat: Updated Settings modal with options to change the theme or make it follow the OS

- Refactored each individual setting into its own component file
- Removed theme icon from header
  • Loading branch information
OliverFlecke committed May 23, 2022
1 parent 86117a0 commit 235de3d
Show file tree
Hide file tree
Showing 9 changed files with 130 additions and 35 deletions.
9 changes: 3 additions & 6 deletions src/features/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { DarkModeToggle, useDarkModeWithClass } from '@oliverflecke/components-react';
import ClientOnly from 'components/ClientOnly';
import SettingsMenu from 'features/Settings/SettingsMenu';
import React, { useEffect, useState } from 'react';
import { User } from 'utils/githubAuth';
import ClientOnly from 'components/ClientOnly';
import { baseUri } from './apiBase';
import LoginState from './login/LoginState';
import Navigation from './Navigation';
import SettingsMenu from 'features/Settings/SettingsMenu';
import { getMyUser } from './user/userApi';

const Header: React.FC = () => {
const { isDarkMode, setDarkMode } = useDarkModeWithClass();
const [user, setUser] = useState<User | null>(null);

const returnUrl =
Expand All @@ -18,7 +16,7 @@ const Header: React.FC = () => {
: 'https://finance.oliverflecke.me';

useEffect(() => {
getMyUser().then((user) => {
getMyUser().then(user => {
if (user) setUser(user);
});
}, []);
Expand All @@ -30,7 +28,6 @@ const Header: React.FC = () => {
<div className="flex flex-row items-center justify-center space-x-4">
<LoginState user={user} authorizeUrl={`${baseUri}/signin?returnUrl=${returnUrl}`} />
<ClientOnly>
<DarkModeToggle darkMode={isDarkMode} onToggle={() => setDarkMode(!isDarkMode)} />
<SettingsMenu />
</ClientOnly>
</div>
Expand Down
26 changes: 26 additions & 0 deletions src/features/Settings/Components/DisplayCurrencySetting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from 'react';
import SelectCurrency from 'components/SelectCurrency';
import { FC, useCallback, useContext } from 'react';
import SettingsContext from '../context';

const DisplayCurrencySetting: FC = () => {
const {
values: { preferredDisplayCurrency },
dispatch,
} = useContext(SettingsContext);

const onChange = useCallback(
(currency: string) => dispatch({ type: 'SET DISPLAY CURRENCY', currency }),
[dispatch]
);

return (
<SelectCurrency
label="Preferred display currency"
defaultCurrency={preferredDisplayCurrency}
onChange={onChange}
/>
);
};

export default DisplayCurrencySetting;
37 changes: 37 additions & 0 deletions src/features/Settings/Components/ThemeSetting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { DarkModeToggle, Toggle } from '@oliverflecke/components-react';
import React, { useContext } from 'react';
import SettingsContext from '../context';

const ThemeSetting = () => {
const { values, dispatch } = useContext(SettingsContext);

return (
<>
<span>Theme follow OS</span>
<div className="flex flex-row justify-end">
<Toggle
checked={values.themeFollowsOS}
onChange={e =>
dispatch({ type: 'SET THEME TO FOLLOW OS', shouldFollowOS: e.target.checked })
}
/>
</div>

{!values.themeFollowsOS && (
<>
<span>Theme</span>
<div className="flex flex-row justify-end">
<DarkModeToggle
onToggle={() =>
dispatch({ type: 'SET THEME', preferresDarkMode: !values.preferresDarkMode })
}
darkMode={values.preferresDarkMode}
/>
</div>
</>
)}
</>
);
};

export default ThemeSetting;
38 changes: 12 additions & 26 deletions src/features/Settings/SettingsMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Button, Modal } from '@oliverflecke/components-react';
import { getCurrencies } from 'features/Currency/api';
import React, { FC, useCallback, useContext, useEffect, useState } from 'react';
import React, { FC, useContext, useEffect, useState } from 'react';
import { IoSettingsOutline } from 'react-icons/io5';
import SelectCurrency from '../../components/SelectCurrency';
import DisplayCurrencySetting from './Components/DisplayCurrencySetting';
import ThemeSetting from './Components/ThemeSetting';
import SettingsContext from './context';

const SettingsMenu: FC = () => {
Expand All @@ -11,21 +12,19 @@ const SettingsMenu: FC = () => {

useEffect(() => {
getCurrencies()
.then((rates) => dispatch({ type: 'SET CURRENCY RATES', rates }))
.then(rates => dispatch({ type: 'SET CURRENCY RATES', rates }))
.catch(() => console.warn('Unable to load currency rates'));
}, [dispatch]);

return (
<div className="z-50">
<button className="flex h-full justify-center" onClick={() => setIsOpen((x) => !x)}>
<button className="flex h-full justify-center" onClick={() => setIsOpen(x => !x)}>
<IoSettingsOutline size={24} />
</button>
<Modal isOpen={isOpen} onDismiss={() => setIsOpen(false)}>
<div className="space-y-4 rounded bg-indigo-500 p-4 dark:bg-indigo-900">
<h2 className="bold col-span-2 text-xl">Settings</h2>
<div className="grid grid-cols-2 gap-x-12">
<DisplayCurrencySetting />
</div>
<SettingsList />

<Button buttonType="Secondary" onClick={() => setIsOpen(false)}>
Close
Expand All @@ -38,22 +37,9 @@ const SettingsMenu: FC = () => {

export default SettingsMenu;

const DisplayCurrencySetting: FC = () => {
const {
values: { preferredDisplayCurrency },
dispatch,
} = useContext(SettingsContext);

const onChange = useCallback(
(currency: string) => dispatch({ type: 'SET DISPLAY CURRENCY', currency }),
[dispatch]
);

return (
<SelectCurrency
label="Preferred display currency"
defaultCurrency={preferredDisplayCurrency}
onChange={onChange}
/>
);
};
const SettingsList: FC = () => (
<div className="grid grid-cols-2 gap-y-4 gap-x-12">
<DisplayCurrencySetting />
<ThemeSetting />
</div>
);
2 changes: 2 additions & 0 deletions src/features/Settings/actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { CurrencyRates } from 'features/Currency/api';

type SettingsAction =
| { type: 'SET THEME TO FOLLOW OS'; shouldFollowOS: boolean }
| { type: 'SET THEME'; preferresDarkMode: boolean }
| { type: 'SET DISPLAY CURRENCY'; currency: string }
| { type: 'SET CURRENCY RATES'; rates: CurrencyRates };

Expand Down
13 changes: 11 additions & 2 deletions src/features/Settings/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import React, { FC, useReducer } from 'react';
import reducer from './reducer';
import { useDarkModeWithClass } from '@oliverflecke/components-react';
import useThemeDetector from 'hooks/useThemeDetector';
import React, { FC, useEffect, useReducer } from 'react';
import SettingsContext from './context';
import reducer from './reducer';
import { initSettings } from './state';

const Settings: FC<{ children: React.ReactNode }> = ({ children }) => {
const [values, dispatch] = useReducer(reducer, initSettings());

const { setDarkMode } = useDarkModeWithClass();
const osTheme = useThemeDetector();

useEffect(() => {
setDarkMode(values.themeFollowsOS ? osTheme : values.preferresDarkMode);
}, [values.preferresDarkMode, values.themeFollowsOS, osTheme, setDarkMode]);

return (
<SettingsContext.Provider value={{ values, dispatch }}>{children}</SettingsContext.Provider>
);
Expand Down
13 changes: 12 additions & 1 deletion src/features/Settings/reducer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { storedReducer } from 'utils/storage';
import SettingsValues from './state';
import SettingsAction from './actions';
import SettingsValues from './state';

export default storedReducer('settings', reducer);

Expand All @@ -18,6 +18,17 @@ function reducer(state: SettingsValues, action: SettingsAction): SettingsValues
preferredDisplayCurrency: action.currency,
};

case 'SET THEME':
return {
...state,
preferresDarkMode: action.preferresDarkMode,
};
case 'SET THEME TO FOLLOW OS':
return {
...state,
themeFollowsOS: action.shouldFollowOS,
};

default:
console.warn(`Action not handled: ${action}`);
return state;
Expand Down
4 changes: 4 additions & 0 deletions src/features/Settings/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { CurrencyRates } from 'features/Currency/api';
export default interface SettingsValues {
preferredDisplayCurrency: string;
currencyRates: CurrencyRates;
themeFollowsOS: boolean;
preferresDarkMode: boolean;
}

export function initSettings(): SettingsValues {
Expand All @@ -14,5 +16,7 @@ export function getDefaultSettings(): SettingsValues {
return {
preferredDisplayCurrency: 'DKK',
currencyRates: { usd: {}, date: new Date().toString() },
themeFollowsOS: true,
preferresDarkMode: false,
};
}
23 changes: 23 additions & 0 deletions src/hooks/useThemeDetector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react';

const useThemeDetector = () => {
const [isDarkTheme, setIsDarkTheme] = useState(getCurrentThemeQuery()?.matches ?? false);
const listener = (e: MediaQueryListEvent) => setIsDarkTheme(e.matches);

useEffect(() => {
const darkThemeMediaQuery = getCurrentThemeQuery();
darkThemeMediaQuery?.addEventListener('change', listener);

return () => darkThemeMediaQuery?.removeEventListener('change', listener);
}, []);

return isDarkTheme;
};

export default useThemeDetector;

function getCurrentThemeQuery(): MediaQueryList | null {
if (typeof window === 'undefined') return null;

return window.matchMedia('(prefers-color-scheme: dark)');
}

0 comments on commit 235de3d

Please sign in to comment.