Add ability to set app theme

Closes #79
This commit is contained in:
Brandon Presley 2022-10-04 14:35:56 +13:00
parent 3164347158
commit eb53d58991
12 changed files with 151 additions and 79 deletions

49
App.tsx
View File

@ -3,7 +3,7 @@ import {
DefaultTheme as NavigationDefaultTheme, DefaultTheme as NavigationDefaultTheme,
NavigationContainer, NavigationContainer,
} from '@react-navigation/native'; } from '@react-navigation/native';
import React, {useState} from 'react'; import React, {useEffect, useMemo, useState} from 'react';
import {useColorScheme} from 'react-native'; import {useColorScheme} from 'react-native';
import { import {
DarkTheme as PaperDarkTheme, DarkTheme as PaperDarkTheme,
@ -12,8 +12,12 @@ import {
} from 'react-native-paper'; } from 'react-native-paper';
import Ionicon from 'react-native-vector-icons/MaterialIcons'; import Ionicon from 'react-native-vector-icons/MaterialIcons';
import {lightColors} from './colors'; import {lightColors} from './colors';
import {runMigrations} from './db';
import MassiveSnack from './MassiveSnack'; import MassiveSnack from './MassiveSnack';
import Routes from './Routes'; import Routes from './Routes';
import Settings from './settings';
import {getSettings} from './settings.service';
import {SettingsContext} from './use-settings';
export const CombinedDefaultTheme = { export const CombinedDefaultTheme = {
...NavigationDefaultTheme, ...NavigationDefaultTheme,
@ -40,21 +44,36 @@ export const CustomTheme = React.createContext({
}); });
const App = () => { const App = () => {
const dark = useColorScheme() === 'dark'; const isDark = useColorScheme() === 'dark';
const [settings, setSettings] = useState<Settings>();
const [color, setColor] = useState( const [color, setColor] = useState(
dark isDark
? CombinedDarkTheme.colors.primary.toUpperCase() ? CombinedDarkTheme.colors.primary.toUpperCase()
: CombinedDefaultTheme.colors.primary.toUpperCase(), : CombinedDefaultTheme.colors.primary.toUpperCase(),
); );
const theme = dark
? { useEffect(() => {
...CombinedDarkTheme, runMigrations().then(async () => {
colors: {...CombinedDarkTheme.colors, primary: color}, const gotSettings = await getSettings();
} setSettings(gotSettings);
: { if (gotSettings.color) setColor(gotSettings.color);
...CombinedDefaultTheme, });
colors: {...CombinedDefaultTheme.colors, primary: color}, }, [setColor]);
};
const theme = useMemo(() => {
const darkTheme = {
...CombinedDarkTheme,
colors: {...CombinedDarkTheme.colors, primary: color},
};
const lightTheme = {
...CombinedDefaultTheme,
colors: {...CombinedDefaultTheme.colors, primary: color},
};
let value = isDark ? darkTheme : lightTheme;
if (settings?.theme === 'dark') value = darkTheme;
else if (settings?.theme === 'light') value = lightTheme;
return value;
}, [color, isDark, settings]);
return ( return (
<CustomTheme.Provider value={{color, setColor}}> <CustomTheme.Provider value={{color, setColor}}>
@ -63,7 +82,11 @@ const App = () => {
settings={{icon: props => <Ionicon {...props} />}}> settings={{icon: props => <Ionicon {...props} />}}>
<NavigationContainer theme={theme}> <NavigationContainer theme={theme}>
<MassiveSnack> <MassiveSnack>
<Routes /> {settings && (
<SettingsContext.Provider value={{settings, setSettings}}>
<Routes />
</SettingsContext.Provider>
)}
</MassiveSnack> </MassiveSnack>
</NavigationContainer> </NavigationContainer>
</Provider> </Provider>

View File

@ -1,15 +1,15 @@
import React from 'react'; import React from 'react';
import {useColorScheme} from 'react-native';
import {TextInput} from 'react-native-paper'; import {TextInput} from 'react-native-paper';
import {CombinedDefaultTheme} from './App'; import {CombinedDefaultTheme} from './App';
import {MARGIN} from './constants'; import {MARGIN} from './constants';
import useDark from './use-dark';
export default function MassiveInput( export default function MassiveInput(
props: Partial<React.ComponentProps<typeof TextInput>> & { props: Partial<React.ComponentProps<typeof TextInput>> & {
innerRef?: React.Ref<any>; innerRef?: React.Ref<any>;
}, },
) { ) {
const dark = useColorScheme() === 'dark'; const dark = useDark();
return ( return (
<TextInput <TextInput

View File

@ -1,7 +1,6 @@
import React, {useContext, useState} from 'react'; import React, {useContext, useState} from 'react';
import {useColorScheme} from 'react-native';
import {Snackbar} from 'react-native-paper'; import {Snackbar} from 'react-native-paper';
import {CombinedDarkTheme, CustomTheme} from './App'; import {CustomTheme} from './App';
export const SnackbarContext = React.createContext<{ export const SnackbarContext = React.createContext<{
toast: (value: string, timeout: number) => void; toast: (value: string, timeout: number) => void;
@ -10,11 +9,10 @@ export const SnackbarContext = React.createContext<{
export default function MassiveSnack({ export default function MassiveSnack({
children, children,
}: { }: {
children: JSX.Element[] | JSX.Element; children?: JSX.Element[] | JSX.Element;
}) { }) {
const [snackbar, setSnackbar] = useState(''); const [snackbar, setSnackbar] = useState('');
const [timeoutId, setTimeoutId] = useState(0); const [timeoutId, setTimeoutId] = useState(0);
const dark = useColorScheme() === 'dark';
const {color} = useContext(CustomTheme); const {color} = useContext(CustomTheme);
const toast = (value: string, timeout: number) => { const toast = (value: string, timeout: number) => {
@ -35,7 +33,7 @@ export default function MassiveSnack({
action={{ action={{
label: 'Close', label: 'Close',
onPress: () => setSnackbar(''), onPress: () => setSnackbar(''),
color: dark ? CombinedDarkTheme.colors.background : color, color,
}}> }}>
{snackbar} {snackbar}
</Snackbar> </Snackbar>

View File

@ -1,36 +1,19 @@
import {createDrawerNavigator} from '@react-navigation/drawer'; import {createDrawerNavigator} from '@react-navigation/drawer';
import React, {useContext, useEffect, useState} from 'react'; import React from 'react';
import {useColorScheme} from 'react-native';
import {IconButton} from 'react-native-paper'; import {IconButton} from 'react-native-paper';
import {CustomTheme} from './App';
import BestPage from './BestPage'; import BestPage from './BestPage';
import {runMigrations} from './db';
import {DrawerParamList} from './drawer-param-list'; import {DrawerParamList} from './drawer-param-list';
import HomePage from './HomePage'; import HomePage from './HomePage';
import PlanPage from './PlanPage'; import PlanPage from './PlanPage';
import Route from './route'; import Route from './route';
import Settings from './settings';
import {getSettings} from './settings.service';
import SettingsPage from './SettingsPage'; import SettingsPage from './SettingsPage';
import {SettingsContext} from './use-settings'; import useDark from './use-dark';
import WorkoutsPage from './WorkoutsPage'; import WorkoutsPage from './WorkoutsPage';
const Drawer = createDrawerNavigator<DrawerParamList>(); const Drawer = createDrawerNavigator<DrawerParamList>();
export default function Routes() { export default function Routes() {
const [settings, setSettings] = useState<Settings>(); const dark = useDark();
const dark = useColorScheme() === 'dark';
const {setColor} = useContext(CustomTheme);
useEffect(() => {
runMigrations().then(async () => {
const gotSettings = await getSettings();
setSettings(gotSettings);
if (gotSettings.color) setColor(gotSettings.color);
});
}, [setColor]);
if (!settings) return null;
const routes: Route[] = [ const routes: Route[] = [
{name: 'Home', component: HomePage, icon: 'home'}, {name: 'Home', component: HomePage, icon: 'home'},
@ -41,23 +24,21 @@ export default function Routes() {
]; ];
return ( return (
<SettingsContext.Provider value={{settings, setSettings}}> <Drawer.Navigator
<Drawer.Navigator screenOptions={{
screenOptions={{ headerTintColor: dark ? 'white' : 'black',
headerTintColor: dark ? 'white' : 'black', swipeEdgeWidth: 1000,
swipeEdgeWidth: 1000, }}>
}}> {routes.map(route => (
{routes.map(route => ( <Drawer.Screen
<Drawer.Screen key={route.name}
key={route.name} name={route.name}
name={route.name} component={route.component}
component={route.component} options={{
options={{ drawerIcon: () => <IconButton icon={route.icon} />,
drawerIcon: () => <IconButton icon={route.icon} />, }}
}} />
/> ))}
))} </Drawer.Navigator>
</Drawer.Navigator>
</SettingsContext.Provider>
); );
} }

View File

@ -30,6 +30,7 @@ export default function SettingsPage() {
const [workouts, setWorkouts] = useState(!!settings.workouts); const [workouts, setWorkouts] = useState(!!settings.workouts);
const [steps, setSteps] = useState(!!settings.steps); const [steps, setSteps] = useState(!!settings.steps);
const [date, setDate] = useState(settings.date || '%Y-%m-%d %H:%M'); const [date, setDate] = useState(settings.date || '%Y-%m-%d %H:%M');
const [theme, setTheme] = useState(settings.theme || 'system');
const [showDate, setShowDate] = useState(!!settings.showDate); const [showDate, setShowDate] = useState(!!settings.showDate);
const {color, setColor} = useContext(CustomTheme); const {color, setColor} = useContext(CustomTheme);
const {toast} = useContext(SnackbarContext); const {toast} = useContext(SnackbarContext);
@ -54,6 +55,7 @@ export default function SettingsPage() {
steps: +steps, steps: +steps,
date, date,
showDate: +showDate, showDate: +showDate,
theme,
}); });
getSettings().then(setSettings); getSettings().then(setSettings);
}, [ }, [
@ -70,6 +72,7 @@ export default function SettingsPage() {
setSettings, setSettings,
date, date,
showDate, showDate,
theme,
]); ]);
const changeAlarmEnabled = useCallback( const changeAlarmEnabled = useCallback(
@ -182,9 +185,36 @@ export default function SettingsPage() {
{input.name} {input.name}
</Switch> </Switch>
))} ))}
{'theme'.includes(search.toLowerCase()) && (
<Picker
style={{color}}
dropdownIconColor={color}
selectedValue={theme}
onValueChange={value => setTheme(value)}>
<Picker.Item value="system" label="Follow system theme" />
<Picker.Item value="dark" label="Dark theme" />
<Picker.Item value="light" label="Light theme" />
</Picker>
)}
{'color'.includes(search.toLowerCase()) && (
<Picker
style={{color, marginTop: -10}}
dropdownIconColor={color}
selectedValue={color}
onValueChange={value => setColor(value)}>
{lightColors.concat(darkColors).map(colorOption => (
<Picker.Item
key={colorOption.hex}
value={colorOption.hex}
label="Primary color"
color={colorOption.hex}
/>
))}
</Picker>
)}
{'new set'.includes(search.toLowerCase()) && ( {'new set'.includes(search.toLowerCase()) && (
<Picker <Picker
style={{color, marginTop: 0}} style={{color, marginTop: -10}}
dropdownIconColor={color} dropdownIconColor={color}
selectedValue={newSet} selectedValue={newSet}
onValueChange={value => setNewSet(value)}> onValueChange={value => setNewSet(value)}>
@ -193,22 +223,6 @@ export default function SettingsPage() {
<Picker.Item value="empty" label="New sets are empty" /> <Picker.Item value="empty" label="New sets are empty" />
</Picker> </Picker>
)} )}
{'theme'.includes(search.toLowerCase()) && (
<Picker
style={{color, marginTop: -10}}
dropdownIconColor={color}
selectedValue={color}
onValueChange={value => setColor(value)}>
{darkColors.concat(lightColors).map(colorOption => (
<Picker.Item
key={colorOption.hex}
value={colorOption.hex}
label={`${colorOption.name} theme`}
color={colorOption.hex}
/>
))}
</Picker>
)}
{'date format'.includes(search.toLowerCase()) && ( {'date format'.includes(search.toLowerCase()) && (
<Picker <Picker
style={{color, marginTop: -10}} style={{color, marginTop: -10}}

View File

@ -1,8 +1,10 @@
import React, {useContext} from 'react'; import React, {useContext, useMemo} from 'react';
import {Pressable} from 'react-native'; import {Pressable} from 'react-native';
import {Switch as PaperSwitch, Text} from 'react-native-paper'; import {Switch as PaperSwitch, Text} from 'react-native-paper';
import {CustomTheme} from './App'; import {CombinedDarkTheme, CombinedDefaultTheme, CustomTheme} from './App';
import {colorShade} from './colors';
import {MARGIN} from './constants'; import {MARGIN} from './constants';
import useDark from './use-dark';
export default function Switch({ export default function Switch({
value, value,
@ -16,6 +18,19 @@ export default function Switch({
children: string; children: string;
}) { }) {
const {color} = useContext(CustomTheme); const {color} = useContext(CustomTheme);
const dark = useDark();
const track = useMemo(() => {
if (dark)
return {
false: CombinedDarkTheme.colors.placeholder,
true: colorShade(color, -40),
};
return {
false: CombinedDefaultTheme.colors.placeholder,
true: colorShade(color, -40),
};
}, [dark, color]);
return ( return (
<Pressable <Pressable
@ -26,6 +41,7 @@ export default function Switch({
alignItems: 'center', alignItems: 'center',
}}> }}>
<PaperSwitch <PaperSwitch
trackColor={track}
color={color} color={color}
style={{marginRight: MARGIN}} style={{marginRight: MARGIN}}
value={value} value={value}

View File

@ -6,7 +6,7 @@ import {
useRoute, useRoute,
} from '@react-navigation/native'; } from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react'; import React, {useCallback, useEffect, useState} from 'react';
import {useColorScheme, View} from 'react-native'; import {View} from 'react-native';
import {FileSystem} from 'react-native-file-access'; import {FileSystem} from 'react-native-file-access';
import {IconButton} from 'react-native-paper'; import {IconButton} from 'react-native-paper';
import Share from 'react-native-share'; import Share from 'react-native-share';
@ -19,11 +19,12 @@ import {Metrics} from './metrics';
import {Periods} from './periods'; import {Periods} from './periods';
import Set from './set'; import Set from './set';
import {formatMonth} from './time'; import {formatMonth} from './time';
import useDark from './use-dark';
import Volume from './volume'; import Volume from './volume';
export default function ViewBest() { export default function ViewBest() {
const {params} = useRoute<RouteProp<BestPageParams, 'ViewBest'>>(); const {params} = useRoute<RouteProp<BestPageParams, 'ViewBest'>>();
const dark = useColorScheme() === 'dark'; const dark = useDark();
const [weights, setWeights] = useState<Set[]>([]); const [weights, setWeights] = useState<Set[]>([]);
const [volumes, setVolumes] = useState<Volume[]>([]); const [volumes, setVolumes] = useState<Volume[]>([]);
const [metric, setMetric] = useState(Metrics.Weight); const [metric, setMetric] = useState(Metrics.Weight);

View File

@ -11,3 +11,26 @@ export const darkColors = [
{hex: '#000000', name: 'Black'}, {hex: '#000000', name: 'Black'},
{hex: '#CD5C5C', name: 'Red'}, {hex: '#CD5C5C', name: 'Red'},
]; ];
export const colorShade = (color: any, amount: number) => {
color = color.replace(/^#/, '');
if (color.length === 3)
color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2];
let [r, g, b] = color.match(/.{2}/g);
[r, g, b] = [
parseInt(r, 16) + amount,
parseInt(g, 16) + amount,
parseInt(b, 16) + amount,
];
r = Math.max(Math.min(255, r), 0).toString(16);
g = Math.max(Math.min(255, g), 0).toString(16);
b = Math.max(Math.min(255, b), 0).toString(16);
const rr = (r.length < 2 ? '0' : '') + r;
const gg = (g.length < 2 ? '0' : '') + g;
const bb = (b.length < 2 ? '0' : '') + b;
return `#${rr}${gg}${bb}`;
};

3
db.ts
View File

@ -112,6 +112,9 @@ const migrations = [
` `
ALTER TABLE settings ADD COLUMN showDate BOOLEAN DEFAULT 0 ALTER TABLE settings ADD COLUMN showDate BOOLEAN DEFAULT 0
`, `,
`
ALTER TABLE settings ADD COLUMN theme TEXT
`,
]; ];
export let db: SQLiteDatabase; export let db: SQLiteDatabase;

View File

@ -26,3 +26,4 @@ git commit --no-verify --message "Set versionCode=$versionCode"
git tag "$versionCode" git tag "$versionCode"
git push origin HEAD & git push --tags git push origin HEAD & git push --tags
cd .. cd ..
./install.sh

View File

@ -12,4 +12,5 @@ export default interface Settings {
steps?: number; steps?: number;
date?: string; date?: string;
showDate: number; showDate: number;
theme?: 'system' | 'dark' | 'light';
} }

11
use-dark.ts Normal file
View File

@ -0,0 +1,11 @@
import {useColorScheme} from 'react-native';
import {useSettings} from './use-settings';
export default function useDark() {
const dark = useColorScheme() === 'dark';
const {settings} = useSettings();
if (settings.theme === 'dark') return true;
if (settings.theme === 'light') return false;
return dark;
}