feat: allow users to override color scheme (closes #138)

This commit is contained in:
Lei Nelissen
2023-04-28 21:01:21 +02:00
parent 24b25d9f4f
commit 130b18bc2e
20 changed files with 249 additions and 66 deletions

View File

@@ -0,0 +1,3 @@
<svg width="17" height="18" viewBox="0 0 17 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.36719 17.2363C6.78711 17.2363 7.11914 17.0508 7.35352 16.6895L16.582 2.1582C16.7578 1.875 16.8262 1.66016 16.8262 1.43555C16.8262 0.898438 16.4746 0.546875 15.9375 0.546875C15.5469 0.546875 15.332 0.673828 15.0977 1.04492L6.32812 15.0195L1.77734 9.0625C1.5332 8.7207 1.28906 8.58398 0.9375 8.58398C0.380859 8.58398 0 8.96484 0 9.50195C0 9.72656 0.0976562 9.98047 0.283203 10.2148L5.35156 16.6699C5.64453 17.0508 5.94727 17.2363 6.36719 17.2363Z"/>
</svg>

After

Width:  |  Height:  |  Size: 563 B

View File

@@ -1,18 +1,18 @@
import React, { useEffect } from 'react';
import React, { PropsWithChildren, useEffect } from 'react';
import { Provider } from 'react-redux';
import TrackPlayer, { Capability } from 'react-native-track-player';
import { PersistGate } from 'redux-persist/integration/react';
import Routes from '../screens';
import store, { persistedStore } from 'store';
import store, { persistedStore, useTypedSelector } from 'store';
import {
NavigationContainer,
DefaultTheme,
DarkTheme as BaseDarkTheme,
} from '@react-navigation/native';
import { useColorScheme } from 'react-native';
import { ColorSchemeContext, themes } from './Colors';
import { ColorSchemeProvider, themes } from './Colors';
import DownloadManager from './DownloadManager';
// import ErrorReportingAlert from 'utility/ErrorReportingAlert';
import { useColorScheme } from 'react-native';
import { ColorScheme } from 'store/settings/types';
const LightTheme = {
...DefaultTheme,
@@ -30,14 +30,29 @@ const DarkTheme = {
}
};
/**
* This is a convenience wrapper for NavigationContainer that ensures that the
* right theme is selected based on OS color scheme settings along with user preferences.
*/
function ThemedNavigationContainer({ children }: PropsWithChildren<{}>) {
const systemScheme = useColorScheme();
const userScheme = useTypedSelector((state) => state.settings.colorScheme);
const scheme = userScheme === ColorScheme.System ? systemScheme : userScheme;
return (
<NavigationContainer
theme={scheme === 'dark' ? DarkTheme : LightTheme}
>
{children}
</NavigationContainer>
);
}
// Track whether the player has already been setup, so that we don't
// accidentally do it twice.
let hasSetupPlayer = false;
export default function App(): JSX.Element {
const colorScheme = useColorScheme();
const theme = themes[colorScheme || 'light'];
useEffect(() => {
async function setupTrackPlayer() {
await TrackPlayer.setupPlayer();
@@ -63,14 +78,12 @@ export default function App(): JSX.Element {
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistedStore}>
<ColorSchemeContext.Provider value={theme}>
<NavigationContainer
theme={colorScheme === 'dark' ? DarkTheme : LightTheme}
>
<ColorSchemeProvider>
<ThemedNavigationContainer>
<Routes />
<DownloadManager />
</NavigationContainer>
</ColorSchemeContext.Provider>
</ThemedNavigationContainer>
</ColorSchemeProvider>
</PersistGate>
</Provider>
);

View File

@@ -3,6 +3,8 @@ import { THEME_COLOR } from 'CONSTANTS';
import React, { PropsWithChildren } from 'react';
import { useContext } from 'react';
import { ColorSchemeName, Platform, StyleSheet, View, useColorScheme } from 'react-native';
import { useTypedSelector } from 'store';
import { ColorScheme } from 'store/settings/types';
const majorPlatformVersion = typeof Platform.Version === 'string' ? parseInt(Platform.Version, 10) : Platform.Version;
@@ -77,6 +79,22 @@ export const themes: Record<'dark' | 'light', ReturnType<typeof generateStyles>>
// Create context for supplying the theming information
export const ColorSchemeContext = React.createContext(themes.dark);
/**
* This provider contains the logic for settings the right theme on the ColorSchemeContext.
*/
export function ColorSchemeProvider({ children }: PropsWithChildren<{}>) {
const systemScheme = useColorScheme();
const userScheme = useTypedSelector((state) => state.settings.colorScheme);
const scheme = userScheme === ColorScheme.System ? systemScheme : userScheme;
const theme = themes[scheme || 'light'];
return (
<ColorSchemeContext.Provider value={theme}>
{children}
</ColorSchemeContext.Provider>
);
}
/**
* Retrieves the default styles object in hook form
*/
@@ -98,13 +116,15 @@ export function DefaultStylesProvider(props: DefaultStylesProviderProps) {
}
export function ColoredBlurView(props: PropsWithChildren<BlurViewProps>) {
const scheme = useColorScheme();
const systemScheme = useColorScheme();
const userScheme = useTypedSelector((state) => state.settings.colorScheme);
const scheme = userScheme === ColorScheme.System ? systemScheme : userScheme;
return Platform.OS === 'ios' ? (
<BlurView
{...props}
blurType={Platform.OS === 'ios' && majorPlatformVersion >= 13
? 'material'
? scheme === 'dark' ? 'materialDark' : 'materialLight'
: scheme === 'dark' ? 'extraDark' : 'xlight'
} />
) : (

View File

@@ -63,5 +63,10 @@
"total-duration": "Total duration",
"similar-albums": "Similar albums",
"playback-reporting": "Playback Reporting",
"playback-reporting-description": "With Playback Reporting, all your playback events are relayed back to Jellyfin. This allows you to track your most listened songs, particularly with Jellyfin plugins such as ListenBrainz."
"playback-reporting-description": "With Playback Reporting, all your playback events are relayed back to Jellyfin. This allows you to track your most listened songs, particularly with Jellyfin plugins such as ListenBrainz.",
"color-scheme": "Color Scheme",
"color-scheme-description": "By default, Fintunes will follow your operating system's color scheme. You can however choose to override this to make sure Fintunes is always in dark mode or light mode.",
"color-scheme-system": "System",
"color-scheme-light": "Light Mode",
"color-scheme-dark": "Dark Mode"
}

View File

@@ -61,4 +61,9 @@ export type LocaleKeys = 'play-next'
| 'total-duration'
| 'similar-albums'
| 'playback-reporting'
| 'playback-reporting-description'
| 'playback-reporting-description'
| 'color-scheme'
| 'color-scheme-description'
| 'color-scheme-system'
| 'color-scheme-light'
| 'color-scheme-dark'

View File

@@ -0,0 +1,8 @@
import { SafeScrollView } from 'components/SafeNavigatorView';
import styled from 'styled-components';
const Container = styled(SafeScrollView)`
padding: 24px;
`;
export default Container;

View File

@@ -0,0 +1,11 @@
import styled from 'styled-components/native';
export const InputContainer = styled.View`
margin: 10px 0;
`;
export const Input = styled.TextInput`
padding: 15px;
margin-top: 5px;
border-radius: 5px;
`;

View File

@@ -0,0 +1,61 @@
import React, { useCallback } from 'react';
import styled from 'styled-components/native';
import CheckmarkIcon from 'assets/icons/checkmark.svg';
import { Text } from 'components/Typography';
import useDefaultStyles from 'components/Colors';
import { THEME_COLOR } from 'CONSTANTS';
import { Gap } from 'components/Utility';
import { View } from 'react-native';
export const RadioList = styled.View`
border-radius: 8px;
overflow: hidden;
`;
const RadioItemContainer = styled.Pressable<{ checked?: boolean }>`
padding: 16px 24px 16px 16px;
border-bottom: 1px solid #444;
display: flex;
flex-direction: row;
align-items: center;
`;
export interface RadioItemProps<T> {
checked?: boolean;
label?: string;
value: T;
onPress: (value: T) => void;
last?: boolean;
}
export function RadioItem<T>({
checked,
label,
value,
onPress,
last
}: RadioItemProps<T>) {
const defaultStyles = useDefaultStyles();
const handlePress = useCallback(() => {
onPress(value);
}, [onPress, value]);
return (
<View style={!last ? { borderBottomWidth: 1, borderBottomColor: defaultStyles.divider.backgroundColor } : undefined}>
<RadioItemContainer
onPress={handlePress}
style={({ pressed }) => [
{ backgroundColor: pressed
? defaultStyles.activeBackground.backgroundColor
: defaultStyles.button.backgroundColor
}
]}
>
{checked ? <CheckmarkIcon fill={THEME_COLOR} height={14} width={14} /> : <Gap size={14} />}
<Gap size={8} />
<Text style={checked && { color: THEME_COLOR }}>{label}</Text>
</RadioItemContainer>
</View>
);
}

View File

@@ -0,0 +1,13 @@
import { Text } from 'components/Typography';
import styled from 'styled-components/native';
export const SwitchContainer = styled.View`
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 16px 0;
`;
export const SwitchLabel = styled(Text)`
font-size: 16px;
`;

View File

@@ -1,17 +1,20 @@
import React, { useCallback } from 'react';
import { StyleSheet } from 'react-native';
import Library from './components/Library';
import Cache from './components/Cache';
import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
import { t } from '@localisation';
import { createStackNavigator } from '@react-navigation/stack';
import { useNavigation } from '@react-navigation/native';
import ListButton from 'components/ListButton';
import { THEME_COLOR } from 'CONSTANTS';
import Sentry from './components/Sentry';
import ListButton from 'components/ListButton';
import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
import { SettingsNavigationProp } from './types';
import Cache from './stacks/Cache';
import Sentry from './stacks/Sentry';
import Library from './stacks/Library';
import ColorScheme from './stacks/ColorScheme';
import PlaybackReporting from './stacks/PlaybackReporting';
import { SafeScrollView } from 'components/SafeNavigatorView';
import PlaybackReporting from './components/PlaybackReporting';
export function SettingsList() {
const navigation = useNavigation<SettingsNavigationProp>();
@@ -19,6 +22,7 @@ export function SettingsList() {
const handleCacheClick = useCallback(() => { navigation.navigate('Cache'); }, [navigation]);
const handleSentryClick = useCallback(() => { navigation.navigate('Sentry'); }, [navigation]);
const handlePlaybackReportingClick = useCallback(() => { navigation.navigate('Playback Reporting'); }, [navigation]);
const handleColorSchemeClick = useCallback(() => { navigation.navigate('Color Scheme'); }, [navigation]);
return (
<SafeScrollView>
@@ -26,6 +30,7 @@ export function SettingsList() {
<ListButton onPress={handleCacheClick}>{t('setting-cache')}</ListButton>
<ListButton onPress={handleSentryClick}>{t('error-reporting')}</ListButton>
<ListButton onPress={handlePlaybackReportingClick}>{t('playback-reporting')}</ListButton>
<ListButton onPress={handleColorSchemeClick}>{t('color-scheme')}</ListButton>
</SafeScrollView>
);
}
@@ -47,6 +52,7 @@ export default function Settings() {
<Stack.Screen name="Cache" component={Cache} options={{ headerTitle: t('setting-cache') }} />
<Stack.Screen name="Sentry" component={Sentry} options={{ headerTitle: t('error-reporting') }} />
<Stack.Screen name="Playback Reporting" component={PlaybackReporting} options={{ headerTitle: t('playback-reporting')}} />
<Stack.Screen name="Color Scheme" component={ColorScheme} options={{ headerTitle: t('color-scheme')}} />
</Stack.Navigator>
);
}

View File

@@ -6,16 +6,12 @@ import Button from 'components/Button';
import styled from 'styled-components/native';
import { Paragraph } from 'components/Typography';
import { useAppDispatch } from 'store';
import { SafeScrollView } from 'components/SafeNavigatorView';
import Container from '../components/Container';
const ClearCache = styled(Button)`
margin-top: 16px;
`;
const Container = styled(SafeScrollView)`
padding: 24px;
`;
export default function CacheSettings() {
const dispatch = useAppDispatch();
const handleClearCache = useCallback(() => {

View File

@@ -0,0 +1,45 @@
import React, { useCallback } from 'react';
import { Paragraph } from 'components/Typography';
import Container from '../components/Container';
import { t } from '@localisation';
import { RadioItem, RadioList } from '../components/Radio';
import { ColorScheme } from 'store/settings/types';
import { useAppDispatch, useTypedSelector } from 'store';
import { setColorScheme } from 'store/settings/actions';
export default function ColorSchemeSetting() {
const dispatch = useAppDispatch();
const scheme = useTypedSelector((state) => state.settings.colorScheme);
const handlePress = useCallback((value: ColorScheme) => {
dispatch(setColorScheme(value));
}, [dispatch]);
return (
<Container>
<Paragraph>{t('color-scheme-description')}</Paragraph>
<Paragraph />
<RadioList>
<RadioItem
label={t('color-scheme-system')}
value={ColorScheme.System}
onPress={handlePress}
checked={scheme === ColorScheme.System}
/>
<RadioItem
label={t('color-scheme-light')}
value={ColorScheme.Light}
onPress={handlePress}
checked={scheme === ColorScheme.Light}
/>
<RadioItem
label={t('color-scheme-dark')}
value={ColorScheme.Dark}
onPress={handlePress}
checked={scheme === ColorScheme.Dark}
last
/>
</RadioList>
</Container>
);
}

View File

@@ -1,4 +1,3 @@
import styled from 'styled-components/native';
import { useNavigation } from '@react-navigation/native';
import React, { useCallback } from 'react';
import useDefaultStyles from 'components/Colors';
@@ -7,21 +6,8 @@ import { useTypedSelector } from 'store';
import { t } from '@localisation';
import Button from 'components/Button';
import { Paragraph } from 'components/Typography';
import { SafeScrollView } from 'components/SafeNavigatorView';
const InputContainer = styled.View`
margin: 10px 0;
`;
const Input = styled.TextInput`
padding: 15px;
margin-top: 5px;
border-radius: 5px;
`;
const Container = styled(SafeScrollView)`
padding: 24px;
`;
import Container from '../components/Container';
import { InputContainer, Input } from '../components/Input';
export default function LibrarySettings() {
const defaultStyles = useDefaultStyles();

View File

@@ -1,26 +1,12 @@
import { Paragraph, Text } from 'components/Typography';
import { Paragraph } from 'components/Typography';
import React, { useCallback } from 'react';
import { Switch } from 'react-native-gesture-handler';
import styled from 'styled-components/native';
import { t } from '@localisation';
import { SafeScrollView } from 'components/SafeNavigatorView';
import { useAppDispatch, useTypedSelector } from 'store';
import { setEnablePlaybackReporting } from 'store/settings/actions';
const Container = styled.View`
padding: 24px;
`;
const SwitchContainer = styled.View`
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 16px 0;
`;
const Label = styled(Text)`
font-size: 16px;
`;
import Container from '../components/Container';
import { SwitchContainer, SwitchLabel } from '../components/Switch';
export default function PlaybackReporting() {
const isEnabled = useTypedSelector((state) => state.settings.enablePlaybackReporting);
@@ -35,7 +21,7 @@ export default function PlaybackReporting() {
<Container>
<Paragraph>{t('playback-reporting-description')}</Paragraph>
<SwitchContainer>
<Label>{t('playback-reporting')}</Label>
<SwitchLabel>{t('playback-reporting')}</SwitchLabel>
<Switch value={isEnabled} onValueChange={toggleSwitch} />
</SwitchContainer>
</Container>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import Modal from 'components/Modal';
import Sentry from 'screens/Settings/components/Sentry';
import Sentry from 'screens/Settings/stacks/Sentry';
export default function ErrorReportingPopup() {
return (

View File

@@ -8,6 +8,7 @@ import settings from './settings';
import music, { initialState as musicInitialState } from './music';
import downloads, { initialState as downloadsInitialState } from './downloads';
import { PersistState } from 'redux-persist/es/types';
import { ColorScheme } from './settings/types';
const persistConfig: PersistConfig<Omit<AppState, '_persist'>> = {
key: 'root',
@@ -44,6 +45,16 @@ const persistConfig: PersistConfig<Omit<AppState, '_persist'>> = {
}
};
},
// @ts-expect-error migrations are poorly typed
4: (state: AppState) => {
return {
...state,
settings: {
...state.settings,
colorScheme: ColorScheme.System,
}
};
},
})
};

View File

@@ -1,7 +1,9 @@
import { createAction } from '@reduxjs/toolkit';
import { ColorScheme } from './types';
export const setJellyfinCredentials = createAction<{ access_token: string, user_id: string, uri: string, device_id: string; }>('SET_JELLYFIN_CREDENTIALS');
export const setBitrate = createAction<number>('SET_BITRATE');
export const setOnboardingStatus = createAction<boolean>('SET_ONBOARDING_STATUS');
export const setReceivedErrorReportingAlert = createAction<void>('SET_RECEIVED_ERROR_REPORTING_ALERT');
export const setEnablePlaybackReporting = createAction<boolean>('SET_ENABLE_PLAYBACK_REPORTING');
export const setEnablePlaybackReporting = createAction<boolean>('SET_ENABLE_PLAYBACK_REPORTING');
export const setColorScheme = createAction<ColorScheme>('SET_COLOR_SCHEME');

View File

@@ -1,5 +1,6 @@
import { createReducer } from '@reduxjs/toolkit';
import { setReceivedErrorReportingAlert, setBitrate, setJellyfinCredentials, setOnboardingStatus, setEnablePlaybackReporting } from './actions';
import { setReceivedErrorReportingAlert, setBitrate, setJellyfinCredentials, setOnboardingStatus, setEnablePlaybackReporting, setColorScheme } from './actions';
import { ColorScheme } from './types';
interface State {
jellyfin?: {
@@ -12,6 +13,7 @@ interface State {
isOnboardingComplete: boolean;
hasReceivedErrorReportingAlert: boolean;
enablePlaybackReporting: boolean;
colorScheme: ColorScheme;
}
const initialState: State = {
@@ -19,6 +21,7 @@ const initialState: State = {
isOnboardingComplete: false,
hasReceivedErrorReportingAlert: false,
enablePlaybackReporting: true,
colorScheme: ColorScheme.System,
};
const settings = createReducer(initialState, builder => {
@@ -42,6 +45,10 @@ const settings = createReducer(initialState, builder => {
...state,
enablePlaybackReporting: action.payload,
}));
builder.addCase(setColorScheme, (state, action) => ({
...state,
colorScheme: action.payload,
}));
});
export default settings;

View File

@@ -0,0 +1,5 @@
export enum ColorScheme {
System = 'system',
Light = 'light',
Dark = 'dark',
}