Theme selection screen (#2320)

* Theme selection screen (initial commit)

PR Review changes

Add custom theme option for users who are already using a custom theme

Fix for limited allowed themes

Selected item case fix

Memoized available themes request, other optimizations

updated snapshots

* Custom theme option changes - title. spacing, save option on state for life-cycle of screen
This commit is contained in:
Chris Duarte
2018-11-23 09:49:28 -08:00
committed by Elias Nahum
parent bc387ed74e
commit 7c711caf2d
12 changed files with 575 additions and 1 deletions

View File

@@ -62,6 +62,7 @@ export function registerScreens(store, Provider) {
Navigation.registerComponent('TableImage', () => wrapWithContextProvider(require('app/screens/table_image').default), store, Provider);
Navigation.registerComponent('TermsOfService', () => wrapWithContextProvider(require('app/screens/terms_of_service').default), store, Provider);
Navigation.registerComponent('TextPreview', () => wrapWithContextProvider(require('app/screens/text_preview').default), store, Provider);
Navigation.registerComponent('ThemeSettings', () => wrapWithContextProvider(require('app/screens/theme').default), store, Provider);
Navigation.registerComponent('Thread', () => wrapWithContextProvider(require('app/screens/thread').default), store, Provider);
Navigation.registerComponent('TimezoneSettings', () => wrapWithContextProvider(require('app/screens/timezone').default), store, Provider);
Navigation.registerComponent('ErrorTeamsList', () => wrapWithContextProvider(require('app/screens/error_teams_list').default), store, Provider);

View File

@@ -0,0 +1,78 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DisplaySettings should match snapshot 1`] = `
<View
style={
Object {
"backgroundColor": "#ffffff",
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"backgroundColor": "rgba(61,60,64,0.06)",
"flex": 1,
"paddingTop": 35,
}
}
>
<View
style={
Object {
"backgroundColor": "rgba(61,60,64,0.1)",
"height": 1,
}
}
/>
<SettingsItem
defaultMessage="Clock Display"
i18nId="mobile.advanced_settings.clockDisplay"
iconName="ios-time"
iconType="ion"
isDestructor={false}
onPress={[Function]}
separator={false}
showArrow={false}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
<View
style={
Object {
"backgroundColor": "rgba(61,60,64,0.1)",
"height": 1,
}
}
/>
</View>
</View>
`;

View File

@@ -20,6 +20,7 @@ export default class DisplaySettings extends PureComponent {
static propTypes = {
navigator: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
enableTheme: PropTypes.bool.isRequired,
enableTimezone: PropTypes.bool.isRequired,
};
@@ -72,12 +73,30 @@ export default class DisplaySettings extends PureComponent {
});
});
goToThemeSettings = preventDoubleTap(() => {
const {navigator, theme} = this.props;
const {intl} = this.context;
navigator.push({
screen: 'ThemeSettings',
title: intl.formatMessage({id: 'mobile.display_settings.theme', defaultMessage: 'Theme'}),
animated: true,
backButtonTitle: '',
navigatorStyle: {
navBarTextColor: theme.sidebarHeaderTextColor,
navBarBackgroundColor: theme.sidebarHeaderBg,
navBarButtonColor: theme.sidebarHeaderTextColor,
screenBackgroundColor: theme.centerChannelBg,
},
});
});
closeClockDisplaySettings = () => {
this.setState({showClockDisplaySettings: false});
};
render() {
const {theme, enableTimezone} = this.props;
const {theme, enableTimezone, enableTheme} = this.props;
const {showClockDisplaySettings} = this.state;
const style = getStyleSheet(theme);
@@ -114,6 +133,18 @@ export default class DisplaySettings extends PureComponent {
<StatusBar/>
<View style={style.wrapper}>
<View style={style.divider}/>
{enableTheme && (
<SettingsItem
defaultMessage='Theme'
i18nId='mobile.display_settings.theme'
iconName='ios-color-palette'
iconType='ion'
onPress={this.goToThemeSettings}
separator={true}
showArrow={false}
theme={theme}
/>
)}
<SettingsItem
defaultMessage='Clock Display'
i18nId='mobile.advanced_settings.clockDisplay'

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import SettingsItem from 'app/screens/settings/settings_item';
import Preferences from 'mattermost-redux/constants/preferences';
import DisplaySettings from './display_settings';
jest.mock('react-intl');
describe('DisplaySettings', () => {
const baseProps = {
theme: Preferences.THEMES.default,
enableTheme: false,
enableTimezone: false,
navigator: {push: () => {}}, // eslint-disable-line no-empty-function
};
test('should match snapshot', () => {
const wrapper = shallow(
<DisplaySettings {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find(SettingsItem).length).toBe(1);
wrapper.setProps({enableTheme: true});
expect(wrapper.find(SettingsItem).length).toBe(2);
wrapper.setProps({enableTimezone: true});
expect(wrapper.find(SettingsItem).length).toBe(3);
});
});

View File

@@ -6,13 +6,17 @@ import {connect} from 'react-redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {isTimezoneEnabled} from 'app/utils/timezone';
import {isThemeSwitchingEnabled} from 'app/utils/theme';
import DisplaySettings from './display_settings';
import {getAllowedThemes} from 'app/selectors/theme';
function mapStateToProps(state) {
const enableTimezone = isTimezoneEnabled(state);
const enableTheme = isThemeSwitchingEnabled(state) && getAllowedThemes(state).length > 1;
return {
enableTheme,
enableTimezone,
theme: getTheme(state),
};

View File

@@ -0,0 +1,69 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Theme should match snapshot 1`] = `
<View
style={
Object {
"backgroundColor": "#ffffff",
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"backgroundColor": "rgba(61,60,64,0.06)",
"flex": 1,
"paddingTop": 35,
}
}
>
<section
disableHeader={true}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
>
<FlatList
disableVirtualization={false}
horizontal={false}
initialNumToRender={10}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
numColumns={1}
onEndReachedThreshold={2}
renderItem={[Function]}
scrollEventThrottle={50}
updateCellsBatchingPeriod={50}
windowSize={21}
/>
</section>
</View>
</View>
`;

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getAllowedThemes, getCustomTheme} from 'app/selectors/theme';
import Theme from './theme';
const mapStateToProps = (state) => ({
userId: getCurrentUserId(state),
teamId: getCurrentTeamId(state),
theme: getTheme(state),
allowedThemes: getAllowedThemes(state),
customTheme: getCustomTheme(state),
});
const mapDispatchToProps = (dispatch) => ({
actions: bindActionCreators({
savePreferences,
}, dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(Theme);

134
app/screens/theme/theme.js Normal file
View File

@@ -0,0 +1,134 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View, FlatList} from 'react-native';
import PropTypes from 'prop-types';
import {intlShape} from 'react-intl';
import StatusBar from 'app/components/status_bar';
import Section from 'app/screens/settings/section';
import SectionItem from 'app/screens/settings/section_item';
import FormattedText from 'app/components/formatted_text';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import Preferences from 'mattermost-redux/constants/preferences';
export default class Theme extends React.PureComponent {
static propTypes = {
teamId: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
userId: PropTypes.string.isRequired,
actions: PropTypes.shape({
savePreferences: PropTypes.func.isRequired,
}).isRequired,
allowedThemes: PropTypes.arrayOf(PropTypes.object),
customTheme: PropTypes.object,
};
static contextTypes = {
intl: intlShape.isRequired,
};
state = {
customTheme: null,
};
static getDerivedStateFromProps(props, state) {
if (!state.customTheme && props.customTheme) {
return {
customTheme: props.customTheme,
};
}
return null;
}
setTheme = (key) => {
const {userId, teamId, actions: {savePreferences}, allowedThemes} = this.props;
const {customTheme} = this.state;
const selectedTheme = allowedThemes.concat(customTheme).find((theme) => theme.key === key);
savePreferences(userId, [{
user_id: userId,
category: Preferences.CATEGORY_THEME,
name: teamId,
value: JSON.stringify(selectedTheme),
}]);
}
renderThemeRow = ({item, title}) => {
const {theme} = this.props;
const style = getStyleSheet(theme);
return (
<React.Fragment>
<SectionItem
label={(
<FormattedText
id={`user.settings.display.${item.type}`}
defaultMessage={title || item.type}
/>
)}
action={this.setTheme}
actionType='select'
actionValue={item.key}
selected={item.type.toLowerCase() === theme.type.toLowerCase()}
theme={theme}
/>
<View style={style.divider}/>
</React.Fragment>
);
};
keyExtractor = (item) => item.key;
render() {
const {theme, allowedThemes} = this.props;
const {customTheme} = this.state;
const style = getStyleSheet(theme);
return (
<View style={style.container}>
<StatusBar/>
<View style={style.wrapper}>
<Section
disableHeader={true}
theme={theme}
>
<FlatList
data={allowedThemes}
renderItem={this.renderThemeRow}
keyExtractor={this.keyExtractor}
/>
</Section>
{customTheme &&
<Section
disableHeader={true}
theme={theme}
>
{this.renderThemeRow({item: customTheme, title: 'Custom'})}
</Section>
}
</View>
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
backgroundColor: theme.centerChannelBg,
},
wrapper: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.06),
flex: 1,
paddingTop: 35,
},
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
height: 1,
},
};
});

View File

@@ -0,0 +1,150 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FlatList} from 'react-native';
import {shallow} from 'enzyme';
import Section from 'app/screens/settings/section';
import Preferences from 'mattermost-redux/constants/preferences';
import Theme from './theme';
jest.mock('react-intl');
describe('Theme', () => {
const baseProps = {
teamId: 'test-team',
theme: Preferences.THEMES.default,
userId: 'test-user',
actions: {
savePreferences: jest.fn(),
},
allowedThemes,
};
test('should match snapshot', () => {
const wrapper = shallow(
<Theme {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find(Section).first().exists()).toEqual(true);
expect(wrapper.find(FlatList).first().exists()).toEqual(true);
});
});
const allowedThemes = [
{
default: {
type: 'Mattermost',
sidebarBg: '#145dbf',
sidebarText: '#ffffff',
sidebarUnreadText: '#ffffff',
sidebarTextHoverBg: '#4578bf',
sidebarTextActiveBorder: '#579eff',
sidebarTextActiveColor: '#ffffff',
sidebarHeaderBg: '#1153ab',
sidebarHeaderTextColor: '#ffffff',
onlineIndicator: '#06d6a0',
awayIndicator: '#ffbc42',
dndIndicator: '#f74343',
mentionBg: '#ffffff',
mentionColor: '#145dbf',
centerChannelBg: '#ffffff',
centerChannelColor: '#3d3c40',
newMessageSeparator: '#ff8800',
linkColor: '#2389d7',
buttonBg: '#166de0',
buttonColor: '#ffffff',
errorTextColor: '#fd5960',
mentionHighlightBg: '#ffe577',
mentionHighlightLink: '#166de0',
codeTheme: 'github',
},
},
{
organization: {
type: 'Organization',
sidebarBg: '#2071a7',
sidebarText: '#ffffff',
sidebarUnreadText: '#ffffff',
sidebarTextHoverBg: '#136197',
sidebarTextActiveBorder: '#7ab0d6',
sidebarTextActiveColor: '#ffffff',
sidebarHeaderBg: '#2f81b7',
sidebarHeaderTextColor: '#ffffff',
onlineIndicator: '#7dbe00',
awayIndicator: '#dcbd4e',
dndIndicator: '#ff6a6a',
mentionBg: '#fbfbfb',
mentionColor: '#2071f7',
centerChannelBg: '#f2f4f8',
centerChannelColor: '#333333',
newMessageSeparator: '#ff8800',
linkColor: '#2f81b7',
buttonBg: '#1dacfc',
buttonColor: '#ffffff',
errorTextColor: '#a94442',
mentionHighlightBg: '#f3e197',
mentionHighlightLink: '#2f81b7',
codeTheme: 'github',
},
},
{
mattermostDark: {
type: 'Mattermost Dark',
sidebarBg: '#1b2c3e',
sidebarText: '#ffffff',
sidebarUnreadText: '#ffffff',
sidebarTextHoverBg: '#4a5664',
sidebarTextActiveBorder: '#66b9a7',
sidebarTextActiveColor: '#ffffff',
sidebarHeaderBg: '#1b2c3e',
sidebarHeaderTextColor: '#ffffff',
onlineIndicator: '#65dcc8',
awayIndicator: '#c1b966',
dndIndicator: '#e81023',
mentionBg: '#b74a4a',
mentionColor: '#ffffff',
centerChannelBg: '#2f3e4e',
centerChannelColor: '#dddddd',
newMessageSeparator: '#5de5da',
linkColor: '#a4ffeb',
buttonBg: '#4cbba4',
buttonColor: '#ffffff',
errorTextColor: '#ff6461',
mentionHighlightBg: '#984063',
mentionHighlightLink: '#a4ffeb',
codeTheme: 'solarized-dark',
},
},
{
windows10: {
type: 'Windows Dark',
sidebarBg: '#171717',
sidebarText: '#ffffff',
sidebarUnreadText: '#ffffff',
sidebarTextHoverBg: '#302e30',
sidebarTextActiveBorder: '#196caf',
sidebarTextActiveColor: '#ffffff',
sidebarHeaderBg: '#1f1f1f',
sidebarHeaderTextColor: '#ffffff',
onlineIndicator: '#399fff',
awayIndicator: '#c1b966',
dndIndicator: '#e81023',
mentionBg: '#0177e7',
mentionColor: '#ffffff',
centerChannelBg: '#1f1f1f',
centerChannelColor: '#dddddd',
newMessageSeparator: '#cc992d',
linkColor: '#0d93ff',
buttonBg: '#0177e7',
buttonColor: '#ffffff',
errorTextColor: '#ff6461',
mentionHighlightBg: '#784098',
mentionHighlightLink: '#a4ffeb',
codeTheme: 'monokai',
},
},
];

39
app/selectors/theme.js Normal file
View File

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import Preferences from 'mattermost-redux/constants/preferences';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
export const getAllowedThemes = createSelector(
getConfig,
getTheme,
(config) => {
const allThemes = Object.keys(Preferences.THEMES).map((key) => ({
...Preferences.THEMES[key],
key,
}));
let acceptableThemes = allThemes;
const allowedThemeKeys = (config.AllowedThemes || '').split(',').filter(String);
if (allowedThemeKeys.length) {
acceptableThemes = allThemes.filter((theme) => allowedThemeKeys.includes(theme.key));
}
return acceptableThemes;
}
);
export const getCustomTheme = createSelector(
getConfig,
getTheme,
(config, activeTheme) => {
if (config.AllowCustomThemes === 'true' && activeTheme.type === 'custom') {
return {
...activeTheme,
key: 'custom',
};
}
return null;
}
);

View File

@@ -27,3 +27,8 @@ export function setNavigatorStyles(navigator, theme) {
screenBackgroundColor: theme.centerChannelBg,
});
}
export function isThemeSwitchingEnabled(state) {
const {config} = state.entities.general;
return config.EnableThemeSelection === 'true';
}

View File

@@ -202,6 +202,8 @@
"mobile.create_channel.public": "New Public Channel",
"mobile.create_post.read_only": "This channel is read-only",
"mobile.custom_list.no_results": "No Results",
"mobile.display_settings.theme": "Theme",
"mobile.display_settings.custom_theme": "Custom Theme",
"mobile.document_preview.failed_description": "An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n",
"mobile.document_preview.failed_title": "Open Document failed",
"mobile.downloader.android_complete": "Download complete",