diff --git a/src/assets/icons/checkmark.svg b/src/assets/icons/checkmark.svg new file mode 100644 index 0000000..f65ae54 --- /dev/null +++ b/src/assets/icons/checkmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/App.tsx b/src/components/App.tsx index 0129ec2..2742b01 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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 ( + + {children} + + ); +} + // 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 ( - - + + - - + + ); diff --git a/src/components/Colors.tsx b/src/components/Colors.tsx index 2c58011..837469f 100644 --- a/src/components/Colors.tsx +++ b/src/components/Colors.tsx @@ -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> // 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 ( + + {children} + + ); +} + /** * Retrieves the default styles object in hook form */ @@ -98,13 +116,15 @@ export function DefaultStylesProvider(props: DefaultStylesProviderProps) { } export function ColoredBlurView(props: PropsWithChildren) { - const scheme = useColorScheme(); + const systemScheme = useColorScheme(); + const userScheme = useTypedSelector((state) => state.settings.colorScheme); + const scheme = userScheme === ColorScheme.System ? systemScheme : userScheme; return Platform.OS === 'ios' ? ( = 13 - ? 'material' + ? scheme === 'dark' ? 'materialDark' : 'materialLight' : scheme === 'dark' ? 'extraDark' : 'xlight' } /> ) : ( diff --git a/src/localisation/lang/en/locale.json b/src/localisation/lang/en/locale.json index 5422c5f..c66c065 100644 --- a/src/localisation/lang/en/locale.json +++ b/src/localisation/lang/en/locale.json @@ -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" } \ No newline at end of file diff --git a/src/localisation/types.ts b/src/localisation/types.ts index 517e2b2..1465209 100644 --- a/src/localisation/types.ts +++ b/src/localisation/types.ts @@ -61,4 +61,9 @@ export type LocaleKeys = 'play-next' | 'total-duration' | 'similar-albums' | 'playback-reporting' -| 'playback-reporting-description' \ No newline at end of file +| 'playback-reporting-description' +| 'color-scheme' +| 'color-scheme-description' +| 'color-scheme-system' +| 'color-scheme-light' +| 'color-scheme-dark' \ No newline at end of file diff --git a/src/screens/Settings/components/Container.tsx b/src/screens/Settings/components/Container.tsx new file mode 100644 index 0000000..13c1460 --- /dev/null +++ b/src/screens/Settings/components/Container.tsx @@ -0,0 +1,8 @@ +import { SafeScrollView } from 'components/SafeNavigatorView'; +import styled from 'styled-components'; + +const Container = styled(SafeScrollView)` + padding: 24px; +`; + +export default Container; diff --git a/src/screens/Settings/components/Input.tsx b/src/screens/Settings/components/Input.tsx new file mode 100644 index 0000000..d585d2d --- /dev/null +++ b/src/screens/Settings/components/Input.tsx @@ -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; +`; \ No newline at end of file diff --git a/src/screens/Settings/components/Radio.tsx b/src/screens/Settings/components/Radio.tsx new file mode 100644 index 0000000..bb33364 --- /dev/null +++ b/src/screens/Settings/components/Radio.tsx @@ -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 { + checked?: boolean; + label?: string; + value: T; + onPress: (value: T) => void; + last?: boolean; +} + +export function RadioItem({ + checked, + label, + value, + onPress, + last +}: RadioItemProps) { + const defaultStyles = useDefaultStyles(); + + const handlePress = useCallback(() => { + onPress(value); + }, [onPress, value]); + + return ( + + [ + { backgroundColor: pressed + ? defaultStyles.activeBackground.backgroundColor + : defaultStyles.button.backgroundColor + } + ]} + > + {checked ? : } + + {label} + + + ); +} \ No newline at end of file diff --git a/src/screens/Settings/components/Switch.tsx b/src/screens/Settings/components/Switch.tsx new file mode 100644 index 0000000..a50461a --- /dev/null +++ b/src/screens/Settings/components/Switch.tsx @@ -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; +`; diff --git a/src/screens/Settings/index.tsx b/src/screens/Settings/index.tsx index 55a994c..812c444 100644 --- a/src/screens/Settings/index.tsx +++ b/src/screens/Settings/index.tsx @@ -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(); @@ -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 ( @@ -26,6 +30,7 @@ export function SettingsList() { {t('setting-cache')} {t('error-reporting')} {t('playback-reporting')} + {t('color-scheme')} ); } @@ -47,6 +52,7 @@ export default function Settings() { + ); } \ No newline at end of file diff --git a/src/screens/Settings/components/Cache.tsx b/src/screens/Settings/stacks/Cache.tsx similarity index 88% rename from src/screens/Settings/components/Cache.tsx rename to src/screens/Settings/stacks/Cache.tsx index efce9e7..b5daf24 100644 --- a/src/screens/Settings/components/Cache.tsx +++ b/src/screens/Settings/stacks/Cache.tsx @@ -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(() => { diff --git a/src/screens/Settings/stacks/ColorScheme.tsx b/src/screens/Settings/stacks/ColorScheme.tsx new file mode 100644 index 0000000..c4999d0 --- /dev/null +++ b/src/screens/Settings/stacks/ColorScheme.tsx @@ -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 ( + + {t('color-scheme-description')} + + + + + + + + ); +} \ No newline at end of file diff --git a/src/screens/Settings/components/Library.tsx b/src/screens/Settings/stacks/Library.tsx similarity index 83% rename from src/screens/Settings/components/Library.tsx rename to src/screens/Settings/stacks/Library.tsx index fdd7274..a48378c 100644 --- a/src/screens/Settings/components/Library.tsx +++ b/src/screens/Settings/stacks/Library.tsx @@ -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(); diff --git a/src/screens/Settings/components/PlaybackReporting.tsx b/src/screens/Settings/stacks/PlaybackReporting.tsx similarity index 70% rename from src/screens/Settings/components/PlaybackReporting.tsx rename to src/screens/Settings/stacks/PlaybackReporting.tsx index 7143e2f..cdd04b7 100644 --- a/src/screens/Settings/components/PlaybackReporting.tsx +++ b/src/screens/Settings/stacks/PlaybackReporting.tsx @@ -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() { {t('playback-reporting-description')} - + {t('playback-reporting')} diff --git a/src/screens/Settings/components/Sentry.tsx b/src/screens/Settings/stacks/Sentry.tsx similarity index 100% rename from src/screens/Settings/components/Sentry.tsx rename to src/screens/Settings/stacks/Sentry.tsx diff --git a/src/screens/modals/ErrorReportingPopup.tsx b/src/screens/modals/ErrorReportingPopup.tsx index e4dc99d..d86feb0 100644 --- a/src/screens/modals/ErrorReportingPopup.tsx +++ b/src/screens/modals/ErrorReportingPopup.tsx @@ -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 ( diff --git a/src/store/index.ts b/src/store/index.ts index f49658f..195a775 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -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> = { key: 'root', @@ -44,6 +45,16 @@ const persistConfig: PersistConfig> = { } }; }, + // @ts-expect-error migrations are poorly typed + 4: (state: AppState) => { + return { + ...state, + settings: { + ...state.settings, + colorScheme: ColorScheme.System, + } + }; + }, }) }; diff --git a/src/store/settings/actions.ts b/src/store/settings/actions.ts index 3f217dd..7a5bcad 100644 --- a/src/store/settings/actions.ts +++ b/src/store/settings/actions.ts @@ -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('SET_BITRATE'); export const setOnboardingStatus = createAction('SET_ONBOARDING_STATUS'); export const setReceivedErrorReportingAlert = createAction('SET_RECEIVED_ERROR_REPORTING_ALERT'); -export const setEnablePlaybackReporting = createAction('SET_ENABLE_PLAYBACK_REPORTING'); \ No newline at end of file +export const setEnablePlaybackReporting = createAction('SET_ENABLE_PLAYBACK_REPORTING'); +export const setColorScheme = createAction('SET_COLOR_SCHEME'); diff --git a/src/store/settings/index.ts b/src/store/settings/index.ts index 216efb2..fd7a183 100644 --- a/src/store/settings/index.ts +++ b/src/store/settings/index.ts @@ -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; \ No newline at end of file diff --git a/src/store/settings/types.ts b/src/store/settings/types.ts new file mode 100644 index 0000000..fc27cb7 --- /dev/null +++ b/src/store/settings/types.ts @@ -0,0 +1,5 @@ +export enum ColorScheme { + System = 'system', + Light = 'light', + Dark = 'dark', +} \ No newline at end of file