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