API & Client - add a user preference for dark mode

This commit is contained in:
Sam 2023-12-16 21:18:09 +01:00
parent 3653239022
commit 3be787de7f
17 changed files with 161 additions and 13 deletions

View File

@ -0,0 +1,34 @@
"""add dark theme preferences
Revision ID: 14f48e46f320
Revises: 24eb097614e4
Create Date: 2023-12-16 18:35:31.377007
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '14f48e46f320'
down_revision = '24eb097614e4'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.add_column(
sa.Column('use_dark_mode', sa.Boolean(), nullable=True)
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_column('use_dark_mode')
# ### end Alembic commands ###

View File

@ -1459,6 +1459,7 @@ class TestUserPreferencesUpdate(ApiTestCaseMixin):
imperial_units=True, imperial_units=True,
display_ascent=False, display_ascent=False,
start_elevation_at_zero=False, start_elevation_at_zero=False,
use_dark_mode=True,
use_raw_gpx_speed=True, use_raw_gpx_speed=True,
date_format='yyyy-MM-dd', date_format='yyyy-MM-dd',
) )
@ -1478,6 +1479,7 @@ class TestUserPreferencesUpdate(ApiTestCaseMixin):
assert data['data']['timezone'] == 'America/New_York' assert data['data']['timezone'] == 'America/New_York'
assert data['data']['date_format'] == 'yyyy-MM-dd' assert data['data']['date_format'] == 'yyyy-MM-dd'
assert data['data']['weekm'] is True assert data['data']['weekm'] is True
assert data['data']['use_dark_mode'] is True
@pytest.mark.parametrize( @pytest.mark.parametrize(
'client_scope, can_access', 'client_scope, can_access',

View File

@ -77,6 +77,12 @@ class TestUserSerializeAsAuthUser(UserModelAssertMixin):
assert serialized_user['timezone'] == user_1.timezone assert serialized_user['timezone'] == user_1.timezone
assert serialized_user['weekm'] == user_1.weekm assert serialized_user['weekm'] == user_1.weekm
assert serialized_user['display_ascent'] == user_1.display_ascent assert serialized_user['display_ascent'] == user_1.display_ascent
assert (
serialized_user['start_elevation_at_zero']
== user_1.start_elevation_at_zero
)
assert serialized_user['use_raw_gpx_speed'] == user_1.use_raw_gpx_speed
assert serialized_user['use_dark_mode'] == user_1.use_dark_mode
def test_it_returns_workouts_infos(self, app: Flask, user_1: User) -> None: def test_it_returns_workouts_infos(self, app: Flask, user_1: User) -> None:
serialized_user = user_1.serialize(user_1) serialized_user = user_1.serialize(user_1)
@ -155,6 +161,9 @@ class TestUserSerializeAsAdmin(UserModelAssertMixin):
assert 'language' not in serialized_user assert 'language' not in serialized_user
assert 'timezone' not in serialized_user assert 'timezone' not in serialized_user
assert 'weekm' not in serialized_user assert 'weekm' not in serialized_user
assert 'start_elevation_at_zero' not in serialized_user
assert 'use_raw_gpx_speed' not in serialized_user
assert 'use_dark_mode' not in serialized_user
def test_it_returns_workouts_infos( def test_it_returns_workouts_infos(
self, app: Flask, user_1_admin: User, user_2: User self, app: Flask, user_1_admin: User, user_2: User

View File

@ -361,6 +361,7 @@ def get_authenticated_user_profile(
"total_ascent": 720.35, "total_ascent": 720.35,
"total_distance": 67.895, "total_distance": 67.895,
"total_duration": "6:50:27", "total_duration": "6:50:27",
"use_dark_mode": null,
"use_raw_gpx_speed": false, "use_raw_gpx_speed": false,
"username": "sam", "username": "sam",
"weekm": false "weekm": false
@ -478,6 +479,7 @@ def edit_user(auth_user: User) -> Union[Dict, HttpResponse]:
"total_ascent": 720.35, "total_ascent": 720.35,
"total_distance": 67.895, "total_distance": 67.895,
"total_duration": "6:50:27", "total_duration": "6:50:27",
"use_dark_mode": null,
"use_raw_gpx_speed": false, "use_raw_gpx_speed": false,
"username": "sam" "username": "sam"
"weekm": true, "weekm": true,
@ -650,6 +652,7 @@ def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]:
"total_ascent": 720.35, "total_ascent": 720.35,
"total_distance": 67.895, "total_distance": 67.895,
"total_duration": "6:50:27", "total_duration": "6:50:27",
"use_dark_mode": null,
"use_raw_gpx_speed": false, "use_raw_gpx_speed": false,
"username": "sam" "username": "sam"
"weekm": true, "weekm": true,
@ -878,6 +881,7 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]:
"total_ascent": 720.35, "total_ascent": 720.35,
"total_distance": 67.895, "total_distance": 67.895,
"total_duration": "6:50:27", "total_duration": "6:50:27",
"use_dark_mode": null,
"use_raw_gpx_speed": true, "use_raw_gpx_speed": true,
"username": "sam" "username": "sam"
"weekm": true, "weekm": true,
@ -892,6 +896,8 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]:
:<json string language: language preferences :<json string language: language preferences
:<json boolean start_elevation_at_zero: do elevation plots start at zero? :<json boolean start_elevation_at_zero: do elevation plots start at zero?
:<json string timezone: user time zone :<json string timezone: user time zone
:<json boolean use_dark_mode: Display interface with dark mode if true.
If null, it uses browser preferences.
:<json boolean use_raw_gpx_speed: Use unfiltered gpx to calculate speeds :<json boolean use_raw_gpx_speed: Use unfiltered gpx to calculate speeds
:<json boolean weekm: does week start on Monday? :<json boolean weekm: does week start on Monday?
@ -916,6 +922,7 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]:
'language', 'language',
'start_elevation_at_zero', 'start_elevation_at_zero',
'timezone', 'timezone',
'use_dark_mode',
'use_raw_gpx_speed', 'use_raw_gpx_speed',
'weekm', 'weekm',
} }
@ -928,6 +935,7 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]:
language = get_language(post_data.get('language')) language = get_language(post_data.get('language'))
start_elevation_at_zero = post_data.get('start_elevation_at_zero') start_elevation_at_zero = post_data.get('start_elevation_at_zero')
use_raw_gpx_speed = post_data.get('use_raw_gpx_speed') use_raw_gpx_speed = post_data.get('use_raw_gpx_speed')
use_dark_mode = post_data.get('use_dark_mode')
timezone = post_data.get('timezone') timezone = post_data.get('timezone')
weekm = post_data.get('weekm') weekm = post_data.get('weekm')
@ -938,6 +946,7 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]:
auth_user.language = language auth_user.language = language
auth_user.start_elevation_at_zero = start_elevation_at_zero auth_user.start_elevation_at_zero = start_elevation_at_zero
auth_user.timezone = timezone auth_user.timezone = timezone
auth_user.use_dark_mode = use_dark_mode
auth_user.use_raw_gpx_speed = use_raw_gpx_speed auth_user.use_raw_gpx_speed = use_raw_gpx_speed
auth_user.weekm = weekm auth_user.weekm = weekm
db.session.commit() db.session.commit()

View File

@ -63,6 +63,7 @@ class User(BaseModel):
db.Boolean, default=True, nullable=False db.Boolean, default=True, nullable=False
) )
use_raw_gpx_speed = db.Column(db.Boolean, default=False, nullable=False) use_raw_gpx_speed = db.Column(db.Boolean, default=False, nullable=False)
use_dark_mode = db.Column(db.Boolean, default=False, nullable=True)
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<User {self.username!r}>' return f'<User {self.username!r}>'
@ -217,6 +218,7 @@ class User(BaseModel):
'language': self.language, 'language': self.language,
'start_elevation_at_zero': self.start_elevation_at_zero, 'start_elevation_at_zero': self.start_elevation_at_zero,
'timezone': self.timezone, 'timezone': self.timezone,
'use_dark_mode': self.use_dark_mode,
'use_raw_gpx_speed': self.use_raw_gpx_speed, 'use_raw_gpx_speed': self.use_raw_gpx_speed,
'weekm': self.weekm, 'weekm': self.weekm,
}, },

View File

@ -107,7 +107,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, capitalize, onBeforeMount } from 'vue' import { computed, ref, capitalize, onBeforeMount, watch } from 'vue'
import type { ComputedRef, Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import UserPicture from '@/components/User/UserPicture.vue' import UserPicture from '@/components/User/UserPicture.vue'
@ -133,12 +133,18 @@
) )
const isMenuOpen: Ref<boolean> = ref(false) const isMenuOpen: Ref<boolean> = ref(false)
const displayModal: Ref<boolean> = ref(false) const displayModal: Ref<boolean> = ref(false)
const darkTheme: Ref<boolean> = ref(false)
const darkMode: ComputedRef<boolean | null> = computed(
() => store.getters[ROOT_STORE.GETTERS.DARK_MODE]
)
const darkTheme: ComputedRef<boolean> = computed(
() => darkMode.value !== false
)
const themeIcon: ComputedRef<string> = computed(() => const themeIcon: ComputedRef<string> = computed(() =>
darkTheme.value ? 'fa-moon' : 'fa-sun-o' darkTheme.value ? 'fa-moon' : 'fa-sun-o'
) )
onBeforeMount(() => initTheme()) onBeforeMount(() => setTheme())
function openMenu() { function openMenu() {
isMenuOpen.value = true isMenuOpen.value = true
@ -163,22 +169,21 @@
} }
function setTheme() { function setTheme() {
if (darkTheme.value) { if (darkTheme.value) {
darkTheme.value = true
document.body.setAttribute('data-theme', 'dark') document.body.setAttribute('data-theme', 'dark')
} else { } else {
document.body.removeAttribute('data-theme') document.body.removeAttribute('data-theme')
} }
} }
function initTheme() {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
darkTheme.value = true
}
setTheme()
}
function toggleTheme() { function toggleTheme() {
darkTheme.value = !darkTheme.value store.commit(ROOT_STORE.MUTATIONS.UPDATE_DARK_MODE, !darkTheme.value)
setTheme()
} }
watch(
() => darkTheme.value,
() => {
setTheme()
}
)
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@ -4,6 +4,8 @@
<dl> <dl>
<dt>{{ $t('user.PROFILE.LANGUAGE') }}:</dt> <dt>{{ $t('user.PROFILE.LANGUAGE') }}:</dt>
<dd>{{ userLanguage }}</dd> <dd>{{ userLanguage }}</dd>
<dt>{{ $t('user.PROFILE.THEME_MODE.LABEL') }}:</dt>
<dd>{{ $t(`user.PROFILE.THEME_MODE.VALUES.${darkMode}`) }}</dd>
<dt>{{ $t('user.PROFILE.TIMEZONE') }}:</dt> <dt>{{ $t('user.PROFILE.TIMEZONE') }}:</dt>
<dd>{{ timezone }}</dd> <dd>{{ timezone }}</dd>
<dt>{{ $t('user.PROFILE.DATE_FORMAT') }}:</dt> <dt>{{ $t('user.PROFILE.DATE_FORMAT') }}:</dt>
@ -95,6 +97,13 @@
const display_ascent = computed(() => const display_ascent = computed(() =>
props.user.display_ascent ? 'DISPLAYED' : 'HIDDEN' props.user.display_ascent ? 'DISPLAYED' : 'HIDDEN'
) )
const darkMode = computed(() =>
props.user.use_dark_mode === true
? 'DARK'
: props.user.use_dark_mode === false
? 'LIGHT'
: 'DEFAULT'
)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -18,6 +18,22 @@
</option> </option>
</select> </select>
</label> </label>
<label class="form-items">
{{ $t('user.PROFILE.THEME_MODE.LABEL') }}
<select
id="use_dark_mode"
v-model="userForm.use_dark_mode"
:disabled="loading"
>
<option
v-for="mode in useDarkMode"
:value="mode.value"
:key="mode.label"
>
{{ $t(`user.PROFILE.THEME_MODE.VALUES.${mode.label}`) }}
</option>
</select>
</label>
<label class="form-items"> <label class="form-items">
{{ $t('user.PROFILE.TIMEZONE') }} {{ $t('user.PROFILE.TIMEZONE') }}
<TimezoneDropdown <TimezoneDropdown
@ -195,6 +211,7 @@
weekm: false, weekm: false,
start_elevation_at_zero: false, start_elevation_at_zero: false,
use_raw_gpx_speed: false, use_raw_gpx_speed: false,
use_dark_mode: false,
}) })
const weekStart = [ const weekStart = [
{ {
@ -246,6 +263,20 @@
value: true, value: true,
}, },
] ]
const useDarkMode = [
{
label: 'DARK',
value: true,
},
{
label: 'DEFAULT',
value: null,
},
{
label: 'LIGHT',
value: false,
},
]
const loading = computed( const loading = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.USER_LOADING] () => store.getters[AUTH_USER_STORE.GETTERS.USER_LOADING]
) )
@ -279,6 +310,7 @@
userForm.timezone = user.timezone ? user.timezone : 'Europe/Paris' userForm.timezone = user.timezone ? user.timezone : 'Europe/Paris'
userForm.date_format = user.date_format ? user.date_format : 'dd/MM/yyyy' userForm.date_format = user.date_format ? user.date_format : 'dd/MM/yyyy'
userForm.weekm = user.weekm ? user.weekm : false userForm.weekm = user.weekm ? user.weekm : false
userForm.use_dark_mode = user.use_dark_mode
} }
function updateProfile() { function updateProfile() {
store.dispatch(AUTH_USER_STORE.ACTIONS.UPDATE_USER_PREFERENCES, userForm) store.dispatch(AUTH_USER_STORE.ACTIONS.UPDATE_USER_PREFERENCES, userForm)
@ -339,7 +371,8 @@
} }
#language, #language,
#date_format { #date_format,
#use_dark_mode {
padding: $default-padding * 0.5; padding: $default-padding * 0.5;
} }
} }

View File

@ -115,6 +115,14 @@
"PROFILE": "profile", "PROFILE": "profile",
"SPORTS": "sports" "SPORTS": "sports"
}, },
"THEME_MODE": {
"LABEL": "Theme mode",
"VALUES": {
"DARK": "Dark",
"DEFAULT": "Browser preference",
"LIGHT": "Light"
}
},
"TIMEZONE": "Timezone", "TIMEZONE": "Timezone",
"UNITS": { "UNITS": {
"IMPERIAL": "Imperial system (ft, mi, mph, °F)", "IMPERIAL": "Imperial system (ft, mi, mph, °F)",

View File

@ -115,6 +115,14 @@
"PROFILE": "profil", "PROFILE": "profil",
"SPORTS": "sports" "SPORTS": "sports"
}, },
"THEME_MODE": {
"LABEL": "Thème",
"VALUES": {
"DARK": "Sombre",
"DEFAULT": "Préférence du navigateur",
"LIGHT": "Clair"
}
},
"TIMEZONE": "Fuseau horaire", "TIMEZONE": "Fuseau horaire",
"UNITS": { "UNITS": {
"IMPERIAL": "Système impérial (ft, mi, mph, °F)", "IMPERIAL": "Système impérial (ft, mi, mph, °F)",

View File

@ -139,6 +139,10 @@ export const actions: ActionTree<IAuthUserState, IRootState> &
res.data.data.language res.data.data.language
) )
} }
context.commit(
ROOT_STORE.MUTATIONS.UPDATE_DARK_MODE,
res.data.data.use_dark_mode
)
context.dispatch(SPORTS_STORE.ACTIONS.GET_SPORTS) context.dispatch(SPORTS_STORE.ACTIONS.GET_SPORTS)
} else { } else {
handleError(context, null) handleError(context, null)
@ -270,6 +274,10 @@ export const actions: ActionTree<IAuthUserState, IRootState> &
AUTH_USER_STORE.MUTATIONS.UPDATE_AUTH_USER_PROFILE, AUTH_USER_STORE.MUTATIONS.UPDATE_AUTH_USER_PROFILE,
res.data.data res.data.data
) )
context.commit(
ROOT_STORE.MUTATIONS.UPDATE_DARK_MODE,
res.data.data.use_dark_mode
)
context context
.dispatch( .dispatch(
ROOT_STORE.ACTIONS.UPDATE_APPLICATION_LANGUAGE, ROOT_STORE.ACTIONS.UPDATE_APPLICATION_LANGUAGE,

View File

@ -10,6 +10,7 @@ export enum RootGetters {
APP_CONFIG = 'APP_CONFIG', APP_CONFIG = 'APP_CONFIG',
APP_LOADING = 'APP_LOADING', APP_LOADING = 'APP_LOADING',
APP_STATS = 'APP_STATS', APP_STATS = 'APP_STATS',
DARK_MODE = 'DARK_MODE',
ERROR_MESSAGES = 'ERROR_MESSAGES', ERROR_MESSAGES = 'ERROR_MESSAGES',
LANGUAGE = 'LANGUAGE', LANGUAGE = 'LANGUAGE',
LOCALE = 'LOCALE', // date-fns LOCALE = 'LOCALE', // date-fns
@ -22,5 +23,6 @@ export enum RootMutations {
UPDATE_APPLICATION_LOADING = 'UPDATE_APPLICATION_LOADING', UPDATE_APPLICATION_LOADING = 'UPDATE_APPLICATION_LOADING',
UPDATE_APPLICATION_PRIVACY_POLICY = 'UPDATE_APPLICATION_PRIVACY_POLICY', UPDATE_APPLICATION_PRIVACY_POLICY = 'UPDATE_APPLICATION_PRIVACY_POLICY',
UPDATE_APPLICATION_STATS = 'UPDATE_APPLICATION_STATS', UPDATE_APPLICATION_STATS = 'UPDATE_APPLICATION_STATS',
UPDATE_DARK_MODE = 'UPDATE_DARK_MODE',
UPDATE_LANG = 'UPDATE_LANG', UPDATE_LANG = 'UPDATE_LANG',
} }

View File

@ -13,6 +13,9 @@ export const getters: GetterTree<IRootState, IRootState> & IRootGetters = {
[ROOT_STORE.GETTERS.APP_STATS]: (state: IRootState) => { [ROOT_STORE.GETTERS.APP_STATS]: (state: IRootState) => {
return state.application.statistics return state.application.statistics
}, },
[ROOT_STORE.GETTERS.DARK_MODE]: (state: IRootState) => {
return state.darkMode
},
[ROOT_STORE.GETTERS.ERROR_MESSAGES]: (state: IRootState) => { [ROOT_STORE.GETTERS.ERROR_MESSAGES]: (state: IRootState) => {
return state.errorMessages return state.errorMessages
}, },

View File

@ -45,4 +45,10 @@ export const mutations: MutationTree<IRootState> & TRootMutations = {
state.language = language state.language = language
state.locale = localeFromLanguage[language] state.locale = localeFromLanguage[language]
}, },
[ROOT_STORE.MUTATIONS.UPDATE_DARK_MODE](
state: IRootState,
darkMode: boolean | null
) {
state.darkMode = darkMode
},
} }

View File

@ -17,4 +17,5 @@ export const state: IRootState = {
}, },
}, },
appLoading: false, appLoading: false,
darkMode: null,
} }

View File

@ -22,6 +22,7 @@ export interface IRootState {
errorMessages: string | string[] | null errorMessages: string | string[] | null
application: IApplication application: IApplication
appLoading: boolean appLoading: boolean
darkMode: boolean | null
} }
export interface IRootActions { export interface IRootActions {
@ -51,6 +52,8 @@ export interface IRootGetters {
[ROOT_STORE.GETTERS.APP_STATS](state: IRootState): IAppStatistics [ROOT_STORE.GETTERS.APP_STATS](state: IRootState): IAppStatistics
[ROOT_STORE.GETTERS.DARK_MODE](state: IRootState): boolean | null
[ROOT_STORE.GETTERS.ERROR_MESSAGES]( [ROOT_STORE.GETTERS.ERROR_MESSAGES](
state: IRootState state: IRootState
): string | string[] | null ): string | string[] | null
@ -83,6 +86,10 @@ export type TRootMutations<S = IRootState> = {
statistics: IAppStatistics statistics: IAppStatistics
): void ): void
[ROOT_STORE.MUTATIONS.UPDATE_LANG](state: S, language: TLanguage): void [ROOT_STORE.MUTATIONS.UPDATE_LANG](state: S, language: TLanguage): void
[ROOT_STORE.MUTATIONS.UPDATE_DARK_MODE](
state: S,
darkMode: boolean | null
): void
} }
export type TRootStoreModule<S = IRootState> = Omit< export type TRootStoreModule<S = IRootState> = Omit<

View File

@ -35,6 +35,7 @@ export interface IAuthUserProfile extends IUserProfile {
timezone: string timezone: string
date_format: string date_format: string
weekm: boolean weekm: boolean
use_dark_mode: boolean | null
} }
export interface IUserPayload { export interface IUserPayload {
@ -73,6 +74,7 @@ export interface IUserPreferencesPayload {
timezone: string timezone: string
date_format: string date_format: string
weekm: boolean weekm: boolean
use_dark_mode: boolean | null
} }
export interface IUserSportPreferencesPayload { export interface IUserSportPreferencesPayload {